From 0b253d72c1fad6fde254deedbe97efe42054b83b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:15:35 +0200 Subject: [PATCH 001/101] Allow Ctrl+wheel zoom in an unfocused window (#3736) --- crates/ui-egui/src/canvas.rs | 39 ++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb9244a284..ee40339df3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2468,8 +2468,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let zoom_delta = crate::motion::zoom_delta(ui.ctx()); // Exclude the previous frame's scrollbar tracks from gesture activation. let navigation_rect = if view.viewport_screen.is_positive() { avail.intersect(view.viewport_screen) } else { avail }; - let navigation_enabled = app.dialog.is_none() && app.close_request.is_none() && !app.palette_open && ui.is_enabled() && ui.input(|i| i.focused); - if zoom_delta.is_finite() && zoom_delta > 0.0 && zoom_delta != 1.0 && navigation_enabled && ui.rect_contains_pointer(navigation_rect) { + // Wheel zoom works in a visible window without focus, as wheel scrolling does; the other gestures need focus. + let zoom_enabled = app.dialog.is_none() && app.close_request.is_none() && !app.palette_open && ui.is_enabled(); + let navigation_enabled = zoom_enabled && ui.input(|i| i.focused); + if zoom_delta.is_finite() && zoom_delta > 0.0 && zoom_delta != 1.0 && zoom_enabled && ui.rect_contains_pointer(navigation_rect) { let z = view.zoom * zoom_delta; match ui.input(|i| i.pointer.hover_pos()) { Some(p) => view.zoom_at(z, p), @@ -6204,6 +6206,39 @@ trailer << /Root 1 0 R >> assert_eq!(app.views[0].edit_lines.get(&0).map(|(g, _)| *g), Some(generation), "the boxes follow the edit while input is blocked"); } + /// Ctrl+wheel zooms a visible window that another application has focus over, as plain + /// wheel scrolling does; the palette still blocks it. + #[test] + fn ctrl_wheel_zooms_an_unfocused_window() { + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + let bytes = app.session.create_blank(300.0, 400.0, 1).unwrap(); + app.open_bytes("blank.pdf", None, bytes.as_ref().clone()).unwrap(); + app + }); + h.run_steps(3); + let center = h.state().views[0].viewport_rect().center(); + h.hover_at(center); + h.input_mut().focused = false; + h.run_steps(2); + let ctrl_wheel = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::COMMAND, + }; + h.state_mut().palette_open = true; + let before = h.state().views[0].zoom; + h.event(ctrl_wheel.clone()); + h.run_steps(10); + assert_eq!(h.state().views[0].zoom, before, "the palette still blocks zoom"); + h.state_mut().palette_open = false; + h.run_steps(2); + h.event(ctrl_wheel); + h.run_steps(10); + assert!(h.state().views[0].zoom > before, "Ctrl+wheel zooms without window focus"); + } + #[test] fn virtualized_organizer_keeps_insert_gaps_at_document_positions() { use egui_kittest::kittest::Queryable; From 85aeef9407b17acabdff9c31556122a319c6243b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:27:35 +0200 Subject: [PATCH 002/101] Search: centre the active match in the viewport (#3530) --- crates/ui-egui/src/canvas.rs | 49 +++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f8df86fb33..c535cac69f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -485,8 +485,8 @@ pub struct DocView { viewport_w: f32, viewport_h: f32, page_count: usize, - /// Page heights in points (view space), for mapping text positions to scroll offsets. - page_heights: Vec, + /// Page sizes (width, height) in points (view space), for mapping text positions to scroll offsets. + page_sizes: Vec<(f32, f32)>, /// Screen rects of the pages drawn last frame (hit-testing, tests, automation). screen_rects: Vec<(usize, Rect)>, screen_xforms: Vec<(usize, PageXform)>, @@ -786,7 +786,7 @@ impl DocView { viewport_w: 800.0, viewport_h: 600.0, page_count: info.pages.len(), - page_heights: info.pages.iter().map(|p| p.height).collect(), + page_sizes: info.pages.iter().map(|p| (p.width, p.height)).collect(), screen_rects: Vec::new(), screen_xforms: Vec::new(), viewport_screen: Rect::NOTHING, @@ -861,7 +861,7 @@ impl DocView { self.invalidate_content(); self.frame_geometry_changed = true; self.page_count = info.pages.len(); - self.page_heights = info.pages.iter().map(|p| p.height).collect(); + self.page_sizes = info.pages.iter().map(|p| (p.width, p.height)).collect(); let last = self.page_count.saturating_sub(1); self.current = self.current.min(last); self.selected.retain(|p| *p <= last); @@ -1249,16 +1249,40 @@ impl DocView { self.flash_match(p); } + /// The centre of the current match as a fraction of its displayed page `[x, y]` (0..1 from + /// the top-left corner, after the view rotation, as [`Self::goto_point`] takes it). + fn match_centre(&self) -> Option<[f32; 2]> { + let f = self.find.as_ref()?; + let (p, r) = f.matches.get(f.current?)?; + let glyphs = self.texts.get(p)?.glyphs.get(r.clone())?; + let [x0, y0, x1, y1] = glyphs.iter().map(|g| g.rect).reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])])?; + let &(w, h) = self.page_sizes.get(*p).filter(|(w, h)| *w > 0.0 && *h > 0.0)?; + let (u, v) = ((((x0 + x1) / 2.0) / w).clamp(0.0, 1.0), (((y0 + y1) / 2.0) / h).clamp(0.0, 1.0)); + // Turned clockwise by the view rotation, as `PageInfo::dest_fraction` does. + Some(match self.rotation % 360 { + 90 => [1.0 - v, u], + 180 => [1.0 - u, 1.0 - v], + 270 => [v, 1.0 - u], + _ => [u, v], + }) + } + fn flash_match(&mut self, page: usize) { let frac = self .find .as_ref() .and_then(|f| f.current.and_then(|c| f.matches.get(c))) .and_then(|(p, r)| self.texts.get(p).and_then(|t| t.glyphs.get(r.start)).map(|g| g.rect[1])) - .zip(self.page_heights.get(page).copied()) + .zip(self.page_sizes.get(page).map(|&(_, h)| h)) .map(|(y, h)| ((y - 60.0) / h).clamp(0.0, 1.0)) .unwrap_or(0.0); self.goto = Some((page, frac)); + // Then put the match's centre at the window's centre, as Acrobat does (#3530) and as a + // /FitR destination's rectangle is: a page wider or taller than the window no longer + // leaves it off to one side or near an edge. + if let Some([fx, fy]) = self.match_centre() { + self.goto_point = Some((page, Some(fx), Some(fy), vec2(self.viewport_w, self.viewport_h) / 2.0)); + } self.current = page; self.reset_coarse_state(); } @@ -5969,7 +5993,8 @@ trailer << /Root 1 0 R >> // Chrome refreshes the idle page box; navigation must not overwrite a draft // that may still be focused. DocView does not own the editor's focus state. assert_eq!(v.page_input, "draft in progress"); - assert!(v.goto_point.is_none(), "an earlier link destination cannot override the search offset"); + let middle = vec2(v.viewport_w, v.viewport_h) / 2.0; + assert_eq!(v.goto_point, Some((3, Some(40.0 / 300.0), Some(0.675), middle)), "the match centre goes to the middle of the viewport"); v.go_to_match(1); assert_eq!(v.back, vec![0], "same-page matches do not duplicate history"); assert!(v.view_history(false)); @@ -5983,6 +6008,18 @@ trailer << /Root 1 0 R >> } } + #[test] + fn search_centres_the_match_on_a_rotated_view() { + // The match's centre is 40 pt from the left and 270 pt down a 300 × 400 page. + for (rotation, fx, fy) in [(90, 1.0 - 0.675, 40.0 / 300.0), (180, 1.0 - 40.0 / 300.0, 1.0 - 0.675), (270, 0.675, 1.0 - 40.0 / 300.0)] { + let mut v = history_search_view(); + v.rotation = rotation; + v.find_step(true); + let middle = vec2(v.viewport_w, v.viewport_h) / 2.0; + assert_eq!(v.goto_point, Some((3, Some(fx), Some(fy), middle)), "view rotated {rotation}°"); + } + } + #[test] fn automatic_search_updates_do_not_record_history_or_discard_forward() { let mut v = history_search_view(); From 2f9207589ec2c5206b00928bfab53ab74ab2d45d Mon Sep 17 00:00:00 2001 From: Lcoding15 <125505497+Lcoding15@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:27:42 +0200 Subject: [PATCH 003/101] M3: a find step keeps its match in the middle while a zoom is still on (#3530) Building on the centring of #3547, a step to a match could still leave the match off screen after zooming, now and then: - A zoom pending in the same frame (Ctrl+wheel, pinch, a zoom button or key) took the layout's anchor branch, so the step's `goto` waited for the next frame and its page-top offset (60 pt from the top, scaled by the zoom) undid the centring. At 600% that is below the window. - egui eases a Ctrl-wheel notch over about a quarter of a second. Every frame of that set a new zoom anchor at the pointer, which moved the match just stepped to away from the middle again. A step whose match centre is known now scrolls by that point alone and drops a pending zoom anchor. Until the first frame without a zoom gesture after the step is laid out, any zoom (`zoom_at`, so the wheel, pinch, buttons and keys) and a changing fit zoom anchor on the match at the window's centre. Another jump lets go of it. Match centres that are not finite (a hostile page's glyphs spanning -inf..inf) fall back to the page jump instead of a NaN scroll target. Tests: search_position.rs steps through needles on pages larger and smaller than the window at 10-3200%, in every page display, from the Search panel, in a fixed pseudo-random mix of zooms and steps (some in the same frame) and during real eased Ctrl-wheel zooms. With #3547 alone three of them fail; on main all four original ones do. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 76 +++++- crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/search_position.rs | 257 +++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 5 files changed, 330 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/tests/it/search_position.rs diff --git a/ROADMAP.md b/ROADMAP.md index bc057209c2..a1422260cc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -684,3 +684,4 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-21 (M4, #3865):** A new bookmark records where the reader is: the top-left of the selected text, or the top of the window, as `/XYZ left top null` (zoom kept), instead of the page top (`Edit::AddBookmark` takes `left` and `top`; `bookmark_add` takes `top` and `left`; `PageInfo::xyz_for_view`). - **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged. +- **2026-10-22 (M3, #3530, builds on PR #3547):** A find step keeps its match in the middle of the window after zooming: a zoom pending in the same frame no longer puts the page-top jump off to the next frame, and the rest of an eased ⌘/Ctrl-wheel zoom anchors on the match instead of the pointer. Synthetic UI regressions step through matches on pages larger and smaller than the window at 10–3200% and in every page display. Estimates unchanged. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c535cac69f..6be6a0b93b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -494,6 +494,11 @@ pub struct DocView { viewport_screen: Rect, /// Pending zoom anchor: page, fractional position (also in gutters), and viewport offset. zoom_anchor: Option<(usize, f32, f32, Vec2)>, + /// The match a find step just went to: its page and its centre as fractions of the displayed + /// page. A zoom before the step has been laid out, or the rest of an eased ⌘-wheel zoom + /// still arriving, keeps it at the window's centre instead of the point under the pointer + /// (#3530). Let go in the first frame without a zoom gesture once the step is laid out. + find_hold: Option<(usize, f32, f32)>, /// The page view has been shown, so its scroll offset in egui's memory is this document's. shown: bool, /// Turns wheel input into page turns in single-page view. @@ -791,6 +796,7 @@ impl DocView { screen_xforms: Vec::new(), viewport_screen: Rect::NOTHING, zoom_anchor: None, + find_hold: None, shown: false, wheel: Default::default(), precise_scroll_until: 0.0, @@ -875,6 +881,7 @@ impl DocView { self.goto = None; self.goto_point = None; self.zoom_anchor = None; + self.find_hold = None; self.flash = None; // A thumbnail drag holds page indexes from before the change. self.panel_drag = None; @@ -1277,11 +1284,19 @@ impl DocView { .map(|(y, h)| ((y - 60.0) / h).clamp(0.0, 1.0)) .unwrap_or(0.0); self.goto = Some((page, frac)); + self.find_hold = None; // Then put the match's centre at the window's centre, as Acrobat does (#3530) and as a // /FitR destination's rectangle is: a page wider or taller than the window no longer // leaves it off to one side or near an edge. - if let Some([fx, fy]) = self.match_centre() { + // Finite only: a hostile page's glyphs can span -inf..inf, and NaN survives `clamp`. + if let Some([fx, fy]) = self.match_centre().filter(|c| c.iter().all(|v| v.is_finite())) { self.goto_point = Some((page, Some(fx), Some(fy), vec2(self.viewport_w, self.viewport_h) / 2.0)); + // The point alone, both axes in one frame. A pending zoom anchor would hold the old + // place for that frame and put `goto` off to the next, where its page-top offset + // undid the centring; a zoom after the step anchors on the match instead. + self.goto = None; + self.zoom_anchor = None; + self.find_hold = Some((page, fx, fy)); } self.current = page; self.reset_coarse_state(); @@ -1326,8 +1341,9 @@ impl DocView { } self.forward.clear(); } - // Partial wheel motion belongs to the old place. + // Partial wheel motion belongs to the old place, and so does a find step's match. self.reset_coarse_state(); + self.find_hold = None; self.goto = Some((page, 0.0)); self.current = page; } @@ -1736,15 +1752,18 @@ impl DocView { if zoom == self.zoom && self.fit == Fit::None { return; } + // Just after a find step, around the match at the window's centre (#3530). + let held = self.find_hold.map(|(p, fx, fy)| (p, fx, fy, vec2(self.viewport_w, self.viewport_h) / 2.0)); // In gutters/gaps, anchor relative to the nearest page rather than jumping to its top. // Fractions outside 0..1 preserve the pointer's position outside the page as well. - let anchor = + let anchor = held.or_else(|| { self.screen_rects.iter().min_by(|(_, a), (_, b)| a.distance_sq_to_pos(screen_pos).total_cmp(&b.distance_sq_to_pos(screen_pos))).map( |(p, r)| { let f = (screen_pos - r.min) / r.size(); (*p, f.x, f.y, screen_pos - self.viewport_screen.min) }, - ); + ) + }); self.zoom = zoom; self.fit = Fit::None; match anchor { @@ -2735,7 +2754,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }) .or_else(|| view.screen_rects.iter().find(|(_, r)| r.min.y >= top_y).map(|(p, r)| (*p, 0.0f32, (r.min.y - top_y).max(0.0)))) .or_else(|| view.screen_rects.last().map(|(p, r)| (*p, 1.0f32, (r.max.y - top_y).max(0.0)))); - if let Some((page, fy, rel_y)) = anchor { + if let Some((page, fx, fy)) = view.find_hold { + // A fit zoom that changes just after a find step keeps the match in the middle too. + view.zoom_anchor = Some((page, fx, fy, avail.size() / 2.0)); + } else if let Some((page, fy, rel_y)) = anchor { view.zoom_anchor = Some((page, 0.5, fy, vec2(avail.width() / 2.0, rel_y))); } else { view.zoom_anchor = Some((view.current, 0.5, 0.0, vec2(avail.width() / 2.0, MARGIN))); @@ -2769,6 +2791,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { Some(p) => view.zoom_at(z, p), None => view.set_zoom(z), } + } else if view.goto_point.is_none() { + // The find step has been laid out and no zoom is easing on: zoom at the pointer again. + view.find_hold = None; } view.viewport_screen = avail; let auto_delta = if unobstructed { @@ -5987,7 +6012,7 @@ trailer << /Root 1 0 R >> v.go_to_match(0); } assert_eq!(v.back, vec![0], "explicit search jump must remember its origin"); - assert_eq!(v.goto, Some((3, 0.5)), "keep the match offset instead of the page top"); + assert_eq!(v.goto, None, "the match's point alone: a zoom that frame cannot put a page-top offset off to the next"); assert_eq!(v.current, 3); assert_eq!(v.current_page_label(None), "4"); // Chrome refreshes the idle page box; navigation must not overwrite a draft @@ -6020,6 +6045,43 @@ trailer << /Root 1 0 R >> } } + #[test] + fn a_zoom_just_after_a_find_step_keeps_the_match_in_the_middle() { + let mut v = history_search_view(); + v.screen_rects = vec![(0, Rect::from_min_size(pos2(100.0, 20.0), vec2(300.0, 400.0)))]; + // A zoom anchored at the pointer was pending when the step came: the step drops it. + v.zoom_at(2.0, pos2(150.0, 50.0)); + assert!(v.zoom_anchor.is_some()); + v.find_step(true); + assert_eq!((v.goto, v.zoom_anchor), (None, None), "the step alone decides where the view goes"); + let middle = vec2(v.viewport_w, v.viewport_h) / 2.0; + let target = Some((3, Some(40.0 / 300.0), Some(0.675), middle)); + assert_eq!(v.goto_point, target); + // The rest of an eased wheel zoom, or a zoom in the same frame, anchors on the match + // at the window's centre, not on the pointer. + v.zoom_at(3.0, pos2(150.0, 50.0)); + assert_eq!(v.zoom_anchor, Some((3, 40.0 / 300.0, 0.675, middle))); + // Another jump lets go of the match: the next zoom is at the pointer again. + v.go_to_page(1); + v.zoom_at(4.0, pos2(150.0, 50.0)); + assert_eq!(v.zoom_anchor.map(|a| a.0), Some(0), "anchored on the page under the pointer"); + } + + #[test] + fn a_match_with_unbounded_glyphs_falls_back_to_its_page() { + let mut v = history_search_view(); + // A hostile page: a glyph from -inf to inf, whose centre is NaN. + if let Some(text) = v.texts.get_mut(&3).and_then(Arc::get_mut) { + text.glyphs[0].rect = [f32::NEG_INFINITY, 260.0, f32::INFINITY, 280.0]; + } + v.zoom_at(2.0, pos2(150.0, 50.0)); + v.find_step(true); + assert!(v.goto_point.is_none(), "no NaN scroll target"); + assert_eq!(v.goto.map(|g| g.0), Some(3), "the match's page instead"); + v.zoom_at(3.0, pos2(150.0, 50.0)); + assert!(v.zoom_anchor.is_none_or(|a| a.1.is_finite() && a.2.is_finite()), "no NaN zoom anchor"); + } + #[test] fn automatic_search_updates_do_not_record_history_or_discard_forward() { let mut v = history_search_view(); @@ -6027,7 +6089,7 @@ trailer << /Root 1 0 R >> v.find = Some(Find { query: "match".into(), ..Default::default() }); v.rerun_find(); assert_eq!(v.current, 3); - assert_eq!(v.goto, Some((3, 0.5))); + assert_eq!((v.goto, v.goto_point.map(|g| g.0)), (None, Some(3)), "the match's point on its page"); assert!(v.back.is_empty()); assert_eq!(v.forward, vec![4]); v.current = 0; diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 7c04df20e3..0af2c96153 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -61,6 +61,7 @@ mod reading_colors; mod recovery; mod redact; mod reload; +mod search_position; mod signing; mod stamps; mod text_select; diff --git a/crates/ui-egui/tests/it/search_position.rs b/crates/ui-egui/tests/it/search_position.rs new file mode 100644 index 0000000000..d45fbc85d0 --- /dev/null +++ b/crates/ui-egui/tests/it/search_position.rs @@ -0,0 +1,257 @@ +//! Find goes to where the match is (#3530): after zooming in or out, every step to a match (⌘G, +//! ⇧⌘G, a click in the Search panel's results) leaves the highlighted match on screen, near the +//! middle of the window, on pages larger and smaller than the window, and also when a zoom and a +//! step land in the same frame. + +use egui_kittest::Harness; +use pdfcraft_ui_egui::PdfCraftApp; + +/// The word searched for, placed (x, y in PDF user space) on each page. +const NEEDLE: &str = "needle"; + +/// A page size, or a point on a page, in points. +type Size = (u32, u32); +type Point = (u32, u32); + +/// Page sizes (points) and where on each page the needle is: the corners, the middle and the +/// edges of large pages, as well as a small page that fits the window. +fn pages() -> Vec<(Size, Vec)> { + vec![ + ((612, 792), vec![(60, 720), (480, 60)]), + ((1700, 2400), vec![(40, 2340), (1560, 2340), (820, 1200), (40, 40), (1560, 40)]), + ((2400, 1200), vec![(2300, 1140), (60, 600), (1200, 60)]), + ((612, 792), vec![(300, 400)]), + ] +} + +/// A PDF of [`pages`]: lines of filler text down every page, and the needles. +fn fixture() -> Vec { + let pages = pages(); + let n = pages.len(); + let font = 3 + 2 * n; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 3 + 2 * i)).collect(); + let mut objs = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string(), format!("<< /Type /Pages /Kids [{}] /Count {n} >>", kids.join(" "))]; + for (i, ((w, h), needles)) in pages.iter().enumerate() { + let mut body = String::from("BT /F1 9 Tf 11 TL"); + // Filler text a needle search must step over; it never contains the needle. + body.push_str(&format!(" 20 {} Td", h.saturating_sub(100))); + for line in 0..(h.saturating_sub(200) / 11) { + body.push_str(&format!(" (filler line {line} of the quick brown fox jumps over the lazy dog) '")); + } + body.push_str(" ET"); + for (x, y) in needles { + body.push_str(&format!(" BT /F1 14 Tf {x} {y} Td ({NEEDLE}) Tj ET")); + } + objs.push(format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Contents {} 0 R /Resources << /Font << /F1 {font} 0 R >> >> >>", + 4 + 2 * i + )); + objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); + } + objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string()); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn needles() -> usize { + pages().iter().map(|(_, n)| n.len()).sum() +} + +/// The app with the fixture open and a search for the needle that has found every match. +fn harness() -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("search.pdf", None, fixture()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app + }); + h.run_steps(4); + settle(&mut h); + h.state_mut().views[0].open_find(); + h.state_mut().views[0].find.as_mut().unwrap().query = NEEDLE.into(); + h.state_mut().views[0].rerun_find(); + // Find runs in the background: wait by a deadline, not a frame count. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while std::time::Instant::now() < deadline && h.state().views[0].find.as_ref().unwrap().matches.len() < needles() { + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(h.state().views[0].find.as_ref().unwrap().matches.len(), needles(), "every needle is found"); + settle(&mut h); + h +} + +/// Run frames until rendering has finished and the layout holds still. +fn settle(h: &mut Harness<'static, PdfCraftApp>) { + for _ in 0..200 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } +} + +/// Fail unless the current match is drawn inside the window, and, where the page lets the view +/// scroll that far, near its middle (#3530 asks for the middle, as Acrobat does). +fn assert_match_shown(h: &Harness<'static, PdfCraftApp>, what: &str) { + let v = &h.state().views[0]; + let f = v.find.as_ref().unwrap(); + let i = f.current.unwrap_or_else(|| panic!("{what}: no current match")); + let (page, range) = f.matches[i].clone(); + let window = v.viewport_rect(); + let at = |g: usize| v.glyph_screen_pos(page, g).unwrap_or_else(|| panic!("{what}: match {i} (page {}) is not on screen", page + 1)); + let (first, last) = (at(range.start), at(range.end - 1)); + // At the highest zooms one word is wider than the window: then its middle is what shows. + let ends = if last.x - first.x < window.width() * 0.8 { vec![first, last] } else { vec![first.lerp(last, 0.5)] }; + for p in ends { + assert!(window.contains(p), "{what}: match {i} (page {}) at {p:?} is outside the window {window:?} (zoom {})", page + 1, v.zoom); + } + let Some(page_rect) = v.page_screen_rect(page) else { return }; + let centre = first.lerp(last, 0.5); + // Glyph centres, not the match's box: their middle is off the box's by up to half a glyph. + let slack = 4.0 + 0.25 * (last.x - first.x).abs(); + // Off the middle only as far as the content's ends stop the scroll. + let room_x = (page_rect.left() < window.left() - 1.0 || centre.x > window.center().x) + && (page_rect.right() > window.right() + 1.0 || centre.x < window.center().x); + let room_y = (page_rect.top() < window.top() - 1.0 || centre.y > window.center().y) + && (page_rect.bottom() > window.bottom() + 1.0 || centre.y < window.center().y); + if room_x { + assert!((centre.x - window.center().x).abs() < slack, "{what}: match {i} at x {} is not centred in {window:?} (zoom {})", centre.x, v.zoom); + } + if room_y { + assert!((centre.y - window.center().y).abs() < slack, "{what}: match {i} at y {} is not centred in {window:?} (zoom {})", centre.y, v.zoom); + } +} + +/// Step through every match forward and back at each zoom, as ⌘G and ⇧⌘G do. +#[test] +fn every_match_is_shown_after_zooming() { + let mut h = harness(); + let n = needles(); + for zoom in [0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0] { + h.state_mut().views[0].set_zoom(zoom); + settle(&mut h); + for forward in [true, false] { + for _ in 0..n { + h.state_mut().views[0].find_step(forward); + settle(&mut h); + assert_match_shown(&h, &format!("zoom {zoom}, step {}", if forward { "forward" } else { "back" })); + } + } + } +} + +/// A click on a result in the Search panel goes to it at any zoom. +#[test] +fn search_panel_results_are_shown_after_zooming() { + let mut h = harness(); + for zoom in [0.33, 3.0, 12.0] { + h.state_mut().views[0].set_zoom(zoom); + settle(&mut h); + for i in (0..needles()).rev() { + h.state_mut().views[0].go_to_match(i); + settle(&mut h); + assert_match_shown(&h, &format!("zoom {zoom}, result {i}")); + } + } +} + +/// Zooms and steps mixed in a fixed pseudo-random order, as a reader zooms in and out between +/// searches; some steps land in the same frame as a zoom, before the zoom has been laid out. +#[test] +fn zooming_and_stepping_in_any_order_shows_the_match() { + let mut h = harness(); + let mut seed: u32 = 0x5eed_3530; + let mut next = |m: u32| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (seed >> 8) % m + }; + for round in 0..80 { + let same_frame = next(3) == 0; + match next(4) { + 0 => h.state_mut().views[0].zoom_step(true), + 1 => h.state_mut().views[0].zoom_step(false), + 2 => { + let z = [0.2, 0.75, 1.5, 6.0, 10.0][next(5) as usize]; + h.state_mut().views[0].set_zoom(z); + } + _ => {} + } + if !same_frame { + settle(&mut h); + } + if next(2) == 0 { + let i = next(needles() as u32) as usize; + h.state_mut().views[0].go_to_match(i); + } else { + let forward = next(2) == 0; + h.state_mut().views[0].find_step(forward); + } + settle(&mut h); + assert_match_shown(&h, &format!("round {round}{}", if same_frame { " (zoom in the same frame)" } else { "" })); + } +} + +/// ⌘/Ctrl with the wheel zooms over several frames (egui eases a notch over about a quarter of a +/// second). A step to a match while that zoom is still easing goes to the match, and the rest of +/// the zoom keeps it where it went instead of holding the point under the pointer still. +#[test] +fn a_step_during_a_wheel_zoom_keeps_the_match_on_screen() { + let mut h = harness(); + h.state_mut().set_option("animation_effects", "on").unwrap(); + h.state_mut().views[0].set_zoom(3.0); + settle(&mut h); + let n = needles(); + for (i, dy) in (0..n).zip([60.0, -60.0].into_iter().cycle()) { + // The pointer well away from the middle, where the zoom would otherwise be anchored. + let w = h.state().views[0].viewport_rect(); + h.hover_at(w.min + egui::vec2(80.0, 80.0)); + h.run_steps(1); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, dy), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::COMMAND, + }); + h.run_steps(2); + let before = h.state().views[0].zoom; + h.state_mut().views[0].find_step(true); + // Let the zoom finish easing, then the rendering. + h.run_steps(60); + settle(&mut h); + assert!((h.state().views[0].zoom - before).abs() > 1e-3, "step {i}: the zoom was still easing when the step came"); + assert_match_shown(&h, &format!("step {i} during a wheel zoom ({dy})")); + } +} + +/// Single page and two-up, scrolling or a page (spread) at a time: each step shows its match. +#[test] +fn every_page_display_shows_the_match_after_zooming() { + use pdfcraft_ui_egui::canvas::PageLayout; + let mut h = harness(); + for (layout, continuous) in [(PageLayout::Single, false), (PageLayout::TwoUp, true), (PageLayout::TwoUp, false)] { + h.state_mut().views[0].set_display(layout, continuous); + for zoom in [0.5, 4.0] { + h.state_mut().views[0].set_zoom(zoom); + settle(&mut h); + for _ in 0..needles() { + h.state_mut().views[0].find_step(true); + settle(&mut h); + assert_match_shown(&h, &format!("{layout:?}, continuous {continuous}, zoom {zoom}")); + } + } + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 45dda39822..d712550159 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1406,8 +1406,8 @@ milestone = "M3" status = "shipped" commands = ["edit.find"] tools = [] -evidence = ["crates/ui-egui/tests/it/ui.rs::find_counts_matches_across_pages"] -notes = "" +evidence = ["crates/ui-egui/tests/it/ui.rs::find_counts_matches_across_pages", "crates/ui-egui/tests/it/search_position.rs::every_match_is_shown_after_zooming", "crates/ui-egui/tests/it/search_position.rs::zooming_and_stepping_in_any_order_shows_the_match", "crates/ui-egui/tests/it/search_position.rs::a_step_during_a_wheel_zoom_keeps_the_match_on_screen", "crates/ui-egui/tests/it/search_position.rs::every_page_display_shows_the_match_after_zooming"] +notes = "Each step (⌘G, ⇧⌘G, Enter, a Search panel result) centres the match in the window on both axes at any zoom, view rotation and page display, as far as the content's ends allow (#3530); a zoom still easing on, or one in the same frame, anchors on the match instead of the pointer. A word wider than the window shows its middle." [[feature]] id = "view.find-options" From 3d24d826351d4402e8039fe31e392425cb77c7bf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 03:22:04 -0700 Subject: [PATCH 004/101] M11: PDF Optimizer can discard links to other documents (external cross-references) PDF Optimizer > Discard User Data gets "Discard external cross-references", off by default. It takes GoToR, GoToE, Launch and ImportData actions, and Thread actions with an /F, out of the actions of link annotations and bookmarks, and the actions chained after one through /Next take its place in order. Other annotations, such as form fields, keep their actions. A link left with no action and no /Dest is removed from its page; when the link is an indirect object, that object is rewritten without the action for anything else that refers to it. A bookmark keeps its title, place and children, including a bookmark stored directly inside another one. Links within the document and web links stay. An indirect action that changes is written as a new indirect object, except the first action left at the head of an /A, which is copied into the /A directly when other actions follow it. Objects that use the old action keep it. An /A with more than 100,000 values to check, or one that opens another document and needs more than 100,000 values to rewrite, stops the optimization with OptimizeError::ExternalRefLimit, and so does an outline of more than 100,000 bookmarks. doc_optimize takes discard_external_refs and reports external_links and external_bookmarks. --- crates/automation/src/lib.rs | 3 + crates/automation/src/tools.rs | 3 +- crates/automation/tests/it/automation.rs | 16 + crates/optimize/README.md | 7 + crates/optimize/src/external_refs.rs | 427 +++++++++++++++++++++++ crates/optimize/src/lib.rs | 20 +- crates/optimize/tests/optimize.rs | 402 +++++++++++++++++++++ crates/ui-egui/src/optimize_ui.rs | 1 + parity/acrobat-features.toml | 10 +- 9 files changed, 882 insertions(+), 7 deletions(-) create mode 100644 crates/optimize/src/external_refs.rs diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index d2114ac7bf..f0b24ba0fe 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1258,6 +1258,7 @@ impl Automation { ("flate_unencoded", &mut settings.flate_unencoded), ("remove_invalid_links", &mut settings.remove_invalid_links), ("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests), + ("discard_external_refs", &mut settings.discard_external_refs), ] { if let Some(b) = a.opt_bool(key)? { *flag = b; @@ -1297,6 +1298,8 @@ impl Automation { "invalid_links": o.invalid_links, "invalid_bookmarks": o.invalid_bookmarks, "unreferenced_dests": o.unreferenced_dests, + "external_links": o.external_links, + "external_bookmarks": o.external_bookmarks, "unused_xobjects": o.unused_xobjects, "merged_objects": r.merged, "discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::>(), diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1e009c1170..aa9fc448a9 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1375,7 +1375,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); discard_external_refs takes GoToR, GoToE, Launch and ImportData actions, and Thread actions with /F, out of links and bookmarks, removes a link left with nothing to do, and keeps links within the document and web links (external_links, external_bookmarks); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ @@ -1392,6 +1392,7 @@ pub fn tools() -> Vec { "flate_unencoded": { "type": "boolean" }, "remove_invalid_links": { "type": "boolean" }, "remove_unreferenced_dests": { "type": "boolean" }, + "discard_external_refs": { "type": "boolean" }, "discard": { "type": "array", "items": { "type": "string" } }, }), &["doc", "path"], diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 9fd5c4269a..1bef7c6315 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -7352,6 +7352,22 @@ fn a_deleted_image_is_not_kept_by_reduce_or_optimize() { assert_eq!(reopened["pages"], 1); } +#[test] +fn optimizing_discards_links_to_other_documents_through_tools() { + let dir = workdir("optimize-external-refs"); + std::fs::write(dir.join("remote.pdf"), remote_link_pdf("other.pdf", None)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "remote.pdf" }))["doc"].as_u64().unwrap(); + let kept = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf" })); + assert_eq!(kept["external_links"], 0, "{kept}"); + let kept = ok(&mut a, "doc_open", json!({ "path": "kept.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "link_list", json!({ "doc": kept }))["count"], 1); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf", "discard_external_refs": true })); + assert_eq!((r["external_links"].as_u64(), r["external_bookmarks"].as_u64()), (Some(1), Some(0)), "{r}"); + let small = ok(&mut a, "doc_open", json!({ "path": "small.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "link_list", json!({ "doc": small }))["count"], 0); +} + /// A two-page PDF with the same three-row table ("Name Qty / Apple 12 / Pear 7"); the second /// page is turned with /Rotate 90. fn table_pdf() -> Vec { diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 3d517eef3a..199c20e186 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -31,6 +31,13 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going - **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex layer, that layer is removed when the result is smaller (the inner filters keep their encoded bytes). Streams with `/DecodeParms`, images and object streams are left alone. +- **Discard external cross-references** (off by default): `GoToR`, `GoToE`, `Launch` and + `ImportData` actions, and `Thread` actions with an `/F`, are taken out of link annotations and + bookmarks, and the actions chained after one through `/Next` take its place. Other annotations, + such as form fields, keep their actions. A link left with no action and no `/Dest` is removed + from its page, and its object, when something else refers to it, keeps the link without the + action; a bookmark keeps its title and children. Links within the document and web links + (`URI`) stay. An outline of more than 100,000 bookmarks stops the optimization with an error. The engine runs Remove Hidden Information (`pdfcraft-redact`) for the user-data categories, then this, then merges identical objects and writes a full, compressed save diff --git a/crates/optimize/src/external_refs.rs b/crates/optimize/src/external_refs.rs new file mode 100644 index 0000000000..2df86175e0 --- /dev/null +++ b/crates/optimize/src/external_refs.rs @@ -0,0 +1,427 @@ +//! Discard User Data ▸ Discard external cross-references: links and bookmarks that open another +//! document. +//! +//! An action opens another document when its `/S` is `GoToR` (another PDF file), `GoToE` (a +//! document embedded in this one, or the one this is embedded in), `Launch` (a file or an +//! application), `ImportData` (form data read from another file) or `Thread` with an `/F` (an +//! article in another file). Such an action is taken out of the `/A` of a link annotation +//! (`/Subtype /Link`) or a bookmark, and the actions chained after it through `/Next` take its +//! place in order. Other annotations, such as form fields, keep their actions. A link left with no +//! action and no `/Dest` is removed from its page; when the link is stored as its own object, that +//! object keeps the link without its action for anything else that refers to it (the structure +//! tree, for example). A bookmark stays, with its title and children. Links that jump within the +//! document (`/Dest`, `GoTo`) and web links (`URI`) are kept. An `/A` that takes more than +//! [`MAX_STEPS`] values to check, or whose actions open another document and take more than that +//! to rewrite, stops the optimization with an error, and so does an outline of more than +//! [`MAX_OUTLINE`] items or with items stored inside each other more than [`MAX_DEPTH`] levels +//! deep. + +use std::collections::{HashMap, HashSet}; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; + +use crate::OptimizeError; + +/// Action types that open or read another document (`Thread` does when it has an `/F`). +const EXTERNAL: [&[u8]; 4] = [b"GoToR", b"GoToE", b"Launch", b"ImportData"]; + +/// How many levels (actions, `/Next` arrays and references) a chain is rewritten. Past it, the rest +/// of the chain is kept when it opens no other document, and removed when it does. +const MAX_DEPTH: usize = 512; + +/// How many values each pass over one `/A` looks at: the check for actions to other documents, +/// then the rewrite with its check past [`MAX_DEPTH`]. A chain that needs more in either pass +/// stops the optimization with [`OptimizeError::ExternalRefLimit`]. +const MAX_STEPS: usize = 100_000; + +/// How many bookmarks the outline walk looks at before it stops the optimization with +/// [`OptimizeError::ExternalRefLimit`]. +const MAX_OUTLINE: usize = 100_000; + +/// What is left of an action or a list of actions. +enum Kept { + /// Nothing changed: keep the object as it is stored. + Same, + /// These actions, in order, replace it. + Now(Vec), +} + +#[derive(Default)] +struct Walk { + /// References on the current chain, to end a chain that comes back to itself. + path: Vec, + /// Indirect objects from which an action to another document can be reached. + dirty: HashSet, + /// Actions to another document taken out. + removed: usize, + /// A chain that opens another document past [`MAX_DEPTH`] was cut there. + cut: bool, + /// Values looked at so far, against [`MAX_STEPS`]. + steps: usize, + /// The walk ran out of steps; its result is not used. + exhausted: bool, +} + +fn is_external(doc: &Document, d: &Dict) -> bool { + let Some(s) = d.get(b"S").map(|s| doc.resolve(s)) else { return false }; + match s.as_name() { + Some(b"Thread") => d.get(b"F").is_some_and(|f| !matches!(doc.resolve(f).as_ref(), Object::Null)), + Some(n) => EXTERNAL.contains(&n), + None => false, + } +} + +/// The actions an unchanged `/Next` value stands for, in order. +fn listed(doc: &Document, next: &Object) -> Vec { + match doc.resolve(next).as_ref() { + Object::Array(a) => a.clone(), + Object::Dict(_) => vec![next.clone()], + _ => Vec::new(), + } +} + +impl Walk { + /// Count one value; false once the walk has run out of steps. + fn step(&mut self) -> bool { + self.steps += 1; + if self.steps > MAX_STEPS { + self.exhausted = true; + } + !self.exhausted + } + + /// Whether the actions stored directly in `o` include one to another document; the references + /// they hold are added to `refs`. + fn scan(&mut self, doc: &Document, o: &Object, refs: &mut Vec) -> bool { + let mut stack = vec![o]; + let mut external = false; + while let Some(o) = stack.pop() { + if !self.step() { + return false; + } + match o { + Object::Ref(r) => refs.push(*r), + Object::Array(items) => stack.extend(items), + Object::Dict(d) => { + external |= is_external(doc, d); + if let Some(n) = d.get(b"Next") { + stack.push(n); + } + } + _ => {} + } + } + external + } + + /// Mark the indirect objects reachable from `a` that lead to an action to another document. + /// Each indirect object is looked at once. + fn reach(&mut self, doc: &Document, a: &Object) { + let mut todo = Vec::new(); + self.scan(doc, a, &mut todo); + let mut seen = HashSet::new(); + let mut parents: HashMap> = HashMap::new(); + let mut found = Vec::new(); + while let Some(r) = todo.pop() { + if self.exhausted { + return; + } + if !seen.insert(r) { + continue; + } + let target = doc.get(r); + let mut refs = Vec::new(); + if self.scan(doc, &target, &mut refs) { + found.push(r); + } + for c in refs { + parents.entry(c).or_default().push(r); + todo.push(c); + } + } + while let Some(r) = found.pop() { + if self.dirty.insert(r) + && let Some(p) = parents.get(&r) + { + found.extend(p); + } + } + } + + /// Whether `o` or an action chained after it opens another document. Needs [`Walk::reach`] + /// to have run on an `/A` that holds `o`. + fn holds_external(&mut self, doc: &Document, o: &Object) -> bool { + let mut refs = Vec::new(); + let external = self.scan(doc, o, &mut refs); + external || refs.iter().any(|r| self.dirty.contains(r)) + } +} + +impl Walk { + /// Indirect actions that change are written as new indirect objects (the old ones stay for + /// whatever else uses them), so a rewritten chain nests no deeper than the original. + fn strip(&mut self, doc: &mut Document, o: &Object, depth: usize) -> Kept { + if !self.step() { + return Kept::Same; + } + if depth > MAX_DEPTH { + if !self.holds_external(doc, o) { + return Kept::Same; + } + self.cut = true; + return Kept::Now(Vec::new()); + } + match o { + Object::Ref(r) => { + if !self.dirty.contains(r) { + return Kept::Same; + } + if self.path.contains(r) { + return Kept::Now(Vec::new()); + } + self.path.push(*r); + let target = doc.get(*r); + let kept = self.strip(doc, &target, depth + 1); + self.path.pop(); + match kept { + Kept::Now(mut v) if v.len() == 1 && matches!(v.first(), Some(Object::Dict(_))) => { + let d = v.remove(0); + Kept::Now(vec![Object::Ref(doc.add(d))]) + } + other => other, + } + } + Object::Array(items) => { + let mut out = Vec::new(); + let mut changed = false; + for item in items { + match self.strip(doc, item, depth + 1) { + Kept::Same => out.push(item.clone()), + Kept::Now(v) => { + changed = true; + out.extend(v); + } + } + } + if changed { Kept::Now(out) } else { Kept::Same } + } + Object::Dict(d) => { + let next = d.get(b"Next"); + let rest = next.map(|n| self.strip(doc, n, depth + 1)); + if is_external(doc, d) { + self.removed += 1; + return Kept::Now(match (rest, next) { + (Some(Kept::Now(v)), _) => v, + (Some(Kept::Same), Some(n)) => listed(doc, n), + _ => Vec::new(), + }); + } + match rest { + Some(Kept::Now(v)) => { + let mut d = d.clone(); + set_next(&mut d, v); + Kept::Now(vec![Object::Dict(d)]) + } + _ => Kept::Same, + } + } + _ => Kept::Same, + } + } +} + +fn set_next(d: &mut Dict, mut v: Vec) { + match v.len() { + 0 => { + d.remove(b"Next"); + } + 1 => d.set(b"Next".to_vec(), v.remove(0)), + _ => d.set(b"Next".to_vec(), Object::Array(v)), + } +} + +/// `/A` of `owner` with its actions to other documents taken out: `None` when nothing changed, +/// `Some(None)` when no action is left, `Some(Some(action))` otherwise (one action, the rest after +/// it). +fn strip_action(doc: &mut Document, a: &Object, owner: ObjRef) -> Result>, OptimizeError> { + let mut walk = Walk::default(); + // A quick check first, which looks at each indirect object once: a chain with no action to + // another document is left alone however its actions are shared, unless it holds more than + // `MAX_STEPS` values. + walk.reach(doc, a); + let external = walk.holds_external(doc, a); + if walk.exhausted { + return Err(OptimizeError::ExternalRefLimit(owner)); + } + if !external { + return Ok(None); + } + walk.steps = 0; + let kept = walk.strip(doc, a, 0); + if walk.exhausted { + return Err(OptimizeError::ExternalRefLimit(owner)); + } + let Kept::Now(mut v) = kept else { return Ok(None) }; + if walk.removed == 0 && !walk.cut { + return Ok(None); + } + if v.is_empty() { + return Ok(Some(None)); + } + let first = v.remove(0); + if v.is_empty() { + return Ok(Some(Some(first))); + } + let Some(mut d) = doc.resolve(&first).as_dict().cloned() else { return Ok(Some(None)) }; + let mut next = d.get(b"Next").map(|n| listed(doc, n)).unwrap_or_default(); + next.extend(v); + set_next(&mut d, next); + Ok(Some(Some(Object::Dict(d)))) +} + +/// The outline walk: bookmarks stored as their own objects wait on `stack`; those stored inside +/// another bookmark are edited where they are. +#[derive(Default)] +struct Outline { + stack: Vec, + items: usize, + changed: usize, +} + +impl Outline { + /// Take actions to other documents out of the bookmark `d` (or, when `item` is false, the + /// outline dictionary) and of the bookmarks stored inside it. `owner` is the object that holds + /// `d`. Returns whether `d` changed. + fn visit(&mut self, doc: &mut Document, d: &mut Dict, owner: ObjRef, depth: usize, item: bool) -> Result { + let mut changed = false; + if item { + self.items += 1; + if self.items > MAX_OUTLINE { + return Err(OptimizeError::ExternalRefLimit(owner)); + } + let stripped = match d.get(b"A") { + Some(a) => strip_action(doc, a, owner)?, + None => None, + }; + if let Some(action) = stripped { + match action { + Some(a) => d.set(b"A".to_vec(), a), + None => { + d.remove(b"A"); + } + } + self.changed += 1; + changed = true; + } + } + let keys: &[&[u8]] = if item { &[b"Next", b"First"] } else { &[b"First"] }; + for k in keys { + match d.get_mut(k) { + Some(Object::Ref(r)) => self.stack.push(*r), + Some(Object::Dict(c)) => { + if depth >= MAX_DEPTH { + return Err(OptimizeError::ExternalRefLimit(owner)); + } + changed |= self.visit(doc, c, owner, depth + 1, true)?; + } + _ => {} + } + } + Ok(changed) + } +} + +/// Take actions to other documents out of bookmarks, depth-first. Returns the bookmarks changed. +fn discard_bookmarks(doc: &mut Document) -> Result { + let Some(root) = doc.root() else { return Ok(0) }; + let catalog = doc.get(root); + let mut walk = Outline::default(); + let mut seen = HashSet::new(); + match catalog.as_dict().and_then(|c| c.get(b"Outlines")) { + Some(Object::Ref(o)) => { + seen.insert(*o); + if let Some(mut d) = doc.get(*o).as_dict().cloned() + && walk.visit(doc, &mut d, *o, 0, false)? + { + doc.set(*o, Object::Dict(d)); + } + } + Some(Object::Dict(o)) => { + let mut d = o.clone(); + if walk.visit(doc, &mut d, root, 0, false)? { + doc.update_dict(root, |c| c.set(b"Outlines".to_vec(), Object::Dict(d)))?; + } + } + _ => {} + } + while let Some(r) = walk.stack.pop() { + if !seen.insert(r) { + continue; + } + let Some(mut d) = doc.get(r).as_dict().cloned() else { continue }; + if walk.visit(doc, &mut d, r, 0, true)? { + doc.set(r, Object::Dict(d)); + } + } + Ok(walk.changed) +} + +/// Take actions to other documents out of links and bookmarks. Returns (links, bookmarks) +/// changed; a link counts whether it was removed or kept for its other actions. +pub(crate) fn discard(doc: &mut Document, pages: &[ObjRef]) -> Result<(usize, usize), OptimizeError> { + let mut links = 0; + for p in pages { + let Some(pd) = doc.get(*p).as_dict().cloned() else { continue }; + let Some(annots) = pd.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()) else { continue }; + let mut keep = Vec::with_capacity(annots.len()); + let mut changed = false; + for x in &annots { + let Some(mut d) = doc.resolve(x).as_dict().cloned() else { + keep.push(x.clone()); + continue; + }; + let owner = match x { + Object::Ref(r) => *r, + _ => *p, + }; + let link = d.get(b"Subtype").map(|s| doc.resolve(s)).is_some_and(|s| s.as_name().is_some_and(|n| n == b"Link")); + let stripped = match d.get(b"A") { + Some(a) if link => strip_action(doc, a, owner)?, + _ => None, + }; + let Some(action) = stripped else { + keep.push(x.clone()); + continue; + }; + links += 1; + match action { + Some(a) => d.set(b"A".to_vec(), a), + None => { + d.remove(b"A"); + } + } + let removed = d.get(b"A").is_none() && d.get(b"Dest").is_none(); + match x { + Object::Ref(r) => { + doc.set(*r, Object::Dict(d)); + if removed { + changed = true; + } else { + keep.push(x.clone()); + } + } + _ => { + changed = true; + if !removed { + keep.push(Object::Dict(d)); + } + } + } + } + if changed { + doc.update_dict(*p, |d| d.set(b"Annots".to_vec(), Object::Array(keep)))?; + } + } + let bookmarks = discard_bookmarks(doc)?; + Ok((links, bookmarks)) +} diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index c8b9b67690..70d9c83100 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -18,11 +18,14 @@ //! //! Metadata, attachments, comments, scripts, private data, hidden layers, bookmarks and form //! fields are discarded by `pdfcraft-redact`'s Remove Hidden Information, which the engine -//! runs alongside. +//! runs alongside. Links that open another document, and bookmark actions that do, are discarded +//! here (Discard User Data ▸ Discard external cross-references, see +//! [`Settings::discard_external_refs`]). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod audit; +mod external_refs; pub mod fragments; mod images; mod links; @@ -40,6 +43,8 @@ pub enum OptimizeError { NoPages, #[error(transparent)] Cos(#[from] pdfcraft_cos::CosError), + #[error("object {0} holds too many actions or bookmarks to take out its links to other documents")] + ExternalRefLimit(ObjRef), #[error("the optimization was cancelled")] Cancelled, } @@ -104,6 +109,9 @@ pub struct Settings { pub remove_invalid_links: bool, /// Clean Up: remove named destinations nothing refers to. pub remove_unreferenced_dests: bool, + /// Discard User Data: take `GoToR`, `GoToE` and `Launch` actions out of links and bookmarks; + /// a link left with nothing to do is removed. Links within the document and web links stay. + pub discard_external_refs: bool, } impl Default for Settings { @@ -120,6 +128,7 @@ impl Default for Settings { flate_unencoded: true, remove_invalid_links: true, remove_unreferenced_dests: true, + discard_external_refs: false, } } } @@ -182,6 +191,10 @@ pub struct Report { pub invalid_links: usize, pub invalid_bookmarks: usize, pub unreferenced_dests: usize, + /// Links removed (or kept for their other actions) and bookmarks whose action was changed or + /// cleared, because they opened another document (Discard external cross-references). + pub external_links: usize, + pub external_bookmarks: usize, /// Images and forms the pages listed but never drew, dropped (Clean Up). pub unused_xobjects: usize, } @@ -276,6 +289,11 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: report.invalid_links = l; report.invalid_bookmarks = b; } + if settings.discard_external_refs { + let (l, b) = external_refs::discard(doc, &pages)?; + report.external_links = l; + report.external_bookmarks = b; + } if settings.remove_unreferenced_dests { report.unreferenced_dests = links::remove_unreferenced_dests(doc)?; } diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 6d9a9bb44e..3f59e081b4 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -393,6 +393,408 @@ fn invalid_links_and_unreferenced_destinations_go() { assert_eq!(optimize(&mut again, &off).unwrap().invalid_links, 0); } +/// An action dictionary of type `s`, followed by `next` when given. +fn action(s: &str, next: Option) -> Object { + let mut d = Dict::new(); + d.set(b"S".to_vec(), Object::name(s)); + match s { + "GoToR" | "Launch" => d.set(b"F".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"other.pdf".to_vec()))), + "URI" => d.set(b"URI".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"https://example.com".to_vec()))), + _ => {} + } + if let Some(n) = next { + d.set(b"Next".to_vec(), n); + } + Object::Dict(d) +} + +fn link_with(a: Option, dest: Option) -> Object { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Link")); + d.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()])); + if let Some(a) = a { + d.set(b"A".to_vec(), a); + } + if let Some(dest) = dest { + d.set(b"Dest".to_vec(), dest); + } + Object::Dict(d) +} + +/// The action types an `/A` value runs, in order (depth-first through `/Next`), at most 1,000. +fn chain(doc: &Document, a: &Object) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![a.clone()]; + while let Some(o) = stack.pop() { + if out.len() >= 1_000 { + break; + } + match doc.resolve(&o).as_ref() { + Object::Array(items) => stack.extend(items.iter().rev().cloned()), + Object::Dict(d) => { + out.push( + d.get(b"S") + .map(|s| doc.resolve(s)) + .and_then(|s| s.as_name().map(|n| String::from_utf8_lossy(n).into_owned())) + .unwrap_or_default(), + ); + if let Some(n) = d.get(b"Next") { + stack.push(n.clone()); + } + } + _ => {} + } + } + out +} + +/// A one-page document whose page holds `annots`, with one bookmark per action in `bookmarks`. +fn linked_doc(annots: impl FnOnce(&mut Document, ObjRef) -> Vec, bookmarks: &[Object]) -> (Document, ObjRef, Vec) { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], "BT ET"); + let annots = annots(&mut doc, p); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(annots))).unwrap(); + let outlines = doc.add(Object::Null); + let items: Vec = bookmarks.iter().map(|_| doc.add(Object::Null)).collect(); + for (i, (r, a)) in items.iter().zip(bookmarks).enumerate() { + let mut item = Dict::new(); + item.set(b"Title".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(format!("Item {i}").into_bytes()))); + item.set(b"Parent".to_vec(), Object::Ref(outlines)); + item.set(b"A".to_vec(), a.clone()); + if let Some(n) = items.get(i + 1) { + item.set(b"Next".to_vec(), Object::Ref(*n)); + } + doc.set(*r, Object::Dict(item)); + } + let mut o = Dict::new(); + if let (Some(first), Some(last)) = (items.first(), items.last()) { + o.set(b"First".to_vec(), Object::Ref(*first)); + o.set(b"Last".to_vec(), Object::Ref(*last)); + } + doc.set(outlines, Object::Dict(o)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Outlines".to_vec(), Object::Ref(outlines))).unwrap(); + (doc, p, items) +} + +fn annots_of(doc: &Document, p: ObjRef) -> Vec { + let annots = doc.get(p).as_dict().unwrap().get(b"Annots").cloned().unwrap(); + doc.resolve(&annots).as_array().unwrap().iter().map(|a| doc.resolve(a).as_dict().unwrap().clone()).collect() +} + +#[test] +fn links_and_bookmarks_to_other_documents_are_discarded_and_links_within_stay() { + let (doc, p, items) = linked_doc( + |doc, p| { + let here = Object::Array(vec![Object::Ref(p), Object::name("Fit")]); + let mut goto = Dict::new(); + goto.set(b"S".to_vec(), Object::name("GoTo")); + goto.set(b"D".to_vec(), here.clone()); + let remote = doc.add(link_with(Some(action("GoToR", None)), None)); + vec![ + Object::Ref(remote), + link_with(Some(action("Launch", None)), None), + link_with(Some(action("GoToE", None)), None), + link_with(Some(action("URI", None)), None), + link_with(Some(Object::Dict(goto)), None), + link_with(None, Some(here)), + link_with(Some(action("GoToR", Some(action("URI", None)))), None), + ] + }, + &[action("GoToR", None), action("Named", None)], + ); + let on = Settings { discard_external_refs: true, ..Settings::default() }; + // Off by default: nothing changes. + let mut off = doc.clone(); + let report = optimize(&mut off, &Settings::default()).unwrap(); + assert_eq!((report.external_links, report.external_bookmarks), (0, 0)); + assert_eq!(annots_of(&off, p).len(), 7); + let mut doc = doc; + let report = optimize(&mut doc, &on).unwrap(); + assert_eq!((report.external_links, report.external_bookmarks, report.invalid_links), (4, 1, 0), "{report:?}"); + let left: Vec> = annots_of(&doc, p).iter().map(|d| d.get(b"A").map(|a| chain(&doc, a)).unwrap_or_default()).collect(); + assert_eq!( + left, + [vec!["URI"], vec!["GoTo"], vec![], vec!["URI"]], + "the web link, the jump within and the /Dest link stay; the chained web link keeps its URI" + ); + assert!(annots_of(&doc, p)[2].get(b"Dest").is_some()); + let bookmark = |doc: &Document, r: ObjRef| doc.get(r).as_dict().unwrap().clone(); + assert!(bookmark(&doc, items[0]).get(b"A").is_none(), "the bookmark stays, without its action"); + assert_eq!(bookmark(&doc, items[0]).get(b"Next"), Some(&Object::Ref(items[1])), "and its place in the outline"); + assert_eq!(chain(&doc, bookmark(&doc, items[1]).get(b"A").unwrap()), ["Named"]); +} + +#[test] +fn an_action_to_another_document_hands_its_place_to_the_actions_after_it() { + let (doc, p, _) = linked_doc( + |doc, p| { + let here = Object::Array(vec![Object::Ref(p), Object::name("Fit")]); + // A shared Launch, stored on its own with an indirect /S, followed by a web link. + let launch_name = doc.add(Object::name("Launch")); + let Object::Dict(mut launch) = action("Launch", Some(action("URI", None))) else { panic!("action() builds a dictionary") }; + launch.set(b"S".to_vec(), Object::Ref(launch_name)); + let launch = doc.add(Object::Dict(launch)); + let mut goto = Dict::new(); + goto.set(b"S".to_vec(), Object::name("GoTo")); + goto.set(b"D".to_vec(), here); + goto.set( + b"Next".to_vec(), + Object::Array(vec![action("GoToR", Some(action("JavaScript", None))), Object::Ref(launch), action("Named", None)]), + ); + // A remote go-to whose web link comes back to it. + let (a1, a2) = (doc.add(Object::Null), doc.add(Object::Null)); + doc.set(a1, action("GoToR", Some(Object::Ref(a2)))); + doc.set(a2, action("URI", Some(Object::Ref(a1)))); + // A remote go-to that leads a list: the rest follow the first action left. + let list = action("GoToR", Some(Object::Array(vec![action("SubmitForm", Some(action("Named", None))), action("URI", None)]))); + vec![ + link_with(Some(Object::Dict(goto)), None), + link_with(Some(Object::Ref(a1)), None), + link_with(Some(list), None), + link_with(Some(Object::Ref(launch)), None), + ] + }, + &[], + ); + let mut doc = doc; + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 4, "{report:?}"); + let left: Vec> = annots_of(&doc, p).iter().map(|d| chain(&doc, d.get(b"A").unwrap())).collect(); + assert_eq!( + left, + [vec!["GoTo", "JavaScript", "URI", "Named"], vec!["URI"], vec!["SubmitForm", "Named", "URI"], vec!["URI"]], + "the actions after a removed one run in their order, and a chain that comes back ends there" + ); +} + +#[test] +fn a_chain_deeper_than_the_limit_loses_its_tail_and_one_without_a_remote_action_stays() { + let deep = |n: usize, last: &str| (0..n).fold(action(last, None), |next, _| action("URI", Some(next))); + let (doc, p, _) = linked_doc(|_, _| vec![link_with(Some(deep(600, "GoToR")), None), link_with(Some(deep(600, "Named")), None)], &[]); + let mut doc = doc; + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 1, "{report:?}"); + let left: Vec> = annots_of(&doc, p).iter().map(|d| chain(&doc, d.get(b"A").unwrap())).collect(); + assert!(left[0].len() < 600 && left[0].iter().all(|s| s == "URI"), "cut before the remote go-to: {} actions", left[0].len()); + assert_eq!(left[1].len(), 601, "a deep chain with nothing to remove is kept whole"); +} + +#[test] +fn a_long_chain_of_indirect_actions_stays_indirect_and_reads_back_after_saving() { + // 130 indirect web links, then a remote go-to: the parser reads at most 128 nested levels. + let mut tail = None; + let (doc, p, _) = linked_doc( + |doc, _| { + let mut r = doc.add(action("GoToR", None)); + for _ in 0..130 { + r = doc.add(action("URI", Some(Object::Ref(r)))); + } + tail = Some(r); + vec![link_with(Some(Object::Ref(r)), None)] + }, + &[], + ); + let mut doc = doc; + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 1, "{report:?}"); + let a = annots_of(&doc, p)[0].get(b"A").cloned().unwrap(); + assert!(matches!(a, Object::Ref(r) if Some(r) != tail), "a new indirect action: {a:?}"); + let old = Object::Ref(tail.unwrap()); + assert_eq!(chain(&doc, &old).last().map(String::as_str), Some("GoToR"), "the old chain is not edited"); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(bytes)).unwrap(); + let a = annots_of(&reopened, p)[0].get(b"A").cloned().unwrap(); + assert_eq!(chain(&reopened, &a), vec!["URI"; 130], "every web link reads back"); +} + +#[test] +fn shared_actions_that_multiply_stop_the_optimization_when_one_opens_another_document() { + use pdfcraft_optimize::OptimizeError; + // Forty levels of indirect actions, each listing the next level twice: 2^40 paths. + let shared = |doc: &mut Document, last: &str| { + let mut r = doc.add(action(last, None)); + for _ in 0..40 { + r = doc.add(action("URI", Some(Object::Array(vec![Object::Ref(r), Object::Ref(r)])))); + } + r + }; + let mut link = None; + let (doc, _, _) = linked_doc( + |doc, _| { + let a = shared(doc, "GoToR"); + let r = doc.add(link_with(Some(Object::Ref(a)), None)); + link = Some(r); + vec![Object::Ref(r)] + }, + &[], + ); + let on = Settings { discard_external_refs: true, ..Settings::default() }; + let r = optimize(&mut doc.clone(), &on); + assert!(matches!(r, Err(OptimizeError::ExternalRefLimit(o)) if Some(o) == link), "{r:?}"); + let (doc, p, _) = linked_doc( + |doc, _| { + let a = shared(doc, "Named"); + vec![link_with(Some(Object::Ref(a)), None)] + }, + &[], + ); + let before = annots_of(&doc, p); + let mut doc = doc; + let report = optimize(&mut doc, &on).unwrap(); + assert_eq!(report.external_links, 0, "{report:?}"); + assert_eq!(annots_of(&doc, p), before, "nothing to remove, however the actions are shared"); +} + +#[test] +fn a_shared_chain_without_remote_actions_is_looked_at_once() { + // 600 levels that each list a 2,000-action chain of web links sixty times, then a remote go-to. + let (doc, p, _) = linked_doc( + |doc, _| { + let mut g = doc.add(action("URI", None)); + for _ in 0..2_000 { + g = doc.add(action("URI", Some(Object::Ref(g)))); + } + let a = (0..600).fold(action("GoToR", None), |next, _| { + let mut list = vec![Object::Ref(g); 60]; + list.push(next); + action("URI", Some(Object::Array(list))) + }); + vec![link_with(Some(a), None)] + }, + &[], + ); + let mut doc = doc; + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 1, "{report:?}"); + let left = annots_of(&doc, p); + assert!(!chain(&doc, left[0].get(b"A").unwrap()).iter().any(|s| s == "GoToR")); +} + +#[test] +fn a_link_named_through_an_indirect_subtype_is_discarded_and_a_form_field_keeps_its_action() { + let (doc, p, _) = linked_doc( + |doc, _| { + let name = doc.add(Object::name("Link")); + let Object::Dict(mut indirect) = link_with(Some(action("GoToR", None)), None) else { panic!("link_with() builds a dictionary") }; + indirect.set(b"Subtype".to_vec(), Object::Ref(name)); + let Object::Dict(mut widget) = link_with(Some(action("GoToR", None)), None) else { panic!("link_with() builds a dictionary") }; + widget.set(b"Subtype".to_vec(), Object::name("Widget")); + vec![Object::Dict(indirect), Object::Dict(widget)] + }, + &[], + ); + let mut doc = doc; + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 1, "{report:?}"); + let left = annots_of(&doc, p); + assert_eq!(left.len(), 1, "the link goes"); + assert_eq!(left[0].name(b"Subtype"), Some(&b"Widget"[..])); + assert_eq!(chain(&doc, left[0].get(b"A").unwrap()), ["GoToR"], "the form field keeps its action"); +} + +#[test] +fn thread_actions_into_another_file_and_data_imports_are_discarded_and_threads_here_stay() { + let with_file = |s: &str| { + let Object::Dict(mut d) = action(s, None) else { panic!("action() builds a dictionary") }; + d.set(b"F".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"other.pdf".to_vec()))); + d.set(b"D".to_vec(), Object::Int(0)); + Object::Dict(d) + }; + let here = { + let Object::Dict(mut d) = action("Thread", None) else { panic!("action() builds a dictionary") }; + d.set(b"D".to_vec(), Object::Int(0)); + Object::Dict(d) + }; + let (doc, p, _) = linked_doc( + |_, _| vec![link_with(Some(with_file("Thread")), None), link_with(Some(here.clone()), None), link_with(Some(with_file("ImportData")), None)], + &[], + ); + let mut doc = doc; + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 2, "{report:?}"); + let left = annots_of(&doc, p); + assert_eq!(left.len(), 1); + assert_eq!(left[0].get(b"A"), Some(&here), "the thread in this document stays"); +} + +#[test] +fn an_outline_over_the_limit_stops_the_optimization() { + use pdfcraft_optimize::OptimizeError; + let bookmarks = vec![action("Named", None); 100_001]; + let (doc, _, _) = linked_doc(|_, _| Vec::new(), &bookmarks); + let r = optimize(&mut doc.clone(), &Settings { discard_external_refs: true, ..Settings::default() }); + assert!(matches!(r, Err(OptimizeError::ExternalRefLimit(_))), "{r:?}"); +} + +#[test] +fn bookmarks_stored_inside_other_bookmarks_lose_their_actions_too() { + let (mut doc, _, _) = linked_doc(|_, _| Vec::new(), &[]); + let bookmark = |title: &str, a: Object| { + let mut d = Dict::new(); + d.set(b"Title".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(title.as_bytes().to_vec()))); + d.set(b"A".to_vec(), a); + d + }; + let third = doc.add(Object::Dict(bookmark("C", action("GoToR", None)))); + let mut second = bookmark("B", action("Launch", None)); + second.set(b"First".to_vec(), Object::Ref(third)); + let mut first = bookmark("A", action("GoToR", Some(action("Named", None)))); + first.set(b"Next".to_vec(), Object::Dict(second)); + let mut outlines = Dict::new(); + outlines.set(b"First".to_vec(), Object::Dict(first)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Outlines".to_vec(), Object::Dict(outlines))).unwrap(); + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_bookmarks, 3, "{report:?}"); + let catalog = doc.get(root).as_dict().cloned().unwrap(); + let first = catalog.get(b"Outlines").and_then(Object::as_dict).and_then(|o| o.get(b"First")).and_then(Object::as_dict).cloned().unwrap(); + assert_eq!(chain(&doc, first.get(b"A").unwrap()), ["Named"]); + let second = first.get(b"Next").and_then(Object::as_dict).cloned().unwrap(); + assert!(second.get(b"A").is_none()); + assert!(doc.get(third).as_dict().unwrap().get(b"A").is_none()); +} + +#[test] +fn a_removed_link_kept_by_the_structure_tree_reads_back_without_its_action() { + let mut link = None; + let (doc, p, _) = linked_doc( + |doc, _| { + let r = doc.add(link_with(Some(action("GoToR", None)), None)); + link = Some(r); + vec![Object::Ref(r)] + }, + &[], + ); + let mut doc = doc; + let mut objr = Dict::new(); + objr.set(b"Type".to_vec(), Object::name("OBJR")); + objr.set(b"Obj".to_vec(), Object::Ref(link.unwrap())); + objr.set(b"Pg".to_vec(), Object::Ref(p)); + let mut element = Dict::new(); + element.set(b"S".to_vec(), Object::name("Link")); + element.set(b"K".to_vec(), Object::Dict(objr)); + let mut tree = Dict::new(); + tree.set(b"Type".to_vec(), Object::name("StructTreeRoot")); + tree.set(b"K".to_vec(), Object::Dict(element)); + let tree = doc.add(Object::Dict(tree)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"StructTreeRoot".to_vec(), Object::Ref(tree))).unwrap(); + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 1, "{report:?}"); + assert!(annots_of(&doc, p).is_empty(), "the link leaves its page"); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(bytes)).unwrap(); + let catalog = reopened.get(reopened.root().unwrap()).as_dict().cloned().unwrap(); + let tree = reopened.dict(catalog.get(b"StructTreeRoot").unwrap()).unwrap(); + let element = reopened.dict(tree.get(b"K").unwrap()).unwrap(); + let objr = reopened.dict(element.get(b"K").unwrap()).unwrap(); + let kept = reopened.dict(objr.get(b"Obj").unwrap()).unwrap(); + assert_eq!(kept.name(b"Subtype"), Some(&b"Link"[..])); + assert!(kept.get(b"A").is_none(), "the link's object holds no action to another document: {kept:?}"); +} + #[test] fn progress_is_reported_per_image_and_a_refusal_cancels() { use pdfcraft_optimize::{OptimizeError, Stage, optimize_with_progress}; diff --git a/crates/ui-egui/src/optimize_ui.rs b/crates/ui-egui/src/optimize_ui.rs index 34188e184a..28f2987c97 100644 --- a/crates/ui-egui/src/optimize_ui.rs +++ b/crates/ui-egui/src/optimize_ui.rs @@ -214,6 +214,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo discard_box(ui, &mut d.discard, Hidden::Comments, "Discard all comments, forms and multimedia"); discard_box(ui, &mut d.discard, Hidden::Metadata, "Discard document information and metadata"); discard_box(ui, &mut d.discard, Hidden::Attachments, "Discard all object data (file attachments)"); + ui.add(widgets::checkbox(&mut s.discard_external_refs, tl!("Discard external cross-references"))); discard_box(ui, &mut d.discard, Hidden::PrivateData, "Discard private data of other applications"); discard_box(ui, &mut d.discard, Hidden::HiddenText, "Discard hidden text"); } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c5..3c7fac6643 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7526,11 +7526,11 @@ title = "Discard external cross-references" area = "K" tier = "P2" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["optimize.advanced"] +tools = ["doc_optimize"] +evidence = ["crates/optimize/tests/optimize.rs::links_and_bookmarks_to_other_documents_are_discarded_and_links_within_stay", "crates/optimize/tests/optimize.rs::an_action_to_another_document_hands_its_place_to_the_actions_after_it", "crates/optimize/tests/optimize.rs::a_chain_deeper_than_the_limit_loses_its_tail_and_one_without_a_remote_action_stays", "crates/optimize/tests/optimize.rs::a_long_chain_of_indirect_actions_stays_indirect_and_reads_back_after_saving", "crates/optimize/tests/optimize.rs::shared_actions_that_multiply_stop_the_optimization_when_one_opens_another_document", "crates/optimize/tests/optimize.rs::a_shared_chain_without_remote_actions_is_looked_at_once", "crates/optimize/tests/optimize.rs::a_link_named_through_an_indirect_subtype_is_discarded_and_a_form_field_keeps_its_action", "crates/optimize/tests/optimize.rs::thread_actions_into_another_file_and_data_imports_are_discarded_and_threads_here_stay", "crates/optimize/tests/optimize.rs::an_outline_over_the_limit_stops_the_optimization", "crates/optimize/tests/optimize.rs::bookmarks_stored_inside_other_bookmarks_lose_their_actions_too", "crates/optimize/tests/optimize.rs::a_removed_link_kept_by_the_structure_tree_reads_back_without_its_action", "crates/automation/tests/it/automation.rs::optimizing_discards_links_to_other_documents_through_tools"] +notes = "PDF Optimizer ▸ Discard User Data, off by default. Takes GoToR, GoToE, Launch and ImportData actions, and Thread actions with an /F, out of the /A of link annotations (/Subtype /Link, stored directly or as an indirect name) and bookmarks, and the actions chained after one through /Next take its place in order. A link left with no action and no /Dest is removed from /Annots, and when the link is an indirect object its object is rewritten without the action for its other users (the structure tree, for example); a bookmark keeps its title and children. Bookmarks stored directly inside another bookmark or the outline dictionary are changed in place; an outline of more than 100,000 items, or with direct items nested more than 512 levels deep, stops the optimization with an error. Links within the document (/Dest, GoTo) and web links (URI) stay. An indirect action that changes is written as a new indirect object, except the first action left at the head of an /A, which is copied into the /A directly when other actions follow it; the old one stays for its other users. Past 512 nested levels (actions, /Next arrays and references), the rest of a chain is kept when it opens no other document and removed when it does. An /A with more than 100,000 values to check, or one that opens another document and needs more than 100,000 values to rewrite (actions shared so that their paths multiply), stops the optimization with an error. Not changed: /AA triggers, widget and other annotation actions, the catalog /OpenAction, and JavaScript." [[feature]] id = "optimize.discard-hidden-layers" From 98907b3907bfd54efe57e60f7e19dede7f33f9fc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 04:43:33 -0700 Subject: [PATCH 005/101] M11: PDF Optimizer can discard an embedded search index PDF Optimizer > Discard Objects gets "Discard embedded search index", off by default. It removes the entries of the catalog's /PieceInfo whose application name contains "index" in any letter case, such as IBM-ODIndexes, and removes /PieceInfo when nothing else is left in it. Other application data and the pages' own /PieceInfo stay. An indirect /PieceInfo is not edited: the catalog gets a direct copy without those entries, and other objects that refer to the original keep it. The full save leaves out the objects that only the removed entries refer to. doc_optimize takes discard_search_index and reports search_indexes. --- crates/automation/src/lib.rs | 2 + crates/automation/src/tools.rs | 3 +- crates/automation/tests/it/automation.rs | 30 ++++++++ crates/optimize/README.md | 7 ++ crates/optimize/src/lib.rs | 51 +++++++++++++ crates/optimize/tests/optimize.rs | 97 ++++++++++++++++++++++++ crates/ui-egui/src/optimize_ui.rs | 1 + parity/acrobat-features.toml | 10 +-- 8 files changed, 195 insertions(+), 6 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index d2114ac7bf..ada8aee4bb 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1254,6 +1254,7 @@ impl Automation { ("discard_thumbnails", &mut settings.discard_thumbnails), ("discard_alternate_images", &mut settings.discard_alternate_images), ("discard_tags", &mut settings.discard_tags), + ("discard_search_index", &mut settings.discard_search_index), ("discard_print_settings", &mut settings.discard_print_settings), ("flate_unencoded", &mut settings.flate_unencoded), ("remove_invalid_links", &mut settings.remove_invalid_links), @@ -1292,6 +1293,7 @@ impl Automation { "image_bytes_after": o.image_bytes_after, "thumbnails": o.thumbnails, "alternate_images": o.alternate_images, + "search_indexes": o.search_indexes, "tags_removed": o.tags_removed, "streams_compressed": o.streams_compressed, "invalid_links": o.invalid_links, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1e009c1170..1661dfcc40 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1375,7 +1375,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, search_index (removes the catalog's /PieceInfo entries whose application name contains \"index\" in any letter case; search_indexes), print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ @@ -1388,6 +1388,7 @@ pub fn tools() -> Vec { "discard_thumbnails": { "type": "boolean" }, "discard_alternate_images": { "type": "boolean" }, "discard_tags": { "type": "boolean" }, + "discard_search_index": { "type": "boolean" }, "discard_print_settings": { "type": "boolean" }, "flate_unencoded": { "type": "boolean" }, "remove_invalid_links": { "type": "boolean" }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 9fd5c4269a..327e25d6cd 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -5685,6 +5685,36 @@ fn signing_with_translated_labels_through_tools() { assert!(stamped.contains("(Genehmigt)") && !stamped.contains("Reason") && !stamped.contains("Date"), "{stamped}"); } +#[test] +fn optimizing_discards_an_embedded_search_index_through_tools() { + use pdfcraft_cos::{Dict, Object}; + let dir = workdir("optimize-search-index"); + let mut cos = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let mut info = Dict::new(); + for name in ["IBM-ODIndexes", "Illustrator"] { + let mut data = Dict::new(); + data.set(b"Private".to_vec(), Object::Int(1)); + info.set(name.as_bytes().to_vec(), Object::Dict(data)); + } + let root = cos.root().unwrap(); + cos.update_dict(root, |c| c.set(b"PieceInfo".to_vec(), Object::Dict(info))).unwrap(); + std::fs::write(dir.join("indexed.pdf"), pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap()).unwrap(); + let piece_info = |name: &str| { + let d = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join(name)).unwrap())).unwrap(); + let cat = d.get(d.root().unwrap()).as_dict().cloned().unwrap(); + let info = d.dict(cat.get(b"PieceInfo").unwrap()).unwrap(); + info.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect::>() + }; + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "indexed.pdf" }))["doc"].as_u64().unwrap(); + let kept = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf" })); + assert_eq!(kept["search_indexes"], 0, "{kept}"); + assert_eq!(piece_info("kept.pdf"), ["IBM-ODIndexes", "Illustrator"]); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf", "discard_search_index": true })); + assert_eq!(r["search_indexes"], 1, "{r}"); + assert_eq!(piece_info("small.pdf"), ["Illustrator"]); +} + #[test] fn optimizing_through_tools() { let dir = workdir("optimize"); diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 3d517eef3a..dde9d9f0f4 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -37,3 +37,10 @@ then this, then merges identical objects and writes a full, compressed save (`pdfcraft_engine::optimizer::OptimizeJob`, which the UI runs on a worker thread with a progress bar). JPEG decoding and encoding and resampling come from the `image` crate. + +Discard embedded search index (`discard_search_index`, off by default) removes the entries of +the catalog's `/PieceInfo` whose application name contains "index" in any letter case, such as +`IBM-ODIndexes`, and removes `/PieceInfo` when no other entry is left. An indirect `/PieceInfo` +is copied into the catalog without those entries, and the original object is not changed. The +full save leaves out the objects that only the removed entries refer to. Pages keep their own +`/PieceInfo`. diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index c8b9b67690..b31a2af8fa 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -15,6 +15,8 @@ //! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted //! image); Flate-compress streams that have no filter; remove a redundant outer ASCII85 or //! ASCIIHex layer from streams that have one. +//! - **Discard embedded search index** (off by default): the search indexes stored in the +//! catalog's `/PieceInfo` (see [`Settings::discard_search_index`]). //! //! Metadata, attachments, comments, scripts, private data, hidden layers, bookmarks and form //! fields are discarded by `pdfcraft-redact`'s Remove Hidden Information, which the engine @@ -96,6 +98,11 @@ pub struct Settings { pub discard_thumbnails: bool, pub discard_alternate_images: bool, pub discard_tags: bool, + /// Remove the entries of the catalog's `/PieceInfo` (data an application keeps for the whole + /// document, ISO 32000-2 §14.5) whose application name contains "index" in any letter case, + /// and `/PieceInfo` itself when nothing else is left in it. Objects that only those entries + /// refer to are left out of the saved file. + pub discard_search_index: bool, pub discard_print_settings: bool, /// Flate-compress streams stored without a filter, and strip a redundant outer ASCII85 or /// ASCIIHex layer from streams that have one. @@ -116,6 +123,7 @@ impl Default for Settings { discard_thumbnails: true, discard_alternate_images: true, discard_tags: false, + discard_search_index: false, discard_print_settings: false, flate_unencoded: true, remove_invalid_links: true, @@ -176,6 +184,8 @@ pub struct Report { pub thumbnails: usize, pub alternate_images: usize, pub tags_removed: bool, + /// Search index entries removed from the catalog's `/PieceInfo`. + pub search_indexes: usize, pub print_settings: usize, pub streams_compressed: usize, /// Links removed and bookmarks whose destination was cleared (Clean Up). @@ -247,6 +257,11 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: } report.tags_removed = true; } + if settings.discard_search_index + && let Some(root) = root + { + report.search_indexes = discard_search_index(doc, root)?; + } if settings.discard_print_settings && let Some(root) = root { @@ -335,3 +350,39 @@ fn stored_len(s: &Stream) -> Option { serialize(&Object::Dict(dict), &mut out); out.len().checked_add(s.raw.len()) } + +/// Remove the search index entries of the catalog's `/PieceInfo` ([`Settings::discard_search_index`]) +/// and return how many there were. An indirect `/PieceInfo` is not edited: the catalog gets a +/// direct copy without those entries, so other objects that refer to it keep theirs. The objects +/// the removed entries held are dropped by the full save, which writes only what the trailer +/// reaches. +fn discard_search_index(doc: &mut Document, root: ObjRef) -> Result { + let Some(info) = doc.get(root).as_dict().and_then(|c| c.get(b"PieceInfo").cloned()) else { + return Ok(0); + }; + let (info, removed) = match &*doc.resolve(&info) { + Object::Dict(d) => { + let kept: pdfcraft_cos::Dict = d.iter().filter(|(k, _)| !is_search_index(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); + let removed = d.len().saturating_sub(kept.len()); + (kept, removed) + } + _ => return Ok(0), + }; + if removed == 0 { + return Ok(0); + } + doc.update_dict(root, |c| { + if info.is_empty() { + c.remove(b"PieceInfo"); + } else { + c.set(b"PieceInfo".to_vec(), Object::Dict(info)); + } + })?; + Ok(removed) +} + +/// Whether a `/PieceInfo` application name names a search index: it contains "index" in any +/// letter case (such as `IBM-ODIndexes`). +fn is_search_index(name: &[u8]) -> bool { + name.windows(5).any(|w| w.eq_ignore_ascii_case(b"index")) +} diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 6d9a9bb44e..bd3f3a3880 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -1139,3 +1139,100 @@ fn named_dest_keys(doc: &Document) -> Vec> { let arr = doc.resolve(tree.get(b"Names").unwrap()).as_array().cloned().unwrap(); arr.chunks(2).filter_map(|c| doc.resolve(&c[0]).as_string().map(|s| s.bytes.clone())).collect() } + +/// A `/PieceInfo` data dictionary holding `private`. +fn app_data(private: Object) -> Object { + let mut d = Dict::new(); + d.set(b"LastModified".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"D:20260101000000Z".to_vec()))); + d.set(b"Private".to_vec(), private); + Object::Dict(d) +} + +fn marked(doc: &mut Document, marker: &str, extra: &[(&str, Object)]) -> ObjRef { + let mut d = Dict::new(); + d.set(b"Marker".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(marker.as_bytes().to_vec()))); + for (k, v) in extra { + d.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Dict(d)) +} + +fn saved(doc: &Document) -> Vec { + write_full(doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap() +} + +fn contains(bytes: &[u8], text: &str) -> bool { + bytes.windows(text.len()).any(|w| w == text.as_bytes()) +} + +#[test] +fn search_indexes_in_the_catalog_go_and_other_application_data_stays() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], "BT ET"); + // The first index holds two objects that refer to each other, and one the page also uses. + let shared = marked(&mut doc, "SHARED-DATA", &[]); + let a = marked(&mut doc, "INDEX-A", &[("Shared", Object::Ref(shared))]); + let b = marked(&mut doc, "INDEX-B", &[("Next", Object::Ref(a))]); + doc.update_dict(a, |d| d.set(b"Next".to_vec(), Object::Ref(b))).unwrap(); + let c = marked(&mut doc, "INDEX-C", &[]); + let keep = marked(&mut doc, "KEEP-APP", &[]); + let mut info = Dict::new(); + info.set(b"IBM-ODIndexes".to_vec(), app_data(Object::Ref(a))); + info.set(b"MySearchINDEX".to_vec(), app_data(Object::Ref(c))); + info.set(b"Illustrator".to_vec(), app_data(Object::Ref(keep))); + let root = doc.root().unwrap(); + doc.update_dict(root, |cat| cat.set(b"PieceInfo".to_vec(), Object::Dict(info))).unwrap(); + let mut page_info = Dict::new(); + page_info.set(b"PageIndexer".to_vec(), app_data(Object::Ref(shared))); + doc.update_dict(p, |d| d.set(b"PieceInfo".to_vec(), Object::Dict(page_info))).unwrap(); + + // Off by default: nothing changes. + let mut untouched = doc.clone(); + assert!(!Settings::default().discard_search_index); + assert_eq!(optimize(&mut untouched, &Settings::default()).unwrap().search_indexes, 0); + let bytes = saved(&untouched); + assert!(["INDEX-A", "INDEX-B", "INDEX-C", "KEEP-APP"].iter().all(|m| contains(&bytes, m))); + + let r = optimize(&mut doc, &Settings { discard_search_index: true, ..Settings::default() }).unwrap(); + assert_eq!(r.search_indexes, 2); + let cat = doc.get(root).as_dict().cloned().unwrap(); + let info = cat.get(b"PieceInfo").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!(info.iter().map(|(k, _)| k.clone()).collect::>(), vec![b"Illustrator".to_vec()]); + // The saved file leaves out the objects only the indexes held, the cycle included; the + // object the page also uses, the other application's data and the page's own entry stay. + let bytes = saved(&doc); + assert!(!["INDEX-A", "INDEX-B", "INDEX-C"].iter().any(|m| contains(&bytes, m))); + assert!(["SHARED-DATA", "KEEP-APP", "PageIndexer"].iter().all(|m| contains(&bytes, m))); + let reopened = Document::open(Arc::new(bytes)).unwrap(); + let cat = reopened.get(reopened.root().unwrap()).as_dict().cloned().unwrap(); + let info = reopened.dict(cat.get(b"PieceInfo").unwrap()).unwrap(); + assert!(info.contains(b"Illustrator") && info.len() == 1); +} + +#[test] +fn an_indirect_piece_info_is_left_for_its_other_users() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], "BT ET"); + let idx = marked(&mut doc, "INDEX-DATA", &[]); + let mut info = Dict::new(); + info.set(b"Index".to_vec(), app_data(Object::Ref(idx))); + let info = doc.add(Object::Dict(info)); + let root = doc.root().unwrap(); + doc.update_dict(root, |cat| cat.set(b"PieceInfo".to_vec(), Object::Ref(info))).unwrap(); + doc.update_dict(p, |d| d.set(b"PieceInfo".to_vec(), Object::Ref(info))).unwrap(); + let settings = Settings { discard_search_index: true, ..Settings::default() }; + let r = optimize(&mut doc, &settings).unwrap(); + // The catalog's only entry was an index, so /PieceInfo goes; the page keeps the shared one. + assert_eq!(r.search_indexes, 1); + assert!(!doc.get(root).as_dict().unwrap().contains(b"PieceInfo")); + assert!(doc.get(info).as_dict().unwrap().contains(b"Index")); + assert!(contains(&saved(&doc), "INDEX-DATA")); + + // A /PieceInfo that is not a dictionary is left as it is. + let mut doc = Document::new_empty(); + page(&mut doc, &[], "BT ET"); + let root = doc.root().unwrap(); + doc.update_dict(root, |cat| cat.set(b"PieceInfo".to_vec(), Object::Int(7))).unwrap(); + assert_eq!(optimize(&mut doc, &settings).unwrap().search_indexes, 0); + assert_eq!(doc.get(root).as_dict().unwrap().int(b"PieceInfo"), Some(7)); +} diff --git a/crates/ui-egui/src/optimize_ui.rs b/crates/ui-egui/src/optimize_ui.rs index 34188e184a..9b485289b4 100644 --- a/crates/ui-egui/src/optimize_ui.rs +++ b/crates/ui-egui/src/optimize_ui.rs @@ -205,6 +205,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo ui.add(widgets::checkbox(&mut s.discard_alternate_images, tl!("Discard alternate images"))); ui.add(widgets::checkbox(&mut s.discard_thumbnails, tl!("Discard embedded page thumbnails"))); ui.add(widgets::checkbox(&mut s.discard_tags, tl!("Discard document tags"))); + ui.add(widgets::checkbox(&mut s.discard_search_index, tl!("Discard embedded search index"))); ui.add(widgets::checkbox(&mut s.discard_print_settings, tl!("Discard embedded print settings"))); discard_box(ui, &mut d.discard, Hidden::Bookmarks, "Discard bookmarks"); discard_box(ui, &mut d.discard, Hidden::FormFields, "Flatten form fields"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c5..bbcf68f66d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7418,11 +7418,11 @@ title = "Discard embedded search index" area = "K" tier = "P2" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["optimize.advanced"] +tools = ["doc_optimize"] +evidence = ["crates/optimize/tests/optimize.rs::search_indexes_in_the_catalog_go_and_other_application_data_stays", "crates/optimize/tests/optimize.rs::an_indirect_piece_info_is_left_for_its_other_users", "crates/automation/tests/it/automation.rs::optimizing_discards_an_embedded_search_index_through_tools"] +notes = "PDF Optimizer ▸ Discard Objects, off by default. The public help page does not say where the index is stored; PdfCraft's rule: an entry of the catalog's /PieceInfo whose application name contains \"index\" in any letter case (such as IBM-ODIndexes) is a search index and is removed, with /PieceInfo itself when nothing else is left. An indirect /PieceInfo is copied into the catalog without those entries and the original object is left for its other users. The full save leaves out objects only the removed entries reach, cycles included; objects something else also uses stay. Page /PieceInfo is not changed. An index stored under an application name that does not contain \"index\" is kept." [[feature]] id = "optimize.discard-print-settings" From bccad5c90d28a01897e7a0d30e15c717c828bbef Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 14:31:50 +0000 Subject: [PATCH 006/101] M3: Previous and Next view let go of a find step's match, as other jumps do (#3530) Previous view while a wheel zoom was still easing kept find_hold, so the rest of the zoom anchored on the old match and the history jump waited until zooming stopped (CodeRabbit on #4139). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LymhVaGZCWwB9HNEbLzeMM --- crates/ui-egui/src/canvas.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ebe5dfc6e7..137b2c49eb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1550,6 +1550,8 @@ impl DocView { self.goto = Some((page, 0.0)); self.current = page; self.reset_coarse_state(); + // A find step's match belongs to the place left, as for any other jump. + self.find_hold = None; true } @@ -6209,6 +6211,19 @@ trailer << /Root 1 0 R >> assert_eq!(v.zoom_anchor.map(|a| a.0), Some(0), "anchored on the page under the pointer"); } + #[test] + fn previous_view_after_a_find_step_lets_go_of_the_match() { + let mut v = history_search_view(); + v.screen_rects = vec![(0, Rect::from_min_size(pos2(100.0, 20.0), vec2(300.0, 400.0)))]; + v.find_step(true); + // Previous view while a wheel zoom is still easing: the zoom must not hold the old match + // and put the history jump off until it stops. + assert!(v.view_history(false)); + assert_eq!(v.goto, Some((0, 0.0))); + v.zoom_at(2.0, pos2(150.0, 50.0)); + assert_eq!(v.zoom_anchor.map(|a| a.0), Some(0), "anchored on the page under the pointer"); + } + #[test] fn a_match_with_unbounded_glyphs_falls_back_to_its_page() { let mut v = history_search_view(); From 8de27ff9add9bd4124c15e6ca706a72afa0191ab Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 08:01:32 -0700 Subject: [PATCH 007/101] Optimize: a removed external action is not replaced by a null in its /Next When an action to another document was removed and its /Next list started with a null or a reference to something other than an action, that entry became the head of the chain, and the actions after it, such as a web link, were dropped with it. Leading entries that are not action dictionaries are now skipped. The Settings::discard_external_refs doc comment lists every action type the option removes. --- crates/optimize/src/external_refs.rs | 3 +++ crates/optimize/src/lib.rs | 5 +++-- crates/optimize/tests/optimize.rs | 25 +++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/optimize/src/external_refs.rs b/crates/optimize/src/external_refs.rs index 2df86175e0..a9a31bca5a 100644 --- a/crates/optimize/src/external_refs.rs +++ b/crates/optimize/src/external_refs.rs @@ -265,6 +265,9 @@ fn strip_action(doc: &mut Document, a: &Object, owner: ObjRef) -> Result Date: Sat, 17 Oct 2026 13:42:08 -0400 Subject: [PATCH 008/101] fix(print): landscape sheets stay landscape on CUPS (#2713) On macOS and Linux the job sent no orientation, so CUPS left a landscape sheet unrotated and shrank it into the portrait paper named by `media` (Letter, A4, ...). Printing in Landscape therefore came out as a small landscape page on a portrait sheet. Add `pdfcraft_print::cups_orientation`, which gives `orientation-requested=4` when every sheet is landscape, and attach it to the job in two places: - the Print dialog (`print_now`); - the headless `doc_print` tool, which builds its own spooler job, so the automation tool and MCP server print the same way. An `orientation-requested` the caller already chose in `options` stands. Neither applies on Windows, whose print ticket sets the orientation. A job that mixes orientations gets nothing, since the option applies to the whole job. Record the new test as evidence on the `print.orientation` parity entry, saying what is still only checked by hand, and mention the option in the print crate README's CUPS paragraph. Checked against Apple's CUPS filters with a generic PostScript queue: a 792x612 sheet sent to Letter without the option stays unrotated at about 77% scale; with it the page is turned 90 degrees to fill the sheet. The new test fails with the fix stubbed out. --- crates/automation/src/printing.rs | 10 ++++++++-- crates/print/README.md | 4 +++- crates/print/src/lib.rs | 12 ++++++++++++ crates/print/src/tests.rs | 22 ++++++++++++++++++++++ crates/ui-egui/src/print_ui.rs | 10 +++++++++- parity/acrobat-features.toml | 4 ++-- 6 files changed, 56 insertions(+), 6 deletions(-) diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 9a1c1a6b7c..fef5beaaab 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -202,7 +202,8 @@ impl Automation { }; let settings = print::Settings { pages, paper, orientation, layout, content, raster, crops: Vec::new() }; let sizes: Vec<(f64, f64)> = self.doc(a)?.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect(); - let sheets = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?.len(); + let laid_out = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?; + let sheets = laid_out.len(); let bytes = self.session.print_pdf(id, &settings).map_err(failed)?; let mut out = json!({ "sheets": sheets, "pages": settings.pages.len(), "bytes": bytes.len() }); match (a.opt_str("path")?, a.opt_str("printer")?) { @@ -212,7 +213,12 @@ impl Automation { out["path"] = json!(path.to_string_lossy()); } (None, Some(printer)) => { - let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index)?; + let mut job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index)?; + // CUPS is told which way the sheets run, as the Print dialog does (#2713); Windows' + // print ticket already is. An orientation the caller chose in `options` stands. + if !cfg!(windows) && !job.options.iter().any(|(k, _)| k == "orientation-requested") { + job.options.extend(print::cups_orientation(&laid_out)); + } out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?); } _ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")), diff --git a/crates/print/README.md b/crates/print/README.md index 1379be2b42..7f9f23b3ab 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`, and with `orientation-requested=4` when +every sheet is landscape (`cups_orientation`), since CUPS would otherwise shrink a landscape +sheet into portrait paper. 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/lib.rs b/crates/print/src/lib.rs index b731aff00f..b32cb15541 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -361,6 +361,18 @@ pub fn media_option(index: usize) -> Option<(String, String)> { cups_media(index).map(|m| ("media".to_string(), m.to_string())) } +/// The CUPS job option that keeps landscape sheets landscape (`orientation-requested` 4). +/// +/// The print-ready PDF's sheets are already laid out on their paper, but the paper is named +/// portrait (`Letter`), and without this option CUPS leaves a landscape sheet unrotated and +/// shrinks it into the portrait paper instead of turning it to fill it (#2713). It is a property +/// of the whole job, so it is given only when *every* sheet is landscape; a job that mixes +/// orientations is left to the spooler, as portrait sheets would be turned wrongly. +pub fn cups_orientation(sheets: &[Sheet]) -> Option<(String, String)> { + let landscape = !sheets.is_empty() && sheets.iter().all(|s| s.size.0 > s.size.1); + landscape.then(|| ("orientation-requested".to_string(), "4".to_string())) +} + /// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid /// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects /// are followed a few levels deep. diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index e6432d8aec..c37abdf8fc 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -509,6 +509,28 @@ fn printer_option_defaults_and_job_arguments() { assert!(spool::printer_options("../../etc/passwd").is_empty(), "not a queue name"); } +#[test] +fn landscape_sheets_ask_cups_to_turn_the_page() { + let landscape = |size: (f64, f64)| Sheet { size, ..Sheet::default() }; + let ask = Some(("orientation-requested".to_string(), "4".to_string())); + // A landscape Size sheet, and a booklet (always landscape sheets), are both turned. + let page = layout(&[(300.0, 200.0)], &settings(vec![0], Layout::Size(SizeMode::Fit))).unwrap(); + assert_eq!(cups_orientation(&page), ask); + let booklet = + layout(&[(200.0, 300.0); 4], &settings(vec![0, 1, 2, 3], Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left })) + .unwrap(); + assert_eq!(cups_orientation(&booklet), ask); + // Portrait sheets, mixed jobs and nothing at all say nothing. + let portrait = layout(&[(200.0, 300.0)], &settings(vec![0], Layout::Size(SizeMode::Fit))).unwrap(); + assert_eq!(cups_orientation(&portrait), None); + assert_eq!(cups_orientation(&[landscape((792.0, 612.0)), landscape((612.0, 792.0))]), None, "a mixed job is not turned as a whole"); + assert_eq!(cups_orientation(&[landscape((612.0, 612.0))]), None, "a square sheet has no orientation"); + assert_eq!(cups_orientation(&[]), None); + // `lp` is given it, among the job's other options. + let job = Job { options: ask.into_iter().collect(), ..Job::default() }; + assert!(lp_args(&job).join(" ").ends_with("-o fit-to-page=false -o orientation-requested=4")); +} + #[cfg(all(unix, not(target_arch = "wasm32")))] mod cups_options { use crate::spool; diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index 5bf8737fcc..2a68927f26 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -653,7 +653,15 @@ impl PdfCraftApp { } } } - Some(printer) => self.send_to_printer(printer, bytes, self.print_draft.job(&name)), + Some(printer) => { + let mut job = self.print_draft.job(&name); + // CUPS is told which way the sheets run; Windows' print ticket already is. + if !cfg!(windows) { + let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (f64::from(p.width), f64::from(p.height))).collect(); + job.options.extend(print::layout(&sizes, &settings).ok().and_then(|sheets| print::cups_orientation(&sheets))); + } + self.send_to_printer(printer, bytes, job) + } None => self.save_print_pdf(&name, bytes), } } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 5e1c4a7e75..a042c2d642 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8144,8 +8144,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/automation/tests/it/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/it/print.rs::architectural_page_can_be_matched_at_100_percent"] -notes = "auto portrait/landscape" +evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/automation/tests/it/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/it/print.rs::architectural_page_can_be_matched_at_100_percent", "crates/print/src/tests.rs::landscape_sheets_ask_cups_to_turn_the_page"] +notes = "auto portrait/landscape. CUPS jobs carry orientation-requested=4 when every sheet is landscape, so a landscape sheet fills its portrait paper instead of being shrunk into it (#2713); a job that mixes orientations is left to the spooler, and Windows sets orientation in its own print ticket. The CUPS option is checked against Apple's filters and one physical printer, not a test that runs a spooler." [[feature]] id = "print.comments-and-forms" From b8111effc6e6e5e9a9272e5d30f0fbfc84cc9bdc Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:00:34 -0400 Subject: [PATCH 009/101] M10: one guarded orientation for both print paths, with a test at each (#2713, #3739) Review follow-ups to the landscape fix rebased from #3068: - Both submission paths add the orientation through pdfcraft_print::add_cups_orientation, which never adds it over an orientation the job already names (a driver choice in the Print dialog, or a caller's doc_print options), so lp never gets it twice. - doc_print adds it inside spool_job, next to the paper (#3752), so its test checks the job it sends. - The Print dialog takes a stand-in for the spooler in tests (PdfCraftApp::spooler), like printer_source and options_source, and a test follows a real Print click to the job. - Doc comments for the tests beside the change. Checked on macOS with cupsfilter (cgpdftoraster, AirPrint PPD): a landscape sheet sent as before lands unrotated at 100% and cut off at the paper's right edge (#3739); with the option it is turned inside the margins. Co-authored-by: Zach <90340773+zdblazer@users.noreply.github.com> --- crates/automation/src/printing.rs | 43 +++++++++++++++------ crates/print/src/lib.rs | 11 ++++++ crates/print/src/tests.rs | 24 ++++++++++++ crates/ui-egui/src/lib.rs | 5 ++- crates/ui-egui/src/print_ui.rs | 28 +++++++++----- crates/ui-egui/tests/it/print.rs | 63 +++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 7 files changed, 154 insertions(+), 24 deletions(-) diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index fef5beaaab..9f0eedba95 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -60,8 +60,10 @@ fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> { /// The spooler job for `doc_print` to `printer`. It feeds the paper the PDF was laid out on /// (`PAPERS[paper]`, as `media` after the driver's `options`) rather than the queue's default -/// paper, which a job naming none got (A4 under Letter sheets on the queue in #3752). -fn spool_job(a: &Args, printer: &str, title: String, duplex: Option, paper: usize) -> Result { +/// paper, which a job naming none got (A4 under Letter sheets on the queue in #3752), and tells +/// CUPS which way the laid-out `sheets` run, as the Print dialog does (#2713): an orientation the +/// caller chose in `options` stands. +fn spool_job(a: &Args, printer: &str, title: String, duplex: Option, paper: usize, sheets: &[print::Sheet]) -> Result { let options = match a.get("options") { None => Vec::new(), Some(Value::Object(m)) => m @@ -81,7 +83,11 @@ fn spool_job(a: &Args, printer: &str, title: String, duplex: Option = options.into_iter().chain(print::media_option(paper)).collect(); + print::add_cups_orientation(&mut options, sheets); + options + }, }) } @@ -213,12 +219,7 @@ impl Automation { out["path"] = json!(path.to_string_lossy()); } (None, Some(printer)) => { - let mut job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index)?; - // CUPS is told which way the sheets run, as the Print dialog does (#2713); Windows' - // print ticket already is. An orientation the caller chose in `options` stands. - if !cfg!(windows) && !job.options.iter().any(|(k, _)| k == "orientation-requested") { - job.options.extend(print::cups_orientation(&laid_out)); - } + let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index, &laid_out)?; out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?); } _ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")), @@ -257,13 +258,31 @@ mod tests { assert!(duplex("sideways", false).is_err()); } + /// #2713, #3739: `doc_print` to a printer tells CUPS when every sheet runs landscape, after + /// the paper, so a landscape sheet is turned onto the portrait paper rather than printed + /// unrotated and cut off. Portrait and mixed jobs say nothing, and an orientation passed in + /// `options` stands and isn't sent twice. + #[test] + fn printing_landscape_sheets_to_a_printer_asks_cups_to_turn_them() { + let sheets = |sizes: &[(f64, f64)]| sizes.iter().map(|&size| print::Sheet { size, ..Default::default() }).collect::>(); + let job = |args: Value, s: &[print::Sheet]| spool_job(&Args(&args), "Office", "a.pdf".into(), None, 0, s).unwrap().options; + let media = ("media".to_string(), "Letter".to_string()); + let landscape = sheets(&[(792.0, 612.0), (792.0, 612.0)]); + let turned = if cfg!(windows) { vec![media.clone()] } else { vec![media.clone(), ("orientation-requested".to_string(), "4".to_string())] }; + assert_eq!(job(json!({}), &landscape), turned); + assert_eq!(job(json!({}), &sheets(&[(612.0, 792.0)])), vec![media.clone()], "portrait sheets"); + assert_eq!(job(json!({}), &sheets(&[(792.0, 612.0), (612.0, 792.0)])), vec![media.clone()], "a job that mixes them"); + let chosen = ("orientation-requested".to_string(), "5".to_string()); + assert_eq!(job(json!({ "options": { "orientation-requested": "5" } }), &landscape), [chosen, media], "the caller's own choice, once"); + } + /// #3752: `doc_print` to a printer sent no paper, so the queue's default paper stood in for /// the one the sheets were laid out on (A4 under Letter sheets on the report's queue). #[test] fn printing_to_a_printer_feeds_the_laid_out_paper() { let job = |args: Value, paper_name: Option<&str>| { let (index, _) = paper_named(paper_name).unwrap(); - spool_job(&Args(&args), "Office", "a.pdf".into(), None, index).unwrap() + spool_job(&Args(&args), "Office", "a.pdf".into(), None, index, &[]).unwrap() }; let media = |m: &str| ("media".to_string(), m.to_string()); // US Letter when no paper is asked for, after the driver's own options. @@ -279,7 +298,7 @@ mod tests { assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]); assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]); assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter"))); - assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0).ok().map(|j| j.printer), Some(None)); - assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0).is_err()); + assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0, &[]).ok().map(|j| j.printer), Some(None)); + assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0, &[]).is_err()); } } diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index b32cb15541..88dfa84960 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -373,6 +373,17 @@ pub fn cups_orientation(sheets: &[Sheet]) -> Option<(String, String)> { landscape.then(|| ("orientation-requested".to_string(), "4".to_string())) } +/// Add [`cups_orientation`] to a CUPS job's `options`, for the Print dialog's jobs and +/// `doc_print`'s alike, unless they already name an orientation (a driver choice, or one a +/// caller passed): `lp` must not be given the option twice. Nothing on Windows, whose print +/// ticket sets each sheet's orientation itself. +pub fn add_cups_orientation(options: &mut Vec<(String, String)>, sheets: &[Sheet]) { + if cfg!(windows) || options.iter().any(|(k, _)| k == "orientation-requested") { + return; + } + options.extend(cups_orientation(sheets)); +} + /// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid /// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects /// are followed a few levels deep. diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index c37abdf8fc..c15fc37254 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -489,6 +489,8 @@ fn printer_options_come_from_the_ppd() { assert!(spool::parse_ppd("*OpenUI *Broken\n*Broken A/B\n").is_empty(), "unclosed or malformed blocks are skipped"); } +/// A driver option's current choice is read from `lpoptions`, and a job passes `lp` only the +/// driver options that are well-formed PPD keywords, never the ones PdfCraft sets itself. #[test] fn printer_option_defaults_and_job_arguments() { let current = @@ -509,6 +511,8 @@ fn printer_option_defaults_and_job_arguments() { assert!(spool::printer_options("../../etc/passwd").is_empty(), "not a queue name"); } +/// CUPS is asked to turn the sheets (`orientation-requested` 4) when every one is landscape, a +/// booklet's included; portrait, mixed, square and empty jobs say nothing (#2713). #[test] fn landscape_sheets_ask_cups_to_turn_the_page() { let landscape = |size: (f64, f64)| Sheet { size, ..Sheet::default() }; @@ -531,6 +535,26 @@ fn landscape_sheets_ask_cups_to_turn_the_page() { assert!(lp_args(&job).join(" ").ends_with("-o fit-to-page=false -o orientation-requested=4")); } +/// Both submission paths add the orientation through `add_cups_orientation`: after the job's +/// other options, never over an orientation the job already names (a driver choice), so `lp` is +/// never given it twice, and not on Windows, whose print ticket sets it. +#[test] +fn a_job_that_names_its_orientation_keeps_it() { + let landscape = layout(&[(300.0, 200.0)], &settings(vec![0], Layout::Size(SizeMode::Fit))).unwrap(); + let media = ("media".to_string(), "Letter".to_string()); + let mut options = vec![media.clone()]; + add_cups_orientation(&mut options, &landscape); + let turned = if cfg!(windows) { vec![media] } else { vec![media, ("orientation-requested".to_string(), "4".to_string())] }; + assert_eq!(options, turned); + // Added again, it isn't doubled. + add_cups_orientation(&mut options, &landscape); + assert_eq!(options, turned); + // A choice already made stands. + let mut chosen = vec![("orientation-requested".to_string(), "5".to_string())]; + add_cups_orientation(&mut chosen, &landscape); + assert_eq!(chosen, [("orientation-requested".to_string(), "5".to_string())]); +} + #[cfg(all(unix, not(target_arch = "wasm32")))] mod cups_options { use crate::spool; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79f46bee7b..83386eed58 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -117,7 +117,7 @@ mod signature_drag; pub mod units; mod web_print; mod word_count_ui; -pub use print_ui::{Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSource, Which as PrintWhich, system_printers}; +pub use print_ui::{Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSource, Spooler, Which as PrintWhich, system_printers}; mod redact_ui; pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode}; pub mod i18n; @@ -706,6 +706,8 @@ pub struct PdfCraftApp { pub printer_source: Option, /// Where Properties… reads a printer's driver options: the spooler's unless a test stands in. pub options_source: Option, + /// What print jobs go to: the system spooler unless a test stands in ([`Spooler`]). + pub spooler: Option, // A browser has no background print work: the type is empty there. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) print_tasks: print_ui::PrintTasks, @@ -1139,6 +1141,7 @@ impl PdfCraftApp { updates: updates::Updates::default(), printer_source: None, options_source: None, + spooler: None, print_tasks: print_ui::PrintTasks::default(), palette_open: false, palette_query: String::new(), diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index 2a68927f26..382e70df4d 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -264,6 +264,10 @@ pub type PrinterSource = std::sync::Arc Vec + Send + /// UI language) unless a test stands in. Called off the GUI thread. pub type OptionsSource = std::sync::Arc Vec + Send + Sync>; +/// What takes a print job in place of the system spooler: a test's stand-in, which gets the +/// print-ready PDF and the job and answers as the spooler would. Called off the GUI thread. +pub type Spooler = std::sync::Arc Result + Send + Sync>; + /// The system spooler's printers, as the desktop app's [`PrinterSource`]. pub fn system_printers() -> PrinterSource { std::sync::Arc::new(spool::printers) @@ -655,10 +659,11 @@ impl PdfCraftApp { } Some(printer) => { let mut job = self.print_draft.job(&name); - // CUPS is told which way the sheets run; Windows' print ticket already is. - if !cfg!(windows) { - let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (f64::from(p.width), f64::from(p.height))).collect(); - job.options.extend(print::layout(&sizes, &settings).ok().and_then(|sheets| print::cups_orientation(&sheets))); + // CUPS is told which way the sheets run (#2713), unless a driver choice already + // says; Windows' print ticket already is. + let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (f64::from(p.width), f64::from(p.height))).collect(); + if let Ok(sheets) = print::layout(&sizes, &settings) { + print::add_cups_orientation(&mut job.options, &sheets); } self.send_to_printer(printer, bytes, job) } @@ -674,6 +679,7 @@ impl PdfCraftApp { fn send_to_printer(&mut self, printer: String, bytes: Vec, job: spool::Job) -> bool { let (tx, rx) = std::sync::mpsc::channel(); let ctx = self.ctx.clone(); + let spooler = self.spooler.clone(); std::thread::spawn(move || { let repaint = || { if let Some(ctx) = &ctx { @@ -681,11 +687,15 @@ impl PdfCraftApp { } }; // The receiver may be gone (the app quit): nothing to report to then. - let result = spool::submit_with_progress(&bytes, &job, &mut |sent, sheets| { - let _ = tx.send(JobNews::Sent(sent, sheets)); - repaint(); - }); - let _ = tx.send(JobNews::Done(result.map_err(|e| e.to_string()))); + let result = match &spooler { + Some(submit) => submit(&bytes, &job), + None => spool::submit_with_progress(&bytes, &job, &mut |sent, sheets| { + let _ = tx.send(JobNews::Sent(sent, sheets)); + repaint(); + }) + .map_err(|e| e.to_string()), + }; + let _ = tx.send(JobNews::Done(result)); repaint(); }); self.print_tasks.jobs.push(PrintJob { printer, sheets: None, news: rx }); diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 81025e756f..5d0707ede8 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -215,6 +215,8 @@ fn cut_stack_dialog_previews_saves_and_refuses_duplex() { let _ = std::fs::remove_file(out); } +/// Save as PDF to a folder that doesn't exist keeps the Print dialog open, with its settings, +/// and says why. #[test] fn a_failed_save_as_pdf_keeps_the_dialog_open() { let mut h = harness(); @@ -232,6 +234,67 @@ fn a_failed_save_as_pdf_keeps_the_dialog_open() { assert!(toast.contains("Could not save"), "the user is told why: {toast:?}"); } +/// #2713, #3739: Print to a CUPS queue tells it when every sheet runs landscape, so a +/// landscape sheet (an A4 landscape page, or a 6 × 4 label, laid out on Letter) is turned onto +/// the portrait paper instead of printed unrotated at 100% and cut off at the paper's edge. A +/// driver choice for the orientation stands and isn't sent twice. +#[test] +fn printing_landscape_sheets_tells_cups_to_turn_them() { + use pdfcraft_engine::print::spool; + use std::sync::{Arc, Mutex}; + let s = pdfcraft_engine::Session::new(); + let bytes = s.create_blank(842.0, 595.0, 2).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("landscape.pdf", None, bytes.to_vec()).unwrap(); + app + }); + h.run_steps(4); + let jobs: Arc>> = Arc::default(); + let sent = Arc::clone(&jobs); + h.state_mut().spooler = Some(Arc::new(move |_pdf: &[u8], job: &spool::Job| { + sent.lock().unwrap().push(job.clone()); + Ok(String::new()) + })); + // Print to the queue; `choose` sets the driver's choices once the dialog has its printer. + let print = |h: &mut Harness<'static, PdfCraftApp>, choose: &dyn Fn(&mut pdfcraft_ui_egui::PrintDraft)| -> spool::Job { + let before = jobs.lock().unwrap().len(); + assert!(h.state_mut().execute("print.dialog")); + h.run_steps(3); + h.state_mut().print_draft.printer = Some("Test_Queue".into()); + h.run_steps(2); + choose(&mut h.state_mut().print_draft); + h.run_steps(1); + h.get_by(|n| n.label().as_deref() == Some("Print") && n.role() == Role::Button).click(); + for _ in 0..400 { + h.run_steps(1); + if jobs.lock().unwrap().len() > before { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + jobs.lock().unwrap().last().cloned().expect("the job reached the spooler") + }; + let orientation = |job: &spool::Job| job.options.iter().filter(|(k, _)| k == "orientation-requested").map(|(_, v)| v.clone()).collect::>(); + // Fit on Letter with Auto orientation lays both pages out on landscape sheets. + let job = print(&mut h, &|_| {}); + assert_eq!(orientation(&job), if cfg!(windows) { vec![] } else { vec!["4".to_string()] }, "{:?}", job.options); + // A driver choice for the orientation goes instead, once. + let job = print(&mut h, &|d| { + let choice = spool::PrinterOption { + key: "orientation-requested".into(), + label: "Orientation".into(), + group: "General".into(), + choices: vec![("4".into(), "Landscape".into()), ("5".into(), "Reverse landscape".into())], + default: "4".into(), + }; + d.driver_options = Some(("Test_Queue".into(), vec![choice])); + d.driver_choices.insert("orientation-requested".into(), "5".into()); + }); + assert_eq!(orientation(&job), ["5"], "{:?}", job.options); +} + /// Properties… shows the printer driver's own options (CUPS) and sends only those changed. #[test] fn print_dialog_shows_the_printer_driver_options() { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a042c2d642..e3319a5fb6 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8144,8 +8144,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/automation/tests/it/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/it/print.rs::architectural_page_can_be_matched_at_100_percent", "crates/print/src/tests.rs::landscape_sheets_ask_cups_to_turn_the_page"] -notes = "auto portrait/landscape. CUPS jobs carry orientation-requested=4 when every sheet is landscape, so a landscape sheet fills its portrait paper instead of being shrunk into it (#2713); a job that mixes orientations is left to the spooler, and Windows sets orientation in its own print ticket. The CUPS option is checked against Apple's filters and one physical printer, not a test that runs a spooler." +evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/automation/tests/it/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/it/print.rs::architectural_page_can_be_matched_at_100_percent", "crates/print/src/tests.rs::landscape_sheets_ask_cups_to_turn_the_page", "crates/print/src/tests.rs::a_job_that_names_its_orientation_keeps_it", "crates/automation/src/printing.rs::printing_landscape_sheets_to_a_printer_asks_cups_to_turn_them", "crates/ui-egui/tests/it/print.rs::printing_landscape_sheets_tells_cups_to_turn_them"] +notes = "auto portrait/landscape. CUPS jobs carry orientation-requested=4 when every sheet is landscape, so a landscape sheet fills its portrait paper instead of being shrunk into it or, with the Mac's filters, printed unrotated and cut off at the paper's edge (#2713, #3739); an orientation the job already names (a driver choice, or doc_print's options) stands, and a job that mixes orientations is left to the spooler, so its landscape sheets still print unturned. Windows sets orientation in its own print ticket. The CUPS option is checked against Apple's filters (cupsfilter on an AirPrint PPD) and one physical printer; the tests check the job both submission paths send, not a spooler's output." [[feature]] id = "print.comments-and-forms" From a4c8621fec11806628ddbffebe28a5f8d8bff407 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:09:35 -0400 Subject: [PATCH 010/101] M10: the orientation note says who checked the physical printer, and that no paper was printed for the filter checks --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e3319a5fb6..4ae029fbd1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8145,7 +8145,7 @@ status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] evidence = ["crates/print/src/tests.rs::size_modes", "crates/print/src/tests.rs::architectural_sheets_preserve_full_scale", "crates/automation/tests/it/automation.rs::architectural_and_iso_papers_through_tools", "crates/ui-egui/tests/it/print.rs::architectural_page_can_be_matched_at_100_percent", "crates/print/src/tests.rs::landscape_sheets_ask_cups_to_turn_the_page", "crates/print/src/tests.rs::a_job_that_names_its_orientation_keeps_it", "crates/automation/src/printing.rs::printing_landscape_sheets_to_a_printer_asks_cups_to_turn_them", "crates/ui-egui/tests/it/print.rs::printing_landscape_sheets_tells_cups_to_turn_them"] -notes = "auto portrait/landscape. CUPS jobs carry orientation-requested=4 when every sheet is landscape, so a landscape sheet fills its portrait paper instead of being shrunk into it or, with the Mac's filters, printed unrotated and cut off at the paper's edge (#2713, #3739); an orientation the job already names (a driver choice, or doc_print's options) stands, and a job that mixes orientations is left to the spooler, so its landscape sheets still print unturned. Windows sets orientation in its own print ticket. The CUPS option is checked against Apple's filters (cupsfilter on an AirPrint PPD) and one physical printer; the tests check the job both submission paths send, not a spooler's output." +notes = "auto portrait/landscape. CUPS jobs carry orientation-requested=4 when every sheet is landscape, so a landscape sheet fills its portrait paper instead of being shrunk into it or, with the Mac's filters, printed unrotated and cut off at the paper's edge (#2713, #3739); an orientation the job already names (a driver choice, or doc_print's options) stands, and a job that mixes orientations is left to the spooler, so its landscape sheets still print unturned. Windows sets orientation in its own print ticket. The CUPS option was checked against Apple's filters (cupsfilter on an AirPrint PPD, no paper printed), and #3068's author printed one landscape page with it on a physical printer on macOS; the tests check the job both submission paths send, not a spooler's output, and physical output is otherwise unvalidated." [[feature]] id = "print.comments-and-forms" From 97790abca1753f329de688d2a968bad66767c3d5 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:14:39 -0400 Subject: [PATCH 011/101] M10: leave the Save as PDF test as main has it (out of this PR's scope) --- crates/ui-egui/tests/it/print.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 5d0707ede8..8e04cf2d82 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -215,8 +215,6 @@ fn cut_stack_dialog_previews_saves_and_refuses_duplex() { let _ = std::fs::remove_file(out); } -/// Save as PDF to a folder that doesn't exist keeps the Print dialog open, with its settings, -/// and says why. #[test] fn a_failed_save_as_pdf_keeps_the_dialog_open() { let mut h = harness(); From af250960cf4c8e19c8bcc61c0e0f85e73dd2f7fc Mon Sep 17 00:00:00 2001 From: Livio Oswald <125505497+Lcoding15@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:31:32 +0100 Subject: [PATCH 012/101] fix(ui): clear stale search point on page jump --- crates/ui-egui/src/canvas.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index abea0ca018..fe719051e9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1395,6 +1395,7 @@ impl DocView { // Partial wheel motion belongs to the old place, and so does a find step's match. self.reset_coarse_state(); self.find_hold = None; + self.goto_point = None; self.goto = Some((page, 0.0)); self.current = page; } From afcbecdcb99afc16b0b7233bc23cf99d91ba83c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:26:36 +0000 Subject: [PATCH 013/101] Search: a zoom still easing keeps the match while the pointer is away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit egui spreads a ⌘/Ctrl-wheel notch over about a quarter of a second. The hold on a find step's match was let go in the first frame the pointer was off the page view, though the zoom was still easing; once the pointer came back, the rest of the zoom anchored on it, and the match ended up to 136 points off the middle at 60 frames a second. The hold is let go only once no zoom is coming in (CodeRabbit on #4139). The test runs at 60 frames a second: kittest's default quarter-second frames end the zoom before the pointer leaves, which is why an earlier test of this did not fail. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR --- crates/ui-egui/src/canvas.rs | 5 ++- crates/ui-egui/tests/it/search_position.rs | 46 +++++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 79d2a7f00c..fc204e380d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2722,8 +2722,11 @@ fn pinch_zoom(ui: &egui::Ui, view: &mut DocView, zoom_delta: f32, navigation_rec Some(p) => view.zoom_at(z, p), None => view.set_zoom(z), } - } else if view.goto_point.is_none() { + } else if view.goto_point.is_none() && zoom_delta == 1.0 { // The find step has been laid out and no zoom is easing on: zoom at the pointer again. + // egui spreads a ⌘/Ctrl-wheel notch over about a quarter of a second; while the pointer + // is off the page view that zoom waits, and the hold must outlast it, or the rest of it + // anchors on the pointer once it is back and the match drifts off the middle. view.find_hold = None; } } diff --git a/crates/ui-egui/tests/it/search_position.rs b/crates/ui-egui/tests/it/search_position.rs index 23f160e7b7..688b9f4299 100644 --- a/crates/ui-egui/tests/it/search_position.rs +++ b/crates/ui-egui/tests/it/search_position.rs @@ -70,7 +70,12 @@ fn needles() -> usize { /// The app with the fixture open and a search for the needle that has found every match. fn harness() -> Harness<'static, PdfCraftApp> { - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| { + harness_dt(1.0 / 4.0) +} + +/// [`harness`] with frames `dt` seconds apart. +fn harness_dt(dt: f32) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(dt).build_eframe(|_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.open_bytes("search.pdf", None, fixture()).expect("opens"); @@ -238,6 +243,45 @@ fn a_step_during_a_wheel_zoom_keeps_the_match_on_screen() { } } +/// CodeRabbit on #4139: egui spreads a ⌘/Ctrl-wheel notch over about a quarter of a second. A +/// step to a match while that zoom is easing, the pointer then leaving the page view for a few +/// frames and coming back before the zoom has ended: the rest of the zoom still keeps the match in +/// the middle, rather than anchoring on the pointer once it is back. Frames a 60th of a second +/// apart, as on screen; the default quarter-second frames end the zoom before the pointer leaves. +#[test] +fn a_step_during_a_wheel_zoom_keeps_the_match_when_the_pointer_leaves_and_returns() { + for (dy, out) in [(120.0, 2), (240.0, 4), (480.0, 3)] { + let mut h = harness_dt(1.0 / 60.0); + h.state_mut().set_option("animation_effects", "on").unwrap(); + h.state_mut().views[0].set_zoom(3.0); + settle(&mut h); + let w = h.state().views[0].viewport_rect(); + // The pointer well away from the middle, where the zoom would otherwise be anchored. + let corner = w.min + egui::vec2(80.0, 80.0); + h.hover_at(corner); + h.run_steps(1); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, dy), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::COMMAND, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(2); + h.state_mut().views[0].find_step(true); + h.run_steps(1); + let during = h.state().views[0].zoom; + // Off the page view (over the toolbar above it) while the zoom eases, then back. + h.hover_at(egui::pos2(corner.x, (w.min.y - 20.0).max(1.0))); + h.run_steps(out); + h.hover_at(corner); + h.run_steps(30); + settle(&mut h); + assert!((h.state().views[0].zoom - during).abs() > 1e-3, "{dy}: the zoom was still easing when the pointer left"); + assert_match_shown(&h, &format!("pointer out for {out} frames during a wheel zoom ({dy})")); + } +} + /// Single page and two-up, scrolling or a page (spread) at a time: each step shows its match. #[test] fn every_page_display_shows_the_match_after_zooming() { From ed7ad9b9fd9f72402a4975197bc3c75469533220 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:37:39 -0400 Subject: [PATCH 014/101] M10: an orientation the job already names stands in any case, as CUPS reads option names --- crates/print/src/sheets.rs | 7 ++++--- crates/print/src/tests.rs | 11 +++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index fcfe7490af..c1685ee8eb 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -17,10 +17,11 @@ pub fn cups_orientation(sheets: &[Sheet]) -> Option<(String, String)> { /// Add [`cups_orientation`] to a CUPS job's `options`, for the Print dialog's jobs and /// `doc_print`'s alike, unless they already name an orientation (a driver choice, or one a -/// caller passed): `lp` must not be given the option twice. Nothing on Windows, whose print -/// ticket sets each sheet's orientation itself. +/// caller passed, in any case: CUPS reads option names without regard to case): `lp` must not be +/// given the option twice. Nothing on Windows, whose print ticket sets each sheet's orientation +/// itself. pub fn add_cups_orientation(options: &mut Vec<(String, String)>, sheets: &[Sheet]) { - if cfg!(windows) || options.iter().any(|(k, _)| k == "orientation-requested") { + if cfg!(windows) || options.iter().any(|(k, _)| k.eq_ignore_ascii_case("orientation-requested")) { return; } options.extend(cups_orientation(sheets)); diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index c15fc37254..f4751889e2 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -549,10 +549,13 @@ fn a_job_that_names_its_orientation_keeps_it() { // Added again, it isn't doubled. add_cups_orientation(&mut options, &landscape); assert_eq!(options, turned); - // A choice already made stands. - let mut chosen = vec![("orientation-requested".to_string(), "5".to_string())]; - add_cups_orientation(&mut chosen, &landscape); - assert_eq!(chosen, [("orientation-requested".to_string(), "5".to_string())]); + // A choice already made stands, however its name is cased: CUPS reads option names without + // regard to case, so a second one would replace it. + for key in ["orientation-requested", "Orientation-Requested", "ORIENTATION-REQUESTED"] { + let mut chosen = vec![(key.to_string(), "5".to_string())]; + add_cups_orientation(&mut chosen, &landscape); + assert_eq!(chosen, [(key.to_string(), "5".to_string())], "{key}"); + } } #[cfg(all(unix, not(target_arch = "wasm32")))] From 518b5c9de979f749d9f90a77a7ecf1cecead0e92 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:23:13 -0400 Subject: [PATCH 015/101] M12: a PDF portfolio opens on its initial document and shows its files (#1652) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A portfolio (ISO 32000-2 §7.11.6) keeps its documents as embedded files; its pages are only a cover sheet for viewers without a file list. DocInfo::portfolio reads the collection dictionary (Table 153): the attachment /D names, decoded as the name-tree keys are, and /View. Opening one presents that PDF in a tab of its own, on the next frame so a nested portfolio waits for the reader, and the portfolio's tab shows its files in the Attachments panel even with the right panel closed (not with /View /H), giving the replaced panel back when the tab is left. doc_info reports the portfolio; organize.portfolio-view is partial. Fixes #1652 --- crates/automation/src/json.rs | 5 + crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/main.rs | 1 + crates/automation/tests/it/portfolio_info.rs | 52 +++++++ crates/render/src/inspect.rs | 6 +- crates/render/src/inspect/core.rs | 22 ++- crates/render/src/inspect/model.rs | 38 +++++ crates/render/src/inspect/portfolio_tests.rs | 50 +++++++ crates/render/src/lib.rs | 3 +- crates/ui-egui/src/app.rs | 4 + crates/ui-egui/src/app/files.rs | 76 ++++++++++ crates/ui-egui/src/lib.rs | 8 ++ crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/portfolio.rs | 140 +++++++++++++++++++ parity/acrobat-features.toml | 8 +- 15 files changed, 408 insertions(+), 8 deletions(-) create mode 100644 crates/automation/tests/it/portfolio_info.rs create mode 100644 crates/render/src/inspect/portfolio_tests.rs create mode 100644 crates/ui-egui/tests/it/portfolio.rs diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs index 9e93d4c347..db327bdbee 100644 --- a/crates/automation/src/json.rs +++ b/crates/automation/src/json.rs @@ -365,6 +365,11 @@ pub(crate) fn info(d: &Document) -> Value { "display": t.display, })).collect::>(), "attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::>(), + // A PDF portfolio (`/Collection`, ISO 32000-2 §7.11.6): its pages are a cover sheet and its + // documents are `attachments`; `initial` names the one presented first, null for the cover. + "portfolio": i.portfolio.as_ref().map(|p| json!({ + "initial": p.initial.and_then(|n| i.attachments.get(n)).map(|a| &a.name), "view": p.view.name(), + })), // `substitute` names the face standing in for a program the document does not embed, and // `substitution` says why. Both are null for an embedded font: nothing stands in. A null // `substitute` with a non-null `substitution` means no face was available at all, so diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 53c4582af9..8727d736fe 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -142,7 +142,7 @@ pub fn tools() -> Vec { .cmd("file.open") .with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])), t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])), - t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, the destination it opens at, attachments, fonts, security and repair notes.") + t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, the destination it opens at, attachments (and, for a PDF portfolio, the one it presents first), fonts, security and repair notes.") .ro() .cmd("file.properties") .with(schema(json!({ "doc": doc() }), &["doc"])), diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index b48b23bf0a..d262e4fa04 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -8,5 +8,6 @@ mod button_caption; mod form_bulk; mod hostile_args; mod optimize_content_boundaries; +mod portfolio_info; mod redact_after_stray_q_form; mod redact_text_after_q; diff --git a/crates/automation/tests/it/portfolio_info.rs b/crates/automation/tests/it/portfolio_info.rs new file mode 100644 index 0000000000..8da6310817 --- /dev/null +++ b/crates/automation/tests/it/portfolio_info.rs @@ -0,0 +1,52 @@ +//! `doc_info` reports a PDF portfolio (`/Collection`, ISO 32000-2 §7.11.6): the attachment it +//! presents first and how its file list first shows. + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +fn tool(a: &mut Automation, name: &str, args: Value) -> Value { + match a.call(name, &args).unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected JSON: {other:?}"), + } +} + +/// A one-page portfolio of `cover.txt` and `form.pdf` with `collection` as its `/Collection` +/// entries, or none at all. +fn portfolio(collection: Option<&str>) -> Vec { + let collection = collection.map(|c| format!("/Collection << /Type /Collection {c} >>")).unwrap_or_default(); + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R {collection} /Names << /EmbeddedFiles << /Names [(cover.txt) 5 0 R (form.pdf) 7 0 R] >> >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj +4 0 obj << /Type /EmbeddedFile /Length 5 >> stream +hello +endstream endobj +5 0 obj << /Type /Filespec /F (cover.txt) /UF (cover.txt) /EF << /F 4 0 R >> >> endobj +6 0 obj << /Type /EmbeddedFile /Length 8 >> stream +%PDF-1.7 +endstream endobj +7 0 obj << /Type /Filespec /F (form.pdf) /UF (form.pdf) /EF << /F 6 0 R >> >> endobj +trailer << /Root 1 0 R >> +%%EOF" + ) + .into_bytes() +} + +#[test] +fn doc_info_names_the_document_a_portfolio_presents_first() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("tile.pdf"), portfolio(Some("/D (form.pdf) /View /T"))).unwrap(); + std::fs::write(dir.path().join("cover.pdf"), portfolio(Some(""))).unwrap(); + std::fs::write(dir.path().join("plain.pdf"), portfolio(None)).unwrap(); + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + let mut info = |path: &str| { + let doc = tool(&mut auto, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap(); + tool(&mut auto, "doc_info", json!({ "doc": doc }))["portfolio"].clone() + }; + assert_eq!(info("tile.pdf"), json!({ "initial": "form.pdf", "view": "tile" })); + // Without `/D` the cover sheet is the initial document. + assert_eq!(info("cover.pdf"), json!({ "initial": null, "view": "details" })); + assert_eq!(info("plain.pdf"), Value::Null); +} diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 0cf287b3f1..3056dfd74a 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -37,7 +37,7 @@ pub use core::{check_pages, inspect, pretty_date}; use core::{inspect_structure, spend}; pub use model::{ Annotation, Attachment, AttachmentSource, DestView, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, - PageTransition, RemoteDestination, TransitionStyle, Xfa, link_replies, thread_tops, + PageTransition, Portfolio, PortfolioView, RemoteDestination, TransitionStyle, Xfa, link_replies, thread_tops, }; #[cfg(test)] use navigation::{OUTLINE_DEPTH, OUTLINE_WINDOW}; @@ -76,6 +76,8 @@ pub struct DocInfo { pub layer_groups: Vec>, pub fonts: Vec, pub attachments: Vec, + /// The document is a PDF portfolio (`/Collection`): its documents are `attachments`. + pub portfolio: Option, /// Pages with a presentation transition (`/Trans`) or display time (`/Dur`), in page order. pub transitions: Vec, /// Where the document opens (`/OpenAction`): the (0-based page, view) of its destination, @@ -85,5 +87,7 @@ pub struct DocInfo { pub warnings: Vec, } +#[cfg(test)] +mod portfolio_tests; #[cfg(test)] mod tests; diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs index c190dc565b..4602bc2e6b 100644 --- a/crates/render/src/inspect/core.rs +++ b/crates/render/src/inspect/core.rs @@ -1,5 +1,5 @@ use super::annotations::MAX_LINK_ATTACHMENT_TEXT_BYTES; -use super::model::{AttachmentSource, PageInfo, Xfa}; +use super::model::{Attachment, AttachmentSource, PageInfo, Portfolio, PortfolioView, Xfa}; use super::navigation::text_string; use super::{ Arc, AssertUnwindSafe, DecryptionError, Dictionary, DocInfo, Document, HashMap, HashSet, LazyStructure, LoadOptions, LoadPdfError, Object, @@ -260,6 +260,7 @@ impl<'a> Inspector<'a> { } info.has_javascript |= names.get(b"JavaScript").is_ok(); } + info.portfolio = catalog.get(b"Collection").ok().and_then(|o| self.dict(o)).map(|c| self.portfolio(c, &info.attachments)); if let Ok(action) = catalog.get(b"OpenAction") && let Some(dict) = self.dict(action) && dict.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) == Some(&b"JavaScript"[..]) @@ -269,6 +270,25 @@ impl<'a> Inspector<'a> { info.open_dest = catalog.get(b"OpenAction").ok().and_then(|o| self.open_dest(o)); } + /// A portfolio's collection dictionary (Table 153). `/D` names the initial document's key in + /// the `/EmbeddedFiles` name tree, decoded as the keys are. + fn portfolio(&self, collection: &Dictionary, attachments: &[Attachment]) -> Portfolio { + let view = match self.name(collection, b"View").as_deref() { + Some("T") => PortfolioView::Tile, + Some("H") => PortfolioView::Hidden, + Some("C") => PortfolioView::Custom, + _ => PortfolioView::Details, + }; + let initial = match collection.get(b"D").map(|o| self.resolve(o)) { + Ok(Object::String(bytes, _)) => { + let name = text_string(bytes); + attachments.iter().position(|a| matches!(&a.source, AttachmentSource::Document { key } if *key == name)) + } + _ => None, + }; + Portfolio { initial, view } + } + pub(super) fn resolve(&self, o: &'a Object) -> &'a Object { self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o) } diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index 490897b60f..625a057973 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -519,6 +519,44 @@ pub enum AttachmentSource { Annotation { page: usize, index: usize }, } +/// A PDF portfolio: a catalog with a `/Collection` (ISO 32000-2 §7.11.6, Table 153). Its pages +/// are a cover sheet for viewers that show no file list; its documents are its attachments. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Portfolio { + /// The document to present first (`/D`): the index in `DocInfo::attachments` of the + /// `/EmbeddedFiles` entry it names. `None` without `/D` (the portfolio's own pages come + /// first) or when it names no listed attachment. + pub initial: Option, + /// How the file list first shows (`/View`). + pub view: PortfolioView, +} + +/// A portfolio's initial view (`/Collection /View`, Table 153). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PortfolioView { + /// `D`, the default: the files with their details. + #[default] + Details, + /// `T`: the files as tiles. + Tile, + /// `H`: the file list starts hidden, shown when the reader asks for it. + Hidden, + /// `C`: a custom navigator (`/Navigator`). + Custom, +} + +impl PortfolioView { + /// The view's name in `doc_info`. + pub fn name(self) -> &'static str { + match self { + Self::Details => "details", + Self::Tile => "tile", + Self::Hidden => "hidden", + Self::Custom => "custom", + } + } +} + /// A font used by the document (Document Properties ▸ Fonts). #[derive(Clone, Debug, PartialEq, Eq)] pub struct FontInfo { diff --git a/crates/render/src/inspect/portfolio_tests.rs b/crates/render/src/inspect/portfolio_tests.rs new file mode 100644 index 0000000000..27c085cfae --- /dev/null +++ b/crates/render/src/inspect/portfolio_tests.rs @@ -0,0 +1,50 @@ +//! PDF portfolios (`/Collection`, ISO 32000-2 §7.11.6): the attachment a portfolio presents +//! first, and how its file list first shows. + +use super::{Arc, Portfolio, PortfolioView, inspect}; + +/// A one-page portfolio of `a.txt` and `b.pdf`, keyed in UTF-16 as portfolio writers key them, +/// with `collection` as its `/Collection` entries (`None`: not a portfolio). +fn portfolio(collection: Option<&str>) -> Vec { + let collection = collection.map(|c| format!("/Collection << /Type /Collection {c} >>")).unwrap_or_default(); + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R {collection} /Names << /EmbeddedFiles << /Names [ 5 0 R 7 0 R] >> >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj +4 0 obj << /Type /EmbeddedFile /Length 5 >> stream +hello +endstream endobj +5 0 obj << /Type /Filespec /F (a.txt) /UF (a.txt) /EF << /F 4 0 R >> >> endobj +6 0 obj << /Type /EmbeddedFile /Length 8 >> stream +%PDF-1.7 +endstream endobj +7 0 obj << /Type /Filespec /F (b.pdf) /UF (b.pdf) /EF << /F 6 0 R >> >> endobj +trailer << /Root 1 0 R >> +%%EOF" + ) + .into_bytes() +} + +fn read(collection: Option<&str>) -> Option { + inspect(Arc::new(portfolio(collection)), None).expect("opens").portfolio +} + +#[test] +fn a_portfolio_presents_the_attachment_its_d_names() { + // `/D` names `b.pdf` in the keys' own UTF-16, then as a literal string: both decode as the + // keys do. + let tile = Some(Portfolio { initial: Some(1), view: PortfolioView::Tile }); + assert_eq!(read(Some("/D /View /T")), tile); + assert_eq!(read(Some("/D (b.pdf) /View /T")), tile); + // Without `/D` the portfolio's own pages come first, and a `/D` naming no file presents none. + assert_eq!(read(Some("")), Some(Portfolio { initial: None, view: PortfolioView::Details })); + assert_eq!(read(Some("/D (c.pdf) /View /D")), Some(Portfolio { initial: None, view: PortfolioView::Details })); + // `/View /H` keeps the file list hidden; `/C` is a custom navigator; an unknown view is + // the default, details. + assert_eq!(read(Some("/D (a.txt) /View /H")), Some(Portfolio { initial: Some(0), view: PortfolioView::Hidden })); + assert_eq!(read(Some("/View /C")).map(|p| p.view), Some(PortfolioView::Custom)); + assert_eq!(read(Some("/View /Bogus /D 7")), Some(Portfolio { initial: None, view: PortfolioView::Details })); + // The same attachments without a `/Collection` are no portfolio. + assert_eq!(read(None), None); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 954823ef09..e92e2c9029 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -18,7 +18,8 @@ pub mod transition; pub use inspect::{ Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, - PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, link_replies, pretty_date, thread_tops, + PageTransition, Portfolio, PortfolioView, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, link_replies, + pretty_date, thread_tops, }; pub use pixels::Pixels; pub use raster::{ diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 23bee9babc..9403f33c71 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -52,6 +52,9 @@ impl PdfCraftApp { right_before_read: None, comments_panel_closed: false, right_panel_closed: false, + portfolio_initial: None, + portfolio_seen: None, + portfolio_panel: None, quick_tool: QuickTool::Select, space_hand: None, full_screen_tool: None, @@ -464,6 +467,7 @@ impl eframe::App for PdfCraftApp { OsEvent::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), } } + self.present_portfolios(); if let Some((_, id)) = self.active_ids() { let out = self.session.take_js_output(id); self.handle_js(id, out); diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 3c68dc5619..d5550cdffc 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -257,6 +257,12 @@ impl PdfCraftApp { ) -> Result { let id = self.session.commit_open(prepared); self.password_prompt = None; + // A tab opening in front leaves a portfolio's tab, whose file list gives the panel back: + // the document a portfolio presents opens with the panels any other would. + if activate { + self.leave_portfolio(); + } + let right_before = self.right; // The reading-colours preference is app-wide: a newly opened document starts with it. // Idempotent: the engine returns `false` when the document has the setting already. self.session.set_reading_colors(id, self.reading_colors); @@ -267,6 +273,13 @@ impl PdfCraftApp { // the user closed stays closed (#1590). let opening_mode = self.mode_override.unwrap_or(self.default_mode); let initial = doc.initial_view(); + // A portfolio presents the document its `/D` names (ISO 32000-2 Table 153), when that is a PDF. + let portfolio_initial = doc + .info + .portfolio + .as_ref() + .and_then(|p| p.initial) + .filter(|&i| doc.info.attachments.get(i).is_some_and(|a| a.name.to_lowercase().ends_with(".pdf"))); if activate && self.right.is_none() && !self.right_panel_closed && opening_mode != Mode::Read { self.right = if !doc.info.annotations.is_empty() { Some(RightPanel::Comments) @@ -297,6 +310,16 @@ impl PdfCraftApp { if activate && initial.full_screen { self.pending_full_screen = Some(id); } + if activate { + // A portfolio's tab shows its files from the start, in place of the panel there was + // before it opened, whichever panel its page mode asked for. + if opening_mode != Mode::Read && self.lists_files(id) { + self.show_portfolio_files(id, right_before); + } + if let Some(index) = portfolio_initial { + self.portfolio_initial = Some((id, index)); + } + } if activate && self.left_open && self.left == LeftPanel::Tool("organize") @@ -398,6 +421,59 @@ impl PdfCraftApp { } } + /// A PDF portfolio (ISO 32000-2 §7.11.6) shows its documents, not the cover sheet that its + /// pages are. The document a portfolio that just opened presents first (`/D`) opens in a + /// tab of its own, and a portfolio's tab shows its file list, the Attachments panel, even + /// with the right panel closed, unless the portfolio keeps the list hidden (`/View /H`). + /// Leaving the tab gives back the panel the list replaced, unless the reader changed panels + /// meanwhile. Called every frame. + pub(crate) fn present_portfolios(&mut self) { + if let Some((doc, index)) = self.portfolio_initial.take() { + self.attachment_action(doc, index, true); + // A portfolio inside the portfolio waits until the reader opens it, so a nested + // file can't open tab after tab on its own. + self.portfolio_initial = None; + } + let active = self.active_ids().map(|(_, id)| id); + if active == self.portfolio_seen { + return; + } + self.leave_portfolio(); + if let Some(id) = active + && self.mode != Mode::Read + && self.right != Some(RightPanel::Attachments) + && self.lists_files(id) + { + self.show_portfolio_files(id, self.right); + } + self.portfolio_seen = active; + } + + /// The document is a portfolio whose tab shows its file list: one that doesn't keep the + /// list hidden (`/View /H`). + fn lists_files(&self, id: DocId) -> bool { + self.session.get(id).and_then(|d| d.info.portfolio.as_ref()).is_some_and(|p| p.view != pdfcraft_render::PortfolioView::Hidden) + } + + /// Show portfolio `id`'s file list in place of `before`, the panel given back when its tab + /// is left. + fn show_portfolio_files(&mut self, id: DocId, before: Option) { + self.portfolio_seen = Some(id); + self.portfolio_panel = Some(before); + self.right = Some(RightPanel::Attachments); + } + + /// Leave a portfolio's tab: the panel its file list replaced comes back, unless the reader + /// changed panels meanwhile. + fn leave_portfolio(&mut self) { + self.portfolio_seen = None; + if let Some(before) = self.portfolio_panel.take() + && self.right == Some(RightPanel::Attachments) + { + self.right = before; + } + } + /// [`Self::attachment_action`] for the file of the attachment comment at (page, index). pub fn comment_attachment_action(&mut self, doc: DocId, page: usize, index: usize, open: bool) { let source = pdfcraft_render::AttachmentSource::Annotation { page, index }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8a8c51775a..ac0f97f7fd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -268,6 +268,14 @@ pub struct PdfCraftApp { /// The user closed the right panel, so opening a document leaves it closed instead of /// showing its comments or bookmarks (#1590). Remembered across restarts. pub right_panel_closed: bool, + /// A portfolio that just opened and the attachment it presents first (`/Collection /D`), + /// opened by [`Self::present_portfolios`] on the next frame. + portfolio_initial: Option<(DocId, usize)>, + /// The document whose tab [`Self::present_portfolios`] last saw active. + portfolio_seen: Option, + /// A portfolio's file list is showing in place of this panel (`None`: the panel was + /// closed), given back when its tab is left. + portfolio_panel: Option>, pub quick_tool: QuickTool, /// The tool to go back to when Space, held for a temporary Hand, is released. space_hand: Option, diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index a5c3192358..8ac54332bb 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -60,6 +60,7 @@ mod panel_snapshots; mod pdf_links; mod pickers; mod pointer; +mod portfolio; mod preferences_layout; mod prepare; mod print; diff --git a/crates/ui-egui/tests/it/portfolio.rs b/crates/ui-egui/tests/it/portfolio.rs new file mode 100644 index 0000000000..b20b7ef6c6 --- /dev/null +++ b/crates/ui-egui/tests/it/portfolio.rs @@ -0,0 +1,140 @@ +//! A PDF portfolio (`/Collection`, ISO 32000-2 §7.11.6) opens on its documents, not on the cover +//! sheet its pages are (#1652): the document `/D` names opens in a tab of its own, and the +//! portfolio's tab shows its file list even with the right panel closed. + +use egui_kittest::Harness; +use pdfcraft_ui_egui::{PdfCraftApp, RightPanel}; + +/// A one-page PDF. +const PAGE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// A one-page portfolio of `files` (name, bytes), with `catalog` (its `/Collection` and any other +/// entries) in its catalog. +fn portfolio(catalog: &str, files: &[(&str, &[u8])]) -> Vec { + let names: String = files.iter().enumerate().map(|(i, (name, _))| format!("({name}) {} 0 R ", 5 + 2 * i)).collect(); + let mut objs: Vec> = vec![ + format!("<< /Type /Catalog /Pages 2 0 R {catalog} /Names << /EmbeddedFiles << /Names [{names}] >> >> >>").into_bytes(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> >>".to_vec(), + ]; + for (i, (name, data)) in files.iter().enumerate() { + objs.push([format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat()); + objs.push(format!("<< /Type /Filespec /F ({name}) /UF ({name}) /EF << /F {} 0 R >> >>", 4 + 2 * i).into_bytes()); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// The app with `pdf` opened as `portfolio.pdf`, the reader keeping the right panel closed or not. +fn opened(pdf: Vec, panel_closed: bool) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.right_panel_closed = panel_closed; + app.open_bytes("portfolio.pdf", None, pdf.clone()).unwrap(); + app + }); + h.run_steps(4); + h +} + +/// The open tabs' names, and the active one's. +fn tabs(h: &Harness<'static, PdfCraftApp>) -> (Vec, Option) { + let s = h.state(); + let name = |i: usize| s.views.get(i).and_then(|v| s.session.get(v.id)).map(|d| d.name.clone()); + ((0..s.views.len()).filter_map(name).collect(), s.active.and_then(name)) +} + +/// Make tab `index` the active one. +fn select(h: &mut Harness<'static, PdfCraftApp>, index: usize) { + h.state_mut().active = Some(index); + h.run_steps(2); +} + +#[test] +fn a_portfolio_opens_its_initial_document_and_shows_its_files_with_the_panel_closed() { + let mut h = opened(portfolio("/Collection << /D (form.pdf) /View /T >>", &[("intro.pdf", PAGE), ("form.pdf", PAGE)]), true); + // The document `/D` names is presented, in a tab of its own, with the reader's own panels. + assert_eq!(tabs(&h), (vec!["portfolio.pdf".into(), "form.pdf".into()], Some("form.pdf".into()))); + assert_eq!(h.state().right, None); + // The portfolio's tab shows its files, without changing the reader's preference. + select(&mut h, 0); + assert_eq!(h.state().right, Some(RightPanel::Attachments)); + assert!(h.state().right_panel_closed); + // Leaving it gives back the closed panel; coming back shows the files again. + select(&mut h, 1); + assert_eq!(h.state().right, None); + select(&mut h, 0); + assert_eq!(h.state().right, Some(RightPanel::Attachments)); + // The reader can still close the list on the portfolio's tab. + h.state_mut().choose_right_panel(None); + h.run_steps(2); + assert_eq!(h.state().right, None); + // A panel the reader had open comes back once they leave the portfolio's tab. + select(&mut h, 1); + h.state_mut().choose_right_panel(Some(RightPanel::Bookmarks)); + select(&mut h, 0); + assert_eq!(h.state().right, Some(RightPanel::Attachments)); + select(&mut h, 1); + assert_eq!(h.state().right, Some(RightPanel::Bookmarks)); +} + +#[test] +fn the_initial_document_opens_without_the_portfolios_file_list() { + // The portfolio asks for the Attachments panel too (`/PageMode /UseAttachments`), and the + // reader keeps the right panel open: the document it presents opens with the panels any other + // would, not with a file list of its own (it has none). + let mut h = opened(portfolio("/PageMode /UseAttachments /Collection << /D (form.pdf) >>", &[("intro.pdf", PAGE), ("form.pdf", PAGE)]), false); + assert_eq!(tabs(&h).1.as_deref(), Some("form.pdf")); + assert_eq!(h.state().right, None); + select(&mut h, 0); + assert_eq!(h.state().right, Some(RightPanel::Attachments)); +} + +#[test] +fn a_portfolio_that_keeps_its_list_hidden_still_presents_its_initial_document() { + let mut h = opened(portfolio("/Collection << /D (form.pdf) /View /H >>", &[("intro.pdf", PAGE), ("form.pdf", PAGE)]), true); + assert_eq!(tabs(&h).1.as_deref(), Some("form.pdf")); + select(&mut h, 0); + assert_eq!(h.state().right, None, "/View /H: the list shows when the reader asks for it"); +} + +#[test] +fn a_portfolio_without_an_initial_document_shows_its_files_over_the_cover() { + // Without `/D` the cover is the initial document; a `/D` naming a file that isn't a PDF + // opens nothing either. + for collection in ["/Collection << /View /D >>", "/Collection << /D (notes.txt) >>"] { + let h = opened(portfolio(collection, &[("notes.txt", b"hello"), ("form.pdf", PAGE)]), true); + assert_eq!(tabs(&h), (vec!["portfolio.pdf".into()], Some("portfolio.pdf".into())), "{collection}"); + assert_eq!(h.state().right, Some(RightPanel::Attachments), "{collection}"); + } +} + +#[test] +fn a_portfolio_inside_the_initial_document_waits_for_the_reader() { + // The initial document is itself a portfolio presenting a third file: opening the outer + // one opens one tab, not a chain of them. + let inner = portfolio("/Collection << /D (deep.pdf) >>", &[("deep.pdf", PAGE)]); + let h = opened(portfolio("/Collection << /D (inner.pdf) >>", &[("inner.pdf", &inner)]), true); + assert_eq!(tabs(&h), (vec!["portfolio.pdf".into(), "inner.pdf".into()], Some("inner.pdf".into()))); + // Its own tab still shows its files. + assert_eq!(h.state().right, Some(RightPanel::Attachments)); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3b8dba920c..04c6b6c6d1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3444,11 +3444,11 @@ title = "View PDF Portfolios as a file list" area = "D" tier = "P2" milestone = "M12" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["doc_info"] +evidence = ["crates/render/src/inspect/portfolio_tests.rs::a_portfolio_presents_the_attachment_its_d_names", "crates/ui-egui/tests/it/portfolio.rs::a_portfolio_opens_its_initial_document_and_shows_its_files_with_the_panel_closed", "crates/ui-egui/tests/it/portfolio.rs::a_portfolio_that_keeps_its_list_hidden_still_presents_its_initial_document", "crates/ui-egui/tests/it/portfolio.rs::a_portfolio_without_an_initial_document_shows_its_files_over_the_cover", "crates/ui-egui/tests/it/portfolio.rs::a_portfolio_inside_the_initial_document_waits_for_the_reader", "crates/automation/tests/it/portfolio_info.rs::doc_info_names_the_document_a_portfolio_presents_first"] +notes = "A document with a /Collection (ISO 32000-2 §7.11.6) opens the PDF its /D names in a tab of its own, and the portfolio's tab shows its files in the Attachments panel even with the right panel closed (not with /View /H, which keeps the list for the reader to open), each PDF opening in a tab from there; doc_info reports the portfolio, its initial document and view. Not yet: the files in a details or tile view of their own with the /Schema columns, /Sort and thumbnails, /Folders, previewing a file inside the portfolio's window, and saving edits back into the portfolio" [[feature]] id = "organize.portfolio-extract" From 751720732a227f94fbbd5e26956c9a5950d7e60b Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:57:49 -0400 Subject: [PATCH 016/101] M12: the portfolio test's initial document has a comment, so it pins that document opening on its own panels (#1652) --- crates/ui-egui/tests/it/portfolio.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/tests/it/portfolio.rs b/crates/ui-egui/tests/it/portfolio.rs index b20b7ef6c6..615363bf26 100644 --- a/crates/ui-egui/tests/it/portfolio.rs +++ b/crates/ui-egui/tests/it/portfolio.rs @@ -13,6 +13,15 @@ const PAGE: &[u8] = b"%PDF-1.7 trailer << /Root 1 0 R >> %%EOF"; +/// A one-page PDF with a comment on its page. +const NOTED: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /Contents (Sign here) /T (HR) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + /// A one-page portfolio of `files` (name, bytes), with `catalog` (its `/Collection` and any other /// entries) in its catalog. fn portfolio(catalog: &str, files: &[(&str, &[u8])]) -> Vec { @@ -101,10 +110,10 @@ fn a_portfolio_opens_its_initial_document_and_shows_its_files_with_the_panel_clo fn the_initial_document_opens_without_the_portfolios_file_list() { // The portfolio asks for the Attachments panel too (`/PageMode /UseAttachments`), and the // reader keeps the right panel open: the document it presents opens with the panels any other - // would, not with a file list of its own (it has none). - let mut h = opened(portfolio("/PageMode /UseAttachments /Collection << /D (form.pdf) >>", &[("intro.pdf", PAGE), ("form.pdf", PAGE)]), false); + // would, here on its comments, not with the portfolio's file list. + let mut h = opened(portfolio("/PageMode /UseAttachments /Collection << /D (form.pdf) >>", &[("intro.pdf", PAGE), ("form.pdf", NOTED)]), false); assert_eq!(tabs(&h).1.as_deref(), Some("form.pdf")); - assert_eq!(h.state().right, None); + assert_eq!(h.state().right, Some(RightPanel::Comments)); select(&mut h, 0); assert_eq!(h.state().right, Some(RightPanel::Attachments)); } From d9127da2e0a83881cdb23fbd38b2933d0d2e306f Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:44:27 -0400 Subject: [PATCH 017/101] M12: a portfolio's initial document opens only while the portfolio is still in front; doc comments for the portfolio code and tests (#1652) --- crates/automation/tests/it/portfolio_info.rs | 2 ++ crates/render/src/inspect/core.rs | 3 +++ crates/render/src/inspect/portfolio_tests.rs | 3 +++ crates/ui-egui/src/app/files.rs | 12 ++++++--- crates/ui-egui/src/lib.rs | 3 ++- crates/ui-egui/tests/it/portfolio.rs | 27 ++++++++++++++++++++ 6 files changed, 45 insertions(+), 5 deletions(-) diff --git a/crates/automation/tests/it/portfolio_info.rs b/crates/automation/tests/it/portfolio_info.rs index 8da6310817..750a3e8840 100644 --- a/crates/automation/tests/it/portfolio_info.rs +++ b/crates/automation/tests/it/portfolio_info.rs @@ -4,6 +4,7 @@ use pdfcraft_automation::{Automation, Content}; use serde_json::{Value, json}; +/// Call tool `name` and return its JSON result. fn tool(a: &mut Automation, name: &str, args: Value) -> Value { match a.call(name, &args).unwrap().remove(0) { Content::Json(value) => value, @@ -34,6 +35,7 @@ trailer << /Root 1 0 R >> .into_bytes() } +/// `doc_info` names a portfolio's initial document and view, and reports none for a plain file. #[test] fn doc_info_names_the_document_a_portfolio_presents_first() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs index 4602bc2e6b..fff53dca91 100644 --- a/crates/render/src/inspect/core.rs +++ b/crates/render/src/inspect/core.rs @@ -161,6 +161,8 @@ impl<'a> Inspector<'a> { me } + /// Read the document's catalog and pages into `info`: metadata, outline, fields, layers, + /// fonts, attachments, a portfolio and where it opens. pub(super) fn fill(&self, info: &mut DocInfo) { // lopdf drops /Encrypt from the trailer once it has decrypted the file. info.encrypted = self.doc.encrypted(); @@ -289,6 +291,7 @@ impl<'a> Inspector<'a> { Portfolio { initial, view } } + /// The object a reference points at, or `o` itself for a direct object or a broken reference. pub(super) fn resolve(&self, o: &'a Object) -> &'a Object { self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o) } diff --git a/crates/render/src/inspect/portfolio_tests.rs b/crates/render/src/inspect/portfolio_tests.rs index 27c085cfae..301a9dab73 100644 --- a/crates/render/src/inspect/portfolio_tests.rs +++ b/crates/render/src/inspect/portfolio_tests.rs @@ -26,10 +26,13 @@ trailer << /Root 1 0 R >> .into_bytes() } +/// The portfolio [`portfolio`] with `collection` reads as. fn read(collection: Option<&str>) -> Option { inspect(Arc::new(portfolio(collection)), None).expect("opens").portfolio } +/// `/D` picks the attachment presented first, matched as the keys decode, and `/View` how the +/// list first shows. #[test] fn a_portfolio_presents_the_attachment_its_d_names() { // `/D` names `b.pdf` in the keys' own UTF-16, then as a literal string: both decode as the diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index d5550cdffc..90dbf7025c 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -247,6 +247,8 @@ impl PdfCraftApp { self.finish_open(name, path, bytes.len(), prepared, activate).map(|_| ()) } + /// Admit a prepared document: its tab, the panels and view it opens with, a portfolio's file + /// list and initial document, the recent-files entry and what its scripts said on opening. fn finish_open( &mut self, name: &str, @@ -316,9 +318,9 @@ impl PdfCraftApp { if opening_mode != Mode::Read && self.lists_files(id) { self.show_portfolio_files(id, right_before); } - if let Some(index) = portfolio_initial { - self.portfolio_initial = Some((id, index)); - } + // A document opening in front of a portfolio that hasn't presented its initial + // document yet (files opened together) keeps the front. + self.portfolio_initial = portfolio_initial.map(|index| (id, index)); } if activate && self.left_open @@ -428,7 +430,9 @@ impl PdfCraftApp { /// Leaving the tab gives back the panel the list replaced, unless the reader changed panels /// meanwhile. Called every frame. pub(crate) fn present_portfolios(&mut self) { - if let Some((doc, index)) = self.portfolio_initial.take() { + if let Some((doc, index)) = self.portfolio_initial.take() + && self.active_ids().is_some_and(|(_, id)| id == doc) + { self.attachment_action(doc, index, true); // A portfolio inside the portfolio waits until the reader opens it, so a nested // file can't open tab after tab on its own. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ac0f97f7fd..f5a1ed5440 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -269,7 +269,8 @@ pub struct PdfCraftApp { /// showing its comments or bookmarks (#1590). Remembered across restarts. pub right_panel_closed: bool, /// A portfolio that just opened and the attachment it presents first (`/Collection /D`), - /// opened by [`Self::present_portfolios`] on the next frame. + /// opened by [`Self::present_portfolios`] on the next frame while the portfolio is still the + /// active tab. portfolio_initial: Option<(DocId, usize)>, /// The document whose tab [`Self::present_portfolios`] last saw active. portfolio_seen: Option, diff --git a/crates/ui-egui/tests/it/portfolio.rs b/crates/ui-egui/tests/it/portfolio.rs index 615363bf26..d8dd2726b3 100644 --- a/crates/ui-egui/tests/it/portfolio.rs +++ b/crates/ui-egui/tests/it/portfolio.rs @@ -78,6 +78,8 @@ fn select(h: &mut Harness<'static, PdfCraftApp>, index: usize) { h.run_steps(2); } +/// The initial document opens in front, and the portfolio's tab lists its files whatever the +/// reader's panel preference, giving the panel back when left. #[test] fn a_portfolio_opens_its_initial_document_and_shows_its_files_with_the_panel_closed() { let mut h = opened(portfolio("/Collection << /D (form.pdf) /View /T >>", &[("intro.pdf", PAGE), ("form.pdf", PAGE)]), true); @@ -106,6 +108,7 @@ fn a_portfolio_opens_its_initial_document_and_shows_its_files_with_the_panel_clo assert_eq!(h.state().right, Some(RightPanel::Bookmarks)); } +/// The initial document opens on its own panels, not on the portfolio's file list. #[test] fn the_initial_document_opens_without_the_portfolios_file_list() { // The portfolio asks for the Attachments panel too (`/PageMode /UseAttachments`), and the @@ -118,6 +121,28 @@ fn the_initial_document_opens_without_the_portfolios_file_list() { assert_eq!(h.state().right, Some(RightPanel::Attachments)); } +/// A file opened together with a portfolio, after it, stays in front. +#[test] +fn a_file_opened_after_the_portfolio_keeps_the_front() { + // Two files opened at once, the portfolio first: the second stays in front, and the + // portfolio's initial document waits for the reader, whose list shows on the portfolio's tab. + let pdf = portfolio("/Collection << /D (form.pdf) >>", &[("form.pdf", PAGE)]); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.right_panel_closed = true; + app.open_bytes("portfolio.pdf", None, pdf.clone()).unwrap(); + app.open_bytes("other.pdf", None, PAGE.to_vec()).unwrap(); + app + }); + h.run_steps(4); + assert_eq!(tabs(&h), (vec!["portfolio.pdf".into(), "other.pdf".into()], Some("other.pdf".into()))); + assert_eq!(h.state().right, None); + select(&mut h, 0); + assert_eq!(h.state().right, Some(RightPanel::Attachments)); +} + +/// `/View /H` presents the initial document but leaves the file list to the reader. #[test] fn a_portfolio_that_keeps_its_list_hidden_still_presents_its_initial_document() { let mut h = opened(portfolio("/Collection << /D (form.pdf) /View /H >>", &[("intro.pdf", PAGE), ("form.pdf", PAGE)]), true); @@ -126,6 +151,7 @@ fn a_portfolio_that_keeps_its_list_hidden_still_presents_its_initial_document() assert_eq!(h.state().right, None, "/View /H: the list shows when the reader asks for it"); } +/// Without a PDF to present, the portfolio stays in front with its files listed. #[test] fn a_portfolio_without_an_initial_document_shows_its_files_over_the_cover() { // Without `/D` the cover is the initial document; a `/D` naming a file that isn't a PDF @@ -137,6 +163,7 @@ fn a_portfolio_without_an_initial_document_shows_its_files_over_the_cover() { } } +/// A portfolio nested in the initial document doesn't open a chain of tabs. #[test] fn a_portfolio_inside_the_initial_document_waits_for_the_reader() { // The initial document is itself a portfolio presenting a third file: opening the outer From a9c3d842737880da56ca3a9a188be1ceb4ff7192 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:08:31 -0400 Subject: [PATCH 018/101] M12: a portfolio's initial document waits for a prompt, picker or front-bound open in progress, so a password asked for a file opened with it is kept (#1652) --- crates/ui-egui/src/app/files.rs | 11 +++++++- crates/ui-egui/tests/it/portfolio.rs | 41 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 90dbf7025c..1da4a191d5 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -430,7 +430,16 @@ impl PdfCraftApp { /// Leaving the tab gives back the panel the list replaced, unless the reader changed panels /// meanwhile. Called every frame. pub(crate) fn present_portfolios(&mut self) { - if let Some((doc, index)) = self.portfolio_initial.take() + // A prompt or picker over the window belongs to the document it was raised for (the + // password of a file opened together with the portfolio, say): the initial document + // waits for its answer, and opening it then would close the prompt. A file still + // opening in the background comes to the front when it arrives, so it is waited for too. + #[cfg(not(target_arch = "wasm32"))] + let busy = self.modal_open() || self.pickers.pending() || self.pending_opens.iter().any(|p| p.activate); + #[cfg(target_arch = "wasm32")] + let busy = self.modal_open(); + if !busy + && let Some((doc, index)) = self.portfolio_initial.take() && self.active_ids().is_some_and(|(_, id)| id == doc) { self.attachment_action(doc, index, true); diff --git a/crates/ui-egui/tests/it/portfolio.rs b/crates/ui-egui/tests/it/portfolio.rs index d8dd2726b3..2a7c91a710 100644 --- a/crates/ui-egui/tests/it/portfolio.rs +++ b/crates/ui-egui/tests/it/portfolio.rs @@ -22,6 +22,21 @@ const NOTED: &[u8] = b"%PDF-1.7 trailer << /Root 1 0 R >> %%EOF"; +/// [`PAGE`] encrypted, opening with the password `pw`. +fn locked() -> Vec { + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(PAGE.to_vec())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "pw", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [4; 32], + }) + .unwrap(); + pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() +} + /// A one-page portfolio of `files` (name, bytes), with `catalog` (its `/Collection` and any other /// entries) in its catalog. fn portfolio(catalog: &str, files: &[(&str, &[u8])]) -> Vec { @@ -142,6 +157,32 @@ fn a_file_opened_after_the_portfolio_keeps_the_front() { assert_eq!(h.state().right, Some(RightPanel::Attachments)); } +/// A password asked for a file opened together with the portfolio stays asked: the initial +/// document waits for the answer, then gives way to that file, or opens once the prompt is +/// cancelled. +#[test] +fn a_password_asked_for_a_file_opened_with_the_portfolio_stays_asked() { + for answer in [Some("pw".to_string()), None] { + let pdf = portfolio("/Collection << /D (form.pdf) >>", &[("form.pdf", PAGE)]); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("portfolio.pdf", None, pdf.clone()).unwrap(); + app.open_bytes("locked.pdf", None, locked()).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state().password_prompt.is_some(), "the prompt for locked.pdf is still up"); + assert_eq!(tabs(&h).0, ["portfolio.pdf"]); + let opened = if answer.is_some() { "locked.pdf" } else { "form.pdf" }; + // The answered file opens in the background worker. + h.state_mut().submit_password(answer); + crate::support::wait_until(&mut h, "a second tab", |app| app.views.len() == 2); + h.run_steps(4); + assert_eq!(tabs(&h), (vec!["portfolio.pdf".into(), opened.into()], Some(opened.into()))); + } +} + /// `/View /H` presents the initial document but leaves the file list to the reader. #[test] fn a_portfolio_that_keeps_its_list_hidden_still_presents_its_initial_document() { From 12d53d707e642b3f818010c5a16b1e888029bb06 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:49:37 -0400 Subject: [PATCH 019/101] M12: in the browser, a portfolio's initial document also waits for file reads under way (drops, the file picker) (#1652) --- crates/ui-egui/src/app.rs | 2 ++ crates/ui-egui/src/app/files.rs | 7 +++++-- crates/ui-egui/src/app/input.rs | 24 ++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 4 ++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 9403f33c71..070517fa2e 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -54,6 +54,8 @@ impl PdfCraftApp { right_panel_closed: false, portfolio_initial: None, portfolio_seen: None, + #[cfg(target_arch = "wasm32")] + web_reads: Default::default(), portfolio_panel: None, quick_tool: QuickTool::Select, space_hand: None, diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 1da4a191d5..8450709a16 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -433,11 +433,12 @@ impl PdfCraftApp { // A prompt or picker over the window belongs to the document it was raised for (the // password of a file opened together with the portfolio, say): the initial document // waits for its answer, and opening it then would close the prompt. A file still - // opening in the background comes to the front when it arrives, so it is waited for too. + // opening in the background (in the open worker, or a browser read of a drop or a + // picked file) comes to the front when it arrives, so it is waited for too. #[cfg(not(target_arch = "wasm32"))] let busy = self.modal_open() || self.pickers.pending() || self.pending_opens.iter().any(|p| p.activate); #[cfg(target_arch = "wasm32")] - let busy = self.modal_open(); + let busy = self.modal_open() || self.web_reads.load(std::sync::atomic::Ordering::Relaxed) > 0; if !busy && let Some((doc, index)) = self.portfolio_initial.take() && self.active_ids().is_some_and(|(_, id)| id == doc) @@ -712,7 +713,9 @@ impl PdfCraftApp { { let inbox = self.inbox.clone(); let ctx = self.ctx.clone(); + let read = super::input::WebRead::start(&self.web_reads); wasm_bindgen_futures::spawn_local(async move { + let _read = read; if let Some(h) = rfd::AsyncFileDialog::new() .add_filter("PDF", &["pdf"]) .add_filter(tl!("Images and text"), &create_ui::CONVERTIBLE) diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index cda282e9ed..5a15c51695 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -129,7 +129,9 @@ impl PdfCraftApp { self.startup_superseded = true; let inbox = self.inbox.clone(); let ctx = ctx.clone(); + let read = WebRead::start(&self.web_reads); wasm_bindgen_futures::spawn_local(async move { + let _read = read; let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into()); if let Ok(bytes) = f.bytes_async().await && let Ok(mut q) = inbox.lock() @@ -140,3 +142,25 @@ impl PdfCraftApp { }); } } + +/// One browser file read counted in `PdfCraftApp::web_reads` for as long as it lives, so the +/// count falls however the read ends. +#[cfg(target_arch = "wasm32")] +pub(super) struct WebRead(std::sync::Arc); + +#[cfg(target_arch = "wasm32")] +impl WebRead { + /// Count a read starting in `reads`. + pub(super) fn start(reads: &std::sync::Arc) -> Self { + reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Self(reads.clone()) + } +} + +#[cfg(target_arch = "wasm32")] +impl Drop for WebRead { + /// The read is over, whether its file arrived or not. + fn drop(&mut self) { + self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f5a1ed5440..5cb48c67db 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -274,6 +274,10 @@ pub struct PdfCraftApp { portfolio_initial: Option<(DocId, usize)>, /// The document whose tab [`Self::present_portfolios`] last saw active. portfolio_seen: Option, + /// Browser file reads still under way (drops, the web file picker): each file opens in front + /// when it arrives. + #[cfg(target_arch = "wasm32")] + web_reads: std::sync::Arc, /// A portfolio's file list is showing in place of this panel (`None`: the panel was /// closed), given back when its tab is left. portfolio_panel: Option>, From 014828ee1d897b1b0d4d3697795d2ef93f42ec58 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:25:10 -0400 Subject: [PATCH 020/101] M12: a portfolio's initial document is kept by its name-tree key, and Read mode gives back the panel the file list replaced (#1652) --- crates/ui-egui/src/app/files.rs | 47 ++++++++++++++++++++-------- crates/ui-egui/src/lib.rs | 8 ++--- crates/ui-egui/tests/it/portfolio.rs | 37 ++++++++++++++++++++++ 3 files changed, 75 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 8450709a16..1e8ed3984f 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -6,6 +6,11 @@ pub(crate) fn same_file(a: &str, b: &str) -> bool { a == b || matches!((std::path::absolute(a), std::path::absolute(b)), (Ok(x), Ok(y)) if x == y) } +/// An attachment opens as a PDF in a tab of its own, as the Attachments panel's Open does. +fn opens_as_pdf(a: &pdfcraft_render::Attachment) -> bool { + a.name.to_lowercase().ends_with(".pdf") +} + impl PdfCraftApp { /// Open a document the way it asks to be opened: navigation panel, layout, magnification, /// page and the place on it (Document Properties ▸ Initial View, `/OpenAction`). With Always @@ -275,13 +280,15 @@ impl PdfCraftApp { // the user closed stays closed (#1590). let opening_mode = self.mode_override.unwrap_or(self.default_mode); let initial = doc.initial_view(); - // A portfolio presents the document its `/D` names (ISO 32000-2 Table 153), when that is a PDF. - let portfolio_initial = doc - .info - .portfolio - .as_ref() - .and_then(|p| p.initial) - .filter(|&i| doc.info.attachments.get(i).is_some_and(|a| a.name.to_lowercase().ends_with(".pdf"))); + // A portfolio presents the document its `/D` names (ISO 32000-2 Table 153), when that is a + // PDF: kept by its name-tree key, which an edit meanwhile can't shift as it can an index. + let portfolio_initial = + doc.info.portfolio.as_ref().and_then(|p| p.initial).and_then(|i| doc.info.attachments.get(i)).filter(|a| opens_as_pdf(a)).and_then(|a| { + match &a.source { + pdfcraft_render::AttachmentSource::Document { key } => Some(key.clone()), + pdfcraft_render::AttachmentSource::Annotation { .. } => None, + } + }); if activate && self.right.is_none() && !self.right_panel_closed && opening_mode != Mode::Read { self.right = if !doc.info.annotations.is_empty() { Some(RightPanel::Comments) @@ -320,7 +327,7 @@ impl PdfCraftApp { } // A document opening in front of a portfolio that hasn't presented its initial // document yet (files opened together) keeps the front. - self.portfolio_initial = portfolio_initial.map(|index| (id, index)); + self.portfolio_initial = portfolio_initial.map(|key| (id, key)); } if activate && self.left_open @@ -440,8 +447,9 @@ impl PdfCraftApp { #[cfg(target_arch = "wasm32")] let busy = self.modal_open() || self.web_reads.load(std::sync::atomic::Ordering::Relaxed) > 0; if !busy - && let Some((doc, index)) = self.portfolio_initial.take() + && let Some((doc, key)) = self.portfolio_initial.take() && self.active_ids().is_some_and(|(_, id)| id == doc) + && let Some(index) = self.portfolio_attachment(doc, &key) { self.attachment_action(doc, index, true); // A portfolio inside the portfolio waits until the reader opens it, so a nested @@ -477,14 +485,27 @@ impl PdfCraftApp { self.right = Some(RightPanel::Attachments); } + /// Where in `doc`'s attachments now the PDF its name tree keys `key` is: an attachment + /// deleted since the portfolio opened moves it, or it is gone. + fn portfolio_attachment(&self, doc: DocId, key: &str) -> Option { + self.session + .get(doc)? + .info + .attachments + .iter() + .position(|a| opens_as_pdf(a) && matches!(&a.source, pdfcraft_render::AttachmentSource::Document { key: k } if k == key)) + } + /// Leave a portfolio's tab: the panel its file list replaced comes back, unless the reader - /// changed panels meanwhile. + /// changed panels meanwhile. In Read mode, which put the list away and gives it back on + /// leaving, it is that panel Read mode gives back instead. fn leave_portfolio(&mut self) { self.portfolio_seen = None; - if let Some(before) = self.portfolio_panel.take() - && self.right == Some(RightPanel::Attachments) - { + let Some(before) = self.portfolio_panel.take() else { return }; + if self.right == Some(RightPanel::Attachments) { self.right = before; + } else if self.mode == Mode::Read && self.right_before_read == Some(RightPanel::Attachments) { + self.right_before_read = before; } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5cb48c67db..41ebc5201e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -268,10 +268,10 @@ pub struct PdfCraftApp { /// The user closed the right panel, so opening a document leaves it closed instead of /// showing its comments or bookmarks (#1590). Remembered across restarts. pub right_panel_closed: bool, - /// A portfolio that just opened and the attachment it presents first (`/Collection /D`), - /// opened by [`Self::present_portfolios`] on the next frame while the portfolio is still the - /// active tab. - portfolio_initial: Option<(DocId, usize)>, + /// A portfolio that just opened and the name-tree key of the attachment it presents first + /// (`/Collection /D`), opened by [`Self::present_portfolios`] on the next frame while the + /// portfolio is still the active tab. + portfolio_initial: Option<(DocId, String)>, /// The document whose tab [`Self::present_portfolios`] last saw active. portfolio_seen: Option, /// Browser file reads still under way (drops, the web file picker): each file opens in front diff --git a/crates/ui-egui/tests/it/portfolio.rs b/crates/ui-egui/tests/it/portfolio.rs index 2a7c91a710..7be6cfede3 100644 --- a/crates/ui-egui/tests/it/portfolio.rs +++ b/crates/ui-egui/tests/it/portfolio.rs @@ -183,6 +183,43 @@ fn a_password_asked_for_a_file_opened_with_the_portfolio_stays_asked() { } } +/// An attachment deleted while the initial document waits (here for a password asked for a file +/// opened with the portfolio) doesn't change which document opens. +#[test] +fn a_deleted_attachment_doesnt_change_which_document_opens() { + let pdf = portfolio("/Collection << /D (form.pdf) >>", &[("intro.pdf", PAGE), ("form.pdf", PAGE)]); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("portfolio.pdf", None, pdf.clone()).unwrap(); + app.open_bytes("locked.pdf", None, locked()).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::DeleteAttachment { key: "intro.pdf".into() })); + h.state_mut().submit_password(None); + h.run_steps(4); + assert_eq!(tabs(&h), (vec!["portfolio.pdf".into(), "form.pdf".into()], Some("form.pdf".into()))); +} + +/// Leaving the portfolio's tab in Read mode: Read mode gives back the panel the file list +/// replaced, not the list, on the tab that is active when it ends. +#[test] +fn read_mode_gives_back_the_panel_the_file_list_replaced() { + let mut h = opened(portfolio("/Collection << /View /D >>", &[("form.pdf", PAGE)]), true); + h.state_mut().open_bytes("other.pdf", None, PAGE.to_vec()).unwrap(); + h.run_steps(2); + select(&mut h, 0); + assert_eq!(h.state().right, Some(RightPanel::Attachments)); + assert!(h.state_mut().execute("view.read_mode")); + h.run_steps(2); + assert_eq!(h.state().right, None); + select(&mut h, 1); + assert!(h.state_mut().execute("view.read_mode")); + h.run_steps(2); + assert_eq!(h.state().right, None, "the panel was closed before the list showed"); +} + /// `/View /H` presents the initial document but leaves the file list to the reader. #[test] fn a_portfolio_that_keeps_its_list_hidden_still_presents_its_initial_document() { From 73a5fbd4cc9306f16777aa2fe926ad3415e1f920 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:51:13 -0400 Subject: [PATCH 021/101] M12: a portfolio's initial document is a PDF by its bytes, and a panel picked in Read mode stays when the tab changes (#1652) --- crates/ui-egui/src/app/files.rs | 48 +++++++++++++++++----------- crates/ui-egui/tests/it/portfolio.rs | 23 +++++++++++++ 2 files changed, 52 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 1e8ed3984f..356a22c903 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -6,11 +6,6 @@ pub(crate) fn same_file(a: &str, b: &str) -> bool { a == b || matches!((std::path::absolute(a), std::path::absolute(b)), (Ok(x), Ok(y)) if x == y) } -/// An attachment opens as a PDF in a tab of its own, as the Attachments panel's Open does. -fn opens_as_pdf(a: &pdfcraft_render::Attachment) -> bool { - a.name.to_lowercase().ends_with(".pdf") -} - impl PdfCraftApp { /// Open a document the way it asks to be opened: navigation panel, layout, magnification, /// page and the place on it (Document Properties ▸ Initial View, `/OpenAction`). With Always @@ -280,14 +275,12 @@ impl PdfCraftApp { // the user closed stays closed (#1590). let opening_mode = self.mode_override.unwrap_or(self.default_mode); let initial = doc.initial_view(); - // A portfolio presents the document its `/D` names (ISO 32000-2 Table 153), when that is a - // PDF: kept by its name-tree key, which an edit meanwhile can't shift as it can an index. + // A portfolio presents the document its `/D` names (ISO 32000-2 Table 153), kept by its + // name-tree key, which an edit meanwhile can't shift as it can an index. let portfolio_initial = - doc.info.portfolio.as_ref().and_then(|p| p.initial).and_then(|i| doc.info.attachments.get(i)).filter(|a| opens_as_pdf(a)).and_then(|a| { - match &a.source { - pdfcraft_render::AttachmentSource::Document { key } => Some(key.clone()), - pdfcraft_render::AttachmentSource::Annotation { .. } => None, - } + doc.info.portfolio.as_ref().and_then(|p| p.initial).and_then(|i| doc.info.attachments.get(i)).and_then(|a| match &a.source { + pdfcraft_render::AttachmentSource::Document { key } => Some(key.clone()), + pdfcraft_render::AttachmentSource::Annotation { .. } => None, }); if activate && self.right.is_none() && !self.right_panel_closed && opening_mode != Mode::Read { self.right = if !doc.info.annotations.is_empty() { @@ -451,7 +444,7 @@ impl PdfCraftApp { && self.active_ids().is_some_and(|(_, id)| id == doc) && let Some(index) = self.portfolio_attachment(doc, &key) { - self.attachment_action(doc, index, true); + self.open_portfolio_initial(doc, index); // A portfolio inside the portfolio waits until the reader opens it, so a nested // file can't open tab after tab on its own. self.portfolio_initial = None; @@ -485,7 +478,7 @@ impl PdfCraftApp { self.right = Some(RightPanel::Attachments); } - /// Where in `doc`'s attachments now the PDF its name tree keys `key` is: an attachment + /// Where in `doc`'s attachments now the file its name tree keys `key` is: an attachment /// deleted since the portfolio opened moves it, or it is gone. fn portfolio_attachment(&self, doc: DocId, key: &str) -> Option { self.session @@ -493,19 +486,36 @@ impl PdfCraftApp { .info .attachments .iter() - .position(|a| opens_as_pdf(a) && matches!(&a.source, pdfcraft_render::AttachmentSource::Document { key: k } if k == key)) + .position(|a| matches!(&a.source, pdfcraft_render::AttachmentSource::Document { key: k } if k == key)) + } + + /// Open portfolio `doc`'s attachment `index` in a tab of its own when its bytes are a PDF, + /// whatever its name. A file of another kind, or one that can't be read, stays in the list. + fn open_portfolio_initial(&mut self, doc: DocId, index: usize) { + let Some(d) = self.session.get(doc) else { return }; + let Some(att) = d.info.attachments.get(index).cloned() else { return }; + let Ok(bytes) = pdfcraft_render::attachment_data(&d.bytes, d.password.as_deref(), &att) else { return }; + if pdfcraft_engine::source_kind(&att.name, &bytes) != Some(pdfcraft_engine::SourceKind::Pdf) { + return; + } + if let Err(e) = self.open_bytes(&crate::files::attachment_file_name(&att.name), None, bytes) { + self.notify_fmt("Couldn't open {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]); + } } /// Leave a portfolio's tab: the panel its file list replaced comes back, unless the reader /// changed panels meanwhile. In Read mode, which put the list away and gives it back on - /// leaving, it is that panel Read mode gives back instead. + /// leaving, it is the panel Read mode gives back that changes, and a panel the reader picked + /// in Read mode stays. fn leave_portfolio(&mut self) { self.portfolio_seen = None; let Some(before) = self.portfolio_panel.take() else { return }; - if self.right == Some(RightPanel::Attachments) { + if self.mode == Mode::Read { + if self.right_before_read == Some(RightPanel::Attachments) { + self.right_before_read = before; + } + } else if self.right == Some(RightPanel::Attachments) { self.right = before; - } else if self.mode == Mode::Read && self.right_before_read == Some(RightPanel::Attachments) { - self.right_before_read = before; } } diff --git a/crates/ui-egui/tests/it/portfolio.rs b/crates/ui-egui/tests/it/portfolio.rs index 7be6cfede3..3bb9b16d59 100644 --- a/crates/ui-egui/tests/it/portfolio.rs +++ b/crates/ui-egui/tests/it/portfolio.rs @@ -220,6 +220,29 @@ fn read_mode_gives_back_the_panel_the_file_list_replaced() { assert_eq!(h.state().right, None, "the panel was closed before the list showed"); } +/// The initial document is a PDF by its bytes, not its name. +#[test] +fn an_initial_pdf_named_without_pdf_opens() { + let h = opened(portfolio("/Collection << /D (Form) >>", &[("Form", PAGE)]), true); + let (names, active) = tabs(&h); + assert_eq!(names.len(), 2, "{names:?}"); + assert_ne!(active.as_deref(), Some("portfolio.pdf")); +} + +/// A panel the reader picks in Read mode on the portfolio's tab stays when they switch tabs. +#[test] +fn a_panel_picked_in_read_mode_stays_when_the_tab_changes() { + let mut h = opened(portfolio("/Collection << /View /D >>", &[("form.pdf", PAGE)]), true); + h.state_mut().open_bytes("other.pdf", None, PAGE.to_vec()).unwrap(); + h.run_steps(2); + select(&mut h, 0); + assert!(h.state_mut().execute("view.read_mode")); + h.run_steps(2); + h.state_mut().choose_right_panel(Some(RightPanel::Attachments)); + select(&mut h, 1); + assert_eq!(h.state().right, Some(RightPanel::Attachments)); +} + /// `/View /H` presents the initial document but leaves the file list to the reader. #[test] fn a_portfolio_that_keeps_its_list_hidden_still_presents_its_initial_document() { From fd653e46ac5e0493df37ea437300ef9fa250bbe5 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:31:49 +0800 Subject: [PATCH 022/101] M8: redaction removes Info entries and XMP that repeat the redacted text (#2448) Applying redactions removed the glyphs under the marks but left the same words in the document information dictionary and the XMP metadata. The redaction interpreter now collects the removed glyphs' text in runs by position. After verification, /Info entries containing a run (or one of its longer words) are removed, and the catalog's and pages' XMP streams whose text mentions one are removed whole; unreadable or oversized XMP fails closed. Report::metadata counts what went. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/redact/README.md | 8 +- crates/redact/src/interp.rs | 54 ++++++++- crates/redact/src/lib.rs | 18 ++- crates/redact/src/metadata.rs | 214 ++++++++++++++++++++++++++++++++++ crates/redact/src/tests.rs | 78 +++++++++++++ parity/acrobat-features.toml | 4 +- 7 files changed, 370 insertions(+), 7 deletions(-) create mode 100644 crates/redact/src/metadata.rs diff --git a/ROADMAP.md b/ROADMAP.md index f5de8044e1..3b207390bb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -324,6 +324,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-14 (M12, reading colours, #2059):** Pending page backgrounds use the document's reading colour immediately, preventing white flashes while scrolling to uncached pages. A headless canvas regression covers three pending pages in every preset and a custom background. Estimates unchanged. - **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged. - **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged. +- **2026-10-15 (M8, #2448):** Applying redactions also removes the document information entries and the document's and pages' XMP metadata that repeat the removed text, so a redacted word can't survive in `/Title`, `/Keywords` or the XMP that mirrors them. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. - **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are unchanged. - **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged. diff --git a/crates/redact/README.md b/crates/redact/README.md index 34c9f4fe1f..55c0e02f85 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -29,7 +29,13 @@ clear_marks(&mut doc, None)?; // remove marks without app - **annotations** whose rectangle overlaps (with their pop-ups and their replies, `/IRT`), and **form fields** with a widget under a mark; - the marks themselves, replaced by boxes in their fill colour (and overlay text) drawn into the - page as a `/PCMark /Redaction` stream. + page as a `/PCMark /Redaction` stream; +- **metadata** that repeats the removed text (#2448): `/Info` entries containing it, and the + catalog's and pages' XMP streams whose text (element content and attribute values, entities + decoded) contains it, removed whole; unreadable XMP goes too. The removed glyphs' text is + collected in runs by position, and each run (two or more letters or digits) and each of its + words of three or more are matched without regard to case or spacing. `Report::metadata` + counts what went. A verification pass re-reads each redacted page and fails the operation if any glyph or inline image is still under an area. It checks every content stream the page had, whatever its diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index ac69c73acb..42e2045aab 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -46,6 +46,9 @@ pub(crate) const MAX_DEPTH: usize = 50; // A form a reader still shows must be walkable here, or a redaction removes content that is // visible; the build says so rather than a test, so the two limits cannot drift apart. const _: () = assert!(MAX_DEPTH >= pdfcraft_edit::text::MAX_FORM_DEPTH); +/// Bounds on the removed text collected for the metadata check (#2448). +const MAX_RUNS: usize = 100_000; +const MAX_RUN_BYTES: usize = 64 * 1024; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Mode { @@ -122,6 +125,13 @@ pub(crate) struct Scope<'a> { xobject_oc_cache: HashMap, /// Content removed from hidden layers (blocks and XObjects). pub layer_blocks: usize, + /// Apply: the text of the glyphs removed under the regions, one entry per run of adjacent + /// glyphs (#2448), so the document's metadata can be checked for it. Call [`Scope::flush`] + /// after processing to close the last run. + pub removed_text: Vec, + /// The run being collected, and where its last glyph ended (device space) with its height. + run: String, + run_end: Option<((f64, f64), f64)>, fonts: HashMap, Rc>, used_names: Vec>, depth: usize, @@ -146,6 +156,9 @@ impl<'a> Scope<'a> { ocmd_cache: HashMap::new(), xobject_oc_cache: HashMap::new(), layer_blocks: 0, + removed_text: Vec::new(), + run: String::new(), + run_end: None, fonts: HashMap::new(), used_names: Vec::new(), depth: 0, @@ -153,6 +166,35 @@ impl<'a> Scope<'a> { } } + /// Close the run of removed text being collected. + pub fn flush(&mut self) { + self.run_end = None; + let run = std::mem::take(&mut self.run); + if !run.trim().is_empty() && self.removed_text.len() < MAX_RUNS { + self.removed_text.push(run); + } + } + + /// Note a removed glyph's text: `origin` and `end` are where it starts and ends in device + /// space, `height` its size there. A glyph that starts where the last one ended continues the + /// run, one a little further along the line is a new word of it, and anything else starts a + /// new run, so separate marks don't join their text. + fn removed(&mut self, text: &str, origin: (f64, f64), end: (f64, f64), height: f64) { + if let Some((last, h)) = self.run_end { + let gap = (origin.0 - last.0).hypot(origin.1 - last.1); + let h = h.max(height); + if !(gap.is_finite() && h.is_finite()) || gap > 1.5 * h { + self.flush(); + } else if gap > 0.15 * h && !self.run.ends_with(char::is_whitespace) { + self.run.push(' '); + } + } + if self.run.len().saturating_add(text.len()) <= MAX_RUN_BYTES { + self.run.push_str(text); + } + self.run_end = Some((end, height)); + } + /// Sanitizing visits every form XObject (not only those under a region). fn everywhere(&self) -> bool { self.hidden_text.is_some() || self.layers_enabled @@ -641,7 +683,7 @@ fn clean_replacements(doc: &Document, resources: &Dict, original: &[Op], process /// Lay out a shown string (or `TJ` array) glyph by glyph, advancing `tm`. Returns the `TJ` /// items with the glyphs under a region replaced by displacements, and how many were removed. -fn show(scope: &Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (Vec, usize) { +fn show(scope: &mut Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (Vec, usize) { let f = &gs.font; let size = gs.size; let mut out: Vec = Vec::new(); @@ -679,6 +721,16 @@ fn show(scope: &Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (Vec scope.glyph_hit(b, origin), }; + if scope.mode == Mode::Apply && scope.hidden_text.is_none() { + if hit { + let end = trm.apply(w0, 0.0); + let top = trm.apply(0.0, 1.0); + let height = (top.0 - origin.0).hypot(top.1 - origin.1); + scope.removed(f.text_of(code).as_deref().unwrap_or(""), origin, end, height); + } else { + scope.flush(); + } + } glyphs.push(Glyph { bytes: pos..pos + len, advance, hit }); *tm = Matrix::translate(advance * gs.scale, 0.0).then(tm); pos += len; diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index de9dafe7db..643dc5671e 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -11,6 +11,7 @@ //! //! A verification pass then re-reads every redacted page; if any glyph or inline image is still //! under a region the whole operation fails (callers keep the previous document: fail-closed). +//! Finally, document metadata (`/Info` entries, XMP) that repeats the removed text is removed. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -21,6 +22,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; pub mod codes; mod image; mod interp; +mod metadata; mod overlay; pub mod patterns; pub mod sanitize; @@ -149,6 +151,8 @@ pub struct Report { /// only streams a check for text left under the marks may skip: an input stream labelled /// like an overlay (`/PCMark /Redaction`) is checked like any other. pub overlays: Vec, + /// Document information entries and XMP streams removed because they held redacted text. + pub metadata: usize, } fn rect_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { @@ -418,6 +422,7 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result) -> Result 0 { return Err(RedactError::Residue(residue)); } + // 6. The document's metadata must not keep what the pages no longer show (#2448). + report.metadata = metadata::scrub(doc, &removed_text)?; // The previous revision still holds the removed content: the next save must rewrite. doc.require_full_save(); Ok(report) @@ -542,6 +549,8 @@ struct Applying { retired_properties: Vec, widget_owner: Vec<(ObjRef, ObjRef)>, removed_everywhere: HashSet, + /// The text of the removed glyphs, for the metadata check at the end. + removed_text: Vec, } /// Step 1 for one page: rewrites its content so nothing readable stays under a mark, and cleans the tags it no longer shows. @@ -552,14 +561,17 @@ fn redact_page_content( page: &pdfcraft_model::Page, rects: &[[f64; 4]], ) -> Result<(), RedactError> { - let Applying { report, doomed_xobjects, retired_properties, .. } = applying; + let Applying { report, doomed_xobjects, retired_properties, removed_text, .. } = applying; // 1. Content. let mut budget = pdfcraft_cos::DecodeBudget::page(); let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let out = { let mut scope = Scope::new(rects, Mode::Apply, report, budget); - process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) + let out = process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY); + scope.flush(); + removed_text.append(&mut scope.removed_text); + out }; // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot // tell whether those glyphs lie under a mark. Rather than report success and possibly diff --git a/crates/redact/src/metadata.rs b/crates/redact/src/metadata.rs new file mode 100644 index 0000000000..821e744b06 --- /dev/null +++ b/crates/redact/src/metadata.rs @@ -0,0 +1,214 @@ +//! Metadata that repeats redacted text (#2448). +//! +//! [`crate::apply`] removes the glyphs under the marks, but the same words are often in the +//! document information dictionary (`/Title`, `/Subject`, `/Keywords`…) and the XMP metadata +//! that mirrors it. Once the page no longer shows them, the metadata must not keep them either. +//! +//! An `/Info` entry whose text contains a redacted term is removed. An XMP stream whose text +//! (element content and attribute values, entities decoded) contains one is removed whole: +//! editing the XML in place could leave it malformed or keep the term in another spelling. +//! Unreadable XMP goes too, and so does text too large to search against every term +//! (fail-closed). Terms are matched without regard to case or spacing. + +use std::collections::HashSet; + +use pdfcraft_cos::{Document, ObjRef, Object}; + +/// Terms longer than this are cut (matching still finds the prefix). +const MAX_TERM_CHARS: usize = 4096; +const MAX_TERMS: usize = 20_000; +/// XMP packets larger than this, decoded, are removed without reading them. +const MAX_XMP_BYTES: usize = 16 * 1024 * 1024; +/// Text whose length times the number of terms exceeds this is treated as matching rather than +/// searched (fail-closed), so a page of removed text against a large packet stays quick. +const MAX_WORK: usize = 1 << 28; + +/// Lower-case `s` with every run of whitespace made one space, trimmed. +fn normalize(s: &str) -> String { + s.split_whitespace().flat_map(|w| w.chars().flat_map(char::to_lowercase).chain(std::iter::once(' '))).collect::().trim_end().to_string() +} + +fn significant(s: &str, min: usize) -> bool { + s.chars().filter(|c| c.is_alphanumeric()).count() >= min +} + +/// The terms to look for: each removed run (at least two letters or digits), and each of its +/// words with at least three, so "Smith, John" in `/Author` is caught when "John Smith" was +/// redacted. Shorter pieces would match nearly any text. +pub(crate) fn terms(removed: &[String]) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for run in removed { + let run: String = normalize(run).chars().take(MAX_TERM_CHARS).collect(); + let words = run.split(|c: char| !c.is_alphanumeric()).filter(|w| significant(w, 3)).map(str::to_string).collect::>(); + for t in std::iter::once(run.clone()).filter(|r| significant(r, 2)).chain(words) { + if out.len() >= MAX_TERMS { + return out; + } + if seen.insert(t.clone()) { + out.push(t); + } + } + } + out +} + +fn mentions(text: &str, terms: &[String]) -> bool { + let text = normalize(text); + text.len().saturating_mul(terms.len()) > MAX_WORK || terms.iter().any(|t| text.contains(t.as_str())) +} + +/// Decode the five predefined XML entities and character references; anything else stays. +fn unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(at) = rest.find('&') { + out.push_str(rest.get(..at).unwrap_or("")); + let tail = rest.get(at..).unwrap_or(""); + let end = tail.find(';').filter(|&e| e <= 12); + let entity = end.and_then(|e| tail.get(1..e)); + let decoded = entity.and_then(|e| match e { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + _ => { + let n = match e.strip_prefix("#x").or_else(|| e.strip_prefix("#X")) { + Some(h) => u32::from_str_radix(h, 16).ok(), + None => e.strip_prefix('#').and_then(|d| d.parse().ok()), + }; + n.and_then(char::from_u32) + } + }); + match (decoded, end) { + (Some(c), Some(e)) => { + out.push(c); + rest = tail.get(e + 1..).unwrap_or(""); + } + _ => { + out.push('&'); + rest = tail.get(1..).unwrap_or(""); + } + } + } + out.push_str(rest); + out +} + +/// The text an XML document carries: element content and attribute values (XMP stores simple +/// properties as attributes), each followed by a space, entities decoded. Names, comments and +/// markup are left out, so a term can't match a tag such as `dc:title`. +pub(crate) fn xml_text(xml: &str) -> String { + let mut out = String::new(); + let mut rest = xml; + while !rest.is_empty() { + let Some(open) = rest.find('<') else { + out.push_str(&unescape(rest)); + break; + }; + out.push_str(&unescape(rest.get(..open).unwrap_or(""))); + out.push(' '); + let tag = rest.get(open..).unwrap_or(""); + if let Some(body) = tag.strip_prefix("").unwrap_or(body.len()); + out.push_str(body.get(..end).unwrap_or("")); + out.push(' '); + rest = body.get(end + 3..).unwrap_or(""); + continue; + } + if tag.starts_with("").and_then(|e| tag.get(e + 3..)).unwrap_or(""); + continue; + } + // Attribute values inside the tag, up to its closing `>` (which can't be inside quotes). + let mut chars = tag.char_indices().skip(1); + let mut end = tag.len(); + while let Some((i, c)) = chars.next() { + match c { + '>' => { + end = i + 1; + break; + } + '"' | '\'' => { + let mut value = String::new(); + for (_, d) in chars.by_ref() { + if d == c { + break; + } + value.push(d); + } + out.push_str(&unescape(&value)); + out.push(' '); + } + _ => {} + } + } + rest = tag.get(end..).unwrap_or(""); + } + out +} + +/// The decoded text of an XMP stream (UTF-8, or UTF-16 with a byte-order mark), or `None` when it +/// can't be read. +fn xmp_text(doc: &Document, o: &Object) -> Option { + let Object::Stream(s) = &*doc.resolve(o) else { return None }; + let bytes = s.decoded().ok()?; + if bytes.len() > MAX_XMP_BYTES { + return None; + } + let units = |be: bool| { + bytes.get(2..).unwrap_or(&[]).as_chunks::<2>().0.iter().map(move |&p| if be { u16::from_be_bytes(p) } else { u16::from_le_bytes(p) }) + }; + let text = match bytes.get(..2) { + Some([0xFE, 0xFF]) => char::decode_utf16(units(true)).map(|c| c.unwrap_or('\u{FFFD}')).collect(), + Some([0xFF, 0xFE]) => char::decode_utf16(units(false)).map(|c| c.unwrap_or('\u{FFFD}')).collect(), + _ => String::from_utf8_lossy(&bytes).into_owned(), + }; + Some(xml_text(&text)) +} + +/// Remove the metadata that still holds `removed` text: matching `/Info` entries, and the +/// catalog's and pages' XMP streams that mention it. Returns how many entries and streams went. +pub(crate) fn scrub(doc: &mut Document, removed: &[String]) -> Result { + let terms = terms(removed); + if terms.is_empty() { + return Ok(0); + } + let mut n = 0; + + // The information dictionary, direct in the trailer or an object of its own. + let info_obj = doc.trailer().get(b"Info").cloned(); + if let Some(info) = info_obj.as_ref().and_then(|i| doc.resolve(i).as_dict().cloned()) { + let doomed: Vec> = info + .iter() + .filter(|(_, v)| doc.resolve(v).as_string().is_some_and(|s| mentions(&s.to_text(), &terms))) + .map(|(k, _)| k.to_vec()) + .collect(); + if !doomed.is_empty() { + n += doomed.len(); + let mut kept = info.clone(); + for k in &doomed { + kept.remove(k); + } + match info_obj.as_ref().and_then(Object::as_ref) { + Some(r) => doc.update_dict(r, |d| *d = kept)?, + None => doc.trailer_mut().set(b"Info".to_vec(), Object::Dict(kept)), + } + } + } + + // XMP on the catalog and on every page. + let mut holders: Vec = doc.root().into_iter().collect(); + holders.extend(pdfcraft_model::pages(doc).iter().map(|p| p.obj)); + for holder in holders { + let Some(xmp) = doc.get(holder).as_dict().and_then(|d| d.get(b"Metadata").cloned()) else { continue }; + if xmp_text(doc, &xmp).is_none_or(|t| mentions(&t, &terms)) { + doc.update_dict(holder, |d| { + d.remove(b"Metadata"); + })?; + n += 1; + } + } + Ok(n) +} diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index c072d19db7..a44dd68a63 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -104,6 +104,84 @@ fn a_whole_line_under_a_mark_goes_even_without_widths() { assert_eq!(under(&mut doc, 0, &[[0.0, 0.0, 300.0, 300.0]]), 0, "{}", content(&doc, 0)); } +/// Issue #2448: the redacted words are often in the document information dictionary and the XMP +/// metadata too. Applying removes the entries and streams that repeat them, and keeps the rest. +#[test] +fn redacted_text_does_not_survive_in_info_or_xmp() { + let xmp = |body: &str| { + stream( + "/Type /Metadata /Subtype /XML", + format!( + r#"{body}"# + ) + .as_bytes(), + ) + }; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Metadata 7 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Metadata 9 0 R >>".to_vec(), + // "BLUEFIN" spans x 55-90. + stream("", b"BT /F1 10 Tf 10 100 Td (Codename BLUEFIN report) Tj ET"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + // 7: the document's XMP names it in an attribute, through a character reference. + xmp(r#""#), + // 8: the information dictionary. + b"<< /Title (Codename BLUEFIN report) /Keywords (tuna, bluefin) /Author (Ada Lovelace) /Producer (Test) >>".to_vec(), + // 9: the page's XMP doesn't mention it. + xmp(r#"Annual figures"#), + ]; + let mut doc = Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry("/Info 8 0 R").build())).unwrap(); + mark(&mut doc, 0, &[[55.0, 95.0, 90.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.glyphs, r.metadata), (7, 3), "{r:?}"); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle)); + assert!(!has(b"bluefin") && !has(b"BlueFin"), "the redacted word survived in the metadata"); + let saved = Document::open(Arc::new(bytes)).unwrap(); + let info = saved.trailer().get(b"Info").and_then(|i| saved.resolve(i).as_dict().cloned()).unwrap(); + let text = |k: &[u8]| info.get(k).and_then(|v| saved.resolve(v).as_string().map(|s| s.to_text())); + assert_eq!((text(b"Title"), text(b"Keywords")), (None, None)); + assert_eq!((text(b"Author").as_deref(), text(b"Producer").as_deref()), (Some("Ada Lovelace"), Some("Test"))); + let catalog = saved.get(saved.root().unwrap()).as_dict().cloned().unwrap(); + assert!(!catalog.contains(b"Metadata"), "the document XMP named the redacted word"); + assert!(pdfcraft_model::pages(&saved)[0].dict.contains(b"Metadata"), "the page XMP didn't, and stays"); +} + +/// The removed text is collected in runs by position, not by operator: glyphs shown one operator +/// at a time still make one word, a gap makes a space, and separate marks make separate runs. +#[test] +fn removed_text_is_collected_in_runs() { + let mut doc = one_page( + b"BT /F1 10 Tf 10 100 Td (BLU) Tj 15 0 Td (E) Tj 5 0 Td [(F) -300 (IN)] TJ ( 7) Tj ET BT /F1 10 Tf 10 200 Td (Gary) Tj ET", + "", + vec![], + ); + let page = pdfcraft_model::pages(&doc).swap_remove(0); + let (_, data) = page_streams(&doc, &page.dict, 0).unwrap(); + let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let mut rep = Report::default(); + let rects = [[10.0, 95.0, 300.0, 110.0], [10.0, 195.0, 30.0, 210.0]]; + let mut scope = Scope::new(&rects, Mode::Apply, &mut rep, pdfcraft_cos::DecodeBudget::page()); + process(&mut doc, &mut scope, &data, &res, pdfcraft_content::Matrix::IDENTITY); + scope.flush(); + assert_eq!(scope.removed_text, ["BLUEF IN 7", "Gary"]); +} + +#[test] +fn metadata_terms_and_xmp_text() { + // Runs of two letters or digits or more are matched whole, and by their words of three or + // more, ignoring case and spacing. + let t = metadata::terms(&["John Smith".into(), "7".into(), "of".into(), "A-12".into()]); + assert_eq!(t, ["john smith", "john", "smith", "of", "a-12"]); + // XMP text is element content and attribute values, entities decoded; not names or comments. + let x = metadata::xml_text(r#"Résumé ]]>"#); + let words: Vec<&str> = x.split_whitespace().collect(); + assert_eq!(words, ["x", "&", "y", "Résumé", ""]); + assert!(!x.contains("title") && !x.contains("hidden")); +} + #[test] fn added_text_parameters_do_not_keep_redacted_text() { // An Edit ▸ Add content item: its stream dictionary keeps the source text under /PCAdded. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3b8dba920c..2cf652df2a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5768,8 +5768,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::the_renderer_check_refuses_text_still_shown_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources"] -notes = "confirmation; one undo step; next save is a full rewrite. Refused, with nothing changed, when the page still shows text under a mark afterwards: text in some predefined CMaps and rotated Type 3 fonts is not removed yet, so those documents cannot be redacted. Also refused on a page with an inline image the renderer reads no image for, since the operators after it can't be known. Removed images and forms are unlinked from page-tree resources (own, inherited or shared) and from surviving forms' resources, not yet from annotation appearances or patterns" +evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::the_renderer_check_refuses_text_still_shown_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources", "crates/redact/src/tests.rs::redacted_text_does_not_survive_in_info_or_xmp"] +notes = "confirmation; one undo step; next save is a full rewrite. Document information entries that contain the removed text, and the document's and pages' XMP streams that do, are removed (#2448; matched without regard to case or spacing, by the whole run of removed text and by its words of three letters or digits or more); other objects' XMP, bookmarks and attachments are not searched. Refused, with nothing changed, when the page still shows text under a mark afterwards: text in some predefined CMaps and rotated Type 3 fonts is not removed yet, so those documents cannot be redacted. Also refused on a page with an inline image the renderer reads no image for, since the operators after it can't be known. Removed images and forms are unlinked from page-tree resources (own, inherited or shared) and from surviving forms' resources, not yet from annotation appearances or patterns" [[feature]] id = "protect.redact-glyph-removal" From cf2207cbea17344d2ce9e531c7b32a27ab586918 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:31:49 +0800 Subject: [PATCH 023/101] M8: an Info value that is part of the redacted text goes too (#2448) An /Info value whose words are too short to be terms ("J. Li") survived when the sentence holding it was redacted. A value that is wholly part of a removed run, with three letters or digits at least, now goes as well (the reverse check #2598 proposed). Co-Authored-By: Claude Opus 5.5 --- crates/redact/README.md | 3 ++- crates/redact/src/metadata.rs | 24 ++++++++++++++++++++++-- crates/redact/src/tests.rs | 22 ++++++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/redact/README.md b/crates/redact/README.md index 55c0e02f85..3659a8e0e1 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -30,7 +30,8 @@ clear_marks(&mut doc, None)?; // remove marks without app widget under a mark; - the marks themselves, replaced by boxes in their fill colour (and overlay text) drawn into the page as a `/PCMark /Redaction` stream; -- **metadata** that repeats the removed text (#2448): `/Info` entries containing it, and the +- **metadata** that repeats the removed text (#2448): `/Info` entries containing it or wholly + part of it (three letters or digits at least), and the catalog's and pages' XMP streams whose text (element content and attribute values, entities decoded) contains it, removed whole; unreadable XMP goes too. The removed glyphs' text is collected in runs by position, and each run (two or more letters or digits) and each of its diff --git a/crates/redact/src/metadata.rs b/crates/redact/src/metadata.rs index 821e744b06..385f637ee8 100644 --- a/crates/redact/src/metadata.rs +++ b/crates/redact/src/metadata.rs @@ -4,7 +4,9 @@ //! document information dictionary (`/Title`, `/Subject`, `/Keywords`…) and the XMP metadata //! that mirrors it. Once the page no longer shows them, the metadata must not keep them either. //! -//! An `/Info` entry whose text contains a redacted term is removed. An XMP stream whose text +//! An `/Info` entry whose text contains a redacted term is removed, and so is one whose whole +//! value is part of a removed run (an author "J. Li" redacted from a sentence, whose words are too +//! short to be terms; the reverse check #2598 also proposed). An XMP stream whose text //! (element content and attribute values, entities decoded) contains one is removed whole: //! editing the XML in place could leave it malformed or keep the term in another spelling. //! Unreadable XMP goes too, and so does text too large to search against every term @@ -58,6 +60,18 @@ fn mentions(text: &str, terms: &[String]) -> bool { text.len().saturating_mul(terms.len()) > MAX_WORK || terms.iter().any(|t| text.contains(t.as_str())) } +/// Is `value` itself part of one of the removed `runs` (normalized)? It needs three letters or +/// digits, so an entry such as "by" doesn't go with every sentence that contains it. Too much +/// text to search counts as part (fail-closed). +fn within(value: &str, runs: &[String]) -> bool { + let value = normalize(value); + if !significant(&value, 3) { + return false; + } + let total = runs.iter().map(String::len).fold(0usize, usize::saturating_add); + value.len().saturating_mul(total) > MAX_WORK || runs.iter().any(|r| r.contains(value.as_str())) +} + /// Decode the five predefined XML entities and character references; anything else stays. fn unescape(s: &str) -> String { let mut out = String::with_capacity(s.len()); @@ -175,6 +189,7 @@ pub(crate) fn scrub(doc: &mut Document, removed: &[String]) -> Result = removed.iter().take(MAX_TERMS).map(|r| normalize(r).chars().take(MAX_TERM_CHARS).collect()).collect(); let mut n = 0; // The information dictionary, direct in the trailer or an object of its own. @@ -182,7 +197,12 @@ pub(crate) fn scrub(doc: &mut Document, removed: &[String]) -> Result> = info .iter() - .filter(|(_, v)| doc.resolve(v).as_string().is_some_and(|s| mentions(&s.to_text(), &terms))) + .filter(|(_, v)| { + doc.resolve(v).as_string().is_some_and(|s| { + let s = s.to_text(); + mentions(&s, &terms) || within(&s, &runs) + }) + }) .map(|(k, _)| k.to_vec()) .collect(); if !doomed.is_empty() { diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index a44dd68a63..c8459ba4d0 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -149,6 +149,28 @@ fn redacted_text_does_not_survive_in_info_or_xmp() { assert!(pdfcraft_model::pages(&saved)[0].dict.contains(b"Metadata"), "the page XMP didn't, and stays"); } +/// An `/Info` value that is itself part of the removed text goes, though its words are too short +/// to be terms ("J. Li"); a value with fewer than three letters or digits stays. +#[test] +fn an_info_value_inside_the_redacted_text_goes() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + stream("", b"BT /F1 10 Tf 10 100 Td (Prepared by J. Li) Tj ET"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + b"<< /Author (J. li) /Subject (by) /Producer (Test) >>".to_vec(), + ]; + let mut doc = Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry("/Info 7 0 R").build())).unwrap(); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.metadata, 1, "{r:?}"); + let info = doc.trailer().get(b"Info").and_then(|i| doc.resolve(i).as_dict().cloned()).unwrap(); + assert!(!info.contains(b"Author"), "the author's name was redacted from the page"); + assert!(info.contains(b"Subject") && info.contains(b"Producer")); +} + /// The removed text is collected in runs by position, not by operator: glyphs shown one operator /// at a time still make one word, a gap makes a space, and separate marks make separate runs. #[test] From 459cf4af5d2559b56302ced43b5606eec3c4c2f0 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:31:49 +0800 Subject: [PATCH 024/101] M8: test that a shorter value goes from custom XMP too (#2448) A value shorter than the redacted sentence ("James Bond" from "JAMES BOND IS A SPY") in a custom schema's element or attribute takes its packet with it, as it goes from /Info, while a clean packet and an unrelated entry stay. The regression #2598 suggested keeping whichever fix lands. Co-Authored-By: Claude Opus 5.5 --- crates/redact/src/tests.rs | 45 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index c8459ba4d0..eff0c3d525 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -171,6 +171,51 @@ fn an_info_value_inside_the_redacted_text_goes() { assert!(info.contains(b"Subject") && info.contains(b"Producer")); } +/// A value shorter than the redacted sentence goes from XMP too, in a custom schema's element or +/// attribute, as from `/Info` (the regression #2598 suggested keeping): the packets holding it are +/// removed, one that doesn't stays. +#[test] +fn a_shorter_value_in_custom_xmp_goes() { + let xmp = |body: &str| { + stream( + "/Type /Metadata /Subtype /XML", + format!( + r#"{body}"# + ) + .as_bytes(), + ) + }; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Metadata 7 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Metadata 8 0 R >>".to_vec(), + stream("", b"BT /F1 10 Tf 10 100 Td (JAMES BOND IS A SPY) Tj ET"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + // 7: an element of a custom schema. + xmp(r#"James Bond"#), + // 8: an attribute of a custom schema. + xmp(r#""#), + // 9: a third packet, on no holder yet; swapped onto the page below to show a clean one stays. + xmp(r#"Annual figures"#), + b"<< /Author (James Bond) /Subject (Annual figures) >>".to_vec(), + ]; + for (holder_of_8, expected) in [("8 0 R", 3), ("9 0 R", 2)] { + let objs: Vec> = + objs.iter().map(|o| String::from_utf8_lossy(o).replace("/Metadata 8 0 R", &format!("/Metadata {holder_of_8}")).into_bytes()).collect(); + let mut doc = Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry("/Info 10 0 R").build())).unwrap(); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.metadata, expected, "{holder_of_8}: {r:?}"); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + assert!(!catalog.contains(b"Metadata"), "the custom element named him"); + let page_keeps = pdfcraft_model::pages(&doc)[0].dict.contains(b"Metadata"); + assert_eq!(page_keeps, holder_of_8 == "9 0 R", "the attribute packet goes, a clean one stays"); + let info = doc.trailer().get(b"Info").and_then(|i| doc.resolve(i).as_dict().cloned()).unwrap(); + assert!(!info.contains(b"Author") && info.contains(b"Subject"), "the author goes, the unrelated subject stays"); + } +} + /// The removed text is collected in runs by position, not by operator: glyphs shown one operator /// at a time still make one word, a gap makes a space, and separate marks make separate runs. #[test] From c405d280c6ef3e97df0fb28610a16f761d4b1a57 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:31:49 +0800 Subject: [PATCH 025/101] M8: metadata check fails closed and finds letter-spaced and short XMP values (#2448) Review follow-up on #2539: - the glyph end includes character and word spacing, so letter-spaced redacted text is one word and still matches /Title (Bluefin); - an XMP value wholly part of a removed run goes, as an /Info value does (J. Li), compared as whole words (True stays with "untrue"); - when the removed text was cut short (too many runs, a run too long, too many terms) every /Info string and XMP stream goes instead of searching what was kept; - XMP that is not valid UTF-8 or UTF-16 (also UTF-16 without a leading mark) is not searched lossily but removed; - namespace declarations and the xpacket wrapper are not searched; comments and other processing instructions are; - the raw-byte assertion saves without object streams so it can see /Info. Co-Authored-By: Claude Opus 5.5 --- crates/redact/README.md | 17 ++-- crates/redact/src/interp.rs | 19 +++- crates/redact/src/lib.rs | 10 +- crates/redact/src/metadata.rs | 177 ++++++++++++++++++++++------------ crates/redact/src/tests.rs | 117 ++++++++++++++++++++-- parity/acrobat-features.toml | 4 +- 6 files changed, 259 insertions(+), 85 deletions(-) diff --git a/crates/redact/README.md b/crates/redact/README.md index 3659a8e0e1..eb3bce469e 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -31,12 +31,17 @@ clear_marks(&mut doc, None)?; // remove marks without app - the marks themselves, replaced by boxes in their fill colour (and overlay text) drawn into the page as a `/PCMark /Redaction` stream; - **metadata** that repeats the removed text (#2448): `/Info` entries containing it or wholly - part of it (three letters or digits at least), and the - catalog's and pages' XMP streams whose text (element content and attribute values, entities - decoded) contains it, removed whole; unreadable XMP goes too. The removed glyphs' text is - collected in runs by position, and each run (two or more letters or digits) and each of its - words of three or more are matched without regard to case or spacing. `Report::metadata` - counts what went. + part of it as whole words (three letters or digits at least), and the catalog's and pages' + XMP streams whose text (element content, attribute values other than `xmlns` declarations, + comments and processing instructions, entities decoded) contains it or has a value wholly + part of it, removed whole, together with any PDF/A identification in that packet. XMP that + isn't valid UTF-8 or UTF-16 goes too, and when the removed text was too much to collect + every `/Info` string and every XMP stream goes (fail-closed). The removed glyphs' text is + collected in runs by position (character and word spacing included, so letter-spaced text + stays one word), and each run (two or more letters or digits) and each of its words of three + or more are matched without regard to case or spacing. A value that shares only words of one + or two letters with the removed text, and isn't wholly part of it, stays: Sanitize's + metadata category removes the rest. `Report::metadata` counts what went. A verification pass re-reads each redacted page and fails the operation if any glyph or inline image is still under an area. It checks every content stream the page had, whatever its diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 42e2045aab..6781a4060c 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -129,6 +129,9 @@ pub(crate) struct Scope<'a> { /// glyphs (#2448), so the document's metadata can be checked for it. Call [`Scope::flush`] /// after processing to close the last run. pub removed_text: Vec, + /// Some removed text was not kept (too many runs, or a run too long): the metadata check + /// can't search for it and must fail closed. + pub removed_cut: bool, /// The run being collected, and where its last glyph ended (device space) with its height. run: String, run_end: Option<((f64, f64), f64)>, @@ -157,6 +160,7 @@ impl<'a> Scope<'a> { xobject_oc_cache: HashMap::new(), layer_blocks: 0, removed_text: Vec::new(), + removed_cut: false, run: String::new(), run_end: None, fonts: HashMap::new(), @@ -170,8 +174,12 @@ impl<'a> Scope<'a> { pub fn flush(&mut self) { self.run_end = None; let run = std::mem::take(&mut self.run); - if !run.trim().is_empty() && self.removed_text.len() < MAX_RUNS { - self.removed_text.push(run); + if !run.trim().is_empty() { + if self.removed_text.len() < MAX_RUNS { + self.removed_text.push(run); + } else { + self.removed_cut = true; + } } } @@ -191,6 +199,8 @@ impl<'a> Scope<'a> { } if self.run.len().saturating_add(text.len()) <= MAX_RUN_BYTES { self.run.push_str(text); + } else { + self.removed_cut = true; } self.run_end = Some((end, height)); } @@ -723,7 +733,10 @@ fn show(scope: &mut Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (V }; if scope.mode == Mode::Apply && scope.hidden_text.is_none() { if hit { - let end = trm.apply(w0, 0.0); + // The glyph ends after its character and word spacing, in `trm`'s + // units (the size is in `trm`), so letter-spaced text stays one word. + let spaced = if size != 0.0 { w0 + spacing / size } else { w0 }; + let end = trm.apply(spaced, 0.0); let top = trm.apply(0.0, 1.0); let height = (top.0 - origin.0).hypot(top.1 - origin.1); scope.removed(f.text_of(code).as_deref().unwrap_or(""), origin, end, height); diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 643dc5671e..9bc3672027 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -423,6 +423,7 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result) -> Result, /// The text of the removed glyphs, for the metadata check at the end. removed_text: Vec, + /// Some removed text was too long to keep whole for that check. + removed_cut: bool, } /// Step 1 for one page: rewrites its content so nothing readable stays under a mark, and cleans the tags it no longer shows. @@ -561,7 +564,7 @@ fn redact_page_content( page: &pdfcraft_model::Page, rects: &[[f64; 4]], ) -> Result<(), RedactError> { - let Applying { report, doomed_xobjects, retired_properties, removed_text, .. } = applying; + let Applying { report, doomed_xobjects, retired_properties, removed_text, removed_cut, .. } = applying; // 1. Content. let mut budget = pdfcraft_cos::DecodeBudget::page(); let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; @@ -571,6 +574,7 @@ fn redact_page_content( let out = process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY); scope.flush(); removed_text.append(&mut scope.removed_text); + *removed_cut |= scope.removed_cut; out }; // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot diff --git a/crates/redact/src/metadata.rs b/crates/redact/src/metadata.rs index 385f637ee8..b6ef956192 100644 --- a/crates/redact/src/metadata.rs +++ b/crates/redact/src/metadata.rs @@ -7,10 +7,15 @@ //! An `/Info` entry whose text contains a redacted term is removed, and so is one whose whole //! value is part of a removed run (an author "J. Li" redacted from a sentence, whose words are too //! short to be terms; the reverse check #2598 also proposed). An XMP stream whose text -//! (element content and attribute values, entities decoded) contains one is removed whole: -//! editing the XML in place could leave it malformed or keep the term in another spelling. -//! Unreadable XMP goes too, and so does text too large to search against every term -//! (fail-closed). Terms are matched without regard to case or spacing. +//! (element content, attribute values other than namespace declarations, comments and +//! processing instructions, entities decoded) contains one, or has a value that is part of a +//! removed run, is removed whole: editing the XML in place could leave it malformed or keep the +//! term in another spelling. A PDF/A identification in that packet goes with it. +//! +//! Fail-closed: XMP that isn't UTF-8 or UTF-16 goes, text too large to search against every term +//! counts as matching, and when the removed text itself was cut short (too many runs or terms, +//! or a run too long to keep) every `/Info` string and every XMP stream goes, since what was +//! never collected can't be searched for. Terms are matched without regard to case or spacing. use std::collections::HashSet; @@ -36,23 +41,28 @@ fn significant(s: &str, min: usize) -> bool { /// The terms to look for: each removed run (at least two letters or digits), and each of its /// words with at least three, so "Smith, John" in `/Author` is caught when "John Smith" was -/// redacted. Shorter pieces would match nearly any text. -pub(crate) fn terms(removed: &[String]) -> Vec { +/// redacted. Shorter pieces would match nearly any text. `None` when a run is longer than +/// [`MAX_TERM_CHARS`] or there are more than [`MAX_TERMS`] terms: the caller then can't search +/// for all of it and must clear the metadata instead. +pub(crate) fn terms(removed: &[String]) -> Option> { let mut seen = HashSet::new(); let mut out = Vec::new(); for run in removed { - let run: String = normalize(run).chars().take(MAX_TERM_CHARS).collect(); + let run = normalize(run); + if run.chars().nth(MAX_TERM_CHARS).is_some() { + return None; + } let words = run.split(|c: char| !c.is_alphanumeric()).filter(|w| significant(w, 3)).map(str::to_string).collect::>(); for t in std::iter::once(run.clone()).filter(|r| significant(r, 2)).chain(words) { - if out.len() >= MAX_TERMS { - return out; - } if seen.insert(t.clone()) { + if out.len() >= MAX_TERMS { + return None; + } out.push(t); } } } - out + Some(out) } fn mentions(text: &str, terms: &[String]) -> bool { @@ -60,8 +70,9 @@ fn mentions(text: &str, terms: &[String]) -> bool { text.len().saturating_mul(terms.len()) > MAX_WORK || terms.iter().any(|t| text.contains(t.as_str())) } -/// Is `value` itself part of one of the removed `runs` (normalized)? It needs three letters or -/// digits, so an entry such as "by" doesn't go with every sentence that contains it. Too much +/// Is `value` itself part of one of the removed `runs` (normalized), as whole words? It needs +/// three letters or digits, so an entry such as "by" doesn't go with every sentence that +/// contains it, and word boundaries, so an XMP value "True" doesn't go with "untrue". Too much /// text to search counts as part (fail-closed). fn within(value: &str, runs: &[String]) -> bool { let value = normalize(value); @@ -69,7 +80,15 @@ fn within(value: &str, runs: &[String]) -> bool { return false; } let total = runs.iter().map(String::len).fold(0usize, usize::saturating_add); - value.len().saturating_mul(total) > MAX_WORK || runs.iter().any(|r| r.contains(value.as_str())) + let word = |c: Option| c.is_some_and(char::is_alphanumeric); + let whole = |r: &String| { + r.match_indices(value.as_str()).any(|(at, m)| { + let before = r.get(..at).and_then(|s| s.chars().next_back()); + let after = r.get(at.saturating_add(m.len())..).and_then(|s| s.chars().next()); + !(word(before) && value.starts_with(char::is_alphanumeric)) && !(word(after) && value.ends_with(char::is_alphanumeric)) + }) + }; + value.len().saturating_mul(total) > MAX_WORK || runs.iter().any(whole) } /// Decode the five predefined XML entities and character references; anything else stays. @@ -110,34 +129,42 @@ fn unescape(s: &str) -> String { out } -/// The text an XML document carries: element content and attribute values (XMP stores simple -/// properties as attributes), each followed by a space, entities decoded. Names, comments and -/// markup are left out, so a term can't match a tag such as `dc:title`. -pub(crate) fn xml_text(xml: &str) -> String { - let mut out = String::new(); +/// The text values an XML document carries, entities decoded: element content, attribute values +/// (XMP stores simple properties as attributes), CDATA, and the text of comments and processing +/// instructions other than the `xpacket` wrapper, which a reader can still see. Names and markup are left out, so a term can't +/// match a tag such as `dc:title`, and so are namespace declarations (`xmlns`), whose URIs +/// would match words such as "adobe" or "application" in every packet. +pub(crate) fn xml_values(xml: &str) -> Vec { + let mut out = Vec::new(); + let mut push = |s: String| { + if !s.trim().is_empty() { + out.push(s); + } + }; let mut rest = xml; while !rest.is_empty() { let Some(open) = rest.find('<') else { - out.push_str(&unescape(rest)); + push(unescape(rest)); break; }; - out.push_str(&unescape(rest.get(..open).unwrap_or(""))); - out.push(' '); + push(unescape(rest.get(..open).unwrap_or(""))); let tag = rest.get(open..).unwrap_or(""); - if let Some(body) = tag.strip_prefix("").unwrap_or(body.len()); - out.push_str(body.get(..end).unwrap_or("")); - out.push(' '); - rest = body.get(end + 3..).unwrap_or(""); - continue; - } - if tag.starts_with("").and_then(|e| tag.get(e + 3..)).unwrap_or(""); + let delimited = [(""), (""), ("")].into_iter().find(|(start, _)| tag.starts_with(start)); + if let Some((start, stop)) = delimited { + let body = tag.get(start.len()..).unwrap_or(""); + let end = body.find(stop).unwrap_or(body.len()); + let text = body.get(..end).unwrap_or(""); + // The packet wrapper's fixed `begin`/`id`/`end` would match words such as "end". + if !(start == "` (which can't be inside quotes). let mut chars = tag.char_indices().skip(1); let mut end = tag.len(); + let mut name = String::new(); while let Some((i, c)) = chars.next() { match c { '>' => { @@ -152,10 +179,15 @@ pub(crate) fn xml_text(xml: &str) -> String { } value.push(d); } - out.push_str(&unescape(&value)); - out.push(' '); + let declaration = name == "xmlns" || name.starts_with("xmlns:"); + if !declaration { + push(unescape(&value)); + } + name.clear(); } - _ => {} + '=' => {} + c if c.is_whitespace() || c == '/' => name.clear(), + c => name.push(c), } } rest = tag.get(end..).unwrap_or(""); @@ -163,48 +195,57 @@ pub(crate) fn xml_text(xml: &str) -> String { out } -/// The decoded text of an XMP stream (UTF-8, or UTF-16 with a byte-order mark), or `None` when it -/// can't be read. -fn xmp_text(doc: &Document, o: &Object) -> Option { +/// Decode an XMP packet: UTF-8 (with or without a byte-order mark), or UTF-16 told by its mark +/// or, as a packet usually begins ` Option { + let utf16 = |body: &[u8], be: bool| -> Option { + let (units, rest) = body.as_chunks::<2>(); + if !rest.is_empty() { + return None; + } + char::decode_utf16(units.iter().map(|&p| if be { u16::from_be_bytes(p) } else { u16::from_le_bytes(p) })).collect::>().ok() + }; + match bytes { + [0xEF, 0xBB, 0xBF, rest @ ..] => std::str::from_utf8(rest).ok().map(str::to_string), + [0xFE, 0xFF, rest @ ..] => utf16(rest, true), + [0xFF, 0xFE, rest @ ..] => utf16(rest, false), + [0, a, ..] if *a != 0 => utf16(bytes, true), + [a, 0, ..] if *a != 0 => utf16(bytes, false), + _ => std::str::from_utf8(bytes).ok().map(str::to_string), + } +} + +/// The text values of an XMP stream, or `None` when it can't be read. +fn xmp_values(doc: &Document, o: &Object) -> Option> { let Object::Stream(s) = &*doc.resolve(o) else { return None }; let bytes = s.decoded().ok()?; if bytes.len() > MAX_XMP_BYTES { return None; } - let units = |be: bool| { - bytes.get(2..).unwrap_or(&[]).as_chunks::<2>().0.iter().map(move |&p| if be { u16::from_be_bytes(p) } else { u16::from_le_bytes(p) }) - }; - let text = match bytes.get(..2) { - Some([0xFE, 0xFF]) => char::decode_utf16(units(true)).map(|c| c.unwrap_or('\u{FFFD}')).collect(), - Some([0xFF, 0xFE]) => char::decode_utf16(units(false)).map(|c| c.unwrap_or('\u{FFFD}')).collect(), - _ => String::from_utf8_lossy(&bytes).into_owned(), - }; - Some(xml_text(&text)) + decode_xmp(&bytes).map(|t| xml_values(&t)) } /// Remove the metadata that still holds `removed` text: matching `/Info` entries, and the -/// catalog's and pages' XMP streams that mention it. Returns how many entries and streams went. -pub(crate) fn scrub(doc: &mut Document, removed: &[String]) -> Result { - let terms = terms(removed); - if terms.is_empty() { - return Ok(0); - } - let runs: Vec = removed.iter().take(MAX_TERMS).map(|r| normalize(r).chars().take(MAX_TERM_CHARS).collect()).collect(); +/// catalog's and pages' XMP streams that mention it. `complete` is false when the collection of +/// removed text was cut short; then, as when the terms themselves exceed their limits, every +/// `/Info` string and every XMP stream goes. Returns how many entries and streams went. +pub(crate) fn scrub(doc: &mut Document, removed: &[String], complete: bool) -> Result { + // `None` matches everything (fail-closed). + let search = match terms(removed).filter(|_| complete) { + Some(t) if t.is_empty() => return Ok(0), + Some(t) => Some((t, removed.iter().map(|r| normalize(r)).collect::>())), + None => None, + }; + let hit = |text: &str| search.as_ref().is_none_or(|(terms, runs)| mentions(text, terms) || within(text, runs)); let mut n = 0; // The information dictionary, direct in the trailer or an object of its own. let info_obj = doc.trailer().get(b"Info").cloned(); if let Some(info) = info_obj.as_ref().and_then(|i| doc.resolve(i).as_dict().cloned()) { - let doomed: Vec> = info - .iter() - .filter(|(_, v)| { - doc.resolve(v).as_string().is_some_and(|s| { - let s = s.to_text(); - mentions(&s, &terms) || within(&s, &runs) - }) - }) - .map(|(k, _)| k.to_vec()) - .collect(); + let doomed: Vec> = + info.iter().filter(|(_, v)| doc.resolve(v).as_string().is_some_and(|s| hit(&s.to_text()))).map(|(k, _)| k.to_vec()).collect(); if !doomed.is_empty() { n += doomed.len(); let mut kept = info.clone(); @@ -223,7 +264,15 @@ pub(crate) fn scrub(doc: &mut Document, removed: &[String]) -> Result true, + Some(values) => { + let words = |(terms, _): &(Vec, Vec)| mentions(&values.join(" "), terms); + let part = |(_, runs): &(Vec, Vec)| values.iter().any(|v| within(v, runs)); + search.as_ref().is_none_or(|s| words(s) || part(s)) + } + }; + if doomed { doc.update_dict(holder, |d| { d.remove(b"Metadata"); })?; diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index eff0c3d525..58194c5cac 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -136,8 +136,10 @@ fn redacted_text_does_not_survive_in_info_or_xmp() { mark(&mut doc, 0, &[[55.0, 95.0, 90.0, 110.0]], ""); let r = apply(&mut doc, None).unwrap(); assert_eq!((r.glyphs, r.metadata), (7, 3), "{r:?}"); - let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + // Without object streams, so the byte search below can see the `/Info` dictionary. + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle)); + assert!(has(b"Ada Lovelace"), "the kept /Info entry is visible to the search"); assert!(!has(b"bluefin") && !has(b"BlueFin"), "the redacted word survived in the metadata"); let saved = Document::open(Arc::new(bytes)).unwrap(); let info = saved.trailer().get(b"Info").and_then(|i| saved.resolve(i).as_dict().cloned()).unwrap(); @@ -171,6 +173,90 @@ fn an_info_value_inside_the_redacted_text_goes() { assert!(info.contains(b"Subject") && info.contains(b"Producer")); } +/// One page showing `content`, with `/Info` `info` and catalog XMP whose `rdf:RDF` holds +/// `xmp_body`. +fn with_metadata(content: &[u8], info: &str, xmp_body: &str) -> Document { + let xmp = format!( + r#"{xmp_body}"# + ); + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Metadata 7 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + stream("", content), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + stream("/Type /Metadata /Subtype /XML", xmp.as_bytes()), + info.as_bytes().to_vec(), + ]; + Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry("/Info 8 0 R").build())).unwrap() +} + +fn info_keys(doc: &Document) -> Vec { + let info = doc.trailer().get(b"Info").and_then(|i| doc.resolve(i).as_dict().cloned()).unwrap(); + info.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect() +} + +fn has_xmp(doc: &Document) -> bool { + doc.get(doc.root().unwrap()).as_dict().is_some_and(|c| c.contains(b"Metadata")) +} + +/// Letter-spaced text (character spacing wider than a word gap between plain glyphs) is still +/// one word, so `/Title (Bluefin)` goes when "B L U E F I N" is redacted (#2539 review). +#[test] +fn letter_spaced_redacted_text_still_matches_metadata() { + let body = r#"Bluefin"#; + let mut doc = with_metadata(b"BT /F1 10 Tf 3 Tc 10 100 Td (BLUEFIN) Tj ET", "<< /Title (Bluefin) /Producer (Test) >>", body); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.metadata, 2, "{r:?}"); + assert_eq!(info_keys(&doc), ["Producer"]); + assert!(!has_xmp(&doc)); +} + +/// An XMP value that is part of the removed text goes, as an `/Info` value does, though its +/// words are too short to be terms ("J. Li"). Values are compared as whole words, so "True" +/// doesn't go with "untrue", and namespace URIs and the `xpacket` wrapper aren't searched, so +/// redacting "Adobe … end" keeps a packet declared under `adobe:ns:meta/` and closed by +/// ``. +#[test] +fn xmp_values_inside_the_redacted_text_go_as_whole_words() { + let creator = r#"J. Li"#; + let mut doc = with_metadata(b"BT /F1 10 Tf 10 100 Td (Prepared by J. Li) Tj ET", "<< /Producer (Test) >>", creator); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.metadata, 1, "{r:?}"); + assert!(!has_xmp(&doc), "the packet named J. Li"); + + let trapped = r#""#; + let mut doc = with_metadata(b"BT /F1 10 Tf 10 100 Td (Adobe said untrue at the end) Tj ET", "<< /Subject (True) /Producer (Test) >>", trapped); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.metadata, 0, "{r:?}"); + assert!(has_xmp(&doc)); + assert_eq!(info_keys(&doc), ["Subject", "Producer"]); +} + +/// When the removed text was too much to collect, what wasn't collected can't be searched for: +/// every `/Info` string and the XMP go (fail-closed), even though the runs that were kept match +/// nothing. +#[test] +fn metadata_is_cleared_when_the_removed_text_was_cut_short() { + // More separate runs of one letter than are kept: each `A` is too far from the last to join. + let mut content = b"BT /F1 10 Tf 10 100 Td".to_vec(); + for _ in 0..50_001 { + content.extend_from_slice(b" (A) Tj 30 0 Td (A) Tj -30 0 Td"); + } + content.extend_from_slice(b" ET"); + let body = r#"Annual figures"#; + let mut doc = with_metadata(&content, "<< /Title (Annual figures) /Producer (Test) /Trapped /False >>", body); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.metadata, 3, "{r:?}"); + assert_eq!(info_keys(&doc), ["Trapped"], "strings go, the name stays"); + assert!(!has_xmp(&doc)); +} + /// A value shorter than the redacted sentence goes from XMP too, in a custom schema's element or /// attribute, as from `/Info` (the regression #2598 suggested keeping): the packets holding it are /// removed, one that doesn't stays. @@ -241,12 +327,29 @@ fn metadata_terms_and_xmp_text() { // Runs of two letters or digits or more are matched whole, and by their words of three or // more, ignoring case and spacing. let t = metadata::terms(&["John Smith".into(), "7".into(), "of".into(), "A-12".into()]); - assert_eq!(t, ["john smith", "john", "smith", "of", "a-12"]); - // XMP text is element content and attribute values, entities decoded; not names or comments. - let x = metadata::xml_text(r#"Résumé ]]>"#); - let words: Vec<&str> = x.split_whitespace().collect(); - assert_eq!(words, ["x", "&", "y", "Résumé", ""]); - assert!(!x.contains("title") && !x.contains("hidden")); + assert_eq!(t.as_deref(), Some(&["john smith", "john", "smith", "of", "a-12"].map(String::from)[..])); + // A run too long to search for whole can't be matched: the caller clears everything. + assert_eq!(metadata::terms(&["x".repeat(5000)]), None); + // XMP values: element content, attribute values, CDATA, comments and processing instructions, + // entities decoded; not names, and not namespace declarations. + let x = metadata::xml_values( + r#"Résumé ]]>"#, + ); + assert_eq!(x, ["audit James Bond", "x & y", " hidden ", "Résumé ", ""]); +} + +/// XMP is decoded from UTF-8 or UTF-16, the latter also without a byte-order mark at the start +/// (a packet's mark sits inside `Bond"; + let utf16 = |be: bool| text.encode_utf16().flat_map(|u| if be { u.to_be_bytes() } else { u.to_le_bytes() }).collect::>(); + assert_eq!(metadata::decode_xmp(text.as_bytes()).as_deref(), Some(text)); + assert_eq!(metadata::decode_xmp(&utf16(true)).as_deref(), Some(text)); + assert_eq!(metadata::decode_xmp(&utf16(false)).as_deref(), Some(text)); + assert_eq!(metadata::decode_xmp(&[[0xFF, 0xFE].as_slice(), &utf16(false)].concat()).as_deref(), Some(text)); + assert_eq!(metadata::decode_xmp(b"Bond \xE9"), None, "Latin-1 isn't searched lossily"); + assert_eq!(metadata::decode_xmp(&utf16(false)[1..]), None, "odd length"); } #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2cf652df2a..4fb222a7e0 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5768,8 +5768,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::the_renderer_check_refuses_text_still_shown_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources", "crates/redact/src/tests.rs::redacted_text_does_not_survive_in_info_or_xmp"] -notes = "confirmation; one undo step; next save is a full rewrite. Document information entries that contain the removed text, and the document's and pages' XMP streams that do, are removed (#2448; matched without regard to case or spacing, by the whole run of removed text and by its words of three letters or digits or more); other objects' XMP, bookmarks and attachments are not searched. Refused, with nothing changed, when the page still shows text under a mark afterwards: text in some predefined CMaps and rotated Type 3 fonts is not removed yet, so those documents cannot be redacted. Also refused on a page with an inline image the renderer reads no image for, since the operators after it can't be known. Removed images and forms are unlinked from page-tree resources (own, inherited or shared) and from surviving forms' resources, not yet from annotation appearances or patterns" +evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::the_renderer_check_refuses_text_still_shown_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources", "crates/redact/src/tests.rs::redacted_text_does_not_survive_in_info_or_xmp", "crates/redact/src/tests.rs::metadata_is_cleared_when_the_removed_text_was_cut_short"] +notes = "confirmation; one undo step; next save is a full rewrite. Document information entries that contain the removed text, and the document's and pages' XMP streams that do, are removed (#2448; matched without regard to case or spacing, by the whole run of removed text and by its words of three letters or digits or more, and a value wholly part of a run as whole words; fail-closed when the removed text was too much to collect or the XMP can't be decoded); other objects' XMP, bookmarks and attachments are not searched. Refused, with nothing changed, when the page still shows text under a mark afterwards: text in some predefined CMaps and rotated Type 3 fonts is not removed yet, so those documents cannot be redacted. Also refused on a page with an inline image the renderer reads no image for, since the operators after it can't be known. Removed images and forms are unlinked from page-tree resources (own, inherited or shared) and from surviving forms' resources, not yet from annotation appearances or patterns" [[feature]] id = "protect.redact-glyph-removal" From ca0cc61b2dcb9077a25e21805f207e01457514cc Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:14:24 +0800 Subject: [PATCH 026/101] M8: exact xpacket target, spaced xmlns declarations, XMP decoded within its cap (#2448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #2539: - only a processing instruction whose target is exactly `xpacket` is skipped, so `` is searched like any other; - an attribute name is kept across whitespace before `=`, so `xmlns:x = "adobe:ns:meta/"` is still a declaration, not a value; - the XMP stream is decoded with decoded_within(MAX_XMP_BYTES), so a small stream that inflates hugely stops at the cap instead of after it. Co-Authored-By: Claude Opus 5.5 --- crates/redact/src/metadata.rs | 25 ++++++++++++++++++------- crates/redact/src/tests.rs | 6 ++++-- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/crates/redact/src/metadata.rs b/crates/redact/src/metadata.rs index b6ef956192..84500ecc81 100644 --- a/crates/redact/src/metadata.rs +++ b/crates/redact/src/metadata.rs @@ -155,7 +155,7 @@ pub(crate) fn xml_values(xml: &str) -> Vec { let end = body.find(stop).unwrap_or(body.len()); let text = body.get(..end).unwrap_or(""); // The packet wrapper's fixed `begin`/`id`/`end` would match words such as "end". - if !(start == " Vec { // Attribute values inside the tag, up to its closing `>` (which can't be inside quotes). let mut chars = tag.char_indices().skip(1); let mut end = tag.len(); + // The attribute name being read, and the last one read before whitespace: XML allows + // whitespace around `=` (`xmlns:x = "…"`). let mut name = String::new(); + let mut last = String::new(); while let Some((i, c)) = chars.next() { match c { '>' => { @@ -179,14 +182,24 @@ pub(crate) fn xml_values(xml: &str) -> Vec { } value.push(d); } - let declaration = name == "xmlns" || name.starts_with("xmlns:"); + let attr = if name.is_empty() { &last } else { &name }; + let declaration = attr == "xmlns" || attr.starts_with("xmlns:"); if !declaration { push(unescape(&value)); } name.clear(); + last.clear(); } '=' => {} - c if c.is_whitespace() || c == '/' => name.clear(), + '/' => { + name.clear(); + last.clear(); + } + c if c.is_whitespace() => { + if !name.is_empty() { + last = std::mem::take(&mut name); + } + } c => name.push(c), } } @@ -220,10 +233,8 @@ pub(crate) fn decode_xmp(bytes: &[u8]) -> Option { /// The text values of an XMP stream, or `None` when it can't be read. fn xmp_values(doc: &Document, o: &Object) -> Option> { let Object::Stream(s) = &*doc.resolve(o) else { return None }; - let bytes = s.decoded().ok()?; - if bytes.len() > MAX_XMP_BYTES { - return None; - } + // Decoding stops at the cap, so a small stream that inflates hugely costs no more memory. + let bytes = s.decoded_within(MAX_XMP_BYTES).ok()?; decode_xmp(&bytes).map(|t| xml_values(&t)) } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 58194c5cac..19f553332f 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -333,9 +333,11 @@ fn metadata_terms_and_xmp_text() { // XMP values: element content, attribute values, CDATA, comments and processing instructions, // entities decoded; not names, and not namespace declarations. let x = metadata::xml_values( - r#"Résumé ]]>"#, + r#"Résumé ]]>"#, ); - assert_eq!(x, ["audit James Bond", "x & y", " hidden ", "Résumé ", ""]); + // Only the `xpacket` wrapper itself is skipped, and a declaration is one with spaces around + // `=` too. + assert_eq!(x, ["audit James Bond", "xpacketAudit SECRET", "spaced", "x & y", " hidden ", "Résumé ", ""]); } /// XMP is decoded from UTF-8 or UTF-16, the latter also without a byte-order mark at the start From 56ce7661d688d70b66eb394c35febe4fdf282ef8 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:44:19 +0800 Subject: [PATCH 027/101] M8: one search budget per metadata scrub, bounded entity scan, fail closed for unread forms (#2448) Review by Losminek on #2539: - The work cap was checked per value, so very many small /Info or XMP values could add up without bound. One budget now covers the whole scrub; past it every further entry or packet counts as matching (fail-closed). Identical removed runs are searched once. - unescape looked for ';' through the rest of the text before checking the 12-byte entity limit, so a packet of bare '&'s was quadratic. It now looks only at the next 13 characters (200,000 '&': 943 ms -> 5 ms). - A form XObject removed whole past the depth limit, or when its stream doesn't decode, never had its text read; both paths now set removed_cut so the metadata check clears everything instead of searching incomplete text. Co-Authored-By: Claude Opus 5.5 --- crates/redact/src/interp.rs | 3 ++ crates/redact/src/metadata.rs | 60 ++++++++++++++++++++++++++++------- crates/redact/src/tests.rs | 57 +++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 11 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 6781a4060c..2dcafb339f 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -861,6 +861,8 @@ fn xobject( return None; } scope.report.forms_removed += 1; + // Its text was never read, so the metadata check can't know what went with it. + scope.removed_cut = true; out.replaced.push(name); out.doomed.push(r); return Some(None); @@ -887,6 +889,7 @@ fn xobject( } Err(_) if scope.mode == Mode::Apply => { scope.report.forms_removed += 1; + scope.removed_cut = true; out.replaced.push(name); out.doomed.push(r); return Some(None); diff --git a/crates/redact/src/metadata.rs b/crates/redact/src/metadata.rs index 84500ecc81..c50401ef34 100644 --- a/crates/redact/src/metadata.rs +++ b/crates/redact/src/metadata.rs @@ -26,8 +26,9 @@ const MAX_TERM_CHARS: usize = 4096; const MAX_TERMS: usize = 20_000; /// XMP packets larger than this, decoded, are removed without reading them. const MAX_XMP_BYTES: usize = 16 * 1024 * 1024; -/// Text whose length times the number of terms exceeds this is treated as matching rather than -/// searched (fail-closed), so a page of removed text against a large packet stays quick. +/// The searching (text length times terms, or value length times removed text) one scrub may +/// do in all; past it every entry and packet counts as matching (fail-closed), so a page of +/// removed text against a large packet, or against very many small values, stays quick. const MAX_WORK: usize = 1 << 28; /// Lower-case `s` with every run of whitespace made one space, trimmed. @@ -65,16 +66,41 @@ pub(crate) fn terms(removed: &[String]) -> Option> { Some(out) } -fn mentions(text: &str, terms: &[String]) -> bool { +/// The searching one scrub may do, across every `/Info` entry and XMP value: past it, each +/// further question is answered "yes" (fail-closed), so a packet of many small values can't add +/// up to more than one large one. +struct Work(std::cell::Cell); + +impl Work { + fn new(limit: usize) -> Self { + Self(std::cell::Cell::new(limit)) + } + + /// Take `cost` from what is left; `false` (and nothing left) when it doesn't fit. + fn spend(&self, cost: usize) -> bool { + match self.0.get().checked_sub(cost) { + Some(left) => { + self.0.set(left); + true + } + None => { + self.0.set(0); + false + } + } + } +} + +fn mentions(text: &str, terms: &[String], work: &Work) -> bool { let text = normalize(text); - text.len().saturating_mul(terms.len()) > MAX_WORK || terms.iter().any(|t| text.contains(t.as_str())) + !work.spend(text.len().saturating_mul(terms.len())) || terms.iter().any(|t| text.contains(t.as_str())) } /// Is `value` itself part of one of the removed `runs` (normalized), as whole words? It needs /// three letters or digits, so an entry such as "by" doesn't go with every sentence that /// contains it, and word boundaries, so an XMP value "True" doesn't go with "untrue". Too much /// text to search counts as part (fail-closed). -fn within(value: &str, runs: &[String]) -> bool { +fn within(value: &str, runs: &[String], work: &Work) -> bool { let value = normalize(value); if !significant(&value, 3) { return false; @@ -88,7 +114,7 @@ fn within(value: &str, runs: &[String]) -> bool { !(word(before) && value.starts_with(char::is_alphanumeric)) && !(word(after) && value.ends_with(char::is_alphanumeric)) }) }; - value.len().saturating_mul(total) > MAX_WORK || runs.iter().any(whole) + !work.spend(value.len().saturating_mul(total)) || runs.iter().any(whole) } /// Decode the five predefined XML entities and character references; anything else stays. @@ -98,7 +124,8 @@ fn unescape(s: &str) -> String { while let Some(at) = rest.find('&') { out.push_str(rest.get(..at).unwrap_or("")); let tail = rest.get(at..).unwrap_or(""); - let end = tail.find(';').filter(|&e| e <= 12); + // An entity ends within 12 bytes; looking further made a text of bare `&`s quadratic. + let end = tail.char_indices().take(13).find(|&(_, c)| c == ';').map(|(e, _)| e); let entity = end.and_then(|e| tail.get(1..e)); let decoded = entity.and_then(|e| match e { "amp" => Some('&'), @@ -243,13 +270,24 @@ fn xmp_values(doc: &Document, o: &Object) -> Option> { /// removed text was cut short; then, as when the terms themselves exceed their limits, every /// `/Info` string and every XMP stream goes. Returns how many entries and streams went. pub(crate) fn scrub(doc: &mut Document, removed: &[String], complete: bool) -> Result { + scrub_within(doc, removed, complete, MAX_WORK) +} + +/// [`scrub`] with the searching it may do given, so tests can run out of it. +pub(crate) fn scrub_within(doc: &mut Document, removed: &[String], complete: bool, limit: usize) -> Result { // `None` matches everything (fail-closed). let search = match terms(removed).filter(|_| complete) { Some(t) if t.is_empty() => return Ok(0), - Some(t) => Some((t, removed.iter().map(|r| normalize(r)).collect::>())), + Some(t) => { + // The same run removed many times is searched for once. + let mut seen = HashSet::new(); + let runs: Vec = removed.iter().map(|r| normalize(r)).filter(|r| seen.insert(r.clone())).collect(); + Some((t, runs)) + } None => None, }; - let hit = |text: &str| search.as_ref().is_none_or(|(terms, runs)| mentions(text, terms) || within(text, runs)); + let work = Work::new(limit); + let hit = |text: &str| search.as_ref().is_none_or(|(terms, runs)| mentions(text, terms, &work) || within(text, runs, &work)); let mut n = 0; // The information dictionary, direct in the trailer or an object of its own. @@ -278,8 +316,8 @@ pub(crate) fn scrub(doc: &mut Document, removed: &[String], complete: bool) -> R let doomed = match xmp_values(doc, &xmp) { None => true, Some(values) => { - let words = |(terms, _): &(Vec, Vec)| mentions(&values.join(" "), terms); - let part = |(_, runs): &(Vec, Vec)| values.iter().any(|v| within(v, runs)); + let words = |(terms, _): &(Vec, Vec)| mentions(&values.join(" "), terms, &work); + let part = |(_, runs): &(Vec, Vec)| values.iter().any(|v| within(v, runs, &work)); search.as_ref().is_none_or(|s| words(s) || part(s)) } }; diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 19f553332f..270434aa95 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -257,6 +257,63 @@ fn metadata_is_cleared_when_the_removed_text_was_cut_short() { assert!(!has_xmp(&doc)); } +/// #2539 review: the searching a scrub may do is one budget for all of it, not a check per +/// value: very many small `/Info` values each fit, but together they don't, and past the budget +/// the rest counts as matching (fail-closed). With room to search, nothing that doesn't mention +/// the removed text goes. +#[test] +fn metadata_search_has_one_budget_for_every_value() { + let entries: String = (0..50).map(|i| format!("/K{i} (an unrelated value number {i:02} in the list) ")).collect(); + let info = format!("<< {entries}/Producer (Test) >>"); + let fresh = || with_metadata(b"BT /F1 10 Tf 10 100 Td (Bluefin) Tj ET", &info, ""); + let mut doc = fresh(); + assert_eq!(metadata::scrub_within(&mut doc, &["Bluefin".into()], true, 1 << 28).unwrap(), 0, "room to search: nothing goes"); + let mut doc = fresh(); + // Each value costs about 40; fifty of them don't fit in 1000. + let n = metadata::scrub_within(&mut doc, &["Bluefin".into()], true, 1000).unwrap(); + assert!(n > 20, "past the budget the rest goes ({n} went)"); +} + +/// #2539 review: an entity is looked for only in the 12 bytes after its `&`, so a packet of bare +/// `&` characters is read in linear time, and decoding is unchanged. +#[test] +fn xml_entities_are_found_near_their_ampersand_only() { + assert_eq!(metadata::xml_values("Résumé & AA �"), ["Résumé & AA �"]); + let bare = "&".repeat(200_000); + let started = std::time::Instant::now(); + let values = metadata::xml_values(&format!("{bare}")); + assert_eq!(values.concat().len(), bare.len()); + eprintln!("200000 bare ampersands: {:?}", started.elapsed()); +} + +/// #2539 review: Apply removes a form XObject whole when it is nested past the depth limit (or +/// its stream can't be decoded), without reading its text, so what it held is unknown: the +/// metadata check fails closed. +#[test] +fn a_form_removed_unread_clears_the_metadata() { + // Forms 5..=20 each draw the next; the last one shows text. The page draws form 5. + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /XObject << /Fm 5 0 R >> >> >>".to_vec(), + stream("", b"q /Fm Do Q"), + ]; + for n in 5..=20 { + objs.push(stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /XObject << /Fm {} 0 R >> >>", n + 1), + b"q /Fm Do Q", + )); + } + objs.push(stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"0 g 10 10 50 50 re f")); + objs.push(b"<< /Title (Annual figures) /Producer (Test) >>".to_vec()); + let info = objs.len(); + let mut doc = Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry(&format!("/Info {info} 0 R")).build())).unwrap(); + mark(&mut doc, 0, &[[0.0, 0.0, 300.0, 300.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert!(r.forms_removed >= 1, "{r:?}"); + assert!(!info_keys(&doc).contains(&"Title".to_string()), "the deep form's text was never read: {:?}", info_keys(&doc)); +} + /// A value shorter than the redacted sentence goes from XMP too, in a custom schema's element or /// attribute, as from `/Info` (the regression #2598 suggested keeping): the packets holding it are /// removed, one that doesn't stays. From d64a363f460c550a5803bbd4277d96f06b71316f Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:35:20 +0800 Subject: [PATCH 028/101] M8: metadata search decodes character references with any number of leading zeros (#2448) Review follow-up: `BLUEFIN` hid BLUEFIN from the 13-byte scan. Co-Authored-By: Claude Opus 5.5 --- crates/redact/src/metadata.rs | 21 +++++++++++++++++---- crates/redact/src/tests.rs | 7 ++++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/redact/src/metadata.rs b/crates/redact/src/metadata.rs index c50401ef34..6fced3ad91 100644 --- a/crates/redact/src/metadata.rs +++ b/crates/redact/src/metadata.rs @@ -124,8 +124,19 @@ fn unescape(s: &str) -> String { while let Some(at) = rest.find('&') { out.push_str(rest.get(..at).unwrap_or("")); let tail = rest.get(at..).unwrap_or(""); - // An entity ends within 12 bytes; looking further made a text of bare `&`s quadratic. - let end = tail.char_indices().take(13).find(|&(_, c)| c == ';').map(|(e, _)| e); + // A named entity ends within 12 bytes; looking further made a text of bare `&`s + // quadratic. A character reference may carry any number of leading zeros, so its digits + // are read to their end: each digit belongs to one `&` only, which keeps it linear. + let end = match tail.strip_prefix("&#") { + Some(digits) => { + let hex = digits.starts_with(['x', 'X']); + let from = 2 + usize::from(hex); + let run = tail.get(from..).unwrap_or("").bytes().take_while(|b| if hex { b.is_ascii_hexdigit() } else { b.is_ascii_digit() }).count(); + let e = from + run; + (tail.as_bytes().get(e) == Some(&b';')).then_some(e) + } + None => tail.char_indices().take(13).find(|&(_, c)| c == ';').map(|(e, _)| e), + }; let entity = end.and_then(|e| tail.get(1..e)); let decoded = entity.and_then(|e| match e { "amp" => Some('&'), @@ -135,8 +146,10 @@ fn unescape(s: &str) -> String { "apos" => Some('\''), _ => { let n = match e.strip_prefix("#x").or_else(|| e.strip_prefix("#X")) { - Some(h) => u32::from_str_radix(h, 16).ok(), - None => e.strip_prefix('#').and_then(|d| d.parse().ok()), + // Leading zeros dropped, so they can't overflow the number; `�` isn't a + // character XML allows, and stays as written. + Some(h) => u32::from_str_radix(h.trim_start_matches('0'), 16).ok(), + None => e.strip_prefix('#').and_then(|d| d.trim_start_matches('0').parse().ok()), }; n.and_then(char::from_u32) } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 270434aa95..9862e9e245 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -279,11 +279,16 @@ fn metadata_search_has_one_budget_for_every_value() { #[test] fn xml_entities_are_found_near_their_ampersand_only() { assert_eq!(metadata::xml_values("Résumé & AA �"), ["Résumé & AA �"]); + // #2539 review: a character reference with many leading zeros still decodes, so it can't + // hide a redacted word from the search; a reference that never ends stays as written. + assert_eq!(metadata::xml_values("BLUEFIN BLUEFIN �"), ["BLUEFIN BLUEFIN �"]); let bare = "&".repeat(200_000); let started = std::time::Instant::now(); let values = metadata::xml_values(&format!("{bare}")); assert_eq!(values.concat().len(), bare.len()); - eprintln!("200000 bare ampersands: {:?}", started.elapsed()); + let zeros = format!("&#{}", "0".repeat(10)).repeat(20_000); + assert_eq!(metadata::xml_values(&format!("{zeros}")).concat().len(), zeros.len()); + eprintln!("200000 bare ampersands, 20000 unended references: {:?}", started.elapsed()); } /// #2539 review: Apply removes a form XObject whole when it is nested past the depth limit (or From 5b2048ef256bf3dd95ac41f8a1d7d2404e80c381 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:37:27 +0800 Subject: [PATCH 029/101] M5: a removed glyph whose text isn't known makes the metadata check fail closed (#2448) Review on #2539: a removed glyph with no known text (no /ToUnicode, a glyph name outside the list) went in as an empty string and nothing marked the collection as incomplete, so when every removed glyph was like that the terms were empty and /Info and XMP were left untouched. It now counts as text the check couldn't keep, and every /Info string and XMP stream goes. Co-Authored-By: Claude Opus 5.5 --- crates/redact/src/interp.rs | 9 ++++++++- crates/redact/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 2dcafb339f..b0029ac912 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -739,7 +739,14 @@ fn show(scope: &mut Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (V let end = trm.apply(spaced, 0.0); let top = trm.apply(0.0, 1.0); let height = (top.0 - origin.0).hypot(top.1 - origin.1); - scope.removed(f.text_of(code).as_deref().unwrap_or(""), origin, end, height); + let text = f.text_of(code); + // A glyph whose text isn't known could stand for anything in the + // metadata, so the metadata check can't rely on the text it has + // (review on #2539): it fails closed, as for text it couldn't keep. + if text.is_none() { + scope.removed_cut = true; + } + scope.removed(text.as_deref().unwrap_or(""), origin, end, height); } else { scope.flush(); } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 9862e9e245..fef02a85dd 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -173,6 +173,38 @@ fn an_info_value_inside_the_redacted_text_goes() { assert!(info.contains(b"Subject") && info.contains(b"Producer")); } +/// Removed glyphs whose text isn't known (no `/ToUnicode`, glyph names outside the glyph list) +/// leave nothing to search the metadata for, so every `/Info` string goes, as when the collected +/// text was cut short (review on #2539). Glyphs whose text is known keep the targeted check. +#[test] +fn removed_glyphs_with_no_known_text_clear_the_metadata() { + let page = |codes: &[u8], font: &str| -> Document { + let content = [&b"BT /F1 10 Tf 10 100 Td ("[..], codes, b") Tj ET"].concat(); + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + stream("", &content), + font.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + b"<< /Title (Project Nightjar) /Producer (Test) >>".to_vec(), + ]; + Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry("/Info 7 0 R").build())).unwrap() + }; + let unnamed = FONT.replace(" /Widths", " /Encoding << /Differences [65 /g17 /g18 /g19] >> /Widths"); + let mut doc = page(b"ABC", &unnamed); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + let info = doc.trailer().get(b"Info").and_then(|i| doc.resolve(i).as_dict().cloned()).unwrap(); + assert!(!info.contains(b"Title") && !info.contains(b"Producer"), "fail closed: {r:?}"); + // Control: the same glyphs with known text don't touch unrelated metadata. + let mut doc = page(b"ABC", FONT); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + apply(&mut doc, None).unwrap(); + let info = doc.trailer().get(b"Info").and_then(|i| doc.resolve(i).as_dict().cloned()).unwrap(); + assert!(info.contains(b"Title") && info.contains(b"Producer")); +} + /// One page showing `content`, with `/Info` `info` and catalog XMP whose `rdf:RDF` holds /// `xmp_body`. fn with_metadata(content: &[u8], info: &str, xmp_body: &str) -> Document { From aed4599ed13bd20f52554cdb452c05814ca118c9 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:37:36 +0800 Subject: [PATCH 030/101] M8: the unread-form metadata test nests past main's form depth limit (#2448) main raised the redaction pass's form depth limit from 12 to 50 (#3645), so the 16-deep chain was walked and the test's form was no longer removed unread. The chain is now built from MAX_DEPTH, one level past it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/redact/src/tests.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index fef02a85dd..e873a03b0e 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -328,14 +328,16 @@ fn xml_entities_are_found_near_their_ampersand_only() { /// metadata check fails closed. #[test] fn a_form_removed_unread_clears_the_metadata() { - // Forms 5..=20 each draw the next; the last one shows text. The page draws form 5. + // Forms 5, 6, ... each draw the next, one more than the depth limit deep, and the last one + // paints. The page draws form 5. + let last = 5 + crate::interp::MAX_DEPTH; let mut objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /XObject << /Fm 5 0 R >> >> >>".to_vec(), stream("", b"q /Fm Do Q"), ]; - for n in 5..=20 { + for n in 5..=last { objs.push(stream( &format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /XObject << /Fm {} 0 R >> >>", n + 1), b"q /Fm Do Q", From 409477067ac3cec662746e696fcf47208b6321ef Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:05:51 +0800 Subject: [PATCH 031/101] M8: the redact README lists every fail-closed trigger and the metadata it doesn't scrub (#2448) Review on #2539 (nitpick): the README named only the collection limit and unreadable XMP as fail-closed cases. It now lists all of them (the run and run-length limits, a removed glyph with unknown text, a form XObject removed unread past the depth limit or unreadable, the term limits, the search work limit, oversized or undecodable XMP) and the metadata the check leaves to Sanitize: XMP on objects other than the catalog and pages, bookmark titles, and attachments. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/redact/README.md | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/crates/redact/README.md b/crates/redact/README.md index eb3bce469e..0db0e18c49 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -34,14 +34,24 @@ clear_marks(&mut doc, None)?; // remove marks without app part of it as whole words (three letters or digits at least), and the catalog's and pages' XMP streams whose text (element content, attribute values other than `xmlns` declarations, comments and processing instructions, entities decoded) contains it or has a value wholly - part of it, removed whole, together with any PDF/A identification in that packet. XMP that - isn't valid UTF-8 or UTF-16 goes too, and when the removed text was too much to collect - every `/Info` string and every XMP stream goes (fail-closed). The removed glyphs' text is - collected in runs by position (character and word spacing included, so letter-spaced text - stays one word), and each run (two or more letters or digits) and each of its words of three - or more are matched without regard to case or spacing. A value that shares only words of one - or two letters with the removed text, and isn't wholly part of it, stays: Sanitize's - metadata category removes the rest. `Report::metadata` counts what went. + part of it, removed whole, together with any PDF/A identification in that packet. The removed + glyphs' text is collected in runs by position (character and word spacing included, so + letter-spaced text stays one word), and each run (two or more letters or digits) and each of + its words of three or more are matched without regard to case or spacing. A value that shares + only words of one or two letters with the removed text, and isn't wholly part of it, stays: + Sanitize's metadata category removes the rest. `Report::metadata` counts what went. + - **Fail-closed.** When the check can't know what the removed text was, every `/Info` + string and every catalog and page XMP stream goes: more than 100,000 runs or a run over + 64 KiB removed (the collection limit); a removed glyph whose text isn't known (no + `/ToUnicode` or encoding gives it); a form XObject removed without being read, because it + is nested past the depth limit (`MAX_DEPTH`, 50) or its content can't be read; or more + than 20,000 terms, or a run longer than 4,096 characters, to search for. Once the + search's work limit (2^28 steps) runs out, every entry and packet not yet checked goes + too. An XMP packet that isn't valid UTF-8 or UTF-16, can't be decoded, or decodes to + more than 16 MiB goes without being read. + - **Not scrubbed** (Sanitize's categories cover them): XMP streams on anything but the + catalog and the pages (images, fonts, form XObjects, annotations and other objects), + bookmark titles, and attachments (embedded files, their names and descriptions). A verification pass re-reads each redacted page and fails the operation if any glyph or inline image is still under an area. It checks every content stream the page had, whatever its From 5c371183c406f2bb32c9c1aa8d7b10b95863a212 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:37:00 +0800 Subject: [PATCH 032/101] M3: documents that hide the toolbar, menu bar or window controls open that way; FitWindow and CenterWindow, ported onto main A single squashed port of the branch's five commits onto current main, which split ui-egui's lib.rs (app.rs, app/files.rs), canvas.rs (canvas/zoom.rs, canvas/tests.rs) and control.rs (control/host.rs), replaced the Menu button with a menu bar and moved the rail and panels into panels/side_panels.rs. The behaviour and tests are the branch's (ISO 32000-2 Table 147): - HideToolbar, HideMenubar and HideWindowUI apply in the document's tab, and F8 (view.toggle_interface) brings them back; on main the toggle is listed in the View menu beside Full Screen and Read Mode, HideMenubar hides the menu bar, and HideWindowUI skips the side panels; - FitWindow measures the spread the view shows, and FitWindow and CenterWindow wait only while the document is the window's only one; - a reloaded document keeps its tab's hidden interface; one-page-at-a-time layouts open at fit page; - the two strings are in every catalog, fa.tsv (added on main since) too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- ATTRIBUTION.md | 68 ++--- ATTRIBUTION.toml | 68 ++--- crates/engine/src/commands.rs | 10 + crates/ui-egui/src/app.rs | 16 +- crates/ui-egui/src/app/files.rs | 6 +- crates/ui-egui/src/canvas.rs | 14 +- crates/ui-egui/src/canvas/tests.rs | 29 ++ crates/ui-egui/src/canvas/zoom.rs | 25 ++ crates/ui-egui/src/chrome.rs | 9 +- crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/control/host.rs | 1 + crates/ui-egui/src/i18n/ar.tsv | 4 + crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/ca.tsv | 4 + crates/ui-egui/src/i18n/ckb.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fa.tsv | 4 + crates/ui-egui/src/i18n/fi.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hi.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/kk.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/lo.tsv | 4 + crates/ui-egui/src/i18n/lt.tsv | 4 + crates/ui-egui/src/i18n/nb.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ro.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sl.tsv | 4 + crates/ui-egui/src/i18n/sq.tsv | 4 + crates/ui-egui/src/i18n/te.tsv | 4 + crates/ui-egui/src/i18n/th.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/viewer_prefs.rs | 358 ++++++++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/viewer_prefs.rs | 335 ++++++++++++++++++++++ parity/acrobat-features.toml | 6 +- 51 files changed, 1009 insertions(+), 80 deletions(-) create mode 100644 crates/ui-egui/src/viewer_prefs.rs create mode 100644 crates/ui-egui/tests/it/viewer_prefs.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 23ebcbbe82..1d65771bf1 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -8,34 +8,34 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| -| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/fa.tsv` | Persian (Farsi) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed | Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, reviewed by a native speaker | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/fa.tsv` | Persian (Farsi) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs | | `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | @@ -286,17 +286,17 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile | | `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format | | `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n.rs | | `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file | -| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n.rs | | `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs | -| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs | ## Compiled in through dependencies (6) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 7dcd882f84..685b4463f9 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -2,7 +2,7 @@ path = "crates/ui-egui/src/i18n/el.tsv" title = "Greek interface translation catalog" author = "Camthalion77 and contributors" -source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -26,7 +26,7 @@ usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/zh-hans.tsv" title = "Simplified Chinese interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -36,7 +36,7 @@ usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.r path = "crates/ui-egui/src/i18n/zh-hant.tsv" title = "Traditional Chinese (Taiwan) interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, reviewed by a native speaker" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -46,7 +46,7 @@ usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n. path = "crates/ui-egui/src/i18n/cs.tsv" title = "Czech interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -56,7 +56,7 @@ usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/pt-br.tsv" title = "Brazilian Portuguese interface translation catalog" author = "André Cardoso (andrecards), PdfCraft contributors" -source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -66,7 +66,7 @@ usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n path = "crates/ui-egui/src/i18n/es.tsv" title = "Spanish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -76,7 +76,7 @@ usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/fr.tsv" title = "French interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -86,7 +86,7 @@ usage = "French UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/de.tsv" title = "German interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -96,7 +96,7 @@ usage = "German UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/lo.tsv" title = "Lao interface translation catalog" author = "@bounkirdni-2025" -source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -106,7 +106,7 @@ usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/te.tsv" title = "Telugu interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -116,7 +116,7 @@ usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/it.tsv" title = "Italian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -126,7 +126,7 @@ usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/pl.tsv" title = "Polish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -136,7 +136,7 @@ usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/ru.tsv" title = "Russian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -146,7 +146,7 @@ usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/hu.tsv" title = "Hungarian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -156,7 +156,7 @@ usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/bg.tsv" title = "Bulgarian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -166,7 +166,7 @@ usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/kk.tsv" title = "Kazakh interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -176,7 +176,7 @@ usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/ja.tsv" title = "Japanese interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -186,7 +186,7 @@ usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/uk.tsv" title = "Ukrainian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -196,7 +196,7 @@ usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/ar.tsv" title = "Arabic interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -206,7 +206,7 @@ usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/ckb.tsv" title = "Central Kurdish (Sorani) interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -216,7 +216,7 @@ usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/fa.tsv" title = "Persian (Farsi) interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -226,7 +226,7 @@ usage = "Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/tr.tsv" title = "Turkish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -236,7 +236,7 @@ usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/lt.tsv" title = "Lithuanian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -246,7 +246,7 @@ usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/hi.tsv" title = "Hindi interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -255,7 +255,7 @@ usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/id.tsv" title = "Indonesian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -264,7 +264,7 @@ usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/ko.tsv" title = "Korean interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -274,7 +274,7 @@ usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/ro.tsv" title = "Romanian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -285,7 +285,7 @@ usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/fi.tsv" title = "Finnish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -3418,7 +3418,7 @@ sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428" path = "crates/ui-egui/src/i18n/vi.tsv" title = "Vietnamese interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -3428,7 +3428,7 @@ usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/sl.tsv" title = "Slovene interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -3450,7 +3450,7 @@ sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697" path = "crates/ui-egui/src/i18n/nb.tsv" title = "Norwegian Bokmål interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -3504,7 +3504,7 @@ sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7" path = "crates/ui-egui/src/i18n/sq.tsv" title = "Albanian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -3514,7 +3514,7 @@ usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/th.tsv" title = "Thai interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -3524,7 +3524,7 @@ usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs" path = "crates/ui-egui/src/i18n/ca.tsv" title = "Catalan interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed; the 2026-10-25 hidden toolbars-and-panels labels are AI-assisted translations from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6bfe9fa826..35538e173f 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -222,6 +222,16 @@ pub const COMMANDS: &[CommandSpec] = &[ c("edit.snapshot", "Take a snapshot", EDIT, None, Copying, "camera"), c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"), c("view.read_mode", "Read mode", VIEW, Some(Shortcut { command: true, shift: false, mac_ctrl: true, key: "H" }), Document, "book-open"), + // A document can hide the toolbars, menu and panels (ISO 32000-2 Table 147); this brings them + // back for its tab, and with nothing hidden hides them. + c( + "view.toggle_interface", + "Show or hide toolbars and panels", + VIEW, + Some(Shortcut { command: false, shift: false, mac_ctrl: false, key: "F8" }), + Document, + "panel-left", + ), // Browsers' tab keys (#1542): Ctrl+Tab on every platform, ⌃Tab rather than ⌘Tab on macOS, // where the system takes ⌘Tab. They follow the tabs' order on screen and wrap around, and // also run from Combine files, which has no document open. diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 425c2e26ce..b867059332 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -253,6 +253,7 @@ impl PdfCraftApp { community_card_closed: false, pending_link: None, pending_full_screen: None, + pending_window: None, pending_remote_link: None, protect_draft: Default::default(), text_style_draft: Default::default(), @@ -611,15 +612,22 @@ impl eframe::App for PdfCraftApp { window_controls::resize_edges(&ctx); } chrome::tab_strip(self, ui); - chrome::mode_bar(self, ui); + // A document can hide the toolbars, the menu and the panels around it in its tab (ISO + // 32000-2 Table 147); the tab strip stays, and F8 brings the rest back (`viewer_prefs`). + let hidden = self.hidden_interface(); + if !hidden.mode_bar() { + chrome::mode_bar(self, ui); + } // Whichever way another tab came to the front, it shows instead of the expanded view. if self.all_tools_full && self.all_tools_tab != self.shown_tab() { self.all_tools_full = false; } - side_panels::show(self, ui); + if !hidden.window_ui { + side_panels::show(self, ui); + } let t = theme::Tokens::get(&ctx); egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| { - if self.mode == Mode::AllTools && self.all_tools_full { + if self.mode == Mode::AllTools && self.all_tools_full && !hidden.window_ui { all_tools_ui::show(self, ui); } else { match self.active { @@ -632,6 +640,8 @@ impl eframe::App for PdfCraftApp { } } }); + // FitWindow and CenterWindow, once the document just opened is laid out. + self.window_preferences_frame(&ctx); self.process_pending_edits(); palette::show(self, &ctx); dialogs::show(self, &ctx); diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 3c68dc5619..687122e07c 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -284,9 +284,11 @@ impl PdfCraftApp { self.active = Some(self.views.len() - 1); } self.apply_initial_view(self.views.len() - 1, &initial); - // Where the reader left off wins over the file's own initial view (#1767). + self.apply_viewer_preferences(self.views.len() - 1, &initial, activate); + // Where the reader left off wins over the file's own initial view and the zoom its + // viewer preferences open at (#1767). self.apply_last_view(self.views.len() - 1); - // After both, so full screen shows one fitted page and leaving it comes back to them. + // After all of them, so full screen shows one fitted page and leaving it comes back to them. if self.full_screen && let Some(view) = self.views.last_mut() { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8b280ba75d..9b244a4933 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -319,6 +319,9 @@ pub struct DocView { pub flash: Option<(usize, [f32; 4], f64)>, /// Compare files: differences shaded on this document's pages (page, user-space box, colour). pub compare_marks: Vec<(usize, [f32; 4], Color32)>, + /// The interface the document hid in this tab, and what is hidden now (ISO 32000-2 Table 147 + /// `HideToolbar`, `HideMenubar`, `HideWindowUI`). Never saved. + pub interface: crate::viewer_prefs::TabInterface, /// Full screen (Acrobat's Full Screen Mode): the display saved on entering it, while the view /// shows one page or spread at a time, fitted to the screen. `None` outside full screen. presentation: Option, @@ -635,6 +638,7 @@ impl DocView { single_edges: None, flash: None, compare_marks: Vec::new(), + interface: Default::default(), presentation: None, pages: HashMap::new(), errors: HashMap::new(), @@ -793,6 +797,8 @@ impl DocView { fresh.snapshot = self.snapshot.clone(); fresh.close_requested = self.close_requested; fresh.cover = self.cover; + // What the document asked to hide, and what is hidden now (F8), belong to the tab. + fresh.interface = self.interface; // Still presenting, with the display to go back to on leaving. fresh.presentation = self.presentation.take(); fresh.back = std::mem::take(&mut self.back); @@ -2619,6 +2625,11 @@ fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry // Every scroll here asks for no animation. An animated area would still queue a zero-length // offset target for the next frame, which overwrites the jump a page turn just set. scroll = scroll.animated(false); + // A document that hides the window's interface (`HideWindowUI`) hides the scroll bars too; + // the wheel, keys and Hand tool still scroll. + if view.interface.hidden.window_ui { + scroll = scroll.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden); + } // A newly opened document starts at the top. eframe saves egui's memory with the settings // and document ids start again at 1 every launch, so the offset stored under this id can be // where an earlier session left another document. @@ -3478,7 +3489,8 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, } // Read mode hides the tools (#2646), and full screen is the page and nothing else (#1459); // the quick tools come back with the window. - if app.mode != crate::Mode::Read && !app.full_screen { + // A document that hides the toolbars (`HideToolbar`) hides this one too. + if app.mode != crate::Mode::Read && !app.full_screen && !app.views.get(index).is_some_and(|v| v.interface.hidden.toolbar) { quick_bar(app, avail, ui); } if let Some((source, file, destination)) = remote_open { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 634a885111..4044f54b08 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2002,3 +2002,32 @@ fn a_moved_picture_is_drawn_from_the_fresh_raster_while_a_tile_is_still_stale() h.state_mut().views[0].stale_tiles.clear(); h.state_mut().views[0].tiles.clear(); } + +/// Review on #4378: `FitWindow` measures the spread the view shows, its pages side by side +/// and as tall as the taller one, with one page in a spread that has only one. +#[test] +fn first_page_now_measures_the_pages_in_the_spread() { + let page = + |width: f32, height: f32| pdfcraft_render::PageInfo { width, height, label: String::new(), crop: [0.0, 0.0, width, height], rotation: 0 }; + let info = DocInfo { pages: vec![page(300.0, 400.0), page(600.0, 800.0), page(200.0, 500.0)], ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults::default()); + let one = v.first_page_now(&info).expect("a page"); + assert_eq!((one.size, one.margins.x), (vec2(300.0 * PT, 400.0 * PT), 2.0 * SIDE)); + v.set_display(PageLayout::TwoUp, false); + let gap = v.page_gap(); + // [0, 1]: 300 + 600 wide, 800 high, one gap between them. + let spread = v.first_page_now(&info).expect("a spread"); + assert_eq!((spread.size, spread.margins.x), (vec2(900.0 * PT, 800.0 * PT), 2.0 * SIDE + gap)); + // [2] on its own at the end: one page, no gap. + v.current = 2; + let last = v.first_page_now(&info).expect("the last page"); + assert_eq!((last.size, last.margins.x), (vec2(200.0 * PT, 500.0 * PT), 2.0 * SIDE)); + // With a cover page: [0] alone, then [1, 2]. + v.set_cover(true); + assert_eq!(v.first_page_now(&info).expect("a spread").size, vec2(800.0 * PT, 800.0 * PT)); + v.current = 0; + let cover = v.first_page_now(&info).expect("the cover"); + assert_eq!((cover.size, cover.margins.x), (vec2(300.0 * PT, 400.0 * PT), 2.0 * SIDE)); + v.current = 9; + assert!(v.first_page_now(&info).is_none()); +} diff --git a/crates/ui-egui/src/canvas/zoom.rs b/crates/ui-egui/src/canvas/zoom.rs index 91bc4feac8..c11f9bf7eb 100644 --- a/crates/ui-egui/src/canvas/zoom.rs +++ b/crates/ui-egui/src/canvas/zoom.rs @@ -140,6 +140,31 @@ impl DocView { self.layout == PageLayout::TwoUp } + /// The page (or spread) the view shows first, as `FitWindow` sizes the window to it + /// ([`crate::viewer_prefs::window_commands`]). `None` when there is no such page. + pub(crate) fn first_page_now(&self, info: &DocInfo) -> Option { + info.pages.get(self.current)?; + // Two-page view shows the spread the page is in (a cover page on its own); the spread + // is as wide as its pages side by side and as tall as the taller one, as `layout` + // places it. The last spread of an odd page count has one page. + let (first, last) = if self.layout == PageLayout::TwoUp { + let first = self.spread_first(self.current); + (first, if self.cover && first == 0 { 0 } else { first.saturating_add(1) }) + } else { + (self.current, self.current) + }; + let sizes: Vec<(f32, f32)> = + info.pages.iter().skip(first).take(last.saturating_sub(first).saturating_add(1)).map(|p| self.display_size(p)).collect(); + let w: f32 = sizes.iter().map(|s| s.0).sum(); + let h = sizes.iter().map(|s| s.1).fold(0.0, f32::max); + let gap = self.page_gap() * sizes.len().saturating_sub(1) as f32; + Some(crate::viewer_prefs::PageNow { + size: vec2(w * PT, h * PT), + margins: vec2(2.0 * SIDE + gap, 2.0 * MARGIN), + zoom: (self.fit == Fit::None).then_some(self.zoom), + }) + } + /// Displayed page size in points for this view rotation. pub(super) fn display_size(&self, p: &pdfcraft_render::PageInfo) -> (f32, f32) { if self.rotation % 180 == 90 { (p.height, p.width) } else { (p.width, p.height) } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index aa6233c133..61a0435f66 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -479,19 +479,24 @@ fn tab(ui: &mut egui::Ui, spec: TabSpec<'_>, close: &mut Option) -> egui: pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); + // The document in the active tab may hide the menu or the tools (`crate::viewer_prefs`). + let hidden = app.hidden_interface(); egui::Panel::top("mode_bar") .exact_size(48.0) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(10, 0)).stroke(Stroke::new(1.0, t.divider))) .show(ui, |ui| { ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 2.0; - // The menus, unless the native macOS menu bar has them. - if app.native_menu.is_none() { + // The menus, unless the native macOS menu bar has them or the document hides them. + if app.native_menu.is_none() && !hidden.menubar { app.menu_bar_ui(ui); ui.add_space(6.0); ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(12.0), Stroke::new(1.0, t.divider)); ui.add_space(10.0); } + if hidden.toolbar { + return; + } // Acrobat's mode bar has no Read tab: reading is what the viewer does with no tool // in use. Read mode (the panes hidden) stays a View command and a default workspace. for (mode, label) in [(Mode::AllTools, "All tools"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")] { diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 08d2040e23..dd5edc19d5 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -319,6 +319,7 @@ impl PdfCraftApp { } } "view.read_mode" => self.switch_mode(if self.mode == Mode::Read { Mode::AllTools } else { Mode::Read }, false), + crate::viewer_prefs::TOGGLE => self.toggle_interface(), "view.previous_view" | "view.next_view" => { if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { return Some(v.view_history(id == "view.next_view")); diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs index 1166c0e7ec..a55699b375 100644 --- a/crates/ui-egui/src/control/host.rs +++ b/crates/ui-egui/src/control/host.rs @@ -137,6 +137,7 @@ impl Host for crate::PdfCraftApp { "left_panel": if self.left_open { json!(format!("{:?}", self.left)) } else { Value::Null }, "tools_side": if self.side_panels.tools_on_right() { "right" } else { "left" }, "right_panel": self.right.map(|r| format!("{r:?}")), + "hidden_interface": self.hidden_interface().to_json(), "dialog": self.dialog.map(|d| format!("{d:?}")), "palette_open": self.palette_open, "theme": format!("{:?}", self.theme), diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index ce5bf387be..19b39149fb 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2859,3 +2859,7 @@ certificate Version الإصدار # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply خيارات هذا الرد Export value قيمة التصدير + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels إظهار أشرطة الأدوات واللوحات أو إخفاؤها + This document hides the toolbars and panels. Press {key} to show them. يُخفي هذا المستند أشرطة الأدوات واللوحات. اضغط {key} لإظهارها. diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index eb37bdd775..8975ca2980 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2546,3 +2546,7 @@ animation effects Off Изключени # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Опции за този отговор Export value Стойност за експортиране + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Покажи или скрий лентите с инструменти и панелите + This document hides the toolbars and panels. Press {key} to show them. Този документ скрива лентите с инструменти и панелите. Натиснете {key}, за да ги покажете. diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 513016faf4..3fb46d023f 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2843,3 +2843,7 @@ theme Appearance Aspecte # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Opcions d'aquesta resposta + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Mostra o amaga les barres d'eines i els taulers + This document hides the toolbars and panels. Press {key} to show them. Aquest document amaga les barres d'eines i els taulers. Prem {key} per mostrar-los. diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index 645f174416..ab7364af4c 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -2775,3 +2775,7 @@ certificate Version وەشان @plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} more formula reads a missing field.|{n} more formulas read missing fields. @plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. @plural {fields} is missing|{fields} are missing {fields} is missing|{fields} are missing + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels پیشاندان یان شاردنەوەی شریتەکانی ئامراز و پانێڵەکان + This document hides the toolbars and panels. Press {key} to show them. ئەم بەڵگەنامەیە شریتەکانی ئامراز و پانێڵەکان دەشارێتەوە. {key} دابگرە بۆ پیشاندانیان. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4ab47a4c3c..178a3a2eb1 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2890,3 +2890,7 @@ certificate Version Verze # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Možnosti této odpovědi Export value Exportní hodnota + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Zobrazit nebo skrýt panely nástrojů a postranní panely + This document hides the toolbars and panels. Press {key} to show them. Tento dokument skrývá panely nástrojů a postranní panely. Stisknutím {key} je zobrazíte. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index fab8b0bff5..1d37ab7855 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2584,3 +2584,7 @@ animation effects Off Aus # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Optionen für diese Antwort Export value Exportwert + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Werkzeugleisten und Bereiche ein- oder ausblenden + This document hides the toolbars and panels. Press {key} to show them. Dieses Dokument blendet die Werkzeugleisten und Bereiche aus. Mit {key} blenden Sie sie ein. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 707f15f99a..f67f7e17a7 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2589,3 +2589,7 @@ animation effects Off Ανενεργά # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Επιλογές για αυτήν την απάντηση Export value Τιμή εξαγωγής + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Εμφάνιση ή απόκρυψη γραμμών εργαλείων και πλαισίων + This document hides the toolbars and panels. Press {key} to show them. Αυτό το έγγραφο αποκρύπτει τις γραμμές εργαλείων και τα πλαίσια. Πατήστε {key} για να εμφανιστούν. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a465e5a1f8..06c4120154 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2906,3 +2906,7 @@ theme Appearance Aspecto # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Opciones de esta respuesta Export value Valor de exportación + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Mostrar u ocultar las barras de herramientas y los paneles + This document hides the toolbars and panels. Press {key} to show them. Este documento oculta las barras de herramientas y los paneles. Pulse {key} para mostrarlos. diff --git a/crates/ui-egui/src/i18n/fa.tsv b/crates/ui-egui/src/i18n/fa.tsv index 99a156bc58..650ea5bad5 100644 --- a/crates/ui-egui/src/i18n/fa.tsv +++ b/crates/ui-egui/src/i18n/fa.tsv @@ -2821,3 +2821,7 @@ theme Appearance ظاهر # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply گزینه‌های این پاسخ + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels نمایش یا پنهان کردن نوارابزارها و پانل‌ها + This document hides the toolbars and panels. Press {key} to show them. این سند نوارابزارها و پانل‌ها را پنهان می‌کند. برای نمایش آن‌ها {key} را فشار دهید. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b1dbaffc1e..742e675c43 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2852,3 +2852,7 @@ theme Appearance Teema # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Tämän vastauksen asetukset + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Näytä tai piilota työkalurivit ja paneelit + This document hides the toolbars and panels. Press {key} to show them. Tämä asiakirja piilottaa työkalurivit ja paneelit. Näytä ne painamalla {key}. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 513ba27b3f..2705e25af8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2849,3 +2849,7 @@ certificate Version Version # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Options de cette réponse Export value Valeur d’exportation + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Afficher ou masquer les barres d'outils et les panneaux + This document hides the toolbars and panels. Press {key} to show them. Ce document masque les barres d'outils et les panneaux. Appuyez sur {key} pour les afficher. diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index fa59df3a15..963cb4dfeb 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2814,3 +2814,7 @@ theme Appearance स्वरूप # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply इस जवाब के विकल्प + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels टूलबार और पैनल दिखाएँ या छिपाएँ + This document hides the toolbars and panels. Press {key} to show them. यह दस्तावेज़ टूलबार और पैनल छिपाता है। उन्हें दिखाने के लिए {key} दबाएँ। diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index bc4614e104..968dab4ef0 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2582,3 +2582,7 @@ animation effects Off Ki # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply A válasz beállításai Export value Exportérték + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Eszköztárak és panelek megjelenítése vagy elrejtése + This document hides the toolbars and panels. Press {key} to show them. Ez a dokumentum elrejti az eszköztárakat és a paneleket. Megjelenítésükhöz nyomja meg ezt: {key}. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 52b2dfbe4f..57339972f0 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2815,3 +2815,7 @@ theme Appearance Tampilan # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Opsi untuk balasan ini + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Tampilkan atau sembunyikan bilah alat dan panel + This document hides the toolbars and panels. Press {key} to show them. Dokumen ini menyembunyikan bilah alat dan panel. Tekan {key} untuk menampilkannya. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3228312cca..311d1ed96c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2815,3 +2815,7 @@ certificate SHA-256 digest Impronta SHA-256 # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Opzioni per questa risposta Export value Valore di esportazione + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Mostra o nascondi barre degli strumenti e pannelli + This document hides the toolbars and panels. Press {key} to show them. Questo documento nasconde le barre degli strumenti e i pannelli. Premi {key} per mostrarli. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 37ddfd4c58..60ecd70e44 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2583,3 +2583,7 @@ animation effects Off オフ # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply この返信のオプション Export value 書き出し値 + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels ツールバーとパネルの表示/非表示 + This document hides the toolbars and panels. Press {key} to show them. この文書はツールバーとパネルを非表示にしています。{key} キーで表示できます。 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index a28d532498..a410a30ecd 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2581,3 +2581,7 @@ animation effects Off Өшірулі # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Осы жауаптың параметрлері Export value Экспорт мәні + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Құралдар тақталары мен панельдерді көрсету немесе жасыру + This document hides the toolbars and panels. Press {key} to show them. Бұл құжат құралдар тақталары мен панельдерді жасырады. Оларды көрсету үшін {key} басыңыз. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ecf2b20faa..e4d33556ff 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2814,3 +2814,7 @@ theme Appearance 모양 # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply 이 답글의 옵션 + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels 도구 모음 및 패널 표시/숨기기 + This document hides the toolbars and panels. Press {key} to show them. 이 문서는 도구 모음과 패널을 숨깁니다. {key} 키를 눌러 표시할 수 있습니다. diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index ca2d8bc7ea..f1cf87dbba 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -2486,3 +2486,7 @@ animation effects Off ປິດ Unchecked reads Unchecked reads {field} reads fields named as it runs {field} reads fields named as it runs Some names are built as it runs Some names are built as it runs + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels ສະແດງ ຫຼື ເຊື່ອງແຖບເຄື່ອງມື ແລະ ແຜງ + This document hides the toolbars and panels. Press {key} to show them. ເອກະສານນີ້ເຊື່ອງແຖບເຄື່ອງມື ແລະ ແຜງ. ກົດ {key} ເພື່ອສະແດງພວກມັນ. diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index b2e588c48a..6fe7fcc306 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2844,3 +2844,7 @@ theme Appearance Išvaizda # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Šio atsakymo parinktys Export value Eksporto reikšmė + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Rodyti arba slėpti įrankių juostas ir skydelius + This document hides the toolbars and panels. Press {key} to show them. Šis dokumentas slepia įrankių juostas ir skydelius. Norėdami juos parodyti, paspauskite {key}. diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index c196fd2394..ff32ea358a 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -2759,3 +2759,7 @@ key Space Mellomrom @plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} more formula reads a missing field.|{n} more formulas read missing fields. @plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. @plural {fields} is missing|{fields} are missing {fields} is missing|{fields} are missing + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Vis eller skjul verktøylinjer og paneler + This document hides the toolbars and panels. Press {key} to show them. Dette dokumentet skjuler verktøylinjene og panelene. Trykk {key} for å vise dem. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ae6463a156..4bcc04cd3d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2679,3 +2679,7 @@ animation effects Off Wyłączone # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Opcje tej odpowiedzi Export value Wartość eksportowa + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Pokaż lub ukryj paski narzędzi i panele + This document hides the toolbars and panels. Press {key} to show them. Ten dokument ukrywa paski narzędzi i panele. Naciśnij {key}, aby je pokazać. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 70309ffc15..5082962f2c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2868,3 +2868,7 @@ theme Appearance Aparência # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Opções desta resposta Export value Valor de exportação + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Mostrar ou ocultar barras de ferramentas e painéis + This document hides the toolbars and panels. Press {key} to show them. Este documento oculta as barras de ferramentas e os painéis. Pressione {key} para mostrá-los. diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index f8a2deb83e..6cd490fad6 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2843,3 +2843,7 @@ theme Appearance Aspect # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Opțiuni pentru acest răspuns + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Afișare sau ascundere bare de instrumente și panouri + This document hides the toolbars and panels. Press {key} to show them. Acest document ascunde barele de instrumente și panourile. Apăsați {key} pentru a le afișa. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 87042f7bd9..c39e5992f9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2496,3 +2496,7 @@ animation effects Off Выкл # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Параметры этого ответа Export value Значение экспорта + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Показать или скрыть панели инструментов и боковые панели + This document hides the toolbars and panels. Press {key} to show them. Этот документ скрывает панели инструментов и боковые панели. Нажмите {key}, чтобы показать их. diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 57d31bd66e..75a2d6e998 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2588,3 +2588,7 @@ animation effects Off Izklopljeni # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Možnosti tega odgovora Export value Izvozna vrednost + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Pokaži ali skrij orodne vrstice in razdelke + This document hides the toolbars and panels. Press {key} to show them. Ta dokument skrije orodne vrstice in razdelke. Za prikaz pritisnite {key}. diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index 4b1769c0f3..8f555253d4 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2569,3 +2569,7 @@ animation effects Off Joaktiv # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Opsionet për këtë përgjigje + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Shfaq ose fshih shiritat e mjeteve dhe panelet + This document hides the toolbars and panels. Press {key} to show them. Ky dokument fsheh shiritat e mjeteve dhe panelet. Shtyp {key} për t'i shfaqur. diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 5b4ba64345..8717abb304 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2492,3 +2492,7 @@ animation effects Off ఆఫ్ # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply ఈ ప్రత్యుత్తరం ఎంపికలు Export value ఎగుమతి విలువ + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels టూల్‌బార్‌లు మరియు ప్యానెల్‌లను చూపించు లేదా దాచు + This document hides the toolbars and panels. Press {key} to show them. ఈ పత్రం టూల్‌బార్‌లను మరియు ప్యానెల్‌లను దాచుతుంది. వాటిని చూపించడానికి {key} నొక్కండి. diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index 71c7d38fe8..7694f63b9f 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -2741,3 +2741,7 @@ theme Appearance ลักษณะที่ปรากฏ Unchecked reads Unchecked reads {field} reads fields named as it runs {field} reads fields named as it runs Some names are built as it runs Some names are built as it runs + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels แสดงหรือซ่อนแถบเครื่องมือและแผง + This document hides the toolbars and panels. Press {key} to show them. เอกสารนี้ซ่อนแถบเครื่องมือและแผง กด {key} เพื่อแสดง diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f76f7f5cd0..f3ae806791 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2579,3 +2579,7 @@ animation effects Off Kapalı # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Bu yanıtın seçenekleri Export value Dışa aktarma değeri + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Araç çubuklarını ve panelleri göster veya gizle + This document hides the toolbars and panels. Press {key} to show them. Bu belge araç çubuklarını ve panelleri gizliyor. Göstermek için {key} tuşuna basın. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index eb7bb2ec4b..a950785904 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2605,3 +2605,7 @@ animation effects Off Вимкнено # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Параметри цієї відповіді Export value Значення експорту + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Показати або приховати панелі інструментів і бічні панелі + This document hides the toolbars and panels. Press {key} to show them. Цей документ приховує панелі інструментів і бічні панелі. Натисніть {key}, щоб показати їх. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 860884ae48..b6858c0a98 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2850,3 +2850,7 @@ key Space Space # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Tùy chọn cho câu trả lời này Export value Giá trị xuất + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels Hiện hoặc ẩn thanh công cụ và bảng + This document hides the toolbars and panels. Press {key} to show them. Tài liệu này ẩn thanh công cụ và bảng. Nhấn {key} để hiện lại. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 995acc78e6..0b034558dd 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2547,3 +2547,7 @@ animation effects Off 关 # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply 此回复的选项 Export value 导出值 + +# Toolbars and panels a document hides (#4378) (AI-assisted, not separately native-reviewed) + Show or hide toolbars and panels 显示或隐藏工具栏和面板 + This document hides the toolbars and panels. Press {key} to show them. 此文档隐藏了工具栏和面板。按 {key} 可以显示。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 79b67e6e70..64dbf75c6b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2533,3 +2533,7 @@ animation effects Off 關 # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply 此回覆的選項 Export value 匯出值 + +# Toolbars and panels a document hides (#4378) (AI-assisted, reviewed by a native speaker) + Show or hide toolbars and panels 顯示或隱藏工具列和面板 + This document hides the toolbars and panels. Press {key} to show them. 這份文件隱藏了工具列和面板。按 {key} 可以顯示。 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 222bb988e4..0449b0725d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -59,6 +59,7 @@ pub use print_ui::{Handling as PrintHandling, OptionsSource, PrintDraft, Printer pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode}; pub mod i18n; pub mod theme; +pub mod viewer_prefs; mod widgets; use pdfcraft_engine::{DocId, Session}; @@ -642,6 +643,9 @@ pub struct PdfCraftApp { /// A document just opened asks to be shown in Full Screen mode (`/PageMode /FullScreen`): /// the person is asked first, as in Acrobat, and this is the document waiting for the answer. pub pending_full_screen: Option, + /// A document just opened asks for its window to fit its first page or to be centred + /// (`FitWindow`, `CenterWindow`): done once the page is laid out. + pub pending_window: Option, /// An absolute GoToR target waiting for approval, before any target-file I/O (#1364). pub pending_remote_link: Option, } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 49f2aeba1a..2d4f2f708a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -170,7 +170,7 @@ const DISPLAY: &[&str] = &[ const THEMES: &[&str] = &["view.theme.system", "view.theme.light", "view.theme.dark"]; const PANES: &[&str] = &["view.side_panel", "view.tools_pane"]; const PANE_COMMANDS: &[&str] = &["comment.list", "form.fields", "ui.dock.reset"]; -const MODES: &[&str] = &["view.full_screen", "view.read_mode"]; +const MODES: &[&str] = &["view.full_screen", "view.read_mode", crate::viewer_prefs::TOGGLE]; const PAGES: &[&str] = &[ "page.organize", SEP, diff --git a/crates/ui-egui/src/viewer_prefs.rs b/crates/ui-egui/src/viewer_prefs.rs new file mode 100644 index 0000000000..067a81bcea --- /dev/null +++ b/crates/ui-egui/src/viewer_prefs.rs @@ -0,0 +1,358 @@ +//! Viewer preferences that change the window and its interface when a document opens +//! (ISO 32000-2 §12.2, Table 147): `HideToolbar`, `HideMenubar` and `HideWindowUI` hide parts of +//! the interface for that document's tab, and `FitWindow` and `CenterWindow` size and place the +//! window around its first page. +//! +//! The hidden interface is the tab's own and is never saved: another tab shows the usual +//! interface, and the command [`TOGGLE`] (F8) brings it back, so a document can't leave the user +//! without the toolbar, menu or panels. + +use egui::{Pos2, Rect, Vec2, ViewportCommand, vec2}; + +/// The command that shows the interface a document hid, and hides it again. +pub const TOGGLE: &str = "view.toggle_interface"; + +/// Which parts of the interface are hidden in a document's tab. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct HiddenInterface { + /// The mode bar's tools: the mode tabs, Save, Print, Properties and the command search, and + /// the floating quick-action bar (`HideToolbar`). + pub toolbar: bool, + /// The menu bar (`HideMenubar`). + pub menubar: bool, + /// The right rail with its page navigation, the side panels and the document's scroll bars, + /// "leaving only the document's contents displayed" (`HideWindowUI`). + pub window_ui: bool, +} + +impl HiddenInterface { + pub const NONE: Self = Self { toolbar: false, menubar: false, window_ui: false }; + pub const ALL: Self = Self { toolbar: true, menubar: true, window_ui: true }; + + /// What a document's viewer preferences ask to hide. + pub fn asked_by(v: &pdfcraft_engine::InitialView) -> Self { + Self { toolbar: v.hide_toolbar, menubar: v.hide_menubar, window_ui: v.hide_window_ui } + } + + pub fn any(self) -> bool { + self.toolbar || self.menubar || self.window_ui + } + + /// The mode bar has nothing left to show. + pub fn mode_bar(self) -> bool { + self.toolbar && self.menubar + } + + /// For the control channel's `ui.state`. + pub fn to_json(self) -> serde_json::Value { + serde_json::json!({ "toolbar": self.toolbar, "menubar": self.menubar, "window_ui": self.window_ui }) + } +} + +/// A tab's interface: what its document asked to hide, and what is hidden now. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TabInterface { + pub asked: HiddenInterface, + pub hidden: HiddenInterface, +} + +impl TabInterface { + /// The interface a document opens with: what it asks to hide, unless the user's preference + /// is to open every document their own way. + pub fn opening(asked: HiddenInterface, ignore_document: bool) -> Self { + if ignore_document { Self::default() } else { Self { asked, hidden: asked } } + } + + /// [`TOGGLE`]: show everything that is hidden; with nothing hidden, hide what the document + /// asked to hide, or everything when it asked for nothing. Returns whether it is now hidden. + pub fn toggle(&mut self) -> bool { + self.hidden = if self.hidden.any() { + HiddenInterface::NONE + } else if self.asked.any() { + self.asked + } else { + HiddenInterface::ALL + }; + self.hidden.any() + } +} + +/// `FitWindow` and `CenterWindow`, waiting for the document's first frame (the size of its page +/// on screen is known only once it is laid out). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PendingWindow { + pub doc: pdfcraft_engine::DocId, + pub fit: bool, + pub center: bool, + /// Frames left to wait for the document to be laid out before giving up. + pub frames_left: u8, +} + +/// What the window looks like now, as the windowing layer reports it (points). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WindowNow { + /// The window's content area. + pub inner: Vec2, + /// The window with its frame, where the windowing layer knows it. + pub outer: Option, + pub monitor: Option, + /// The document area inside the window. + pub document_area: Vec2, + pub maximized: bool, + pub fullscreen: bool, +} + +/// The first displayed page (or spread) as the view lays it out. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PageNow { + /// Its width and height on screen at 100%, in points. + pub size: Vec2, + /// The space the view keeps around it (gutters, margins, the gap in a spread), in points. + pub margins: Vec2, + /// The view's set zoom (1.0 = 100%); `None` when the view fits the page to the window. + pub zoom: Option, +} + +/// Smallest window the fit makes, so a tiny page doesn't leave a sliver of a window. +const MIN_INNER: Vec2 = vec2(480.0, 360.0); +/// Share of the monitor the fitted window may take, leaving room for the desktop's own bars. +const MONITOR_SHARE: f32 = 0.9; + +/// The viewport commands for `FitWindow` / `CenterWindow`. None for a maximized or full-screen +/// window (resizing it would fight the user's choice), or when the sizes are unusable. +/// +/// `FitWindow` keeps the document area's height and makes the window as wide as the page needs +/// at that height when the view fits the page to the window, and makes the document area the +/// page's size on screen when the view has a set zoom; either way within the monitor. +/// `CenterWindow` centres the window on the monitor when the window is on the monitor at the +/// desktop's origin (the windowing layer reports the monitor's size but not where it is). +pub fn window_commands(now: WindowNow, page: PageNow, fit: bool, center: bool) -> Vec { + let mut out = Vec::new(); + if now.maximized || now.fullscreen || !usable(now.inner) || !usable(now.document_area) { + return out; + } + let chrome = (now.inner - now.document_area).max(Vec2::ZERO); + let mut inner = now.inner; + let zoom_ok = page.zoom.is_none_or(|z| z.is_finite() && z > 0.0); + if fit && zoom_ok && usable(page.size) && page.margins.x.is_finite() && page.margins.y.is_finite() { + let area = match page.zoom { + // Fitted to the window: keep the document area's height, and make the area as wide + // as the page is at the zoom that height gives it. + None => { + let zoom = ((now.document_area.y - page.margins.y) / page.size.y).max(0.0); + vec2(page.size.x * zoom + page.margins.x, now.document_area.y) + } + Some(zoom) => page.size * zoom + page.margins, + }; + let mut target = area + chrome; + if let Some(monitor) = now.monitor.filter(|m| usable(*m)) { + target = target.min(monitor * MONITOR_SHARE); + } + target = target.max(MIN_INNER).round(); + if usable(target) && (target - now.inner).length() >= 1.0 { + out.push(ViewportCommand::InnerSize(target)); + inner = target; + } + } + if center + && let (Some(outer), Some(monitor)) = (now.outer, now.monitor.filter(|m| usable(*m))) + && Rect::from_min_size(Pos2::ZERO, monitor).contains(outer.min) + { + // The frame around the content area stays the size it is. + let frame = (outer.size() - now.inner).max(Vec2::ZERO); + let size = inner + frame; + let at = ((monitor - size) / 2.0).max(Vec2::ZERO).round(); + if at.x.is_finite() && at.y.is_finite() && (at - outer.min.to_vec2()).length() >= 1.0 { + out.push(ViewportCommand::OuterPosition(at.to_pos2())); + } + } + out +} + +fn usable(v: Vec2) -> bool { + v.x.is_finite() && v.y.is_finite() && v.x >= 1.0 && v.y >= 1.0 +} + +impl crate::PdfCraftApp { + /// The window and interface preferences of the document opening in tab `index` + /// ([`Self::apply_initial_view`] has applied its navigation, layout, zoom and page). + /// + /// - `HideToolbar`, `HideMenubar`, `HideWindowUI` hide those parts of the interface in its tab, + /// unless Always use my default zoom and page layout is on, with a notice of the key that + /// brings them back. + /// - A document that shows one page or one spread at a time (`/PageLayout /SinglePage`, + /// `/TwoPageLeft`, `/TwoPageRight`) and names no zoom opens at fit page instead of the + /// default zoom. ISO 32000-2 doesn't say which zoom such a layout opens at. + /// - `FitWindow`, `CenterWindow` wait for the page to be laid out + /// ([`Self::window_preferences_frame`]), and only for the window's only document: a + /// document opened in a tab beside others doesn't resize or move their window. + pub(crate) fn apply_viewer_preferences(&mut self, index: usize, v: &pdfcraft_engine::InitialView, activate: bool) { + use pdfcraft_engine::{InitialLayout as L, Magnification as M}; + let own_view = self.always_default_view; + let only_document = self.views.len() == 1 && !self.combine_tab.open; + let Some(view) = self.views.get_mut(index) else { return }; + view.interface = TabInterface::opening(HiddenInterface::asked_by(v), own_view); + if !own_view && v.magnification == M::Default && matches!(v.layout, L::SinglePage | L::TwoUp | L::TwoUpCoverPage) { + view.fit = crate::canvas::Fit::Page; + } + let (doc, hidden) = (view.id, view.interface.hidden.any()); + if activate && hidden { + self.notify_interface_hidden(); + } + // A browser page can't resize or move its window. + if !cfg!(target_arch = "wasm32") && activate && only_document && !self.full_screen && (v.fit_window || v.center_window) { + self.pending_window = Some(PendingWindow { doc, fit: v.fit_window, center: v.center_window, frames_left: 30 }); + } + } + + /// The parts of the interface hidden in the active tab. + pub fn hidden_interface(&self) -> HiddenInterface { + self.active.and_then(|i| self.views.get(i)).map_or(HiddenInterface::NONE, |v| v.interface.hidden) + } + + /// [`TOGGLE`] in the active tab. + pub(crate) fn toggle_interface(&mut self) { + let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return }; + if view.interface.toggle() { + self.notify_interface_hidden(); + } + } + + fn notify_interface_hidden(&mut self) { + let key = pdfcraft_engine::commands::command(TOGGLE).and_then(|c| c.shortcut).map(|s| s.label(cfg!(target_os = "macos"))).unwrap_or_default(); + self.notify_fmt("This document hides the toolbars and panels. Press {key} to show them.", &[("key", &key)]); + } + + /// `FitWindow` and `CenterWindow` for the document just opened, once its first page is laid + /// out. Dropped when another tab shows first, when another tab has opened behind it (it is no + /// longer the window's only document), or after a few frames without a layout. + pub(crate) fn window_preferences_frame(&mut self, ctx: &egui::Context) { + let Some(mut pending) = self.pending_window.take() else { return }; + let Some((index, id)) = self.active_ids() else { return }; + if id != pending.doc || self.views.len() != 1 || self.combine_tab.open { + return; + } + let Some(view) = self.views.get(index) else { return }; + let area = view.viewport_rect(); + let page = self.session.get(id).and_then(|d| view.first_page_now(&d.info)); + let Some(page) = page.filter(|_| area.is_positive()) else { + if let Some(left) = pending.frames_left.checked_sub(1) { + pending.frames_left = left; + self.pending_window = Some(pending); + ctx.request_repaint(); + } + return; + }; + let content = ctx.content_rect().size(); + let now = ctx.input(|i| { + let vp = i.viewport(); + WindowNow { + inner: vp.inner_rect.map_or(content, |r| r.size()), + outer: vp.outer_rect, + monitor: vp.monitor_size, + document_area: area.size(), + maximized: vp.maximized.unwrap_or(false), + fullscreen: vp.fullscreen.unwrap_or(false), + } + }); + for command in window_commands(now, page, pending.fit, pending.center) { + ctx.send_viewport_cmd(command); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn now() -> WindowNow { + WindowNow { + inner: vec2(1400.0, 900.0), + outer: Some(Rect::from_min_size(Pos2::new(50.0, 40.0), vec2(1400.0, 930.0))), + monitor: Some(vec2(1920.0, 1080.0)), + document_area: vec2(1300.0, 800.0), + maximized: false, + fullscreen: false, + } + } + + const FITTED: PageNow = PageNow { size: vec2(200.0, 300.0), margins: vec2(140.0, 56.0), zoom: None }; + + #[test] + fn a_fitted_page_gets_a_window_of_its_shape() { + // An 800-point-high area fits the 300-point page at 248%: 496 points wide plus margins, + // and the 100 points of interface beside the document area. + let c = window_commands(now(), FITTED, true, false); + assert_eq!(c, vec![ViewportCommand::InnerSize(vec2(736.0, 900.0))]); + } + + #[test] + fn a_set_zoom_gets_the_page_size_within_the_monitor() { + let page = PageNow { size: vec2(400.0, 500.0), margins: vec2(100.0, 100.0), zoom: Some(1.0) }; + assert_eq!(window_commands(now(), page, true, false), vec![ViewportCommand::InnerSize(vec2(600.0, 700.0))]); + // Too tall for the monitor: the window stops at the share of the monitor it may take. + let page = PageNow { size: vec2(1000.0, 2000.0), margins: Vec2::ZERO, zoom: Some(1.0) }; + assert_eq!(window_commands(now(), page, true, false), vec![ViewportCommand::InnerSize(vec2(1100.0, 972.0))]); + // A tiny page doesn't make a sliver of a window. + let page = PageNow { size: vec2(10.0, 10.0), margins: Vec2::ZERO, zoom: Some(1.0) }; + assert_eq!(window_commands(now(), page, true, false), vec![ViewportCommand::InnerSize(MIN_INNER)]); + } + + #[test] + fn a_maximized_or_full_screen_window_is_left_alone() { + assert!(window_commands(WindowNow { maximized: true, ..now() }, FITTED, true, true).is_empty()); + assert!(window_commands(WindowNow { fullscreen: true, ..now() }, FITTED, true, true).is_empty()); + // Nothing asked, nothing done. + assert!(window_commands(now(), FITTED, false, false).is_empty()); + } + + #[test] + fn centring_uses_the_new_size_and_the_frame() { + let c = window_commands(now(), FITTED, true, true); + // 736 wide and 900 + 30 of frame high, on 1920 × 1080. + assert_eq!(c.get(1), Some(&ViewportCommand::OuterPosition(Pos2::new(592.0, 75.0)))); + // Centre alone keeps the size. + let c = window_commands(now(), FITTED, false, true); + assert_eq!(c, vec![ViewportCommand::OuterPosition(Pos2::new(260.0, 75.0))]); + // A window on another monitor (outside the one at the origin) isn't moved. + let away = WindowNow { outer: Some(Rect::from_min_size(Pos2::new(2000.0, 40.0), vec2(1400.0, 930.0))), ..now() }; + assert!(window_commands(away, FITTED, false, true).is_empty()); + // Nor is one whose monitor or frame is unknown. + assert!(window_commands(WindowNow { monitor: None, ..now() }, FITTED, false, true).is_empty()); + assert!(window_commands(WindowNow { outer: None, ..now() }, FITTED, false, true).is_empty()); + } + + #[test] + fn unusable_sizes_do_nothing() { + for bad in [Vec2::ZERO, vec2(f32::NAN, 10.0), vec2(f32::INFINITY, 10.0), vec2(-5.0, 10.0)] { + let page = PageNow { size: bad, ..FITTED }; + assert!(window_commands(now(), page, true, false).is_empty(), "{bad:?}"); + assert!(window_commands(WindowNow { inner: bad, ..now() }, FITTED, true, true).is_empty(), "{bad:?}"); + assert!(window_commands(WindowNow { document_area: bad, ..now() }, FITTED, true, true).is_empty(), "{bad:?}"); + assert!(window_commands(WindowNow { monitor: Some(bad), ..now() }, FITTED, false, true).is_empty(), "{bad:?}"); + } + for zoom in [f32::NAN, 0.0, -1.0, f32::INFINITY] { + assert!(window_commands(now(), PageNow { zoom: Some(zoom), ..FITTED }, true, false).is_empty(), "{zoom}"); + } + let page = PageNow { margins: vec2(f32::NAN, 0.0), ..FITTED }; + assert!(window_commands(now(), page, true, false).is_empty()); + } + + #[test] + fn toggling_restores_and_rehides() { + let asked = HiddenInterface { toolbar: true, ..HiddenInterface::NONE }; + let mut tab = TabInterface::opening(asked, false); + assert_eq!(tab.hidden, asked); + assert!(!tab.toggle()); + assert_eq!(tab.hidden, HiddenInterface::NONE); + assert!(tab.toggle()); + assert_eq!(tab.hidden, asked); + // The user's preference to open documents their own way hides nothing. + let mut own = TabInterface::opening(asked, true); + assert_eq!(own.hidden, HiddenInterface::NONE); + // A tab whose document asked for nothing hides everything, and shows it again. + assert!(own.toggle()); + assert_eq!(own.hidden, HiddenInterface::ALL); + assert!(!own.toggle()); + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index a5c3192358..3196d16ce6 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -79,4 +79,5 @@ mod ui; mod updates; mod view; mod viewer; +mod viewer_prefs; mod xfa; diff --git a/crates/ui-egui/tests/it/viewer_prefs.rs b/crates/ui-egui/tests/it/viewer_prefs.rs new file mode 100644 index 0000000000..781e461668 --- /dev/null +++ b/crates/ui-egui/tests/it/viewer_prefs.rs @@ -0,0 +1,335 @@ +//! Viewer preferences applied when a document opens (ISO 32000-2 §12.2, Table 147): hiding the +//! toolbars, menu and window controls in its tab, with F8 to bring them back; fitting and +//! centring the window; and the zoom a one-page-at-a-time layout opens at. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_ui_egui::PdfCraftApp; +use pdfcraft_ui_egui::canvas::Fit; +use pdfcraft_ui_egui::viewer_prefs::HiddenInterface; + +/// Two 200 × 300 pages; `catalog` is added to the catalog dictionary. +fn fixture(catalog: &str) -> Vec { + let objs = [ + format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>"), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +const HIDE_ALL: &str = "/ViewerPreferences << /HideToolbar true /HideMenubar true /HideWindowUI true >>"; + +/// A plain document in the first tab and `files` after it, the last one active. +fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static, files: &'static [&'static str]) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + setup(&mut app); + for (i, catalog) in files.iter().enumerate() { + app.open_bytes(&format!("{i}.pdf"), None, fixture(catalog)).unwrap(); + } + app + }); + settle(&mut h); + h +} + +fn settle(h: &mut Harness<'static, PdfCraftApp>) { + for _ in 0..3000 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.run_steps(2); +} + +/// Which of the toolbar (the command search), the menu (the menu bar's File menu) and the window +/// controls (the rail's fit button) are on screen. +fn shown(h: &Harness<'static, PdfCraftApp>) -> (bool, bool, bool) { + ( + h.query_by_label("Find tools and commands").is_some(), + h.query_by(|n| n.role() == egui::accesskit::Role::MenuItem && n.label().as_deref() == Some("File")).is_some(), + h.query_by_label("Toggle fit page / fit width").is_some(), + ) +} + +#[test] +fn a_document_that_hides_the_interface_opens_without_it_and_f8_brings_it_back() { + let mut h = harness(|_| {}, &["", HIDE_ALL]); + assert_eq!(shown(&h), (false, false, false), "the toolbar, menu and rail are hidden"); + assert_eq!(h.state().hidden_interface(), HiddenInterface::ALL); + // The quick-action bar is a toolbar too. + assert!(h.query_by_label_contains("Hand (H)").is_none()); + // The tab strip stays: the tabs and Home are still there. + h.get_by_label("Home"); + + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (true, true, true), "F8 shows them"); + h.get_by_label_contains("Hand (H)"); + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (false, false, false), "and hides them again"); + + // Another tab, whose document asks for nothing, has the usual interface. + h.state_mut().active = Some(0); + h.run_steps(3); + assert_eq!(shown(&h), (true, true, true)); + h.state_mut().active = Some(1); + h.run_steps(3); + assert_eq!(shown(&h), (false, false, false)); + + // The command (palette, menus, automation) does what F8 does. + assert!(h.state_mut().execute("view.toggle_interface")); + h.run_steps(3); + assert_eq!(shown(&h), (true, true, true)); +} + +/// Reloading a file changed on disk keeps the tab's interface: hidden stays hidden, and F8 still +/// hides only what the document asked for. +#[test] +fn a_reloaded_document_keeps_its_interface() { + let mut h = harness(|_| {}, &["/ViewerPreferences << /HideToolbar true >>"]); + let reload = |h: &mut Harness<'static, PdfCraftApp>| { + let info = h.state().session.get(h.state().views[0].id).unwrap().info.clone(); + h.state_mut().views[0].document_replaced(&info); + h.run_steps(3); + }; + reload(&mut h); + assert_eq!(shown(&h), (false, true, true), "still hidden after the reload"); + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (true, true, true)); + reload(&mut h); + assert_eq!(shown(&h), (true, true, true), "still shown after the reload"); + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (false, true, true), "F8 hides only what the document asked for"); +} + +#[test] +fn each_preference_hides_its_own_part() { + let top = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].viewport_rect().top(); + let mut h = harness(|_| {}, &["/ViewerPreferences << /HideToolbar true >>"]); + assert_eq!(shown(&h), (false, true, true), "HideToolbar"); + // Shown and hidden again, F8 hides only what the document asked for. + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (true, true, true)); + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (false, true, true)); + let h = harness(|_| {}, &["/ViewerPreferences << /HideMenubar true >>"]); + assert_eq!(shown(&h), (true, false, true), "HideMenubar"); + let h = harness(|_| {}, &["/ViewerPreferences << /HideWindowUI true >>"]); + assert_eq!(shown(&h), (true, true, false), "HideWindowUI"); + assert!(h.state().hidden_interface().window_ui); + // False is the default, and is nothing to hide. + let h = harness(|_| {}, &["/ViewerPreferences << /HideToolbar false /HideMenubar false /HideWindowUI false >>"]); + assert_eq!(shown(&h), (true, true, true)); + let with_bar = top(&h); + // With both the tools and the menu hidden, the mode bar goes and the document moves up. + let h = harness(|_| {}, &["/ViewerPreferences << /HideToolbar true /HideMenubar true >>"]); + assert_eq!(shown(&h), (false, false, true)); + assert!(top(&h) < with_bar - 40.0, "{} against {with_bar}", top(&h)); +} + +#[test] +fn hiding_the_window_controls_leaves_the_document_the_whole_width() { + // The document also asks for the page thumbnails panel, which is hidden with the rest. + let mut h = harness(|_| {}, &["/PageMode /UseThumbs /ViewerPreferences << /HideWindowUI true >>"]); + let width = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].viewport_rect().width(); + assert!(width(&h) > 1390.0, "{}", width(&h)); + h.key_press(egui::Key::F8); + settle(&mut h); + // The rail and the Pages panel are back beside the document. + assert!(width(&h) < 1400.0 - 48.0 - 200.0, "{}", width(&h)); +} + +#[test] +fn the_preference_to_ignore_document_views_keeps_the_interface() { + let h = harness(|app| app.always_default_view = true, &[HIDE_ALL]); + assert_eq!(shown(&h), (true, true, true)); + assert_eq!(h.state().hidden_interface(), HiddenInterface::NONE); +} + +#[test] +fn f8_hides_the_interface_of_any_document_and_shows_it_again() { + let mut h = harness(|_| {}, &[""]); + assert_eq!(shown(&h), (true, true, true)); + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (false, false, false)); + h.get_by_label_contains("Press F8 to show them"); + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (true, true, true)); +} + +#[test] +fn hiding_the_interface_is_never_saved() { + let h = harness(|_| {}, &[HIDE_ALL]); + let settings = h.state().persist(); + assert!(!settings.contains("interface"), "{settings}"); +} + +/// The fit a document with `catalog` opens at, with the user's defaults (fit width). +fn opening_fit(catalog: &str, own_view: bool) -> Fit { + let mut app = PdfCraftApp::new(); + app.always_default_view = own_view; + app.open_bytes("x.pdf", None, fixture(catalog)).unwrap(); + app.views[0].fit +} + +#[test] +fn one_page_at_a_time_opens_at_fit_page() { + let default = opening_fit("", false); + assert_eq!(default, Fit::Width, "the default zoom"); + for layout in ["SinglePage", "TwoPageLeft", "TwoPageRight"] { + assert_eq!(opening_fit(&format!("/PageLayout /{layout}"), false), Fit::Page, "{layout}"); + // The user's preference keeps their zoom. + assert_eq!(opening_fit(&format!("/PageLayout /{layout}"), true), default, "{layout}"); + } + // Scrolling layouts keep the default zoom. + for layout in ["OneColumn", "TwoColumnLeft", "TwoColumnRight"] { + assert_eq!(opening_fit(&format!("/PageLayout /{layout}"), false), default, "{layout}"); + } + // A zoom the document names wins. + assert_eq!(opening_fit("/PageLayout /SinglePage /OpenAction [3 0 R /FitH 300]", false), Fit::Width); + let mut app = PdfCraftApp::new(); + app.open_bytes("x.pdf", None, fixture("/PageLayout /SinglePage /OpenAction [3 0 R /XYZ 0 300 2]")).unwrap(); + assert_eq!((app.views[0].fit, app.views[0].zoom), (Fit::None, 2.0)); +} + +/// Review on #4378: where the reader left off wins over the fit page a one-page-at-a-time +/// layout opens at. +#[test] +fn a_remembered_zoom_wins_over_the_layouts_fit_page() { + let path = std::env::temp_dir().join(format!("pdfcraft-viewer-prefs-last-view-{}.pdf", std::process::id())).to_string_lossy().into_owned(); + let mut app = PdfCraftApp::new(); + let json_path = serde_json::to_string(&path).unwrap(); + app.restore(&format!(r#"{{"last_views": [{{"path": {json_path}, "page": 1, "zoom": "62.5%", "display": "single"}}]}}"#)); + assert!(app.restore_last_view); + app.open_bytes("x.pdf", Some(path), fixture("/PageLayout /SinglePage")).unwrap(); + let v = &app.views[0]; + assert_eq!((v.fit, v.zoom, v.current), (Fit::None, 0.625, 1)); +} + +/// The last `InnerSize` and `OuterPosition` asked for over `frames` frames. +fn window_commands(h: &mut Harness<'static, PdfCraftApp>, frames: usize) -> (Option, Option) { + let (mut size, mut at) = (None, None); + for _ in 0..frames { + h.run_steps(1); + for c in &h.output().viewport_output[&egui::ViewportId::ROOT].commands { + match c { + egui::ViewportCommand::InnerSize(s) => size = Some(*s), + egui::ViewportCommand::OuterPosition(p) => at = Some(*p), + _ => {} + } + } + } + (size, at) +} + +fn on_monitor(h: &mut Harness<'static, PdfCraftApp>) { + let vp = h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default(); + vp.monitor_size = Some(egui::vec2(1920.0, 1080.0)); + vp.inner_rect = Some(egui::Rect::from_min_size(egui::pos2(50.0, 70.0), egui::vec2(1400.0, 900.0))); + vp.outer_rect = Some(egui::Rect::from_min_size(egui::pos2(50.0, 40.0), egui::vec2(1400.0, 930.0))); +} + +/// A harness whose window is on a 1920 × 1080 monitor, with one document opened in the first +/// frames. +fn windowed(files: &'static [&'static str]) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app + }); + on_monitor(&mut h); + h.run_steps(2); + for (i, catalog) in files.iter().enumerate() { + h.state_mut().open_bytes(&format!("{i}.pdf"), None, fixture(catalog)).unwrap(); + } + h +} + +#[test] +fn fit_window_sizes_the_window_to_the_first_page() { + let mut h = windowed(&["/ViewerPreferences << /FitWindow true >> /OpenAction [3 0 R /Fit]"]); + let (size, at) = window_commands(&mut h, 12); + let size = size.expect("the window is resized"); + assert_eq!(at, None, "not moved"); + // A 2:3 portrait page fitted to the window's height makes a narrower window. + assert!(size.x < 1000.0 && (size.y - 900.0).abs() < 1.0, "{size:?}"); + // Asked once. + assert_eq!(window_commands(&mut h, 6), (None, None)); + // The page now fills the document area's height and nearly its width. + let v = &h.state().views[0]; + let (page, area) = (v.page_screen_rect(0).expect("shown"), v.viewport_rect()); + assert!(area.width() - page.width() < 160.0, "page {page:?} in {area:?}"); +} + +#[test] +fn center_window_centres_the_window_on_the_monitor() { + let mut h = windowed(&["/ViewerPreferences << /CenterWindow true >>"]); + // 1400 × 930 on 1920 × 1080. + assert_eq!(window_commands(&mut h, 12), (None, Some(egui::pos2(260.0, 75.0)))); + let mut h = windowed(&["/ViewerPreferences << /FitWindow true /CenterWindow true >>"]); + let (size, at) = window_commands(&mut h, 12); + let (size, at) = (size.expect("resized"), at.expect("moved")); + assert!(((1920.0 - size.x) / 2.0 - at.x).abs() <= 0.5, "{size:?} at {at:?}"); +} + +#[test] +fn a_maximized_window_or_a_second_tab_is_left_alone() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| PdfCraftApp::new()); + on_monitor(&mut h); + h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().maximized = Some(true); + h.run_steps(2); + h.state_mut().open_bytes("0.pdf", None, fixture("/ViewerPreferences << /FitWindow true /CenterWindow true >>")).unwrap(); + assert_eq!(window_commands(&mut h, 12), (None, None), "maximized"); + + let mut h = windowed(&["", "/ViewerPreferences << /FitWindow true /CenterWindow true >>"]); + assert_eq!(window_commands(&mut h, 12), (None, None), "beside another document"); + + // A tab opened behind the document before its page is laid out (a late startup file) leaves + // the window alone too, though the document stays active. + let mut h = windowed(&["/ViewerPreferences << /FitWindow true /CenterWindow true >>"]); + h.state_mut().open_bytes("1.pdf", None, fixture("")).unwrap(); + h.state_mut().active = Some(0); + assert_eq!(window_commands(&mut h, 12), (None, None), "a tab opened behind it"); +} + +#[test] +fn the_user_is_told_which_key_brings_the_interface_back() { + let mut h = windowed(&[HIDE_ALL]); + h.run_steps(3); + h.get_by_label_contains("This document hides the toolbars and panels. Press F8 to show them."); + // Not when nothing is hidden. + let mut h = windowed(&["/ViewerPreferences << /HideToolbar false /HideMenubar false /HideWindowUI false >>"]); + h.run_steps(3); + assert!(h.query_by_label_contains("Press F8").is_none()); + // Nor when the user opens documents their own way. + let mut h = windowed(&[]); + h.state_mut().always_default_view = true; + h.state_mut().open_bytes("0.pdf", None, fixture(HIDE_ALL)).unwrap(); + h.run_steps(3); + assert!(h.query_by_label_contains("Press F8").is_none()); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1404247cd7..7a6cccab87 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3517,10 +3517,10 @@ area = "D" tier = "P1" milestone = "M4" status = "shipped" -commands = ["file.properties"] +commands = ["file.properties", "view.toggle_interface"] tools = ["doc_initial_view"] -evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/it/automation.rs::initial_view_through_tools", "crates/render/src/inspect/navigation.rs::a_go_to_action_opens_at_its_destination_explicit_or_named", "crates/ui-egui/tests/it/open_action.rs::an_xyz_open_action_puts_its_point_at_the_window_top_left", "crates/ui-egui/tests/it/open_action.rs::a_fith_open_action_puts_its_top_at_the_window_top", "crates/ui-egui/tests/it/open_action.rs::a_named_open_destination_opens_its_page", "crates/ui-egui/tests/it/open_action.rs::a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen", "crates/organize/src/tests.rs::an_unchanged_opening_destination_keeps_its_position_and_name", "crates/automation/tests/it/automation.rs::doc_info_reports_the_open_destination", "crates/organize/src/tests.rs::choosing_page_one_replaces_a_named_opening_destination", "crates/organize/src/tests.rs::a_named_opening_destination_is_kept_while_it_still_holds"] -notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Window and interface options also read indirect boolean ViewerPreferences values. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen). On open the /OpenAction destination places the view as ISO 32000-2 Table 149 says (the /XYZ point at the window's top-left, /FitH and /FitV top or left at the edge), and a named destination (name or string, /Names /Dests tree or catalog /Dests, §12.3.2.4) resolves; Always use my default zoom keeps only the page. A full-screen document shows its /NonFullScreenPageMode panel outside full screen (Table 147). doc_info reports the opening destination. Document Properties keeps an opening action whose page and magnification are left alone, but still edits only those two; it shows a named destination's page and magnification, and choosing another page or magnification replaces the name with an explicit destination, except that page 1 at the default magnification removes /OpenAction so the first page opens. A name that resolves nowhere reads as page 1 and is kept until another page is chosen" +evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/it/automation.rs::initial_view_through_tools", "crates/render/src/inspect/navigation.rs::a_go_to_action_opens_at_its_destination_explicit_or_named", "crates/ui-egui/tests/it/open_action.rs::an_xyz_open_action_puts_its_point_at_the_window_top_left", "crates/ui-egui/tests/it/open_action.rs::a_fith_open_action_puts_its_top_at_the_window_top", "crates/ui-egui/tests/it/open_action.rs::a_named_open_destination_opens_its_page", "crates/ui-egui/tests/it/open_action.rs::a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen", "crates/organize/src/tests.rs::an_unchanged_opening_destination_keeps_its_position_and_name", "crates/automation/tests/it/automation.rs::doc_info_reports_the_open_destination", "crates/organize/src/tests.rs::choosing_page_one_replaces_a_named_opening_destination", "crates/organize/src/tests.rs::a_named_opening_destination_is_kept_while_it_still_holds", "crates/ui-egui/tests/it/viewer_prefs.rs::a_document_that_hides_the_interface_opens_without_it_and_f8_brings_it_back", "crates/ui-egui/tests/it/viewer_prefs.rs::one_page_at_a_time_opens_at_fit_page", "crates/ui-egui/tests/it/viewer_prefs.rs::fit_window_sizes_the_window_to_the_first_page"] +notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Window and interface options also read indirect boolean ViewerPreferences values. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen). On open the /OpenAction destination places the view as ISO 32000-2 Table 149 says (the /XYZ point at the window's top-left, /FitH and /FitV top or left at the edge), and a named destination (name or string, /Names /Dests tree or catalog /Dests, §12.3.2.4) resolves; Always use my default zoom keeps only the page. A full-screen document shows its /NonFullScreenPageMode panel outside full screen (Table 147). doc_info reports the opening destination. Document Properties keeps an opening action whose page and magnification are left alone, but still edits only those two; it shows a named destination's page and magnification, and choosing another page or magnification replaces the name with an explicit destination, except that page 1 at the default magnification removes /OpenAction so the first page opens. A name that resolves nowhere reads as page 1 and is kept until another page is chosen. HideToolbar, HideMenubar and HideWindowUI are applied in the document's tab only (the mode bar's tools and quick-action bar, the menu bar, the rail, side panels and scroll bars; the tab strip stays), never saved, skipped with Always use my default zoom and page layout, and F8 (view.toggle_interface) brings them back. FitWindow and CenterWindow apply to the window's only document when the window is neither maximized nor full screen; centring needs the window on the monitor at the desktop origin, and Wayland and the browser ignore both. SinglePage, TwoPageLeft and TwoPageRight without a named zoom open at fit page" [[feature]] id = "organize.custom-properties" From 726024143f2503ede36805aa5e2ecd351539030b Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:27:02 +0800 Subject: [PATCH 033/101] M3: a document that hides only the toolbar leaves no empty mode bar under the macOS menu bar Review follow-up on #4378: HiddenInterface::mode_bar skipped the bar only when both the toolbar and the menu bar were hidden. With the native macOS menu bar the bar never draws the menus, so a document setting only HideToolbar (ISO 32000-2 Table 147) left an empty 48 px bar above the page. The bar now goes when the tools are hidden and the menus are hidden or native. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/app.rs | 2 +- crates/ui-egui/src/viewer_prefs.rs | 22 +++++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index b867059332..a713c1d576 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -615,7 +615,7 @@ impl eframe::App for PdfCraftApp { // A document can hide the toolbars, the menu and the panels around it in its tab (ISO // 32000-2 Table 147); the tab strip stays, and F8 brings the rest back (`viewer_prefs`). let hidden = self.hidden_interface(); - if !hidden.mode_bar() { + if !hidden.mode_bar(self.native_menu.is_some()) { chrome::mode_bar(self, ui); } // Whichever way another tab came to the front, it shows instead of the expanded view. diff --git a/crates/ui-egui/src/viewer_prefs.rs b/crates/ui-egui/src/viewer_prefs.rs index 067a81bcea..8b6f023b6d 100644 --- a/crates/ui-egui/src/viewer_prefs.rs +++ b/crates/ui-egui/src/viewer_prefs.rs @@ -38,9 +38,10 @@ impl HiddenInterface { self.toolbar || self.menubar || self.window_ui } - /// The mode bar has nothing left to show. - pub fn mode_bar(self) -> bool { - self.toolbar && self.menubar + /// The mode bar has nothing left to show: the tools are hidden, and so are the menus, by the + /// document or because the native macOS menu bar (`native_menu`) has them instead. + pub fn mode_bar(self, native_menu: bool) -> bool { + self.toolbar && (self.menubar || native_menu) } /// For the control channel's `ui.state`. @@ -355,4 +356,19 @@ mod tests { assert_eq!(own.hidden, HiddenInterface::ALL); assert!(!own.toggle()); } + + #[test] + fn the_mode_bar_goes_when_nothing_is_left_on_it() { + let toolbar = HiddenInterface { toolbar: true, ..HiddenInterface::NONE }; + // The in-window menus are still there. + assert!(!toolbar.mode_bar(false)); + // #4378 review: on macOS the native menu bar has the menus, so hiding the tools leaves an + // empty bar, which goes too. + assert!(toolbar.mode_bar(true)); + let both = HiddenInterface { menubar: true, ..toolbar }; + assert!(both.mode_bar(false) && both.mode_bar(true)); + // Menus hidden, tools shown: the bar has the tools. + let menubar = HiddenInterface { menubar: true, ..HiddenInterface::NONE }; + assert!(!menubar.mode_bar(false) && !menubar.mode_bar(true)); + } } From ab433eb763dd45086ef16131f734f2de804031fc Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:15:49 -0500 Subject: [PATCH 034/101] M12: edit attachment descriptions --- crates/automation/src/dispatch.rs | 4 +++ crates/automation/src/tools.rs | 2 ++ crates/automation/tests/it/automation.rs | 29 ++++++++++++++++ crates/engine/src/edit.rs | 5 +++ crates/engine/src/edit/labels.rs | 2 ++ crates/engine/src/edit/run.rs | 4 +++ crates/engine/src/history.rs | 2 +- crates/organize/src/attachments.rs | 33 +++++++++++++++++- crates/organize/src/import.rs | 2 +- crates/organize/src/import/carry.rs | 39 +++++++++++++-------- crates/organize/src/lib.rs | 2 +- crates/organize/src/tests.rs | 37 ++++++++++++++++++++ crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/dialogs.rs | 41 +++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/src/panels/right.rs | 7 ++++ crates/ui-egui/src/state.rs | 2 ++ crates/ui-egui/tests/it/attachment_row.rs | 21 +++++++++++- parity/acrobat-features.toml | 8 ++--- 19 files changed, 220 insertions(+), 23 deletions(-) diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 36e0d00454..4d3d3ec425 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -704,6 +704,10 @@ impl Automation { let edit = Edit::DeleteAttachment { key: a.str("name")?.into() }; self.apply(&a, edit)? } + "attachment_edit" => { + let edit = Edit::EditAttachmentDescription { key: a.str("name")?.into(), description: Some(a.str("description")?.into()) }; + self.apply(&a, edit)? + } "doc_remove_hidden" => self.doc_remove_hidden(&a)?, "doc_audit_redactions" => self.doc_audit_redactions(&a)?, "fill_sign_add" => self.fill_sign_add(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c6c200cece..5206ce8bdf 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -138,6 +138,8 @@ pub fn tools() -> Vec { t("attachment_delete", "Delete an attachment", "Remove the document-level attachment with this name (as doc_info lists it). Undoable.") .destructive() .with(schema(json!({"doc": doc(), "name": {"type": "string"}}), &["doc", "name"])), + t("attachment_edit", "Edit an attachment description", "Change the description of the document-level attachment with this name (as doc_info lists it). An empty description clears it. The file and other attachment data are preserved. Undoable.") + .with(schema(json!({"doc": doc(), "name": {"type": "string"}, "description": {"type": "string"}}), &["doc", "name", "description"])), t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited.") .cmd("file.open") .with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6a5765b911..4be863ac5b 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9324,6 +9324,35 @@ fn attaching_and_deleting_files_through_the_tools() { assert!(a.call("attachment_add", &json!({ "doc": doc, "path": "missing.txt" })).is_err(), "a file that is not there is refused"); } +#[test] +fn attachment_descriptions_can_be_edited_cleared_undone_and_saved() { + let dir = workdir("attachment-description"); + let bytes = b"original attachment bytes"; + std::fs::write(dir.join("notes.txt"), bytes).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "attachment_add", json!({"doc": doc, "path": "notes.txt", "description": "Original"})); + + ok(&mut a, "attachment_edit", json!({"doc": doc, "name": "notes.txt", "description": "Updated"})); + assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"], "Updated"); + ok(&mut a, "edit_undo", json!({"doc": doc})); + assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"], "Original"); + ok(&mut a, "edit_redo", json!({"doc": doc})); + assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"], "Updated"); + + ok(&mut a, "attachment_edit", json!({"doc": doc, "name": "notes.txt", "description": ""})); + assert!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"].is_null()); + ok(&mut a, "edit_undo", json!({"doc": doc})); + ok(&mut a, "doc_save", json!({"doc": doc, "path": "edited.pdf", "full": true})); + let saved = std::fs::read(dir.join("edited.pdf")).unwrap(); + let info = pdfcraft_render::inspect(std::sync::Arc::new(saved.clone()), None).unwrap(); + let attachment = info.attachments.iter().find(|item| item.name == "notes.txt").unwrap(); + assert_eq!(attachment.description.as_deref(), Some("Updated")); + assert_eq!(pdfcraft_render::attachment_data(&saved, None, attachment).unwrap(), bytes); + let reopened = ok(&mut a, "doc_open", json!({"path": "edited.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "doc_info", json!({"doc": reopened}))["attachments"][0]["description"], "Updated"); +} + #[test] fn fdf_nested_comment_values_survive_export_import_and_save_through_tools() { use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full}; diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 8f43689aa9..dd4833b4f8 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -317,6 +317,11 @@ pub enum Edit { DeleteAttachment { key: String, }, + /// Change or clear only `/Desc` on a document-level attachment's file specification. + EditAttachmentDescription { + key: String, + description: Option, + }, /// Build a tagged structure tree for an untagged, text-only document. AutoTagText, /// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index c3339453af..cc5ca9508e 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -31,6 +31,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Insert pages from {name} — fields renamed", "Attach {name}", "Delete attachment {key}", + "Edit attachment description", "Add bookmark", "Rename bookmark", "Delete bookmark", @@ -233,6 +234,7 @@ impl Edit { Edit::SetInitialView(_) => Label::of("Change initial view"), Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()), Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()), + Edit::EditAttachmentDescription { .. } => Label::of("Edit attachment description"), Edit::AutoTagText => Label::of("Automatically tag text"), Edit::SetAltText { .. } => Label::of("Set alternate text"), Edit::AutoTagFormFields => Label::of("Tag form fields"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index c6b1a7438b..ffce0e0c75 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -127,6 +127,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::SetInitialView(_) | Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } + | Edit::EditAttachmentDescription { .. } | Edit::AutoTagText | Edit::SetAltText { .. } | Edit::AutoTagFormFields @@ -360,6 +361,9 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?; } Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, + Edit::EditAttachmentDescription { key, description } => { + pdfcraft_organize::set_attachment_description(doc, key, description.as_deref())?; + } Edit::ReplaceText { page, quads, text, author, strike, caret } => { // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by // the strikeout's `/NM` (#2666). diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index fafa65b4c2..29dd7ba59d 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -48,7 +48,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { match edit { Edit::SetInfo { .. } => Scope::Metadata, // The Attachments list is read from the whole document. - Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } => Scope::Full, + Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } | Edit::EditAttachmentDescription { .. } => Scope::Full, // A file attachment also changes the Attachments list. Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full, Edit::AddMeasurement(_) diff --git a/crates/organize/src/attachments.rs b/crates/organize/src/attachments.rs index b0bf222991..694755c074 100644 --- a/crates/organize/src/attachments.rs +++ b/crates/organize/src/attachments.rs @@ -7,7 +7,7 @@ use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; use crate::OrganizeError; -use crate::import::{name_tree_entries, set_attachments}; +use crate::import::{name_tree_entries, set_attachments, set_attachments_exact}; /// The keys of the document-level attachments, in name-tree order. pub fn attachment_keys(doc: &Document) -> Vec { @@ -82,6 +82,37 @@ pub fn delete_attachment(doc: &mut Document, key: &str) -> Result<(), OrganizeEr if all.is_empty() { clear_embedded_files(doc) } else { set_attachments(doc, all) } } +/// Set or clear the description of a document-level attachment without replacing its file +/// specification or embedded stream. +pub fn set_attachment_description(doc: &mut Document, key: &str, description: Option<&str>) -> Result<(), OrganizeError> { + let mut all = entries(doc); + let Some((_, value)) = all.iter_mut().find(|(name, _)| PdfString::literal(name.clone()).to_text() == key) else { + return Err(OrganizeError::Invalid(format!("there is no attachment named {key}"))); + }; + let description = description.map(str::trim).filter(|s| !s.is_empty()).map(PdfString::text).map(Object::String); + match value { + Object::Ref(reference) => { + doc.update_dict(*reference, |spec| match description { + Some(description) => spec.set(b"Desc".to_vec(), description), + None => { + spec.remove(b"Desc"); + } + })?; + } + Object::Dict(spec) => { + match description { + Some(description) => spec.set(b"Desc".to_vec(), description), + None => { + spec.remove(b"Desc"); + } + } + set_attachments_exact(doc, all)?; + } + _ => return Err(OrganizeError::Invalid(format!("attachment {key} has an invalid file specification"))), + } + Ok(()) +} + /// The `/EmbeddedFiles` entries (key, file specification), in tree order. fn entries(doc: &Document) -> Vec<(Vec, Object)> { let Some(tree) = doc diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 9f86d6455a..ab946e169b 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -28,8 +28,8 @@ mod combine; mod copy; mod fields; -pub(crate) use carry::set_attachments; use carry::{Mark, source_outline, write_outline_level}; +pub(crate) use carry::{set_attachments, set_attachments_exact}; pub use combine::{ CombineOutcome, Combiner, Run, SplitBy, combine, combine_grouped, combine_grouped_with, combine_selected, combine_selected_with, combine_with, extract_pages, split, split_ranges, diff --git a/crates/organize/src/import/carry.rs b/crates/organize/src/import/carry.rs index 15a412e231..c04c17ec9e 100644 --- a/crates/organize/src/import/carry.rs +++ b/crates/organize/src/import/carry.rs @@ -23,29 +23,40 @@ pub(super) fn collect_attachments(dst: &mut Document, src: &Document, out: &mut /// Write attachments as a flat, sorted `/EmbeddedFiles` name tree; duplicate names get " (2)"… pub(crate) fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec, Object)>) -> Result<(), OrganizeError> { + write_attachments(dst, &mut entries, true) +} + +/// Rewrite the name tree without changing attachment keys (used when editing one file spec). +pub(crate) fn set_attachments_exact(dst: &mut Document, mut entries: Vec<(Vec, Object)>) -> Result<(), OrganizeError> { + write_attachments(dst, &mut entries, false) +} + +fn write_attachments(dst: &mut Document, entries: &mut [(Vec, Object)], uniquify: bool) -> Result<(), OrganizeError> { if entries.is_empty() { return Ok(()); } // Names are compared and numbered as text: a key written as UTF-16 (a name with an accent // or Chinese letters) must not get ASCII bytes appended to it. - let mut seen = std::collections::HashSet::new(); - for (k, _) in entries.iter_mut() { - let base = pdfcraft_cos::PdfString::literal(k.clone()).to_text(); - let mut text = base.clone(); - let mut n = 2; - while !seen.insert(text.clone()) { - text = format!("{base} ({n})"); - n += 1; - } - if text != base { - *k = pdfcraft_cos::PdfString::text(&text).bytes; + if uniquify { + let mut seen = std::collections::HashSet::new(); + for (k, _) in entries.iter_mut() { + let base = pdfcraft_cos::PdfString::literal(k.clone()).to_text(); + let mut text = base.clone(); + let mut n = 2; + while !seen.insert(text.clone()) { + text = format!("{base} ({n})"); + n += 1; + } + if text != base { + *k = pdfcraft_cos::PdfString::text(&text).bytes; + } } } entries.sort_by(|a, b| a.0.cmp(&b.0)); let mut names = Vec::new(); - for (k, v) in entries { - names.push(Object::String(pdfcraft_cos::PdfString::literal(k))); - names.push(v); + for (k, v) in entries.iter() { + names.push(Object::String(pdfcraft_cos::PdfString::literal(k.clone()))); + names.push(v.clone()); } let mut tree = Dict::new(); tree.set(b"Names".to_vec(), Object::Array(names)); diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index b890965d0c..63328a83dd 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -26,7 +26,7 @@ mod transition; pub mod view; mod xmp; -pub use attachments::{add_attachment, attachment_keys, delete_attachment, mime_for_name}; +pub use attachments::{add_attachment, attachment_keys, delete_attachment, mime_for_name, set_attachment_description}; pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box}; pub use comments::import_comments; pub use dedupe::dedupe_resources; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 33dc262fce..d196596b0a 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -3440,6 +3440,43 @@ fn attachments_are_added_listed_and_deleted() { assert!(doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Names").is_none()); } +#[test] +fn attachment_description_changes_only_the_file_spec_and_can_be_cleared() { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let key = add_attachment(&mut doc, "notes.txt", b"keep these bytes", Some("Original"), Some("text/plain"), None).unwrap(); + let root = doc.root().unwrap(); + let names = doc.get(root).as_dict().unwrap().get(b"Names").cloned().unwrap(); + let tree = doc.resolve(&names).as_dict().unwrap().get(b"EmbeddedFiles").cloned().unwrap(); + let tree = doc.resolve(&tree); + let pairs = tree.as_dict().unwrap().get(b"Names").cloned().unwrap(); + let resolved_pairs = doc.resolve(&pairs); + let pairs = resolved_pairs.as_array().unwrap(); + let spec_ref = pairs.get(1).and_then(Object::as_ref).unwrap(); + let stream_ref = doc.get(spec_ref).as_dict().unwrap().get(b"EF").and_then(Object::as_dict).unwrap().reference(b"F").unwrap(); + doc.update_dict(spec_ref, |spec| spec.set(b"CustomKey".to_vec(), Object::name("preserved"))).unwrap(); + + set_attachment_description(&mut doc, &key, Some("Revised description")).unwrap(); + let spec_object = doc.get(spec_ref); + let spec = spec_object.as_dict().unwrap(); + assert_eq!(spec.get(b"Desc").and_then(Object::as_string).map(pdfcraft_cos::PdfString::to_text).as_deref(), Some("Revised description")); + assert_eq!(spec.get(b"UF").and_then(Object::as_string).map(pdfcraft_cos::PdfString::to_text).as_deref(), Some("notes.txt")); + assert_eq!(spec.get(b"CustomKey"), Some(&Object::name("preserved"))); + assert_eq!(spec.get(b"EF").and_then(Object::as_dict).and_then(|ef| ef.reference(b"F")), Some(stream_ref)); + let stream_object = doc.get(stream_ref); + let pdfcraft_cos::Object::Stream(stream) = stream_object.as_ref() else { panic!("the attachment file remains a stream") }; + assert_eq!(stream.decoded().unwrap(), b"keep these bytes"); + assert_eq!(attachment_keys(&doc), ["notes.txt"]); + + set_attachment_description(&mut doc, &key, Some(" ")).unwrap(); + assert!(!doc.get(spec_ref).as_dict().unwrap().contains(b"Desc")); + let reopened = save_and_reopen(&doc); + assert_eq!(attachment_keys(&reopened), ["notes.txt"]); + assert_eq!( + set_attachment_description(&mut doc, "missing.txt", Some("x")), + Err(OrganizeError::Invalid("there is no attachment named missing.txt".into())) + ); +} + #[test] fn rotate_repeated_indices_accumulate_from_the_current_page_value() { for level in 0..3 { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 425c2e26ce..d6ce979484 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -165,6 +165,7 @@ impl PdfCraftApp { pending_overwrite: None, save_override: None, props_draft: None, + attachment_description_draft: None, view_draft: None, custom_draft: None, requests: Default::default(), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d24e62cb7d..220a04393a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -54,6 +54,7 @@ struct Pending { optimize_now: bool, duplicate_now: Option, page_props_now: Option<(pdfcraft_engine::DocId, u64, Edit)>, + attachment_description_now: Option<(pdfcraft_engine::DocId, Edit)>, replace_now: bool, open_revision: Option, a11y_now: bool, @@ -334,6 +335,24 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m req.close = req.apply || cancel; return false; } + Dialog::AttachmentDescription => { + let Some((doc, _, _)) = app.attachment_description_draft.as_ref() else { + req.close = true; + return false; + }; + if app.active_ids().is_none_or(|(_, active)| active != *doc) { + req.close = true; + return false; + } + let Some((_, key, description)) = app.attachment_description_draft.as_mut() else { + req.close = true; + return false; + }; + ui.heading(tl!("Edit attachment description")); + ui.label(key.as_str()); + let label = ui.label(tl!("Description")); + ui.add(widgets::line(description)).labelled_by(label.id); + } Dialog::Signature => { if !dialog_signature(ui, app, req) { return false; @@ -1585,6 +1604,17 @@ fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { req.close = true; } + } else if dialog == Dialog::AttachmentDescription { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { + if let Some((doc, key, description)) = app.attachment_description_draft.as_ref() { + req.attachment_description_now = + Some((*doc, Edit::EditAttachmentDescription { key: key.clone(), description: Some(description.clone()) })); + } + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } } else if changed { if widgets::pill_button(ui, tl!("OK"), true).clicked() { req.apply = true; @@ -1660,6 +1690,14 @@ fn finish_requests(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, re /// Runs the document actions the dialog asked for. fn finish_actions(app: &mut PdfCraftApp, req: &mut Pending) { + if let Some((doc, edit)) = req.attachment_description_now.take() { + app.attachment_description_draft = None; + if app.active_ids().is_some_and(|(_, active)| active == doc) { + app.apply_edit(edit); + } else { + app.notify_tr("The document changed while the dialog was open, so nothing was changed. Open the description editor again."); + } + } if req.summarize_now { app.summarize_comments(); } @@ -1837,6 +1875,9 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: let replaces = req.link_command.is_some_and(|c| c.starts_with("protect.")) || req.open_revision.is_some(); if modal_closed || req.close || replaces { app.dialog = None; + if dialog == Dialog::AttachmentDescription { + app.attachment_description_draft = None; + } app.props_draft = None; app.view_draft = None; app.custom_draft = None; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 222bb988e4..e847a75dd7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -432,6 +432,8 @@ pub struct PdfCraftApp { pub save_override: Option, /// Document Properties ▸ Description fields being edited: (document, Title/Author/Subject/Keywords). pub props_draft: Option<(DocId, [String; 4])>, + /// Attachment description being edited: (document, name-tree key, description). + pub attachment_description_draft: Option<(DocId, String, String)>, /// Document Properties ▸ Initial View (and reading options) being edited. pub view_draft: Option<(DocId, pdfcraft_engine::InitialView)>, /// Document Properties ▸ Custom being edited. diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 79769867d3..500398651f 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -361,6 +361,13 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { attachment_action = Some((ai, false)); ui.close(); } + if let Some(key) = deletable + && ui.button(tl!("Edit description")).clicked() + { + app.attachment_description_draft = Some((id, key.clone(), a.description.clone().unwrap_or_default())); + app.dialog = Some(crate::Dialog::AttachmentDescription); + ui.close(); + } if let Some(key) = deletable && ui.button(tl!("Delete attachment")).clicked() { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 03df25f201..11c89af71e 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -125,6 +125,8 @@ pub enum Dialog { Signature, /// Comment Properties. CommentProps, + /// Edit the description of an embedded document attachment. + AttachmentDescription, /// Replace Pages (after choosing the file). ReplacePages, /// Prepare a form ▸ Field Properties. diff --git a/crates/ui-egui/tests/it/attachment_row.rs b/crates/ui-egui/tests/it/attachment_row.rs index c52b2892e0..144e03ab78 100644 --- a/crates/ui-egui/tests/it/attachment_row.rs +++ b/crates/ui-egui/tests/it/attachment_row.rs @@ -22,7 +22,7 @@ fn attachments_pdf() -> Vec { [format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n", INNER.len()).as_bytes(), INNER, b"\nendstream"].concat(), b"<< /Type /Filespec /F (inner.pdf) /UF (inner.pdf) /EF << /F 4 0 R >> >>".to_vec(), b"<< /Type /EmbeddedFile /Length 11 >>\nstream\nhello notes\nendstream".to_vec(), - b"<< /Type /Filespec /F (notes.txt) /UF (notes.txt) /EF << /F 6 0 R >> >>".to_vec(), + b"<< /Type /Filespec /F (notes.txt) /UF (notes.txt) /Desc (Original) /EF << /F 6 0 R >> >>".to_vec(), ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); @@ -151,6 +151,25 @@ fn the_right_click_menu_opens_saves_and_deletes() { assert!(doc.can_undo().is_some_and(|l| l.starts_with("Delete attachment")), "as one undoable edit"); } +#[test] +fn the_description_can_be_edited_from_the_row_menu() { + let mut h = harness(); + right_click(&mut h, "notes.txt"); + h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Edit description").click(); + h.run_steps(2); + let description = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Description"); + description.click(); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + description.type_text("Edited in the panel"); + h.get_by_label("OK").click(); + h.run_steps(3); + let state = h.state(); + let doc = state.session.get(state.views[0].id).unwrap(); + let attachment = doc.info.attachments.iter().find(|a| a.name == "notes.txt").unwrap(); + assert_eq!(attachment.description.as_deref(), Some("Edited in the panel")); + assert!(doc.can_undo().is_some_and(|label| label == "Edit attachment description")); +} + #[test] fn the_row_buttons_still_take_their_own_clicks() { let dir = Dir::new("buttons"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1404247cd7..4997b56a1c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3420,11 +3420,11 @@ title = "Edit attachment descriptions" area = "D" tier = "P2" milestone = "M12" -status = "planned" +status = "shipped" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["attachment_edit"] +evidence = ["crates/organize/src/tests.rs::attachment_description_changes_only_the_file_spec_and_can_be_cleared", "crates/automation/tests/it/automation.rs::attachment_descriptions_can_be_edited_cleared_undone_and_saved", "crates/ui-egui/tests/it/attachment_row.rs::the_description_can_be_edited_from_the_row_menu"] +notes = "The document-level attachment row menu edits only the Filespec /Desc; an empty description clears it. The embedded stream and other file-spec keys stay intact. Undo/redo and the headless attachment_edit tool use the same edit. Page paper-clip comments are not affected." [[feature]] id = "organize.associated-files" From 6d24eedac981daa6aef39c52d3a4b9c3aa567cc8 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:26:11 -0500 Subject: [PATCH 035/101] docs: cover attachment edit helpers --- crates/organize/src/import/carry.rs | 1 + crates/ui-egui/src/dialogs.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/organize/src/import/carry.rs b/crates/organize/src/import/carry.rs index c04c17ec9e..dbe3bd8d1b 100644 --- a/crates/organize/src/import/carry.rs +++ b/crates/organize/src/import/carry.rs @@ -31,6 +31,7 @@ pub(crate) fn set_attachments_exact(dst: &mut Document, mut entries: Vec<(Vec, Object)], uniquify: bool) -> Result<(), OrganizeError> { if entries.is_empty() { return Ok(()); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 220a04393a..9f03e77b79 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1572,6 +1572,7 @@ fn dialog_about(ui: &mut egui::Ui, req: &mut Pending) -> bool { true } +/// Draw the active dialog's footer and record any requested action in `req`. fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { ui.add_space(16.0); let changed = draft_changes(app).is_some_and(|c| !c.is_empty()); From b87b0d35924a2c4af8e2173d16a7a7c9b343fd79 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 19:51:32 -0700 Subject: [PATCH 036/101] Optimize: remove every page's reference to a shared link left with no action A link annotation listed twice in one page's /Annots, or by two pages, kept its later references after the first one stripped its external action, leaving inert links on the page. Every reference to a link that ends up with no action and no /Dest is now removed. A shared link counts once in external_links. --- crates/optimize/src/external_refs.rs | 8 ++++++- crates/optimize/tests/optimize.rs | 32 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/optimize/src/external_refs.rs b/crates/optimize/src/external_refs.rs index a9a31bca5a..4a61f37e88 100644 --- a/crates/optimize/src/external_refs.rs +++ b/crates/optimize/src/external_refs.rs @@ -373,6 +373,7 @@ fn discard_bookmarks(doc: &mut Document) -> Result { /// changed; a link counts whether it was removed or kept for its other actions. pub(crate) fn discard(doc: &mut Document, pages: &[ObjRef]) -> Result<(usize, usize), OptimizeError> { let mut links = 0; + let mut removed_links = HashSet::new(); for p in pages { let Some(pd) = doc.get(*p).as_dict().cloned() else { continue }; let Some(annots) = pd.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()) else { continue }; @@ -393,7 +394,11 @@ pub(crate) fn discard(doc: &mut Document, pages: &[ObjRef]) -> Result<(usize, us _ => None, }; let Some(action) = stripped else { - keep.push(x.clone()); + if !matches!(x, Object::Ref(r) if removed_links.contains(r)) { + keep.push(x.clone()); + } else { + changed = true; + } continue; }; links += 1; @@ -408,6 +413,7 @@ pub(crate) fn discard(doc: &mut Document, pages: &[ObjRef]) -> Result<(usize, us Object::Ref(r) => { doc.set(*r, Object::Dict(d)); if removed { + removed_links.insert(*r); changed = true; } else { keep.push(x.clone()); diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index bb9734101d..eda167b18b 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -2436,6 +2436,38 @@ fn links_and_bookmarks_to_other_documents_are_discarded_and_links_within_stay() assert_eq!(chain(&doc, bookmark(&doc, items[1]).get(b"A").unwrap()), ["Named"]); } +#[test] +fn repeated_indirect_remote_link_is_removed_from_one_page() { + let (mut doc, p, _) = linked_doc( + |doc, _| { + let link = doc.add(link_with(Some(action("GoToR", None)), None)); + vec![Object::Ref(link), Object::Ref(link)] + }, + &[], + ); + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 1, "{report:?}"); + assert!(annots_of(&doc, p).is_empty(), "both references leave the page"); +} + +#[test] +fn shared_indirect_remote_link_with_empty_next_is_removed_from_every_page() { + let (mut doc, first, items) = linked_doc(|_, _| Vec::new(), &[action("Named", None), action("GoToR", None)]); + let second = page(&mut doc, &[], "BT ET"); + let link = doc.add(link_with(Some(action("GoToR", Some(Object::Array(vec![Object::Null])))), None)); + for p in [first, second] { + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(link)]))).unwrap(); + } + doc.update_dict(items[0], |d| d.set(b"First".to_vec(), Object::Ref(items[1]))).unwrap(); + let report = optimize(&mut doc, &Settings { discard_external_refs: true, ..Settings::default() }).unwrap(); + assert_eq!(report.external_links, 1, "{report:?}"); + assert_eq!(report.external_bookmarks, 1, "{report:?}"); + assert!(doc.get(items[1]).as_dict().unwrap().get(b"A").is_none(), "the shared bookmark has no action"); + assert!(annots_of(&doc, first).is_empty(), "the first page loses the link"); + assert!(annots_of(&doc, second).is_empty(), "the second page loses the link"); + assert!(doc.get(link).as_dict().unwrap().get(b"A").is_none(), "the shared link has no action"); +} + #[test] fn an_action_to_another_document_hands_its_place_to_the_actions_after_it() { let (doc, p, _) = linked_doc( From f15e8c5115825a84def86f1a205219a5839588af Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:42:12 +0800 Subject: [PATCH 037/101] M3: full screen plays page transitions and advances by display time (#379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In full screen, a page's transition (/Trans, ISO 32000-2 §12.4.4, Table 164) plays when the page comes into view, from the outgoing page's raster to the incoming one's (render::transition geometry), and a page's display time (/Dur) advances to the next page once it is up. A still pointer hides after three seconds and shows again when it moves. - Transitions play in single-page view without view rotation, once the incoming page has rendered; spreads, a rotated view and the Animation effects preference set to off change pages at once. - Display times count from the end of the transition and stop at the last page; the reader can turn the page sooner. The document's durations are bounded (a transition at most 10 s). The logic lives in canvas/presenter.rs; canvas.rs keeps its state on DocView and calls it after drawing the pages, and the Animation effects preference reaches it through PageEnv. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/canvas.rs | 14 ++ crates/ui-egui/src/canvas/presenter.rs | 249 +++++++++++++++++++++++++ crates/ui-egui/src/canvas/zoom.rs | 8 + crates/ui-egui/tests/it/view.rs | 110 +++++++++++ 4 files changed, 381 insertions(+) create mode 100644 crates/ui-egui/src/canvas/presenter.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8b280ba75d..34b643bd0e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -30,6 +30,7 @@ mod organize; pub(super) mod page_cursor; pub(super) mod page_pointer; pub(super) mod page_router; +mod presenter; pub mod scroll_speed; mod selection; pub(super) mod signature_drag; @@ -322,6 +323,8 @@ pub struct DocView { /// Full screen (Acrobat's Full Screen Mode): the display saved on entering it, while the view /// shows one page or spread at a time, fitted to the screen. `None` outside full screen. presentation: Option, + /// Full screen's transitions, display times and pointer (see [`presenter`]). + pub(crate) presenter: presenter::Presenter, pages: HashMap, /// Pages the renderer could not draw, with the reason (never re-requested). errors: HashMap, @@ -636,6 +639,7 @@ impl DocView { flash: None, compare_marks: Vec::new(), presentation: None, + presenter: Default::default(), pages: HashMap::new(), errors: HashMap::new(), waiting_since: HashMap::new(), @@ -795,6 +799,7 @@ impl DocView { fresh.cover = self.cover; // Still presenting, with the display to go back to on leaving. fresh.presentation = self.presentation.take(); + fresh.presenter = std::mem::take(&mut self.presenter); fresh.back = std::mem::take(&mut self.back); fresh.forward = std::mem::take(&mut self.forward); fresh.find = self.find.take(); @@ -856,6 +861,8 @@ struct PageEnv<'a> { glide: Vec2, hand: bool, info: &'a DocInfo, + /// The Animation effects preference as it applies now (full screen's transitions follow it). + animates: bool, initials: &'a Option, key_scroll: f32, middle_gesture: bool, @@ -1974,6 +1981,7 @@ fn draw_pages( y_shift, .. } = *env; + let animates = env.animates; // egui's drag responses accept every pointer button. Keep a wheel gesture from also // selecting text, drawing a mark, or panning with the Hand tool. if middle_gesture { @@ -2082,6 +2090,11 @@ fn draw_pages( let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); crate::calc_order_ui::paint_trace(ui, painter, visible, &view.calc_order, generation, selected); } + // Full screen plays transitions over the page just drawn, and keeps time (#379). + if view.presentation.is_some() { + let now = ui.input(|i| i.time); + presenter::frame(view, info, painter, ui.ctx(), now, animates, pointer); + } // The pages' one cursor, from the claim on the highest layer the tool reaches, and the tip. let hover_text = pointer.filter(|_| unobstructed).zip(view.cursor.resolve(ui.ctx(), tool)); // ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag @@ -3174,6 +3187,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let env = PageEnv { added: &added, allowed, + animates, author: &author, auto_delta, avail, diff --git a/crates/ui-egui/src/canvas/presenter.rs b/crates/ui-egui/src/canvas/presenter.rs new file mode 100644 index 0000000000..7738cf906c --- /dev/null +++ b/crates/ui-egui/src/canvas/presenter.rs @@ -0,0 +1,249 @@ +//! Full screen as a presentation (ISO 32000-2 §12.4.4): a page's transition (`/Trans`, Table 164) +//! plays when the page comes into view, a page with a display time (`/Dur`) advances by itself, +//! and the pointer hides while it stays still. +//! +//! Transitions play in single-page view without view rotation, from the outgoing page's raster +//! to the incoming one's once it has rendered; spreads, a rotated view and the Animation effects +//! preference set to off change pages at once. Display times count from the end of the +//! transition and stop at the last page. + +use egui::{Color32, CornerRadius, CursorIcon, Painter, Pos2, Rect, TextureHandle, pos2}; +use pdfcraft_render::transition::{self, Effect, Piece}; +use pdfcraft_render::{DocInfo, PageTransition}; + +use super::{DocView, PageLayout}; + +/// Seconds the pointer stays still before full screen hides it. +pub(crate) const HIDE_POINTER_AFTER: f64 = 3.0; +/// Bounds on a document's numbers, which are untrusted: the longest transition played and the +/// longest display time waited for. +const MAX_TRANSITION: f64 = 10.0; +const MAX_DISPLAY: f64 = 24.0 * 3600.0; + +/// What full screen remembers between frames. +#[derive(Default)] +pub(crate) struct Presenter { + /// The page wholly in view, and since when (its transition over). + shown: Option<(usize, f64)>, + turn: Option, + /// Where the pointer last moved to, and when. + pointer: Option<(Pos2, f64)>, +} + +/// A transition under way, from one page to the next. +struct Turn { + from_page: usize, + /// The outgoing page's raster, kept while the transition plays. + from: TextureHandle, + to_page: usize, + effect: Effect, + seconds: f64, + /// When the incoming page's raster arrived and the transition began. + started: Option, +} + +impl Presenter { + /// Whether a page transition is playing or waiting for its incoming page. + pub(crate) fn turning(&self) -> bool { + self.turn.is_some() + } +} + +/// The transition and display time of page `page` (0-based), if it has either. +fn entry(info: &DocInfo, page: usize) -> Option<&PageTransition> { + info.transitions.iter().find(|t| t.page == page) +} + +/// The effect a page's transition plays and its length in seconds; `None` for no visible effect. +fn effect_of(t: &PageTransition) -> Option<(Effect, f64)> { + let style = t.style?; + let effect = Effect::from_parts(style.name(), t.horizontal, t.inward, t.direction, t.scale); + let seconds = f64::from(t.duration); + (effect != Effect::Replace && seconds.is_finite() && seconds > 0.0).then(|| (effect, seconds.min(MAX_TRANSITION))) +} + +/// `rect` with the aspect ratio of a `w` by `h` page, as large as fits in `within` and centred +/// there. +fn fitted(w: f32, h: f32, within: Rect) -> Rect { + let (w, h) = (w.max(1.0), h.max(1.0)); + let k = (within.width() / w).min(within.height() / h); + if !k.is_finite() || k <= 0.0 { + return within; + } + Rect::from_center_size(within.center(), egui::vec2(w * k, h * k)) +} + +/// Where a piece of a page goes on screen and which part of its raster it shows: `dst` in `page` +/// (screen), `src` as texture coordinates, and its tint. `None` for a piece with nothing to show. +pub(crate) fn piece_on_screen(page: Rect, p: &Piece) -> Option<(Rect, Rect, Color32)> { + let alpha = if p.alpha.is_finite() { p.alpha.clamp(0.0, 1.0) } else { 0.0 }; + let at = |f: [f32; 4]| { + let f = f.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }); + (pos2(f[0], f[1]), pos2(f[2], f[3])) + }; + let (d0, d1) = at(p.dst); + let (s0, s1) = at(p.src); + let dst = Rect::from_min_max(page.lerp_inside(d0.to_vec2()), page.lerp_inside(d1.to_vec2())); + let uv = Rect::from_min_max(s0, s1); + (alpha > 0.0 && dst.width() > 0.0 && dst.height() > 0.0).then(|| (dst, uv, Color32::WHITE.gamma_multiply(alpha))) +} + +fn paint(painter: &Painter, tex: &TextureHandle, page: Rect, pieces: &[Piece]) { + for p in pieces { + if let Some((dst, uv, tint)) = piece_on_screen(page, p) { + painter.image(tex.id(), dst, uv, tint); + } + } +} + +/// One frame of full screen for `view`, after its pages were drawn: start, paint or finish the +/// current page's transition, advance a page whose display time is up, and hide a still pointer. +/// `animates` is the Animation effects preference as it applies now. +pub(super) fn frame(view: &mut DocView, info: &DocInfo, painter: &Painter, ctx: &egui::Context, now: f64, animates: bool, pointer: Option) { + let page = view.current; + let showing = view.presenter.shown.map(|(p, _)| p); + let arriving = view.presenter.turn.as_ref().map(|t| t.to_page); + if showing != Some(page) && arriving != Some(page) { + // A new page: play its transition from the page before, when there is one to play. + let plays = animates && view.layout == PageLayout::Single && view.rotation == 0; + let turn = showing.or(arriving).filter(|_| plays).and_then(|from_page| { + let (effect, seconds) = entry(info, page).and_then(effect_of)?; + let from = view.pages.get(&from_page)?.tex.clone(); + Some(Turn { from_page, from, to_page: page, effect, seconds, started: None }) + }); + view.presenter.shown = if turn.is_some() { None } else { Some((page, now)) }; + view.presenter.turn = turn; + } + + if let Some(turn) = view.presenter.turn.as_mut() { + let to_rect = view.screen_rects.iter().find(|(i, _)| *i == turn.to_page).map(|&(_, r)| r); + let incoming = view.pages.get(&turn.to_page).map(|p| p.tex.clone()); + match to_rect { + None => { + view.presenter.turn = None; + view.presenter.shown = Some((page, now)); + } + Some(to_rect) => { + // The outgoing page keeps its own shape, centred where the incoming one is. + let from_rect = info.pages.get(turn.from_page).map_or(to_rect, |p| fitted(p.width, p.height, to_rect.union(view.viewport_screen))); + let from_rect = if (from_rect.aspect_ratio() - to_rect.aspect_ratio()).abs() < 0.01 { to_rect } else { from_rect }; + let t = match (&incoming, turn.started) { + // Hold the outgoing page until the incoming one has rendered. + (None, _) => 0.0, + (Some(_), Some(start)) => ((now - start) / turn.seconds) as f32, + (Some(_), None) => { + turn.started = Some(now); + 0.0 + } + }; + if t >= 1.0 { + view.presenter.turn = None; + view.presenter.shown = Some((page, now)); + } else { + let f = transition::frame(&turn.effect, t); + painter.rect_filled(from_rect.union(to_rect), CornerRadius::ZERO, Color32::BLACK); + paint(painter, &turn.from, from_rect, &f.outgoing); + if let Some(tex) = &incoming { + paint(painter, tex, to_rect, &f.incoming); + } + ctx.request_repaint(); + } + } + } + } + + // A page with a display time advances once it is up (not past the last page). + if let Some((shown, since)) = view.presenter.shown + && shown == page + && page + 1 < info.pages.len() + && let Some(seconds) = entry(info, page).and_then(|t| t.display).map(f64::from).filter(|d| d.is_finite() && *d >= 0.0) + { + let left = since + seconds.min(MAX_DISPLAY) - now; + if left <= 0.0 { + view.step_page(true); + ctx.request_repaint(); + } else { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(left)); + } + } + + // The pointer hides once it has been still for a while, and comes back when it moves. + match pointer { + None => view.presenter.pointer = None, + Some(p) if view.presenter.pointer.map(|(q, _)| q) != Some(p) => view.presenter.pointer = Some((p, now)), + Some(_) => {} + } + if let Some((_, since)) = view.presenter.pointer { + let left = since + HIDE_POINTER_AFTER - now; + if left <= 0.0 { + ctx.set_cursor_icon(CursorIcon::None); + } else { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(left)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn page() -> Rect { + Rect::from_min_size(pos2(100.0, 50.0), egui::vec2(200.0, 400.0)) + } + + #[test] + fn a_piece_maps_page_fractions_to_the_screen_and_its_raster() { + let p = Piece { src: [0.0, 0.0, 0.5, 1.0], dst: [0.5, 0.0, 1.0, 1.0], alpha: 1.0 }; + let (dst, uv, tint) = piece_on_screen(page(), &p).unwrap(); + assert_eq!(dst, Rect::from_min_max(pos2(200.0, 50.0), pos2(300.0, 450.0))); + assert_eq!(uv, Rect::from_min_max(pos2(0.0, 0.0), pos2(0.5, 1.0))); + assert_eq!(tint, Color32::WHITE); + let faded = piece_on_screen(page(), &Piece { alpha: 0.5, ..p }).unwrap().2; + assert!(faded.a() > 0 && faded.a() < 255, "half transparent: {faded:?}"); + } + + #[test] + fn hostile_pieces_paint_nothing_or_stay_on_the_page() { + for p in [ + Piece { src: [0.0; 4], dst: [0.2, 0.2, 0.2, 0.9], alpha: 1.0 }, + Piece { src: [0.0, 0.0, 1.0, 1.0], dst: [0.0, 0.0, 1.0, 1.0], alpha: 0.0 }, + Piece { src: [0.0, 0.0, 1.0, 1.0], dst: [0.0, 0.0, 1.0, 1.0], alpha: f32::NAN }, + ] { + assert!(piece_on_screen(page(), &p).is_none(), "{p:?}"); + } + let (dst, uv, _) = + piece_on_screen(page(), &Piece { src: [f32::NAN, -3.0, 9.0, f32::INFINITY], dst: [-1.0, f32::NAN, 2.0, 1.5], alpha: 1.0 }).unwrap(); + assert!(page().contains_rect(dst), "{dst:?}"); + assert!(Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)).contains_rect(uv), "{uv:?}"); + } + + #[test] + fn only_a_visible_effect_with_a_length_plays() { + use pdfcraft_render::TransitionStyle; + let t = |style, duration| PageTransition { + page: 1, + style, + duration, + horizontal: true, + inward: true, + direction: Some(0), + scale: 1.0, + opaque: false, + display: None, + }; + assert_eq!(effect_of(&t(Some(TransitionStyle::Wipe), 1.5)), Some((Effect::Wipe { direction: 0 }, 1.5))); + assert_eq!(effect_of(&t(Some(TransitionStyle::Fade), 600.0)).map(|e| e.1), Some(MAX_TRANSITION), "capped"); + for (style, duration) in + [(None, 1.0), (Some(TransitionStyle::Replace), 1.0), (Some(TransitionStyle::Fade), 0.0), (Some(TransitionStyle::Fade), f32::NAN)] + { + assert_eq!(effect_of(&t(style, duration)), None, "{style:?} {duration}"); + } + } + + #[test] + fn the_outgoing_page_keeps_its_shape() { + let r = fitted(600.0, 300.0, Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(400.0, 400.0))); + assert_eq!(r, Rect::from_min_max(pos2(0.0, 100.0), pos2(400.0, 300.0))); + assert_eq!(fitted(0.0, f32::NAN, Rect::ZERO), Rect::ZERO); + } +} diff --git a/crates/ui-egui/src/canvas/zoom.rs b/crates/ui-egui/src/canvas/zoom.rs index 91bc4feac8..04ae59a0be 100644 --- a/crates/ui-egui/src/canvas/zoom.rs +++ b/crates/ui-egui/src/canvas/zoom.rs @@ -35,12 +35,14 @@ impl DocView { self.continuous = false; self.fit = Fit::Page; self.selection = None; + self.presenter = Default::default(); self.goto = Some((self.current, 0.0)); } /// Leave full screen: the display from before it comes back, at the page reached meanwhile. pub fn leave_presentation(&mut self) { let Some(saved) = self.presentation.take() else { return }; + self.presenter = Default::default(); self.layout = saved.layout; self.continuous = saved.continuous; self.cover = saved.cover; @@ -65,6 +67,12 @@ impl DocView { self.presentation.is_some() } + /// Whether full screen is playing a page transition (or holding the page before it until the + /// next one has rendered). + pub fn presenting_a_transition(&self) -> bool { + self.presenter.turning() + } + /// Switch the base page display (Single or Two-page), leaving continuous scrolling as it is. /// A no-op when the layout is already current, so re-selecting it never yanks a scrolled view /// back to the top. diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index aef5ddae73..9e96746841 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2329,3 +2329,113 @@ fn a_document_press_releases_pages_keyboard_focus_without_escape() { sidebar_key(&mut h, Key::A, Modifiers::COMMAND); assert!(h.state().views[0].selected.is_empty(), "Ctrl+A now belongs to the viewer, not page thumbnails"); } + +/// Three 300×400 pt pages; the second comes in with a one-second Wipe and stays one second +/// (`/Trans`, `/Dur`), the others have neither. +const PRESENTATION: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /Trans << /S /Wipe /D 1 >> /Dur 1 >> endobj +5 0 obj << /Type /Page /Parent 2 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// The presentation document in full screen, frames a tenth of a second apart, every raster in. +fn presenting(options: &'static [(&'static str, &'static str)]) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(0.1).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("slides.pdf", None, PRESENTATION.to_vec()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app.set_option("animation_effects", "on").unwrap(); + for (k, v) in options { + app.set_option(k, v).unwrap(); + } + app + }); + h.run_steps(4); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(4); + assert!(h.state().full_screen); + settle(&mut h); + h +} + +/// Run frames until the renderer has nothing left to deliver (bounded, in real time: the +/// renderer's threads do not follow the harness clock). +fn settle(h: &mut Harness<'static, PdfCraftApp>) { + for _ in 0..300 { + h.run_steps(1); + if !h.state().render_pending() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } +} + +/// Run frames until `done` holds (bounded), returning the number of frames it took. +fn frames_until(h: &mut Harness<'static, PdfCraftApp>, done: impl Fn(&PdfCraftApp) -> bool) -> usize { + for n in 0..300 { + if done(h.state()) { + return n; + } + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("never happened"); +} + +/// #379: in full screen, a page's /Trans plays when it comes into view (from the page before, +/// once the new one has rendered), and its /Dur turns to the next page once it is up; the last +/// page, with no display time, stays. +#[test] +fn full_screen_plays_a_page_transition_and_honours_the_display_time() { + let mut h = presenting(&[]); + assert!(!h.state().views[0].presenting_a_transition(), "page 1 just shows"); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.state().views[0].current, 1); + assert!(h.state().views[0].presenting_a_transition(), "page 2 comes in with its Wipe"); + let frames = frames_until(&mut h, |a| !a.views[0].presenting_a_transition()); + assert!(frames >= 9, "the Wipe takes its second ({frames} frames of 0.1 s)"); + assert_eq!(h.state().views[0].current, 1, "still page 2 when the transition ends"); + h.run_steps(5); + assert_eq!(h.state().views[0].current, 1, "half of its display time"); + h.run_steps(7); + assert_eq!(h.state().views[0].current, 2, "its one-second display time is up"); + assert!(!h.state().views[0].presenting_a_transition(), "page 3 has no transition"); + h.run_steps(30); + assert_eq!(h.state().views[0].current, 2, "the last page stays"); + h.key_press(Key::Escape); + h.run_steps(2); + assert!(!h.state().views[0].presenting_a_transition()); +} + +/// The page changes at once where a transition can't or shouldn't play: with the Animation +/// effects preference off, and with two-page spreads. +#[test] +fn full_screen_turns_at_once_without_animations_or_in_spreads() { + for options in [&[("animation_effects", "off")][..], &[("layout", "two-up")][..]] { + let mut h = presenting(options); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert!(!h.state().views[0].presenting_a_transition(), "{options:?}"); + } +} + +/// In full screen the pointer hides after three still seconds, and shows again when it moves. +#[test] +fn full_screen_hides_a_still_pointer() { + let mut h = presenting(&[]); + let centre = h.state().views[0].viewport_rect().center(); + h.hover_at(centre); + h.run_steps(2); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "just moved"); + h.run_steps(32); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "still for over three seconds"); + h.hover_at(centre + egui::vec2(40.0, 0.0)); + h.run_steps(1); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "moved again"); +} From d51ac9a547866a99b92a1204784b56d0c7faa623 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:43:02 +0800 Subject: [PATCH 038/101] M3: in full screen a right-click and Shift+Enter go back a page (#5315) A left click goes forward a page in full screen; a right-click now goes back one, and full screen shows no context menu, as it shows no tools (Adobe Acrobat Help, 2019, "Full Screen preferences", p. 69: "Left Click To Go Forward One Page; Right Click To Go Back One Page"). Enter goes forward a page and Shift+Enter now goes back; it went forward too (Adobe Acrobat Help, 2019, "Read a document in Full Screen mode", p. 66: "To go to the previous page, press Shift+Enter"). parity: view.full-screen and view.full-screen-presentation cite the new tests and say what is still missing; organize.page-transitions is now played. docs/ui-parity.md row and revision history updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/canvas.rs | 19 ++++++++++-- crates/ui-egui/src/canvas/navigation.rs | 11 ++++--- crates/ui-egui/tests/it/view.rs | 41 +++++++++++++++++++++++++ docs/ui-parity.md | 5 +-- parity/acrobat-features.toml | 10 +++--- 5 files changed, 73 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 34b643bd0e..cd5fbbf8f7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2139,7 +2139,8 @@ fn draw_pages( if view.continuous { view.current = current; } - resp.context_menu(|ui| { + let presenting = view.presentation.is_some(); + page_context_menu(&resp, presenting, |ui| { // Preparing a form: the selected field's menu. if preparing && let Some((name, wi)) = view.prepare.selected.clone() { // Several fields: Align, Center, Distribute, Set Fields to Same Size. @@ -2860,8 +2861,22 @@ fn shown_row(view: &DocView, rects: &[Rect]) -> Vec { /// Full screen: a click on the document goes forward a page and a shift-click back, as in /// Acrobat, unless the click did something else (a link, a word selected). Decided once the pages /// have had the click. `Some(back)` on the frame of a click, `back` when it was a shift-click. +/// The pages' context menu, except in full screen, where a right-click turns back a page instead +/// ([`presentation_click_state`]). +fn page_context_menu(resp: &egui::Response, presenting: bool, add: impl FnOnce(&mut egui::Ui)) { + if !presenting { + resp.context_menu(add); + } +} + fn presentation_click_state(ui: &egui::Ui, view: &DocView, navigation_rect: Rect, enabled: bool) -> Option { - let clicked = view.presentation.is_some() && enabled && presentation_click(ui, navigation_rect); + let presenting = view.presentation.is_some() && enabled; + // A right-click goes back a page, as a left click goes forward (*Adobe Acrobat Help*, 2019, + // "Full Screen preferences", p. 69). + if presenting && ui.rect_contains_pointer(navigation_rect) && ui.input(|i| i.pointer.secondary_clicked()) { + return Some(true); + } + let clicked = presenting && presentation_click(ui, navigation_rect); // Shift as held now, or as the click's own event carried it (a synthetic click). clicked.then(|| { ui.input(|i| { diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index 731b2c2586..ae716c8d3c 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -397,13 +397,16 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s if key(Key::End) { view.go_to_page(usize::MAX); } - // Full screen: Space and Enter go forward a page and Backspace back, as in Acrobat (the - // arrows and Page Up / Page Down already turn pages where nothing scrolls, below). + // Full screen: Space and Enter go forward a page, and Shift+Enter and Backspace back + // (*Adobe Acrobat Help*, 2019, "Read a document in Full Screen mode", p. 66: "To go to the + // previous page, press Shift+Enter"). The arrows and Page Up / Page Down already turn pages + // where nothing scrolls, below. if view.presentation.is_some() { - if key(Key::Space) || key(Key::Enter) { + let shift = ctx.input(|i| i.modifiers.shift); + if key(Key::Space) || (key(Key::Enter) && !shift) { view.step_page(true); } - if key(Key::Backspace) { + if key(Key::Backspace) || (key(Key::Enter) && shift) { view.step_page(false); } } diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 9e96746841..97aaf41074 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2439,3 +2439,44 @@ fn full_screen_hides_a_still_pointer() { h.run_steps(1); assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "moved again"); } + +/// In full screen a right-click goes back a page, as a left click goes forward (*Adobe Acrobat +/// Help*, 2019, "Full Screen preferences", p. 69: "Left Click To Go Forward One Page; Right Click +/// To Go Back One Page"), and no context menu opens. +#[test] +fn full_screen_right_click_goes_back_a_page() { + // A document with no display times, so no page turns by itself meanwhile. + let mut h = harness(&[]); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + assert!(h.state().full_screen); + h.key_press(Key::ArrowRight); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 1); + let centre = h.state().views[0].viewport_rect().center(); + h.hover_at(centre); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: centre, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(6); + assert_eq!(h.state().views[0].current, 0, "a right-click goes back"); + assert!(!egui::Popup::is_any_open(&h.ctx), "no context menu in full screen"); +} + +/// In full screen Enter goes forward a page and Shift+Enter back (*Adobe Acrobat Help*, 2019, +/// "Read a document in Full Screen mode", p. 66: "To go to the previous page, press Shift+Enter"). +#[test] +fn full_screen_shift_enter_goes_back_a_page() { + // A document with no display times, so no page turns by itself meanwhile. + let mut h = harness(&[]); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + assert!(h.state().full_screen); + h.key_press(Key::Enter); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 1, "Enter goes forward"); + h.key_press_modifiers(Modifiers::SHIFT, Key::Enter); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 0, "Shift+Enter goes back"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 86eb0c66e7..b1b41c7212 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (Pages inspector thumbnail size slider and buttons #3808; full screen presents one page at a time #1459; the selected field nudges with the arrow keys in Prepare a form #2608) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-29 · **Last updated:** 2026-10-29 · **Change:** minor (full screen plays page transitions and display times, hides a still pointer, and a right-click or Shift+Enter goes back a page #379) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How PdfCraft's tools, panels, shortcuts and feel compare with Acrobat Pro's current interface (the 2023+ "new Acrobat": All tools pane on the left, quick-action toolbar, Home view). Behaviour is @@ -31,7 +31,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Document tabs | yes | yes, with unsaved marker | shipped | | Multiple windows, drag a tab out | yes | no | planned | | Split window, spreadsheet split | yes | no | planned | -| Read mode, full screen | yes | yes; full screen is one fitted page at a time on black, click/keys turn pages (#1459) | shipped; auto-advance, loop and transitions planned | +| Read mode, full screen | yes | yes; full screen is one fitted page at a time on black, click/keys turn pages (#1459), a right-click or Shift+Enter goes back, page transitions and display times play (#379), a still pointer hides | shipped; loop after the last page and the full-screen preferences planned | | Light, dark, system theme | yes | yes | shipped | | Side panels: thumbnails, bookmarks, attachments, layers, comments, fields, signatures | yes | yes; panels keep their width (#704); the Pages inspector has a 50–150% thumbnail size slider and buttons (#3808) | shipped | | Destinations, articles, model tree, Tags, Order, Content panels | yes | no | planned | @@ -82,6 +82,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Full screen plays page transitions (/Trans) and display times (/Dur), hides a still pointer, and a right-click or Shift+Enter goes back a page (#379) | | 2026-10-21 | minor | Pages inspector thumbnail sizing (#3808), separate from page and Organize zoom; view setting retained on reload and exposed through UI control. Not persisted between sessions. | | 2026-10-15 | minor | A selected field nudges with the arrow keys in Prepare a form: a point, Shift ten points, one undo step per press (#2608) | | 2026-10-14 | minor | Full screen shows one fitted page (or spread) at a time on black with click and key paging, as Acrobat's Full Screen Mode (#379, #1459, #1353) | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0b6f658dd6..f34fd12414 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1118,8 +1118,8 @@ milestone = "M3" status = "shipped" commands = ["view.full_screen"] tools = [] -evidence = ["crates/ui-egui/tests/it/commands.rs::full_screen_toggles_by_shortcut_and_command_and_escape_leaves", "crates/ui-egui/tests/it/view.rs::full_screen_shows_one_fitted_page_and_clicks_turn_it", "crates/ui-egui/tests/it/view.rs::full_screen_keeps_two_page_view_as_spreads"] -notes = "Acrobat's Full Screen Mode (#1459): the page on black and nothing else, one page at a time fitted to the screen (one spread when two-page view was on, #1353), from the current page; a click, Space, Enter, \u2192 or Page Down go forward, a shift-click, Backspace, \u2190 or Page Up back, Home/End to the ends; Esc or \u2318L bring the display from before back at the page reached. Links still open on click. Missing: the cursor is not hidden, no transitions, auto-advance or loop (view.full-screen-presentation), and no full-screen preferences such as the background colour" +evidence = ["crates/ui-egui/tests/it/commands.rs::full_screen_toggles_by_shortcut_and_command_and_escape_leaves", "crates/ui-egui/tests/it/view.rs::full_screen_shows_one_fitted_page_and_clicks_turn_it", "crates/ui-egui/tests/it/view.rs::full_screen_keeps_two_page_view_as_spreads", "crates/ui-egui/tests/it/view.rs::full_screen_right_click_goes_back_a_page", "crates/ui-egui/tests/it/view.rs::full_screen_shift_enter_goes_back_a_page", "crates/ui-egui/tests/it/view.rs::full_screen_hides_a_still_pointer"] +notes = "Acrobat's Full Screen Mode (#1459): the page on black and nothing else, one page at a time fitted to the screen (one spread when two-page view was on, #1353), from the current page; a click, Space, Enter, \u2192 or Page Down go forward, a shift-click, a right-click (no context menu in full screen), Shift+Enter, Backspace, \u2190 or Page Up back, Home/End to the ends; Esc or \u2318L bring the display from before back at the page reached. Links still open on click. The pointer hides after three still seconds. Transitions and display times: view.full-screen-presentation. Missing: loop after the last page, and full-screen preferences such as the background colour" [[feature]] id = "view.full-screen-presentation" @@ -1130,8 +1130,8 @@ milestone = "M12" status = "partial" commands = ["view.full_screen"] tools = [] -evidence = ["crates/ui-egui/tests/it/view.rs::full_screen_shows_one_fitted_page_and_clicks_turn_it"] -notes = "Click to advance (and shift-click back) ships with view.full-screen; auto-advance every N seconds, loop after the last page, page transitions and the full-screen preferences are not done" +evidence = ["crates/ui-egui/tests/it/view.rs::full_screen_shows_one_fitted_page_and_clicks_turn_it", "crates/ui-egui/tests/it/view.rs::full_screen_plays_a_page_transition_and_honours_the_display_time", "crates/ui-egui/tests/it/view.rs::full_screen_turns_at_once_without_animations_or_in_spreads", "crates/ui-egui/tests/it/view.rs::full_screen_hides_a_still_pointer"] +notes = "Click to advance (and shift-click back) ships with view.full-screen. A page's /Trans plays when it comes into view (ISO 32000-2 \u00a712.4.4, Table 164; single-page view, no view rotation; the outgoing page is held until the incoming one has rendered; the Animation effects preference turns them off), a page's /Dur advances to the next page once it is up (not past the last), and the pointer hides after three still seconds. Not done: full-screen preferences that override the document (advance every N seconds, loop after the last page, a default transition, ignoring the document's transitions, the pointer setting), and transitions between two-page spreads" [[feature]] id = "view.reflow" @@ -3232,7 +3232,7 @@ status = "partial" commands = [] tools = ["doc_info", "page_transition_set"] evidence = ["crates/render/src/inspect/tests.rs::page_transitions_and_display_times_are_read", "crates/automation/tests/it/automation.rs::doc_info_lists_page_transitions_and_display_times", "crates/organize/src/tests.rs::page_transitions_are_set_removed_and_checked", "crates/automation/tests/it/automation.rs::page_transitions_are_set_and_removed_through_the_tools"] -notes = "Read and set, not played: each page's /Trans (style, duration, dimension, motion, direction, Fly scale and backdrop) and /Dur display time are read with the spec's defaults and listed by doc_info (DocInfo::transitions); page_transition_set (Edit::SetTransition, one undo step) writes or removes them on chosen pages with the values checked first. Not yet: playing them in full screen, auto-advancing on /Dur, and the Page Transitions dialog" +notes = "Read, set and played: each page's /Trans (style, duration, dimension, motion, direction, Fly scale and backdrop) and /Dur display time are read with the spec's defaults and listed by doc_info (DocInfo::transitions); page_transition_set (Edit::SetTransition, one undo step) writes or removes them on chosen pages with the values checked first. Full screen plays them and advances on /Dur (view.full-screen-presentation). Not yet: the Page Transitions dialog" [[feature]] id = "organize.page-tab-order" From 689b5790c45a065c4b955c267bd45030baa3b362 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:40:11 +0800 Subject: [PATCH 039/101] M3: full screen review fixes: a new document starts afresh, a failed page ends its transition, no redraw at the last spread, the pointer hides over links - Replacing the document clears the presenter: no transition, page or timer of the old document carries over. - A transition into a page that failed to render ends at once instead of waiting for a texture that never comes. - A display time that expires on the last spread asks for a redraw only when the page actually moved, so the last spread doesn't redraw every frame. - A still pointer hides through a cursor claim on the top layer, which every tool reaches, so it hides over a link too, and its tip with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/canvas.rs | 12 +++-- crates/ui-egui/src/canvas/presenter.rs | 41 +++++++++----- crates/ui-egui/src/canvas/tests.rs | 74 ++++++++++++++++++++++++++ crates/ui-egui/tests/it/view.rs | 34 ++++++++++++ 4 files changed, 145 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index cd5fbbf8f7..412ba11a58 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -799,7 +799,8 @@ impl DocView { fresh.cover = self.cover; // Still presenting, with the display to go back to on leaving. fresh.presentation = self.presentation.take(); - fresh.presenter = std::mem::take(&mut self.presenter); + // A new document: none of the old one's transition, page or timer carries over. + fresh.presenter = Default::default(); fresh.back = std::mem::take(&mut self.back); fresh.forward = std::mem::take(&mut self.forward); fresh.find = self.find.take(); @@ -2091,12 +2092,17 @@ fn draw_pages( crate::calc_order_ui::paint_trace(ui, painter, visible, &view.calc_order, generation, selected); } // Full screen plays transitions over the page just drawn, and keeps time (#379). + let mut hide_pointer = false; if view.presentation.is_some() { let now = ui.input(|i| i.time); - presenter::frame(view, info, painter, ui.ctx(), now, animates, pointer); + hide_pointer = presenter::frame(view, info, painter, ui.ctx(), now, animates, pointer); + } + // A still pointer hides over a link too, and its tip with it: the top layer, which every tool reaches. + if hide_pointer { + view.cursor.claim(Layer::Drag, egui::CursorIcon::None); } // The pages' one cursor, from the claim on the highest layer the tool reaches, and the tip. - let hover_text = pointer.filter(|_| unobstructed).zip(view.cursor.resolve(ui.ctx(), tool)); + let hover_text = pointer.filter(|_| unobstructed && !hide_pointer).zip(view.cursor.resolve(ui.ctx(), tool)); // ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag // scrolls the page itself (#721). After the page loop, so it keeps scrolling once the // pointer leaves the selection's page for the gap or the next page; over the whole diff --git a/crates/ui-egui/src/canvas/presenter.rs b/crates/ui-egui/src/canvas/presenter.rs index 7738cf906c..dd43132731 100644 --- a/crates/ui-egui/src/canvas/presenter.rs +++ b/crates/ui-egui/src/canvas/presenter.rs @@ -7,7 +7,7 @@ //! preference set to off change pages at once. Display times count from the end of the //! transition and stop at the last page. -use egui::{Color32, CornerRadius, CursorIcon, Painter, Pos2, Rect, TextureHandle, pos2}; +use egui::{Color32, CornerRadius, Painter, Pos2, Rect, TextureHandle, pos2}; use pdfcraft_render::transition::{self, Effect, Piece}; use pdfcraft_render::{DocInfo, PageTransition}; @@ -97,9 +97,19 @@ fn paint(painter: &Painter, tex: &TextureHandle, page: Rect, pieces: &[Piece]) { } /// One frame of full screen for `view`, after its pages were drawn: start, paint or finish the -/// current page's transition, advance a page whose display time is up, and hide a still pointer. -/// `animates` is the Animation effects preference as it applies now. -pub(super) fn frame(view: &mut DocView, info: &DocInfo, painter: &Painter, ctx: &egui::Context, now: f64, animates: bool, pointer: Option) { +/// current page's transition, and advance a page whose display time is up. Returns whether the +/// pointer has been still long enough to hide: the caller hides it once the page has chosen its +/// cursor, which would otherwise show it again over a link. `animates` is the Animation effects +/// preference as it applies now. +pub(super) fn frame( + view: &mut DocView, + info: &DocInfo, + painter: &Painter, + ctx: &egui::Context, + now: f64, + animates: bool, + pointer: Option, +) -> bool { let page = view.current; let showing = view.presenter.shown.map(|(p, _)| p); let arriving = view.presenter.turn.as_ref().map(|t| t.to_page); @@ -116,7 +126,10 @@ pub(super) fn frame(view: &mut DocView, info: &DocInfo, painter: &Painter, ctx: } if let Some(turn) = view.presenter.turn.as_mut() { - let to_rect = view.screen_rects.iter().find(|(i, _)| *i == turn.to_page).map(|&(_, r)| r); + // A page the renderer can't draw ends the turn: waiting for it would hold the page before + // forever, and its display time would never start. + let failed = view.errors.contains_key(&turn.to_page); + let to_rect = view.screen_rects.iter().find(|(i, _)| *i == turn.to_page).map(|&(_, r)| r).filter(|_| !failed); let incoming = view.pages.get(&turn.to_page).map(|p| p.tex.clone()); match to_rect { None => { @@ -160,8 +173,12 @@ pub(super) fn frame(view: &mut DocView, info: &DocInfo, painter: &Painter, ctx: { let left = since + seconds.min(MAX_DISPLAY) - now; if left <= 0.0 { + // In two-page view the last spread can't advance: then there is nothing to redraw. + let before = view.current; view.step_page(true); - ctx.request_repaint(); + if view.current != before { + ctx.request_repaint(); + } } else { ctx.request_repaint_after(std::time::Duration::from_secs_f64(left)); } @@ -173,14 +190,12 @@ pub(super) fn frame(view: &mut DocView, info: &DocInfo, painter: &Painter, ctx: Some(p) if view.presenter.pointer.map(|(q, _)| q) != Some(p) => view.presenter.pointer = Some((p, now)), Some(_) => {} } - if let Some((_, since)) = view.presenter.pointer { - let left = since + HIDE_POINTER_AFTER - now; - if left <= 0.0 { - ctx.set_cursor_icon(CursorIcon::None); - } else { - ctx.request_repaint_after(std::time::Duration::from_secs_f64(left)); - } + let Some((_, since)) = view.presenter.pointer else { return false }; + let left = since + HIDE_POINTER_AFTER - now; + if left > 0.0 { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(left)); } + left <= 0.0 } #[cfg(test)] diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 634a885111..b2e5974f08 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2002,3 +2002,77 @@ fn a_moved_picture_is_drawn_from_the_fresh_raster_while_a_tile_is_still_stale() h.state_mut().views[0].stale_tiles.clear(); h.state_mut().views[0].tiles.clear(); } + +/// Full screen's presenter for one frame of `ctx`, at time `now`: whether the frame asked to be +/// drawn again at once. +fn presenter_frame(ctx: &egui::Context, v: &mut DocView, info: &DocInfo, now: f64) -> bool { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::background()); + presenter::frame(v, info, &painter, ui.ctx(), now, true, None); + }); + // No renderer here to upload textures: drop the frame's texture changes. + out.textures_delta.clear(); + out.viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|o| o.repaint_delay.is_zero()) +} + +/// A transition into a page the renderer could not draw ends at once: the page before is not held +/// on screen waiting for it, and the page's own time can start. +#[test] +fn a_transition_into_a_page_that_failed_to_render_ends_at_once() { + use pdfcraft_render::{PageTransition, TransitionStyle}; + let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }; + let wipe = PageTransition { + page: 1, + style: Some(TransitionStyle::Wipe), + duration: 1.0, + horizontal: false, + inward: false, + direction: Some(0), + scale: 1.0, + opaque: false, + display: None, + }; + let info = DocInfo { pages: vec![page.clone(), page], transitions: vec![wipe], ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::Single, continuous: false, ..Default::default() }); + v.enter_presentation(); + let ctx = egui::Context::default(); + let tex = ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default()); + v.pages.insert(0, super::cache::PageTex { tag: 0, tex }); + presenter_frame(&ctx, &mut v, &info, 0.0); + v.current = 1; + v.screen_rects = vec![(1, Rect::from_min_size(Pos2::ZERO, egui::vec2(300.0, 400.0)))]; + v.errors.insert(1, "the renderer failed".into()); + presenter_frame(&ctx, &mut v, &info, 0.1); + assert!(!v.presenting_a_transition(), "no wait for a page that won't render"); +} + +/// A display time on the first page of the last two-page spread can't advance (there is no spread +/// after it): the presenter then stays on it without asking for a frame after frame. +#[test] +fn a_display_time_on_the_last_spread_does_not_redraw_every_frame() { + use pdfcraft_render::PageTransition; + let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }; + let timed = PageTransition { + page: 2, + style: None, + duration: 1.0, + horizontal: false, + inward: false, + direction: Some(0), + scale: 1.0, + opaque: false, + display: Some(1.0), + }; + let info = DocInfo { pages: vec![page.clone(), page.clone(), page.clone(), page], transitions: vec![timed], ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::TwoUp, continuous: false, ..Default::default() }); + v.enter_presentation(); + v.current = 2; + let ctx = egui::Context::default(); + // egui asks for its first frames itself: past them, only the presenter asks. + for now in [0.0, 0.5, 0.6] { + presenter_frame(&ctx, &mut v, &info, now); + } + let again = presenter_frame(&ctx, &mut v, &info, 5.0); + assert_eq!(v.current, 2, "the last spread stays"); + assert!(!again, "and asks for no frame after frame"); +} diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 97aaf41074..815ce284d3 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2480,3 +2480,37 @@ fn full_screen_shift_enter_goes_back_a_page() { h.run_steps(4); assert_eq!(h.state().views[0].current, 0, "Shift+Enter goes back"); } + +/// A document replaced while full screen plays a transition (a reload) starts afresh: none of the +/// old document's transition, page or timer carries over. +#[test] +fn full_screen_starts_afresh_when_the_document_is_replaced() { + let mut h = presenting(&[]); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert!(h.state().views[0].presenting_a_transition(), "page 2 comes in with its Wipe"); + { + let app = h.state_mut(); + let info = app.session.get(app.views[0].id).unwrap().info.clone(); + app.views[0].document_replaced(&info); + } + assert!(!h.state().views[0].presenting_a_transition(), "the old transition is dropped"); +} + +/// A still pointer hides over a link too: the page's own cursor (the hand over a link) is chosen +/// first, and full screen hides the pointer after it. +#[test] +fn full_screen_hides_a_still_pointer_over_a_link() { + let mut h = harness(&[]); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + assert!(h.state().full_screen); + // Page 1's link covers x 50–250, y 300–350 of the 300×400 pt page (from its bottom). + let r = rect(&h, 0).expect("page 1 on screen"); + let on_link = egui::pos2(r.left() + r.width() * 150.0 / 300.0, r.top() + r.height() * 75.0 / 400.0); + h.hover_at(on_link); + h.run_steps(1); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "just moved, over the link"); + h.run_steps(16); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "still for over three seconds"); +} From 94d3e628b0ce07f6239e59a9409d760963b21ffc Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:02:45 +0800 Subject: [PATCH 040/101] M3: full screen clicks on fields and notes, Shift+Ctrl first and last page, no redraw while a page renders - A click on a link, form field or comment turns no page in full screen: the pointer stays active to click links and open notes (Adobe Acrobat Help, 2019, p. 65). - Shift+Ctrl+Page Up / Up Arrow go to the first page and Shift+Ctrl+Page Down / Down Arrow to the last, as Home and End (p. 83). - A transition waiting for its incoming page looks again every 50 ms rather than asking for every frame; the render cache wakes the window. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/canvas.rs | 20 ++++-- crates/ui-egui/src/canvas/navigation.rs | 15 ++-- crates/ui-egui/src/canvas/page_cursor.rs | 10 +++ crates/ui-egui/src/canvas/presenter.rs | 6 +- crates/ui-egui/src/canvas/tests.rs | 35 ++++++++++ crates/ui-egui/tests/it/view.rs | 87 ++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 412ba11a58..90464d90d6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -911,6 +911,9 @@ struct PageOut { open_signature: bool, open_table: Option<(usize, [f64; 4])>, primary_down: bool, + /// The pointer is over a link, comment, field or what is placed on the page: a click there is + /// for it, and turns no page in full screen. + on_object: bool, refused: Option, stamp_placed: bool, } @@ -2082,6 +2085,7 @@ fn draw_pages( open_signature, open_table, primary_down: _, + on_object: _, refused, stamp_placed, } = page_out; @@ -2097,6 +2101,8 @@ fn draw_pages( let now = ui.input(|i| i.time); hide_pointer = presenter::frame(view, info, painter, ui.ctx(), now, animates, pointer); } + // Read before the claims are spent below, and before the pointer hides, which claims the top layer. + let on_object = view.cursor.over_object(tool); // A still pointer hides over a link too, and its tip with it: the top layer, which every tool reaches. if hide_pointer { view.cursor.claim(Layer::Drag, egui::CursorIcon::None); @@ -2269,6 +2275,7 @@ fn draw_pages( open_signature, open_table, primary_down: ptr.primary_down, + on_object, refused, stamp_placed, }; @@ -2770,12 +2777,14 @@ fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, prim /// Full screen: the click turns the page unless it did something else first. It waits out the /// double-click time: turning on the first click's release sent a double-click's second click, /// meant to select a word, to the next page (review on #2070). A selection cancels it, and so do a -/// link followed meanwhile and leaving full screen; a double-click elsewhere still turns once. +/// click on a link, field or comment (`clicked_object`) and leaving full screen: the pointer stays +/// active "so that you can click links and open notes" (*Adobe Acrobat Help*, 2019, p. 65), and +/// fields to fill. A double-click elsewhere still turns once. /// `click` is `Some(back)` on the frame of a click, `back` when it was a shift-click. -fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option, link_followed: bool) { +fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option, clicked_object: bool) { let now = ui.input(|i| i.time); if let Some(back) = click - && !link_followed + && !clicked_object && view.selection.is_none() && view.clicks < 2 { @@ -2783,7 +2792,7 @@ fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option= due { view.present_turn = None; @@ -3281,6 +3290,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { open_signature, open_table, primary_down, + on_object, mut refused, stamp_placed, } = page_out; @@ -3354,7 +3364,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { Some(comments::CanvasAction::CopyImageFile(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::CopyFile(p, i)), None => {} } - schedule_presentation_turn(ui, view, present, clicked_link.is_some()); + schedule_presentation_turn(ui, view, present, clicked_link.is_some() || on_object && present.is_some()); let remote_open = follow_link(app, index, clicked_link); if tool == QuickTool::Crop && cropped { tool = QuickTool::Select; diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index ae716c8d3c..24f358bc6b 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -391,18 +391,26 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s view.find = None; } } - if key(Key::Home) { + // First and last page: Home and End, or Shift+Ctrl+Page Up / Up Arrow and Shift+Ctrl+Page Down / + // Down Arrow (*Adobe Acrobat Help*, 2019, "Keys for navigating a PDF", p. 83). These take the + // key from the page turning and scrolling below. + let (command, shift) = ctx.input(|i| (i.modifiers.command, i.modifiers.shift)); + let to_end = command && shift && (key(Key::PageDown) || key(Key::ArrowDown)); + let to_start = command && shift && (key(Key::PageUp) || key(Key::ArrowUp)); + if key(Key::Home) || to_start { view.go_to_page(0); } - if key(Key::End) { + if key(Key::End) || to_end { view.go_to_page(usize::MAX); } + if to_end || to_start { + return; + } // Full screen: Space and Enter go forward a page, and Shift+Enter and Backspace back // (*Adobe Acrobat Help*, 2019, "Read a document in Full Screen mode", p. 66: "To go to the // previous page, press Shift+Enter"). The arrows and Page Up / Page Down already turn pages // where nothing scrolls, below. if view.presentation.is_some() { - let shift = ctx.input(|i| i.modifiers.shift); if key(Key::Space) || (key(Key::Enter) && !shift) { view.step_page(true); } @@ -412,7 +420,6 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s } // As in Acrobat: → / ← go to the next / previous page in every layout (#185), and so do // ⌘Page Down / ⌘Page Up, or plain Page Down / Page Up in single-page view. - let command = ctx.input(|i| i.modifiers.command); let scrolls = view.continuous; if key(Key::ArrowRight) || (key(Key::PageDown) && (command || !scrolls)) { view.step_page(true); diff --git a/crates/ui-egui/src/canvas/page_cursor.rs b/crates/ui-egui/src/canvas/page_cursor.rs index e40dc1c4f3..b820f74d77 100644 --- a/crates/ui-egui/src/canvas/page_cursor.rs +++ b/crates/ui-egui/src/canvas/page_cursor.rs @@ -109,6 +109,16 @@ impl PageCursor { } } + /// Whether the pointer is over something a press with `tool` acts on in its own right: a + /// link, a comment, a field, or what is placed on the page and its grips and editors. Not the + /// page and its text, nor a drag, whose layer also hides the pointer in full screen. + pub(crate) fn over_object(&self, tool: QuickTool) -> bool { + LAYERS + .into_iter() + .zip(&self.claims) + .any(|(layer, claim)| claim.is_some() && (Layer::Link..Layer::Drag).contains(&layer) && layer.reached_by(tool)) + } + /// The claim on the highest layer `tool` reaches. fn winner(claims: [Option; LAYERS.len()], tool: QuickTool) -> Option { LAYERS.into_iter().zip(claims).rev().find_map(|(layer, claim)| claim.filter(|_| layer.reached_by(tool))) diff --git a/crates/ui-egui/src/canvas/presenter.rs b/crates/ui-egui/src/canvas/presenter.rs index dd43132731..3ecd16e882 100644 --- a/crates/ui-egui/src/canvas/presenter.rs +++ b/crates/ui-egui/src/canvas/presenter.rs @@ -158,8 +158,12 @@ pub(super) fn frame( paint(painter, &turn.from, from_rect, &f.outgoing); if let Some(tex) = &incoming { paint(painter, tex, to_rect, &f.incoming); + ctx.request_repaint(); + } else { + // Nothing moves until the page arrives, and the render cache wakes the window + // while pages are queued: look again now and then rather than every frame. + ctx.request_repaint_after(std::time::Duration::from_millis(50)); } - ctx.request_repaint(); } } } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index b2e5974f08..0610439118 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2046,6 +2046,41 @@ fn a_transition_into_a_page_that_failed_to_render_ends_at_once() { assert!(!v.presenting_a_transition(), "no wait for a page that won't render"); } +/// While the incoming page is still rendering, the presenter holds the page before without asking +/// for a frame after frame: the render cache wakes the window while pages are queued. +#[test] +fn a_transition_waiting_for_its_page_does_not_redraw_every_frame() { + use pdfcraft_render::{PageTransition, TransitionStyle}; + let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }; + let wipe = PageTransition { + page: 1, + style: Some(TransitionStyle::Wipe), + duration: 1.0, + horizontal: false, + inward: false, + direction: Some(0), + scale: 1.0, + opaque: false, + display: None, + }; + let info = DocInfo { pages: vec![page.clone(), page], transitions: vec![wipe], ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::Single, continuous: false, ..Default::default() }); + v.enter_presentation(); + let ctx = egui::Context::default(); + let tex = ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default()); + v.pages.insert(0, super::cache::PageTex { tag: 0, tex }); + presenter_frame(&ctx, &mut v, &info, 0.0); + v.current = 1; + v.screen_rects = vec![(1, Rect::from_min_size(Pos2::ZERO, egui::vec2(300.0, 400.0)))]; + // egui asks for its first frames itself: past them, only the presenter asks. + for now in [0.1, 0.2, 0.3] { + presenter_frame(&ctx, &mut v, &info, now); + } + let again = presenter_frame(&ctx, &mut v, &info, 0.4); + assert!(v.presenting_a_transition(), "still waiting for page 2"); + assert!(!again, "without asking for a frame after frame"); +} + /// A display time on the first page of the last two-page spread can't advance (there is no spread /// after it): the presenter then stays on it without asking for a frame after frame. #[test] diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 815ce284d3..5f6ed64783 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2514,3 +2514,90 @@ fn full_screen_hides_a_still_pointer_over_a_link() { h.run_steps(16); assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "still for over three seconds"); } + +/// A text field on page 1 of three. +const FIELD_PAGES: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [6 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R >> endobj +5 0 obj << /Type /Page /Parent 2 0 R >> endobj +6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [50 100 250 140] /P 3 0 R /F 4 >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// Full screen keeps the pointer active "so that you can click links and open notes" (*Adobe +/// Acrobat Help*, 2019, p. 65): a click on a form field opens it for typing rather than turning +/// the page, and a click beside it still turns. +#[test] +fn full_screen_click_on_a_field_fills_it_and_turns_no_page() { + let mut h = harness(&[]); + h.state_mut().open_bytes("field.pdf", None, FIELD_PAGES.to_vec()).expect("opens"); + h.run_steps(8); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + let n = h.state().views.len() - 1; + let r = h.state().views[n].page_screen_rect(0).expect("page 1 on screen"); + let on_field = egui::pos2(r.left() + r.width() * 0.5, r.top() + r.height() * (400.0 - 120.0) / 400.0); + click_with(&mut h, on_field, Modifiers::NONE); + h.run_steps(8); + assert_eq!(h.state().views[n].current, 0, "no page turned"); + assert!(h.ctx.egui_wants_keyboard_input(), "the field is open for typing"); + click_with(&mut h, egui::pos2(r.center().x, r.top() + 20.0), Modifiers::NONE); + h.run_steps(4); + assert_eq!(h.state().views[n].current, 1, "a click beside the field turns the page"); +} + +/// Shift+Ctrl+Page Down or Down Arrow goes to the last page, and Shift+Ctrl+Page Up or Up Arrow +/// to the first, as Home and End do (*Adobe Acrobat Help*, 2019, "Keys for navigating a PDF", +/// p. 83), in full screen and out of it. +#[test] +fn shift_ctrl_page_keys_and_arrows_go_to_the_first_and_last_page() { + for full_screen in [false, true] { + let mut h = harness(&[("layout", "single")]); + if full_screen { + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + } + let both = Modifiers::COMMAND | Modifiers::SHIFT; + for (k, want) in [(Key::PageDown, 2), (Key::PageUp, 0), (Key::ArrowDown, 2), (Key::ArrowUp, 0)] { + h.key_press_modifiers(both, k); + h.run_steps(6); + assert_eq!(h.state().views[0].current, want, "{k:?}, full screen {full_screen}"); + } + } +} + +/// In full screen a double-click on a note opens it to edit, and turns no page: the pointer stays +/// active "so that you can click links and open notes" (*Adobe Acrobat Help*, 2019, p. 65). +#[test] +fn full_screen_double_click_on_a_note_opens_it_and_turns_no_page() { + const NOTE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [100 100 124 124] /Contents (Hello) /T (A) /F 4 >> endobj +5 0 obj << /Type /Page /Parent 2 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut h = harness_stepping(1.0 / 60.0, &[]); + h.state_mut().open_bytes("note.pdf", None, NOTE.to_vec()).expect("opens"); + h.run_steps(8); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + let n = h.state().views.len() - 1; + let r = h.state().views[n].page_screen_rect(0).expect("page 1 on screen"); + let p = egui::pos2(r.left() + r.width() * 112.0 / 300.0, r.top() + r.height() * (400.0 - 112.0) / 400.0); + h.hover_at(p); + h.run_steps(2); + for _ in 0..2 { + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + } + h.step(); + } + // Past the double-click time, when a turn would have come. + h.run_steps(30); + assert_eq!(h.state().views[n].current, 0, "no page turned"); + assert!(h.ctx.egui_wants_keyboard_input(), "the note is open to edit"); +} From 2d21753b1ed5e7a5a73ab3b9fb365859365f3e06 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:58:59 +0800 Subject: [PATCH 041/101] M3: a page's display time starts once it has rendered; the Wipe test checks what is painted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on #5316: - Full screen started a page's /Dur (ISO 32000-2 §12.4.4.1) when the page became current, so a slow render could use up its display time while the page still said Rendering…. The time now starts once the page's raster has arrived, or at once for a page the renderer can't draw. Test: a_display_time_starts_once_the_page_has_rendered, which fails with the fix reverted. a_display_time_on_the_last_spread_does_not_redraw_every_frame now gives its page a raster, so it still reaches the display time. - full_screen_plays_a_page_transition_and_honours_the_display_time also checks the Wipe's painted output: a textured strip of page 2 as tall as the page and only part of its width. It fails when the presenter stops painting the incoming page. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/canvas/presenter.rs | 5 +++- crates/ui-egui/src/canvas/tests.rs | 36 ++++++++++++++++++++++++++ crates/ui-egui/tests/it/view.rs | 25 +++++++++++++++++- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas/presenter.rs b/crates/ui-egui/src/canvas/presenter.rs index 3ecd16e882..8b98a8b4f6 100644 --- a/crates/ui-egui/src/canvas/presenter.rs +++ b/crates/ui-egui/src/canvas/presenter.rs @@ -121,7 +121,10 @@ pub(super) fn frame( let from = view.pages.get(&from_page)?.tex.clone(); Some(Turn { from_page, from, to_page: page, effect, seconds, started: None }) }); - view.presenter.shown = if turn.is_some() { None } else { Some((page, now)) }; + // Its display time starts once the page is on screen, not while it still says Rendering… + // (or at once for a page the renderer can't draw, which never will be). + let ready = view.pages.contains_key(&page) || view.errors.contains_key(&page); + view.presenter.shown = if turn.is_some() || !ready { None } else { Some((page, now)) }; view.presenter.turn = turn; } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 0610439118..3efa809d74 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2103,6 +2103,8 @@ fn a_display_time_on_the_last_spread_does_not_redraw_every_frame() { v.enter_presentation(); v.current = 2; let ctx = egui::Context::default(); + let tex = ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default()); + v.pages.insert(2, super::cache::PageTex { tag: 0, tex }); // egui asks for its first frames itself: past them, only the presenter asks. for now in [0.0, 0.5, 0.6] { presenter_frame(&ctx, &mut v, &info, now); @@ -2111,3 +2113,37 @@ fn a_display_time_on_the_last_spread_does_not_redraw_every_frame() { assert_eq!(v.current, 2, "the last spread stays"); assert!(!again, "and asks for no frame after frame"); } + +/// A page's display time (ISO 32000-2 §12.4.4.1, `/Dur`) counts from when the page is on screen: +/// a slow render that still shows Rendering… does not use it up. +#[test] +fn a_display_time_starts_once_the_page_has_rendered() { + use pdfcraft_render::PageTransition; + let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }; + let timed = PageTransition { + page: 0, + style: None, + duration: 1.0, + horizontal: false, + inward: false, + direction: Some(0), + scale: 1.0, + opaque: false, + display: Some(1.0), + }; + let info = DocInfo { pages: vec![page.clone(), page], transitions: vec![timed], ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::Single, continuous: false, ..Default::default() }); + v.enter_presentation(); + let ctx = egui::Context::default(); + for now in [0.0, 1.0, 2.0, 5.0] { + presenter_frame(&ctx, &mut v, &info, now); + } + assert_eq!(v.current, 0, "page 1 is still rendering: its second has not started"); + let tex = ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default()); + v.pages.insert(0, super::cache::PageTex { tag: 0, tex }); + presenter_frame(&ctx, &mut v, &info, 5.5); + presenter_frame(&ctx, &mut v, &info, 6.0); + assert_eq!(v.current, 0, "half of its second on screen"); + presenter_frame(&ctx, &mut v, &info, 6.6); + assert_eq!(v.current, 1, "its second on screen is up"); +} diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 5f6ed64783..e4fdad63d3 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2398,7 +2398,30 @@ fn full_screen_plays_a_page_transition_and_honours_the_display_time() { h.run_steps(1); assert_eq!(h.state().views[0].current, 1); assert!(h.state().views[0].presenting_a_transition(), "page 2 comes in with its Wipe"); - let frames = frames_until(&mut h, |a| !a.views[0].presenting_a_transition()); + // The Wipe is painted, not only timed: once page 2 has rendered, a frame shows a strip of it as + // tall as the page and only part of its width. + let wipe_strip = |h: &Harness<'static, PdfCraftApp>| { + h.state().views[0].page_screen_rect(1).is_some_and(|page| { + h.output().shapes.iter().any(|clipped| { + let egui::Shape::Mesh(mesh) = &clipped.shape else { return false }; + let b = mesh.calc_bounds(); + mesh.texture_id != egui::TextureId::default() + && (b.min.x - page.min.x).abs() < 1.0 + && (b.min.y - page.min.y).abs() < 1.0 + && (b.max.y - page.max.y).abs() < 1.0 + && b.width() > 1.0 + && b.width() < page.width() - 1.0 + }) + }) + }; + let mut steps = 0; + while !wipe_strip(&h) && steps < 300 { + h.run_steps(1); + steps += 1; + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(wipe_strip(&h), "the Wipe paints part of page 2 ({steps} frames)"); + let frames = steps + frames_until(&mut h, |a| !a.views[0].presenting_a_transition()); assert!(frames >= 9, "the Wipe takes its second ({frames} frames of 0.1 s)"); assert_eq!(h.state().views[0].current, 1, "still page 2 when the transition ends"); h.run_steps(5); From 03a147ecedfa6e0df1f079969c16223e957a2595 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:04:02 +0800 Subject: [PATCH 042/101] M10: Windows without the print DLLs still starts (#2630) pdfcraft.exe and pdfcraft-cli.exe imported windows.data.pdf.dll and prntvpt.dll at load time (through craft-print's printing), so a trimmed Windows installation without them refused to start the program at all. The executables now delay-load the two DLLs, and the spool adapters check that they exist before calling in: without them the printer list is empty and a print job fails with a message pointing to Save as PDF. Co-Authored-By: Claude Opus 5.5 --- apps/pdfcraft-cli/build.rs | 13 +++++++++++ apps/pdfcraft/build.rs | 13 +++++++++++ crates/print/src/spool.rs | 45 +++++++++++++++++++++++++++++++++++++- crates/print/src/tests.rs | 20 +++++++++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) diff --git a/apps/pdfcraft-cli/build.rs b/apps/pdfcraft-cli/build.rs index ee77f13f6a..1e8e69258a 100644 --- a/apps/pdfcraft-cli/build.rs +++ b/apps/pdfcraft-cli/build.rs @@ -6,12 +6,25 @@ //! cross-compile from macOS or Linux still links, unless `PDFCRAFT_REQUIRE_WINRES=1` turns it //! into an error (for release builds). +/// The in-box DLLs only printing uses (Windows.Data.Pdf draws the job, prntvpt reads the +/// driver's print capabilities). Trimmed Windows installations (NTLite) can lack them, and a +/// load-time import of a missing DLL stops the program before `main` (#2630). Delay-loaded, they +/// load on the first print; `pdfcraft_print::spool` checks they exist before calling in. +/// Same list in `apps/pdfcraft{,-cli}/build.rs` and `crates/print/src/spool.rs`. +const DELAY_LOADED: [&str; 2] = ["windows.data.pdf.dll", "prntvpt.dll"]; + fn main() { println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-env-changed=PDFCRAFT_REQUIRE_WINRES"); if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { return; } + if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") { + for dll in DELAY_LOADED { + println!("cargo:rustc-link-arg-bins=/DELAYLOAD:{dll}"); + } + println!("cargo:rustc-link-arg-bins=delayimp.lib"); + } let mut res = winresource::WindowsResource::new(); res.set("ProductName", "PdfCraft") .set("FileDescription", "PdfCraft command-line tool") diff --git a/apps/pdfcraft/build.rs b/apps/pdfcraft/build.rs index 35d519d99e..ce2981ac20 100644 --- a/apps/pdfcraft/build.rs +++ b/apps/pdfcraft/build.rs @@ -9,6 +9,13 @@ #[path = "src/windows_manifest.rs"] mod windows_manifest; +/// The in-box DLLs only printing uses (Windows.Data.Pdf draws the job, prntvpt reads the +/// driver's print capabilities). Trimmed Windows installations (NTLite) can lack them, and a +/// load-time import of a missing DLL stops the program before `main` (#2630). Delay-loaded, they +/// load on the first print; `pdfcraft_print::spool` checks they exist before calling in. +/// Same list in `apps/pdfcraft{,-cli}/build.rs` and `crates/print/src/spool.rs`. +const DELAY_LOADED: [&str; 2] = ["windows.data.pdf.dll", "prntvpt.dll"]; + fn main() { println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=../../assets/app-icon/pdfcraft.ico"); @@ -18,6 +25,12 @@ fn main() { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { return; } + if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") { + for dll in DELAY_LOADED { + println!("cargo:rustc-link-arg-bins=/DELAYLOAD:{dll}"); + } + println!("cargo:rustc-link-arg-bins=delayimp.lib"); + } let mut res = winresource::WindowsResource::new(); res.set_icon("../../assets/app-icon/pdfcraft.ico") .set_icon_with_id("../../assets/app-icon/pdfcraft-document.ico", "2") diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index 5ce3d9736b..3771149551 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -3,9 +3,46 @@ pub use craft_print::{ Duplex, HANDLED_OPTIONS, HAS_PRINTER_PREFERENCES, Job, Printer, PrinterOption, Scaling, lp_args, lpstat_command, parse_lpoptions, parse_lpstat, - parse_lpstat_e, parse_ppd, parse_windows_printers, ppd_text, printers, printers_parsed, + parse_lpstat_e, parse_ppd, parse_windows_printers, ppd_text, printers_parsed, }; +/// Windows: the in-box DLLs printing draws and describes jobs with. The executables delay-load +/// them (`apps/pdfcraft{,-cli}/build.rs`, the same list) so a Windows installation without them +/// still starts (#2630); a call into a missing delay-loaded DLL would end the process, so +/// printing checks for them first. +#[cfg(windows)] +pub(crate) const PRINT_DLLS: [&str; 2] = ["windows.data.pdf.dll", "prntvpt.dll"]; + +/// The first print DLL missing from `system32`, if any. +#[cfg(windows)] +pub(crate) fn missing_print_dll_in(system32: &std::path::Path) -> Option<&'static str> { + PRINT_DLLS.into_iter().find(|dll| !system32.join(dll).is_file()) +} + +/// The first print DLL this Windows installation lacks. `None` when all are there, and when the +/// system folder is unknown (the loader then decides, as before). +fn missing_print_dll() -> Option<&'static str> { + #[cfg(windows)] + { + let root = std::env::var_os("SystemRoot").or_else(|| std::env::var_os("windir"))?; + missing_print_dll_in(&std::path::Path::new(&root).join("System32")) + } + #[cfg(not(windows))] + None +} + +fn unavailable(dll: &str) -> crate::PrintError { + crate::PrintError::Spool(format!("printing needs {dll}, which this Windows installation lacks; use Save as PDF instead")) +} + +/// The system's printers; none on a Windows installation without the print DLLs. +pub fn printers() -> Vec { + if missing_print_dll().is_some() { + return Vec::new(); + } + craft_print::printers() +} + /// The driver's job options for a printer (CUPS: its PPD, with the queue's current defaults); /// empty when it has none or on other platforms. Reads files and runs `lpoptions`, so call it /// when the user asks, not every frame. @@ -144,12 +181,18 @@ pub(crate) fn printer_options_via_within( } pub fn submit(pdf: &[u8], job: &Job) -> Result { + if let Some(dll) = missing_print_dll() { + return Err(unavailable(dll)); + } craft_print::submit(pdf, 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 { + if let Some(dll) = missing_print_dll() { + return Err(unavailable(dll)); + } craft_print::submit_with_progress(pdf, job, progress).map_err(|e| crate::PrintError::Spool(e.to_string())) } diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 885756e8c6..a6c0585aeb 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -1144,3 +1144,23 @@ fn contents_pieces_join_with_a_space_across_a_trailing_comment() { let printed_ctrl = impose(&with_newline, &settings(vec![0], Layout::Size(SizeMode::Actual))).unwrap(); assert_eq!(form_strings(printed_ctrl), [b"HIDDEN".to_vec(), b"SHOWN".to_vec()]); } + +/// #2630: a Windows installation without the print DLLs is told so, before anything calls into +/// a delay-loaded DLL that is not there. +#[cfg(windows)] +#[test] +fn missing_print_dlls_are_named() { + use crate::spool::{PRINT_DLLS, missing_print_dll_in}; + let dir = std::env::temp_dir().join(format!("pdfcraft-print-dlls-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + assert_eq!(missing_print_dll_in(&dir), Some(PRINT_DLLS[0])); + std::fs::write(dir.join(PRINT_DLLS[0]), b"").unwrap(); + assert_eq!(missing_print_dll_in(&dir), Some(PRINT_DLLS[1])); + std::fs::write(dir.join(PRINT_DLLS[1]), b"").unwrap(); + assert_eq!(missing_print_dll_in(&dir), None); + let _ = std::fs::remove_dir_all(&dir); + // This machine's own Windows has them. + let system32 = std::path::Path::new(&std::env::var_os("SystemRoot").unwrap()).join("System32"); + assert_eq!(missing_print_dll_in(&system32), None); +} From f1753c1431b5c7a6574bf9b07d4e9f83cdc5f605 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:04:02 +0800 Subject: [PATCH 043/101] M10: test the print entry points against a Windows without the DLLs (#2630) Review follow-up on #2701: the test no longer requires the DLLs in the host's System32 (the trimmed installations this supports lack them). Instead a test-only override points the lookup at an empty folder and submit, submit_with_progress and printers are called through: printing is refused naming the DLL and no printers are listed, before any call into the delay-loaded DLLs. The job names a printer that doesn't exist and the bytes aren't a PDF, so a missing guard can't reach a real one. Co-Authored-By: Claude Opus 5.5 --- crates/print/src/spool.rs | 11 +++++++++++ crates/print/src/tests.rs | 29 ++++++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index 3771149551..a940822087 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -19,11 +19,22 @@ pub(crate) fn missing_print_dll_in(system32: &std::path::Path) -> Option<&'stati PRINT_DLLS.into_iter().find(|dll| !system32.join(dll).is_file()) } +#[cfg(all(windows, test))] +thread_local! { + /// Tests: the folder the print DLLs are looked for in, instead of the system's, so the + /// public entry points can be checked against a Windows that lacks them. + pub(crate) static SYSTEM32_FOR_TEST: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + /// The first print DLL this Windows installation lacks. `None` when all are there, and when the /// system folder is unknown (the loader then decides, as before). fn missing_print_dll() -> Option<&'static str> { #[cfg(windows)] { + #[cfg(test)] + if let Some(dir) = SYSTEM32_FOR_TEST.with(|d| d.borrow().clone()) { + return missing_print_dll_in(&dir); + } let root = std::env::var_os("SystemRoot").or_else(|| std::env::var_os("windir"))?; missing_print_dll_in(&std::path::Path::new(&root).join("System32")) } diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index a6c0585aeb..aa6e184e50 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -1160,7 +1160,30 @@ fn missing_print_dlls_are_named() { std::fs::write(dir.join(PRINT_DLLS[1]), b"").unwrap(); assert_eq!(missing_print_dll_in(&dir), None); let _ = std::fs::remove_dir_all(&dir); - // This machine's own Windows has them. - let system32 = std::path::Path::new(&std::env::var_os("SystemRoot").unwrap()).join("System32"); - assert_eq!(missing_print_dll_in(&system32), None); +} + +/// #2630: on a Windows without the print DLLs, the public entry points answer before any call +/// into the delay-loaded DLLs: no printers, and printing refused naming the DLL. The job names a +/// printer that doesn't exist and the bytes aren't a PDF, so a missing guard fails here with +/// another error rather than reaching a real printer. +#[cfg(windows)] +#[test] +fn printing_without_the_print_dlls_is_refused_before_the_spooler() { + use crate::spool::{PRINT_DLLS, SYSTEM32_FOR_TEST, printers, submit, submit_with_progress}; + let dir = std::env::temp_dir().join(format!("pdfcraft-print-no-dlls-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + SYSTEM32_FOR_TEST.with(|d| *d.borrow_mut() = Some(dir.clone())); + let job = crate::spool::Job { printer: Some("PdfCraft test: no such printer".into()), ..Default::default() }; + let named = |r: Result| match r { + Err(crate::PrintError::Spool(m)) => m.contains(PRINT_DLLS[0]), + _ => false, + }; + assert!(named(submit(b"not a PDF", &job)), "submit names the missing DLL"); + let mut called = false; + assert!(named(submit_with_progress(b"not a PDF", &job, &mut |_, _| called = true)), "submit_with_progress names it"); + assert!(!called, "no sheet reached a spooler"); + assert!(printers().is_empty(), "no printers are listed"); + SYSTEM32_FOR_TEST.with(|d| *d.borrow_mut() = None); + let _ = std::fs::remove_dir_all(&dir); } From 914001a78447969e0a730e1f511e80a1beae43b1 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:18:23 +0800 Subject: [PATCH 044/101] M10: the missing-DLL test checks that printers() never asked the backend Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/print/src/spool.rs | 5 +++++ crates/print/src/tests.rs | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index a940822087..8c2d35335a 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -24,6 +24,9 @@ thread_local! { /// Tests: the folder the print DLLs are looked for in, instead of the system's, so the /// public entry points can be checked against a Windows that lacks them. pub(crate) static SYSTEM32_FOR_TEST: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; + /// Tests: how many times [`printers`] asked the system's printer backend, so a test can tell + /// the guard answered without relying on the machine having no printers. + pub(crate) static PRINTER_BACKEND_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// The first print DLL this Windows installation lacks. `None` when all are there, and when the @@ -51,6 +54,8 @@ pub fn printers() -> Vec { if missing_print_dll().is_some() { return Vec::new(); } + #[cfg(all(windows, test))] + PRINTER_BACKEND_CALLS.with(|c| c.set(c.get().saturating_add(1))); craft_print::printers() } diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index aa6e184e50..9d8a469cdc 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -1169,7 +1169,7 @@ fn missing_print_dlls_are_named() { #[cfg(windows)] #[test] fn printing_without_the_print_dlls_is_refused_before_the_spooler() { - use crate::spool::{PRINT_DLLS, SYSTEM32_FOR_TEST, printers, submit, submit_with_progress}; + use crate::spool::{PRINT_DLLS, PRINTER_BACKEND_CALLS, SYSTEM32_FOR_TEST, printers, submit, submit_with_progress}; let dir = std::env::temp_dir().join(format!("pdfcraft-print-no-dlls-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); @@ -1183,7 +1183,10 @@ fn printing_without_the_print_dlls_is_refused_before_the_spooler() { let mut called = false; assert!(named(submit_with_progress(b"not a PDF", &job, &mut |_, _| called = true)), "submit_with_progress names it"); assert!(!called, "no sheet reached a spooler"); + // Not only an empty list, which a machine without printers gives anyway: the backend wasn't asked. + PRINTER_BACKEND_CALLS.with(|c| c.set(0)); assert!(printers().is_empty(), "no printers are listed"); + assert_eq!(PRINTER_BACKEND_CALLS.with(|c| c.get()), 0, "the printer backend wasn't asked"); SYSTEM32_FOR_TEST.with(|d| *d.borrow_mut() = None); let _ = std::fs::remove_dir_all(&dir); } From bdda289585317a0184dc94a4e9b65646b36fc70d Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:21:54 +0800 Subject: [PATCH 045/101] M3: a two-page spread's display time waits for both pages; the parity row says what full screen does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review (CodeRabbit): in two-page view the /Dur timer started once the current page had rendered, so the spread could turn while its other page still showed Rendering…; it now waits for every page on screen. The ui-parity row says full screen shows a fitted page or spread and that background clicks and keys turn pages while clicks on page objects don't. The doc comment of presentation_click_state is back on its function. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/canvas.rs | 6 ++--- crates/ui-egui/src/canvas/presenter.rs | 6 +++-- crates/ui-egui/src/canvas/tests.rs | 37 ++++++++++++++++++++++++++ docs/ui-parity.md | 2 +- 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 90464d90d6..61a266da88 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2873,9 +2873,6 @@ fn shown_row(view: &DocView, rects: &[Rect]) -> Vec { } } -/// Full screen: a click on the document goes forward a page and a shift-click back, as in -/// Acrobat, unless the click did something else (a link, a word selected). Decided once the pages -/// have had the click. `Some(back)` on the frame of a click, `back` when it was a shift-click. /// The pages' context menu, except in full screen, where a right-click turns back a page instead /// ([`presentation_click_state`]). fn page_context_menu(resp: &egui::Response, presenting: bool, add: impl FnOnce(&mut egui::Ui)) { @@ -2884,6 +2881,9 @@ fn page_context_menu(resp: &egui::Response, presenting: bool, add: impl FnOnce(& } } +/// Full screen: a click on the document goes forward a page and a shift-click back, as in +/// Acrobat, unless the click did something else (a link, a word selected). Decided once the pages +/// have had the click. `Some(back)` on the frame of a click, `back` when it was a shift-click. fn presentation_click_state(ui: &egui::Ui, view: &DocView, navigation_rect: Rect, enabled: bool) -> Option { let presenting = view.presentation.is_some() && enabled; // A right-click goes back a page, as a left click goes forward (*Adobe Acrobat Help*, 2019, diff --git a/crates/ui-egui/src/canvas/presenter.rs b/crates/ui-egui/src/canvas/presenter.rs index 8b98a8b4f6..b130a1b9e3 100644 --- a/crates/ui-egui/src/canvas/presenter.rs +++ b/crates/ui-egui/src/canvas/presenter.rs @@ -122,8 +122,10 @@ pub(super) fn frame( Some(Turn { from_page, from, to_page: page, effect, seconds, started: None }) }); // Its display time starts once the page is on screen, not while it still says Rendering… - // (or at once for a page the renderer can't draw, which never will be). - let ready = view.pages.contains_key(&page) || view.errors.contains_key(&page); + // (or at once for a page the renderer can't draw, which never will be). In two-page view + // that is every page of the spread, so the spread isn't turned before both were shown. + let drawn = |p: &usize| view.pages.contains_key(p) || view.errors.contains_key(p); + let ready = drawn(&page) && view.screen_rects.iter().all(|(p, _)| drawn(p)); view.presenter.shown = if turn.is_some() || !ready { None } else { Some((page, now)) }; view.presenter.turn = turn; } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 3efa809d74..c10f3a35be 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2147,3 +2147,40 @@ fn a_display_time_starts_once_the_page_has_rendered() { presenter_frame(&ctx, &mut v, &info, 6.6); assert_eq!(v.current, 1, "its second on screen is up"); } + +/// In two-page view a display time counts from when the whole spread is on screen: one page of +/// it still showing Rendering… holds the spread (#5316 review). +#[test] +fn a_display_time_in_two_page_view_waits_for_the_whole_spread() { + use pdfcraft_render::PageTransition; + let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }; + let timed = PageTransition { + page: 0, + style: None, + duration: 1.0, + horizontal: false, + inward: false, + direction: Some(0), + scale: 1.0, + opaque: false, + display: Some(1.0), + }; + let info = DocInfo { pages: vec![page.clone(), page.clone(), page.clone(), page], transitions: vec![timed], ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::TwoUp, continuous: false, ..Default::default() }); + v.enter_presentation(); + let ctx = egui::Context::default(); + let tex = |ctx: &egui::Context| ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default()); + let r = Rect::from_min_size(Pos2::ZERO, egui::vec2(300.0, 400.0)); + v.screen_rects = vec![(0, r), (1, r.translate(egui::vec2(300.0, 0.0)))]; + v.pages.insert(0, super::cache::PageTex { tag: 0, tex: tex(&ctx) }); + for now in [0.0, 1.0, 2.0, 5.0] { + presenter_frame(&ctx, &mut v, &info, now); + } + assert_eq!(v.current, 0, "page 2 of the spread is still rendering: the spread's second has not started"); + v.pages.insert(1, super::cache::PageTex { tag: 0, tex: tex(&ctx) }); + presenter_frame(&ctx, &mut v, &info, 5.5); + presenter_frame(&ctx, &mut v, &info, 6.0); + assert_eq!(v.current, 0, "half of its second on screen"); + presenter_frame(&ctx, &mut v, &info, 6.6); + assert_eq!(v.current, 2, "its second on screen is up: the next spread"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b1b41c7212..eabfb9da1f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -31,7 +31,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Document tabs | yes | yes, with unsaved marker | shipped | | Multiple windows, drag a tab out | yes | no | planned | | Split window, spreadsheet split | yes | no | planned | -| Read mode, full screen | yes | yes; full screen is one fitted page at a time on black, click/keys turn pages (#1459), a right-click or Shift+Enter goes back, page transitions and display times play (#379), a still pointer hides | shipped; loop after the last page and the full-screen preferences planned | +| Read mode, full screen | yes | yes; full screen is one fitted page or spread at a time on black, and background clicks and keys turn pages while clicks on links, comments, fields and other page objects do not (#1459), a right-click or Shift+Enter goes back, page transitions and display times play (#379), a still pointer hides | shipped; loop after the last page and the full-screen preferences planned | | Light, dark, system theme | yes | yes | shipped | | Side panels: thumbnails, bookmarks, attachments, layers, comments, fields, signatures | yes | yes; panels keep their width (#704); the Pages inspector has a 50–150% thumbnail size slider and buttons (#3808) | shipped | | Destinations, articles, model tree, Tags, Order, Content panels | yes | no | planned | From 25d07f69b545e4e2b7519e83912b4ae2364ff6e8 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:00:02 +0800 Subject: [PATCH 046/101] fix(forms): a turned field keeps its place and its text runs along the turn (#4296) Setting a field's rotation (Properties > Position, form_set_props) swapped the widget's width and height around its center, so a tall field placed along a page edge jumped into a short, wide one, and typing in it showed the text upright. ISO 32000-2 Table 192 /MK /R turns the widget annotation relative to the page; its /Rect stays the area it occupies in default user space. So a rotation now changes only /MK /R: the rectangle stays, and the regenerated appearance lays the text out along the turned direction (the box /BBox is as wide as the field is tall, and /Matrix turns it onto /Rect). On screen, the in-place editor and the just-committed preview paint the text turned by /MK /R less the page's /Rotate and the view's rotation, as the page will show it. The editor still takes the keys and pointer over the field, and a press or drag places the caret in the turned text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/automation/src/tools.rs | 4 +- crates/automation/tests/it/automation.rs | 8 +- crates/engine/tests/it/form_rotation.rs | 18 +- crates/forms/src/author.rs | 5 +- crates/forms/src/author/edit.rs | 58 ++--- crates/forms/src/tests.rs | 34 +-- crates/ui-egui/src/tools/forms_ui.rs | 199 ++++++++++++++++-- .../ui-egui/src/tools/prepare/properties.rs | 9 +- crates/ui-egui/tests/it/forms.rs | 28 +++ crates/ui-egui/tests/it/prepare.rs | 18 +- parity/acrobat-features.toml | 4 +- 11 files changed, 282 insertions(+), 103 deletions(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c6c200cece..bb83c23b30 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -466,7 +466,7 @@ pub fn tools() -> Vec { t( "form_set_props", "Field properties", - "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename: a dotted name such as MASTER.first is the full name and moves the field under those parent fields; a plain name renames it within its parent), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", + "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename: a dotted name such as MASTER.first is the full name and moves the field under those parent fields; a plain name renames it within its parent), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise; the field keeps its rectangle and its content turns inside it. Undoable.", ) .with(schema( json!({ @@ -490,7 +490,7 @@ pub fn tools() -> Vec { "flags": { "type": "object", "additionalProperties": { "type": "boolean" } }, "font_size": { "type": "number", "minimum": 0 }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Move/resize: points from the top-left of the displayed page." }, - "rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. Changing between horizontal and vertical swaps the rectangle around its center." }, + "rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. The field keeps its rectangle; its content turns inside it." }, "appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (0–12 points; 1 thin, 2 medium, 3 thick), style (solid, dashed, beveled, inset, underline), font (helvetica, times, courier)." }, "format": { "description": "Format tab: {\"type\": \"number\", \"decimals\": 2, \"currency\": \"$\", \"separator\": 0-4, \"negative\": 0-3}, {\"type\": \"percent\"}, {\"type\": \"date\"|\"time\", \"pattern\": \"mm/dd/yyyy\"}, {\"type\": \"zip\"|\"zip4\"|\"phone\"|\"ssn\"}, {\"type\": \"mask\", \"mask\": \"AA-9999\"}, or \"none\"." }, "validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out, or null) or \"none\"." }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6a5765b911..c0a7a01eee 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4226,12 +4226,8 @@ fn rotating_a_field_through_tools() { ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "City", "rotation": 90 })); let (rot, turned) = rect_of(&mut a); assert_eq!(rot, 90); - let (bw, bh) = (before[2] - before[0], before[3] - before[1]); - let (tw, th) = (turned[2] - turned[0], turned[3] - turned[1]); - assert!((tw - bh).abs() < 0.2 && (th - bw).abs() < 0.2, "swapped {before:?} -> {turned:?}"); - let center = |r: &[f64]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); - let (bc, tc) = (center(&before), center(&turned)); - assert!((bc.0 - tc.0).abs() < 0.2 && (bc.1 - tc.1).abs() < 0.2, "center moved {bc:?} -> {tc:?}"); + // The field stays where it is; only its content turns (#4296). + assert!(turned.iter().zip(&before).all(|(t, b)| (t - b).abs() < 0.2), "the field moved: {before:?} -> {turned:?}"); assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Change field properties"); let (rot, back) = rect_of(&mut a); assert_eq!((rot, back), (0, before)); diff --git a/crates/engine/tests/it/form_rotation.rs b/crates/engine/tests/it/form_rotation.rs index 67d560bb4b..8d7574ebec 100644 --- a/crates/engine/tests/it/form_rotation.rs +++ b/crates/engine/tests/it/form_rotation.rs @@ -23,7 +23,8 @@ fn one_page() -> Document { doc } -/// Width and height of the dark pixels on page 1. +/// Width and height of the dark pixels on page 1: the black text, not the field's grey (0.55) +/// border, which would give the field's own outline. fn ink_span(doc: &Document) -> (u32, u32) { let bytes = write_incremental(doc, &SaveOptions::default()).unwrap(); let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); @@ -34,7 +35,7 @@ fn ink_span(doc: &Document) -> (u32, u32) { for y in 0..p.height { for x in 0..p.width { let i = ((y * p.width + x) * 4) as usize; - if p.rgba[i] < 200 { + if p.rgba[i] < 100 { ink = true; (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); } @@ -44,15 +45,18 @@ fn ink_span(doc: &Document) -> (u32, u32) { (x1 - x0, y1 - y0) } +/// A tall, narrow field turned 90 degrees stays where it is and its text runs up its long side +/// (#4296), rather than the field turning into a short, wide one that cuts the text off. #[test] fn a_rotated_field_draws_along_the_vertical_edge() { let mut doc = one_page(); - add_field(&mut doc, 0, [10.0, 40.0, 90.0, 60.0], &NewField::Text { multiline: false }, Some("wide")).unwrap(); - set_value(&mut doc, "wide", &FieldValue::Text("MMMMMM".into())).unwrap(); - set_props(&mut doc, "wide", &FieldProps { font_size: Some(12.0), ..FieldProps::default() }).unwrap(); + add_field(&mut doc, 0, [10.0, 40.0, 30.0, 120.0], &NewField::Text { multiline: false }, Some("tall")).unwrap(); + set_value(&mut doc, "tall", &FieldValue::Text("MMMMMM".into())).unwrap(); + set_props(&mut doc, "tall", &FieldProps { font_size: Some(12.0), ..FieldProps::default() }).unwrap(); let (wide, tall) = ink_span(&doc); assert!(wide > tall, "upright text is a horizontal run: {wide}x{tall}"); - set_props(&mut doc, "wide", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap(); + set_props(&mut doc, "tall", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap(); let (wide, tall) = ink_span(&doc); - assert!(tall > wide, "rotated text is a vertical run: {wide}x{tall}"); + // Six 12 pt M's are about 60 pt, 120 px at scale 2; the field is 20 pt (40 px) wide. + assert!(tall >= 100 && wide < 40, "the text does not run up the field: {wide}x{tall} px"); } diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index de597187d4..4f5f21d4e6 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -118,8 +118,9 @@ pub struct FieldProps { /// Options tab: the mark of a check box or radio button (every widget). pub check_style: Option, /// Position tab: rotate one widget (`/MK /R`) to 0, 90, 180 or 270 degrees counterclockwise. - /// Changing between horizontal and vertical swaps that widget's width and height around its - /// center, unless [`Self::rect`] is also set. + /// The widget keeps its rectangle: `/R` turns the appearance inside it (ISO 32000-2 Table 192 + /// turns the annotation relative to the page, and its `/Rect` stays in default user space), so + /// a tall field turned 90 degrees holds text running up its long side. pub rotation: Option<(usize, i64)>, } diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index 2d05600ae2..91722df508 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -3,18 +3,6 @@ use super::look::{BorderStyle, da_string, patch_da, patch_widget_look, redraw_wi use super::tree::{ancestor_sets, attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited}; use super::{Dict, Document, Field, FieldKind, FieldProps, FormError, MIN_FIELD_SIDE, ObjRef, Object, PdfString, fields, flags, invalid}; -/// Swap width and height, keeping the center. `None` when that is not a usable widget. -fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { - if !r.iter().all(|v| v.is_finite()) { - return None; - } - let (x0, y0, x1, y1) = (r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])); - let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); - let (nw, nh) = (y1 - y0, x1 - x0); - let out = [cx - nw / 2.0, cy - nh / 2.0, cx + nw / 2.0, cy + nh / 2.0]; - (out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= MIN_FIELD_SIDE && out[3] - out[1] >= MIN_FIELD_SIDE).then_some(out) -} - /// Re-point a choice field's `/I`, positions in `/Opt`, at the new list `exports` after `/Opt` was /// replaced (ISO 32000-2 §12.7.5.4): the positions of the selected `values` still offered. `kept` /// are the old `/I` positions carried into the new list, which tell apart options sharing an @@ -139,21 +127,8 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result> = None; - if let Some((wi, rot)) = props.rotation { - if !matches!(rot, 0 | 90 | 180 | 270) { - return invalid("rotation must be 0, 90, 180 or 270 degrees"); - } - let Some(w) = f.widgets.get(wi) else { - return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); - }; - let axis_change = (w.rotation % 180 == 0) != (rot % 180 == 0); - rotated_rect = Some(None); - if axis_change && props.rect.is_none() { - let Some(rect) = swapped_around_center(w.rect) else { return invalid("the field is too small") }; - rotated_rect = Some(Some(rect)); - } + if let Some(rotation) = props.rotation { + check_rotation(&f, name, rotation)?; } // A locked field only takes unlocking (Acrobat greys its properties out). if f.locked() && props.locked != Some(false) && *props != (FieldProps { locked: props.locked, ..FieldProps::default() }) { @@ -363,7 +338,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result>, -) -> Result, FormError> { +/// Whether `set_props` can turn widget `wi` of `f` to `rot`: 0, 90, 180 or 270 degrees, of a +/// widget the field has. +fn check_rotation(f: &Field, name: &str, (wi, rot): (usize, i64)) -> Result<(), FormError> { + if !matches!(rot, 0 | 90 | 180 | 270) { + return invalid("rotation must be 0, 90, 180 or 270 degrees"); + } + if f.widgets.get(wi).is_none() { + return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); + } + Ok(()) +} + +fn apply_late_widget_props(doc: &mut Document, f: &Field, props: &FieldProps, new_da: &str) -> Result, FormError> { // A complete look replaces widget /DA overrides. Partial changes were patched above // against each override, including size-only changes that keep its font and colour. if props.look.is_some() { @@ -548,12 +529,11 @@ fn apply_late_widget_props( } } } - if let (Some((wi, rot)), Some(swapped)) = (props.rotation, rotated_rect) + // Only `/MK /R` changes: the widget stays where it is on the page, and the redraw below lays + // its content out along the turned direction inside the same rectangle. + if let Some((wi, rot)) = props.rotation && let Some(w) = f.widgets.get(wi) { - if let Some(rect) = swapped { - doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())))?; - } let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); if rot == 0 { mk.remove(b"R"); diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index cca4cb4881..be9fa9785e 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -2674,14 +2674,14 @@ fn rotated_fields_draw_in_their_quadrant() { let turned = field(&fields(&doc), "name").widgets[0].clone(); assert_eq!(turned.rotation, 90); let r = turned.rect; - // [50 700 250 720] around its center becomes 20 wide and 200 tall. - assert!(close(r[0], 140.0) && close(r[1], 610.0) && close(r[2], 160.0) && close(r[3], 810.0), "{r:?}"); + // The field stays where it was: only its content turns. + assert!(r.iter().zip(before).all(|(a, b)| close(*a, b)), "{r:?} moved from {before:?}"); let widget = doc.get(turned.obj); let mk = widget.as_dict().unwrap().get(b"MK").unwrap().as_dict().cloned().unwrap(); assert!(mk.contains(b"BG") && mk.contains(b"BC") && mk.int(b"R") == Some(90)); let (_, matrix) = appearance_box(&doc, &turned); assert!(matrix.is_some()); - // 270 stays on the vertical axis, so the rectangle is not swapped again. + // 270 keeps the rectangle too. set_props(&mut doc, "name", &FieldProps { rotation: Some((0, 270)), ..FieldProps::default() }).unwrap(); let r270 = field(&fields(&doc), "name").widgets[0].rect; assert!(r270.iter().zip(r).all(|(a, b)| close(*a, b)), "{r270:?}"); @@ -2704,18 +2704,26 @@ fn rotated_fields_draw_in_their_quadrant() { assert!(text.contains("(Go) Tj") && matrix.is_some(), "{text}"); } +/// #4296: a tall field turned a quarter keeps its place, and its text is laid out along the long +/// side (the appearance box is as wide as the field is tall) and turned onto the field. #[test] -fn a_rotation_refused_as_too_small_changes_nothing_else() { +fn a_tall_field_turned_a_quarter_keeps_its_place_and_runs_its_text_up_it() { let mut doc = one_page(); - add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("thin")).unwrap(); - // 200 x 3: turned a quarter it would be 3 wide, too small to use. - let obj = field(&fields(&doc), "thin").widgets[0].obj; - doc.update_dict(obj, |d| d.set(b"Rect".to_vec(), Object::Array([50.0, 700.0, 250.0, 703.0].iter().map(|v| Object::Real(*v)).collect()))).unwrap(); - let props = FieldProps { tooltip: Some("changed".into()), rotation: Some((0, 90)), ..FieldProps::default() }; - assert!(set_props(&mut doc, "thin", &props).is_err()); - let f = field(&fields(&doc), "thin").clone(); - assert_eq!(f.tooltip, None, "the tooltip was written before the rotation was refused"); - assert_eq!(f.widgets[0].rotation, 0); + let rect = [50.0, 600.0, 70.0, 760.0]; + add_field(&mut doc, 0, rect, &NewField::Text { multiline: false }, Some("tall")).unwrap(); + set_value(&mut doc, "tall", &FieldValue::Text("Ada".into())).unwrap(); + let props = FieldProps { tooltip: Some("Name".into()), rotation: Some((0, 90)), ..FieldProps::default() }; + set_props(&mut doc, "tall", &props).unwrap(); + let f = field(&fields(&doc), "tall").clone(); + let w = &f.widgets[0]; + assert_eq!((w.rotation, f.tooltip.as_deref()), (90, Some("Name"))); + assert!(w.rect.iter().zip(rect).all(|(a, b)| close(*a, b)), "the field moved: {:?} -> {:?}", rect, w.rect); + let (bbox, matrix) = appearance_box(&doc, w); + assert!(close(bbox[2], 160.0) && close(bbox[3], 20.0), "the text is laid out across the field, not up it: {bbox:?}"); + let m = matrix.unwrap(); + // (x, y) -> (20 - y, x): the box's 160 pt run goes up the field. + assert!(close(m[1], 1.0) && close(m[2], -1.0) && close(m[4], 20.0), "{m:?}"); + assert!(ap(&doc, w).contains("(Ada) Tj")); } #[test] diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 7232b1169b..d575435482 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -91,6 +91,52 @@ fn widget_rect(xf: &PageXform, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect xf.user_rect(info, page, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]) } +/// Where a field's text sits on screen. It is laid out upright in `upright`, the field's screen +/// rectangle with its sides swapped when the text runs up or down the screen, and turned onto +/// the field about its centre. The field's `/MK /R` turns its content counterclockwise on the +/// page (ISO 32000-2 Table 192), as its page appearance draws it; the page's `/Rotate` and the +/// view's rotation turn the page clockwise. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct TextFrame { + pub upright: Rect, + /// Clockwise on screen: 0, 90, 180 or 270 degrees. + pub turn: i64, +} + +impl TextFrame { + pub(crate) fn new(field: Rect, mk_r: i64, page_rotate: u16, view_rotation: u16) -> Self { + // Each term is reduced first, so the sum cannot overflow. + let turn = (i64::from(page_rotate % 360) + i64::from(view_rotation % 360) - mk_r.rem_euclid(360)).rem_euclid(360) / 90 * 90; + let upright = if turn % 180 == 90 { Rect::from_center_size(field.center(), vec2(field.height(), field.width())) } else { field }; + Self { upright, turn } + } + + fn of(xf: &PageXform, info: &DocInfo, page: usize, w: &FormWidget, field: Rect) -> Self { + Self::new(field, w.rotation, info.pages.get(page).map_or(0, |p| p.rotation), xf.rot) + } + + fn angle(self) -> f32 { + (self.turn as f32).to_radians() + } + + /// A point of the upright box, on screen. + pub(crate) fn on_screen(self, p: egui::Pos2) -> egui::Pos2 { + let c = self.upright.center(); + c + egui::emath::Rot2::from_angle(self.angle()) * (p - c) + } + + /// A screen point, in the upright box. + pub(crate) fn in_box(self, p: egui::Pos2) -> egui::Pos2 { + let c = self.upright.center(); + c + egui::emath::Rot2::from_angle(-self.angle()) * (p - c) + } + + /// Paint `galley` with its top-left corner at `pos` in the upright box. + fn paint_galley(self, painter: &egui::Painter, pos: egui::Pos2, galley: std::sync::Arc, color: Color32) { + painter.add(egui::epaint::TextShape::new(self.on_screen(pos), galley, color).with_angle(self.angle())); + } +} + fn fillable(f: &FormField) -> bool { !f.read_only() && !matches!(f.kind, FormFieldKind::PushButton | FormFieldKind::Signature) } @@ -507,7 +553,7 @@ pub(crate) fn paint_page( painter.rect_stroke(r.expand(1.0), CornerRadius::same(2), Stroke::new(2.0, FOCUS_BLUE), egui::StrokeKind::Outside); } if view.forms.unbaked.contains(&(f.name.clone(), page)) { - paint_unbaked_widget(painter, xf, f, w, r); + paint_unbaked_widget(painter, xf, TextFrame::of(xf, info, page, w, r), f, w, r); } else if fillable(f) && pointer.is_some_and(|p| r.contains(p)) { painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, FOCUS_BLUE.gamma_multiply(0.8)), egui::StrokeKind::Outside); } @@ -530,7 +576,10 @@ pub fn field_text_size(painter: &egui::Painter, f: &FormField, w: &FormWidget, t let size = fixed.unwrap_or_else(|| { // The appearance's padding: 2 pt inside a 1 pt border. let pad = 3.0; - let (width, height) = ((w.rect[2] - w.rect[0]).abs() - 2.0 * pad, (w.rect[3] - w.rect[1]).abs() - 2.0 * pad); + let (across, up) = ((w.rect[2] - w.rect[0]).abs(), (w.rect[3] - w.rect[1]).abs()); + // A field turned a quarter lays its text out along its height, as its appearance does. + let (across, up) = if matches!(w.rotation, 90 | 270) { (up, across) } else { (across, up) }; + let (width, height) = (across - 2.0 * pad, up - 2.0 * pad); // Comb cells hold one character each, so only the height limits them, as on the page. if f.is_comb() { return pdfcraft_engine::field_comb_auto_text_size(height); @@ -566,7 +615,7 @@ pub fn field_screen_font(painter: &egui::Painter, f: &FormField, w: &FormWidget, if font.is_nan() { 3.0 } else { font.clamp(3.0, MAX_SCREEN_FONT) } } -fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, w: &FormWidget, r: Rect) { +fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, frame: TextFrame, f: &FormField, w: &FormWidget, r: Rect) { match f.kind { FormFieldKind::Text | FormFieldKind::Combo => { let text = f.value.first().map(String::as_str).unwrap_or(""); @@ -578,16 +627,18 @@ fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, let font_size = field_screen_font(painter, f, w, text, zoom); let display_text = if f.has(field_flags::PASSWORD) { "•".repeat(text.chars().count()) } else { text.to_string() }; let p = painter.with_clip_rect(r); + // Laid out upright in the frame's box, then turned onto the field (#4296). + let b = frame.upright; let (align, pos) = match f.quadding { - 1 => (egui::Align2::CENTER_TOP, egui::pos2(r.center().x, r.min.y + 1.0)), - 2 => (egui::Align2::RIGHT_TOP, egui::pos2(r.max.x - 3.0, r.min.y + 1.0)), - _ => (egui::Align2::LEFT_TOP, egui::pos2(r.min.x + 3.0, r.min.y + 1.0)), + 1 => (egui::Align2::CENTER_TOP, egui::pos2(b.center().x, b.min.y + 1.0)), + 2 => (egui::Align2::RIGHT_TOP, egui::pos2(b.max.x - 3.0, b.min.y + 1.0)), + _ => (egui::Align2::LEFT_TOP, egui::pos2(b.min.x + 3.0, b.min.y + 1.0)), }; // Multiline fields wrap at the box, as their page appearance does. // At the width the size was fitted to: the box less 3 pt each side, scaled. - let wrap = if f.has(field_flags::MULTILINE) { (r.width() - 6.0 * zoom).max(1.0) } else { f32::INFINITY }; + let wrap = if f.has(field_flags::MULTILINE) { (b.width() - 6.0 * zoom).max(1.0) } else { f32::INFINITY }; let galley = p.layout(display_text, egui::FontId::proportional(font_size), Color32::BLACK, wrap); - p.galley(align.anchor_size(pos, galley.size()).min, galley, Color32::BLACK); + frame.paint_galley(&p, align.anchor_size(pos, galley.size()).min, galley, Color32::BLACK); } } FormFieldKind::List => { @@ -654,7 +705,9 @@ pub(crate) fn text_box(doc: &pdfcraft_engine::Document, view: &DocView, form: &[ } /// After the text editor is drawn: keeps a field that doesn't scroll within what fits, puts the -/// caret where the press that opened the editor landed, and takes focus when asked. +/// caret where the press that opened the editor landed, and takes focus when asked. `in_galley` +/// turns a screen point into a position in the laid-out text, which a turned field's editor +/// lays out upright (#4296). Returns whether it placed the caret for such a press. fn settle_text_editor( ui: &mut egui::Ui, fx: &mut Focus, @@ -662,7 +715,9 @@ fn settle_text_editor( primary_down: bool, laid_out: Option<(std::sync::Arc, Pos2)>, fitted: Option<(&form_appearance::TextBox, String)>, -) { + in_galley: impl Fn(&egui::Galley, Pos2, Pos2) -> egui::Vec2, +) -> bool { + let mut pressed = false; if let Some((tb, before)) = fitted && fx.text != before { @@ -681,12 +736,13 @@ fn settle_text_editor( && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) { // The press went to the page, not to this editor, which did not exist yet. - let at = galley.cursor_from_pos(press - pos + vec2(galley.rect.left(), 0.0)); + let at = galley.cursor_from_pos(in_galley(&galley, pos, press)); st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at))); st.store(ui.ctx(), r.id); if primary_down { ui.ctx().set_dragged_id(r.id); } + pressed = true; } if fx.request_focus { r.request_focus(); @@ -699,6 +755,7 @@ fn settle_text_editor( st.store(ui.ctx(), r.id); } } + pressed } /// Tab / Shift+Tab: commits the field and moves to the next fillable widget in the document's tab @@ -814,33 +871,100 @@ pub(crate) fn overlay( ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL); // A field that doesn't scroll takes no more than fits (ISO 32000-2 Table 228). let before = text_box.map(|_| fx.text.clone()); + // A turned field (/MK /R, or a turned page or view) is typed into where it is: + // the editor takes the keys and the pointer over the field, but draws nothing, + // and its text is painted turned below, as the page will show it (#4296). + let frame = TextFrame::of(&xf, info, page, w, rect); + let turned = frame.turn != 0; + let upright = frame.upright; + let password = f.has(field_flags::PASSWORD); + // Wraps (inside the 3 px side margins) at the width the size was fitted to: + // the box less 3 pt each side, scaled. + let wrap = (upright.width() - 6.0 * zoom).max(1.0); + let mut layouter = |ui: &egui::Ui, text: &dyn egui::TextBuffer, _: f32| { + let shown = if password { "•".repeat(text.as_str().chars().count()) } else { text.as_str().to_owned() }; + let font = egui::FontId::proportional(font); + let mut job = if multiline { + egui::text::LayoutJob::simple(shown, font, Color32::BLACK, wrap) + } else { + egui::text::LayoutJob::simple_singleline(shown, font, Color32::BLACK) + }; + job.keep_trailing_whitespace = true; + ui.fonts_mut(|f| f.layout_job(job)) + }; let mut te = if multiline { egui::TextEdit::multiline(&mut fx.text) } else { egui::TextEdit::singleline(&mut fx.text) }; te = te - // Wraps (inside the 3 px side margins) at the width the size was fitted to: - // the box less 3 pt each side, scaled. - .desired_width((rect.width() - 6.0 * zoom + 6.0).max(1.0)) + .desired_width((upright.width() - 6.0 * zoom + 6.0).max(1.0)) .font(egui::FontId::proportional(font)) .background_color(FIELD_FILL) .text_color(Color32::BLACK) .margin(egui::Margin::symmetric(3, 1)) - .password(f.has(field_flags::PASSWORD)) + .password(password) .id(egui::Id::unique(("form-field", view.id.0, &f.name, focus.widget))); if let Some(max) = f.max_len { te = te.char_limit(max); } if multiline { - te = te.desired_rows(((rect.height() / (font * 1.3)).floor() as usize).max(1)); + te = te.desired_rows(((upright.height() / (font * 1.3)).floor() as usize).max(1)); + } + if turned { + // Laid out at the upright box's width, not the field's on screen. + te = te.layouter(&mut layouter); } let mut laid_out = None; let r = ui .add_sized(rect.size(), |ui: &mut egui::Ui| { + if turned { + ui.set_opacity(0.0); + } let out = te.show(ui); laid_out = Some((out.galley, out.galley_pos)); out.response.response }) // The form field's own logical name; not a visual/preordered label. .accessible_name(f.name.clone()); - settle_text_editor(ui, fx, &r, primary_down, laid_out, text_box.zip(before)); + // Where the upright text starts: inside the editor's margins. + let origin = upright.min + vec2(3.0, 1.0); + // A screen point as a position in the laid-out text. + let in_galley = |galley: &egui::Galley, pos: Pos2, p: Pos2| { + if turned { frame.in_box(p) - origin + vec2(galley.rect.left(), 0.0) } else { p - pos + vec2(galley.rect.left(), 0.0) } + }; + let caret_placed = settle_text_editor(ui, fx, &r, primary_down, laid_out.clone(), text_box.zip(before), in_galley); + if !caret_placed + && turned + && r.is_pointer_button_down_on() + && let Some(p) = ui.input(|i| i.pointer.interact_pos()) + && let Some((galley, pos)) = &laid_out + && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) + { + // The editor placed the caret as if its text were upright: place it under + // the pointer in the turned text instead, a press at the point and a drag + // from where the press left it. + let at = galley.cursor_from_pos(in_galley(galley, *pos, p)); + let range = match st.cursor.char_range() { + Some(was) if !ui.input(|i| i.pointer.primary_pressed()) => egui::text::CCursorRange::two(was.secondary, at), + _ => egui::text::CCursorRange::one(at), + }; + st.cursor.set_char_range(Some(range)); + st.store(ui.ctx(), r.id); + } + if turned && let Some((galley, _)) = &laid_out { + let painter = ui.painter_at(rect); + painter.rect_filled(rect, CornerRadius::ZERO, FIELD_FILL); + let range = r.has_focus().then(|| egui::TextEdit::load_state(ui.ctx(), r.id).and_then(|st| st.cursor.char_range())).flatten(); + let mut shown = galley.clone(); + if let Some(range) = &range { + egui::text_selection::visuals::paint_text_selection(&mut shown, ui.visuals(), range, None); + } + let start = origin - vec2(galley.rect.left(), 0.0); + frame.paint_galley(&painter, start, shown, Color32::BLACK); + if let Some(range) = range { + let mut caret = galley.pos_from_cursor(range.primary); + caret.max.y = caret.max.y.max(caret.min.y + font); + let (top, bottom) = (start + caret.center_top().to_vec2(), start + caret.center_bottom().to_vec2()); + painter.line_segment([frame.on_screen(top), frame.on_screen(bottom)], ui.visuals().text_cursor.stroke); + } + } let lost = crate::edit_text_ui::lost_focus_once(&r, &mut fx.had_focus); let (enter, esc, tab, shift) = ui.input(|i| { (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift) @@ -1005,7 +1129,8 @@ pub fn field_screen_rect(view: &DocView, info: &DocInfo, f: &FormField, widget: #[cfg(test)] mod tests { - use super::{MAX_SCREEN_FONT, bounded_line_width}; + use super::{MAX_SCREEN_FONT, TextFrame, bounded_line_width}; + use egui::{Rect, pos2}; use std::cell::Cell; #[test] @@ -1020,4 +1145,42 @@ mod tests { assert_eq!(bounded_line_width(10.0, measure), 30.0, "below it, at the size itself"); assert_eq!(largest.get(), MAX_SCREEN_FONT); } + + fn near(a: egui::Pos2, b: egui::Pos2) -> bool { + (a - b).length() < 1e-3 + } + + /// #4296: a tall field turned 90 degrees counterclockwise lays its text out in a box as wide + /// as the field is tall, and that box's top-left corner lands on the field's bottom-left, so + /// the text runs up the screen. + #[test] + fn a_quarter_turned_field_runs_its_text_up_the_screen() { + let field = Rect::from_min_max(pos2(100.0, 100.0), pos2(120.0, 260.0)); + let frame = TextFrame::new(field, 90, 0, 0); + assert_eq!(frame.turn, 270); + assert_eq!(frame.upright.size(), egui::vec2(160.0, 20.0)); + assert!(near(frame.on_screen(frame.upright.left_top()), field.left_bottom()), "{:?}", frame.on_screen(frame.upright.left_top())); + assert!(near(frame.on_screen(frame.upright.right_top()), field.left_top())); + for p in [pos2(105.0, 110.0), pos2(119.0, 250.0)] { + assert!(near(frame.on_screen(frame.in_box(p)), p)); + } + // 270 runs down the screen, from the top-right corner. + let down = TextFrame::new(field, 270, 0, 0); + assert_eq!(down.turn, 90); + assert!(near(down.on_screen(down.upright.left_top()), field.right_top())); + } + + /// The page's /Rotate and the view's rotation turn the page clockwise, against /MK /R: a field + /// turned 90 on a page shown a quarter clockwise reads upright, in its own rectangle. + #[test] + fn page_and_view_rotation_turn_against_the_field() { + let field = Rect::from_min_max(pos2(0.0, 0.0), pos2(80.0, 20.0)); + assert_eq!(TextFrame::new(field, 90, 90, 0), TextFrame { upright: field, turn: 0 }); + assert_eq!(TextFrame::new(field, 90, 0, 90), TextFrame { upright: field, turn: 0 }); + assert_eq!(TextFrame::new(field, 0, 0, 0), TextFrame { upright: field, turn: 0 }); + assert_eq!(TextFrame::new(field, 180, 270, 270).turn, 0); + assert_eq!(TextFrame::new(field, 0, 180, 0).turn, 180); + // Out-of-range input stays a quarter turn. + assert_eq!(TextFrame::new(field, i64::MIN, u16::MAX, u16::MAX).turn % 90, 0); + } } diff --git a/crates/ui-egui/src/tools/prepare/properties.rs b/crates/ui-egui/src/tools/prepare/properties.rs index fd74420707..120452adf8 100644 --- a/crates/ui-egui/src/tools/prepare/properties.rs +++ b/crates/ui-egui/src/tools/prepare/properties.rs @@ -161,17 +161,12 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke ui.add_space(8.0); ui.horizontal(|ui| { let l = ui.label(tl!("Rotation:")); - let before = d.rotation; + // The field keeps its place and size: the rotation turns its content (#4296). craft_ui::ComboBox::from_id_salt("field-rotation") .selected_text(format!("{}°", d.rotation)) .show_ui(ui, |ui| { for r in [0, 90, 180, 270] { - if ui.selectable_value(&mut d.rotation, r, format!("{r}°")).changed() && (before % 180 == 0) != (r % 180 == 0) { - // Keep the same center. set_props skips its own swap when the rect changed too. - let [x, y, w, h] = d.position; - let (cx, cy) = (x + w / 2.0, y + h / 2.0); - d.position = [cx - h / 2.0, cy - w / 2.0, h, w]; - } + ui.selectable_value(&mut d.rotation, r, format!("{r}°")); } }) .response diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index fa828a8f9f..8722d5fdc8 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -168,6 +168,34 @@ fn a_multi_line_auto_sized_field_that_doesnt_scroll_stops_when_full() { assert!(tb.fits(&kept) && !tb.fits(&format!("{kept}\nline")), "as many lines as fit at 4 pt: {lines}"); } +/// #4296: a field turned a quarter (`/MK /R` 90) is typed into where it is, with its text running +/// up the field as the page draws it: a click near the field's top puts the caret at the end of +/// the text, where the turned text ends, not at its start. +#[test] +fn a_turned_field_places_the_caret_along_its_turned_text() { + let mut h = harness(); + // Tall and narrow, with room up it for the text. + let props = pdfcraft_engine::FieldProps { rect: Some((0, [240.0, 20.0, 260.0, 170.0])), font_size: Some(10.0), ..Default::default() }; + set_city(&mut h, props); + set_city(&mut h, pdfcraft_engine::FieldProps { rotation: Some((0, 90)), ..Default::default() }); + click_field(&mut h, "city", 0); + assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city")); + h.event(egui::Event::Text("Paris".into())); + h.run_steps(2); + let field = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == "city").unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen") + }; + click_at(&mut h, field.left_top() + egui::vec2(3.0, 2.0)); + h.event(egui::Event::Text("X".into())); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(4); + assert_eq!(value(&h, "city"), ["ParisX"]); +} + #[test] fn typing_into_a_text_field_and_tabbing_on() { let mut h = harness(); diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 82e33b29de..5d7958295c 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -335,17 +335,21 @@ fn position_tab_rotates_a_field() { assert!(h.state().field_props.as_ref().unwrap().props().is_none(), "an unchanged draft is not an edit"); h.get_by_label("Position").click(); h.run_steps(2); - h.get_by_label("Rotation:"); - h.state_mut().field_props.as_mut().unwrap().rotation = 90; - assert!(h.state().field_props.as_ref().unwrap().props().is_some()); + // Picked from the list, as a person does. + h.get_by_label("Rotation:").click(); + h.run_steps(2); + h.get_by_label("90°").click(); + h.run_steps(2); + let draft = h.state().field_props.as_ref().unwrap().clone(); + assert_eq!(draft.rotation, 90); + let props = draft.props().expect("a new rotation is an edit"); + assert!(props.rect.is_none(), "picking a rotation moved the field: {:?}", props.rect); h.get_by_label("OK").click(); h.run_steps(3); let turned = widget(&h); assert_eq!(turned.rotation, 90); - let [x0, y0, x1, y1] = before.rect; - let (cx, cy, w, ht) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, x1 - x0, y1 - y0); - let expect = [cx - ht / 2.0, cy - w / 2.0, cx + ht / 2.0, cy + w / 2.0]; - assert!(turned.rect.iter().zip(expect).all(|(a, b)| (a - b).abs() < 0.05), "{:?} vs {expect:?}", turned.rect); + // The field stays where it was placed; only its content turns (#4296). + assert!(turned.rect.iter().zip(before.rect).all(|(a, b)| (a - b).abs() < 0.05), "{:?} moved from {:?}", turned.rect, before.rect); h.state_mut().execute("edit.undo"); h.run_steps(3); let back = widget(&h); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0b6f658dd6..eba3081f8a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4571,8 +4571,8 @@ milestone = "M6" status = "partial" commands = ["form.fields"] tools = ["form_fill", "form_set_props"] -evidence = ["crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/forms/src/tests.rs::auto_text_size_fits_single_and_multiline_text", "crates/automation/tests/it/automation.rs::auto_sized_text_follows_a_tall_field", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/forms/src/tests.rs::rotated_fields_draw_in_their_quadrant", "crates/engine/tests/it/form_rotation.rs::a_rotated_field_draws_along_the_vertical_edge", "crates/automation/tests/it/automation.rs::rotating_a_field_through_tools", "crates/ui-egui/tests/it/prepare.rs::position_tab_rotates_a_field", "crates/ui-egui/tests/it/prepare.rs::dropdown_alignment_is_editable_and_preserved", "crates/ui-egui/tests/it/prepare.rs::list_box_alignment_is_editable_and_preserved"] -notes = "auto-size, comb, multiline, password, quadding (text, dropdown and list-box Options controls), /MK colours and /MK /R rotation (0, 90, 180, 270); approximate Helvetica metrics. Single-line auto-size follows the field height with no 12 pt cap, as Acrobat Reader draws it; multiline and comb auto-size still stop at 12 pt, not yet checked against Acrobat" +evidence = ["crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/forms/src/tests.rs::auto_text_size_fits_single_and_multiline_text", "crates/automation/tests/it/automation.rs::auto_sized_text_follows_a_tall_field", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/forms/src/tests.rs::rotated_fields_draw_in_their_quadrant", "crates/forms/src/tests.rs::a_tall_field_turned_a_quarter_keeps_its_place_and_runs_its_text_up_it", "crates/ui-egui/tests/it/forms.rs::a_turned_field_places_the_caret_along_its_turned_text", "crates/engine/tests/it/form_rotation.rs::a_rotated_field_draws_along_the_vertical_edge", "crates/automation/tests/it/automation.rs::rotating_a_field_through_tools", "crates/ui-egui/tests/it/prepare.rs::position_tab_rotates_a_field", "crates/ui-egui/tests/it/prepare.rs::dropdown_alignment_is_editable_and_preserved", "crates/ui-egui/tests/it/prepare.rs::list_box_alignment_is_editable_and_preserved"] +notes = "auto-size, comb, multiline, password, quadding (text, dropdown and list-box Options controls), /MK colours and /MK /R rotation (0, 90, 180, 270: the field keeps its rectangle and its text runs along the turned direction, also while it is typed into; double-click word selection in a turned field is not mapped); approximate Helvetica metrics. Single-line auto-size follows the field height with no 12 pt cap, as Acrobat Reader draws it; multiline and comb auto-size still stop at 12 pt, not yet checked against Acrobat" [[feature]] id = "form.rich-text-fields" From 83d0654b0e1247f5c89cac3cf85c66450f33f12b Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:28:31 +0800 Subject: [PATCH 047/101] fix(forms): a turned editable combo box types along its turn (#4296) An editable combo box (ISO 32000-2 Table 233, Edit) turned by /MK /R (Table 192) sized its font for the turn but still drew a horizontal editor, so its text, selection and caret ran across the field while the committed text ran along it. The combo editor now takes the field's TextFrame and shares the turned text field's path: an upright layout at the turned box's width, the pointer mapped back into the upright text, and the text, selection and caret painted turned. The shared pieces are pulled out of the text field editor (turned_layouter, show_field_editor, turned_caret_and_paint) so the two editors cannot drift apart. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --- crates/ui-egui/src/tools/forms_ui.rs | 195 ++++++++++++++++----------- crates/ui-egui/tests/it/forms.rs | 39 ++++++ 2 files changed, 158 insertions(+), 76 deletions(-) diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index d575435482..c2359de6d1 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -135,6 +135,96 @@ impl TextFrame { fn paint_galley(self, painter: &egui::Painter, pos: egui::Pos2, galley: std::sync::Arc, color: Color32) { painter.add(egui::epaint::TextShape::new(self.on_screen(pos), galley, color).with_angle(self.angle())); } + + /// Where a field editor's upright text starts: inside its margins. + fn text_origin(self) -> egui::Pos2 { + self.upright.min + vec2(3.0, 1.0) + } + + /// A screen point `p` as a position in `galley`, which the editor drew at `pos`; a turned + /// field's text is laid out upright in the frame's box. + fn in_galley(self, galley: &egui::Galley, pos: egui::Pos2, p: egui::Pos2) -> egui::Vec2 { + if self.turn != 0 { self.in_box(p) - self.text_origin() + vec2(galley.rect.left(), 0.0) } else { p - pos + vec2(galley.rect.left(), 0.0) } + } +} + +/// A field editor's laid-out text and where the editor drew it. +type Laid = (std::sync::Arc, egui::Pos2); + +/// How a turned field's editor lays its text out: at the upright box's width (`wrap`, for a +/// multi-line field), not the field's on screen. +fn turned_layouter( + font: f32, + wrap: Option, + password: bool, +) -> impl FnMut(&egui::Ui, &dyn egui::TextBuffer, f32) -> std::sync::Arc { + move |ui, text, _| { + let shown = if password { "•".repeat(text.as_str().chars().count()) } else { text.as_str().to_owned() }; + let font = egui::FontId::proportional(font); + let mut job = match wrap { + Some(wrap) => egui::text::LayoutJob::simple(shown, font, Color32::BLACK, wrap), + None => egui::text::LayoutJob::simple_singleline(shown, font, Color32::BLACK), + }; + job.keep_trailing_whitespace = true; + ui.fonts_mut(|f| f.layout_job(job)) + } +} + +/// Show a field's editor over `rect`. A turned field's editor takes the keys and the pointer +/// over the field but draws nothing: [`turned_caret_and_paint`] paints its text turned (#4296). +fn show_field_editor(ui: &mut egui::Ui, te: egui::TextEdit<'_>, rect: Rect, turned: bool) -> (egui::Response, Option) { + let mut laid_out = None; + let r = ui.add_sized(rect.size(), |ui: &mut egui::Ui| { + if turned { + ui.set_opacity(0.0); + } + let out = te.show(ui); + laid_out = Some((out.galley, out.galley_pos)); + out.response.response + }); + (r, laid_out) +} + +/// Unless the caret was just placed under the press that opened the editor (`caret_placed`, by +/// [`settle_text_editor`]), place it under the pointer in a turned field's text, and paint a +/// turned field's text, selection and caret as the page will show them. +fn turned_caret_and_paint(ui: &egui::Ui, r: &egui::Response, rect: Rect, frame: TextFrame, laid_out: Option<&Laid>, caret_placed: bool, font: f32) { + let turned = frame.turn != 0; + if !caret_placed + && turned + && r.is_pointer_button_down_on() + && let Some(p) = ui.input(|i| i.pointer.interact_pos()) + && let Some((galley, pos)) = laid_out + && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) + { + // The editor placed the caret as if its text were upright: place it under the pointer + // in the turned text instead, a press at the point and a drag from where the press + // left it. + let at = galley.cursor_from_pos(frame.in_galley(galley, *pos, p)); + let range = match st.cursor.char_range() { + Some(was) if !ui.input(|i| i.pointer.primary_pressed()) => egui::text::CCursorRange::two(was.secondary, at), + _ => egui::text::CCursorRange::one(at), + }; + st.cursor.set_char_range(Some(range)); + st.store(ui.ctx(), r.id); + } + if turned && let Some((galley, _)) = laid_out { + let painter = ui.painter_at(rect); + painter.rect_filled(rect, CornerRadius::ZERO, FIELD_FILL); + let range = r.has_focus().then(|| egui::TextEdit::load_state(ui.ctx(), r.id).and_then(|st| st.cursor.char_range())).flatten(); + let mut shown = galley.clone(); + if let Some(range) = &range { + egui::text_selection::visuals::paint_text_selection(&mut shown, ui.visuals(), range, None); + } + let start = frame.text_origin() - vec2(galley.rect.left(), 0.0); + frame.paint_galley(&painter, start, shown, Color32::BLACK); + if let Some(range) = range { + let mut caret = galley.pos_from_cursor(range.primary); + caret.max.y = caret.max.y.max(caret.min.y + font); + let (top, bottom) = (start + caret.center_top().to_vec2(), start + caret.center_bottom().to_vec2()); + painter.line_segment([frame.on_screen(top), frame.on_screen(bottom)], ui.visuals().text_cursor.stroke); + } + } } fn fillable(f: &FormField) -> bool { @@ -363,18 +453,20 @@ pub(crate) fn commit(view: &mut DocView, form: &[FormField]) { } /// The text box of an editable combo box, over the field. What is typed becomes the value when -/// Enter, Tab or a click away commits it; a pick from the list still sets an option. +/// Enter, Tab or a click away commits it; a pick from the list still sets an option. A turned +/// field is typed into as a turned text field is: its text runs along the turn (#4296). struct ComboEditor<'a> { form: &'a [FormField], field: &'a FormField, widget: &'a FormWidget, rect: Rect, + frame: TextFrame, zoom: f32, index: usize, } fn combo_editor(ctx: &egui::Context, view: &mut DocView, editor: ComboEditor<'_>) { - let ComboEditor { form, field: f, widget: w, rect, zoom, index: widget } = editor; + let ComboEditor { form, field: f, widget: w, rect, frame, zoom, index: widget } = editor; let mut enter = false; egui::Area::new(egui::Id::unique(("form-editor", view.id.0))) .fade_in(false) @@ -388,15 +480,22 @@ fn combo_editor(ctx: &egui::Context, view: &mut DocView, editor: ComboEditor<'_> let font = field_screen_font(ui.painter(), f, w, &fx.text, zoom); // The box is pale yellow in every theme, as a text field's is (#655). ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL); - let te = egui::TextEdit::singleline(&mut fx.text) - .desired_width((rect.width() - 6.0 * zoom + 6.0).max(1.0)) + let turned = frame.turn != 0; + let mut layouter = turned_layouter(font, None, false); + let mut te = egui::TextEdit::singleline(&mut fx.text) + .desired_width((frame.upright.width() - 6.0 * zoom + 6.0).max(1.0)) .font(egui::FontId::proportional(font)) .background_color(FIELD_FILL) .text_color(Color32::BLACK) .margin(egui::Margin::symmetric(3, 1)) .id(egui::Id::unique(("form-field", view.id.0, &f.name, widget))); + if turned { + te = te.layouter(&mut layouter); + } + let (r, laid_out) = show_field_editor(ui, te, rect, turned); // The form field's own logical name; not a visual/preordered label. - let r = ui.add_sized(rect.size(), te).accessible_name(f.name.clone()); + let r = r.accessible_name(f.name.clone()); + turned_caret_and_paint(ui, &r, rect, frame, laid_out.as_ref(), false, font); if std::mem::take(&mut fx.request_focus) { r.request_focus(); // Entered with Tab: typing replaces the value. @@ -705,9 +804,8 @@ pub(crate) fn text_box(doc: &pdfcraft_engine::Document, view: &DocView, form: &[ } /// After the text editor is drawn: keeps a field that doesn't scroll within what fits, puts the -/// caret where the press that opened the editor landed, and takes focus when asked. `in_galley` -/// turns a screen point into a position in the laid-out text, which a turned field's editor -/// lays out upright (#4296). Returns whether it placed the caret for such a press. +/// caret where the press that opened the editor landed (in the upright text of a turned field, +/// through `frame`, #4296), and takes focus when asked. Returns whether it placed the caret. fn settle_text_editor( ui: &mut egui::Ui, fx: &mut Focus, @@ -715,7 +813,7 @@ fn settle_text_editor( primary_down: bool, laid_out: Option<(std::sync::Arc, Pos2)>, fitted: Option<(&form_appearance::TextBox, String)>, - in_galley: impl Fn(&egui::Galley, Pos2, Pos2) -> egui::Vec2, + frame: TextFrame, ) -> bool { let mut pressed = false; if let Some((tb, before)) = fitted @@ -736,7 +834,7 @@ fn settle_text_editor( && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) { // The press went to the page, not to this editor, which did not exist yet. - let at = galley.cursor_from_pos(in_galley(&galley, pos, press)); + let at = galley.cursor_from_pos(frame.in_galley(&galley, pos, press)); st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at))); st.store(ui.ctx(), r.id); if primary_down { @@ -881,17 +979,7 @@ pub(crate) fn overlay( // Wraps (inside the 3 px side margins) at the width the size was fitted to: // the box less 3 pt each side, scaled. let wrap = (upright.width() - 6.0 * zoom).max(1.0); - let mut layouter = |ui: &egui::Ui, text: &dyn egui::TextBuffer, _: f32| { - let shown = if password { "•".repeat(text.as_str().chars().count()) } else { text.as_str().to_owned() }; - let font = egui::FontId::proportional(font); - let mut job = if multiline { - egui::text::LayoutJob::simple(shown, font, Color32::BLACK, wrap) - } else { - egui::text::LayoutJob::simple_singleline(shown, font, Color32::BLACK) - }; - job.keep_trailing_whitespace = true; - ui.fonts_mut(|f| f.layout_job(job)) - }; + let mut layouter = turned_layouter(font, multiline.then_some(wrap), password); let mut te = if multiline { egui::TextEdit::multiline(&mut fx.text) } else { egui::TextEdit::singleline(&mut fx.text) }; te = te .desired_width((upright.width() - 6.0 * zoom + 6.0).max(1.0)) @@ -911,60 +999,11 @@ pub(crate) fn overlay( // Laid out at the upright box's width, not the field's on screen. te = te.layouter(&mut layouter); } - let mut laid_out = None; - let r = ui - .add_sized(rect.size(), |ui: &mut egui::Ui| { - if turned { - ui.set_opacity(0.0); - } - let out = te.show(ui); - laid_out = Some((out.galley, out.galley_pos)); - out.response.response - }) - // The form field's own logical name; not a visual/preordered label. - .accessible_name(f.name.clone()); - // Where the upright text starts: inside the editor's margins. - let origin = upright.min + vec2(3.0, 1.0); - // A screen point as a position in the laid-out text. - let in_galley = |galley: &egui::Galley, pos: Pos2, p: Pos2| { - if turned { frame.in_box(p) - origin + vec2(galley.rect.left(), 0.0) } else { p - pos + vec2(galley.rect.left(), 0.0) } - }; - let caret_placed = settle_text_editor(ui, fx, &r, primary_down, laid_out.clone(), text_box.zip(before), in_galley); - if !caret_placed - && turned - && r.is_pointer_button_down_on() - && let Some(p) = ui.input(|i| i.pointer.interact_pos()) - && let Some((galley, pos)) = &laid_out - && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) - { - // The editor placed the caret as if its text were upright: place it under - // the pointer in the turned text instead, a press at the point and a drag - // from where the press left it. - let at = galley.cursor_from_pos(in_galley(galley, *pos, p)); - let range = match st.cursor.char_range() { - Some(was) if !ui.input(|i| i.pointer.primary_pressed()) => egui::text::CCursorRange::two(was.secondary, at), - _ => egui::text::CCursorRange::one(at), - }; - st.cursor.set_char_range(Some(range)); - st.store(ui.ctx(), r.id); - } - if turned && let Some((galley, _)) = &laid_out { - let painter = ui.painter_at(rect); - painter.rect_filled(rect, CornerRadius::ZERO, FIELD_FILL); - let range = r.has_focus().then(|| egui::TextEdit::load_state(ui.ctx(), r.id).and_then(|st| st.cursor.char_range())).flatten(); - let mut shown = galley.clone(); - if let Some(range) = &range { - egui::text_selection::visuals::paint_text_selection(&mut shown, ui.visuals(), range, None); - } - let start = origin - vec2(galley.rect.left(), 0.0); - frame.paint_galley(&painter, start, shown, Color32::BLACK); - if let Some(range) = range { - let mut caret = galley.pos_from_cursor(range.primary); - caret.max.y = caret.max.y.max(caret.min.y + font); - let (top, bottom) = (start + caret.center_top().to_vec2(), start + caret.center_bottom().to_vec2()); - painter.line_segment([frame.on_screen(top), frame.on_screen(bottom)], ui.visuals().text_cursor.stroke); - } - } + let (r, laid_out) = show_field_editor(ui, te, rect, turned); + // The form field's own logical name; not a visual/preordered label. + let r = r.accessible_name(f.name.clone()); + let caret_placed = settle_text_editor(ui, fx, &r, primary_down, laid_out.clone(), text_box.zip(before), frame); + turned_caret_and_paint(ui, &r, rect, frame, laid_out.as_ref(), caret_placed, font); let lost = crate::edit_text_ui::lost_focus_once(&r, &mut fx.had_focus); let (enter, esc, tab, shift) = ui.input(|i| { (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift) @@ -1001,7 +1040,11 @@ pub(crate) fn overlay( // An editable combo box has a text box over the field as well as its list (ISO // 32000-2 Table 233, Edit). if editable_combo(f) { - combo_editor(ctx, view, ComboEditor { form, field: f, widget: w, rect, zoom, index: focus.widget }); + combo_editor( + ctx, + view, + ComboEditor { form, field: f, widget: w, rect, frame: TextFrame::of(&xf, info, page, w, rect), zoom, index: focus.widget }, + ); } let resp = egui::Area::new(egui::Id::unique(("form-choices", view.id.0))) .order(egui::Order::Foreground) diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index 8722d5fdc8..0165aa2b1a 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -1565,6 +1565,45 @@ fn an_editable_combo_box_takes_a_typed_value() { let _ = std::fs::remove_dir_all(&dir); } +/// #4296: an editable combo box turned a quarter (`/MK /R` 90, ISO 32000-2 Table 192) is typed +/// into as a turned text field is: a click near the field's top puts the caret at the end of the +/// text, where the text running up the field ends, not at its start. +#[test] +fn a_turned_editable_combo_box_places_the_caret_along_its_turned_text() { + let pdf = pdf_from_objects(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (fruit) /Ff 393216 /Opt [(Apple) (Banana)] /Rect [240 20 260 170] /MK << /R 90 >> /P 3 0 R /F 4 >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("turned-combo.pdf", None, pdf.clone()).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + crate::support::settle(&mut h); + click_field(&mut h, "fruit", 0); + assert_eq!(focused(&h), Some(("fruit".into(), 0))); + h.event(egui::Event::Text("Paris".into())); + h.run_steps(2); + let field = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == "fruit").unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen") + }; + click_at(&mut h, field.left_top() + egui::vec2(3.0, 2.0)); + h.event(egui::Event::Text("X".into())); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(4); + assert_eq!(value(&h, "fruit"), ["ParisX"]); +} + /// A combo box without the Edit flag is a drop-down list only: typing changes nothing. #[test] fn a_combo_box_without_edit_takes_no_typing() { From 7dbf3809575d29125aa9cc1ae7e3962b13a08740 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:37:54 +0800 Subject: [PATCH 048/101] fix(forms): a turned field's editor reads the pointer through PagePointer (#4296) The turned editor's caret placement read egui's pointer state directly (primary_down, interact_pos, primary_pressed), which the page-pointer guard refuses in page modules. The editor is an Area over the page, so it reads a PagePointer for its own response: the press on it is its own, held until the frame of its release, a touch's lift included. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK --- crates/ui-egui/src/tools/forms_ui.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index c2359de6d1..864792a638 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -15,6 +15,7 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageToolContext, PageXform}; use crate::page_cursor::Layer; +use crate::page_pointer::PagePointer; use crate::page_router::{Hit, Owner}; use crate::theme::Tokens; @@ -190,10 +191,15 @@ fn show_field_editor(ui: &mut egui::Ui, te: egui::TextEdit<'_>, rect: Rect, turn /// turned field's text, selection and caret as the page will show them. fn turned_caret_and_paint(ui: &egui::Ui, r: &egui::Response, rect: Rect, frame: TextFrame, laid_out: Option<&Laid>, caret_placed: bool, font: f32) { let turned = frame.turn != 0; + // The pointer as the editor sees it. The editor is an Area over the page, so a press on it + // is its own and the pages' pointer does not reach it: read for the editor's response, it + // holds the pointer from a press on the editor until the frame of its release, a touch's + // lift included. A dialog over the editor takes the presses before they reach it. + let ptr = PagePointer::read(ui, r, false); if !caret_placed && turned && r.is_pointer_button_down_on() - && let Some(p) = ui.input(|i| i.pointer.interact_pos()) + && let Some(p) = ptr.pos && let Some((galley, pos)) = laid_out && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) { @@ -202,7 +208,7 @@ fn turned_caret_and_paint(ui: &egui::Ui, r: &egui::Response, rect: Rect, frame: // left it. let at = galley.cursor_from_pos(frame.in_galley(galley, *pos, p)); let range = match st.cursor.char_range() { - Some(was) if !ui.input(|i| i.pointer.primary_pressed()) => egui::text::CCursorRange::two(was.secondary, at), + Some(was) if ptr.pressed().is_none() => egui::text::CCursorRange::two(was.secondary, at), _ => egui::text::CCursorRange::one(at), }; st.cursor.set_char_range(Some(range)); From 0e580f7672dc2b6a7096320a69520b4aa9359021 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:59:58 +0800 Subject: [PATCH 049/101] M3: full screen tests for display times, bounded and hostile durations, unknown styles, reduced motion, Escape and every key Review on #5316. No behaviour change: the presenter already cut transitions when animations are off (by default following the system), bounded /D to 10 s and /Dur to a day, ignored non-finite or negative values, read an unknown /S as Replace (ISO 32000-2 Table 164) and stopped with full screen. These tests pin each of those down, and every key and click full screen answers to. The Wipe test now counts the transition's frames from its first painted strip, so frames spent waiting for the raster don't count toward it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/canvas/presenter.rs | 13 +- crates/ui-egui/src/canvas/tests.rs | 177 ++++++++++++++++++++++++- crates/ui-egui/tests/it/view.rs | 173 +++++++++++++++++++++++- 3 files changed, 355 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas/presenter.rs b/crates/ui-egui/src/canvas/presenter.rs index b130a1b9e3..42bf7a2481 100644 --- a/crates/ui-egui/src/canvas/presenter.rs +++ b/crates/ui-egui/src/canvas/presenter.rs @@ -3,9 +3,13 @@ //! and the pointer hides while it stays still. //! //! Transitions play in single-page view without view rotation, from the outgoing page's raster -//! to the incoming one's once it has rendered; spreads, a rotated view and the Animation effects -//! preference set to off change pages at once. Display times count from the end of the -//! transition and stop at the last page. +//! to the incoming one's once it has rendered; spreads, a rotated view and animations turned off +//! (the Animation effects preference, which by default follows the system's request for no +//! animations) change pages at once. A style the spec doesn't name is its default, Replace, a +//! cut. Display times count from the end of the transition and stop at the last page; the keys +//! and clicks still turn pages meanwhile (§12.4.4.1, NOTE 1), and leaving full screen stops them. +//! The file's numbers are bounded: a transition plays for at most `MAX_TRANSITION` seconds, and a +//! display time waits at most `MAX_DISPLAY`. use egui::{Color32, CornerRadius, Painter, Pos2, Rect, TextureHandle, pos2}; use pdfcraft_render::transition::{self, Effect, Piece}; @@ -16,7 +20,8 @@ use super::{DocView, PageLayout}; /// Seconds the pointer stays still before full screen hides it. pub(crate) const HIDE_POINTER_AFTER: f64 = 3.0; /// Bounds on a document's numbers, which are untrusted: the longest transition played and the -/// longest display time waited for. +/// longest display time waited for (a day: a timer can't hold the largest number a file can +/// write). const MAX_TRANSITION: f64 = 10.0; const MAX_DISPLAY: f64 = 24.0 * 3600.0; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index c10f3a35be..57da3e7da8 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2006,9 +2006,15 @@ fn a_moved_picture_is_drawn_from_the_fresh_raster_while_a_tile_is_still_stale() /// Full screen's presenter for one frame of `ctx`, at time `now`: whether the frame asked to be /// drawn again at once. fn presenter_frame(ctx: &egui::Context, v: &mut DocView, info: &DocInfo, now: f64) -> bool { + presenter_frame_animating(ctx, v, info, now, true) +} + +/// [`presenter_frame`] with the Animation effects preference as given (`false` also when the +/// system asks for no animations). +fn presenter_frame_animating(ctx: &egui::Context, v: &mut DocView, info: &DocInfo, now: f64, animates: bool) -> bool { let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { let painter = ui.ctx().layer_painter(egui::LayerId::background()); - presenter::frame(v, info, &painter, ui.ctx(), now, true, None); + presenter::frame(v, info, &painter, ui.ctx(), now, animates, None); }); // No renderer here to upload textures: drop the frame's texture changes. out.textures_delta.clear(); @@ -2184,3 +2190,172 @@ fn a_display_time_in_two_page_view_waits_for_the_whole_spread() { presenter_frame(&ctx, &mut v, &info, 6.6); assert_eq!(v.current, 2, "its second on screen is up: the next spread"); } + +/// Page 1 of `pages` 300×400 pt pages, with `transitions`, in full screen (single page), its +/// raster in. The other pages get theirs with [`give_raster`]. +fn presenting_view(pages: usize, transitions: Vec, ctx: &egui::Context) -> (DocView, DocInfo) { + let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }; + let info = DocInfo { pages: vec![page; pages], transitions, ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::Single, continuous: false, ..Default::default() }); + v.enter_presentation(); + give_raster(ctx, &mut v, 0); + (v, info) +} + +/// Page `page`'s raster has arrived (a blank one). +fn give_raster(ctx: &egui::Context, v: &mut DocView, page: usize) { + let tex = ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default()); + v.pages.insert(page, super::cache::PageTex { tag: 0, tex }); +} + +/// Turn to `page` as a key would, with its raster in and its place on screen. +fn turn_to(ctx: &egui::Context, v: &mut DocView, page: usize) { + give_raster(ctx, v, page); + v.current = page; + v.screen_rects = vec![(page, Rect::from_min_size(Pos2::ZERO, egui::vec2(300.0, 400.0)))]; +} + +/// A page's `/Trans` (style and `/D`) and `/Dur`, the rest as the spec's defaults. +fn trans(page: usize, style: Option, duration: f32, display: Option) -> pdfcraft_render::PageTransition { + pdfcraft_render::PageTransition { page, style, duration, horizontal: true, inward: true, direction: Some(0), scale: 1.0, opaque: false, display } +} + +/// A page with a transition but no display time (ISO 32000-2 Table 31, `Dur`: "By default, the +/// viewer shall not advance automatically") stays until the user turns it, however long it shows. +#[test] +fn a_page_without_a_display_time_never_advances_by_itself() { + use pdfcraft_render::TransitionStyle; + let ctx = egui::Context::default(); + let (mut v, info) = presenting_view(3, vec![trans(0, Some(TransitionStyle::Wipe), 1.0, None)], &ctx); + for now in [0.0, 1.0, 60.0, 86_400.0, 1.0e7] { + presenter_frame(&ctx, &mut v, &info, now); + } + assert_eq!(v.current, 0, "no /Dur: the page waits for the presenter"); +} + +/// A display time of 0 seconds advances as soon as the page is on screen: zero is a number the +/// spec allows, the time a page "shall be displayed" at most. +#[test] +fn a_display_time_of_zero_advances_once_the_page_is_on_screen() { + let ctx = egui::Context::default(); + let (mut v, info) = presenting_view(3, vec![trans(0, None, 1.0, Some(0.0))], &ctx); + presenter_frame(&ctx, &mut v, &info, 0.0); + assert_eq!(v.current, 1, "page 1's zero seconds are up at once"); + presenter_frame(&ctx, &mut v, &info, 0.1); + assert_eq!(v.current, 1, "page 2 has no display time"); +} + +/// A display time from the file that is negative, not a number or endless is no display time, or +/// at most a day: it neither turns the page at once nor reaches a timer that can't hold it. +#[test] +fn a_negative_or_endless_display_time_is_no_display_time() { + for display in [-1.0, -0.001, f32::NAN, f32::NEG_INFINITY, f32::INFINITY] { + let ctx = egui::Context::default(); + let (mut v, info) = presenting_view(3, vec![trans(0, None, 1.0, Some(display))], &ctx); + for now in [0.0, 0.1, 5.0, 3600.0] { + presenter_frame(&ctx, &mut v, &info, now); + } + assert_eq!(v.current, 0, "/Dur {display}"); + } +} + +/// A display time longer than a day is cut to a day; the largest number the file can hold would +/// otherwise be past what a timer can wait for. +#[test] +fn a_display_time_longer_than_a_day_is_cut_to_a_day() { + for display in [1.0e9, f32::MAX] { + let ctx = egui::Context::default(); + let (mut v, info) = presenting_view(3, vec![trans(0, None, 1.0, Some(display))], &ctx); + presenter_frame(&ctx, &mut v, &info, 0.0); + presenter_frame(&ctx, &mut v, &info, 86_399.0); + assert_eq!(v.current, 0, "/Dur {display}: not yet a day"); + presenter_frame(&ctx, &mut v, &info, 86_400.5); + assert_eq!(v.current, 1, "/Dur {display}: a day is up"); + } +} + +/// A transition the file says lasts a day (or longer) ends within ten seconds: it can't hold the +/// screen. +#[test] +fn a_transition_of_a_day_ends_within_ten_seconds() { + use pdfcraft_render::TransitionStyle; + for duration in [86_400.0, f32::MAX] { + let ctx = egui::Context::default(); + let (mut v, info) = presenting_view(3, vec![trans(1, Some(TransitionStyle::Wipe), duration, None)], &ctx); + presenter_frame(&ctx, &mut v, &info, 0.0); + turn_to(&ctx, &mut v, 1); + presenter_frame(&ctx, &mut v, &info, 0.1); + presenter_frame(&ctx, &mut v, &info, 5.0); + assert!(v.presenting_a_transition(), "/D {duration}: playing"); + presenter_frame(&ctx, &mut v, &info, 10.2); + assert!(!v.presenting_a_transition(), "/D {duration}: over after ten seconds"); + } +} + +/// A transition of no length, a negative one or one that is not a number changes the page at once +/// (a negative one would otherwise never end). +#[test] +fn a_transition_of_zero_or_negative_length_cuts() { + use pdfcraft_render::TransitionStyle; + for duration in [0.0, -1.0, f32::NAN, f32::NEG_INFINITY] { + let ctx = egui::Context::default(); + let (mut v, info) = presenting_view(3, vec![trans(1, Some(TransitionStyle::Wipe), duration, None)], &ctx); + presenter_frame(&ctx, &mut v, &info, 0.0); + turn_to(&ctx, &mut v, 1); + presenter_frame(&ctx, &mut v, &info, 0.1); + assert!(!v.presenting_a_transition(), "/D {duration}: cut"); + assert_eq!(v.current, 1); + } +} + +/// A style the spec doesn't name is read as its default R, Replace (ISO 32000-2 Table 164: "The +/// new page simply replaces the old one with no special transition effect"): the page changes at +/// once, and its display time still counts. +#[test] +fn an_unknown_transition_style_cuts_and_keeps_the_display_time() { + use pdfcraft_render::TransitionStyle; + let ctx = egui::Context::default(); + let unknown = TransitionStyle::from_name("Spiral"); + let (mut v, info) = presenting_view(3, vec![trans(1, Some(unknown), 5.0, Some(1.0))], &ctx); + presenter_frame(&ctx, &mut v, &info, 0.0); + turn_to(&ctx, &mut v, 1); + presenter_frame(&ctx, &mut v, &info, 0.1); + assert!(!v.presenting_a_transition(), "no effect for an unknown style"); + presenter_frame(&ctx, &mut v, &info, 0.9); + assert_eq!(v.current, 1, "its display time is not up"); + presenter_frame(&ctx, &mut v, &info, 1.2); + assert_eq!(v.current, 2, "its display time is up"); +} + +/// With animations off (the preference, or the system asking for none), a transition is a cut and +/// the page's display time still counts from when it is on screen. +#[test] +fn without_animations_a_transition_cuts_and_the_display_time_counts() { + use pdfcraft_render::TransitionStyle; + let ctx = egui::Context::default(); + let (mut v, info) = presenting_view(3, vec![trans(1, Some(TransitionStyle::Wipe), 5.0, Some(1.0))], &ctx); + presenter_frame_animating(&ctx, &mut v, &info, 0.0, false); + turn_to(&ctx, &mut v, 1); + presenter_frame_animating(&ctx, &mut v, &info, 0.1, false); + assert!(!v.presenting_a_transition(), "cut"); + presenter_frame_animating(&ctx, &mut v, &info, 1.2, false); + assert_eq!(v.current, 2, "page 2's second is up"); +} + +/// A page turned by hand while a display time counts starts its own time afresh (ISO 32000-2 +/// §12.4.4.1, NOTE 1: "The user can advance the page manually before the specified time has +/// expired"); the time left on the page before is not carried over. +#[test] +fn a_page_turned_by_hand_starts_its_own_display_time() { + let ctx = egui::Context::default(); + let timed = (0..4).map(|p| trans(p, None, 1.0, Some(2.0))).collect(); + let (mut v, info) = presenting_view(4, timed, &ctx); + presenter_frame(&ctx, &mut v, &info, 0.0); + presenter_frame(&ctx, &mut v, &info, 1.5); + turn_to(&ctx, &mut v, 1); + presenter_frame(&ctx, &mut v, &info, 1.5); + presenter_frame(&ctx, &mut v, &info, 3.0); + assert_eq!(v.current, 1, "page 2 has shown 1.5 of its 2 seconds"); + presenter_frame(&ctx, &mut v, &info, 3.6); + assert_eq!(v.current, 2, "page 2's own 2 seconds are up"); +} diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index e4fdad63d3..b41ad5f2dc 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2343,10 +2343,15 @@ trailer << /Root 1 0 R >> /// The presentation document in full screen, frames a tenth of a second apart, every raster in. fn presenting(options: &'static [(&'static str, &'static str)]) -> Harness<'static, PdfCraftApp> { + presenting_pdf(PRESENTATION, options) +} + +/// [`presenting`] with another document. +fn presenting_pdf(pdf: &'static [u8], options: &'static [(&'static str, &'static str)]) -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(0.1).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("slides.pdf", None, PRESENTATION.to_vec()).expect("opens"); + app.open_bytes("slides.pdf", None, pdf.to_vec()).expect("opens"); app.set_option("left", "closed").unwrap(); app.set_option("panel", "none").unwrap(); app.set_option("animation_effects", "on").unwrap(); @@ -2421,8 +2426,10 @@ fn full_screen_plays_a_page_transition_and_honours_the_display_time() { std::thread::sleep(std::time::Duration::from_millis(5)); } assert!(wipe_strip(&h), "the Wipe paints part of page 2 ({steps} frames)"); - let frames = steps + frames_until(&mut h, |a| !a.views[0].presenting_a_transition()); - assert!(frames >= 9, "the Wipe takes its second ({frames} frames of 0.1 s)"); + // Counted from the first painted strip, when page 2's raster is in and the Wipe is under way: + // the frames spent waiting for the raster don't count toward its second. + let frames = frames_until(&mut h, |a| !a.views[0].presenting_a_transition()); + assert!(frames >= 8, "the Wipe takes its second ({frames} frames of 0.1 s after the first strip)"); assert_eq!(h.state().views[0].current, 1, "still page 2 when the transition ends"); h.run_steps(5); assert_eq!(h.state().views[0].current, 1, "half of its display time"); @@ -2624,3 +2631,163 @@ trailer << /Root 1 0 R >> assert_eq!(h.state().views[n].current, 0, "no page turned"); assert!(h.ctx.egui_wants_keyboard_input(), "the note is open to edit"); } + +/// With Animation effects following the system and the system asking for no animations (Windows' +/// Animation effects off, or another system's Reduce motion), full screen changes pages at once +/// and still keeps the page's display time; with the system not asking, the same transition plays. +#[test] +fn full_screen_cuts_transitions_when_the_system_asks_for_no_animations() { + for reduced in [true, false] { + let mut h = presenting(&[("animation_effects", "system")]); + h.state_mut().motion.reduced = reduced; + h.run_steps(1); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.state().views[0].current, 1); + assert_eq!(h.state().views[0].presenting_a_transition(), !reduced, "system asks for no animations: {reduced}"); + if reduced { + settle(&mut h); + let frames = frames_until(&mut h, |a| a.views[0].current == 2); + assert!(frames <= 20, "page 2's one-second display time still turns it ({frames} frames of 0.1 s)"); + } + } +} + +/// Escape leaves full screen while a transition plays and while a display time counts, and the +/// display time stops with it: the page reached stays. +#[test] +fn escape_leaves_full_screen_mid_transition_and_stops_the_display_time() { + for mid_transition in [true, false] { + let mut h = presenting(&[]); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert!(h.state().views[0].presenting_a_transition(), "page 2 comes in with its Wipe"); + if !mid_transition { + frames_until(&mut h, |a| !a.views[0].presenting_a_transition()); + h.run_steps(3); + assert_eq!(h.state().views[0].current, 1, "page 2's display time is counting"); + } + h.key_press(Key::Escape); + h.run_steps(1); + assert!(!h.state().full_screen, "Escape leaves full screen (mid-transition: {mid_transition})"); + assert!(!h.state().views[0].presenting_a_transition()); + h.run_steps(30); + assert_eq!(h.state().views[0].current, 1, "no page turns by itself after leaving (mid-transition: {mid_transition})"); + } +} + +/// While a page's display time counts, the keys still turn pages at once (ISO 32000-2 §12.4.4.1, +/// NOTE 1: "The user can advance the page manually before the specified time has expired"): +/// → goes on before the time is up, and ← back to a page with no display time, which then stays. +#[test] +fn full_screen_keys_take_over_from_a_display_time() { + let mut h = presenting(&[("animation_effects", "off")]); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.state().views[0].current, 1, "page 2, with its one-second display time"); + h.key_press(Key::ArrowLeft); + h.run_steps(1); + assert_eq!(h.state().views[0].current, 0, "← goes back before the time is up"); + h.run_steps(30); + assert_eq!(h.state().views[0].current, 0, "page 1 has no display time: it stays"); + h.key_press(Key::ArrowRight); + h.run_steps(2); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.state().views[0].current, 2, "→ goes on before page 2's time is up"); +} + +/// Every way full screen moves: forward (→, Space, Enter, Page Down, a click), back (←, Backspace, +/// Shift+Enter, Page Up, a right-click, a Shift-click), first (Home, Shift+Ctrl+Page Up), last (End, +/// Shift+Ctrl+Page Down) and out (Escape, Ctrl+L). +#[test] +fn full_screen_every_key_and_click_goes_where_it_says() { + #[derive(Clone, Copy, Debug)] + enum Input { + Key(Key, Modifiers), + Click(egui::PointerButton, Modifiers), + } + use Input::{Click, Key as K}; + use egui::PointerButton::{Primary, Secondary}; + let both = Modifiers::COMMAND | Modifiers::SHIFT; + // (input, page before, page after); a page past the end stays where it is. + let moves = [ + (K(Key::ArrowRight, Modifiers::NONE), 0, 1), + (K(Key::Space, Modifiers::NONE), 0, 1), + (K(Key::Enter, Modifiers::NONE), 0, 1), + (K(Key::PageDown, Modifiers::NONE), 0, 1), + (Click(Primary, Modifiers::NONE), 0, 1), + (K(Key::ArrowLeft, Modifiers::NONE), 1, 0), + (K(Key::Backspace, Modifiers::NONE), 1, 0), + (K(Key::Enter, Modifiers::SHIFT), 1, 0), + (K(Key::PageUp, Modifiers::NONE), 1, 0), + (Click(Secondary, Modifiers::NONE), 1, 0), + (Click(Primary, Modifiers::SHIFT), 1, 0), + (K(Key::Home, Modifiers::NONE), 2, 0), + (K(Key::PageUp, both), 2, 0), + (K(Key::End, Modifiers::NONE), 0, 2), + (K(Key::PageDown, both), 0, 2), + ]; + // A document with no display times, so no page turns by itself meanwhile. + let mut h = harness(&[]); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + assert!(h.state().full_screen); + for (input, from, to) in moves { + h.state_mut().views[0].go_to_page(from); + h.run_steps(4); + assert_eq!(h.state().views[0].current, from, "{input:?}: on page {}", from + 1); + match input { + K(key, modifiers) => { + h.key_press_modifiers(modifiers, key); + h.run_steps(4); + } + Click(button, modifiers) => { + // Clear of page 1's link, which a click would follow instead. + let r = rect(&h, from).expect("the page is on screen"); + let at = egui::pos2(r.center().x, r.bottom() - 20.0); + h.hover_at(at); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button, pressed, modifiers }); + } + h.run_steps(6); + } + } + assert_eq!(h.state().views[0].current, to, "{input:?} from page {}", from + 1); + assert!(h.state().full_screen, "{input:?} stays in full screen"); + } + for (key, modifiers) in [(Key::Escape, Modifiers::NONE), (Key::L, Modifiers::COMMAND)] { + if !h.state().full_screen { + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + } + assert!(h.state().full_screen); + h.key_press_modifiers(modifiers, key); + h.run_steps(4); + assert!(!h.state().full_screen, "{key:?} leaves full screen"); + } +} + +/// A transition style the spec doesn't name (here `/Spiral`, with a five-second `/D`) is the +/// default, Replace (ISO 32000-2 Table 164, `S`: "Default value: R"): the page changes at once, and +/// its display time still turns it. +#[test] +fn full_screen_cuts_an_unknown_transition_style_and_keeps_its_display_time() { + const SPIRAL: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /Trans << /S /Spiral /D 5 >> /Dur 1 >> endobj +5 0 obj << /Type /Page /Parent 2 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut h = presenting_pdf(SPIRAL, &[]); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.state().views[0].current, 1); + assert!(!h.state().views[0].presenting_a_transition(), "no effect for an unknown style"); + settle(&mut h); + let frames = frames_until(&mut h, |a| a.views[0].current == 2); + assert!(frames <= 20, "its one-second display time turns it ({frames} frames of 0.1 s)"); +} From ca4ddcfb90c1ac4ced6c8ac8ca620443a765c9e9 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:46:19 +0800 Subject: [PATCH 050/101] M8: a test for each way the metadata check fails closed; XMP is decoded strictly (#2448) End-to-end tests for a removed run over 64 KiB, more than 20,000 terms, a run over 4,096 characters, and XMP that is corrupt, cut short, behind two filters, over 16 MiB or in another encoding. Each was seen failing with its guard removed or loosened. The corrupt and cut-short cases showed that the tolerant decoder returned the part of the packet before the damage, which was then searched as if it were the whole packet. XMP is now decoded strictly, and a packet behind more than one filter goes unread. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/redact/README.md | 5 +- crates/redact/src/metadata.rs | 15 +- crates/redact/src/tests.rs | 1 + .../redact/src/tests/metadata_fail_closed.rs | 157 ++++++++++++++++++ 4 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 crates/redact/src/tests/metadata_fail_closed.rs diff --git a/crates/redact/README.md b/crates/redact/README.md index 0db0e18c49..ec73af0ae3 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -47,8 +47,9 @@ clear_marks(&mut doc, None)?; // remove marks without app is nested past the depth limit (`MAX_DEPTH`, 50) or its content can't be read; or more than 20,000 terms, or a run longer than 4,096 characters, to search for. Once the search's work limit (2^28 steps) runs out, every entry and packet not yet checked goes - too. An XMP packet that isn't valid UTF-8 or UTF-16, can't be decoded, or decodes to - more than 16 MiB goes without being read. + too. An XMP packet that isn't valid UTF-8 or UTF-16, can't be decoded strictly (damaged or + cut-short filter data, or more than one filter), or decodes to more than + 16 MiB goes without being read. - **Not scrubbed** (Sanitize's categories cover them): XMP streams on anything but the catalog and the pages (images, fonts, form XObjects, annotations and other objects), bookmark titles, and attachments (embedded files, their names and descriptions). diff --git a/crates/redact/src/metadata.rs b/crates/redact/src/metadata.rs index 6fced3ad91..8946cf46f6 100644 --- a/crates/redact/src/metadata.rs +++ b/crates/redact/src/metadata.rs @@ -12,8 +12,9 @@ //! removed run, is removed whole: editing the XML in place could leave it malformed or keep the //! term in another spelling. A PDF/A identification in that packet goes with it. //! -//! Fail-closed: XMP that isn't UTF-8 or UTF-16 goes, text too large to search against every term -//! counts as matching, and when the removed text itself was cut short (too many runs or terms, +//! Fail-closed: XMP that isn't UTF-8 or UTF-16, or whose filter data is damaged or behind more than +//! one filter (it is decoded strictly, so nothing after damage goes unsearched), goes; text too +//! large to search against every term counts as matching, and when the removed text itself was cut short (too many runs or terms, //! or a run too long to keep) every `/Info` string and every XMP stream goes, since what was //! never collected can't be searched for. Terms are matched without regard to case or spacing. @@ -273,8 +274,14 @@ pub(crate) fn decode_xmp(bytes: &[u8]) -> Option { /// The text values of an XMP stream, or `None` when it can't be read. fn xmp_values(doc: &Document, o: &Object) -> Option> { let Object::Stream(s) = &*doc.resolve(o) else { return None }; - // Decoding stops at the cap, so a small stream that inflates hugely costs no more memory. - let bytes = s.decoded_within(MAX_XMP_BYTES).ok()?; + // Decoded strictly: a tolerant decode returns whatever came before the damage, and the part + // after it would go unsearched. A chain of more than one filter has no strict decoder here, + // so it isn't read either (fail-closed; XMP is rarely filtered at all). Decoding stops at the + // cap, so a small stream that inflates hugely costs no more memory. + if s.filters().len() > 1 { + return None; + } + let bytes = s.decoded_first_filter_strict(MAX_XMP_BYTES).ok()?; decode_xmp(&bytes).map(|t| xml_values(&t)) } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index e873a03b0e..b810043279 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -3040,6 +3040,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { mod descriptions; mod inline_images; mod limits; +mod metadata_fail_closed; mod property_retirement; mod surviving_references; mod text_state; diff --git a/crates/redact/src/tests/metadata_fail_closed.rs b/crates/redact/src/tests/metadata_fail_closed.rs new file mode 100644 index 0000000000..0a958ccd88 --- /dev/null +++ b/crates/redact/src/tests/metadata_fail_closed.rs @@ -0,0 +1,157 @@ +//! #2539 review: each way the metadata check fails closed, end to end. When the removed text +//! can't be known in full, every `/Info` string and both the catalog's and the page's XMP go; +//! when one XMP packet can't be read, that packet goes and the rest of the metadata is checked +//! as usual. + +use super::*; + +const CLEAN_XMP: &str = r#"Annual figures"#; + +/// One page showing `content`, with `/Info` (two strings and a name), catalog XMP (object 7) +/// and page XMP (object 8). None of the metadata mentions anything the tests remove. +fn page_with_metadata(content: &[u8], catalog_xmp: Vec) -> Document { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Metadata 7 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Metadata 8 0 R >>".to_vec(), + stream("", content), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + catalog_xmp, + stream("/Type /Metadata /Subtype /XML", CLEAN_XMP.as_bytes()), + b"<< /Title (Annual figures) /Producer (Test) /Trapped /False >>".to_vec(), + ]; + Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry("/Info 9 0 R").build())).unwrap() +} + +fn clean_xmp() -> Vec { + stream("/Type /Metadata /Subtype /XML", CLEAN_XMP.as_bytes()) +} + +fn catalog_has_xmp(doc: &Document) -> bool { + doc.get(doc.root().unwrap()).as_dict().is_some_and(|c| c.contains(b"Metadata")) +} + +fn page_has_xmp(doc: &Document) -> bool { + pdfcraft_model::pages(doc)[0].dict.contains(b"Metadata") +} + +/// Mark the line at y 100 across the page and apply. +fn redact_line(doc: &mut Document) -> Report { + mark(doc, 0, &[[0.0, 95.0, 300.0, 110.0]], ""); + apply(doc, None).unwrap() +} + +/// Every `/Info` string and both XMP streams went, though none of them mentions what was removed. +fn assert_cleared(doc: &Document, r: &Report) { + assert_eq!(r.metadata, 4, "{r:?}"); + assert_eq!(info_keys(doc), ["Trapped"], "strings go, the name stays"); + assert!(!catalog_has_xmp(doc) && !page_has_xmp(doc), "both XMP streams go"); +} + +/// Control for the tests below: removing a short word that the metadata doesn't mention keeps +/// all of it. +#[test] +fn metadata_that_does_not_mention_the_removed_text_stays() { + let mut doc = page_with_metadata(b"BT /F1 10 Tf 10 100 Td (Bluefin) Tj ET", clean_xmp()); + let r = redact_line(&mut doc); + assert_eq!(r.metadata, 0, "{r:?}"); + assert_eq!(info_keys(&doc), ["Title", "Producer", "Trapped"]); + assert!(catalog_has_xmp(&doc) && page_has_xmp(&doc)); +} + +/// A single removed run longer than the collection keeps (64 KiB): the part past the limit is +/// never seen. The run is one letter, 70,000 spaces (one space once normalized) and another +/// letter, so what would be collected without the limit is short enough to search, and only +/// the limit itself clears the metadata. +#[test] +fn a_removed_run_over_64_kib_clears_the_metadata() { + // At 0.004 pt each glyph advances 0.002 pt, so all 70,002 fit in 141 pt of one line. + let text = format!("A{}B", " ".repeat(70_000)); + let content = format!("BT /F1 0.004 Tf 10 100 Td ({text}) Tj ET"); + let mut doc = page_with_metadata(content.as_bytes(), clean_xmp()); + let r = redact_line(&mut doc); + assert_eq!(r.glyphs, 70_002, "{r:?}"); + assert_cleared(&doc, &r); +} + +/// More than 20,000 distinct terms: each removed run is one word, far enough from the last to +/// start a run of its own, and each word is different. +#[test] +fn more_than_20000_terms_clear_the_metadata() { + let mut content = b"BT /F1 10 Tf 10 100 Td".to_vec(); + // Words alternate between x 10 and x 70; each is 30 pt wide, so the gap between two is + // 30 pt, more than a run spans. + for i in 0..20_001u32 { + let x = if i % 2 == 0 { 60 } else { -60 }; + content.extend_from_slice(format!(" (q{i:05}) Tj {x} 0 Td").as_bytes()); + } + content.extend_from_slice(b" ET"); + let mut doc = page_with_metadata(&content, clean_xmp()); + let r = redact_line(&mut doc); + assert_eq!(r.glyphs, 20_001 * 6, "{r:?}"); + assert_cleared(&doc, &r); +} + +/// A removed run longer than 4,096 characters can't be searched for whole. +#[test] +fn a_removed_run_over_4096_characters_clears_the_metadata() { + // 4,100 letters at 0.05 pt advance 0.025 pt each: 103 pt in all, one run, 4 KiB. + let content = format!("BT /F1 0.05 Tf 10 100 Td ({}) Tj ET", "Z".repeat(4_100)); + let mut doc = page_with_metadata(content.as_bytes(), clean_xmp()); + let r = redact_line(&mut doc); + assert_eq!(r.glyphs, 4_100, "{r:?}"); + assert_cleared(&doc, &r); +} + +/// The catalog's packet can't be read: that packet goes, unread, while the page's packet and +/// `/Info`, which don't mention the removed word, stay. +fn assert_only_the_catalog_packet_went(catalog_xmp: Vec) { + let mut doc = page_with_metadata(b"BT /F1 10 Tf 10 100 Td (Bluefin) Tj ET", catalog_xmp); + let r = redact_line(&mut doc); + assert_eq!(r.metadata, 1, "{r:?}"); + assert!(!catalog_has_xmp(&doc), "the packet that couldn't be read goes"); + assert!(page_has_xmp(&doc), "the readable packet is checked, and stays"); + assert_eq!(info_keys(&doc), ["Title", "Producer", "Trapped"]); +} + +/// XMP whose Flate data is corrupt can't be decoded, so it can't be searched. +#[test] +fn an_xmp_stream_whose_filter_data_is_corrupt_goes() { + assert_only_the_catalog_packet_went(stream("/Type /Metadata /Subtype /XML /Filter /FlateDecode", b"this is not deflate data at all")); +} + +/// A valid Flate stream cut off half way decodes, leniently, to the first part of the packet; +/// the rest was never seen, so the packet isn't searched but goes. +#[test] +fn an_xmp_stream_whose_filter_data_is_cut_short_goes() { + let full = Stream::flate(Dict::new(), CLEAN_XMP.as_bytes()); + let cut = full.raw.get(..full.raw.len() / 2).unwrap(); + assert_only_the_catalog_packet_went(stream("/Type /Metadata /Subtype /XML /Filter /FlateDecode", cut)); +} + +/// XMP that decodes to more than 16 MiB isn't read at all. +#[test] +fn an_xmp_stream_over_16_mib_goes() { + // A valid packet, padded with whitespace past the limit. + let padded = CLEAN_XMP.replace("", &format!("{}", " ".repeat(16 * 1024 * 1024))); + assert_only_the_catalog_packet_went(stream("/Type /Metadata /Subtype /XML", padded.as_bytes())); +} + +/// XMP that is neither UTF-8 nor UTF-16 (here Latin-1) isn't searched lossily. +#[test] +fn an_xmp_stream_in_another_encoding_goes() { + let latin1 = CLEAN_XMP.replace("Annual figures", "Ann\u{e9}e").replace('\u{e9}', "\u{0}"); + let bytes: Vec = latin1.bytes().map(|b| if b == 0 { 0xE9 } else { b }).collect(); + assert!(std::str::from_utf8(&bytes).is_err()); + assert_only_the_catalog_packet_went(stream("/Type /Metadata /Subtype /XML", &bytes)); +} + +/// XMP behind two filters has no strict decoder here, so it isn't read either. The first filter +/// alone decodes to valid ASCII (the packet in hex), which must not be searched as the packet. +#[test] +fn an_xmp_stream_behind_two_filters_goes() { + let hex: String = CLEAN_XMP.bytes().map(|b| format!("{b:02x}")).chain(std::iter::once(">".to_string())).collect(); + let Stream { raw, .. } = Stream::flate(Dict::new(), hex.as_bytes()); + assert_only_the_catalog_packet_went(stream("/Type /Metadata /Subtype /XML /Filter [/FlateDecode /ASCIIHexDecode]", &raw)); +} From 81d629d1ff41943031db1c0270c6426d2c6741a4 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:51:37 +0800 Subject: [PATCH 051/101] M8: applying redactions counts the bookmarks, attachments, comments and XMP that still hold the removed text (#2448) Report::still_holding (Elsewhere) counts the places apply leaves as they are that still hold the removed text: bookmark titles, attachment names, file names and descriptions, the text and authors of the comments that remain, and XMP on objects other than the catalog and the pages. Nothing is changed; Remove hidden information removes them. The text is matched as the metadata check matches it (one Search type now serves both), with its own work limit. When the removed text couldn't be searched for, every place holding any text counts and `unsearched` says so. The walks read each outline item and name-tree node once, within caps. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/redact/README.md | 16 +- crates/redact/src/elsewhere.rs | 219 +++++++++++++++++++++++++++ crates/redact/src/lib.rs | 11 ++ crates/redact/src/metadata.rs | 113 ++++++++++---- crates/redact/src/sanitize.rs | 41 +++++ crates/redact/src/tests.rs | 1 + crates/redact/src/tests/elsewhere.rs | 113 ++++++++++++++ 7 files changed, 484 insertions(+), 30 deletions(-) create mode 100644 crates/redact/src/elsewhere.rs create mode 100644 crates/redact/src/tests/elsewhere.rs diff --git a/crates/redact/README.md b/crates/redact/README.md index ec73af0ae3..0bb6241715 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -50,9 +50,19 @@ clear_marks(&mut doc, None)?; // remove marks without app too. An XMP packet that isn't valid UTF-8 or UTF-16, can't be decoded strictly (damaged or cut-short filter data, or more than one filter), or decodes to more than 16 MiB goes without being read. - - **Not scrubbed** (Sanitize's categories cover them): XMP streams on anything but the - catalog and the pages (images, fonts, form XObjects, annotations and other objects), - bookmark titles, and attachments (embedded files, their names and descriptions). + - **Not scrubbed, but reported** (Remove hidden information's categories remove them): bookmark + titles, attachments (names in the document's list of embedded files, file names and + descriptions, and the files of file attachment comments), the comments that remain (their + text, rich text, subject and author; not form fields or pop-ups), and XMP streams on anything + but the catalog and the pages (images, fonts, form XObjects, annotations and other objects). + `apply` changes none of them, but counts those that still hold the removed text, matched as + above, in `Report::still_holding` (`Elsewhere`), so the interface and the `redact_apply` tool + can say where it is; this search has a work limit of its own, past which every place counts. + When the removed text couldn't be searched for (the cases above in which every `/Info` + string goes), every one of them that holds any text is counted and `unsearched` is set; an XMP + stream or rich text stream that can't be read counts as holding it. The walks are bounded: at + most 100,000 bookmarks and 100,000 name-tree nodes (32 deep) are read, each once, and a walk + cut short counts one more place. A verification pass re-reads each redacted page and fails the operation if any glyph or inline image is still under an area. It checks every content stream the page had, whatever its diff --git a/crates/redact/src/elsewhere.rs b/crates/redact/src/elsewhere.rs new file mode 100644 index 0000000000..901775ac1b --- /dev/null +++ b/crates/redact/src/elsewhere.rs @@ -0,0 +1,219 @@ +//! Places [`crate::apply`] leaves as they are that may still hold the removed text (#2448 +//! review): bookmark titles, attachments, the comments that remain, and XMP on objects other than +//! the catalog and the pages. Removing them is Remove hidden information's job (its bookmarks, +//! attachments, comments and metadata categories); applying redactions only counts them, so the +//! user can be told. Nothing here changes the document. +//! +//! The text is matched as the metadata check matches it ([`crate::metadata::Search`]), with a +//! work limit of its own. Every walk is bounded: outline items and name-tree nodes are each read +//! once (a seen-set for indirect ones) up to a cap, and the XMP walk is Remove hidden +//! information's own. + +use std::collections::HashSet; + +use pdfcraft_cos::{Document, ObjRef, Object}; + +use crate::metadata::Search; + +/// Places that may still hold the removed text after [`crate::apply`], which it doesn't change. +/// Remove hidden information removes them. Each count is of places, not of occurrences. +/// +/// When the removed text couldn't be searched for (`unsearched`: the text of a removed glyph +/// isn't known, a form was removed unread, or there was too much removed text to collect or +/// search for, as for the metadata check), every place holding any text is counted, since any +/// of them may hold it. A count may also include places that couldn't be read or that lie past +/// a walk's cap (one for each walk cut short), which count as holding it, and, once the search's +/// work limit runs out, every place not yet checked that holds any text. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Elsewhere { + /// Bookmarks (outline items) whose title holds it. + pub bookmarks: usize, + /// Embedded files whose name in the document's list of attachments (`/EmbeddedFiles`), + /// file name (`/F`, `/UF`) or description (`/Desc`) holds it, and file attachment + /// annotations whose file's name or description does. + pub attachments: usize, + /// Annotations left on the pages (not form field widgets or pop-ups) whose text + /// (`/Contents`, rich text `/RC`, subject `/Subj`) or author (`/T`) holds it. + pub comments: usize, + /// XMP streams on objects other than the catalog and the pages (images, fonts, form + /// XObjects, annotations…) whose text holds it, or that can't be read. + pub xmp: usize, + /// The removed text couldn't be searched for, so the counts are of every place that holds + /// any text. + pub unsearched: bool, +} + +impl Elsewhere { + /// All the places counted. + pub fn total(&self) -> usize { + self.bookmarks.saturating_add(self.attachments).saturating_add(self.comments).saturating_add(self.xmp) + } +} + +/// The most outline items read. +const MAX_OUTLINE_ITEMS: usize = 100_000; +/// The most name-tree nodes read, and how deep the tree is followed. +const MAX_NAME_TREE_NODES: usize = 100_000; +const MAX_NAME_TREE_DEPTH: usize = 32; + +/// Count the places that hold the `removed` text (see [`Elsewhere`]). `complete` is false when +/// the collection of removed text was cut short. +pub(crate) fn find(doc: &Document, removed: &[String], complete: bool) -> Elsewhere { + let search = Search::of(removed, complete); + if search.is_nothing() { + return Elsewhere::default(); + } + // A blank value holds nothing, even when everything counts. + let hit = + |o: Option<&Object>| o.and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).is_some_and(|t| !t.trim().is_empty() && search.text(&t)); + Elsewhere { + bookmarks: bookmarks(doc, &hit), + attachments: attachments(doc, &hit), + comments: comments(doc, &search, &hit), + xmp: xmp(doc, &search), + unsearched: search.is_everything(), + } +} + +/// Outline items whose `/Title` holds the text. +fn bookmarks(doc: &Document, hit: &dyn Fn(Option<&Object>) -> bool) -> usize { + let Some(root) = doc.root() else { return 0 }; + let Some(first) = + doc.get(root).as_dict().and_then(|c| c.get(b"Outlines").cloned()).and_then(|o| doc.resolve(&o).as_dict()?.get(b"First").cloned()) + else { + return 0; + }; + let (mut n, mut read) = (0usize, 0usize); + let mut seen = HashSet::new(); + let mut next = vec![first]; + while let Some(o) = next.pop() { + if let Object::Ref(r) = &o + && !seen.insert(*r) + { + continue; + } + let Some(item) = doc.resolve(&o).as_dict().cloned() else { continue }; + if read >= MAX_OUTLINE_ITEMS { + // Items past the cap aren't read: count one for them. + return n.saturating_add(1); + } + read += 1; + n += usize::from(hit(item.get(b"Title"))); + next.extend([b"Next".as_slice(), b"First"].into_iter().filter_map(|k| item.get(k).cloned())); + } + n +} + +/// Embedded files in the `/EmbeddedFiles` name tree whose name, file name or description holds +/// the text, and file attachment annotations whose file's do. +fn attachments(doc: &Document, hit: &dyn Fn(Option<&Object>) -> bool) -> usize { + let file = |spec: Option<&Object>| { + spec.and_then(|s| doc.resolve(s).as_dict().cloned()).is_some_and(|d| [b"F".as_slice(), b"UF", b"Desc"].iter().any(|k| hit(d.get(k)))) + }; + let mut n = 0usize; + let tree = doc + .root() + .and_then(|r| doc.get(r).as_dict()?.get(b"Names").cloned()) + .and_then(|names| doc.resolve(&names).as_dict()?.get(b"EmbeddedFiles").cloned()); + if let Some(tree) = tree { + let mut seen = HashSet::new(); + let mut next = vec![(tree, 0usize)]; + let mut read = 0usize; + while let Some((o, depth)) = next.pop() { + if let Object::Ref(r) = &o + && !seen.insert(*r) + { + continue; + } + let Some(node) = doc.resolve(&o).as_dict().cloned() else { continue }; + if read >= MAX_NAME_TREE_NODES || depth > MAX_NAME_TREE_DEPTH { + // A node past the cap isn't read: count one for what it holds. + n = n.saturating_add(1); + break; + } + read += 1; + if let Some(leaves) = node.get(b"Names").map(|l| doc.resolve(l)).and_then(|l| l.as_array().cloned()) { + for pair in leaves.chunks(2) { + n += usize::from(hit(pair.first()) || file(pair.get(1))); + } + } + if let Some(kids) = node.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { + next.extend(kids.into_iter().map(|k| (k, depth.saturating_add(1)))); + } + } + } + for page in pdfcraft_model::pages(doc) { + for a in crate::annots_of(doc, &page.dict) { + let Some(a) = doc.resolve(&a).as_dict().cloned() else { continue }; + if a.name(b"Subtype") == Some(b"FileAttachment".as_slice()) { + n += usize::from(file(a.get(b"FS"))); + } + } + } + n +} + +/// Annotations on the pages, other than form field widgets and pop-ups (which show their +/// parent's text), whose text or author holds it. +fn comments(doc: &Document, search: &Search, hit: &dyn Fn(Option<&Object>) -> bool) -> usize { + let mut seen = HashSet::new(); + let mut n = 0usize; + for page in pdfcraft_model::pages(doc) { + for a in crate::annots_of(doc, &page.dict) { + if let Some(r) = a.as_ref() + && !seen.insert(r) + { + continue; + } + let Some(a) = doc.resolve(&a).as_dict().cloned() else { continue }; + if matches!(a.name(b"Subtype"), Some(b"Widget" | b"Popup")) { + continue; + } + // Rich text is XHTML: its text is searched, not its markup. A rich text stream that + // can't be read counts as holding it. + let rich = a.get(b"RC").is_some_and(|o| rich_text(doc, o).is_none_or(|t| !t.trim().is_empty() && search.text(&t))); + n += usize::from(rich || [b"Contents".as_slice(), b"T", b"Subj"].iter().any(|k| hit(a.get(k)))); + } + } + n +} + +/// The text of a rich text string or stream (`/RC`), markup left out; `None` when a stream +/// can't be read (strictly, as UTF-8, within the XMP limit). +fn rich_text(doc: &Document, o: &Object) -> Option { + let xml = match &*doc.resolve(o) { + Object::String(s) => s.to_text(), + Object::Stream(s) if s.filters().len() <= 1 => String::from_utf8(s.decoded_first_filter_strict(crate::metadata::MAX_XMP_BYTES).ok()?).ok()?, + Object::Stream(_) => return None, + _ => return Some(String::new()), + }; + Some(crate::metadata::xml_values(&xml).join(" ")) +} + +/// XMP streams on anything reachable from the catalog other than the catalog's and the pages' +/// own, each counted once, that hold the text or can't be read. +fn xmp(doc: &Document, search: &Search) -> usize { + let scrubbed: HashSet = crate::metadata::catalog_and_pages(doc).into_iter().collect(); + let Ok(values) = crate::sanitize::owned_values(doc, b"Metadata") else { + // A graph too deep to walk: some XMP may be there, unread. + return 1; + }; + let mut seen = HashSet::new(); + let mut n = 0usize; + for (holder, own, v) in values { + if own && scrubbed.contains(&holder) { + continue; + } + if let Some(r) = v.as_ref() + && !seen.insert(r) + { + continue; + } + // An entry that isn't a stream holds no XMP. + if !matches!(&*doc.resolve(&v), Object::Stream(_)) { + continue; + } + n += usize::from(search.xmp(doc, &v)); + } + n +} diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 9bc3672027..4d6bd676e0 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -12,6 +12,8 @@ //! A verification pass then re-reads every redacted page; if any glyph or inline image is still //! under a region the whole operation fails (callers keep the previous document: fail-closed). //! Finally, document metadata (`/Info` entries, XMP) that repeats the removed text is removed. +//! Places it leaves as they are (bookmark titles, attachments, remaining comments, other XMP) +//! are only counted when they still hold that text ([`Report::still_holding`]). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -20,6 +22,7 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; pub mod codes; +mod elsewhere; mod image; mod interp; mod metadata; @@ -32,6 +35,7 @@ mod tests; pub(crate) mod type3; pub(crate) mod type3_tables; +pub use elsewhere::Elsewhere; use interp::{Mode, Scope, process}; pub type Rgb = [f64; 3]; @@ -153,6 +157,11 @@ pub struct Report { pub overlays: Vec, /// Document information entries and XMP streams removed because they held redacted text. pub metadata: usize, + /// Places this apply leaves as they are that still hold the removed text (bookmark titles, + /// attachments, the comments that remain, XMP on other objects), counted so the user can be + /// told; Remove hidden information removes them. See [`Elsewhere`] for when the counts are + /// of every place holding any text. + pub still_holding: Elsewhere, } fn rect_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { @@ -536,6 +545,8 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result Option> { decode_xmp(&bytes).map(|t| xml_values(&t)) } +/// What the removed text is looked for with, and the searching left to do it. +pub(crate) struct Search { + lookup: Lookup, + work: Work, +} + +enum Lookup { + /// Nothing with text was removed: nothing can hold it. + Nothing, + /// The removed text couldn't be collected or searched for in full: everything counts as + /// holding it (fail-closed). + Everything, + /// The terms ([`terms`]) and the distinct runs, normalized. + Terms(Vec, Vec), +} + +impl Search { + /// The search for `removed` text, with `limit` steps of searching ([`MAX_WORK`] outside + /// tests). `complete` is false when the collection of removed text was cut short; then, as + /// when the terms exceed their limits, everything matches. + pub(crate) fn new(removed: &[String], complete: bool, limit: usize) -> Self { + let lookup = match terms(removed).filter(|_| complete) { + Some(t) if t.is_empty() => Lookup::Nothing, + Some(t) => { + // The same run removed many times is searched for once. + let mut seen = HashSet::new(); + let runs: Vec = removed.iter().map(|r| normalize(r)).filter(|r| seen.insert(r.clone())).collect(); + Lookup::Terms(t, runs) + } + None => Lookup::Everything, + }; + Self { lookup, work: Work::new(limit) } + } + + /// [`Search::new`] with the whole work limit. + pub(crate) fn of(removed: &[String], complete: bool) -> Self { + Self::new(removed, complete, MAX_WORK) + } + + /// Nothing with text was removed. + pub(crate) fn is_nothing(&self) -> bool { + matches!(self.lookup, Lookup::Nothing) + } + + /// The removed text couldn't be searched for: everything matches. + pub(crate) fn is_everything(&self) -> bool { + matches!(self.lookup, Lookup::Everything) + } + + /// Does `text` hold the removed text: a term, or wholly part of a removed run? Past the + /// work limit, yes (fail-closed). + pub(crate) fn text(&self, text: &str) -> bool { + match &self.lookup { + Lookup::Nothing => false, + Lookup::Everything => true, + Lookup::Terms(terms, runs) => mentions(text, terms, &self.work) || within(text, runs, &self.work), + } + } + + /// Does the XMP stream `o` hold the removed text? A stream that can't be read does + /// (fail-closed), unless nothing with text was removed. + pub(crate) fn xmp(&self, doc: &Document, o: &Object) -> bool { + match &self.lookup { + Lookup::Nothing => false, + Lookup::Everything => true, + Lookup::Terms(terms, runs) => xmp_values(doc, o) + .is_none_or(|values| mentions(&values.join(" "), terms, &self.work) || values.iter().any(|v| within(v, runs, &self.work))), + } + } +} + /// Remove the metadata that still holds `removed` text: matching `/Info` entries, and the /// catalog's and pages' XMP streams that mention it. `complete` is false when the collection of /// removed text was cut short; then, as when the terms themselves exceed their limits, every @@ -295,26 +366,17 @@ pub(crate) fn scrub(doc: &mut Document, removed: &[String], complete: bool) -> R /// [`scrub`] with the searching it may do given, so tests can run out of it. pub(crate) fn scrub_within(doc: &mut Document, removed: &[String], complete: bool, limit: usize) -> Result { - // `None` matches everything (fail-closed). - let search = match terms(removed).filter(|_| complete) { - Some(t) if t.is_empty() => return Ok(0), - Some(t) => { - // The same run removed many times is searched for once. - let mut seen = HashSet::new(); - let runs: Vec = removed.iter().map(|r| normalize(r)).filter(|r| seen.insert(r.clone())).collect(); - Some((t, runs)) - } - None => None, - }; - let work = Work::new(limit); - let hit = |text: &str| search.as_ref().is_none_or(|(terms, runs)| mentions(text, terms, &work) || within(text, runs, &work)); + let search = Search::new(removed, complete, limit); + if search.is_nothing() { + return Ok(0); + } let mut n = 0; // The information dictionary, direct in the trailer or an object of its own. let info_obj = doc.trailer().get(b"Info").cloned(); if let Some(info) = info_obj.as_ref().and_then(|i| doc.resolve(i).as_dict().cloned()) { let doomed: Vec> = - info.iter().filter(|(_, v)| doc.resolve(v).as_string().is_some_and(|s| hit(&s.to_text()))).map(|(k, _)| k.to_vec()).collect(); + info.iter().filter(|(_, v)| doc.resolve(v).as_string().is_some_and(|s| search.text(&s.to_text()))).map(|(k, _)| k.to_vec()).collect(); if !doomed.is_empty() { n += doomed.len(); let mut kept = info.clone(); @@ -329,19 +391,9 @@ pub(crate) fn scrub_within(doc: &mut Document, removed: &[String], complete: boo } // XMP on the catalog and on every page. - let mut holders: Vec = doc.root().into_iter().collect(); - holders.extend(pdfcraft_model::pages(doc).iter().map(|p| p.obj)); - for holder in holders { + for holder in catalog_and_pages(doc) { let Some(xmp) = doc.get(holder).as_dict().and_then(|d| d.get(b"Metadata").cloned()) else { continue }; - let doomed = match xmp_values(doc, &xmp) { - None => true, - Some(values) => { - let words = |(terms, _): &(Vec, Vec)| mentions(&values.join(" "), terms, &work); - let part = |(_, runs): &(Vec, Vec)| values.iter().any(|v| within(v, runs, &work)); - search.as_ref().is_none_or(|s| words(s) || part(s)) - } - }; - if doomed { + if search.xmp(doc, &xmp) { doc.update_dict(holder, |d| { d.remove(b"Metadata"); })?; @@ -350,3 +402,10 @@ pub(crate) fn scrub_within(doc: &mut Document, removed: &[String], complete: boo } Ok(n) } + +/// The catalog and the pages: the objects whose XMP [`scrub`] checks. +pub(crate) fn catalog_and_pages(doc: &Document) -> Vec { + let mut holders: Vec = doc.root().into_iter().collect(); + holders.extend(pdfcraft_model::pages(doc).iter().map(|p| p.obj)); + holders +} diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 98703dfbf2..b6cf65a1c8 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -318,6 +318,47 @@ fn strip_owned(o: &mut Object, role: Held, keys: &[&[u8]], depth: usize) { } } +/// Every value of `key` held outside a name map, as [`owners`] finds them: with the indirect +/// object that holds it and whether it is that object's own entry (rather than one in a +/// dictionary written inside it). Fails as [`owners`] does for a graph nested too deeply. +pub(crate) fn owned_values(doc: &Document, key: &[u8]) -> Result, RedactError> { + let (_, holders) = owners(doc, &[key])?; + let mut out = Vec::new(); + for (r, role) in holders { + collect_owned(&doc.get(r), role, key, 0, &mut |own, v| out.push((r, own, v.clone()))); + } + Ok(out) +} + +/// Calls `found` with each value of `key` in `o` and what it holds in place, outside name maps +/// (the reading counterpart of [`strip_owned`]), and whether it is `o`'s own entry. +fn collect_owned(o: &Object, role: Held, key: &[u8], depth: usize, found: &mut dyn FnMut(bool, &Object)) { + if depth > MAX_DIRECT_DEPTH { + return; + } + let (d, role) = match o { + Object::Array(a) => { + for v in a { + collect_owned(v, Held::Owner, key, depth + 1, found); + } + return; + } + Object::Dict(d) => (d, role), + Object::Stream(s) => (&s.dict, Held::Owner), + _ => return, + }; + if role != Held::Map + && let Some(v) = d.get(key) + { + found(depth == 0, v); + } + for (k, v) in d.iter() { + if matches!(v, Object::Array(_) | Object::Dict(_)) { + collect_owned(v, role.child(k), key, depth + 1, found); + } + } +} + /// A comment as the comments list sees it, less file attachments (removed with Attachments) and /// redaction marks. fn is_comment(subtype: &[u8]) -> bool { diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index b810043279..49c74e64bf 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -3038,6 +3038,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { } mod descriptions; +mod elsewhere; mod inline_images; mod limits; mod metadata_fail_closed; diff --git a/crates/redact/src/tests/elsewhere.rs b/crates/redact/src/tests/elsewhere.rs new file mode 100644 index 0000000000..7379af19cc --- /dev/null +++ b/crates/redact/src/tests/elsewhere.rs @@ -0,0 +1,113 @@ +//! #2539 review: what applying leaves as it is but still holds the removed text is counted in +//! [`Report::still_holding`], and nothing of it is changed. + +use super::*; + +const XMP_BLUEFIN: &str = r#"Photo for Bluefin"#; +const XMP_OTHER: &str = r#"Annual figures"#; + +/// The objects whose text the check reads, by number. +const HELD: [u32; 20] = [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26]; + +/// A page showing "Codename BLUEFIN report" (BLUEFIN at x 55–90) with `font`, and the word, or +/// other text, in a bookmark, attachments, comments and XMP the mark doesn't touch: +/// - bookmarks: "Bluefin plans", "Annual figures", and under it "BLUEFIN appendix"; +/// - attachments: one named `bluefin.txt`, one described "About Bluefin", one with neither, and +/// a file attachment annotation for `Bluefin.pdf`; +/// - comments: one whose text mentions it, one by "Bluefin Team", one whose rich text does, one +/// that doesn't, and a pop-up repeating it (pop-ups aren't counted); +/// - XMP: an image's mentions it, a font's doesn't. +fn page_with_places(font: &str) -> Document { + let annot = |rest: &str| format!("<< /Type /Annot /Rect [200 200 220 220] {rest} >>").into_bytes(); + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Outlines 7 0 R /Names << /EmbeddedFiles 11 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 >> /XObject << /Im 24 0 R >> >> /Annots [16 0 R 17 0 R 18 0 R 19 0 R 20 0 R 21 0 R] >>".to_vec(), + stream("", b"BT /F1 10 Tf 10 100 Td (Codename BLUEFIN report) Tj ET q 10 0 0 10 200 250 cm /Im Do Q"), + format!("{} /Metadata 26 0 R >>", font.replace("95 0 R", "6 0 R").strip_suffix(">>").unwrap()).into_bytes(), + widths(), + // 7–10: the outline. + b"<< /Type /Outlines /First 8 0 R /Last 9 0 R /Count 3 >>".to_vec(), + b"<< /Title (Bluefin plans) /Parent 7 0 R /Next 9 0 R >>".to_vec(), + b"<< /Title (Annual figures) /Parent 7 0 R /Prev 8 0 R /First 10 0 R /Last 10 0 R /Count 1 >>".to_vec(), + b"<< /Title (BLUEFIN appendix) /Parent 9 0 R >>".to_vec(), + // 11–15: the attachments' name tree and file specifications. + b"<< /Kids [12 0 R] >>".to_vec(), + b"<< /Limits [(a.txt) (bluefin.txt)] /Names [(a.txt) 13 0 R (b.txt) 14 0 R (bluefin.txt) 15 0 R] >>".to_vec(), + b"<< /Type /Filespec /F (a.txt) /UF (a.txt) >>".to_vec(), + b"<< /Type /Filespec /F (b.txt) /Desc (About Bluefin) >>".to_vec(), + b"<< /Type /Filespec /F (c.txt) >>".to_vec(), + // 16–21: the annotations. + annot("/Subtype /Text /Contents (Ask about bluefin) /T (Ada)"), + annot("/Subtype /Text /Contents (Seen) /T (Bluefin Team)"), + annot("/Subtype /FreeText /Contents (plain) /RC (

BLUEFIN

) /DA (/F1 10 Tf)"), + annot("/Subtype /Text /Contents (Looks good) /T (Ada) /Popup 21 0 R"), + annot("/Subtype /FileAttachment /Contents (Attached) /T (Ada) /FS << /Type /Filespec /UF (Bluefin.pdf) >>"), + annot("/Subtype /Popup /Parent 19 0 R /Contents (bluefin)"), + // 22–23: unused. + b"null".to_vec(), + b"null".to_vec(), + // 24–26: an image with XMP that mentions it, and the two packets. + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Metadata 25 0 R", b"\x80"), + stream("/Type /Metadata /Subtype /XML", XMP_BLUEFIN.as_bytes()), + stream("/Type /Metadata /Subtype /XML", XMP_OTHER.as_bytes()), + ]; + pdf(objs) +} + +fn held(doc: &Document) -> Vec { + HELD.iter().map(|&n| (*doc.get(ObjRef::new(n, 0))).clone()).collect() +} + +#[test] +fn places_apply_leaves_that_still_hold_the_removed_text_are_counted_and_kept() { + let mut doc = page_with_places(FONT); + let before = held(&doc); + mark(&mut doc, 0, &[[55.0, 95.0, 90.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.glyphs, 7, "{r:?}"); + assert_eq!(r.still_holding, Elsewhere { bookmarks: 2, attachments: 3, comments: 3, xmp: 1, unsearched: false }, "{r:?}"); + assert_eq!(r.still_holding.total(), 9); + assert_eq!(held(&doc), before, "counting changes nothing"); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + assert!(catalog.contains(b"Outlines") && catalog.contains(b"Names")); + assert_eq!(annots_of(&doc, &pdfcraft_model::pages(&doc)[0].dict).len(), 6, "the comments stay"); +} + +/// Removed text that is unrelated to every place counts nothing. +#[test] +fn places_without_the_removed_text_are_not_counted() { + let mut doc = page_with_places(FONT); + // "Codename" alone, at x 10–50. + mark(&mut doc, 0, &[[8.0, 95.0, 50.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.glyphs, 8, "{r:?}"); + assert_eq!(r.still_holding, Elsewhere::default(), "{r:?}"); +} + +/// When the removed text can't be known (a removed glyph whose text isn't known), every place +/// that holds any text counts, and says why. +#[test] +fn places_count_whenever_they_hold_text_if_the_removed_text_is_unknown() { + let unnamed = FONT.replace(" /Widths", " /Encoding << /Differences [66 /g17 /g18 /g19 /g20 /g21 /g22 /g23 /g24 /g25 /g26 /g27 /g28 /g29 /g30 /g31 /g32 /g33 /g34 /g35 /g36 /g37 /g38 /g39 /g40 /g41 /g42] >> /Widths"); + let mut doc = page_with_places(&unnamed); + let before = held(&doc); + mark(&mut doc, 0, &[[55.0, 95.0, 90.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + // Every bookmark title, every attachment (each has a name), every comment but the pop-up, + // and both XMP packets (the font's too). + assert_eq!(r.still_holding, Elsewhere { bookmarks: 3, attachments: 4, comments: 5, xmp: 2, unsearched: true }, "{r:?}"); + assert_eq!(held(&doc), before); +} + +/// A bookmark list that loops back on itself is walked once. +#[test] +fn a_looping_outline_is_read_once() { + let mut doc = page_with_places(FONT); + // The last item's /Next goes back to the first. + doc.update_dict(ObjRef::new(9, 0), |d| d.set(b"Next".to_vec(), Object::Ref(ObjRef::new(8, 0)))).unwrap(); + doc.update_dict(ObjRef::new(10, 0), |d| d.set(b"First".to_vec(), Object::Ref(ObjRef::new(7, 0)))).unwrap(); + mark(&mut doc, 0, &[[55.0, 95.0, 90.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.still_holding.bookmarks, 2, "{r:?}"); +} From 1fd2ff31bbd219cfa9e7dd16f4c899a942d098ea Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:09:13 +0800 Subject: [PATCH 052/101] M8: test that a form whose content can't be decoded clears the metadata (#2448) The unread-form branch was tested for a form nested past the depth limit only; a form under a mark whose filter can't be decoded is removed unread too, and now has an end-to-end test, seen failing with that branch's flag removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- .../redact/src/tests/metadata_fail_closed.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/redact/src/tests/metadata_fail_closed.rs b/crates/redact/src/tests/metadata_fail_closed.rs index 0a958ccd88..2735b302ba 100644 --- a/crates/redact/src/tests/metadata_fail_closed.rs +++ b/crates/redact/src/tests/metadata_fail_closed.rs @@ -155,3 +155,26 @@ fn an_xmp_stream_behind_two_filters_goes() { let Stream { raw, .. } = Stream::flate(Dict::new(), hex.as_bytes()); assert_only_the_catalog_packet_went(stream("/Type /Metadata /Subtype /XML /Filter [/FlateDecode /ASCIIHexDecode]", &raw)); } + +/// A form XObject under a mark whose content can't be decoded is removed without being read, so +/// what it showed is unknown (the deep-form case is `a_form_removed_unread_clears_the_metadata`). +#[test] +fn a_form_whose_content_cant_be_read_clears_the_metadata() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Metadata 7 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Fm 9 0 R >> >> /Metadata 8 0 R >>".to_vec(), + stream("", b"q 1 0 0 1 0 0 cm /Fm Do Q"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + clean_xmp(), + clean_xmp(), + // 9: a form whose data no filter here decodes. + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /NoSuchDecode", b"BT /F1 10 Tf 10 100 Td (Bluefin) Tj ET"), + b"<< /Title (Annual figures) /Producer (Test) /Trapped /False >>".to_vec(), + ]; + let mut doc = Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry("/Info 10 0 R").build())).unwrap(); + let r = redact_line(&mut doc); + assert_eq!(r.forms_removed, 1, "{r:?}"); + assert_cleared(&doc, &r); +} From d37bde8c3b43beda006a6909d90f4e8d68322905 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:31:52 +0800 Subject: [PATCH 053/101] M8: Apply redactions passes on where the removed text is still held; redact_apply returns it (#2448) The document keeps the last apply's Report::still_holding as redaction_elsewhere (re-exported as RedactionElsewhere), cleared by the next edit, undo or redo as edit_notices is. redact_apply returns it as still_holding with the four counts, their total and unsearched. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/automation/src/redact.rs | 10 ++++++++++ crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 25 ++++++++++++++++++++++++ crates/engine/src/document.rs | 5 +++++ crates/engine/src/edit/run.rs | 7 ++++++- crates/engine/src/lib.rs | 1 + crates/engine/src/open.rs | 1 + crates/engine/src/session/editing.rs | 4 ++++ crates/engine/src/tests.rs | 19 ++++++++++++++++++ 9 files changed, 72 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index e662bcac19..baea3d75c5 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -165,6 +165,16 @@ impl Automation { let after = self.doc(a)?.redaction_marks(); out["applied"] = json!(before - after); out["marks_pending"] = json!(after); + // Where the removed text is still held that applying leaves as it is. + let left = self.doc(a)?.redaction_elsewhere.unwrap_or_default(); + out["still_holding"] = json!({ + "bookmarks": left.bookmarks, + "attachments": left.attachments, + "comments": left.comments, + "xmp": left.xmp, + "total": left.total(), + "unsearched": left.unsearched, + }); Ok(out) } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 53c4582af9..2d4d597a75 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -544,7 +544,7 @@ pub fn tools() -> Vec { t( "redact_apply", "Apply redactions", - "Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Undoable until saved; the saved file no longer contains the content.", + "Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Document metadata (Info entries, the document's and pages' XMP) that repeats the removed text is removed too. still_holding counts the places applying leaves as they are that still hold the removed text: bookmarks (titles), attachments (names, file names, descriptions), comments (text, rich text, subject or author of those that remain) and xmp (XMP on images, fonts, forms, annotations and other objects), with their total; doc_remove_hidden removes them. unsearched: true means the removed text couldn't be searched for, so every such place holding any text is counted. Undoable until saved; the saved file no longer contains the content.", ) .destructive() .with(schema(json!({ "doc": doc(), "pages": pages("whose marks to apply (default: all)") }), &["doc"])), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 01d0d1eb24..412bc9f80e 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4289,6 +4289,31 @@ fn redacting_through_tools() { assert!(matches!(a.call("redact_mark", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_)))); } +/// #2539 review: `redact_apply` says where the removed text is still held that applying leaves as +/// it is (a bookmark title and a note's text here), and changes neither. +#[test] +fn redact_apply_reports_places_that_still_hold_the_removed_text() { + let dir = workdir("redact-still-holding"); + std::fs::write(dir.join("memo.txt"), "Codename Bluefin report\nPublic line").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "memo.txt" }))["doc"].as_u64().unwrap(); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Bluefin plans", "page": 1 })); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Annual figures", "page": 1 })); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [300, 400], "contents": "Ask about bluefin" })); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [300, 500], "contents": "Looks good" })); + ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "Bluefin" })); + let r = ok(&mut a, "redact_apply", json!({ "doc": doc })); + assert_eq!(r["still_holding"], json!({ "bookmarks": 1, "attachments": 0, "comments": 1, "xmp": 0, "total": 2, "unsearched": false }), "{r}"); + assert!(!page_text(&mut a, doc)[0].contains("Bluefin")); + let titles: Vec = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"] + .as_array() + .unwrap() + .iter() + .map(|b| b["title"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(titles, ["Bluefin plans", "Annual figures"], "reported, not changed"); +} + #[test] /// Mark, apply and save remove the complete international number while retaining labels and controls. fn redacting_italian_international_slash_numbers_through_tools() { diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index eaa8f70807..7ad7fffec9 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -62,6 +62,11 @@ pub struct Document { /// What the last successful [`Session::apply`](crate::Session::apply) wrote that its fonts can't draw: kept as typed, /// drawn with `?` in its place (see [`TextNotice`]). The interface shows it; tools return it. pub edit_notices: Vec, + /// What the last successful [`Session::apply`](crate::Session::apply), when it applied redactions, + /// found still holding the removed text in places applying leaves as they are (bookmark titles, + /// attachments, remaining comments, other XMP; see [`RedactionElsewhere`](crate::RedactionElsewhere)). + /// `None` after any other edit, an undo or a redo. The interface says so; tools return it. + pub redaction_elsewhere: Option, } /// Text an edit stored as typed but drew with `?` in place of `ch`, because the font it is drawn diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 32dc7942ab..af49c1db00 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -183,6 +183,8 @@ pub(crate) struct EditCtx { /// The overlay streams the edit's applied redactions added, which alone the renderer's check /// leaves out. pub(crate) redaction_overlays: Vec, + /// What the edit's applied redactions left as it was that still holds the removed text. + pub(crate) redaction_elsewhere: Option, } impl EditCtx { @@ -211,6 +213,7 @@ impl EditCtx { count: 0, renamed_fields: Vec::new(), redaction_overlays: Vec::new(), + redaction_elsewhere: None, } } @@ -648,7 +651,9 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E pdfcraft_edit::add_content(doc, *page, &content)?; } Edit::ApplyRedactions { pages } => { - cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays; + let report = pdfcraft_redact::apply(doc, pages.as_deref())?; + cx.redaction_overlays = report.overlays; + cx.redaction_elsewhere = Some(report.still_holding); } Edit::ClearRedactions => { pdfcraft_redact::clear_marks(doc, None)?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c41c0466c4..c168cd2bf9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -112,6 +112,7 @@ pub use pdfcraft_forms::detect; pub use pdfcraft_optimize as optimize; pub use pdfcraft_preflight as pdfa; pub use pdfcraft_print as print; +pub use pdfcraft_redact::Elsewhere as RedactionElsewhere; pub use pdfcraft_redact::codes::{CODE_SETS as REDACTION_CODE_SETS, CodeSet as RedactionCodeSet}; pub use pdfcraft_redact::patterns::{ MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list, diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs index 58bc8327e2..84c9472a20 100644 --- a/crates/engine/src/open.rs +++ b/crates/engine/src/open.rs @@ -352,6 +352,7 @@ impl Session { fonts_stale: false, xfa_warnings: Vec::new(), edit_notices: Vec::new(), + redaction_elsewhere: None, } } } diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index f8d5648a2d..675597a565 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -16,6 +16,7 @@ impl Session { let xfa_time_scale = self.xfa_time_scale.unwrap_or(1); let doc = self.doc_mut(id)?; doc.edit_notices.clear(); + doc.redaction_elsewhere = None; let name = doc.name.clone(); let is_xfa = doc.info.xfa.is_some(); let mut scope = scope_of(&edit); @@ -201,6 +202,7 @@ impl Session { doc.dirty = true; doc.generation += 1; doc.edit_notices = notices; + doc.redaction_elsewhere = cx.redaction_elsewhere; if !cx.xfa_removed.is_empty() { let removed: Vec = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect(); doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); @@ -246,6 +248,7 @@ impl Session { return Err(e); } doc.edit_notices.clear(); + doc.redaction_elsewhere = None; Self::sync_dirty(doc); doc.generation += 1; Ok(label) @@ -275,6 +278,7 @@ impl Session { return Err(e); } doc.edit_notices.clear(); + doc.redaction_elsewhere = None; Self::sync_dirty(doc); doc.generation += 1; Ok(label) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6a68916dff..16a612339c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2829,6 +2829,25 @@ fn text_after_an_inline_image_is_redacted_where_the_page_shows_it() { ); } +/// #2539 review: Apply redactions tells the caller where the removed text is still held that +/// it leaves as it is (here a bookmark title), and the next edit, or an undo, forgets it. +#[test] +fn applied_redactions_report_a_bookmark_that_still_holds_the_removed_text() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let page = one_line_page("/Font << /F1 4 0 R >>", "BT /F1 10 Tf 20 150 Td (Codename BLUEFIN report) Tj ET"); + let id = s.open("bookmark.pdf", None, Arc::new(page), None).unwrap(); + s.apply(id, Edit::AddBookmark { parent: vec![], index: 0, title: "Bluefin plans".into(), page: 0, left: None, top: None }).unwrap(); + s.apply(id, Edit::AddBookmark { parent: vec![], index: 1, title: "Annual figures".into(), page: 0, left: None, top: None }).unwrap(); + mark_shown_text(&mut s, id, "BLUEFIN"); + assert_eq!(s.get(id).unwrap().redaction_elsewhere, None, "only applying redactions reports it"); + s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); + let found = s.get(id).unwrap().redaction_elsewhere.expect("applying reports what it left"); + assert_eq!(found, crate::RedactionElsewhere { bookmarks: 1, ..Default::default() }); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Bluefin plans→1", "Annual figures→1"], "the bookmark is reported, not changed"); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().redaction_elsewhere, None, "an undo forgets it"); +} + /// A word whose font and size an ExtGState /Font entry (`gs`) sets is placed where the renderer /// draws it, so a mark drawn over it as displayed takes it out of the file. #[test] From 4dbc4fac696c9d9b27711eb989a25001b4e4c094 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:31:52 +0800 Subject: [PATCH 054/101] M8: the apply notification says where the removed text is still held (#2448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After "Applied N redaction marks…", the same notification names the bookmark titles, attachments, comments and other metadata streams that still hold the redacted text, and points to Remove hidden information. New English templates; untranslated text shows in English. The protect.apply-redactions parity note and evidence say what is reported and what is still left. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/dialogs.rs | 41 +++++++++++++++++++-- crates/ui-egui/tests/it/redact.rs | 60 +++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 3 files changed, 100 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e57299fdcb..046f6a87af 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1711,10 +1711,17 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) { Some(Dialog::RedactApply) => { let marks = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks()); if app.apply_edit(Edit::ApplyRedactions { pages: None }) { - if marks == 1 { - app.notify_tr("Applied 1 redaction mark. Save to remove the content from the file."); + let applied = if marks == 1 { + tl!("Applied 1 redaction mark. Save to remove the content from the file.").to_string() } else { - app.notify_fmt("Applied {n} redaction marks. Save to remove the content from the file.", &[("n", &marks.to_string())]); + crate::i18n::fmt(tl!("Applied {n} redaction marks. Save to remove the content from the file."), &[("n", &marks.to_string())]) + }; + // Where the removed text is still held that applying leaves as it is: said in the + // same notification, so it can't replace the first sentence. + let left = app.active_ids().and_then(|(_, id)| app.session.get(id)).and_then(|d| d.redaction_elsewhere); + match left.and_then(|e| still_holding_note(&e)) { + Some(note) => app.notify(format!("{applied} {note}")), + None => app.notify(applied), } } } @@ -1760,6 +1767,34 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) { } } +/// The sentence telling the user where the removed text is still held that applying redactions +/// leaves as it is, or `None` when it is nowhere else. When the text couldn't be searched for, +/// the places are those holding any text, and the sentence says they may hold it. +fn still_holding_note(e: &pdfcraft_engine::RedactionElsewhere) -> Option { + let count = |n: usize, one: &str, many: &str| match n { + 0 => None, + 1 => Some(tl!(one).to_string()), + n => Some(crate::i18n::fmt(tl!(many), &[("n", &n.to_string())])), + }; + let places: Vec = [ + count(e.bookmarks, "1 bookmark title", "{n} bookmark titles"), + count(e.attachments, "1 attachment", "{n} attachments"), + count(e.comments, "1 comment", "{n} comments"), + count(e.xmp, "1 other metadata stream", "{n} other metadata streams"), + ] + .into_iter() + .flatten() + .collect(); + let (last, rest) = places.split_last()?; + let list = if rest.is_empty() { last.clone() } else { crate::i18n::fmt(tl!("{list} and {last}"), &[("list", &rest.join(", ")), ("last", last)]) }; + let template = if e.unsearched { + "The redacted text couldn't be searched for, so {places} may still hold it. Remove hidden information removes them." + } else { + "The redacted text also appears in {places}, which redaction doesn't change. Remove hidden information removes them." + }; + Some(crate::i18n::fmt(tl!(template), &[("places", &list)])) +} + /// Edits built from the dialog's drafts. fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { if req.props_now diff --git a/crates/ui-egui/tests/it/redact.rs b/crates/ui-egui/tests/it/redact.rs index 3e11861cbc..f32341adf6 100644 --- a/crates/ui-egui/tests/it/redact.rs +++ b/crates/ui-egui/tests/it/redact.rs @@ -265,3 +265,63 @@ fn removing_hidden_information_and_sanitizing() { let s = h.state(); assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Sanitize document")); } + +/// A one-page PDF showing "Codename BLUEFIN report", with bookmarks "Bluefin plans" and +/// "Annual figures". +fn bookmarked_pdf() -> Vec { + let content = "BT /F1 18 Tf 20 150 Td (Codename BLUEFIN report) Tj ET"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /Outlines 6 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>".to_string(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Outlines /First 7 0 R /Last 8 0 R /Count 2 >>".to_string(), + "<< /Title (Bluefin plans) /Parent 6 0 R /Next 8 0 R /Dest [3 0 R /Fit] >>".to_string(), + "<< /Title (Annual figures) /Parent 6 0 R /Prev 7 0 R /Dest [3 0 R /Fit] >>".to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// #2539 review: applying redactions says when the removed text is still in a place it leaves +/// as it is (here a bookmark title), in the same notification as the count of marks applied. +#[test] +fn applying_says_where_the_removed_text_is_still_held() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("bluefin.pdf", None, bookmarked_pdf()).unwrap(); + app + }); + crate::support::settle(&mut h); + assert!(h.state_mut().execute("redact.mark")); + h.run_steps(2); + // Redact pages ▸ current page, then Redact all ▸ Apply. + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(marks(&h), 1); + h.get_by_label("Redact all").click(); + h.run_steps(3); + h.get_by_label("Apply").click(); + h.run_steps(4); + assert_eq!(marks(&h), 0); + let toast = h.state().toast.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); + assert_eq!( + toast, + "Applied 1 redaction mark. Save to remove the content from the file. The redacted text also appears in 1 bookmark title, which redaction doesn't change. Remove hidden information removes them." + ); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4fb222a7e0..cedd69b876 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5768,8 +5768,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::the_renderer_check_refuses_text_still_shown_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources", "crates/redact/src/tests.rs::redacted_text_does_not_survive_in_info_or_xmp", "crates/redact/src/tests.rs::metadata_is_cleared_when_the_removed_text_was_cut_short"] -notes = "confirmation; one undo step; next save is a full rewrite. Document information entries that contain the removed text, and the document's and pages' XMP streams that do, are removed (#2448; matched without regard to case or spacing, by the whole run of removed text and by its words of three letters or digits or more, and a value wholly part of a run as whole words; fail-closed when the removed text was too much to collect or the XMP can't be decoded); other objects' XMP, bookmarks and attachments are not searched. Refused, with nothing changed, when the page still shows text under a mark afterwards: text in some predefined CMaps and rotated Type 3 fonts is not removed yet, so those documents cannot be redacted. Also refused on a page with an inline image the renderer reads no image for, since the operators after it can't be known. Removed images and forms are unlinked from page-tree resources (own, inherited or shared) and from surviving forms' resources, not yet from annotation appearances or patterns" +evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::the_renderer_check_refuses_text_still_shown_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources", "crates/redact/src/tests.rs::redacted_text_does_not_survive_in_info_or_xmp", "crates/redact/src/tests.rs::metadata_is_cleared_when_the_removed_text_was_cut_short", "crates/redact/src/tests/metadata_fail_closed.rs::an_xmp_stream_whose_filter_data_is_corrupt_goes", "crates/redact/src/tests/elsewhere.rs::places_apply_leaves_that_still_hold_the_removed_text_are_counted_and_kept", "crates/engine/src/tests.rs::applied_redactions_report_a_bookmark_that_still_holds_the_removed_text", "crates/automation/tests/it/automation.rs::redact_apply_reports_places_that_still_hold_the_removed_text", "crates/ui-egui/tests/it/redact.rs::applying_says_where_the_removed_text_is_still_held"] +notes = "confirmation; one undo step; next save is a full rewrite. Document information entries that contain the removed text, and the document's and pages' XMP streams that do, are removed (#2448; matched without regard to case or spacing, by the whole run of removed text and by its words of three letters or digits or more, and a value wholly part of a run as whole words; fail-closed: all of it goes when the removed text can't be known in full (too much to collect or search for, a glyph whose text isn't known, a form removed unread), and an XMP packet goes when it can't be decoded strictly, is over 16 MiB or isn't UTF-8 or UTF-16). Bookmark titles, attachments (names, file names, descriptions), the comments that remain and other objects' XMP are not changed: applying counts those that still hold the removed text, and the notification and redact_apply's still_holding say where (Remove hidden information removes them); when the removed text couldn't be searched for, every such place holding any text is counted. Refused, with nothing changed, when the page still shows text under a mark afterwards: text in some predefined CMaps and rotated Type 3 fonts is not removed yet, so those documents cannot be redacted. Also refused on a page with an inline image the renderer reads no image for, since the operators after it can't be known. Removed images and forms are unlinked from page-tree resources (own, inherited or shared) and from surviving forms' resources, not yet from annotation appearances or patterns" [[feature]] id = "protect.redact-glyph-removal" From a20607da36917f29c6c33c47827cb5138aac09ec Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:44:11 +0800 Subject: [PATCH 055/101] M3: full screen takes no page input until the current page is on screen While a page's transition plays, or the page waits for its raster, the page under the pointer isn't the one shown, yet its links, fields, text and comments took clicks. Page input now waits until full screen shows the current page; turning pages (a click, a key) is handled apart and still works. A press made meanwhile gives nothing on the page an owner, so its release once the page is in takes nothing either. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/canvas.rs | 19 +++++-- crates/ui-egui/src/canvas/presenter.rs | 7 +++ crates/ui-egui/tests/it/view.rs | 68 ++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 61a266da88..a56ae244e2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1680,6 +1680,13 @@ fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageTex } } +/// Full screen is turning to the current page, or waiting for it to render: the page under the +/// pointer isn't the one on screen, so its links, fields, text and comments take no input. +/// Turning the page (a click, a key) is handled apart and still works. +fn veiled(view: &DocView) -> bool { + view.presentation.is_some() && !view.presenter.settled_on(view.current) +} + /// Everything drawn over the page after its text: pictures, comments, content, links, fields, marks /// and badges. fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { @@ -1713,8 +1720,9 @@ fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_ } crate::image_pick::drop_stale(doc, view); crate::image_pick::paint(painter, &xf, i, info, view); - // A cancelled press ends no selection: no markup or redaction is made from it. - if !view.press_cancelled { + // A cancelled press ends no selection: no markup or redaction is made from it, nor from a + // page full screen doesn't show yet. + if !view.press_cancelled && !veiled(view) { comments::page_after_text(resp, &pcx, view); if tool == QuickTool::Redact && can_modify { crate::redact_ui::after_text(resp, i, info, view); @@ -1885,8 +1893,11 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i } let slot = PageSlot { i, r, xf }; - // A cancelled press takes nothing until the button is up. - let input = if view.press_cancelled { PageInput { consumed: true, area_tool: false } } else { page_tool_input(ui, view, frame, &slot) }; + // A cancelled press takes nothing until the button is up, nor does a page full screen + // doesn't show yet. (A press there gives nothing on the page an owner, so its release, once + // the page is in, takes nothing either.) + let input = + if view.press_cancelled || veiled(view) { PageInput { consumed: true, area_tool: false } } else { page_tool_input(ui, view, frame, &slot) }; let painter = ui.painter(); let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; let preview_target = (matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten(); diff --git a/crates/ui-egui/src/canvas/presenter.rs b/crates/ui-egui/src/canvas/presenter.rs index 42bf7a2481..d6c8461bfa 100644 --- a/crates/ui-egui/src/canvas/presenter.rs +++ b/crates/ui-egui/src/canvas/presenter.rs @@ -52,6 +52,13 @@ impl Presenter { pub(crate) fn turning(&self) -> bool { self.turn.is_some() } + + /// Whether `page` is the page wholly in view: not while it comes in or waits for its raster, + /// nor in the frame that turns to it (this frame's [`frame`] hasn't caught up yet). While a + /// page turns, `shown` is `None`, so a transition under way needs no check of its own. + pub(crate) fn settled_on(&self, page: usize) -> bool { + self.shown.is_some_and(|(p, _)| p == page) + } } /// The transition and display time of page `page` (0-based), if it has either. diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index b41ad5f2dc..d1843c8760 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2598,6 +2598,74 @@ fn shift_ctrl_page_keys_and_arrows_go_to_the_first_and_last_page() { } } +/// Two pages, the second with a text field and a five-second Wipe. +const FIELD_BEHIND_TRANSITION: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /Annots [5 0 R] /Trans << /S /Wipe /D 5 >> >> endobj +5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [50 100 250 140] /P 4 0 R /F 4 >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// #5316 review: while full screen turns to a page (its transition playing, or its raster on the +/// way), the page isn't the one on screen yet, so a click there opens none of its fields; once +/// the page is in, the same click does. +#[test] +fn full_screen_takes_no_page_input_until_the_page_is_on_screen() { + let mut h = presenting_pdf(FIELD_BEHIND_TRANSITION, &[]); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.state().views[0].current, 1); + assert!(h.state().views[0].presenting_a_transition(), "page 2 comes in with its Wipe"); + let r = h.state().views[0].page_screen_rect(1).expect("page 2 on screen"); + let on_field = egui::pos2(r.left() + r.width() * 0.5, r.top() + r.height() * (400.0 - 120.0) / 400.0); + click_with(&mut h, on_field, Modifiers::NONE); + h.run_steps(2); + assert!(h.state().views[0].presenting_a_transition(), "the Wipe is still playing"); + assert!(!h.ctx.egui_wants_keyboard_input(), "the field behind the transition isn't opened"); + frames_until(&mut h, |a| !a.views[0].presenting_a_transition()); + h.run_steps(1); + click_with(&mut h, on_field, Modifiers::NONE); + h.run_steps(4); + assert!(h.ctx.egui_wants_keyboard_input(), "once page 2 is in, the field opens"); +} + +/// [`FIELD_BEHIND_TRANSITION`] with a Wipe of 0.3 s, shorter than a click may be held. +const FIELD_BEHIND_SHORT_TRANSITION: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /Annots [5 0 R] /Trans << /S /Wipe /D 0.3 >> >> endobj +5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [50 100 250 140] /P 4 0 R /F 4 >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// #5316 review: a press made while full screen turns to a page belongs to nothing on it, through +/// its release: let go on the field just after the transition ends, within a click's time, it +/// still opens nothing. +#[test] +fn full_screen_press_made_during_a_transition_opens_nothing_when_released() { + let mut h = presenting_pdf(FIELD_BEHIND_SHORT_TRANSITION, &[]); + let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen"); + let on_field = egui::pos2(r.left() + r.width() * 0.5, r.top() + r.height() * (400.0 - 120.0) / 400.0); + h.hover_at(on_field); + h.run_steps(1); + // Page 2 renders ahead: once nothing is left to render, its Wipe starts with the key. + settle(&mut h); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert!(h.state().views[0].presenting_a_transition(), "page 2 comes in with its Wipe"); + h.event(egui::Event::PointerButton { pos: on_field, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + assert!(h.state().views[0].presenting_a_transition(), "pressed while the Wipe plays"); + let frames = frames_until(&mut h, |a| !a.views[0].presenting_a_transition()); + assert!(frames <= 4, "the Wipe ends within a click's time ({frames} frames of 0.1 s)"); + h.event(egui::Event::PointerButton { pos: on_field, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(4); + assert!(!h.ctx.egui_wants_keyboard_input(), "released on the field once page 2 is in: nothing opens"); +} + /// In full screen a double-click on a note opens it to edit, and turns no page: the pointer stays /// active "so that you can click links and open notes" (*Adobe Acrobat Help*, 2019, p. 65). #[test] From 3e025b91436c45d5b515a2de058d44fb2dcb3733 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 00:44:54 +0800 Subject: [PATCH 056/101] M8: the places applied redactions leave holding the removed text are an EditNotice (#2448) Main's `EditNotice` is "something the last edit did that the user should hear about", set by `Session::apply` and cleared by the next edit, undo and redo, which is what the PR's separate `redaction_elsewhere` field did by hand. It is now `EditNotice::RedactionElsewhere(Elsewhere)`, pushed when a place is counted or the text couldn't be searched for: tools list it in `warnings` with the other notices, the interface says it through the same path, and `Document::redaction_elsewhere()` gives `redact_apply` and the Apply dialog the counts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/automation/src/redact.rs | 2 +- crates/automation/tests/it/automation.rs | 8 ++++ crates/engine/src/document.rs | 54 +++++++++++++++++++++--- crates/engine/src/open.rs | 1 - crates/engine/src/session/editing.rs | 8 ++-- crates/engine/src/tests.rs | 9 ++-- crates/ui-egui/src/dialogs.rs | 4 +- crates/ui-egui/src/editing.rs | 6 ++- 8 files changed, 71 insertions(+), 21 deletions(-) diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index baea3d75c5..4b95736613 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -166,7 +166,7 @@ impl Automation { out["applied"] = json!(before - after); out["marks_pending"] = json!(after); // Where the removed text is still held that applying leaves as it is. - let left = self.doc(a)?.redaction_elsewhere.unwrap_or_default(); + let left = self.doc(a)?.redaction_elsewhere().unwrap_or_default(); out["still_holding"] = json!({ "bookmarks": left.bookmarks, "attachments": left.attachments, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index bf2d1b3ceb..653c18c4bf 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4365,6 +4365,14 @@ fn redact_apply_reports_places_that_still_hold_the_removed_text() { ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "Bluefin" })); let r = ok(&mut a, "redact_apply", json!({ "doc": doc })); assert_eq!(r["still_holding"], json!({ "bookmarks": 1, "attachments": 0, "comments": 1, "xmp": 0, "total": 2, "unsearched": false }), "{r}"); + // Said in the warnings too, with the other edit notices. + assert_eq!( + r["warnings"], + json!([ + "the redacted text also appears in 1 bookmark title and 1 comment, which redaction doesn't change (Remove hidden information removes them)" + ]), + "{r}" + ); assert!(!page_text(&mut a, doc)[0].contains("Bluefin")); let titles: Vec = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"] .as_array() diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index fa0bedafad..863f370a72 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -60,14 +60,10 @@ pub struct Document { /// (also in `info.warnings`, kept there when the document is re-read). pub xfa_warnings: Vec, /// What the last successful [`Session::apply`](crate::Session::apply) wants said (see - /// [`EditNotice`]): text its fonts can't draw, pages whose own backdrop stays off. The - /// interface shows it; tools return it. + /// [`EditNotice`]): text its fonts can't draw, pages whose own backdrop stays off, places + /// applied redactions left that still hold the removed text. The interface shows it; tools + /// return it. pub edit_notices: Vec, - /// What the last successful [`Session::apply`](crate::Session::apply), when it applied redactions, - /// found still holding the removed text in places applying leaves as they are (bookmark titles, - /// attachments, remaining comments, other XMP; see [`RedactionElsewhere`](crate::RedactionElsewhere)). - /// `None` after any other edit, an undo or a redo. The interface says so; tools return it. - pub redaction_elsewhere: Option, } /// Something the last successful edit did that the user should hear about, never left silent @@ -81,6 +77,11 @@ pub enum EditNotice { /// turning it on could paint over something (`pdfcraft_edit::backdrops_left_off`). They look /// the same on white paper. BackdropsLeftOff { pages: Vec }, + /// Applying redactions left these places as they were, and they still hold the removed text + /// (bookmark titles, attachments, the comments that remain, XMP on other objects; see + /// [`RedactionElsewhere`](crate::RedactionElsewhere)). Only when one is counted, or the text + /// couldn't be searched for. + RedactionElsewhere(pdfcraft_redact::Elsewhere), } impl std::fmt::Display for EditNotice { @@ -95,10 +96,39 @@ impl std::fmt::Display for EditNotice { page_runs(pages) ) } + EditNotice::RedactionElsewhere(e) => elsewhere_text(e, f), } } } +/// [`EditNotice::RedactionElsewhere`] in words (English; the interface has its own wording). +fn elsewhere_text(e: &pdfcraft_redact::Elsewhere, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let count = |n: usize, one: &str, many: &str| match n { + 0 => None, + 1 => Some(format!("1 {one}")), + n => Some(format!("{n} {many}")), + }; + let places: Vec = [ + count(e.bookmarks, "bookmark title", "bookmark titles"), + count(e.attachments, "attachment", "attachments"), + count(e.comments, "comment", "comments"), + count(e.xmp, "other metadata stream", "other metadata streams"), + ] + .into_iter() + .flatten() + .collect(); + let list = match places.split_last() { + None => "no place counted".to_string(), + Some((last, [])) => last.clone(), + Some((last, rest)) => format!("{} and {last}", rest.join(", ")), + }; + if e.unsearched { + write!(f, "the redacted text couldn't be searched for, so {list} may still hold it (Remove hidden information removes them)") + } else { + write!(f, "the redacted text also appears in {list}, which redaction doesn't change (Remove hidden information removes them)") + } +} + /// 0-based pages as 1-based runs: `[0, 1, 2, 5]` is "1-3, 6". pub(crate) fn page_runs(pages: &[usize]) -> String { let mut runs: Vec<(usize, usize)> = Vec::new(); @@ -185,6 +215,16 @@ pub(crate) fn undrawn_notices(prev: &pdfcraft_cos::Document, next: &pdfcraft_cos } impl Document { + /// The places the last edit's applied redactions left that still hold the removed text + /// ([`EditNotice::RedactionElsewhere`]), or `None` when there are none or the last edit + /// applied none. + pub fn redaction_elsewhere(&self) -> Option { + self.edit_notices.iter().find_map(|n| match n { + EditNotice::RedactionElsewhere(e) => Some(*e), + _ => None, + }) + } + /// How the document opens (Document Properties ▸ Initial View). /// Accessibility ▸ Check for accessibility: the full check (`None` if the document can't be /// read for editing). diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs index c43ac354d1..32db07abee 100644 --- a/crates/engine/src/open.rs +++ b/crates/engine/src/open.rs @@ -352,7 +352,6 @@ impl Session { fonts_stale: false, xfa_warnings: Vec::new(), edit_notices: Vec::new(), - redaction_elsewhere: None, } } } diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index d7e84f09f6..023ce138ab 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -26,7 +26,6 @@ impl Session { let xfa_time_scale = self.xfa_time_scale.unwrap_or(1); let doc = self.doc_mut(id)?; doc.edit_notices.clear(); - doc.redaction_elsewhere = None; let name = doc.name.clone(); let is_xfa = doc.info.xfa.is_some(); let mut scope = scope_of(&edit); @@ -182,7 +181,9 @@ impl Session { if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { return Err(EditError::SignedRewrite(edit.label().to_string())); } - let notices = edit_notices(&edit, &editor.cos, &next); + let mut notices = edit_notices(&edit, &editor.cos, &next); + // Places applied redactions left that still hold the removed text: said, not silent. + notices.extend(cx.redaction_elsewhere.filter(|e| e.total() > 0 || e.unsearched).map(EditNotice::RedactionElsewhere)); let previous = std::mem::replace(&mut editor.cos, next); let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone()); let previous_keys = std::mem::replace(&mut editor.keys, keys); @@ -211,7 +212,6 @@ impl Session { doc.dirty = true; doc.generation += 1; doc.edit_notices = notices; - doc.redaction_elsewhere = cx.redaction_elsewhere; if !cx.xfa_removed.is_empty() { let removed: Vec = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect(); doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); @@ -257,7 +257,6 @@ impl Session { return Err(e); } doc.edit_notices.clear(); - doc.redaction_elsewhere = None; Self::sync_dirty(doc); doc.generation += 1; Ok(label) @@ -287,7 +286,6 @@ impl Session { return Err(e); } doc.edit_notices.clear(); - doc.redaction_elsewhere = None; Self::sync_dirty(doc); doc.generation += 1; Ok(label) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8925bff358..91ad63e783 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2992,13 +2992,16 @@ fn applied_redactions_report_a_bookmark_that_still_holds_the_removed_text() { s.apply(id, Edit::AddBookmark { parent: vec![], index: 0, title: "Bluefin plans".into(), page: 0, left: None, top: None }).unwrap(); s.apply(id, Edit::AddBookmark { parent: vec![], index: 1, title: "Annual figures".into(), page: 0, left: None, top: None }).unwrap(); mark_shown_text(&mut s, id, "BLUEFIN"); - assert_eq!(s.get(id).unwrap().redaction_elsewhere, None, "only applying redactions reports it"); + assert_eq!(s.get(id).unwrap().redaction_elsewhere(), None, "only applying redactions reports it"); s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); - let found = s.get(id).unwrap().redaction_elsewhere.expect("applying reports what it left"); + let found = s.get(id).unwrap().redaction_elsewhere().expect("applying reports what it left"); assert_eq!(found, crate::RedactionElsewhere { bookmarks: 1, ..Default::default() }); + // One of the edit notices, so tools and the interface say it with the others. + let said: Vec = s.get(id).unwrap().edit_notices.iter().map(ToString::to_string).collect(); + assert_eq!(said, ["the redacted text also appears in 1 bookmark title, which redaction doesn't change (Remove hidden information removes them)"]); assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Bluefin plans→1", "Annual figures→1"], "the bookmark is reported, not changed"); s.undo(id).unwrap(); - assert_eq!(s.get(id).unwrap().redaction_elsewhere, None, "an undo forgets it"); + assert_eq!(s.get(id).unwrap().redaction_elsewhere(), None, "an undo forgets it"); } /// A word whose font and size an ExtGState /Font entry (`gs`) sets is placed where the renderer diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index dc89a87a12..75f6e1463c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1726,7 +1726,7 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) { }; // Where the removed text is still held that applying leaves as it is: said in the // same notification, so it can't replace the first sentence. - let left = app.active_ids().and_then(|(_, id)| app.session.get(id)).and_then(|d| d.redaction_elsewhere); + let left = app.active_ids().and_then(|(_, id)| app.session.get(id)).and_then(|d| d.redaction_elsewhere()); match left.and_then(|e| still_holding_note(&e)) { Some(note) => app.notify(format!("{applied} {note}")), None => app.notify(applied), @@ -1782,7 +1782,7 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) { /// The sentence telling the user where the removed text is still held that applying redactions /// leaves as it is, or `None` when it is nowhere else. When the text couldn't be searched for, /// the places are those holding any text, and the sentence says they may hold it. -fn still_holding_note(e: &pdfcraft_engine::RedactionElsewhere) -> Option { +pub(crate) fn still_holding_note(e: &pdfcraft_engine::RedactionElsewhere) -> Option { let count = |n: usize, one: &str, many: &str| match n { 0 => None, 1 => Some(tl!(one).to_string()), diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 8def569f58..517609d0fa 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -171,12 +171,14 @@ impl PdfCraftApp { self.quick_tool = crate::QuickTool::Select; } // Text saved as typed but drawn with `?` (its font can't draw it), or pages whose - // own backdrop stays off: said, not silent. Before the scripts' output, so an alert - // they raise still shows. + // own backdrop stays off, or places applied redactions left holding the removed text + // (the Apply redactions dialog then says it after its count): said, not silent. + // Before the scripts' output, so an alert they raise still shows. if let Some(n) = self.session.get(id).and_then(|d| d.edit_notices.first()).cloned() { self.notify(match &n { pdfcraft_engine::EditNotice::Undrawn(t) => crate::content_ui::undrawn_message(t), pdfcraft_engine::EditNotice::BackdropsLeftOff { pages } => crate::marks_ui::backdrops_left_off_message(pages), + pdfcraft_engine::EditNotice::RedactionElsewhere(e) => crate::dialogs::still_holding_note(e).unwrap_or_else(|| n.to_string()), }); } let out = self.session.take_js_output(id); From fdcc569094520e737c8a5d5b50826bc0890c7730 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 00:51:48 +0800 Subject: [PATCH 057/101] M8: the metadata check also compares letters and digits alone, so spacing and TJ tracking can't hide the removed text (#2448) #2539 review: a heading tracked with TJ numbers ([(B) -300 (L) -300 ...] TJ) was collected as "B L U E F I N", which neither held "Bluefin" nor had a word of three letters, and a value spelled "Blue fin" or "BLUE-FIN" didn't hold the term "bluefin". A TJ gap can't be told from a word space, so the glyph ends stay as they are; instead every term with three letters or digits is also looked for by its letters and digits alone, and a value is part of a run when its letters and digits are, starting and ending at the run's word edges ("True" still isn't part of "untrue"). It over-matches only towards removal. Tests: a_heading_tracked_with_tj_numbers_matches_the_metadata, a_value_spaced_or_hyphenated_otherwise_matches (both failed before: /Title kept, metadata 0). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/redact/README.md | 8 +- crates/redact/src/metadata.rs | 107 +++++++++++++++---- crates/redact/src/tests.rs | 1 + crates/redact/src/tests/metadata_matching.rs | 37 +++++++ 4 files changed, 130 insertions(+), 23 deletions(-) create mode 100644 crates/redact/src/tests/metadata_matching.rs diff --git a/crates/redact/README.md b/crates/redact/README.md index 8c742f6cfe..fef032fa1b 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -42,7 +42,13 @@ clear_marks(&mut doc, None)?; // remove marks without app part of it, removed whole, together with any PDF/A identification in that packet. The removed glyphs' text is collected in runs by position (character and word spacing included, so letter-spaced text stays one word), and each run (two or more letters or digits) and each of - its words of three or more are matched without regard to case or spacing. A value that shares + its words of three or more are matched without regard to case or spacing, and also by their + letters and digits alone when they have three or more: "Blue fin" and "BLUE-FIN" hold + "Bluefin", and so does a heading whose letters `TJ` numbers spread apart (collected as + "B L U E F I N", since a `TJ` gap can't be told from a word space). A value is wholly part of + a run when its letters and digits are, starting and ending where the run's words do, so + "True" isn't part of "untrue". This over-matches only towards removal: "is a" also holds + "Lisa". A value that shares only words of one or two letters with the removed text, and isn't wholly part of it, stays: Sanitize's metadata category removes the rest. `Report::metadata` counts what went. - **Fail-closed.** When the check can't know what the removed text was, every `/Info` diff --git a/crates/redact/src/metadata.rs b/crates/redact/src/metadata.rs index 38dca290fd..2eafdb63c9 100644 --- a/crates/redact/src/metadata.rs +++ b/crates/redact/src/metadata.rs @@ -16,7 +16,9 @@ //! one filter (it is decoded strictly, so nothing after damage goes unsearched), goes; text too //! large to search against every term counts as matching, and when the removed text itself was cut short (too many runs or terms, //! or a run too long to keep) every `/Info` string and every XMP stream goes, since what was -//! never collected can't be searched for. Terms are matched without regard to case or spacing. +//! never collected can't be searched for. Terms are matched without regard to case or spacing, +//! and by their letters and digits alone, so "Blue fin", "BLUE-FIN" and a tracked heading +//! collected as "B L U E F I N" all match "Bluefin" (#2539 review). use std::collections::HashSet; @@ -41,6 +43,50 @@ fn significant(s: &str, min: usize) -> bool { s.chars().filter(|c| c.is_alphanumeric()).count() >= min } +/// The letters and digits of `s` alone: "blue-fin" and "b l u e f i n" are both "bluefin". +fn squeeze(s: &str) -> String { + s.chars().filter(|c| c.is_alphanumeric()).collect() +} + +/// A removed run (normalized), and its letters and digits alone with where its words begin and +/// end among them, so a value can be found in it whatever its spacing but still as whole words. +struct Run { + text: String, + letters: String, + /// `edges[i]`: byte `i` of `letters` begins a word (something other than a letter or digit + /// comes before it in the run, or nothing does), or `i` is the end. One entry per byte of + /// `letters`, and one for the end. + edges: Vec, +} + +impl Run { + fn new(text: String) -> Self { + let mut letters = String::new(); + let mut edges = Vec::new(); + let mut gap = true; + for c in text.chars() { + if c.is_alphanumeric() { + edges.resize(letters.len(), false); + edges.push(gap); + letters.push(c); + gap = false; + } else { + gap = true; + } + } + edges.resize(letters.len(), false); + edges.push(true); + Self { text, letters, edges } + } + + /// Are the letters and digits `value` (squeezed) among the run's, beginning and ending at + /// word edges? "bluefin" is in "b l u e f i n" and "bluef in"; "true" isn't in "untrue". + fn holds_letters(&self, value: &str) -> bool { + let edge = |i: usize| self.edges.get(i).copied().unwrap_or(false); + !value.is_empty() && self.letters.match_indices(value).any(|(at, m)| edge(at) && edge(at.saturating_add(m.len()))) + } +} + /// The terms to look for: each removed run (at least two letters or digits), and each of its /// words with at least three, so "Smith, John" in `/Author` is caught when "John Smith" was /// redacted. Shorter pieces would match nearly any text. `None` when a run is longer than @@ -67,6 +113,14 @@ pub(crate) fn terms(removed: &[String]) -> Option> { Some(out) } +/// The terms ([`terms`]) with three letters or digits or more, by their letters and digits +/// alone, each once: "blue fin" holds the term "bluefin" this way, and "bluefin" the run +/// "b l u e f i n". +fn squeezed_terms(terms: &[String]) -> Vec { + let mut seen = HashSet::new(); + terms.iter().filter(|t| significant(t, 3)).map(|t| squeeze(t)).filter(|t| seen.insert(t.clone())).collect() +} + /// The searching one scrub may do, across every `/Info` entry and XMP value: past it, each /// further question is answered "yes" (fail-closed), so a packet of many small values can't add /// up to more than one large one. @@ -92,28 +146,35 @@ impl Work { } } -fn mentions(text: &str, terms: &[String], work: &Work) -> bool { +/// Does `text` hold a term, as written or, for `squeezed` terms, by its letters and digits alone? +fn mentions(text: &str, terms: &[String], squeezed: &[String], work: &Work) -> bool { let text = normalize(text); - !work.spend(text.len().saturating_mul(terms.len())) || terms.iter().any(|t| text.contains(t.as_str())) + let letters = squeeze(&text); + !work.spend(text.len().saturating_mul(terms.len().saturating_add(squeezed.len()))) + || terms.iter().any(|t| text.contains(t.as_str())) + || squeezed.iter().any(|t| letters.contains(t.as_str())) } -/// Is `value` itself part of one of the removed `runs` (normalized), as whole words? It needs -/// three letters or digits, so an entry such as "by" doesn't go with every sentence that -/// contains it, and word boundaries, so an XMP value "True" doesn't go with "untrue". Too much -/// text to search counts as part (fail-closed). -fn within(value: &str, runs: &[String], work: &Work) -> bool { +/// Is `value` itself part of one of the removed `runs`, as whole words, compared as written or by +/// letters and digits alone ("Bluefin" is part of "b l u e f i n")? It needs three letters or +/// digits, so an entry such as "by" doesn't go with every sentence that contains it, and word +/// edges, so an XMP value "True" doesn't go with "untrue". Too much text to search counts as +/// part (fail-closed). +fn within(value: &str, runs: &[Run], work: &Work) -> bool { let value = normalize(value); if !significant(&value, 3) { return false; } - let total = runs.iter().map(String::len).fold(0usize, usize::saturating_add); + let letters = squeeze(&value); + let total = runs.iter().map(|r| r.text.len().saturating_add(r.letters.len())).fold(0usize, usize::saturating_add); let word = |c: Option| c.is_some_and(char::is_alphanumeric); - let whole = |r: &String| { - r.match_indices(value.as_str()).any(|(at, m)| { - let before = r.get(..at).and_then(|s| s.chars().next_back()); - let after = r.get(at.saturating_add(m.len())..).and_then(|s| s.chars().next()); - !(word(before) && value.starts_with(char::is_alphanumeric)) && !(word(after) && value.ends_with(char::is_alphanumeric)) - }) + let whole = |r: &Run| { + r.holds_letters(&letters) + || r.text.match_indices(value.as_str()).any(|(at, m)| { + let before = r.text.get(..at).and_then(|s| s.chars().next_back()); + let after = r.text.get(at.saturating_add(m.len())..).and_then(|s| s.chars().next()); + !(word(before) && value.starts_with(char::is_alphanumeric)) && !(word(after) && value.ends_with(char::is_alphanumeric)) + }) }; !work.spend(value.len().saturating_mul(total)) || runs.iter().any(whole) } @@ -297,8 +358,9 @@ enum Lookup { /// The removed text couldn't be collected or searched for in full: everything counts as /// holding it (fail-closed). Everything, - /// The terms ([`terms`]) and the distinct runs, normalized. - Terms(Vec, Vec), + /// The terms ([`terms`]), the same by their letters and digits alone ([`squeezed_terms`]), + /// and the distinct runs, normalized. + Terms(Vec, Vec, Vec), } impl Search { @@ -311,8 +373,9 @@ impl Search { Some(t) => { // The same run removed many times is searched for once. let mut seen = HashSet::new(); - let runs: Vec = removed.iter().map(|r| normalize(r)).filter(|r| seen.insert(r.clone())).collect(); - Lookup::Terms(t, runs) + let runs: Vec = removed.iter().map(|r| normalize(r)).filter(|r| seen.insert(r.clone())).map(Run::new).collect(); + let squeezed = squeezed_terms(&t); + Lookup::Terms(t, squeezed, runs) } None => Lookup::Everything, }; @@ -340,7 +403,7 @@ impl Search { match &self.lookup { Lookup::Nothing => false, Lookup::Everything => true, - Lookup::Terms(terms, runs) => mentions(text, terms, &self.work) || within(text, runs, &self.work), + Lookup::Terms(terms, squeezed, runs) => mentions(text, terms, squeezed, &self.work) || within(text, runs, &self.work), } } @@ -350,8 +413,8 @@ impl Search { match &self.lookup { Lookup::Nothing => false, Lookup::Everything => true, - Lookup::Terms(terms, runs) => xmp_values(doc, o) - .is_none_or(|values| mentions(&values.join(" "), terms, &self.work) || values.iter().any(|v| within(v, runs, &self.work))), + Lookup::Terms(terms, squeezed, runs) => xmp_values(doc, o) + .is_none_or(|values| mentions(&values.join(" "), terms, squeezed, &self.work) || values.iter().any(|v| within(v, runs, &self.work))), } } } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index c7e5334b4d..24fd6eeba8 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -3229,6 +3229,7 @@ mod elsewhere; mod inline_images; mod limits; mod metadata_fail_closed; +mod metadata_matching; mod property_retirement; mod surviving_references; mod text_state; diff --git a/crates/redact/src/tests/metadata_matching.rs b/crates/redact/src/tests/metadata_matching.rs new file mode 100644 index 0000000000..b29ed51893 --- /dev/null +++ b/crates/redact/src/tests/metadata_matching.rs @@ -0,0 +1,37 @@ +//! #2539 review (second round): spellings of the removed text the metadata check has to match. + +use super::*; + +fn dc_title(title: &str) -> String { + format!(r#"{title}"#) +} + +/// A tracked heading, its letters spread by `TJ` numbers rather than character spacing, is +/// collected as "B L U E F I N": its letters and digits still match `/Title (Bluefin)` and the +/// XMP title, while `/Producer` stays. +#[test] +fn a_heading_tracked_with_tj_numbers_matches_the_metadata() { + let content = b"BT /F1 10 Tf 10 100 Td [(B) -300 (L) -300 (U) -300 (E) -300 (F) -300 (I) -300 (N)] TJ ET"; + let mut doc = with_metadata(content, "<< /Title (Bluefin) /Producer (Test) >>", &dc_title("Bluefin")); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.glyphs, 7, "{r:?}"); + assert_eq!(info_keys(&doc), ["Producer"], "{r:?}"); + assert!(!has_xmp(&doc), "the XMP title named it"); + assert_eq!(r.metadata, 2, "{r:?}"); +} + +/// A value that spells the removed word with a space or a hyphen in it still holds it: "Blue fin" +/// and "BLUE-FIN" go when "BLUEFIN" is redacted, while "Bluefish", which only shares letters +/// with it, stays. +#[test] +fn a_value_spaced_or_hyphenated_otherwise_matches() { + let info = "<< /Title (Blue fin) /Keywords (tuna, BLUE-FIN) /Subject (Bluefish) /Producer (Test) >>"; + let mut doc = with_metadata(b"BT /F1 10 Tf 10 100 Td (Codename BLUEFIN report) Tj ET", info, &dc_title("Blue Fin")); + // "BLUEFIN" spans x 55-90. + mark(&mut doc, 0, &[[55.0, 95.0, 90.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(info_keys(&doc), ["Subject", "Producer"], "{r:?}"); + assert!(!has_xmp(&doc), "the XMP title spelled it with a space"); + assert_eq!(r.metadata, 3, "{r:?}"); +} From 38030a7d54513f632899cf1106ab90504d937cdf Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 00:54:23 +0800 Subject: [PATCH 058/101] M8: the metadata check compares NFKC, lower-case text, so Unicode forms can't hide the removed text (#2448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2539 review: a decomposed "Müller" in a UTF-16BE /Title, a U+FB01 ligature from a ToUnicode map and full-width letters never matched their plain forms. Both sides are now normalized to NFKC and lower case (unicode-normalization, already in the build through pdfcraft-crypt's stringprep, so no new crate). Normalized text more than four times its input's length (U+FDFA and the like) isn't searched: it counts as holding the text. Tests: unicode_forms_of_the_removed_text_match (failed before: Title, Subject and Keywords kept), a_value_that_normalizes_to_far_more_text_counts_as_holding_it (failed with the growth limit raised to 100: 0 removed). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- Cargo.lock | 1 + crates/redact/Cargo.toml | 2 + crates/redact/README.md | 9 ++-- crates/redact/src/metadata.rs | 51 +++++++++++++++----- crates/redact/src/tests/metadata_matching.rs | 33 +++++++++++++ 5 files changed, 81 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fd186d995c..3cc808ab1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4586,6 +4586,7 @@ dependencies = [ "pdfcraft-testkit", "pdfcraft-xfa", "thiserror 2.0.21", + "unicode-normalization", ] [[package]] diff --git a/crates/redact/Cargo.toml b/crates/redact/Cargo.toml index 8d1515d8a4..10cb97ccde 100644 --- a/crates/redact/Cargo.toml +++ b/crates/redact/Cargo.toml @@ -18,6 +18,8 @@ pdfcraft-edit = { workspace = true } pdfcraft-annot = { workspace = true } pdfcraft-xfa = { workspace = true } thiserror = { workspace = true } +# NFKC for the metadata check; already in the build through pdfcraft-crypt (stringprep). +unicode-normalization = "0.1" [dev-dependencies] pdfcraft-filters = { workspace = true } diff --git a/crates/redact/README.md b/crates/redact/README.md index fef032fa1b..129602287c 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -48,8 +48,10 @@ clear_marks(&mut doc, None)?; // remove marks without app "B L U E F I N", since a `TJ` gap can't be told from a word space). A value is wholly part of a run when its letters and digits are, starting and ending where the run's words do, so "True" isn't part of "untrue". This over-matches only towards removal: "is a" also holds - "Lisa". A value that shares - only words of one or two letters with the removed text, and isn't wholly part of it, stays: + "Lisa". Both sides are compared in Unicode compatibility form (NFKC) and lower case, so a + decomposed "Müller" (u and U+0308), full-width letters and a "fi" ligature from a + `ToUnicode` map match their plain forms. A value that shares only words of one or two + letters with the removed text, and isn't wholly part of it, stays: Sanitize's metadata category removes the rest. `Report::metadata` counts what went. - **Fail-closed.** When the check can't know what the removed text was, every `/Info` string and every catalog and page XMP stream goes: more than 100,000 runs or a run over @@ -60,7 +62,8 @@ clear_marks(&mut doc, None)?; // remove marks without app search's work limit (2^28 steps) runs out, every entry and packet not yet checked goes too. An XMP packet that isn't valid UTF-8 or UTF-16, can't be decoded strictly (damaged or cut-short filter data, or more than one filter), or decodes to more than - 16 MiB goes without being read. + 16 MiB goes without being read, and a value whose normalized form would be more than four + times as long (U+FDFA alone is 18 letters) counts as holding the text. - **Not scrubbed, but reported** (Remove hidden information's categories remove them): bookmark titles, attachments (names in the document's list of embedded files, file names and descriptions, and the files of file attachment comments), the comments that remain (their diff --git a/crates/redact/src/metadata.rs b/crates/redact/src/metadata.rs index 2eafdb63c9..9c6d435d27 100644 --- a/crates/redact/src/metadata.rs +++ b/crates/redact/src/metadata.rs @@ -23,6 +23,7 @@ use std::collections::HashSet; use pdfcraft_cos::{Document, ObjRef, Object}; +use unicode_normalization::UnicodeNormalization; /// Terms longer than this are cut (matching still finds the prefix). const MAX_TERM_CHARS: usize = 4096; @@ -33,10 +34,34 @@ pub(crate) const MAX_XMP_BYTES: usize = 16 * 1024 * 1024; /// do in all; past it every entry and packet counts as matching (fail-closed), so a page of /// removed text against a large packet, or against very many small values, stays quick. const MAX_WORK: usize = 1 << 28; +/// Normalized text longer than this many times its input (plus 64 bytes) isn't searched: it +/// counts as matching. Ordinary text barely changes length; a few compatibility characters +/// (U+FDFA, the square katakana words) decompose to many. +const MAX_GROWTH: usize = 4; -/// Lower-case `s` with every run of whitespace made one space, trimmed. -fn normalize(s: &str) -> String { - s.split_whitespace().flat_map(|w| w.chars().flat_map(char::to_lowercase).chain(std::iter::once(' '))).collect::().trim_end().to_string() +/// `s` in Unicode compatibility form (NFKC), lower case, every run of whitespace made one space, +/// trimmed: "Mu\u{308}ller" (decomposed), "MÜLLER" (full width) and "Müller" are all +/// "müller", and the ligature "fi" is "fi" (#2539 review). `None` when it would grow past +/// [`MAX_GROWTH`] times `s`. +fn normalize(s: &str) -> Option { + let cap = s.len().saturating_mul(MAX_GROWTH).saturating_add(64); + let mut out = String::new(); + let mut space = false; + for c in s.nfkc().flat_map(char::to_lowercase).nfkc() { + if c.is_whitespace() { + space = !out.is_empty(); + continue; + } + if space { + out.push(' '); + space = false; + } + out.push(c); + if out.len() > cap { + return None; + } + } + Some(out) } fn significant(s: &str, min: usize) -> bool { @@ -90,13 +115,14 @@ impl Run { /// The terms to look for: each removed run (at least two letters or digits), and each of its /// words with at least three, so "Smith, John" in `/Author` is caught when "John Smith" was /// redacted. Shorter pieces would match nearly any text. `None` when a run is longer than -/// [`MAX_TERM_CHARS`] or there are more than [`MAX_TERMS`] terms: the caller then can't search +/// [`MAX_TERM_CHARS`] (or grows past [`MAX_GROWTH`] when normalized) or there are more than +/// [`MAX_TERMS`] terms: the caller then can't search /// for all of it and must clear the metadata instead. pub(crate) fn terms(removed: &[String]) -> Option> { let mut seen = HashSet::new(); let mut out = Vec::new(); for run in removed { - let run = normalize(run); + let run = normalize(run)?; if run.chars().nth(MAX_TERM_CHARS).is_some() { return None; } @@ -148,7 +174,7 @@ impl Work { /// Does `text` hold a term, as written or, for `squeezed` terms, by its letters and digits alone? fn mentions(text: &str, terms: &[String], squeezed: &[String], work: &Work) -> bool { - let text = normalize(text); + let Some(text) = normalize(text) else { return true }; let letters = squeeze(&text); !work.spend(text.len().saturating_mul(terms.len().saturating_add(squeezed.len()))) || terms.iter().any(|t| text.contains(t.as_str())) @@ -161,7 +187,7 @@ fn mentions(text: &str, terms: &[String], squeezed: &[String], work: &Work) -> b /// edges, so an XMP value "True" doesn't go with "untrue". Too much text to search counts as /// part (fail-closed). fn within(value: &str, runs: &[Run], work: &Work) -> bool { - let value = normalize(value); + let Some(value) = normalize(value) else { return true }; if !significant(&value, 3) { return false; } @@ -368,16 +394,17 @@ impl Search { /// tests). `complete` is false when the collection of removed text was cut short; then, as /// when the terms exceed their limits, everything matches. pub(crate) fn new(removed: &[String], complete: bool, limit: usize) -> Self { - let lookup = match terms(removed).filter(|_| complete) { - Some(t) if t.is_empty() => Lookup::Nothing, - Some(t) => { + let runs: Option> = removed.iter().map(|r| normalize(r)).collect(); + let lookup = match (terms(removed).filter(|_| complete), runs) { + (Some(t), _) if t.is_empty() => Lookup::Nothing, + (Some(t), Some(runs)) => { // The same run removed many times is searched for once. let mut seen = HashSet::new(); - let runs: Vec = removed.iter().map(|r| normalize(r)).filter(|r| seen.insert(r.clone())).map(Run::new).collect(); + let runs: Vec = runs.into_iter().filter(|r| seen.insert(r.clone())).map(Run::new).collect(); let squeezed = squeezed_terms(&t); Lookup::Terms(t, squeezed, runs) } - None => Lookup::Everything, + _ => Lookup::Everything, }; Self { lookup, work: Work::new(limit) } } diff --git a/crates/redact/src/tests/metadata_matching.rs b/crates/redact/src/tests/metadata_matching.rs index b29ed51893..d944dac766 100644 --- a/crates/redact/src/tests/metadata_matching.rs +++ b/crates/redact/src/tests/metadata_matching.rs @@ -35,3 +35,36 @@ fn a_value_spaced_or_hyphenated_otherwise_matches() { assert!(!has_xmp(&doc), "the XMP title spelled it with a space"); assert_eq!(r.metadata, 3, "{r:?}"); } + +/// `s` as a UTF-16BE text string in hex, byte order mark first. +fn utf16be(s: &str) -> String { + format!("", s.encode_utf16().map(|u| format!("{u:04X}")).collect::()) +} + +/// The removed text and the metadata are compared after Unicode compatibility normalization +/// (NFKC) and lower case on both sides: a decomposed "Müller" (u + U+0308) in a UTF-16BE `/Title` +/// matches the composed one removed from the page, a "fi" ligature from a `ToUnicode` map matches +/// "fi", and full-width letters match their ASCII forms. +#[test] +fn unicode_forms_of_the_removed_text_match() { + let info = format!( + "<< /Title {} /Subject (Finance plan) /Keywords {} /Author (Ada Lovelace) /Producer (Test) >>", + utf16be("Mu\u{308}ller"), + utf16be("\u{FF22}\u{FF2C}\u{FF35}\u{FF25}\u{FF26}\u{FF29}\u{FF2E}") + ); + let mut doc = with_metadata(b"BT /F1 10 Tf 10 100 Td (x) Tj ET", &info, ""); + let removed = ["M\u{FC}ller".to_string(), "\u{FB01}nance".to_string(), "Bluefin".to_string()]; + let n = metadata::scrub_within(&mut doc, &removed, true, 1 << 28).unwrap(); + assert_eq!(info_keys(&doc), ["Author", "Producer"]); + assert_eq!(n, 3); +} + +/// A value whose normalized form would be far longer than itself (U+FDFA is 18 letters in NFKC) +/// isn't normalized in full: it counts as holding the removed text (fail-closed). +#[test] +fn a_value_that_normalizes_to_far_more_text_counts_as_holding_it() { + let info = format!("<< /Subject {} /Producer (Test) >>", utf16be(&"\u{FDFA}".repeat(50))); + let mut doc = with_metadata(b"BT /F1 10 Tf 10 100 Td (x) Tj ET", &info, ""); + assert_eq!(metadata::scrub_within(&mut doc, &["Bluefin".into()], true, 1 << 28).unwrap(), 1); + assert_eq!(info_keys(&doc), ["Producer"]); +} From 17a934506452e3c2f88e72be8ddba309fbcd8eb9 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 00:56:20 +0800 Subject: [PATCH 059/101] M8: an FF FE text string is searched as UTF-16LE, in /Info and the places applying leaves (#2448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2539 review: ISO 32000-2 §7.9.2.2.1 defines text strings in PDFDocEncoding, UTF-16BE (FE FF) and UTF-8 (EF BB BF) only, so an FF FE string was read through PDFDocEncoding and Cyrillic or CJK text in it could never match. It is now read as UTF-16LE for the search (fail-closed), in the /Info scrub and in the bookmark, attachment and comment counts. Test: a_utf16le_string_is_searched_as_utf16le (failed before: 0 /Info entries removed; with only the count reverted, bookmarks 0 instead of 1). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/redact/README.md | 4 ++- crates/redact/src/elsewhere.rs | 7 ++--- crates/redact/src/metadata.rs | 17 +++++++++++- crates/redact/src/tests/metadata_matching.rs | 28 ++++++++++++++++++++ 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/crates/redact/README.md b/crates/redact/README.md index 129602287c..2cba145390 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -50,7 +50,9 @@ clear_marks(&mut doc, None)?; // remove marks without app "True" isn't part of "untrue". This over-matches only towards removal: "is a" also holds "Lisa". Both sides are compared in Unicode compatibility form (NFKC) and lower case, so a decomposed "Müller" (u and U+0308), full-width letters and a "fi" ligature from a - `ToUnicode` map match their plain forms. A value that shares only words of one or two + `ToUnicode` map match their plain forms. A string behind an `FF FE` mark, which ISO 32000-2 + §7.9.2.2.1 doesn't define, is read as UTF-16LE, here and in the places reported below, rather + than through PDFDocEncoding, in which it could never match. A value that shares only words of one or two letters with the removed text, and isn't wholly part of it, stays: Sanitize's metadata category removes the rest. `Report::metadata` counts what went. - **Fail-closed.** When the check can't know what the removed text was, every `/Info` diff --git a/crates/redact/src/elsewhere.rs b/crates/redact/src/elsewhere.rs index 901775ac1b..4ebac7bad9 100644 --- a/crates/redact/src/elsewhere.rs +++ b/crates/redact/src/elsewhere.rs @@ -64,8 +64,9 @@ pub(crate) fn find(doc: &Document, removed: &[String], complete: bool) -> Elsewh return Elsewhere::default(); } // A blank value holds nothing, even when everything counts. - let hit = - |o: Option<&Object>| o.and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).is_some_and(|t| !t.trim().is_empty() && search.text(&t)); + let hit = |o: Option<&Object>| { + o.and_then(|o| doc.resolve(o).as_string().map(crate::metadata::text_string)).is_some_and(|t| !t.trim().is_empty() && search.text(&t)) + }; Elsewhere { bookmarks: bookmarks(doc, &hit), attachments: attachments(doc, &hit), @@ -182,7 +183,7 @@ fn comments(doc: &Document, search: &Search, hit: &dyn Fn(Option<&Object>) -> bo /// can't be read (strictly, as UTF-8, within the XMP limit). fn rich_text(doc: &Document, o: &Object) -> Option { let xml = match &*doc.resolve(o) { - Object::String(s) => s.to_text(), + Object::String(s) => crate::metadata::text_string(s), Object::Stream(s) if s.filters().len() <= 1 => String::from_utf8(s.decoded_first_filter_strict(crate::metadata::MAX_XMP_BYTES).ok()?).ok()?, Object::Stream(_) => return None, _ => return Some(String::new()), diff --git a/crates/redact/src/metadata.rs b/crates/redact/src/metadata.rs index 9c6d435d27..18cff41907 100644 --- a/crates/redact/src/metadata.rs +++ b/crates/redact/src/metadata.rs @@ -358,6 +358,21 @@ pub(crate) fn decode_xmp(bytes: &[u8]) -> Option { } } +/// The text of a text string, as searched for the removed text. ISO 32000-2 §7.9.2.2.1 defines +/// PDFDocEncoding, UTF-16BE (`FE FF`) and UTF-8 (`EF BB BF`) only, but a string behind an `FF FE` +/// mark is UTF-16LE to any reader that takes it as such, and read through PDFDocEncoding it +/// could never match: it is read as UTF-16LE (an odd last byte is left out, a lone surrogate +/// becomes U+FFFD), so the search stays fail-closed (#2539 review). +pub(crate) fn text_string(s: &pdfcraft_cos::PdfString) -> String { + match s.bytes.as_slice() { + [0xFF, 0xFE, rest @ ..] => { + let units = rest.as_chunks::<2>().0.iter().map(|&p| u16::from_le_bytes(p)); + char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect() + } + _ => s.to_text(), + } +} + /// The text values of an XMP stream, or `None` when it can't be read. fn xmp_values(doc: &Document, o: &Object) -> Option> { let Object::Stream(s) = &*doc.resolve(o) else { return None }; @@ -466,7 +481,7 @@ pub(crate) fn scrub_within(doc: &mut Document, removed: &[String], complete: boo let info_obj = doc.trailer().get(b"Info").cloned(); if let Some(info) = info_obj.as_ref().and_then(|i| doc.resolve(i).as_dict().cloned()) { let doomed: Vec> = - info.iter().filter(|(_, v)| doc.resolve(v).as_string().is_some_and(|s| search.text(&s.to_text()))).map(|(k, _)| k.to_vec()).collect(); + info.iter().filter(|(_, v)| doc.resolve(v).as_string().is_some_and(|s| search.text(&text_string(s)))).map(|(k, _)| k.to_vec()).collect(); if !doomed.is_empty() { n += doomed.len(); let mut kept = info.clone(); diff --git a/crates/redact/src/tests/metadata_matching.rs b/crates/redact/src/tests/metadata_matching.rs index d944dac766..441ad104a3 100644 --- a/crates/redact/src/tests/metadata_matching.rs +++ b/crates/redact/src/tests/metadata_matching.rs @@ -68,3 +68,31 @@ fn a_value_that_normalizes_to_far_more_text_counts_as_holding_it() { assert_eq!(metadata::scrub_within(&mut doc, &["Bluefin".into()], true, 1 << 28).unwrap(), 1); assert_eq!(info_keys(&doc), ["Producer"]); } + +/// `s` as UTF-16LE bytes in hex, behind an `FF FE` byte order mark. +fn utf16le(s: &str) -> String { + format!("", s.encode_utf16().map(|u| format!("{:02X}{:02X}", u & 0xFF, u >> 8)).collect::()) +} + +/// ISO 32000-2 §7.9.2.2.1 defines text strings in PDFDocEncoding, UTF-16BE (`FE FF`) and UTF-8 +/// (`EF BB BF`) only, but a string behind an `FF FE` mark is UTF-16LE to anyone who reads it as +/// such; read through PDFDocEncoding, Cyrillic or CJK text in it could never match. It is +/// searched as UTF-16LE in `/Info` and in the places applying leaves (here a bookmark title). +#[test] +fn a_utf16le_string_is_searched_as_utf16le() { + let info = format!("<< /Title {} /Subject {} /Producer (Test) >>", utf16le("Синий план"), utf16le("Годовой отчёт")); + let mut doc = with_metadata(b"BT /F1 10 Tf 10 100 Td (x) Tj ET", &info, ""); + assert_eq!(metadata::scrub_within(&mut doc, &["СИНИЙ".into()], true, 1 << 28).unwrap(), 1); + assert_eq!(info_keys(&doc), ["Subject", "Producer"]); + + // A bookmark titled the same way is counted as still holding it. + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Outlines 4 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] >>".to_vec(), + b"<< /Type /Outlines /First 5 0 R /Last 5 0 R /Count 1 >>".to_vec(), + format!("<< /Title {} /Parent 4 0 R /Dest [3 0 R /Fit] >>", utf16le("Синий план")).into_bytes(), + ]; + let doc = Document::open(Arc::new(Pdf::new().objs(objs).build())).unwrap(); + assert_eq!(crate::elsewhere::find(&doc, &["СИНИЙ".into()], true), Elsewhere { bookmarks: 1, ..Default::default() }); +} From 6ce5b5fe4312e67283812e3224d1df9d8a7315c6 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:16:34 +0800 Subject: [PATCH 060/101] M8: a removed document XMP packet's PDF/A and PDF/UA identification is written back, or reported lost (#2448) #2539 review: the scrub removes the catalog's XMP packet whole when it holds the removed text, and with it the file's PDF/A and PDF/UA claims, which Report didn't say. The identification (pdfaid part, conformance, amd, rev; pdfuaid part, rev) is now read by namespace (roxmltree, already in the build through pdfcraft-xfa), each value kept only when it is the short code it must be and doesn't hold the removed text (one-character codes always), and written back in a packet holding nothing else, with a fixed pdfuaid extension schema under PDF/A-1 to -3 (left out, with the PDF/UA claim, if the redacted words are among its words). Report::identification says, per standard, None / Kept / Lost; the engine passes it on as EditNotice::RedactionIdentification, the Apply notification says it after the count, and redact_apply returns identification { pdfa, pdfua }. Tests (redact): the_identification_of_a_removed_packet_is_written_back, a_packet_without_identification_is_only_removed, an_identification_that_cannot_be_kept_is_reported_lost, unsearched_text_keeps_only_one_character_codes, the_extension_schema_is_left_out_when_it_uses_a_redacted_word; engine applied_redactions_report_the_pdfa_identification_written_back; automation redact_apply_reports_the_identification_written_back; ui-egui applying_says_the_pdfa_identification_was_written_back. With the write-back replaced by "nothing kept", the redact tests fail (Kept expected, None found); with the notice not pushed, the engine, automation and UI tests fail. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- Cargo.lock | 1 + crates/automation/src/redact.rs | 8 + crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 34 ++++ crates/engine/src/document.rs | 31 +++ crates/engine/src/edit/run.rs | 5 + crates/engine/src/lib.rs | 1 + crates/engine/src/session/editing.rs | 6 +- crates/engine/src/tests.rs | 32 +++ crates/redact/Cargo.toml | 2 + crates/redact/README.md | 12 +- crates/redact/src/identification.rs | 204 +++++++++++++++++++ crates/redact/src/lib.rs | 9 +- crates/redact/src/metadata.rs | 46 ++++- crates/redact/src/tests.rs | 5 +- crates/redact/src/tests/identification.rs | 123 +++++++++++ crates/redact/src/tests/metadata_matching.rs | 6 +- crates/ui-egui/src/dialogs.rs | 42 +++- crates/ui-egui/src/editing.rs | 3 + crates/ui-egui/tests/it/redact.rs | 53 +++++ 20 files changed, 601 insertions(+), 24 deletions(-) create mode 100644 crates/redact/src/identification.rs create mode 100644 crates/redact/src/tests/identification.rs diff --git a/Cargo.lock b/Cargo.lock index 3cc808ab1c..846b85418e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4585,6 +4585,7 @@ dependencies = [ "pdfcraft-model", "pdfcraft-testkit", "pdfcraft-xfa", + "roxmltree 0.21.2", "thiserror 2.0.21", "unicode-normalization", ] diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index 4b95736613..70e4cb73b2 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -175,6 +175,14 @@ impl Automation { "total": left.total(), "unsearched": left.unsearched, }); + // What became of a PDF/A or PDF/UA claim in the document XMP the scrub removed. + let claim = |c: pdfcraft_engine::RedactionClaim| match c { + pdfcraft_engine::RedactionClaim::None => "none", + pdfcraft_engine::RedactionClaim::Kept => "kept", + pdfcraft_engine::RedactionClaim::Lost => "lost", + }; + let id = self.doc(a)?.redaction_identification().unwrap_or_default(); + out["identification"] = json!({ "pdfa": claim(id.pdfa), "pdfua": claim(id.pdfua) }); Ok(out) } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 0f1b14b9a3..aa30801bfe 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -545,7 +545,7 @@ pub fn tools() -> Vec { t( "redact_apply", "Apply redactions", - "Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Document metadata (Info entries, the document's and pages' XMP) that repeats the removed text is removed too. still_holding counts the places applying leaves as they are that still hold the removed text: bookmarks (titles), attachments (names, file names, descriptions), comments (text, rich text, subject or author of those that remain) and xmp (XMP on images, fonts, forms, annotations and other objects), with their total; doc_remove_hidden removes them. unsearched: true means the removed text couldn't be searched for, so every such place holding any text is counted. Undoable until saved; the saved file no longer contains the content.", + "Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Document metadata (Info entries, the document's and pages' XMP) that repeats the removed text is removed too; when the document XMP goes, identification says what became of its PDF/A and PDF/UA claims (pdfa, pdfua: none, kept when written back in a packet holding only the identification, or lost). still_holding counts the places applying leaves as they are that still hold the removed text: bookmarks (titles), attachments (names, file names, descriptions), comments (text, rich text, subject or author of those that remain) and xmp (XMP on images, fonts, forms, annotations and other objects), with their total; doc_remove_hidden removes them. unsearched: true means the removed text couldn't be searched for, so every such place holding any text is counted. Undoable until saved; the saved file no longer contains the content.", ) .destructive() .with(schema(json!({ "doc": doc(), "pages": pages("whose marks to apply (default: all)") }), &["doc"])), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 653c18c4bf..133e31022b 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4381,6 +4381,40 @@ fn redact_apply_reports_places_that_still_hold_the_removed_text() { .map(|b| b["title"].as_str().unwrap().to_string()) .collect(); assert_eq!(titles, ["Bluefin plans", "Annual figures"], "reported, not changed"); + assert_eq!(r["identification"], json!({ "pdfa": "none", "pdfua": "none" }), "{r}"); +} + +/// #2539 review: when `redact_apply` removes the document's XMP packet because it names the +/// redacted word, `identification` says the PDF/A-2b and PDF/UA-1 identification it held was +/// written back, and the warnings say it too. +#[test] +fn redact_apply_reports_the_identification_written_back() { + let dir = workdir("redact-identification"); + let xmp = r#"Bluefin report"#; + let body = "BT /F1 18 Tf 20 150 Td (Codename BLUEFIN report) Tj ET"; + let content = format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()); + let metadata = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /Metadata 6 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + &content, + &metadata, + ]; + std::fs::write(dir.join("pdfa.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "pdfa.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "BLUEFIN" })); + let r = ok(&mut a, "redact_apply", json!({ "doc": doc })); + assert_eq!(r["identification"], json!({ "pdfa": "kept", "pdfua": "kept" }), "{r}"); + assert_eq!( + r["warnings"], + json!([ + "the document's XMP metadata held the redacted text and was removed; its PDF/A and PDF/UA identification was written back on its own" + ]), + "{r}" + ); } #[test] diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 863f370a72..da921f7ccc 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -82,6 +82,16 @@ pub enum EditNotice { /// [`RedactionElsewhere`](crate::RedactionElsewhere)). Only when one is counted, or the text /// couldn't be searched for. RedactionElsewhere(pdfcraft_redact::Elsewhere), + /// Applying redactions removed the document's XMP packet, which held the removed text and + /// said the file conforms to PDF/A or PDF/UA: each claim was written back in a packet of its + /// own, or lost (see [`RedactionIdentification`](crate::RedactionIdentification)). + RedactionIdentification(pdfcraft_redact::Identification), +} + +/// The standards whose identification `id` has as `claim`: "PDF/A", "PDF/UA" or both, joined. +pub(crate) fn standards_with(id: &pdfcraft_redact::Identification, claim: pdfcraft_redact::Claim) -> Option { + let names: Vec<&str> = [("PDF/A", id.pdfa), ("PDF/UA", id.pdfua)].into_iter().filter(|(_, c)| *c == claim).map(|(n, _)| n).collect(); + (!names.is_empty()).then(|| names.join(" and ")) } impl std::fmt::Display for EditNotice { @@ -97,6 +107,17 @@ impl std::fmt::Display for EditNotice { ) } EditNotice::RedactionElsewhere(e) => elsewhere_text(e, f), + EditNotice::RedactionIdentification(id) => { + use pdfcraft_redact::Claim; + write!(f, "the document's XMP metadata held the redacted text and was removed")?; + if let Some(kept) = standards_with(id, Claim::Kept) { + write!(f, "; its {kept} identification was written back on its own")?; + } + match standards_with(id, Claim::Lost) { + Some(lost) => write!(f, "; its {lost} identification couldn't be kept, so the file no longer says it conforms to {lost}"), + None => Ok(()), + } + } } } } @@ -225,6 +246,16 @@ impl Document { }) } + /// What became of the PDF/A and PDF/UA identification when the last edit's applied + /// redactions removed the document's XMP packet ([`EditNotice::RedactionIdentification`]), + /// or `None` when no packet that made such a claim was removed. + pub fn redaction_identification(&self) -> Option { + self.edit_notices.iter().find_map(|n| match n { + EditNotice::RedactionIdentification(id) => Some(*id), + _ => None, + }) + } + /// How the document opens (Document Properties ▸ Initial View). /// Accessibility ▸ Check for accessibility: the full check (`None` if the document can't be /// read for editing). diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 89e3e7b176..17841128d7 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -187,6 +187,9 @@ pub(crate) struct EditCtx { pub(crate) redaction_overlays: Vec, /// What the edit's applied redactions left as it was that still holds the removed text. pub(crate) redaction_elsewhere: Option, + /// What became of the PDF/A and PDF/UA identification when the edit's applied redactions + /// removed the catalog's XMP packet. + pub(crate) redaction_identification: pdfcraft_redact::Identification, } impl EditCtx { @@ -217,6 +220,7 @@ impl EditCtx { renamed_fields: Vec::new(), redaction_overlays: Vec::new(), redaction_elsewhere: None, + redaction_identification: pdfcraft_redact::Identification::default(), } } @@ -653,6 +657,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E let report = pdfcraft_redact::apply(doc, pages.as_deref())?; cx.redaction_overlays = report.overlays; cx.redaction_elsewhere = Some(report.still_holding); + cx.redaction_identification = report.identification; } Edit::ClearRedactions => { pdfcraft_redact::clear_marks(doc, None)?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 200a79dc9a..69036d9d52 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -119,6 +119,7 @@ pub use pdfcraft_redact::patterns::{ MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list, }; pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden}; +pub use pdfcraft_redact::{Claim as RedactionClaim, Identification as RedactionIdentification}; pub use pdfcraft_sign as sign; pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore}; pub use pdfcraft_xfdf::Format as DataFormat; diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index 023ce138ab..a8b07c73e1 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -182,8 +182,12 @@ impl Session { return Err(EditError::SignedRewrite(edit.label().to_string())); } let mut notices = edit_notices(&edit, &editor.cos, &next); - // Places applied redactions left that still hold the removed text: said, not silent. + // Places applied redactions left that still hold the removed text, and what became of + // the PDF/A or PDF/UA identification in the XMP they removed: said, not silent. notices.extend(cx.redaction_elsewhere.filter(|e| e.total() > 0 || e.unsearched).map(EditNotice::RedactionElsewhere)); + if cx.redaction_identification != pdfcraft_redact::Identification::default() { + notices.push(EditNotice::RedactionIdentification(cx.redaction_identification)); + } let previous = std::mem::replace(&mut editor.cos, next); let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone()); let previous_keys = std::mem::replace(&mut editor.keys, keys); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 91ad63e783..71a212671c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2903,6 +2903,11 @@ fn one_line_page_with(resources: &str, stream_entries: &str, body: &str) -> Vec< "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), format!("<< /Length {} {stream_entries} >>\nstream\n{body}\nendstream", body.len()), ]; + pdf_of(&objs) +} + +/// A file of `objs`, numbered from 1, the first the catalog. +fn pdf_of(objs: &[String]) -> Vec { let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { @@ -3004,6 +3009,33 @@ fn applied_redactions_report_a_bookmark_that_still_holds_the_removed_text() { assert_eq!(s.get(id).unwrap().redaction_elsewhere(), None, "an undo forgets it"); } +/// #2539 review: applying redactions that remove the document's XMP packet, which named the +/// redacted word and said the file conforms to PDF/A-2b, writes the identification back and +/// says so; tools list it with the other edit notices, and an undo forgets it. +#[test] +fn applied_redactions_report_the_pdfa_identification_written_back() { + let xmp = r#"Bluefin report"#; + let body = "BT /F1 10 Tf 20 150 Td (Codename BLUEFIN report) Tj ET"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /Metadata 6 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 300] >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>".to_string(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()), + ]; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("pdfa.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap(); + mark_shown_text(&mut s, id, "BLUEFIN"); + s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); + let kept = crate::RedactionIdentification { pdfa: crate::RedactionClaim::Kept, pdfua: crate::RedactionClaim::None }; + assert_eq!(s.get(id).unwrap().redaction_identification(), Some(kept)); + let said: Vec = s.get(id).unwrap().edit_notices.iter().map(ToString::to_string).collect(); + assert_eq!(said, ["the document's XMP metadata held the redacted text and was removed; its PDF/A identification was written back on its own"]); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().redaction_identification(), None, "an undo forgets it"); +} + /// A word whose font and size an ExtGState /Font entry (`gs`) sets is placed where the renderer /// draws it, so a mark drawn over it as displayed takes it out of the file. #[test] diff --git a/crates/redact/Cargo.toml b/crates/redact/Cargo.toml index 10cb97ccde..984e0ea34e 100644 --- a/crates/redact/Cargo.toml +++ b/crates/redact/Cargo.toml @@ -20,6 +20,8 @@ pdfcraft-xfa = { workspace = true } thiserror = { workspace = true } # NFKC for the metadata check; already in the build through pdfcraft-crypt (stringprep). unicode-normalization = "0.1" +# Reads the PDF/A and PDF/UA identification by namespace; already in the build through pdfcraft-xfa. +roxmltree = "0.21" [dev-dependencies] pdfcraft-filters = { workspace = true } diff --git a/crates/redact/README.md b/crates/redact/README.md index 2cba145390..8063f1a9f4 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -39,7 +39,17 @@ clear_marks(&mut doc, None)?; // remove marks without app part of it as whole words (three letters or digits at least), and the catalog's and pages' XMP streams whose text (element content, attribute values other than `xmlns` declarations, comments and processing instructions, entities decoded) contains it or has a value wholly - part of it, removed whole, together with any PDF/A identification in that packet. The removed + part of it, removed whole. When the catalog's packet goes, its PDF/A (`pdfaid`) and PDF/UA + (`pdfuaid`) identification, read by namespace, is written back in a packet holding nothing + else: the part number, the PDF/A conformance letter, amendment and revision, and PDF/UA's + part and revision, each kept only if it is the short code it must be and doesn't itself hold + the removed text (a one-character code can't, so it is kept even when the text couldn't be + searched for), plus a fixed extension schema for `pdfuaid` under PDF/A-1 to -3, left out + with the PDF/UA claim if the redacted words are among its words. `Report::identification` + says, for each, whether the claim was kept or lost (a value that isn't a valid code or holds + the text, or a packet that can't be read); the notification and `redact_apply` say it too. + `/Info` entries that remain aren't mirrored into the new packet, so a PDF/A validator may + still report them. The removed glyphs' text is collected in runs by position (character and word spacing included, so letter-spaced text stays one word), and each run (two or more letters or digits) and each of its words of three or more are matched without regard to case or spacing, and also by their diff --git a/crates/redact/src/identification.rs b/crates/redact/src/identification.rs new file mode 100644 index 0000000000..b509481adf --- /dev/null +++ b/crates/redact/src/identification.rs @@ -0,0 +1,204 @@ +//! The PDF/A and PDF/UA identification of a document XMP packet the metadata check removes +//! (#2539 review). +//! +//! The scrub removes the catalog's XMP packet whole when it holds the removed text, and the +//! packet is where a file says it conforms to PDF/A (ISO 19005, the `pdfaid` schema) or PDF/UA +//! (ISO 14289, `pdfuaid`). Rather than drop that claim silently, the scrub writes back a packet +//! holding only those identification properties, read by namespace (not by prefix), each +//! checked to be the short code it must be (a part number, a conformance letter, a year), and +//! none of them holding the removed text. Nothing else of the old packet is copied. What became +//! of each claim is reported ([`Identification`]). + +use pdfcraft_cos::{Dict, Document, Object, Stream}; + +use crate::metadata::Search; + +const PDFA_ID: &str = "http://www.aiim.org/pdfa/ns/id/"; +const PDFUA_ID: &str = "http://www.aiim.org/pdfua/ns/id/"; +/// Packets with more `=` than this (attributes, roughly) aren't parsed: the parser checks each +/// element's attributes against each other, so a huge attribute list would be slow. +const MAX_EQUALS: usize = 4096; +/// The most XML nodes parsed. +const MAX_NODES: u32 = 100_000; +/// The PDF/A extension schema describing `pdfuaid:part`, fixed text. +const PDFUA_EXTENSION: &str = concat!( + r#""#, + r#""#, + r#"PDF/UA identification schemahttp://www.aiim.org/pdfua/ns/id/pdfuaid"#, + r#""#, + r#"partIntegerinternalThe part of ISO 14289 the file conforms to"#, + r#""#, +); + +/// What became of a conformance claim when the packet holding it was removed. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Claim { + /// No removed packet made the claim. + #[default] + None, + /// The packet written in its place makes it again. + Kept, + /// The removed packet made it (or, when it couldn't be read, seems to have), and it couldn't + /// be written back: a property wasn't a valid code, held the removed text, or the packet + /// couldn't be read. + Lost, +} + +/// What became of the document's PDF/A and PDF/UA identification when the metadata check +/// removed the catalog's XMP packet. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Identification { + /// PDF/A (`pdfaid`: part, conformance, and amendment and revision when present). + pub pdfa: Claim, + /// PDF/UA (`pdfuaid`: part, and revision when present). + pub pdfua: Claim, +} + +impl Identification { + /// A claim was lost. + pub fn lost(&self) -> bool { + self.pdfa == Claim::Lost || self.pdfua == Claim::Lost + } +} + +/// The identification of the removed catalog packet `xmp`, and the packet to write in its place +/// (`None` when nothing is kept). +pub(crate) fn keep(doc: &Document, xmp: &Object, search: &Search) -> (Identification, Option) { + let Some(text) = crate::metadata::xmp_text(doc, xmp) else { + // Unreadable: a claim it seems to make, by its namespace or usual prefix in the raw + // bytes, is lost. + let seen = |raw: &[u8], ns: &str, prefix: &[u8]| { + let has = |n: &[u8]| raw.windows(n.len()).any(|w| w == n); + if has(ns.as_bytes()) || has(prefix) { Claim::Lost } else { Claim::None } + }; + let id = match &*doc.resolve(xmp) { + Object::Stream(s) => Identification { pdfa: seen(&s.raw, PDFA_ID, b"pdfaid:"), pdfua: seen(&s.raw, PDFUA_ID, b"pdfuaid:") }, + _ => Identification::default(), + }; + return (id, None); + }; + let Some(found) = read(&text) else { + let seen = |ns: &str, prefix: &str| if text.contains(ns) || text.contains(prefix) { Claim::Lost } else { Claim::None }; + return (Identification { pdfa: seen(PDFA_ID, "pdfaid:"), pdfua: seen(PDFUA_ID, "pdfuaid:") }, None); + }; + // A value is kept when it is a valid code and doesn't hold the removed text. One letter or + // digit can't be a term, so it is kept even when everything counts as holding the text. + let ok = |v: &Option, valid: fn(&str) -> bool| -> Option { + let v = v.as_deref()?; + let short = v.chars().filter(|c| c.is_alphanumeric()).count() < 2; + (valid(v) && (short || !search.text(v))).then(|| v.to_string()) + }; + let mut id = Identification::default(); + let mut body = String::new(); + + let a = &found.pdfa; + if a.any() { + let part = ok(&a.part, part); + let conformance = ok(&a.conformance, |v| v.len() == 1 && v.bytes().all(|b| b.is_ascii_alphabetic())); + // PDF/A-1 to -3 require a conformance level; PDF/A-4 has one only for -4e and -4f. + if part.is_some() && (conformance.is_some() || a.conformance.is_none()) { + id.pdfa = Claim::Kept; + body.push_str(r#""#); + let props = [("part", part), ("conformance", conformance), ("amd", ok(&a.amd, amendment)), ("rev", ok(&a.rev, year))]; + for (name, v) in props { + if let Some(v) = v { + body.push_str(&format!("{v}")); + } + } + body.push_str(""); + } else { + id.pdfa = Claim::Lost; + } + } + if found.pdfua.any() { + // PDF/A-1 to -3 accept only the schemas they define or that the packet describes in an + // extension schema, which pdfuaid needs there. Its fixed text is ours, but a file whose + // redacted words it happens to use doesn't get it (nor, then, the PDF/UA claim). + let extension = id.pdfa == Claim::Kept && a.part.as_deref().is_some_and(|p| matches!(p, "1" | "2" | "3")); + let extension_ok = !extension || !crate::metadata::xml_values(PDFUA_EXTENSION).iter().any(|v| search.text(v)); + match ok(&found.pdfua.part, part).filter(|_| extension_ok) { + Some(p) => { + id.pdfua = Claim::Kept; + // PDF/UA-2 adds a revision year (PDF 2.0, so never beside PDF/A-1 to -3). + let rev = ok(&found.pdfua.rev, year).map(|r| format!("{r}")).unwrap_or_default(); + body.push_str(&format!(r#"{p}{rev}"#)); + if extension { + body.push_str(PDFUA_EXTENSION); + } + } + None => id.pdfua = Claim::Lost, + } + } + if body.is_empty() { + return (id, None); + } + let packet = format!( + "\n{body}\n" + ); + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("Metadata")); + dict.set(b"Subtype".to_vec(), Object::name("XML")); + // Unfiltered, as PDF/A requires of metadata streams. + (id, Some(Stream::from_raw(dict, packet.into_bytes()))) +} + +/// A part number: one or two digits. +fn part(v: &str) -> bool { + (1..=2).contains(&v.len()) && v.bytes().all(|b| b.is_ascii_digit()) +} + +/// An amendment identifier, such as "2005" or "1:2005". +fn amendment(v: &str) -> bool { + (1..=16).contains(&v.len()) && v.bytes().all(|b| b.is_ascii_digit() || b == b':' || b == b'.' || b == b'-') +} + +/// A revision year. +fn year(v: &str) -> bool { + v.len() == 4 && v.bytes().all(|b| b.is_ascii_digit()) +} + +#[derive(Default)] +struct PdfaId { + part: Option, + conformance: Option, + amd: Option, + rev: Option, +} + +impl PdfaId { + fn any(&self) -> bool { + self.part.is_some() || self.conformance.is_some() || self.amd.is_some() || self.rev.is_some() + } +} + +#[derive(Default)] +struct Found { + pdfa: PdfaId, + pdfua: PdfaId, +} + +/// The identification properties of a packet, by namespace, written as attributes or as +/// elements; the first of each wins. `None` when the packet can't be parsed. +fn read(text: &str) -> Option { + if text.bytes().filter(|&b| b == b'=').count() > MAX_EQUALS { + return None; + } + let options = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES, ..Default::default() }; + let xml = roxmltree::Document::parse_with_options(text, options).ok()?; + let mut found = Found::default(); + for node in xml.descendants().filter(roxmltree::Node::is_element) { + for (ns, id) in [(PDFA_ID, &mut found.pdfa), (PDFUA_ID, &mut found.pdfua)] { + for (local, slot) in [("part", &mut id.part), ("conformance", &mut id.conformance), ("amd", &mut id.amd), ("rev", &mut id.rev)] { + if slot.is_some() { + continue; + } + if let Some(v) = node.attribute((ns, local)) { + *slot = Some(v.trim().to_string()); + } else if node.tag_name().namespace() == Some(ns) && node.tag_name().name() == local { + *slot = Some(node.text().unwrap_or("").trim().to_string()); + } + } + } + } + Some(found) +} diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 4d6bd676e0..5925878430 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -23,6 +23,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; pub mod codes; mod elsewhere; +mod identification; mod image; mod interp; mod metadata; @@ -36,6 +37,7 @@ pub(crate) mod type3; pub(crate) mod type3_tables; pub use elsewhere::Elsewhere; +pub use identification::{Claim, Identification}; use interp::{Mode, Scope, process}; pub type Rgb = [f64; 3]; @@ -157,6 +159,9 @@ pub struct Report { pub overlays: Vec, /// Document information entries and XMP streams removed because they held redacted text. pub metadata: usize, + /// What became of the PDF/A and PDF/UA identification when the catalog's XMP packet was + /// removed: written back in a packet of its own, or lost (see [`Identification`]). + pub identification: Identification, /// Places this apply leaves as they are that still hold the removed text (bookmark titles, /// attachments, the comments that remain, XMP on other objects), counted so the user can be /// told; Remove hidden information removes them. See [`Elsewhere`] for when the counts are @@ -544,7 +549,9 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result String { /// The text values of an XMP stream, or `None` when it can't be read. fn xmp_values(doc: &Document, o: &Object) -> Option> { + xmp_text(doc, o).map(|t| xml_values(&t)) +} + +/// The decoded text of an XMP stream, or `None` when it can't be read. +pub(crate) fn xmp_text(doc: &Document, o: &Object) -> Option { let Object::Stream(s) = &*doc.resolve(o) else { return None }; // Decoded strictly: a tolerant decode returns whatever came before the damage, and the part // after it would go unsearched. A chain of more than one filter has no strict decoder here, @@ -384,7 +389,7 @@ fn xmp_values(doc: &Document, o: &Object) -> Option> { return None; } let bytes = s.decoded_first_filter_strict(MAX_XMP_BYTES).ok()?; - decode_xmp(&bytes).map(|t| xml_values(&t)) + decode_xmp(&bytes) } /// What the removed text is looked for with, and the searching left to do it. @@ -461,19 +466,30 @@ impl Search { } } +/// What [`scrub`] did. +#[derive(Debug, Default)] +pub(crate) struct Scrubbed { + /// `/Info` entries and XMP streams removed. + pub(crate) removed: usize, + /// What became of the PDF/A and PDF/UA identification of a removed catalog packet. + pub(crate) identification: crate::Identification, +} + /// Remove the metadata that still holds `removed` text: matching `/Info` entries, and the /// catalog's and pages' XMP streams that mention it. `complete` is false when the collection of /// removed text was cut short; then, as when the terms themselves exceed their limits, every -/// `/Info` string and every XMP stream goes. Returns how many entries and streams went. -pub(crate) fn scrub(doc: &mut Document, removed: &[String], complete: bool) -> Result { +/// `/Info` string and every XMP stream goes. A removed catalog packet's PDF/A and PDF/UA +/// identification is written back in a packet of its own ([`crate::identification`]). +pub(crate) fn scrub(doc: &mut Document, removed: &[String], complete: bool) -> Result { scrub_within(doc, removed, complete, MAX_WORK) } /// [`scrub`] with the searching it may do given, so tests can run out of it. -pub(crate) fn scrub_within(doc: &mut Document, removed: &[String], complete: bool, limit: usize) -> Result { +pub(crate) fn scrub_within(doc: &mut Document, removed: &[String], complete: bool, limit: usize) -> Result { let search = Search::new(removed, complete, limit); + let mut out = Scrubbed::default(); if search.is_nothing() { - return Ok(0); + return Ok(out); } let mut n = 0; @@ -496,16 +512,30 @@ pub(crate) fn scrub_within(doc: &mut Document, removed: &[String], complete: boo } // XMP on the catalog and on every page. + let root = doc.root(); for holder in catalog_and_pages(doc) { let Some(xmp) = doc.get(holder).as_dict().and_then(|d| d.get(b"Metadata").cloned()) else { continue }; if search.xmp(doc, &xmp) { - doc.update_dict(holder, |d| { - d.remove(b"Metadata"); + // The catalog's packet says whether the file conforms to PDF/A or PDF/UA: that much + // is written back, in a packet of its own. + let replacement = if Some(holder) == root { + let (id, packet) = crate::identification::keep(doc, &xmp, &search); + out.identification = id; + packet.map(|p| doc.add(Object::Stream(p))) + } else { + None + }; + doc.update_dict(holder, |d| match replacement { + Some(r) => d.set(b"Metadata".to_vec(), Object::Ref(r)), + None => { + d.remove(b"Metadata"); + } })?; n += 1; } } - Ok(n) + out.removed = n; + Ok(out) } /// The catalog and the pages: the objects whose XMP [`scrub`] checks. diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 24fd6eeba8..7a19040e52 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -299,10 +299,10 @@ fn metadata_search_has_one_budget_for_every_value() { let info = format!("<< {entries}/Producer (Test) >>"); let fresh = || with_metadata(b"BT /F1 10 Tf 10 100 Td (Bluefin) Tj ET", &info, ""); let mut doc = fresh(); - assert_eq!(metadata::scrub_within(&mut doc, &["Bluefin".into()], true, 1 << 28).unwrap(), 0, "room to search: nothing goes"); + assert_eq!(metadata::scrub_within(&mut doc, &["Bluefin".into()], true, 1 << 28).unwrap().removed, 0, "room to search: nothing goes"); let mut doc = fresh(); // Each value costs about 40; fifty of them don't fit in 1000. - let n = metadata::scrub_within(&mut doc, &["Bluefin".into()], true, 1000).unwrap(); + let n = metadata::scrub_within(&mut doc, &["Bluefin".into()], true, 1000).unwrap().removed; assert!(n > 20, "past the budget the rest goes ({n} went)"); } @@ -3226,6 +3226,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { mod descriptions; mod elsewhere; +mod identification; mod inline_images; mod limits; mod metadata_fail_closed; diff --git a/crates/redact/src/tests/identification.rs b/crates/redact/src/tests/identification.rs new file mode 100644 index 0000000000..c896e335fe --- /dev/null +++ b/crates/redact/src/tests/identification.rs @@ -0,0 +1,123 @@ +//! #2539 review: the PDF/A and PDF/UA identification of a catalog XMP packet the metadata check +//! removes is written back in a packet of its own, or reported lost. + +use super::*; +use crate::{Claim, Identification}; + +const DOC_XMP_HEAD: &str = r#""#; +const DOC_XMP_TAIL: &str = r#""#; + +/// One page showing "Codename BLUEFIN report", whose catalog XMP is `xmp` (raw bytes). +fn page(xmp: &[u8]) -> Document { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Metadata 7 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + stream("", b"BT /F1 10 Tf 10 100 Td (Codename BLUEFIN report) Tj ET"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + stream("/Type /Metadata /Subtype /XML", xmp), + b"<< /Producer (Test) >>".to_vec(), + ]; + Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry("/Info 8 0 R").build())).unwrap() +} + +/// Apply a mark over "BLUEFIN" and return the report and the catalog's XMP text afterwards. +fn redact(doc: &mut Document) -> (Report, Option) { + mark(doc, 0, &[[55.0, 95.0, 90.0, 110.0]], ""); + let r = apply(doc, None).unwrap(); + let xmp = doc.get(doc.root().unwrap()).as_dict().and_then(|c| c.get(b"Metadata").cloned()); + let text = xmp.and_then(|x| crate::metadata::xmp_text(doc, &x)); + (r, text) +} + +/// The packet that names the redacted word goes, and one holding only its PDF/A and PDF/UA +/// identification takes its place: the PDF/A properties written as attributes, PDF/UA's as an +/// element under a prefix of its own, both found by namespace. Nothing else is copied. +#[test] +fn the_identification_of_a_removed_packet_is_written_back() { + let body = r#"1Bluefin reportAda Lovelace"#; + let mut doc = page(format!("{DOC_XMP_HEAD}{body}{DOC_XMP_TAIL}").as_bytes()); + let (r, text) = redact(&mut doc); + assert_eq!(r.identification, Identification { pdfa: Claim::Kept, pdfua: Claim::Kept }, "{r:?}"); + assert_eq!(r.metadata, 1, "{r:?}"); + let text = text.expect("a packet is written back"); + assert!(!text.to_lowercase().contains("bluefin") && !text.contains("Lovelace"), "{text}"); + let xml = roxmltree::Document::parse(&text).expect("well-formed"); + let value = |ns: &str, local: &str| { + xml.descendants().find(|n| n.tag_name().namespace() == Some(ns) && n.tag_name().name() == local).and_then(|n| n.text()).map(str::to_string) + }; + let a = "http://www.aiim.org/pdfa/ns/id/"; + let ua = "http://www.aiim.org/pdfua/ns/id/"; + assert_eq!((value(a, "part").as_deref(), value(a, "conformance").as_deref(), value(ua, "part").as_deref()), (Some("2"), Some("B"), Some("1"))); + // PDF/A-2 accepts pdfuaid only through an extension schema describing it. + assert_eq!(value("http://www.aiim.org/pdfa/ns/schema#", "prefix").as_deref(), Some("pdfuaid"), "{text}"); +} + +/// A packet without identification is removed as before, and nothing is said about it. +#[test] +fn a_packet_without_identification_is_only_removed() { + let body = r#"Bluefin report"#; + let mut doc = page(format!("{DOC_XMP_HEAD}{body}{DOC_XMP_TAIL}").as_bytes()); + let (r, text) = redact(&mut doc); + assert_eq!((r.identification, text), (Identification::default(), None), "{r:?}"); +} + +/// The identification is lost, and said to be, when its value isn't the code it must be (a +/// conformance level that is a word) or when the packet can't be read (here it isn't UTF-8). +#[test] +fn an_identification_that_cannot_be_kept_is_reported_lost() { + let wordy = r#""#; + let mut doc = page(format!("{DOC_XMP_HEAD}{wordy}{DOC_XMP_TAIL}").as_bytes()); + let (r, text) = redact(&mut doc); + assert_eq!((r.identification, text), (Identification { pdfa: Claim::Lost, pdfua: Claim::None }, None), "{r:?}"); + + let latin1 = [ + DOC_XMP_HEAD.as_bytes(), + br#""#, + DOC_XMP_TAIL.as_bytes(), + ] + .concat(); + let mut doc = page(&latin1); + let (r, text) = redact(&mut doc); + assert_eq!((r.identification, text), (Identification { pdfa: Claim::None, pdfua: Claim::Lost }, None), "{r:?}"); + assert!(r.identification.lost()); +} + +/// When the removed text couldn't be searched for, every catalog packet goes, but a part number +/// and a conformance letter, one letter or digit each, can't hold it and are written back; a +/// revision year could, and isn't. +#[test] +fn unsearched_text_keeps_only_one_character_codes() { + let body = r#"Annual figures"#; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Metadata 7 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + stream("", b"BT /F1 10 Tf 10 100 Td (ABC) Tj ET"), + // Glyph names outside the glyph list: the removed text isn't known. + FONT.replace("95 0 R", "6 0 R").replace(" /Widths", " /Encoding << /Differences [65 /g17 /g18 /g19] >> /Widths").into_bytes(), + widths(), + stream("/Type /Metadata /Subtype /XML", format!("{DOC_XMP_HEAD}{body}{DOC_XMP_TAIL}").as_bytes()), + ]; + let mut doc = Document::open(Arc::new(Pdf::new().objs(objs).build())).unwrap(); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.identification, Identification { pdfa: Claim::Kept, pdfua: Claim::None }, "{r:?}"); + let xmp = doc.get(doc.root().unwrap()).as_dict().and_then(|c| c.get(b"Metadata").cloned()).unwrap(); + let text = crate::metadata::xmp_text(&doc, &xmp).unwrap(); + assert!(text.contains("4") && !text.contains("2020") && !text.contains("Annual"), "{text}"); +} + +/// The extension schema PDF/A-2 needs for pdfuaid is fixed text, but if the redacted words are +/// among its words ("schema" here) it isn't written, and the PDF/UA claim is reported lost. +#[test] +fn the_extension_schema_is_left_out_when_it_uses_a_redacted_word() { + let body = r#"Schema notes"#; + let mut doc = page(format!("{DOC_XMP_HEAD}{body}{DOC_XMP_TAIL}").as_bytes()); + let s = crate::metadata::scrub_within(&mut doc, &["SCHEMA".into()], true, 1 << 28).unwrap(); + assert_eq!(s.identification, Identification { pdfa: Claim::Kept, pdfua: Claim::Lost }); + let xmp = doc.get(doc.root().unwrap()).as_dict().and_then(|c| c.get(b"Metadata").cloned()).unwrap(); + let text = crate::metadata::xmp_text(&doc, &xmp).unwrap(); + assert!(!text.to_lowercase().contains("schema") && text.contains("U"), "{text}"); +} diff --git a/crates/redact/src/tests/metadata_matching.rs b/crates/redact/src/tests/metadata_matching.rs index 441ad104a3..8cab62b0ce 100644 --- a/crates/redact/src/tests/metadata_matching.rs +++ b/crates/redact/src/tests/metadata_matching.rs @@ -54,7 +54,7 @@ fn unicode_forms_of_the_removed_text_match() { ); let mut doc = with_metadata(b"BT /F1 10 Tf 10 100 Td (x) Tj ET", &info, ""); let removed = ["M\u{FC}ller".to_string(), "\u{FB01}nance".to_string(), "Bluefin".to_string()]; - let n = metadata::scrub_within(&mut doc, &removed, true, 1 << 28).unwrap(); + let n = metadata::scrub_within(&mut doc, &removed, true, 1 << 28).unwrap().removed; assert_eq!(info_keys(&doc), ["Author", "Producer"]); assert_eq!(n, 3); } @@ -65,7 +65,7 @@ fn unicode_forms_of_the_removed_text_match() { fn a_value_that_normalizes_to_far_more_text_counts_as_holding_it() { let info = format!("<< /Subject {} /Producer (Test) >>", utf16be(&"\u{FDFA}".repeat(50))); let mut doc = with_metadata(b"BT /F1 10 Tf 10 100 Td (x) Tj ET", &info, ""); - assert_eq!(metadata::scrub_within(&mut doc, &["Bluefin".into()], true, 1 << 28).unwrap(), 1); + assert_eq!(metadata::scrub_within(&mut doc, &["Bluefin".into()], true, 1 << 28).unwrap().removed, 1); assert_eq!(info_keys(&doc), ["Producer"]); } @@ -82,7 +82,7 @@ fn utf16le(s: &str) -> String { fn a_utf16le_string_is_searched_as_utf16le() { let info = format!("<< /Title {} /Subject {} /Producer (Test) >>", utf16le("Синий план"), utf16le("Годовой отчёт")); let mut doc = with_metadata(b"BT /F1 10 Tf 10 100 Td (x) Tj ET", &info, ""); - assert_eq!(metadata::scrub_within(&mut doc, &["СИНИЙ".into()], true, 1 << 28).unwrap(), 1); + assert_eq!(metadata::scrub_within(&mut doc, &["СИНИЙ".into()], true, 1 << 28).unwrap().removed, 1); assert_eq!(info_keys(&doc), ["Subject", "Producer"]); // A bookmark titled the same way is counted as still holding it. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 75f6e1463c..0c4c5c81c8 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1724,13 +1724,14 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) { } else { crate::i18n::fmt(tl!("Applied {n} redaction marks. Save to remove the content from the file."), &[("n", &marks.to_string())]) }; - // Where the removed text is still held that applying leaves as it is: said in the - // same notification, so it can't replace the first sentence. - let left = app.active_ids().and_then(|(_, id)| app.session.get(id)).and_then(|d| d.redaction_elsewhere()); - match left.and_then(|e| still_holding_note(&e)) { - Some(note) => app.notify(format!("{applied} {note}")), - None => app.notify(applied), - } + // Where the removed text is still held that applying leaves as it is, and what + // became of a PDF/A or PDF/UA claim in the document XMP it removed: said in the + // same notification, so neither can replace the first sentence. + let doc = app.active_ids().and_then(|(_, id)| app.session.get(id)); + let left = doc.and_then(|d| d.redaction_elsewhere()).and_then(|e| still_holding_note(&e)); + let id = doc.and_then(|d| d.redaction_identification()).and_then(|id| identification_note(&id)); + let message = [Some(applied), left, id].into_iter().flatten().collect::>().join(" "); + app.notify(message); } } _ => {} @@ -1807,6 +1808,33 @@ pub(crate) fn still_holding_note(e: &pdfcraft_engine::RedactionElsewhere) -> Opt Some(crate::i18n::fmt(tl!(template), &[("places", &list)])) } +/// The sentence telling the user what became of the document's PDF/A or PDF/UA identification +/// when applying redactions removed the XMP packet that held it (and the redacted text), or +/// `None` when no such packet was removed. +pub(crate) fn identification_note(id: &pdfcraft_engine::RedactionIdentification) -> Option { + use pdfcraft_engine::RedactionClaim as Claim; + let named = |claim: Claim| -> Option { + match (id.pdfa == claim, id.pdfua == claim) { + (true, true) => Some(tl!("PDF/A and PDF/UA").to_string()), + (true, false) => Some("PDF/A".to_string()), + (false, true) => Some("PDF/UA".to_string()), + (false, false) => None, + } + }; + let kept = named(Claim::Kept).map(|s| crate::i18n::fmt(tl!("Its {standard} identification was written back on its own."), &[("standard", &s)])); + let lost = named(Claim::Lost).map(|s| { + crate::i18n::fmt( + tl!("Its {standard} identification couldn't be kept, so the file no longer says it conforms to {standard}."), + &[("standard", &s)], + ) + }); + if kept.is_none() && lost.is_none() { + return None; + } + let head = tl!("The document's XMP metadata held the redacted text and was removed.").to_string(); + Some([Some(head), kept, lost].into_iter().flatten().collect::>().join(" ")) +} + /// Edits built from the dialog's drafts. fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { if req.props_now diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 517609d0fa..53d4257f01 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -179,6 +179,9 @@ impl PdfCraftApp { pdfcraft_engine::EditNotice::Undrawn(t) => crate::content_ui::undrawn_message(t), pdfcraft_engine::EditNotice::BackdropsLeftOff { pages } => crate::marks_ui::backdrops_left_off_message(pages), pdfcraft_engine::EditNotice::RedactionElsewhere(e) => crate::dialogs::still_holding_note(e).unwrap_or_else(|| n.to_string()), + pdfcraft_engine::EditNotice::RedactionIdentification(id) => { + crate::dialogs::identification_note(id).unwrap_or_else(|| n.to_string()) + } }); } let out = self.session.take_js_output(id); diff --git a/crates/ui-egui/tests/it/redact.rs b/crates/ui-egui/tests/it/redact.rs index f32341adf6..8f75b44985 100644 --- a/crates/ui-egui/tests/it/redact.rs +++ b/crates/ui-egui/tests/it/redact.rs @@ -325,3 +325,56 @@ fn applying_says_where_the_removed_text_is_still_held() { "Applied 1 redaction mark. Save to remove the content from the file. The redacted text also appears in 1 bookmark title, which redaction doesn't change. Remove hidden information removes them." ); } + +/// #2539 review: applying redactions that remove the document's XMP packet, which named the +/// redacted word and said the file conforms to PDF/A, says that the identification was written +/// back, in the same notification as the count of marks applied. +#[test] +fn applying_says_the_pdfa_identification_was_written_back() { + let xmp = r#"Bluefin report"#; + let content = "BT /F1 18 Tf 20 150 Td (Codename BLUEFIN report) Tj ET"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /Metadata 6 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>".to_string(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("pdfa.pdf", None, bytes).unwrap(); + app + }); + crate::support::settle(&mut h); + assert!(h.state_mut().execute("redact.mark")); + h.run_steps(2); + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(marks(&h), 1); + h.get_by_label("Redact all").click(); + h.run_steps(3); + h.get_by_label("Apply").click(); + h.run_steps(4); + assert_eq!(marks(&h), 0); + let toast = h.state().toast.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); + assert_eq!( + toast, + "Applied 1 redaction mark. Save to remove the content from the file. The document's XMP metadata held the redacted text and was removed. Its PDF/A identification was written back on its own." + ); +} From 4bfea3ade6bc27a9f4abe0552e941b326b472ae8 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:17:24 +0800 Subject: [PATCH 061/101] M8: protect.apply-redactions notes and evidence cover the second review round (#2448) Letters-and-digits matching, NFKC, FF FE strings as UTF-16LE, and the PDF/A / PDF/UA identification written back or reported lost; what still isn't done (remaining /Info entries aren't mirrored into the new packet). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- parity/acrobat-features.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 016d69b49d..846eddbe18 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5780,8 +5780,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::the_renderer_check_refuses_text_still_shown_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources", "crates/redact/src/tests.rs::redacted_text_does_not_survive_in_info_or_xmp", "crates/redact/src/tests.rs::metadata_is_cleared_when_the_removed_text_was_cut_short", "crates/redact/src/tests/metadata_fail_closed.rs::an_xmp_stream_whose_filter_data_is_corrupt_goes", "crates/redact/src/tests/elsewhere.rs::places_apply_leaves_that_still_hold_the_removed_text_are_counted_and_kept", "crates/engine/src/tests.rs::applied_redactions_report_a_bookmark_that_still_holds_the_removed_text", "crates/automation/tests/it/automation.rs::redact_apply_reports_places_that_still_hold_the_removed_text", "crates/ui-egui/tests/it/redact.rs::applying_says_where_the_removed_text_is_still_held"] -notes = "confirmation; one undo step; next save is a full rewrite. Document information entries that contain the removed text, and the document's and pages' XMP streams that do, are removed (#2448; matched without regard to case or spacing, by the whole run of removed text and by its words of three letters or digits or more, and a value wholly part of a run as whole words; fail-closed: all of it goes when the removed text can't be known in full (too much to collect or search for, a glyph whose text isn't known, a form removed unread), and an XMP packet goes when it can't be decoded strictly, is over 16 MiB or isn't UTF-8 or UTF-16). Bookmark titles, attachments (names, file names, descriptions), the comments that remain and other objects' XMP are not changed: applying counts those that still hold the removed text, and the notification and redact_apply's still_holding say where (Remove hidden information removes them); when the removed text couldn't be searched for, every such place holding any text is counted. Refused, with nothing changed, when the page still shows text under a mark afterwards: text in some predefined CMaps and rotated Type 3 fonts is not removed yet, so those documents cannot be redacted. Also refused on a page with an inline image the renderer reads no image for, since the operators after it can't be known. Removed images and forms are unlinked from page-tree resources (own, inherited or shared) and from surviving forms' resources, not yet from annotation appearances or patterns" +evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::the_renderer_check_refuses_text_still_shown_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources", "crates/redact/src/tests.rs::redacted_text_does_not_survive_in_info_or_xmp", "crates/redact/src/tests.rs::metadata_is_cleared_when_the_removed_text_was_cut_short", "crates/redact/src/tests/metadata_fail_closed.rs::an_xmp_stream_whose_filter_data_is_corrupt_goes", "crates/redact/src/tests/elsewhere.rs::places_apply_leaves_that_still_hold_the_removed_text_are_counted_and_kept", "crates/engine/src/tests.rs::applied_redactions_report_a_bookmark_that_still_holds_the_removed_text", "crates/automation/tests/it/automation.rs::redact_apply_reports_places_that_still_hold_the_removed_text", "crates/ui-egui/tests/it/redact.rs::applying_says_where_the_removed_text_is_still_held", "crates/redact/src/tests/metadata_matching.rs::a_heading_tracked_with_tj_numbers_matches_the_metadata", "crates/redact/src/tests/metadata_matching.rs::unicode_forms_of_the_removed_text_match", "crates/redact/src/tests/metadata_matching.rs::a_utf16le_string_is_searched_as_utf16le", "crates/redact/src/tests/identification.rs::the_identification_of_a_removed_packet_is_written_back", "crates/automation/tests/it/automation.rs::redact_apply_reports_the_identification_written_back", "crates/ui-egui/tests/it/redact.rs::applying_says_the_pdfa_identification_was_written_back"] +notes = "confirmation; one undo step; next save is a full rewrite. Document information entries that contain the removed text, and the document's and pages' XMP streams that do, are removed (#2448; matched without regard to case or spacing, by the whole run of removed text and by its words of three letters or digits or more, and a value wholly part of a run as whole words, each also compared by letters and digits alone (so spacing, hyphens and TJ tracking don't hide it), after NFKC and lower case, with FF FE strings read as UTF-16LE; fail-closed: all of it goes when the removed text can't be known in full (too much to collect or search for, a glyph whose text isn't known, a form removed unread), and an XMP packet goes when it can't be decoded strictly, is over 16 MiB or isn't UTF-8 or UTF-16). When the document's XMP goes, its PDF/A and PDF/UA identification is written back in a packet holding only that, value by value when each is a valid code that doesn't hold the removed text, else reported lost (Report::identification, the notification, redact_apply's identification); /Info entries that remain aren't mirrored into that packet. Bookmark titles, attachments (names, file names, descriptions), the comments that remain and other objects' XMP are not changed: applying counts those that still hold the removed text, and the notification and redact_apply's still_holding say where (Remove hidden information removes them); when the removed text couldn't be searched for, every such place holding any text is counted. Refused, with nothing changed, when the page still shows text under a mark afterwards: text in some predefined CMaps and rotated Type 3 fonts is not removed yet, so those documents cannot be redacted. Also refused on a page with an inline image the renderer reads no image for, since the operators after it can't be known. Removed images and forms are unlinked from page-tree resources (own, inherited or shared) and from surviving forms' resources, not yet from annotation appearances or patterns" [[feature]] id = "protect.redact-glyph-removal" From 8c6c5a261b6a22ce04a569038ca349618dbe2c9e Mon Sep 17 00:00:00 2001 From: Osvaldo Duran <338149330+regendorf-ux@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:53:50 -0500 Subject: [PATCH 062/101] Print production: Add printer marks (dialog, edit, tool) Trim marks at the trim box's corners, dashed bleed marks at the bleed box, registration targets, a colour bar and a page-information line, drawn outside the trim box as a removable page mark (MarkKind::PrinterMarks); the page is widened to make room when asked and the trim box stays, so the printed page cuts the same. Edit::AddPrinterMarks (scope Full, undoable) and Remove printer marks through Edit::RemoveMarks; Dialog::PrinterMarks reachable from the catalog, the palette and --view printer-marks; tools doc_printer_marks and doc_remove_marks kind printer_marks; rows in every language; parity print.printer-marks shipped. Carries the review fixes: - ATTRIBUTION.toml: the language files' checksums after the new rows - Printer marks: drop the unreachable AddPrinterMarks pattern in scope_of - Tests: printer marks read the document's marks through upstream's marks() (#1434) - Printer marks: make room inside the crop box and keep the trim box on rerun - Format with rustfmt - Printer marks: keep accented labels and make room for the bleed marks - Printer marks test: the page is letter size, so the bleed marks (33 pt beyond the trim box) fit - i18n(ui): carry this PR's new wordings in the catalogues that must cover them - Parity: evidence paths follow the tests moved into tests/it - Fix the build after merging upstream/main - fix(printer-marks): refuse numbers out of range, print on every plate Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/dispatch.rs | 2 +- crates/automation/src/documents.rs | 22 ++ crates/automation/src/tools.rs | 18 +- crates/automation/tests/it/automation.rs | 29 ++ crates/edit/src/added/store.rs | 2 + crates/edit/src/lib.rs | 2 + crates/edit/src/marks.rs | 3 + crates/edit/src/printer_marks.rs | 340 ++++++++++++++++++ crates/edit/src/tests.rs | 46 +++ .../tests/printer_marks_accented_label.rs | 12 + .../edit/tests/printer_marks_bleed_reach.rs | 50 +++ crates/edit/tests/printer_marks_boxes.rs | 108 ++++++ crates/engine/src/catalog.rs | 2 +- crates/engine/src/commands.rs | 2 + crates/engine/src/edit.rs | 15 +- crates/engine/src/edit/labels.rs | 2 + crates/engine/src/edit/run.rs | 4 + crates/engine/src/history.rs | 1 + crates/engine/src/lib.rs | 3 +- crates/engine/src/session/editing.rs | 9 +- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/control.rs | 1 + crates/ui-egui/src/commands.rs | 5 +- crates/ui-egui/src/dialogs.rs | 11 + crates/ui-egui/src/dialogs/marks_ui.rs | 8 +- .../ui-egui/src/dialogs/printer_marks_ui.rs | 152 ++++++++ crates/ui-egui/src/i18n/ar.tsv | 15 + crates/ui-egui/src/i18n/bg.tsv | 15 + crates/ui-egui/src/i18n/ca.tsv | 15 + crates/ui-egui/src/i18n/cs.tsv | 15 + crates/ui-egui/src/i18n/de.tsv | 15 + crates/ui-egui/src/i18n/el.tsv | 15 + crates/ui-egui/src/i18n/es.tsv | 15 + crates/ui-egui/src/i18n/fi.tsv | 15 + crates/ui-egui/src/i18n/fr.tsv | 15 + crates/ui-egui/src/i18n/hi.tsv | 15 + crates/ui-egui/src/i18n/hu.tsv | 15 + crates/ui-egui/src/i18n/id.tsv | 15 + crates/ui-egui/src/i18n/it.tsv | 15 + crates/ui-egui/src/i18n/ja.tsv | 15 + crates/ui-egui/src/i18n/kk.tsv | 15 + crates/ui-egui/src/i18n/ko.tsv | 15 + crates/ui-egui/src/i18n/lt.tsv | 15 + crates/ui-egui/src/i18n/pl.tsv | 15 + crates/ui-egui/src/i18n/pt-br.tsv | 15 + crates/ui-egui/src/i18n/ro.tsv | 15 + crates/ui-egui/src/i18n/ru.tsv | 15 + crates/ui-egui/src/i18n/sl.tsv | 15 + crates/ui-egui/src/i18n/sq.tsv | 15 + crates/ui-egui/src/i18n/te.tsv | 15 + crates/ui-egui/src/i18n/tr.tsv | 15 + crates/ui-egui/src/i18n/uk.tsv | 15 + crates/ui-egui/src/i18n/vi.tsv | 15 + crates/ui-egui/src/i18n/zh-hans.tsv | 15 + crates/ui-egui/src/i18n/zh-hant.tsv | 15 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/state.rs | 2 + crates/ui-egui/tests/it/commands.rs | 4 + crates/ui-egui/tests/it/editing.rs | 26 ++ docs/gaps.md | 6 +- parity/acrobat-features.toml | 10 +- 61 files changed, 1316 insertions(+), 19 deletions(-) create mode 100644 crates/edit/src/printer_marks.rs create mode 100644 crates/edit/tests/printer_marks_accented_label.rs create mode 100644 crates/edit/tests/printer_marks_bleed_reach.rs create mode 100644 crates/edit/tests/printer_marks_boxes.rs create mode 100644 crates/ui-egui/src/dialogs/printer_marks_ui.rs diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 8bc23ff280..8a7903251d 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -689,7 +689,7 @@ impl Automation { summary(self.session.get(new).ok_or_else(|| failed("the document vanished"))?) } "doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?, - "doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?, + "doc_header_footer" | "doc_watermark" | "doc_background" | "doc_printer_marks" | "doc_remove_marks" => self.marks(name, &a)?, "doc_backdrops" => self.backdrops(&a)?, "doc_unprotect" => { let mut out = self.apply(&a, Edit::RemoveProtection)?; diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index 859c92da48..e0d2d0376a 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -546,11 +546,33 @@ impl Automation { replace, file: self.mark_file(a)?, }, + "doc_printer_marks" => { + use pdfcraft_engine::PrinterMarks; + let flag = |k: &str, default: bool| -> Result { Ok(a.opt_bool(k)?.unwrap_or(default)) }; + let (y, m, d) = self.session.today(); + let settings = PrinterMarks { + trim: flag("trim", true)?, + bleed: flag("bleed", true)?, + registration: flag("registration", true)?, + color_bars: flag("color_bars", true)?, + page_info: flag("page_info", true)?, + line_weight: a.opt_num("line_weight")?.unwrap_or(0.25), + make_room: match a.opt_num("make_room")? { + Some(r) if r < 0.0 => None, + Some(r) => Some(r), + None => Some(36.0), + }, + label: a.opt_str("label")?.map(str::to_string).unwrap_or_else(|| self.doc(a).map(|d| d.name.clone()).unwrap_or_default()), + date: a.opt_str("date")?.map(str::to_string).unwrap_or_else(|| format!("{y:04}-{m:02}-{d:02}")), + }; + Edit::AddPrinterMarks { pages, settings, replace: a.opt_bool("replace")?.unwrap_or(true) } + } _ => { let kind = match a.str("kind")? { "header_footer" => MarkKind::HeaderFooter, "watermark" => MarkKind::Watermark, "background" => MarkKind::Background, + "printer_marks" => MarkKind::PrinterMarks, other => return Err(ToolError::InvalidArgs(format!("unknown kind {other:?}"))), }; Edit::RemoveMarks { kind } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 866138899d..d33ccf5aa9 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1038,13 +1038,27 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "file": { "type": "string" }, "file_page": { "type": "integer", "minimum": 1 }, "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" }, "replace_backdrop": { "type": "boolean", "description": "Replace a white backdrop the page paints itself, which would hide the background. Default true." } }), &["doc"], )), + t("doc_printer_marks", "Add printer marks", "Print production: trim marks, bleed marks (dashed, at the bleed box), registration targets, colour bars and a page-information line (label, page n of N, date) drawn outside the trim box as a removable page mark, in registration colour (Separation All, so on every plate) except the process-ink colour bars; the page is widened by make_room points a side when the marks would fall outside it (the trim box stays). replace (default true) replaces marks added before. Undoable; doc_remove_marks kind printer_marks takes them off.") + .cmd("prepress.marks") + .with(schema( + json!({ + "doc": doc(), "pages": pages("the pages to mark (default all)"), + "trim": { "type": "boolean" }, "bleed": { "type": "boolean" }, "registration": { "type": "boolean" }, "color_bars": { "type": "boolean" }, "page_info": { "type": "boolean" }, + "line_weight": { "type": "number", "minimum": 0.1, "maximum": 4, "description": "Points (Acrobat offers 0.25, 0.5 and 1)." }, + "make_room": { "type": "number", "maximum": 288, "description": "Points to widen the page by on each side when needed (default 36); negative leaves the page as it is." }, + "label": { "type": "string", "description": "The page-information line's label (default: the file's name)." }, + "date": { "type": "string", "description": "The page-information line's date (default: today)." }, + "replace": { "type": "boolean" }, + }), + &["doc"], + )), t("doc_backdrops", "List page backdrops", "Pages that paint their own backdrop, which hides a background added behind the content: `fill` (an opaque white fill doc_background can replace) or `image` (a page-sized picture, e.g. a scan, that still hides it). Pages are 1-based; pages without one are left out.") .ro() .cmd("edit.background") .with(schema(json!({ "doc": doc(), "pages": pages("to check (default: all)") }), &["doc"])), - t("doc_remove_marks", "Remove header & footer, watermark or background", "Remove every header and footer, watermark or background PdfCraft (or a compatible tool) added. Undoable.") + t("doc_remove_marks", "Remove header & footer, watermark, background or printer marks", "Remove every header and footer, watermark, background or printer marks PdfCraft (or a compatible tool) added. Undoable.") .destructive() - .with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background"] } }), &["doc", "kind"])), + .with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background", "printer_marks"] } }), &["doc", "kind"])), t("doc_export_images", "Export pages as images", "Write pages as PNG, JPEG or TIFF files (`_page_.png|jpg|tif`) into a folder, at a resolution (default 150 dpi). JPEG and TIFF are flattened onto white paper. A page too large for the renderer at that resolution is drawn at the most it allows, listed in `lower_dpi` ({page, dpi}). Includes unsaved edits.") .cmd("export.image") .with(schema( diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 0d73dbb900..d17584a99f 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9457,6 +9457,35 @@ fn a_root_less_write_output_is_atomic_too() { let _ = std::fs::remove_dir_all(&dir); } +/// Printer marks through the tools: added around the trim box (the page widened to make room), +/// removable by kind. +#[test] +fn printer_marks_through_tools() { + let dir = workdir("printer-marks"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let width_before = ok(&mut a, "doc_info", json!({ "doc": doc }))["pages"][0]["width"].as_f64().unwrap_or(0.0); + let r = ok(&mut a, "doc_printer_marks", json!({ "doc": doc, "pages": [1], "color_bars": false, "line_weight": 0.5 })); + assert_eq!(r["undo"], "Add printer marks"); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + let width_after = info["pages"][0]["width"].as_f64().unwrap_or(0.0); + assert!(width_after > width_before + 70.0 || width_before == 0.0, "widened to make room: {width_before} → {width_after}; {info}"); + let r = ok(&mut a, "doc_remove_marks", json!({ "doc": doc, "kind": "printer_marks" })); + assert_eq!(r["undo"], "Remove printer marks"); + for bad in [json!({ "line_weight": -1 }), json!({ "line_weight": 0 }), json!({ "line_weight": 10000 }), json!({ "make_room": 1000000 })] { + let mut args = bad.clone(); + args["doc"] = json!(doc); + assert!(matches!(a.call("doc_printer_marks", &args), Err(ToolError::Failed(_) | ToolError::InvalidArgs(_))), "{bad}"); + } + assert!(matches!( + a.call( + "doc_printer_marks", + &json!({ "doc": doc, "trim": false, "bleed": false, "registration": false, "color_bars": false, "page_info": false }) + ), + Err(ToolError::Failed(_) | ToolError::InvalidArgs(_)) + )); +} + #[test] fn attaching_and_deleting_files_through_the_tools() { let dir = workdir("attach"); diff --git a/crates/edit/src/added/store.rs b/crates/edit/src/added/store.rs index 7dae55596f..84cd137c4e 100644 --- a/crates/edit/src/added/store.rs +++ b/crates/edit/src/added/store.rs @@ -86,6 +86,8 @@ pub fn scan_markers(doc: &Document) -> MarkerScan { Some(MarkKind::Watermark) } else if tag == Some(&b"Background"[..]) { Some(MarkKind::Background) + } else if tag == Some(&b"PrinterMarks"[..]) { + Some(MarkKind::PrinterMarks) } else { None }; diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index aa11617c14..91778488da 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -54,6 +54,8 @@ pub use text::{ BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping, clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines, }; +pub mod printer_marks; +pub use printer_marks::{PrinterMarks, add_printer_marks}; pub mod added; pub use added::{ Added, AddedImage, AddedText, Align, Content, Family, MarkerScan, add_content, delete_content, first_undrawable, isolate_content, list_added, diff --git a/crates/edit/src/marks.rs b/crates/edit/src/marks.rs index d936955184..2e667c7cc6 100644 --- a/crates/edit/src/marks.rs +++ b/crates/edit/src/marks.rs @@ -6,6 +6,8 @@ pub enum MarkKind { HeaderFooter, Watermark, Background, + /// Printer marks: trim, bleed, registration, colour bars, page information. + PrinterMarks, } impl MarkKind { @@ -14,6 +16,7 @@ impl MarkKind { MarkKind::HeaderFooter => "HeaderFooter", MarkKind::Watermark => "Watermark", MarkKind::Background => "Background", + MarkKind::PrinterMarks => "PrinterMarks", } } } diff --git a/crates/edit/src/printer_marks.rs b/crates/edit/src/printer_marks.rs new file mode 100644 index 0000000000..a247f65d30 --- /dev/null +++ b/crates/edit/src/printer_marks.rs @@ -0,0 +1,340 @@ +//! Printer marks (Acrobat's Print production ▸ Add Printer Marks): trim and +//! bleed marks, registration targets, colour bars and page information drawn outside the trim +//! box, as a page mark like a header, so they can be removed or replaced. The page is widened +//! when asked and the marks would otherwise fall outside it; the trim box stays where it is. +//! +//! Lines, targets and the information line are in registration colour, the `/All` Separation +//! (ISO 32000-2, 8.6.6.4), so they print on every plate, spot plates too; viewers show its +//! four-ink alternate. The colour bars are process inks and stay DeviceCMYK. + +use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_fonts::{literal, win_ansi}; + +use crate::marks::{MarkKind, n}; +use crate::place::{END, add_resources, begin, check, page_list, place}; +use crate::{EditError, remove_marks}; + +/// Which marks, and how heavy. +#[derive(Clone, Debug, PartialEq)] +pub struct PrinterMarks { + pub trim: bool, + pub bleed: bool, + pub registration: bool, + pub color_bars: bool, + pub page_info: bool, + /// Line weight in points (Acrobat: 0.25, 0.5, 1). + pub line_weight: f64, + /// Widen the page by this many points on each side (at least what the marks need) when + /// they would fall outside it; `None` leaves the page and clips the marks. + pub make_room: Option, + /// What the page information line says before the page number (the file's name). + pub label: String, + /// The date on the page information line. + pub date: String, +} + +impl Default for PrinterMarks { + fn default() -> Self { + Self { + trim: true, + bleed: true, + registration: true, + color_bars: true, + page_info: true, + line_weight: 0.25, + make_room: Some(36.0), + label: String::new(), + date: String::new(), + } + } +} + +/// How far a mark reaches beyond the trim box: a gap, then the mark itself. +const GAP: f64 = 6.0; +const LENGTH: f64 = 18.0; +/// Registration targets sit this far beyond the trim box, centred on each side. +const TARGET_OFFSET: f64 = 16.0; +const TARGET_RADIUS: f64 = 4.0; +const BAR: f64 = 10.0; +const INFO_SIZE: f64 = 7.0; +/// The colour space resource holding registration colour. +const REGISTRATION: &str = "PCReg"; +/// The largest page side PDF viewers take (Annex C of ISO 32000-1 lists it as Acrobat's limit). +const MAX_PAGE_SIDE: f64 = 14_400.0; + +/// `[/Separation /All /DeviceCMYK ]`: full tint is all four process inks. +fn registration() -> Object { + let reals = |v: &[f64]| Object::Array(v.iter().map(|x| Object::Real(*x)).collect()); + let mut tint = Dict::new(); + tint.set(b"FunctionType".to_vec(), Object::Int(2)); + tint.set(b"Domain".to_vec(), reals(&[0.0, 1.0])); + tint.set(b"C0".to_vec(), reals(&[0.0; 4])); + tint.set(b"C1".to_vec(), reals(&[1.0; 4])); + tint.set(b"N".to_vec(), Object::Int(1)); + Object::Array(vec![Object::name("Separation"), Object::name("All"), Object::name("DeviceCMYK"), Object::Dict(tint)]) +} + +impl PrinterMarks { + /// The line weights a run accepts, in points. + pub const WEIGHTS: std::ops::RangeInclusive = 0.1..=4.0; + /// The most a run widens a page by on each side at the caller's asking, in points. + pub const MAX_ROOM: f64 = 288.0; + + fn any(&self) -> bool { + self.trim || self.bleed || self.registration || self.color_bars || self.page_info + } + + /// How far beyond the trim box the chosen marks reach. + pub fn reach(&self) -> f64 { + let mut r: f64 = 0.0; + if self.trim || self.bleed { + r = r.max(GAP + LENGTH); + } + if self.registration { + r = r.max(TARGET_OFFSET + TARGET_RADIUS + 2.0); + } + if self.color_bars || self.page_info { + r = r.max(GAP + BAR + 6.0); + } + r + } + + /// The marks' content for a page whose trim box is `trim` and bleed box `bleed` (user + /// space), `page` of `count` (1-based). + pub fn content(&self, trim: [f64; 4], bleed: [f64; 4], page: usize, count: usize) -> String { + let w = self.line_weight.clamp(*Self::WEIGHTS.start(), *Self::WEIGHTS.end()); + // Registration colour: every plate. + let mut c = format!("/{REGISTRATION} CS 1 SCN /{REGISTRATION} cs 1 scn {} w 0 J\n", n(w)); + let [x0, y0, x1, y1] = trim; + let line = |c: &mut String, ax: f64, ay: f64, bx: f64, by: f64| { + c.push_str(&format!("{} {} m {} {} l S\n", n(ax), n(ay), n(bx), n(by))); + }; + // Crop (trim) marks: at each corner, outside the box, along both edges. + let corner_marks = |c: &mut String, b: [f64; 4]| { + let [bx0, by0, bx1, by1] = b; + for (x, dir) in [(bx0, -1.0), (bx1, 1.0)] { + for y in [by0, by1] { + // Vertical mark beyond the top or bottom edge. + let (ya, yb) = if y == by0 { (y - GAP, y - GAP - LENGTH) } else { (y + GAP, y + GAP + LENGTH) }; + line(c, x, ya, x, yb); + // Horizontal mark beyond the left or right edge. + line(c, x + dir * GAP, y, x + dir * (GAP + LENGTH), y); + } + } + }; + if self.trim { + corner_marks(&mut c, trim); + } + if self.bleed && bleed != trim { + c.push_str(&format!("[{} {}] 0 d\n", n(w * 4.0), n(w * 4.0))); + corner_marks(&mut c, bleed); + c.push_str("[] 0 d\n"); + } + if self.registration { + let r = TARGET_RADIUS; + let k = 0.5523 * r; + let target = |c: &mut String, cx: f64, cy: f64| { + c.push_str(&format!( + "{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c S\n", + n(cx + r), + n(cy), + n(cx + r), + n(cy + k), + n(cx + k), + n(cy + r), + n(cx), + n(cy + r), + n(cx - k), + n(cy + r), + n(cx - r), + n(cy + k), + n(cx - r), + n(cy), + n(cx - r), + n(cy - k), + n(cx - k), + n(cy - r), + n(cx), + n(cy - r), + n(cx + k), + n(cy - r), + n(cx + r), + n(cy - k), + n(cx + r), + n(cy) + )); + line(c, cx - r * 1.6, cy, cx + r * 1.6, cy); + line(c, cx, cy - r * 1.6, cx, cy + r * 1.6); + }; + let (mx, my) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + target(&mut c, mx, y1 + TARGET_OFFSET); + target(&mut c, mx, y0 - TARGET_OFFSET); + target(&mut c, x0 - TARGET_OFFSET, my); + target(&mut c, x1 + TARGET_OFFSET, my); + } + if self.color_bars { + // Above the top edge, from the left: C, M, Y, K at 100%, then 75, 50, 25% K. + let y = y1 + GAP + 2.0; + let x = x0 + LENGTH + GAP + 12.0; + let swatches: [(f64, f64, f64, f64); 7] = [ + (1.0, 0.0, 0.0, 0.0), + (0.0, 1.0, 0.0, 0.0), + (0.0, 0.0, 1.0, 0.0), + (0.0, 0.0, 0.0, 1.0), + (0.0, 0.0, 0.0, 0.75), + (0.0, 0.0, 0.0, 0.5), + (0.0, 0.0, 0.0, 0.25), + ]; + for (i, (cy, m, yl, k)) in swatches.iter().enumerate() { + c.push_str(&format!("{} {} {} {} k {} {} {} {} re f\n", n(*cy), n(*m), n(*yl), n(*k), n(x + i as f64 * BAR), n(y), n(BAR), n(BAR))); + } + c.push_str(&format!("/{REGISTRATION} cs 1 scn\n")); + } + if self.page_info { + let mut info = String::new(); + if !self.label.trim().is_empty() { + info.push_str(self.label.trim()); + info.push_str(" "); + } + info.push_str(&format!("{page} of {count}")); + if !self.date.trim().is_empty() { + info.push_str(" "); + info.push_str(self.date.trim()); + } + let info: String = info.chars().filter(|ch| pdfcraft_fonts::win_ansi_encodable(*ch)).collect(); + c.push_str(&format!("BT /PCHelv {} Tf 1 0 0 1 {} {} Tm ", n(INFO_SIZE), n(x0 + LENGTH + GAP + 12.0), n(y0 - GAP - BAR))); + c.push_str(&ascii_literal(&literal(&win_ansi(&info)))); + c.push_str(" Tj ET\n"); + } + c + } +} + +/// A PDF literal string made of WinAnsi bytes, as ASCII text: bytes from 0x80 up are written as +/// octal escapes, so they reach the content stream unchanged instead of being replaced when the +/// bytes are turned into a `String`. +fn ascii_literal(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len()); + for &b in bytes { + if b < 0x80 { + out.push(char::from(b)); + } else { + out.push_str(&format!("\\{b:03o}")); + } + } + out +} + +/// How far the chosen marks reach beyond the trim box on each side (left, bottom, right, top): +/// `reach`, or more where the bleed marks, which start at the bleed box, go further out. +fn side_reach(marks: &PrinterMarks, trim: [f64; 4], bleed: [f64; 4]) -> [f64; 4] { + let base = marks.reach(); + if !(marks.bleed && bleed != trim) { + return [base; 4]; + } + let beyond = |bleed_edge: f64, trim_edge: f64| base.max((trim_edge - bleed_edge).abs() + GAP + LENGTH); + [beyond(bleed[0], trim[0]), beyond(bleed[1], trim[1]), beyond(bleed[2], trim[2]), beyond(bleed[3], trim[3])] +} + +/// Add printer marks to `pages` (0-based). With `replace`, marks already there are removed +/// first. Returns the pages that were widened (media or crop box) to make room. +pub fn add_printer_marks(doc: &mut Document, pages: &[usize], marks: &PrinterMarks, replace: bool) -> Result, EditError> { + let all = page_list(doc); + check(pages, all.len())?; + if !marks.any() { + return Err(EditError::Invalid("choose at least one kind of mark".into())); + } + if !PrinterMarks::WEIGHTS.contains(&marks.line_weight) { + let (low, high) = (PrinterMarks::WEIGHTS.start(), PrinterMarks::WEIGHTS.end()); + return Err(EditError::Invalid(format!("the line weight must be from {low} to {high} points"))); + } + if marks.make_room.is_some_and(|room| !(0.0..=PrinterMarks::MAX_ROOM).contains(&room)) { + return Err(EditError::Invalid(format!("the room to make must be from 0 to {} points", PrinterMarks::MAX_ROOM))); + } + if replace { + remove_marks(doc, pages, MarkKind::PrinterMarks)?; + } + let count = all.len(); + let mut widened = Vec::new(); + for &i in pages { + let [media, crop, bleed, trim, art] = pdfcraft_organize_boxes(doc, i)?; + let side = side_reach(marks, trim, bleed); + let needed = [trim[0] - side[0], trim[1] - side[1], trim[2] + side[2], trim[3] + side[3]]; + let within = |b: [f64; 4]| b[0] <= needed[0] && b[1] <= needed[1] && b[2] >= needed[2] && b[3] >= needed[3]; + // The crop box is what shows, so the marks need room inside it, not only inside the media box. + if !within(crop) + && let Some(room) = marks.make_room + { + let new_media = if within(media) { + None + } else { + let room = |s: f64| room.max(s); + Some([ + media[0].min(trim[0] - room(side[0])), + media[1].min(trim[1] - room(side[1])), + media[2].max(trim[2] + room(side[2])), + media[3].max(trim[3] + room(side[3])), + ]) + }; + if new_media.is_some_and(|m| m[2] - m[0] > MAX_PAGE_SIDE || m[3] - m[1] > MAX_PAGE_SIDE) { + return Err(EditError::Invalid(format!("page {} would be wider than the {MAX_PAGE_SIDE} points a PDF page can be", i + 1))); + } + // Without a wider media box, only the crop box grows, to the marks and no further than the media box. + let new_crop = match new_media { + Some(m) => m, + None => [ + crop[0].min(needed[0]).max(media[0]), + crop[1].min(needed[1]).max(media[1]), + crop[2].max(needed[2]).min(media[2]), + crop[3].max(needed[3]).min(media[3]), + ], + }; + let rect = |r: [f64; 4]| Object::Array(r.iter().map(|v| Object::Real(*v)).collect()); + let page = page_list(doc)[i].clone(); + doc.update_dict(page.obj, |d| { + if let Some(m) = new_media { + d.set(b"MediaBox".to_vec(), rect(m)); + } + // The crop box shows the marks too. The trim, bleed and art boxes default to the + // crop box and are clipped to the media box, so each is written out as it was in + // effect, or it would move with the wider boxes and a second run would widen the + // page again. + d.set(b"CropBox".to_vec(), rect(new_crop)); + for (key, value) in [(&b"BleedBox"[..], bleed), (&b"TrimBox"[..], trim), (&b"ArtBox"[..], art)] { + d.set(key.to_vec(), rect(value)); + } + })?; + widened.push(i); + } + let page = page_list(doc)[i].clone(); + let content = marks.content(trim, bleed, i + 1, count); + let body = format!("{}{}{}", begin(MarkKind::PrinterMarks, "PrinterMarks", [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]), content, END); + add_resources(doc, &page, None, Some(body.as_bytes()))?; + let page = page_list(doc)[i].clone(); + // add_resources gave the page resources of its own. + let mut res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut spaces = res.get(b"ColorSpace").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()).unwrap_or_default(); + spaces.set(REGISTRATION.as_bytes().to_vec(), registration()); + res.set(b"ColorSpace".to_vec(), Object::Dict(spaces)); + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + let page = page_list(doc)[i].clone(); + place(doc, &page, MarkKind::PrinterMarks, body.into_bytes(), false)?; + } + Ok(widened) +} + +/// A page's boxes: media, crop, bleed, trim, art (each inherited or falling back as the +/// standard says), without depending on the organize crate. +fn pdfcraft_organize_boxes(doc: &Document, page: usize) -> Result<[[f64; 4]; 5], EditError> { + let p = page_list(doc).get(page).cloned().ok_or_else(|| EditError::Invalid(format!("page {} does not exist", page + 1)))?; + let rect = |k: &[u8]| -> Option<[f64; 4]> { + let a = p.dict.get(k).map(|o| doc.resolve(o))?; + let v: Vec = a.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); + (v.len() == 4 && v.iter().all(|x| x.is_finite())).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]) + }; + let media = rect(b"MediaBox").unwrap_or([0.0, 0.0, 612.0, 792.0]); + let clip = |r: [f64; 4]| [r[0].max(media[0]), r[1].max(media[1]), r[2].min(media[2]), r[3].min(media[3])]; + let crop = rect(b"CropBox").map(clip).unwrap_or(media); + let (bleed, trim, art) = (rect(b"BleedBox").map(clip), rect(b"TrimBox").map(clip), rect(b"ArtBox").map(clip)); + Ok([media, crop, bleed.unwrap_or(crop), trim.unwrap_or(crop), art.unwrap_or(crop)]) +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index f7740d60cd..7ab812f082 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -7816,6 +7816,52 @@ fn removing_marks_strips_wrappers_that_closed_open_state() { assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]); } +/// Printer marks: drawn outside the trim box as a removable page mark; the page +/// is widened to make room when asked and the trim box stays; running again replaces them. +#[test] +fn printer_marks_are_drawn_outside_the_trim_box_and_removable() { + use crate::printer_marks::PrinterMarks; + let mut doc = fixture(); + let page_content_text = |doc: &Document, page: usize| String::from_utf8_lossy(&page_content_bytes(doc, page)).into_owned(); + let page = page_list(&doc)[0].clone(); + // A trim box inside a letter-size page, 36 pt in: room for the marks, bleed marks included + // (the bleed box is 9 pt out, so they reach 33 pt beyond the trim box), without widening. + doc.update_dict(page.obj, |d| { + d.set(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, 612.0, 792.0].iter().map(|v| Object::Real(*v)).collect())); + d.set(b"TrimBox".to_vec(), Object::Array([36.0, 36.0, 576.0, 756.0].iter().map(|v| Object::Real(*v)).collect())); + d.set(b"BleedBox".to_vec(), Object::Array([27.0, 27.0, 585.0, 765.0].iter().map(|v| Object::Real(*v)).collect())); + }) + .unwrap(); + let marks = PrinterMarks { label: "Quote 1042.pdf".into(), date: "2026-10-13".into(), ..PrinterMarks::default() }; + let widened = add_printer_marks(&mut doc, &[0, 1], &marks, false).unwrap(); + assert_eq!(widened, vec![1], "page 2 has no trim box inside its media box, so it was widened"); + assert_eq!(marks_present(&doc), vec![MarkKind::PrinterMarks]); + let text = page_content_text(&doc, 0); + assert!(text.contains("/PCMark /PrinterMarks"), "{text}"); + // A trim mark at the lower-left corner: vertical below the box, horizontal to its left. + assert!(text.contains("36 30 m 36 12 l S") && text.contains("30 36 m 12 36 l S"), "{text}"); + assert!(text.contains("/PCReg CS 1 SCN /PCReg cs 1 scn"), "registration colour: {text}"); + assert!(!text.contains("1 1 1 1 K"), "not four process inks alone: {text}"); + assert!(text.contains("[1 1] 0 d") && text.contains("27 21 m 27 3 l S"), "dashed bleed marks: {text}"); + assert!(text.contains("1 0 0 0 k") && text.contains("0 0 0 0.25 k"), "colour bars: {text}"); + assert!(text.contains("(Quote 1042.pdf 1 of 3 2026-10-13) Tj"), "page information: {text}"); + // Page 2: the media box grew by 36 pt a side around its (whole-page) trim box. + let p2 = page_list(&doc)[1].clone(); + let media: Vec = p2.dict.get(b"MediaBox").and_then(|m| m.as_array()).unwrap().iter().filter_map(|v| v.as_f64()).collect(); + assert_eq!(media, vec![-36.0, -36.0, 636.0, 836.0]); + // Again with replace: still one set of marks per page; without replace, two. + add_printer_marks(&mut doc, &[0], &marks, true).unwrap(); + assert_eq!(page_content_text(&doc, 0).matches("/PCMark /PrinterMarks").count(), 1); + add_printer_marks(&mut doc, &[0], &marks, false).unwrap(); + assert_eq!(page_content_text(&doc, 0).matches("/PCMark /PrinterMarks").count(), 2); + assert_eq!(remove_marks(&mut doc, &[0, 1], MarkKind::PrinterMarks).unwrap(), 3); + assert!(marks_present(&doc).is_empty()); + // Nothing chosen, or a bad weight, is refused. + let none = PrinterMarks { trim: false, bleed: false, registration: false, color_bars: false, page_info: false, ..PrinterMarks::default() }; + assert!(add_printer_marks(&mut doc, &[0], &none, false).is_err()); + assert!(add_printer_marks(&mut doc, &[0], &PrinterMarks { line_weight: 0.0, ..PrinterMarks::default() }, false).is_err()); +} + /// #3087: flattening comments leaves alone what isn't a comment: links, and the non-markup /// multimedia and print-production annotations (3D, Screen, Movie, RichMedia, PrinterMark, /// TrapNet, Watermark), which it used to bake into the page and delete with their data. diff --git a/crates/edit/tests/printer_marks_accented_label.rs b/crates/edit/tests/printer_marks_accented_label.rs new file mode 100644 index 0000000000..ccb8eb5861 --- /dev/null +++ b/crates/edit/tests/printer_marks_accented_label.rs @@ -0,0 +1,12 @@ +//! The page information line keeps accented letters: WinAnsi bytes from 0x80 up must reach the +//! content stream as escapes, not be replaced with U+FFFD. + +use pdfcraft_edit::PrinterMarks; + +#[test] +fn accented_label_is_written_as_winansi_escapes() { + let marks = PrinterMarks { label: "Café.pdf".into(), ..PrinterMarks::default() }; + let content = marks.content([0.0, 0.0, 612.0, 792.0], [0.0, 0.0, 612.0, 792.0], 1, 1); + assert!(!content.contains('\u{FFFD}'), "no replacement character in the stream: {content:?}"); + assert!(content.contains("(Caf\\351.pdf 1 of 1)"), "é is the WinAnsi byte 0xE9 written as \\351: {content:?}"); +} diff --git a/crates/edit/tests/printer_marks_bleed_reach.rs b/crates/edit/tests/printer_marks_bleed_reach.rs new file mode 100644 index 0000000000..cb7590768c --- /dev/null +++ b/crates/edit/tests/printer_marks_bleed_reach.rs @@ -0,0 +1,50 @@ +//! Bleed marks start at the bleed box, so the room made for the marks counts the bleed too. + +use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_edit::{PrinterMarks, add_printer_marks}; + +fn rect(r: [f64; 4]) -> Object { + Object::Array(r.iter().map(|v| Object::Real(*v)).collect()) +} + +fn page_with(boxes: &[(&[u8], [f64; 4])]) -> Document { + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(pages)); + for (key, r) in boxes { + page.set(key.to_vec(), rect(*r)); + } + let page = doc.add(page); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + doc +} + +fn page_box(doc: &Document, key: &[u8]) -> Option> { + let page = pdfcraft_model::pages(doc).first().cloned()?; + let a = page.dict.get(key).map(|o| doc.resolve(o))?; + Some(a.as_array()?.iter().filter_map(|v| doc.resolve(v).as_f64()).collect()) +} + +/// A 9 pt bleed: the bleed marks run out to 9 + 6 + 18 = 33 pt from the trim box, further than the +/// usual 24, so the crop box grows by that much. +#[test] +fn crop_box_grows_to_hold_the_bleed_marks() { + let mut doc = page_with(&[ + (b"MediaBox", [0.0, 0.0, 1000.0, 1000.0]), + (b"CropBox", [200.0, 200.0, 800.0, 800.0]), + (b"TrimBox", [200.0, 200.0, 800.0, 800.0]), + (b"BleedBox", [191.0, 191.0, 809.0, 809.0]), + ]); + let marks = PrinterMarks::default(); + assert_eq!(marks.reach(), 24.0); + let widened = add_printer_marks(&mut doc, &[0], &marks, false).unwrap(); + assert_eq!(widened, vec![0]); + assert_eq!(page_box(&doc, b"CropBox"), Some(vec![167.0, 167.0, 833.0, 833.0])); + assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![200.0, 200.0, 800.0, 800.0])); +} diff --git a/crates/edit/tests/printer_marks_boxes.rs b/crates/edit/tests/printer_marks_boxes.rs new file mode 100644 index 0000000000..d236c48197 --- /dev/null +++ b/crates/edit/tests/printer_marks_boxes.rs @@ -0,0 +1,108 @@ +//! Printer marks and the page boxes: the marks must show inside the crop box, and running +//! again must not move the trim box or grow the page. + +use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_edit::{PrinterMarks, add_printer_marks}; + +fn rect(r: [f64; 4]) -> Object { + Object::Array(r.iter().map(|v| Object::Real(*v)).collect()) +} + +/// One empty page with these boxes set on it. +fn page_with(boxes: &[(&[u8], [f64; 4])]) -> Document { + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(pages)); + for (key, r) in boxes { + page.set(key.to_vec(), rect(*r)); + } + let page = doc.add(page); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + doc +} + +fn page_box(doc: &Document, key: &[u8]) -> Option> { + let page = pdfcraft_model::pages(doc).first().cloned()?; + let a = page.dict.get(key).map(|o| doc.resolve(o))?; + Some(a.as_array()?.iter().filter_map(|v| doc.resolve(v).as_f64()).collect()) +} + +/// A crop box cut to the trim inside a large media box: the marks fit the media box but +/// not the crop box, so the crop box grows to show them and the trim box stays. +#[test] +fn printer_marks_widen_crop_inside_media() { + let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 1000.0, 1000.0]), (b"CropBox", [200.0, 200.0, 800.0, 800.0])]); + let marks = PrinterMarks::default(); + let reach = marks.reach(); + let widened = add_printer_marks(&mut doc, &[0], &marks, false).unwrap(); + assert_eq!(widened, vec![0]); + assert_eq!(page_box(&doc, b"MediaBox"), Some(vec![0.0, 0.0, 1000.0, 1000.0]), "the media box already had room"); + let crop = page_box(&doc, b"CropBox").unwrap(); + assert_eq!(crop, vec![200.0 - reach, 200.0 - reach, 800.0 + reach, 800.0 + reach]); + assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![200.0, 200.0, 800.0, 800.0])); +} + +/// No trim box given: it defaults to the crop box, which widening moves. Running again with +/// replace must keep the trim box and the page size from the first run. +#[test] +fn printer_marks_rerun_keeps_trim() { + let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 612.0, 792.0])]); + let marks = PrinterMarks::default(); + add_printer_marks(&mut doc, &[0], &marks, false).unwrap(); + let media = page_box(&doc, b"MediaBox"); + let crop = page_box(&doc, b"CropBox"); + assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![0.0, 0.0, 612.0, 792.0])); + assert_eq!(page_box(&doc, b"BleedBox"), Some(vec![0.0, 0.0, 612.0, 792.0])); + let widened = add_printer_marks(&mut doc, &[0], &marks, true).unwrap(); + assert!(widened.is_empty(), "the page already has room"); + assert_eq!(page_box(&doc, b"MediaBox"), media, "the page did not grow again"); + assert_eq!(page_box(&doc, b"CropBox"), crop); + assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![0.0, 0.0, 612.0, 792.0])); +} + +/// A trim box past the media box is clipped to it. Widening the media box must not let it +/// grow to what was written. +#[test] +fn printer_marks_keep_the_trim_box_in_effect() { + let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 100.0, 100.0]), (b"TrimBox", [-20.0, -20.0, 120.0, 120.0])]); + add_printer_marks(&mut doc, &[0], &PrinterMarks::default(), false).unwrap(); + assert_eq!(page_box(&doc, b"TrimBox"), Some(vec![0.0, 0.0, 100.0, 100.0])); +} + +/// Line weights and room out of range are refused, not clamped, and nothing past the largest +/// page a PDF can have is written. +#[test] +fn printer_marks_refuse_numbers_out_of_range() { + let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 612.0, 792.0])]); + for line_weight in [-1.0, 0.0, 0.05, 5.0, 10_000.0, f64::NAN, f64::INFINITY] { + assert!(add_printer_marks(&mut doc, &[0], &PrinterMarks { line_weight, ..PrinterMarks::default() }, false).is_err(), "{line_weight}"); + } + for room in [-1.0, 289.0, 1e9, f64::NAN, f64::INFINITY] { + assert!(add_printer_marks(&mut doc, &[0], &PrinterMarks { make_room: Some(room), ..PrinterMarks::default() }, false).is_err(), "{room}"); + } + let mut big = page_with(&[(b"MediaBox", [0.0, 0.0, 14_400.0, 1000.0])]); + assert!(add_printer_marks(&mut big, &[0], &PrinterMarks::default(), false).is_err()); + assert!(add_printer_marks(&mut big, &[0], &PrinterMarks { make_room: None, ..PrinterMarks::default() }, false).is_ok(), "left as it is"); +} + +/// Registration colour is the /All Separation, in the page's resources beside what it had. +#[test] +fn printer_marks_use_the_all_separation() { + let mut doc = page_with(&[(b"MediaBox", [0.0, 0.0, 612.0, 792.0])]); + add_printer_marks(&mut doc, &[0], &PrinterMarks::default(), false).unwrap(); + let page = pdfcraft_model::pages(&doc).swap_remove(0); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()); + let res = res.as_dict().unwrap(); + assert!(res.get(b"Font").is_some()); + let spaces = doc.resolve(res.get(b"ColorSpace").unwrap()); + let cs = doc.resolve(spaces.as_dict().unwrap().get(b"PCReg").unwrap()); + let cs = cs.as_array().unwrap(); + assert_eq!(cs[..3], [Object::name("Separation"), Object::name("All"), Object::name("DeviceCMYK")]); + assert!(cs[3].as_dict().unwrap().int(b"FunctionType") == Some(2)); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index e479232bbb..e96cc918af 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -411,7 +411,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Preflight", "file-check", "preflight.run", Planned("M11")), item("Convert colors", "palette", "prepress.convert_colors", Planned("M11")), item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready), - item("Add printer marks", "ruler", "prepress.marks", Planned("M11")), + item("Add printer marks", "ruler", "prepress.marks", Ready), item("Fix hairlines", "minus", "prepress.hairlines", Planned("M11")), ], }], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0647726452..054c618ea9 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -397,6 +397,8 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"), c("page.split", "Split document…", PAGES, None, Assembly, "scissors"), c("page.number", "Number pages…", PAGES, None, Assembly, "hash"), + c("prepress.marks", "Add printer marks…", None, None, Modification, "ruler"), + c("prepress.marks.remove", "Remove printer marks", None, None, Marks(crate::MarkKind::PrinterMarks), "ruler"), c("page.properties", "Page properties…", PAGES, None, Modification, "info"), c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"), c("help.discord", "Join the ArtCraft Discord", HELP, None, Nothing, "messages-square"), diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 8f43689aa9..77bb0271e1 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -4,9 +4,10 @@ use std::sync::Arc; use crate::{ AddedContent, AddedText, Background, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit, InitialView, Label, - LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, Protection, ReviewState, - Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image, field_table, fit_picture, - image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, + LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, PrinterMarks, + Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image, + field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, + xfa_after_data_change, }; mod labels; @@ -305,6 +306,14 @@ pub enum Edit { }, /// Document Properties ▸ Initial View (and the language and binding). SetInitialView(Box), + /// Print production ▸ Add printer marks: trim and bleed marks, registration targets, colour + /// bars and page information outside the trim box, as a page mark; the page may be widened + /// to make room (`settings.make_room`), so the page geometry is read again. + AddPrinterMarks { + pages: Vec, + settings: PrinterMarks, + replace: bool, + }, /// Edit ▸ Attach a file: a document-level attachment (`/Names /EmbeddedFiles`) under /// `name`, made unique; `mime` is the embedded file's `/Subtype` when known. AddAttachment { diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 5343804ef3..b8d79f9a4c 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -284,6 +284,8 @@ impl Edit { Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => Label::of("Remove header & footer"), Edit::RemoveMarks { kind: MarkKind::Watermark } => Label::of("Remove watermark"), Edit::RemoveMarks { kind: MarkKind::Background } => Label::of("Remove background"), + Edit::RemoveMarks { kind: MarkKind::PrinterMarks } => Label::of("Remove printer marks"), + Edit::AddPrinterMarks { .. } => Label::of("Add printer marks"), Edit::AddText { .. } => Label::of("Add text"), Edit::AddImage { .. } => Label::of("Add image"), Edit::UpdateContent { .. } => Label::of("Edit content"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 5ed70aed7a..72861fe748 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -96,6 +96,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re } Edit::SetInfo { .. } | Edit::AddHeaderFooter { .. } + | Edit::AddPrinterMarks { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. } @@ -704,6 +705,9 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::MarkTagArtifact { object } => { a11y::mark_tag_artifact(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; } + Edit::AddPrinterMarks { pages, settings, replace } => { + pdfcraft_edit::add_printer_marks(doc, pages, settings, *replace)?; + } Edit::AddHeaderFooter { pages, settings, replace } => { let date = cx.today; pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index fafa65b4c2..75af5d8fcb 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -47,6 +47,7 @@ pub(crate) fn uses_scripts(edit: &Edit) -> bool { pub(crate) fn scope_of(edit: &Edit) -> Scope { match edit { Edit::SetInfo { .. } => Scope::Metadata, + Edit::AddPrinterMarks { .. } => Scope::Full, // The Attachments list is read from the whole document. Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } => Scope::Full, // A file attachment also changes the Attachments list. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fe139164be..9e64e0c523 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -86,7 +86,8 @@ pub use pdfcraft_edit::added::{ }; pub use pdfcraft_edit::paragraph_align; pub use pdfcraft_edit::{ - Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, + Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, + PrinterMarks, Watermark, }; pub use pdfcraft_forms::{ BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index be7fd5aca5..48b7f00088 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -417,7 +417,14 @@ impl Session { ) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) }); let touches_marks = edit.is_none_or(|e| { - matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. }) + matches!( + e, + Edit::AddHeaderFooter { .. } + | Edit::AddWatermark { .. } + | Edit::AddBackground { .. } + | Edit::AddPrinterMarks { .. } + | Edit::RemoveMarks { .. } + ) }); (touches_added, touches_marks) } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 43d0066e63..49dbc865ee 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -295,6 +295,7 @@ impl PdfCraftApp { replace_draft: None, number_draft: NumberDraft { from: 1, to: 1, style: pdfcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 }, word_count_draft: word_count_ui::Draft::default(), + printer_marks_draft: Default::default(), } } diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index e4078efd13..c7dd79e1b0 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -110,6 +110,7 @@ impl PdfCraftApp { "header-footer" => Some(Dialog::Marks(pdfcraft_engine::MarkKind::HeaderFooter)), "watermark" => Some(Dialog::Marks(pdfcraft_engine::MarkKind::Watermark)), "background" => Some(Dialog::Marks(pdfcraft_engine::MarkKind::Background)), + "printer-marks" => Some(Dialog::PrinterMarks), "export-image" => Some(Dialog::Export(export_ui::ExportKind::Image)), "word-count" => Some(Dialog::WordCount), "export-text" => Some(Dialog::Export(export_ui::ExportKind::Text)), diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index f072b7febe..cebd86e3a2 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -132,6 +132,7 @@ impl PdfCraftApp { pdfcraft_engine::MarkKind::HeaderFooter => tl!("header or footer"), pdfcraft_engine::MarkKind::Watermark => tl!("watermark"), pdfcraft_engine::MarkKind::Background => tl!("background"), + pdfcraft_engine::MarkKind::PrinterMarks => tl!("printer marks"), }; crate::i18n::fmt(tl!("This document has no {kind} to change"), &[("kind", kind)]) } @@ -510,11 +511,13 @@ impl PdfCraftApp { self.marks_draft.preview = None; self.dialog = Some(Dialog::Marks(pdfcraft_engine::MarkKind::HeaderFooter)); } - "edit.header_footer.remove" | "edit.watermark.remove" | "edit.background.remove" => { + "prepress.marks" => self.dialog = Some(Dialog::PrinterMarks), + "edit.header_footer.remove" | "edit.watermark.remove" | "edit.background.remove" | "prepress.marks.remove" => { use pdfcraft_engine::MarkKind as K; let kind = match id { "edit.header_footer.remove" => K::HeaderFooter, "edit.watermark.remove" => K::Watermark, + "prepress.marks.remove" => K::PrinterMarks, _ => K::Background, }; self.apply_edit(Edit::RemoveMarks { kind }); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 42bed83657..592ebcc524 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -14,6 +14,7 @@ pub(super) mod credits; pub(super) mod marks_ui; pub(super) mod pageboxes; pub(super) mod palette; +pub(super) mod printer_marks_ui; mod prompts; pub(super) mod protect; pub(super) mod standards_ui; @@ -39,6 +40,7 @@ struct Pending { link_command: Option<&'static str>, protect_now: bool, boxes_now: bool, + printer_marks_now: bool, marks_now: bool, export_now: bool, props_now: bool, @@ -423,6 +425,12 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m req.close = req.apply || cancel; return false; } + Dialog::PrinterMarks => { + let (go, cancel) = crate::printer_marks_ui::body(ui, app, &t); + req.printer_marks_now = go; + req.close = go || cancel; + return false; + } Dialog::BookmarksFromTextStyles => { req.close = crate::bookmark_styles_ui::body(ui, app, &t); return false; @@ -1922,6 +1930,9 @@ fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { app.protect_draft = Default::default(); app.notify_tr("Password protection will be applied when you save"); } + if req.printer_marks_now { + app.add_printer_marks(); + } if req.apply_number && let Some(edit) = req.number_now.take() { diff --git a/crates/ui-egui/src/dialogs/marks_ui.rs b/crates/ui-egui/src/dialogs/marks_ui.rs index 085c581119..45e0002fa3 100644 --- a/crates/ui-egui/src/dialogs/marks_ui.rs +++ b/crates/ui-egui/src/dialogs/marks_ui.rs @@ -469,6 +469,8 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M MarkKind::HeaderFooter => crate::i18n::fmt(tl!("{verb} Header and Footer"), &[("verb", verb)]), MarkKind::Watermark => crate::i18n::fmt(tl!("{verb} Watermark"), &[("verb", verb)]), MarkKind::Background => crate::i18n::fmt(tl!("{verb} Background"), &[("verb", verb)]), + // Printer marks have a dialog of their own (printer_marks_ui); this one is never opened for them. + MarkKind::PrinterMarks => tl!("Add printer marks").to_string(), }; ui.label(egui::RichText::new(title).font(theme::semibold(18.0))); ui.add_space(8.0); @@ -603,6 +605,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M }); }); } + MarkKind::PrinterMarks => {} MarkKind::Background => { ui.horizontal(|ui| { widgets::radio_value(ui, &mut d.use_file, false, tl!("From colour")); @@ -714,6 +717,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M } // A colour is the page's tint. MarkKind::Background => {} + MarkKind::PrinterMarks => {} }); ui.vertical(|ui| { ui.horizontal(|ui| { @@ -764,6 +768,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M } } MarkKind::Background => !d.use_file || d.file.is_some(), + MarkKind::PrinterMarks => true, } && !d.range.pages(count).is_empty(); if ui.add_enabled_ui(ready, |ui| widgets::pill_button(ui, tl!("OK"), true)).inner.clicked() { @@ -799,7 +804,7 @@ fn undrawable(kind: MarkKind, d: &MarksDraft) -> Option { match kind { MarkKind::HeaderFooter => d.hf.first_undrawable(), MarkKind::Watermark if !d.use_file => d.wm.first_undrawable(), - MarkKind::Watermark | MarkKind::Background => None, + MarkKind::Watermark | MarkKind::Background | MarkKind::PrinterMarks => None, } } @@ -878,6 +883,7 @@ pub fn edit(d: &MarksDraft, kind: MarkKind, count: usize) -> Edit { Edit::AddWatermark { pages, settings: wm, replace: d.replace, file: mark_file(d) } } MarkKind::Background => Edit::AddBackground { pages, settings: d.bg.clone(), replace: d.replace, file: mark_file(d) }, + MarkKind::PrinterMarks => Edit::AddPrinterMarks { pages, settings: pdfcraft_engine::PrinterMarks::default(), replace: d.replace }, } } diff --git a/crates/ui-egui/src/dialogs/printer_marks_ui.rs b/crates/ui-egui/src/dialogs/printer_marks_ui.rs new file mode 100644 index 0000000000..5331cfa3a5 --- /dev/null +++ b/crates/ui-egui/src/dialogs/printer_marks_ui.rs @@ -0,0 +1,152 @@ +//! Print production ▸ Add printer marks (Acrobat's Add Printer Marks): which +//! marks, the line weight, whether to widen the page to make room, and the pages. The marks are +//! a removable page mark (Remove printer marks takes them off; adding again replaces them). + +use pdfcraft_engine::{Edit, PrinterMarks}; + +use crate::pageboxes::Range; +use crate::theme::{self, Tokens}; +use crate::{PdfCraftApp, widgets}; + +#[derive(Clone, Debug, PartialEq)] +pub struct PrinterMarksDraft { + pub settings: PrinterMarks, + pub range: Range, + /// Widen the page when the marks would fall outside it (`settings.make_room`). + pub make_room: bool, + pub room: f64, +} + +impl Default for PrinterMarksDraft { + fn default() -> Self { + Self { settings: PrinterMarks::default(), range: Range::All, make_room: true, room: 36.0 } + } +} + +impl PrinterMarksDraft { + fn all(&self) -> bool { + let s = &self.settings; + s.trim && s.bleed && s.registration && s.color_bars && s.page_info + } + + fn set_all(&mut self, on: bool) { + let s = &mut self.settings; + s.trim = on; + s.bleed = on; + s.registration = on; + s.color_bars = on; + s.page_info = on; + } + + fn any(&self) -> bool { + let s = &self.settings; + s.trim || s.bleed || s.registration || s.color_bars || s.page_info + } + + /// The edit for `count` pages with `current` on screen, labelled with the file's name and + /// today's date. + pub fn edit(&self, current: usize, count: usize, label: &str, date: &str) -> Edit { + let pages = match self.range { + Range::All => (0..count).collect(), + Range::Current => vec![current.min(count.saturating_sub(1))], + Range::Pages(a, b) => (a.max(1) - 1..b.min(count)).collect(), + }; + let settings = PrinterMarks { + make_room: self.make_room.then_some(self.room.max(0.0)), + label: label.to_string(), + date: date.to_string(), + ..self.settings.clone() + }; + Edit::AddPrinterMarks { pages, settings, replace: true } + } +} + +/// The dialog: (add, cancel). +pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) { + let Some((i, id)) = app.active_ids() else { return (false, true) }; + let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); + let current = app.views[i].current; + let d = &mut app.printer_marks_draft; + ui.label(egui::RichText::new(tl!("Add printer marks")).font(theme::semibold(18.0))); + ui.add_space(8.0); + widgets::section_title(ui, tl!("Marks")); + let mut all = d.all(); + if ui.add(widgets::checkbox(&mut all, tl!("All marks"))).changed() { + d.set_all(all); + } + ui.horizontal(|ui| { + ui.add(widgets::checkbox(&mut d.settings.trim, tl!("Trim marks"))); + ui.add(widgets::checkbox(&mut d.settings.bleed, tl!("Bleed marks"))); + ui.add(widgets::checkbox(&mut d.settings.registration, tl!("Registration marks"))); + }); + ui.horizontal(|ui| { + ui.add(widgets::checkbox(&mut d.settings.color_bars, tl!("Colour bars"))); + ui.add(widgets::checkbox(&mut d.settings.page_info, tl!("Page information"))); + }); + ui.horizontal(|ui| { + let weight_label = ui.label(tl!("Line weight")); + let weights = [(0.25, "0.25 pt"), (0.5, "0.50 pt"), (1.0, "1.00 pt")]; + let shown = weights.iter().find(|(w, _)| (*w - d.settings.line_weight).abs() < 1e-6).map_or("0.25 pt", |(_, l)| l); + let combo = craft_ui::ComboBox::from_id_salt("marks-weight").selected_text(shown).width(100.0).show_ui(ui, |ui| { + for (w, l) in weights { + ui.selectable_value(&mut d.settings.line_weight, w, l); + } + }); + combo.response.labelled_by(weight_label.id); + }); + widgets::section_title(ui, tl!("Page")); + ui.horizontal(|ui| { + ui.add(widgets::checkbox(&mut d.make_room, tl!("Widen the page to make room"))); + ui.add_enabled_ui(d.make_room, |ui| { + let room_label = ui.label(tl!("Room on each side")); + ui.add(egui::DragValue::new(&mut d.room).range(0.0..=PrinterMarks::MAX_ROOM).suffix(" pt")).labelled_by(room_label.id); + }); + }); + ui.label(egui::RichText::new(tl!("The trim box is kept where it is, so the printed page cuts the same.")).small().color(t.text_muted)); + widgets::section_title(ui, tl!("Pages")); + ui.horizontal(|ui| { + widgets::radio_value(ui, &mut d.range, Range::All, tl!("All")); + widgets::radio_value(ui, &mut d.range, Range::Current, crate::i18n::fmt(tl!("Current page ({n})"), &[("n", &(current + 1).to_string())])); + let (mut a, mut b) = match d.range { + Range::Pages(a, b) => (a, b), + _ => (1, count.max(1)), + }; + let on = matches!(d.range, Range::Pages(..)); + if ui.add(widgets::radio(on, tl!("From"))).clicked() { + d.range = Range::Pages(a, b); + } + let ra = ui.add(egui::DragValue::new(&mut a).range(1..=count.max(1))); + ui.label(tl!("to")); + let rb = ui.add(egui::DragValue::new(&mut b).range(1..=count.max(1))); + if ra.changed() || rb.changed() { + d.range = Range::Pages(a.min(b), b.max(a)); + } + }); + ui.add_space(12.0); + let (mut go, mut cancel) = (false, false); + let ready = d.any() && count > 0; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.add_enabled_ui(ready, |ui| widgets::pill_button(ui, tl!("Add"), true)).inner.clicked() { + go = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + cancel = true; + } + }); + (go, cancel) +} + +impl PdfCraftApp { + /// Add: the marks on the chosen pages, labelled with the file's name and today's date. + pub(crate) fn add_printer_marks(&mut self) { + let Some((i, id)) = self.active_ids() else { return }; + let Some(doc) = self.session.get(id) else { return }; + let (count, label) = (doc.info.pages.len(), doc.name.clone()); + let (y, m, d) = self.session.today(); + let date = format!("{y:04}-{m:02}-{d:02}"); + let edit = self.printer_marks_draft.edit(self.views[i].current, count, &label, &date); + if self.apply_edit(edit) { + self.notify_tr("Printer marks added"); + } + } +} diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 1b08929307..9d703752b0 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2815,6 +2815,21 @@ certificate Version الإصدار Edit this PDF تحرير هذا الملف PDF Export this PDF تصدير هذا الملف PDF Find text or tools البحث عن نص أو أدوات + Add printer marks… إضافة علامات الطباعة… + Remove printer marks إزالة علامات الطباعة + Marks العلامات + All marks كل العلامات + Trim marks علامات القص + Bleed marks علامات النزيف + Registration marks علامات التسجيل + Colour bars أشرطة الألوان + Page information معلومات الصفحة + Line weight سُمك الخط + Widen the page to make room توسيع الصفحة لإفساح المجال + Room on each side المساحة على كل جانب + The trim box is kept where it is, so the printed page cuts the same. يبقى مربع القص في مكانه، فتُقص الصفحة المطبوعة كما هي. + Printer marks added تمت إضافة علامات الطباعة + printer marks علامات الطباعة Create & Edit الإنشاء والتحرير Review & Compare المراجعة والمقارنة Forms & Signatures النماذج والتوقيعات diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e269febf18..76edd04473 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2502,6 +2502,21 @@ animation effects Off Изключени Edit this PDF Редактирай този PDF Export this PDF Експортирай този PDF Find text or tools Търсене на текст или инструменти + Add printer marks… Добавяне на печатарски знаци… + Remove printer marks Премахване на печатарските знаци + Marks Знаци + All marks Всички знаци + Trim marks Знаци за обрязване + Bleed marks Знаци за наддаване + Registration marks Знаци за регистрация + Colour bars Цветни ленти + Page information Информация за страницата + Line weight Дебелина на линията + Widen the page to make room Разширяване на страницата, за да има място + Room on each side Място от всяка страна + The trim box is kept where it is, so the printed page cuts the same. Полето за обрязване остава на мястото си, така че отпечатаната страница се реже по същия начин. + Printer marks added Печатарските знаци са добавени + printer marks печатарски знаци Create & Edit Създаване и редактиране Review & Compare Преглед и сравнение Forms & Signatures Формуляри и подписи diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 7ff0f83fee..97564b02ff 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2846,6 +2846,21 @@ theme Appearance Aspecte Unpin tool Desenganxa l'eina Arrange image Organitza la imatge Crop Escapça + Add printer marks… Afegeix marques d'impressora… + All marks Totes les marques + Bleed marks Marques de sang + Colour bars Barres de color + Line weight Gruix de línia + Marks Marques + Page information Informació de la pàgina + Printer marks added S'han afegit les marques d'impressora + Registration marks Marques de registre + Remove printer marks Elimina les marques d'impressora + Room on each side Espai a cada costat + The trim box is kept where it is, so the printed page cuts the same. El quadre de retall es manté on és, de manera que la pàgina impresa es talla igual. + Trim marks Marques de retall + Widen the page to make room Amplia la pàgina per fer-hi lloc + printer marks marques d'impressora # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Avui diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 22f6e3e056..5659fbb18d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2613,6 +2613,21 @@ animation effects Off Vypnuto Edit this PDF Upravit toto PDF Export this PDF Exportovat toto PDF Find text or tools Hledat text nebo nástroje + Add printer marks… Přidat tiskové značky… + Remove printer marks Odebrat tiskové značky + Marks Značky + All marks Všechny značky + Trim marks Ořezové značky + Bleed marks Značky spadávky + Registration marks Soutiskové značky + Colour bars Barevné pruhy + Page information Informace o stránce + Line weight Tloušťka čáry + Widen the page to make room Rozšířit stránku, aby bylo místo + Room on each side Místo na každé straně + The trim box is kept where it is, so the printed page cuts the same. Ořezový rámeček zůstává na místě, takže se vytištěná stránka ořízne stejně. + Printer marks added Tiskové značky přidány + printer marks tiskové značky Create & Edit Vytváření a úpravy Review & Compare Revize a porovnání Forms & Signatures Formuláře a podpisy diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c9ce7ae36..508958c5da 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2540,6 +2540,21 @@ animation effects Off Aus Edit this PDF Dieses PDF bearbeiten Export this PDF Dieses PDF exportieren Find text or tools Text oder Werkzeuge suchen + Add printer marks… Druckmarken hinzufügen… + Remove printer marks Druckmarken entfernen + Marks Marken + All marks Alle Marken + Trim marks Schnittmarken + Bleed marks Beschnittmarken + Registration marks Passermarken + Colour bars Farbbalken + Page information Seiteninformationen + Line weight Linienstärke + Widen the page to make room Seite verbreitern, um Platz zu schaffen + Room on each side Platz auf jeder Seite + The trim box is kept where it is, so the printed page cuts the same. Der Endformatrahmen bleibt, wo er ist, sodass die gedruckte Seite gleich beschnitten wird. + Printer marks added Druckmarken hinzugefügt + printer marks Druckmarken Create & Edit Erstellen & Bearbeiten Review & Compare Prüfen & Vergleichen Forms & Signatures Formulare & Signaturen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d83bf4aef7..9aaefad43e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2546,6 +2546,21 @@ animation effects Off Ανενεργά Edit this PDF Επεξεργασία αυτού του PDF Export this PDF Εξαγωγή αυτού του PDF Find text or tools Εύρεση κειμένου ή εργαλείων + Add printer marks… Προσθήκη σημαδιών εκτύπωσης… + Remove printer marks Αφαίρεση σημαδιών εκτύπωσης + Marks Σημάδια + All marks Όλα τα σημάδια + Trim marks Σημάδια ξακρίσματος + Bleed marks Σημάδια ξεχειλώματος + Registration marks Σημάδια σύμπτωσης + Colour bars Χρωματικές ράβδοι + Page information Πληροφορίες σελίδας + Line weight Πάχος γραμμής + Widen the page to make room Διεύρυνση της σελίδας για χώρο + Room on each side Χώρος σε κάθε πλευρά + The trim box is kept where it is, so the printed page cuts the same. Το πλαίσιο ξακρίσματος μένει όπου είναι, ώστε η τυπωμένη σελίδα να κόβεται ίδια. + Printer marks added Προστέθηκαν σημάδια εκτύπωσης + printer marks σημάδια εκτύπωσης Create & Edit Δημιουργία και επεξεργασία Review & Compare Αναθεώρηση και σύγκριση Forms & Signatures Φόρμες και υπογραφές diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 131d2dacaf..d84bbbc05f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2709,6 +2709,21 @@ animation effects Off Desactivados Edit this PDF Editar este PDF Export this PDF Exportar este PDF Find text or tools Buscar texto o herramientas + Add printer marks… Añadir marcas de impresión… + Remove printer marks Quitar marcas de impresión + Marks Marcas + All marks Todas las marcas + Trim marks Marcas de recorte + Bleed marks Marcas de sangrado + Registration marks Marcas de registro + Colour bars Barras de color + Page information Información de página + Line weight Grosor de línea + Widen the page to make room Ensanchar la página para hacer sitio + Room on each side Espacio a cada lado + The trim box is kept where it is, so the printed page cuts the same. El cuadro de recorte se mantiene donde está, así que la página impresa se corta igual. + Printer marks added Marcas de impresión añadidas + printer marks marcas de impresión Attach a file… Adjuntar un archivo… Delete attachment Eliminar adjunto Attaching files is not available on the web yet Adjuntar archivos aún no está disponible en la web diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 8c1a9baab9..842af58ebd 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2856,6 +2856,21 @@ theme Appearance Teema Unpin tool Irrota työkalu Arrange image Järjestä kuva Crop Rajaa + Add printer marks… Lisää painomerkit… + All marks Kaikki merkit + Bleed marks Leikkuuvaramerkit + Colour bars Värikaistat + Line weight Viivan paksuus + Marks Merkit + Page information Sivun tiedot + Printer marks added Painomerkit lisätty + Registration marks Rekisterimerkit + Remove printer marks Poista painomerkit + Room on each side Tilaa joka sivulla + The trim box is kept where it is, so the printed page cuts the same. Leikkuulaatikko pidetään paikallaan, joten painettu sivu leikataan samoin. + Trim marks Leikkuumerkit + Widen the page to make room Levennä sivua tilan tekemiseksi + printer marks painomerkit # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Tänään diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1c22cf9d81..2afcd3ff80 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2539,6 +2539,21 @@ animation effects Off Désactivés Edit this PDF Modifier ce PDF Export this PDF Exporter ce PDF Find text or tools Rechercher du texte ou des outils + Add printer marks… Ajouter des repères d'impression… + Remove printer marks Supprimer les repères d'impression + Marks Repères + All marks Tous les repères + Trim marks Traits de coupe + Bleed marks Repères de fond perdu + Registration marks Repères de montage + Colour bars Gammes de couleurs + Page information Informations sur la page + Line weight Épaisseur de trait + Widen the page to make room Élargir la page pour faire de la place + Room on each side Marge de chaque côté + The trim box is kept where it is, so the printed page cuts the same. La zone de rognage reste en place : la page imprimée se coupe de la même façon. + Printer marks added Repères d'impression ajoutés + printer marks repères d'impression Create & Edit Créer et modifier Review & Compare Réviser et comparer Forms & Signatures Formulaires et signatures diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 52c5450bb3..3e32357a6c 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2816,6 +2816,21 @@ theme Appearance स्वरूप Remove crop क्रॉप हटाएँ Send backward पीछे भेजें Send to back सबसे पीछे भेजें + Add printer marks… प्रिंटर मार्क जोड़ें… + All marks सभी मार्क + Bleed marks ब्लीड मार्क + Colour bars कलर बार + Line weight रेखा की मोटाई + Marks मार्क + Page information पृष्ठ की जानकारी + Printer marks added प्रिंटर मार्क जोड़े गए + Registration marks रजिस्ट्रेशन मार्क + Remove printer marks प्रिंटर मार्क हटाएँ + Room on each side हर ओर जगह + The trim box is kept where it is, so the printed page cuts the same. ट्रिम बॉक्स वहीं रखा जाता है जहाँ है, इसलिए प्रिंट किया गया पृष्ठ उसी तरह कटता है। + Trim marks ट्रिम मार्क + Widen the page to make room जगह बनाने के लिए पृष्ठ चौड़ा करें + printer marks प्रिंटर मार्क # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today आज diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 47d9c24b40..a66a051492 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2539,6 +2539,21 @@ animation effects Off Ki Edit this PDF Ezen PDF szerkesztése Export this PDF Ezen PDF exportálása Find text or tools Szöveg vagy eszközök keresése + Add printer marks… Nyomdai jelek hozzáadása… + Remove printer marks Nyomdai jelek eltávolítása + Marks Jelek + All marks Minden jel + Trim marks Vágójelek + Bleed marks Kifutójelek + Registration marks Illesztőjelek + Colour bars Színcsíkok + Page information Oldalinformáció + Line weight Vonalvastagság + Widen the page to make room Az oldal szélesítése, hogy legyen hely + Room on each side Hely mindkét oldalon + The trim box is kept where it is, so the printed page cuts the same. A vágókeret a helyén marad, így a nyomtatott oldal ugyanúgy vágható. + Printer marks added Nyomdai jelek hozzáadva + printer marks nyomdai jelek Create & Edit Létrehozás és szerkesztés Review & Compare Áttekintés és összehasonlítás Forms & Signatures Űrlapok és aláírások diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 89152f62a4..8520086970 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2817,6 +2817,21 @@ theme Appearance Tampilan Remove crop Hapus pangkasan Send backward Mundur Selangkah Send to back Kirim ke Belakang + Add printer marks… Tambahkan tanda printer… + All marks Semua tanda + Bleed marks Tanda bleed + Colour bars Bilah warna + Line weight Ketebalan garis + Marks Tanda + Page information Informasi halaman + Printer marks added Tanda printer ditambahkan + Registration marks Tanda registrasi + Remove printer marks Hapus tanda printer + Room on each side Ruang di setiap sisi + The trim box is kept where it is, so the printed page cuts the same. Kotak pangkas tetap di tempatnya, sehingga halaman cetak terpotong sama persis. + Trim marks Tanda pangkas + Widen the page to make room Lebarkan halaman untuk memberi ruang + printer marks tanda printer # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Hari ini diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0cae8f3e98..95ef56c3cc 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2751,6 +2751,21 @@ certificate SHA-256 digest Impronta SHA-256 Edit this PDF Modifica questo PDF Export this PDF Esporta questo PDF Find text or tools Trova testo o strumenti + Add printer marks… Aggiungi segni di stampa… + Remove printer marks Rimuovi segni di stampa + Marks Segni + All marks Tutti i segni + Trim marks Segni di rifilo + Bleed marks Segni di abbondanza + Registration marks Segni di registro + Colour bars Barre colore + Page information Informazioni pagina + Line weight Spessore linea + Widen the page to make room Allarga la pagina per fare spazio + Room on each side Spazio su ogni lato + The trim box is kept where it is, so the printed page cuts the same. Il riquadro di rifilo resta dov'è, così la pagina stampata si taglia allo stesso modo. + Printer marks added Segni di stampa aggiunti + printer marks segni di stampa Create & Edit Crea e modifica Review & Compare Revisione e confronto Forms & Signatures Moduli e firme diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index abc616b817..bf5c1f0961 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2539,6 +2539,21 @@ animation effects Off オフ Edit this PDF この PDF を編集 Export this PDF この PDF を書き出す Find text or tools テキストまたはツールを検索 + Add printer marks… プリンターマークを追加… + Remove printer marks プリンターマークを削除 + Marks マーク + All marks すべてのマーク + Trim marks トンボ(仕上がり) + Bleed marks 裁ち落としマーク + Registration marks レジストレーションマーク + Colour bars カラーバー + Page information ページ情報 + Line weight 線の太さ + Widen the page to make room 余白を作るためにページを広げる + Room on each side 各辺の余白 + The trim box is kept where it is, so the printed page cuts the same. 仕上がりボックスはそのままなので、印刷したページは同じ位置で裁断されます。 + Printer marks added プリンターマークを追加しました + printer marks プリンターマーク Create & Edit 作成と編集 Review & Compare レビューと比較 Forms & Signatures フォームと署名 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index fc6a2d783a..1928e156e5 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2537,6 +2537,21 @@ animation effects Off Өшірулі Edit this PDF Осы PDF файлын түзету Export this PDF Осы PDF файлын экспорттау Find text or tools Мәтінді немесе құралдарды табу + Add printer marks… Басып шығару белгілерін қосу… + Remove printer marks Басып шығару белгілерін алып тастау + Marks Белгілер + All marks Барлық белгілер + Trim marks Кесу белгілері + Bleed marks Шығыңқы жиек белгілері + Registration marks Тіркеу белгілері + Colour bars Түс жолақтары + Page information Бет туралы ақпарат + Line weight Сызық қалыңдығы + Widen the page to make room Орын босату үшін бетті кеңейту + Room on each side Әр жақтағы орын + The trim box is kept where it is, so the printed page cuts the same. Кесу жақтауы орнында қалады, сондықтан басылған бет сол күйінде кесіледі. + Printer marks added Басып шығару белгілері қосылды + printer marks басып шығару белгілері Create & Edit Жасау және өңдеу Review & Compare Шолу және салыстыру Forms & Signatures Пішіндер және қолтаңбалар diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 20315b5fcf..5d71bb59fb 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2816,6 +2816,21 @@ theme Appearance 모양 Remove crop 자르기 제거 Send backward 뒤로 보내기 Send to back 맨 뒤로 보내기 + Add printer marks… 인쇄용 표시 추가… + All marks 모든 표시 + Bleed marks 재단 여유 표시 + Colour bars 색상 막대 + Line weight 선 두께 + Marks 표시 + Page information 페이지 정보 + Printer marks added 인쇄용 표시를 추가했습니다 + Registration marks 등록 표시 + Remove printer marks 인쇄용 표시 제거 + Room on each side 각 면의 여백 + The trim box is kept where it is, so the printed page cuts the same. 재단 상자는 그대로 유지되므로 인쇄된 페이지도 동일하게 재단됩니다. + Trim marks 재단 표시 + Widen the page to make room 페이지를 넓혀 공간 확보 + printer marks 인쇄용 표시 # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 오늘 diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index fd7f431154..351869c7d9 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2809,6 +2809,21 @@ theme Appearance Išvaizda Edit this PDF Redaguoti šį PDF Export this PDF Eksportuoti šį PDF Find text or tools Rasti tekstą arba įrankius + Add printer marks… Pridėti spaudos žymes… + Remove printer marks Pašalinti spaudos žymes + Marks Žymės + All marks Visos žymės + Trim marks Apkirpimo žymės + Bleed marks Užlaidos žymės + Registration marks Sutapdinimo žymės + Colour bars Spalvų juostos + Page information Puslapio informacija + Line weight Linijos storis + Widen the page to make room Praplėsti puslapį, kad būtų vietos + Room on each side Vieta kiekvienoje pusėje + The trim box is kept where it is, so the printed page cuts the same. Apkirpimo rėmelis lieka vietoje, todėl atspausdintas puslapis kerpamas taip pat. + Printer marks added Spaudos žymės pridėtos + printer marks spaudos žymės Attach a file… Pridėti failą… Delete attachment Ištrinti priedą Attaching files is not available on the web yet Failų pridėjimas žiniatinklyje dar negalimas diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 208dd1c61e..98ac33dc2c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2515,6 +2515,21 @@ animation effects Off Wyłączone Edit this PDF Edytuj ten plik PDF Export this PDF Eksportuj ten plik PDF Find text or tools Znajdź tekst lub narzędzia + Add printer marks… Dodaj znaczniki drukarskie… + Remove printer marks Usuń znaczniki drukarskie + Marks Znaczniki + All marks Wszystkie znaczniki + Trim marks Znaczniki cięcia + Bleed marks Znaczniki spadu + Registration marks Pasery + Colour bars Paski kolorów + Page information Informacje o stronie + Line weight Grubość linii + Widen the page to make room Poszerz stronę, aby zrobić miejsce + Room on each side Miejsce z każdej strony + The trim box is kept where it is, so the printed page cuts the same. Ramka cięcia zostaje na miejscu, więc wydrukowana strona tnie się tak samo. + Printer marks added Dodano znaczniki drukarskie + printer marks znaczniki drukarskie Create & Edit Tworzenie i edycja Review & Compare Recenzja i porównanie Forms & Signatures Formularze i podpisy diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a172a03879..807acddbd4 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2552,6 +2552,21 @@ animation effects Off Desativados Edit this PDF Editar este PDF Export this PDF Exportar este PDF Find text or tools Localizar texto ou ferramentas + Add printer marks… Adicionar marcas de impressão… + Remove printer marks Remover marcas de impressão + Marks Marcas + All marks Todas as marcas + Trim marks Marcas de corte + Bleed marks Marcas de sangria + Registration marks Marcas de registro + Colour bars Barras de cores + Page information Informações da página + Line weight Espessura da linha + Widen the page to make room Alargar a página para abrir espaço + Room on each side Espaço de cada lado + The trim box is kept where it is, so the printed page cuts the same. A caixa de corte fica onde está, então a página impressa é cortada do mesmo jeito. + Printer marks added Marcas de impressão adicionadas + printer marks marcas de impressão Create & Edit Criar e editar Review & Compare Revisar e comparar Forms & Signatures Formulários e assinaturas diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 0d0fbf16b4..c6fe080ba6 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2846,6 +2846,21 @@ theme Appearance Aspect Unpin tool Anulează fixarea instrumentului Arrange image Aranjează imaginea Crop Decupare + Add printer marks… Adăugare semne de imprimare… + All marks Toate semnele + Bleed marks Semne de tăiere cu bleed + Colour bars Bare de culoare + Line weight Grosimea liniei + Marks Semne + Page information Informații despre pagină + Printer marks added Semne de imprimare adăugate + Registration marks Semne de registru + Remove printer marks Eliminare semne de imprimare + Room on each side Loc pe fiecare parte + The trim box is kept where it is, so the printed page cuts the same. Caseta de tăiere rămâne unde este, astfel încât pagina imprimată se taie la fel. + Trim marks Semne de tăiere + Widen the page to make room Lărgiți pagina pentru a face loc + printer marks semne de imprimare # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Astăzi diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b45d73ecab..3d0550eac7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2452,6 +2452,21 @@ animation effects Off Выкл Edit this PDF Редактировать этот PDF Export this PDF Экспортировать этот PDF Find text or tools Найти текст или инструменты + Add printer marks… Добавить типографские метки… + Remove printer marks Удалить типографские метки + Marks Метки + All marks Все метки + Trim marks Метки обреза + Bleed marks Метки выпуска под обрез + Registration marks Приводные метки + Colour bars Цветовые шкалы + Page information Сведения о странице + Line weight Толщина линии + Widen the page to make room Расширить страницу, чтобы освободить место + Room on each side Место с каждой стороны + The trim box is kept where it is, so the printed page cuts the same. Рамка обреза остаётся на месте, поэтому напечатанная страница режется так же. + Printer marks added Типографские метки добавлены + printer marks типографские метки Create & Edit Создание и редактирование Review & Compare Рецензирование и сравнение Forms & Signatures Формы и подписи diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index c95f6d6bd6..04ae1dab45 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2544,6 +2544,21 @@ animation effects Off Izklopljeni Edit this PDF Uredi ta PDF Export this PDF Izvozi ta PDF Find text or tools Poišči besedilo ali orodja + Add printer marks… Dodaj tiskarske oznake… + Remove printer marks Odstrani tiskarske oznake + Marks Oznake + All marks Vse oznake + Trim marks Oznake za obrez + Bleed marks Oznake za dodatek za obrez + Registration marks Oznake za poravnavo + Colour bars Barvne lestvice + Page information Podatki o strani + Line weight Debelina črte + Widen the page to make room Razširi stran, da bo prostor + Room on each side Prostor na vsaki strani + The trim box is kept where it is, so the printed page cuts the same. Okvir obreza ostane, kjer je, zato se natisnjena stran reže enako. + Printer marks added Tiskarske oznake so dodane + printer marks tiskarske oznake Create & Edit Ustvarjanje in urejanje Review & Compare Pregled in primerjava Forms & Signatures Obrazci in podpisi diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index eb9eed3a0e..4c108c6e25 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2571,6 +2571,21 @@ animation effects Off Joaktiv Remove crop Hiq prerjen Send backward Kalo prapa Send to back Dërgo mbrapa + Add printer marks… Shto shenja shtypjeje… + All marks Të gjitha shenjat + Bleed marks Shenjat e bleed-it + Colour bars Shiritat e ngjyrave + Line weight Trashësia e vijës + Marks Shenjat + Page information Informacioni i faqes + Printer marks added U shtuan shenjat e shtypjes + Registration marks Shenjat e regjistrimit + Remove printer marks Hiq shenjat e shtypjes + Room on each side Hapësira në secilën anë + The trim box is kept where it is, so the printed page cuts the same. Kutia e prerjes (trim) mbetet aty ku është, kështu që faqja e printuar pritet njësoj. + Trim marks Shenjat e prerjes + Widen the page to make room Zgjero faqen për të krijuar hapësirë + printer marks shenja shtypjeje # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Sot diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 9b81ffb8b9..d35e611940 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2448,6 +2448,21 @@ animation effects Off ఆఫ్ Edit this PDF ఈ PDFను సవరించు Export this PDF ఈ PDFను ఎగుమти చేయి Find text or tools వచనం లేదా సాధనాలను కనుగొనండి + Add printer marks… ప్రింటర్ గుర్తులను జోడించు… + Remove printer marks ప్రింటర్ గుర్తులను తొలగించు + Marks గుర్తులు + All marks అన్ని గుర్తులు + Trim marks ట్రిమ్ గుర్తులు + Bleed marks బ్లీడ్ గుర్తులు + Registration marks రిజిస్ట్రేషన్ గుర్తులు + Colour bars రంగు పట్టీలు + Page information పేజీ సమాచారం + Line weight గీత మందం + Widen the page to make room స్థలం కోసం పేజీని వెడల్పు చేయండి + Room on each side ప్రతి వైపు స్థలం + The trim box is kept where it is, so the printed page cuts the same. ట్రిమ్ బాక్స్ ఉన్నచోటే ఉంటుంది, కాబట్టి ముద్రించిన పేజీ అలాగే కత్తిరించబడుతుంది. + Printer marks added ప్రింటర్ గుర్తులు జోడించబడ్డాయి + printer marks ప్రింటర్ గుర్తులు Create & Edit సృష్టించు & సవరించు Review & Compare సమీక్షించు & పోల్చు Forms & Signatures ఫారమ్‌లు & సంతకాలు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6921a6c33e..0e4b879281 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2535,6 +2535,21 @@ animation effects Off Kapalı Edit this PDF Bu PDF'i düzenle Export this PDF Bu PDF'i dışa aktar Find text or tools Metin veya araç bul + Add printer marks… Yazıcı işaretleri ekle… + Remove printer marks Yazıcı işaretlerini kaldır + Marks İşaretler + All marks Tüm işaretler + Trim marks Kesim işaretleri + Bleed marks Taşma payı işaretleri + Registration marks Hizalama işaretleri + Colour bars Renk çubukları + Page information Sayfa bilgisi + Line weight Çizgi kalınlığı + Widen the page to make room Yer açmak için sayfayı genişlet + Room on each side Her kenarda boşluk + The trim box is kept where it is, so the printed page cuts the same. Kesim kutusu yerinde kalır, böylece basılan sayfa aynı şekilde kesilir. + Printer marks added Yazıcı işaretleri eklendi + printer marks yazıcı işaretleri Create & Edit Oluştur ve Düzenle Review & Compare İncele ve Karşılaştır Forms & Signatures Formlar ve İmzalar diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a824769600..b16b4ef468 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2561,6 +2561,21 @@ animation effects Off Вимкнено Edit this PDF Редактировать цей PDF Export this PDF Експортувати цей PDF Find text or tools Знайти текст або інструменти + Add printer marks… Додати друкарські позначки… + Remove printer marks Вилучити друкарські позначки + Marks Позначки + All marks Усі позначки + Trim marks Позначки обрізання + Bleed marks Позначки випуску під обріз + Registration marks Приводні позначки + Colour bars Кольорові шкали + Page information Відомості про сторінку + Line weight Товщина лінії + Widen the page to make room Розширити сторінку, щоб звільнити місце + Room on each side Місце з кожного боку + The trim box is kept where it is, so the printed page cuts the same. Рамка обрізання лишається на місці, тож надрукована сторінка ріжеться так само. + Printer marks added Друкарські позначки додано + printer marks друкарські позначки Create & Edit Створення й редагування Review & Compare Рецензування й порівняння Forms & Signatures Форми й підписи diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e79a8aacc4..2bc7605e4d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2819,6 +2819,21 @@ key Space Space Edit this PDF Chỉnh sửa PDF này Export this PDF Xuất PDF này Find text or tools Tìm văn bản hoặc công cụ + Add printer marks… Thêm dấu máy in… + Remove printer marks Gỡ dấu máy in + Marks Dấu + All marks Tất cả các dấu + Trim marks Dấu xén + Bleed marks Dấu tràn lề + Registration marks Dấu chồng màu + Colour bars Thanh màu + Page information Thông tin trang + Line weight Độ dày nét + Widen the page to make room Mở rộng trang để có chỗ + Room on each side Khoảng trống mỗi bên + The trim box is kept where it is, so the printed page cuts the same. Hộp xén được giữ nguyên vị trí, nên trang in được xén như cũ. + Printer marks added Đã thêm dấu máy in + printer marks dấu máy in Attach a file… Đính kèm tệp… Delete attachment Xóa tệp đính kèm Attaching files is not available on the web yet Chưa thể đính kèm tệp trên web diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 96e9c3bb75..69aeb500bc 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2503,6 +2503,21 @@ animation effects Off 关 Edit this PDF 编辑此 PDF Export this PDF 导出此 PDF Find text or tools 查找文本或工具 + Add printer marks… 添加打印标记… + Remove printer marks 移除打印标记 + Marks 标记 + All marks 所有标记 + Trim marks 裁切标记 + Bleed marks 出血标记 + Registration marks 套准标记 + Colour bars 色条 + Page information 页面信息 + Line weight 线条粗细 + Widen the page to make room 加宽页面以留出空间 + Room on each side 每侧空间 + The trim box is kept where it is, so the printed page cuts the same. 裁切框保持不变,打印页面的裁切位置相同。 + Printer marks added 已添加打印标记 + printer marks 打印标记 Create & Edit 创建和编辑 Review & Compare 审阅和比较 Forms & Signatures 表单和签名 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a1b933b830..03957ab42d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2489,6 +2489,21 @@ animation effects Off 關 Edit this PDF 編輯此 PDF Export this PDF 匯出此 PDF Find text or tools 尋找文字或工具 + Add printer marks… 新增印刷標記… + Remove printer marks 移除印刷標記 + Marks 標記 + All marks 所有標記 + Trim marks 裁切標記 + Bleed marks 出血標記 + Registration marks 對位標記 + Colour bars 色條 + Page information 頁面資訊 + Line weight 線條粗細 + Widen the page to make room 加寬頁面以留出空間 + Room on each side 每側空間 + The trim box is kept where it is, so the printed page cuts the same. 裁切框保持不變,列印頁面的裁切位置相同。 + Printer marks added 已新增印刷標記 + printer marks 印刷標記 Create & Edit 建立與編輯 Review & Compare 審閱與比較 Forms & Signatures 表單與簽名 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fe0138f27f..21d4d80c0f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -105,6 +105,7 @@ use dialogs::credits; use dialogs::marks_ui; use dialogs::pageboxes; use dialogs::palette; +use dialogs::printer_marks_ui; use dialogs::protect; use dialogs::standards_ui; use dialogs::word_count_ui; @@ -609,6 +610,7 @@ pub struct PdfCraftApp { /// Number pages dialog settings (1-based pages). pub number_draft: NumberDraft, pub(crate) word_count_draft: word_count_ui::Draft, + pub printer_marks_draft: printer_marks_ui::PrinterMarksDraft, /// Protect Using Password dialog state. pub protect_draft: protect::ProtectDraft, /// New bookmarks from text styles dialog state (#1693). diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e3a9c540a6..2f9be25d28 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -109,6 +109,8 @@ pub enum Dialog { Split, /// Pages ▸ Number pages… (page labels). NumberPages, + /// Print production ▸ Add printer marks. + PrinterMarks, /// Organize pages ▸ Page properties (the tab order of the selected pages). PageProps, /// Protect Using Password. diff --git a/crates/ui-egui/tests/it/commands.rs b/crates/ui-egui/tests/it/commands.rs index 1e3c36216f..2e59144ff3 100644 --- a/crates/ui-egui/tests/it/commands.rs +++ b/crates/ui-egui/tests/it/commands.rs @@ -100,6 +100,10 @@ fn every_registered_command_is_implemented() { if spec.id == "edit.redo" { app.undo(); } + if spec.id == "prepress.marks.remove" { + use pdfcraft_engine::{Edit, PrinterMarks}; + app.apply_edit(Edit::AddPrinterMarks { pages: vec![0], settings: PrinterMarks::default(), replace: false }); + } if spec.id.starts_with("edit.") && (spec.id.ends_with(".update") || spec.id.ends_with(".remove")) { use pdfcraft_engine::{Background, Edit, HeaderFooter, Watermark}; let mut hf = HeaderFooter::default(); diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 7b245a5a9e..1e677caafa 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -8034,6 +8034,32 @@ fn test_cropped_image_selection_and_bounding_box() { assert!(h.state().views[0].picked_image.is_none(), "clicking cropped-away region must not hit-test or select the image"); } +/// Print production ▸ Add printer marks: the dialog adds removable marks around +/// the trim box, widening the page to make room; Remove printer marks takes them off. +#[test] +fn printer_marks_are_added_from_the_dialog_and_removed() { + let mut h = harness(2, |_| {}); + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap().info.pages[0].width; + assert!(h.state_mut().execute("prepress.marks")); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(Dialog::PrinterMarks)); + h.get_by_label("All marks"); + h.get_by_label("Add").click(); + h.run_steps(3); + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.can_undo(), Some("Add printer marks")); + assert!(doc.marks().contains(&pdfcraft_engine::MarkKind::PrinterMarks)); + // No trim box: the whole page is the trim box, so the page grew 36 pt a side for the marks. + assert!((doc.info.pages[0].width - (before + 72.0)).abs() < 0.5, "{} → {}", before, doc.info.pages[0].width); + assert_eq!(h.state().toast.clone().unwrap().0, "Printer marks added"); + assert!(h.state_mut().execute("prepress.marks.remove")); + h.run_steps(3); + let doc = h.state().session.get(id).unwrap(); + assert!(!doc.marks().contains(&pdfcraft_engine::MarkKind::PrinterMarks)); + assert_eq!(doc.can_undo(), Some("Remove printer marks")); +} + /// The Read mode command (not the mode bar) applies a paragraph still open in the editor, as /// putting the Edit text tool down does; left open, it would stay drawn in Read mode with no tool. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index 4f35e651d1..50e149225d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -358,9 +358,9 @@ update the numbers. ### 18. Print production tools absent - **Missing:** Output Preview, separations, total area coverage, overprint preview, ink manager, - colour conversion, printer marks, fix hairlines, flattener preview, colour management settings; - print as image; PostScript output. -- **Evidence:** 18 print-production features planned (L area). + colour conversion, fix hairlines, flattener preview, colour management settings; + print as image; PostScript output. Printer marks are in (Print production ▸ Add printer marks…). +- **Evidence:** 17 print-production features planned (L area). - **Estimate:** 25–45 h (part of the L area's 30–55). **Doc:** [target-app-parity.md](target-app-parity.md). ### 19. Preferences mostly missing diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cabfc7823a..14c89acb5f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8442,11 +8442,11 @@ title = "Add printer marks (crop, bleed, registration, colour bars)" area = "L" tier = "P1" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["prepress.marks", "prepress.marks.remove"] +tools = ["doc_printer_marks", "doc_remove_marks"] +evidence = ["crates/edit/src/tests.rs::printer_marks_are_drawn_outside_the_trim_box_and_removable", "crates/ui-egui/tests/it/editing.rs::printer_marks_are_added_from_the_dialog_and_removed", "crates/automation/tests/it/automation.rs::printer_marks_through_tools"] +notes = "Print production ▸ Add printer marks…: trim marks at the trim box's corners, dashed bleed marks at the bleed box, registration targets centred on each side, a C/M/Y/K + 75/50/25 % K colour bar and a page-information line (file name, page n of N, date), at 0.25/0.5/1 pt, outside the trim box as a removable page mark (Remove printer marks; adding again replaces), in registration colour (the /All Separation) except the process-ink colour bar; the page is widened by 36 pt a side when the marks would fall outside it, the trim box kept; all, current or a range of pages; tool doc_printer_marks and doc_remove_marks kind printer_marks. Still missing against Acrobat: the Japanese-style and Eastern-style mark variants, and the choice of marks per page box (always the trim box)." [[feature]] id = "print.fix-hairlines" From d06154ac803bce50d12dee639b22e197f11fa775 Mon Sep 17 00:00:00 2001 From: Osvaldo Duran <338149330+regendorf-ux@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:53:50 -0500 Subject: [PATCH 063/101] Print production: Convert colours (grayscale, RGB, CMYK by the device formulas) Every colour operator in the pages' content and the Form XObjects it draws is rewritten in the target space by the standard device formulas, source colours read through the space in force (edit/src/colour.rs: device, ICCBased, CalRGB/ CalGray, Lab, Indexed, Separation/DeviceN through a Type 2 tint). Preserve black, pictures too (optimize::recolour_image: 8-bit gray/RGB re-encoded, JPEG stays JPEG, CMYK lossless), comments' and fields' appearances optionally. Edit::ConvertColours (one undo step; refuses when nothing changes, so Session::count_colours counts first), the Convert colours dialog, command prepress.convert_colors, tool doc_convert_colours, rows in every language (RGB and CMYK allowed unchanged in the Ukrainian and Kazakh checks), parity print.convert-colors shipped. Carries the review fixes: - ATTRIBUTION.toml: the language files' checksums after the new rows - Convert colours: keep RGB and CMYK as-is in the Czech and Turkish menu-label checks - Convert colours: CMYK black start, shared objects copied, streams kept apart, guarded count - Convert colours tests: drop an unused helper - Format with rustfmt - i18n(ui): carry this PR's new wordings in the catalogues that must cover them - Parity: evidence paths follow the tests moved into tests/it - fix(colours): comment permission for appearances; page range per document - fix(convert-colours): say what it is not, and what it leaves behind Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/dispatch.rs | 1 + crates/automation/src/printing.rs | 39 ++ crates/automation/src/tools.rs | 10 + crates/automation/tests/it/automation.rs | 25 + crates/edit/src/colour.rs | 230 ++++++++ crates/edit/src/convert_colours.rs | 492 ++++++++++++++++++ crates/edit/src/lib.rs | 4 + crates/edit/src/page_streams.rs | 69 +++ crates/edit/src/tests.rs | 61 +++ .../tests/it/convert_colours_cmyk_initial.rs | 71 +++ .../edit/tests/it/convert_colours_shared.rs | 144 +++++ .../edit/tests/it/convert_colours_streams.rs | 100 ++++ crates/edit/tests/it/main.rs | 5 + crates/engine/src/catalog.rs | 2 +- crates/engine/src/commands.rs | 1 + crates/engine/src/edit.rs | 5 + crates/engine/src/edit/labels.rs | 1 + crates/engine/src/edit/run.rs | 20 + crates/engine/src/history.rs | 3 +- crates/engine/src/lib.rs | 3 +- crates/engine/src/session/files.rs | 18 + crates/engine/src/tests.rs | 58 +++ crates/optimize/src/images.rs | 70 +++ crates/optimize/src/lib.rs | 2 +- crates/optimize/tests/optimize.rs | 26 + crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/control.rs | 1 + crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/dialogs.rs | 11 + .../ui-egui/src/dialogs/convert_colours_ui.rs | 141 +++++ crates/ui-egui/src/i18n/ar.tsv | 15 + crates/ui-egui/src/i18n/bg.tsv | 15 + crates/ui-egui/src/i18n/ca.tsv | 15 + crates/ui-egui/src/i18n/cs.tsv | 15 + crates/ui-egui/src/i18n/de.tsv | 15 + crates/ui-egui/src/i18n/el.tsv | 15 + crates/ui-egui/src/i18n/es.tsv | 15 + crates/ui-egui/src/i18n/fi.tsv | 15 + crates/ui-egui/src/i18n/fr.tsv | 15 + crates/ui-egui/src/i18n/hi.tsv | 15 + crates/ui-egui/src/i18n/hu.tsv | 15 + crates/ui-egui/src/i18n/id.tsv | 15 + crates/ui-egui/src/i18n/it.tsv | 15 + crates/ui-egui/src/i18n/ja.tsv | 15 + crates/ui-egui/src/i18n/kk.tsv | 15 + crates/ui-egui/src/i18n/ko.tsv | 15 + crates/ui-egui/src/i18n/lt.tsv | 15 + crates/ui-egui/src/i18n/pl.tsv | 15 + crates/ui-egui/src/i18n/pt-br.tsv | 15 + crates/ui-egui/src/i18n/ro.tsv | 15 + crates/ui-egui/src/i18n/ru.tsv | 15 + crates/ui-egui/src/i18n/sl.tsv | 15 + crates/ui-egui/src/i18n/sq.tsv | 15 + crates/ui-egui/src/i18n/te.tsv | 15 + crates/ui-egui/src/i18n/tr.tsv | 15 + crates/ui-egui/src/i18n/uk.tsv | 15 + crates/ui-egui/src/i18n/vi.tsv | 15 + crates/ui-egui/src/i18n/zh-hans.tsv | 15 + crates/ui-egui/src/i18n/zh-hant.tsv | 15 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/state.rs | 2 + crates/ui-egui/tests/it/editing.rs | 36 ++ parity/acrobat-features.toml | 10 +- 63 files changed, 2091 insertions(+), 9 deletions(-) create mode 100644 crates/edit/src/colour.rs create mode 100644 crates/edit/src/convert_colours.rs create mode 100644 crates/edit/src/page_streams.rs create mode 100644 crates/edit/tests/it/convert_colours_cmyk_initial.rs create mode 100644 crates/edit/tests/it/convert_colours_shared.rs create mode 100644 crates/edit/tests/it/convert_colours_streams.rs create mode 100644 crates/edit/tests/it/main.rs create mode 100644 crates/ui-egui/src/dialogs/convert_colours_ui.rs diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 8bc23ff280..f3e6ca25dc 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -753,6 +753,7 @@ impl Automation { let edit = Edit::DeleteAttachment { key: a.str("name")?.into() }; self.apply(&a, edit)? } + "doc_convert_colours" => self.convert_colours(&a)?, "doc_remove_hidden" => self.doc_remove_hidden(&a)?, "doc_audit_redactions" => self.doc_audit_redactions(&a)?, "fill_sign_add" => self.fill_sign_add(&a)?, diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 9a1c1a6b7c..d11a4b0b14 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -86,6 +86,45 @@ fn spool_job(a: &Args, printer: &str, title: String, duplex: Option Result { + let n = self.doc(a)?.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let target = match a.opt_str("to")?.unwrap_or("gray") { + "gray" | "grayscale" => pdfcraft_engine::ColourTarget::Gray, + "rgb" => pdfcraft_engine::ColourTarget::Rgb, + "cmyk" => pdfcraft_engine::ColourTarget::Cmyk, + other => return Err(ToolError::InvalidArgs(format!("unknown target {other:?} (gray, rgb, cmyk)"))), + }; + let settings = pdfcraft_engine::ConvertColours { + target, + preserve_black: a.opt_bool("preserve_black")?.unwrap_or(true), + images: a.opt_bool("images")?.unwrap_or(true), + annotations: a.opt_bool("annotations")?.unwrap_or(false), + }; + let id = self.doc(a)?.id; + let found = self.session.count_colours(id, &pages, &settings); + if found.colours == 0 && found.images == 0 { + return Ok(json!({ "colours": 0, "images": 0, "images_left": found.images_left, "pages": 0, "loses_conformance": [] })); + } + // PDF/X has no declaration of its own here: it always carries an output intent. + let declared = self.doc(a)?.standards(); + let pdfa = declared.pdfa.map(|(part, level)| format!("PDF/A-{part}{}", level.to_lowercase())); + let loses: Vec = pdfa.into_iter().chain(declared.output_intents.iter().map(|i| format!("output intent {i}"))).collect(); + let r = self.apply(a, pdfcraft_engine::Edit::ConvertColours { pages, settings })?; + Ok(json!({ + "colours": found.colours, + "images": found.images, + "images_left": found.images_left, + "pages": found.pages, + "loses_conformance": loses, + "undo": r["undo"], + })) + } + /// `printers`: the system's printers, the default marked, and each one's two-sided support /// where the spooler reports it. pub(crate) fn printers(&self) -> Result { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 866138899d..40741e7e9b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -138,6 +138,16 @@ pub fn tools() -> Vec { t("attachment_delete", "Delete an attachment", "Remove the document-level attachment with this name (as doc_info lists it). Undoable.") .destructive() .with(schema(json!({"doc": doc(), "name": {"type": "string"}}), &["doc", "name"])), + t("doc_convert_colours", "Convert colours", "Print production: every colour operator in the pages' content and the artwork it draws rewritten in the target space (to: gray, rgb or cmyk) by the standard device formulas (device spaces, ICC by component count, Lab, Indexed, Separation and DeviceN tints; patterns, shadings and inline images stay). Not a prepress separation: no ICC profile and no output intent is used. Spot colours are converted, not kept: exact through a Type 2 tint, otherwise a gray of their darkness; /None stays. preserve_black (default true) keeps pure black as 100% K / gray 0; images (default true) re-encodes 8-bit gray and RGB pictures (JPEG stays JPEG, compressed again at quality 80; CMYK written lossless; soft masks kept), and leaves indexed, CMYK, masked and other pictures as they are; annotations also converts the pages' comment and field appearances. Returns how many colours and pictures changed on how many pages, images_left (pictures not in the target that stayed), and loses_conformance (the PDF/A level and output intents the file declared: it may no longer meet them, and still declares them; standards_remove_id takes that off). Undoable.") + .cmd("prepress.convert_colors") + .with(schema( + json!({ + "doc": doc(), "pages": pages("to convert (default all)"), + "to": { "type": "string", "enum": ["gray", "rgb", "cmyk"] }, + "preserve_black": { "type": "boolean" }, "images": { "type": "boolean" }, "annotations": { "type": "boolean" }, + }), + &["doc"], + )), t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited.") .cmd("file.open") .with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 0d73dbb900..39c06b0ee2 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9457,6 +9457,31 @@ fn a_root_less_write_output_is_atomic_too() { let _ = std::fs::remove_dir_all(&dir); } +/// Convert colours through the tools: counts, converts, reports nothing to do. +#[test] +fn convert_colours_through_tools() { + let dir = workdir("convert-colours"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + // The fixture sets no colour of its own: nothing to convert yet. + let r = ok(&mut a, "doc_convert_colours", json!({ "doc": doc, "to": "gray" })); + assert_eq!((r["colours"].as_u64(), r["undo"].is_null()), (Some(0), true), "{r}"); + // Red text added as page content converts. + ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "Red", "at": [20, 20], "color": "#FF0000" })); + let r = ok(&mut a, "doc_convert_colours", json!({ "doc": doc, "to": "gray", "pages": [1] })); + assert!(r["colours"].as_u64().unwrap_or(0) >= 1 && r["pages"].as_u64() == Some(1), "{r}"); + assert_eq!(r["undo"], "Convert colours"); + assert_eq!(r["loses_conformance"], json!([]), "{r}"); + assert!(matches!(a.call("doc_convert_colours", &json!({ "doc": doc, "to": "lab" })), Err(ToolError::InvalidArgs(_)))); + // A file that declares a standard is told what it may have lost. + ok(&mut a, "pdfa_convert", json!({ "doc": doc, "level": "3b" })); + ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "Blue", "at": [20, 40], "color": "#0000FF" })); + let r = ok(&mut a, "doc_convert_colours", json!({ "doc": doc, "to": "cmyk", "pages": [1] })); + let loses = r["loses_conformance"].as_array().unwrap(); + assert_eq!(loses[0], "PDF/A-3b", "{r}"); + assert!(loses[1].as_str().unwrap().starts_with("output intent "), "{r}"); +} + #[test] fn attaching_and_deleting_files_through_the_tools() { let dir = workdir("attach"); diff --git a/crates/edit/src/colour.rs b/crates/edit/src/colour.rs new file mode 100644 index 0000000000..7eaab55384 --- /dev/null +++ b/crates/edit/src/colour.rs @@ -0,0 +1,230 @@ +//! Colours as sRGB through the colour space in force, for Convert colours: `g`/`rg`/`k`, and +//! `cs` + `sc`/`scn` through the space named. Device spaces, ICCBased by its component count (or +//! its Alternate), CalRGB and CalGray as their device kin, Lab through CIE XYZ with the +//! dictionary's white point, Indexed through its table, Separation and DeviceN through a Type 2 +//! (exponential) tint transform into the alternate space; a sampled or PostScript tint function, +//! or a pattern, falls back to a plain darkness so nothing reads as the wrong hue. + +use pdfcraft_cos::{Dict, Document, Object}; + +/// A colour space as far as a fill colour needs it. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Space { + Gray, + Rgb, + Cmyk, + Lab { + white: [f64; 3], + range: [f64; 4], + }, + Indexed { + base: Box, + hival: usize, + table: Vec, + }, + /// Separation and DeviceN: the number of inputs, the alternate space, and the tint + /// transform when it is a Type 2 function (C0, C1, N). + Tint { + inputs: usize, + alt: Box, + exp: Option<(Vec, Vec, f64)>, + }, + Pattern, +} + +impl Space { + /// How many numbers set a colour in this space. + fn components(&self) -> usize { + match self { + Space::Gray => 1, + Space::Rgb | Space::Lab { .. } => 3, + Space::Cmyk => 4, + Space::Indexed { .. } => 1, + Space::Tint { inputs, .. } => *inputs, + Space::Pattern => 0, + } + } +} + +fn nums(doc: &Document, o: &Object) -> Vec { + doc.resolve(o).as_array().map(|a| a.iter().filter_map(|v| doc.resolve(v).as_f64()).collect()).unwrap_or_default() +} + +/// The colour space an operand of `cs` names: a device space, or a name in the resources' +/// `/ColorSpace` dictionary, or (inside arrays) a family. +pub(crate) fn space_of(doc: &Document, operand: &Object, cs_res: &Dict, depth: u8) -> Space { + if depth > 4 { + return Space::Gray; + } + let resolved = doc.resolve(operand); + match &*resolved { + Object::Name(n) => match n.as_slice() { + b"DeviceGray" | b"G" | b"CalGray" => Space::Gray, + b"DeviceRGB" | b"RGB" | b"CalRGB" => Space::Rgb, + b"DeviceCMYK" | b"CMYK" => Space::Cmyk, + b"Pattern" => Space::Pattern, + other => match cs_res.get(other) { + Some(o) => space_of(doc, o, &Dict::default(), depth + 1), + None => Space::Gray, + }, + }, + Object::Array(a) => { + let family = a.first().map(|f| doc.resolve(f)).and_then(|f| f.as_name().map(|n| n.to_vec())).unwrap_or_default(); + match family.as_slice() { + b"ICCBased" => { + let stream = a.get(1).map(|s| doc.resolve(s)); + let dict = stream.as_ref().and_then(|s| match &**s { + Object::Stream(st) => Some(st.dict.clone()), + _ => None, + }); + if let Some(alt) = dict.as_ref().and_then(|d| d.get(b"Alternate").cloned()) { + return space_of(doc, &alt, cs_res, depth + 1); + } + match dict.and_then(|d| d.int(b"N")) { + Some(1) => Space::Gray, + Some(4) => Space::Cmyk, + _ => Space::Rgb, + } + } + b"CalRGB" => Space::Rgb, + b"CalGray" => Space::Gray, + b"Lab" => { + let d = a.get(1).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()).unwrap_or_default(); + let wp = d.get(b"WhitePoint").map(|w| nums(doc, w)).unwrap_or_default(); + let white = if wp.len() == 3 { [wp[0], wp[1], wp[2]] } else { [0.9505, 1.0, 1.089] }; + let r = d.get(b"Range").map(|w| nums(doc, w)).unwrap_or_default(); + let range = if r.len() == 4 { [r[0], r[1], r[2], r[3]] } else { [-100.0, 100.0, -100.0, 100.0] }; + Space::Lab { white, range } + } + b"Indexed" | b"I" => { + let base = a.get(1).map(|b| space_of(doc, b, cs_res, depth + 1)).unwrap_or(Space::Rgb); + let hival = a.get(2).and_then(|h| doc.resolve(h).as_f64()).unwrap_or(0.0).clamp(0.0, 255.0) as usize; + let table = match a.get(3).map(|t| doc.resolve(t)) { + Some(t) => match &*t { + Object::String(s) => s.bytes.clone(), + Object::Stream(st) => st.decoded().unwrap_or_default(), + _ => Vec::new(), + }, + None => Vec::new(), + }; + Space::Indexed { base: Box::new(base), hival, table } + } + b"Separation" | b"DeviceN" => { + // The `/None` colorant paints nothing (ISO 32000-2, 8.6.6.4): given a colour + // of its own it would appear, so it stays what it is, as a pattern does. + let is_none = |o: &Object| doc.resolve(o).as_name() == Some(b"None"); + let none = a.get(1).is_some_and(|names| { + is_none(names) || doc.resolve(names).as_array().is_some_and(|names| !names.is_empty() && names.iter().all(is_none)) + }); + if none { + return Space::Pattern; + } + let inputs = if family == b"Separation" { + 1 + } else { + a.get(1).map(|n| doc.resolve(n)).and_then(|n| n.as_array().map(Vec::len)).unwrap_or(1).max(1) + }; + let alt = a.get(2).map(|b| space_of(doc, b, cs_res, depth + 1)).unwrap_or(Space::Gray); + let exp = a.get(3).map(|f| doc.resolve(f)).and_then(|f| { + let d = match &*f { + Object::Dict(d) => d.clone(), + Object::Stream(s) => s.dict.clone(), + _ => return None, + }; + if d.int(b"FunctionType") != Some(2) { + return None; + } + let c0 = d.get(b"C0").map(|v| nums(doc, v)).unwrap_or_else(|| vec![0.0]); + let c1 = d.get(b"C1").map(|v| nums(doc, v)).unwrap_or_else(|| vec![1.0]); + let n = d.get(b"N").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(1.0); + Some((c0, c1, n)) + }); + Space::Tint { inputs, alt: Box::new(alt), exp } + } + b"Pattern" => Space::Pattern, + b"DeviceGray" | b"G" => Space::Gray, + b"DeviceRGB" | b"RGB" => Space::Rgb, + b"DeviceCMYK" | b"CMYK" => Space::Cmyk, + _ => Space::Gray, + } + } + _ => Space::Gray, + } +} + +fn clamp01(v: f64) -> f64 { + if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } +} + +/// CMYK to sRGB, the usual multiplicative approximation (no ICC profile). +pub(crate) fn cmyk(c: f64, m: f64, y: f64, k: f64) -> [f64; 3] { + let (c, m, y, k) = (clamp01(c), clamp01(m), clamp01(y), clamp01(k)); + [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)] +} + +/// CIE L*a*b* (with the dictionary's white point) to sRGB, through XYZ and the D65 sRGB matrix. +fn lab(white: [f64; 3], l: f64, a: f64, b: f64) -> [f64; 3] { + let m = (l + 16.0) / 116.0; + let (ll, mm, nn) = (m + a / 500.0, m, m - b / 200.0); + let g = |x: f64| if x >= 6.0 / 29.0 { x * x * x } else { 108.0 / 841.0 * (x - 4.0 / 29.0) }; + let (xw, yw, zw) = (white[0].max(1e-6), white[1].max(1e-6), white[2].max(1e-6)); + let (x, y, z) = (xw * g(ll), yw * g(mm), zw * g(nn)); + // Scale the white point to D65 before the sRGB matrix (a plain von Kries over XYZ). + let (x, y, z) = (x * 0.9505 / xw, y / yw, z * 1.089 / zw); + let lin = [3.2406 * x - 1.5372 * y - 0.4986 * z, -0.9689 * x + 1.8758 * y + 0.0415 * z, 0.0557 * x - 0.2040 * y + 1.0570 * z]; + lin.map(|c| { + let c = clamp01(c); + if c <= 0.0031308 { 12.92 * c } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 } + }) +} + +/// The sRGB of `values` in `space` (missing values read as 0; a pattern reads as black). +pub(crate) fn rgb(space: &Space, values: &[f64]) -> [f64; 3] { + let v = |i: usize| values.get(i).copied().unwrap_or(0.0); + match space { + Space::Gray => { + let g = clamp01(v(0)); + [g, g, g] + } + Space::Rgb => [clamp01(v(0)), clamp01(v(1)), clamp01(v(2))], + Space::Cmyk => cmyk(v(0), v(1), v(2), v(3)), + Space::Lab { white, range } => { + let l = v(0).clamp(0.0, 100.0); + let a = v(1).clamp(range[0].min(range[1]), range[1].max(range[0])); + let b = v(2).clamp(range[2].min(range[3]), range[3].max(range[2])); + lab(*white, l, a, b) + } + Space::Indexed { base, hival, table } => { + let n = base.components().max(1); + let i = (v(0).max(0.0).round() as usize).min(*hival); + let vals: Vec = (0..n).map(|k| table.get(i * n + k).map(|b| *b as f64 / 255.0).unwrap_or(0.0)).collect(); + // Lab tables hold the L*, a*, b* range in bytes; the others are 0..1 already. + match &**base { + Space::Lab { range, .. } => { + let scaled = [ + vals[0] * 100.0, + range[0] + vals.get(1).copied().unwrap_or(0.0) * (range[1] - range[0]), + range[2] + vals.get(2).copied().unwrap_or(0.0) * (range[3] - range[2]), + ]; + rgb(base, &scaled) + } + _ => rgb(base, &vals), + } + } + Space::Tint { inputs, alt, exp } => match exp { + Some((c0, c1, n)) => { + let t = clamp01(v(0)); + let k = if *n == 1.0 { t } else { t.powf(*n) }; + let out: Vec = c0.iter().zip(c1.iter()).map(|(a, b)| a + k * (b - a)).collect(); + rgb(alt, &out) + } + // No usable transform: the darkness of the strongest tint, in the alternate space's ink. + None => { + let t = (0..*inputs).map(v).fold(0.0, f64::max); + let g = 1.0 - clamp01(t); + [g, g, g] + } + }, + Space::Pattern => [0.0, 0.0, 0.0], + } +} diff --git a/crates/edit/src/convert_colours.rs b/crates/edit/src/convert_colours.rs new file mode 100644 index 0000000000..7134e537d8 --- /dev/null +++ b/crates/edit/src/convert_colours.rs @@ -0,0 +1,492 @@ +//! Convert colours (Acrobat's Print production ▸ Convert Colors): every colour +//! operator in the page content and the artwork it draws is rewritten in a target space - +//! grayscale, RGB or CMYK - by the standard device formulas (no ICC profile is applied), black +//! preserved on request; pictures are re-encoded through a helper the caller supplies. +//! +//! This is not a prepress separation. No ICC profile, rendering intent or output intent is +//! used, so the numbers are not calibrated for any press, and the catalog's `/OutputIntents` +//! stay as they were. A PDF/A or PDF/X file may no longer conform afterwards, and still says it +//! does: nothing here changes what it declares (the `doc_convert_colours` tool lists what it +//! declared; `standards_remove_id` takes that off). +//! +//! Spot colours are converted, not kept: Separation and DeviceN through a Type 2 tint transform +//! into their alternate space, any other tint transform as a gray of the strongest tint's +//! darkness (the hue is lost). `/All` converts like any other; `/None` paints nothing and stays. +//! Patterns, shadings, inline images and transparency group spaces stay as they are. +//! +//! Soft masks are not touched: a picture keeps the one it has. The helper leaves a picture alone +//! when it cannot re-encode it (indexed, CMYK, not 8-bit, an explicit mask, a matted soft mask, +//! JPEG 2000 ...); those that are not in the target already are counted in +//! [`Converted::images_left`]. + +use pdfcraft_content::{Op, parse, serialize_ops}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use std::collections::{HashMap, HashSet}; + +use crate::colour::{Space, rgb, space_of}; +use crate::page_streams::PageStreams; +use crate::{EditError, check, page_list}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ColourTarget { + Gray, + Rgb, + Cmyk, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ConvertColours { + pub target: ColourTarget, + /// Pure black stays 100% K (or gray 0) rather than a mix. + pub preserve_black: bool, + /// Re-encode pictures too (through the caller's helper). + pub images: bool, + /// The appearance streams of the pages' comments and form fields too. + pub annotations: bool, +} + +impl Default for ConvertColours { + fn default() -> Self { + Self { target: ColourTarget::Gray, preserve_black: true, images: true, annotations: false } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Converted { + /// Colour operators rewritten. + pub colours: usize, + /// Pictures re-encoded. + pub images: usize, + /// Pictures not in the target space that could not be re-encoded and are left as they were. + pub images_left: usize, + /// Pages on which something changed. + pub pages: usize, +} + +/// Re-encode one image stream in the target space, if it can be: the caller's (the optimizer +/// knows how to decode and encode pictures; the edit crate does not). +pub type ImageRecolour<'a> = dyn FnMut(&Document, &Stream, ColourTarget) -> Option + 'a; + +fn luma(c: [f64; 3]) -> f64 { + (0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]).clamp(0.0, 1.0) +} + +/// sRGB → the target's components. +pub fn components(target: ColourTarget, c: [f64; 3], preserve_black: bool) -> Vec { + let black = c.iter().all(|v| *v < 0.02); + match target { + ColourTarget::Gray => vec![if black && preserve_black { 0.0 } else { luma(c) }], + ColourTarget::Rgb => c.to_vec(), + ColourTarget::Cmyk => { + if black && preserve_black { + return vec![0.0, 0.0, 0.0, 1.0]; + } + let k = 1.0 - c.iter().cloned().fold(0.0, f64::max); + if k >= 0.999 { + return vec![0.0, 0.0, 0.0, 1.0]; + } + let f = |v: f64| ((1.0 - v - k) / (1.0 - k)).clamp(0.0, 1.0); + vec![f(c[0]), f(c[1]), f(c[2]), k] + } + } +} + +fn op_for(target: ColourTarget, stroke: bool) -> &'static str { + match (target, stroke) { + (ColourTarget::Gray, false) => "g", + (ColourTarget::Gray, true) => "G", + (ColourTarget::Rgb, false) => "rg", + (ColourTarget::Rgb, true) => "RG", + (ColourTarget::Cmyk, false) => "k", + (ColourTarget::Cmyk, true) => "K", + } +} + +/// Whether a `cs` operand names DeviceCMYK itself (directly or through a `/ColorSpace` +/// resource), whose initial colour is black, unlike an ICC CMYK space's all-zero white. +fn device_cmyk(doc: &Document, operand: &Object, cs_res: &Dict) -> bool { + let is_device = |o: &Object| match &*doc.resolve(o) { + Object::Name(n) => matches!(n.as_slice(), b"DeviceCMYK" | b"CMYK"), + Object::Array(a) => a.len() == 1 && a.first().and_then(Object::as_name).is_some_and(|n| matches!(n, b"DeviceCMYK" | b"CMYK")), + _ => false, + }; + match operand.as_name() { + Some(n) => is_device(operand) || cs_res.get(n).is_some_and(is_device), + None => is_device(operand), + } +} + +fn r3(v: f64) -> Object { + Object::Real((v * 1000.0).round() / 1000.0) +} + +/// The current fill and stroke spaces (None = a device space set by g/rg/k, which the +/// converted operator sets again), with the q/Q stack: what one content stream leaves for the +/// next on the same page. +#[derive(Clone, Debug, Default)] +struct State { + fill: Option, + stroke: Option, + stack: Vec<(Option, Option)>, +} + +/// Rewrite one content stream's colour operators, starting from `state` and leaving in it the +/// state at the stream's end. `cs_res` is its `/ColorSpace` resource dictionary. Returns the +/// new bytes when anything changed, and how many operators. +fn rewrite(data: &[u8], state: &mut State, doc: &Document, cs_res: &Dict, settings: &ConvertColours) -> (Option>, usize) { + let mut ops: Vec = parse(data).ops; + let mut count = 0usize; + let mut out: Vec = Vec::with_capacity(ops.len()); + let State { fill, stroke, stack } = state; + let numbers = |op: &Op| -> Vec { op.operands.iter().filter_map(Object::as_f64).collect() }; + let convert = |c: [f64; 3], is_stroke: bool| -> Op { + Op::new(op_for(settings.target, is_stroke), components(settings.target, c, settings.preserve_black).into_iter().map(r3).collect()) + }; + for op in ops.drain(..) { + match op.op.as_slice() { + b"q" => { + stack.push((fill.clone(), stroke.clone())); + if stack.len() > 512 { + stack.remove(0); + } + out.push(op); + } + b"Q" => { + if let Some((f, s)) = stack.pop() { + *fill = f; + *stroke = s; + } + out.push(op); + } + b"cs" | b"CS" => { + let is_stroke = op.op.as_slice() == b"CS"; + let space = op.operands.first().map(|o| space_of(doc, o, cs_res, 0)).unwrap_or(Space::Gray); + let pattern = space == Space::Pattern; + if is_stroke { + *stroke = Some(space); + } else { + *fill = Some(space); + } + if pattern { + // Patterns stay what they are (their own content is not converted here). + out.push(op); + } else { + // The space's initial colour, now in the target: black (a tint at full, + // DeviceCMYK's 0 0 0 1, ISO 32000-1 8.6.5.5). + let space = if is_stroke { stroke.clone() } else { fill.clone() }.unwrap_or(Space::Gray); + let initial = match space { + Space::Tint { .. } => rgb(&space, &[1.0; 8]), + Space::Cmyk if op.operands.first().is_some_and(|o| device_cmyk(doc, o, cs_res)) => rgb(&space, &[0.0, 0.0, 0.0, 1.0]), + _ => rgb(&space, &[]), + }; + out.push(convert(initial, is_stroke)); + count += 1; + } + } + b"g" | b"rg" | b"k" | b"G" | b"RG" | b"K" => { + let is_stroke = op.op[0].is_ascii_uppercase(); + let space = match op.op.to_ascii_lowercase().as_slice() { + b"g" => Space::Gray, + b"rg" => Space::Rgb, + _ => Space::Cmyk, + }; + let already = matches!( + (settings.target, &space), + (ColourTarget::Gray, Space::Gray) | (ColourTarget::Rgb, Space::Rgb) | (ColourTarget::Cmyk, Space::Cmyk) + ); + if is_stroke { + *stroke = None; + } else { + *fill = None; + } + if already { + out.push(op); + } else { + let c = rgb(&space, &numbers(&op)); + out.push(convert(c, is_stroke)); + count += 1; + } + } + b"sc" | b"scn" | b"SC" | b"SCN" => { + let is_stroke = op.op[0].is_ascii_uppercase(); + let space = if is_stroke { stroke.clone() } else { fill.clone() }; + match space { + Some(Space::Pattern) | None if op.operands.iter().any(|o| matches!(o, Object::Name(_))) => out.push(op), + None => { + // sc after g/rg/k: the device space those set; read the numbers by count. + let n = numbers(&op); + let space = match n.len() { + 1 => Space::Gray, + 4 => Space::Cmyk, + _ => Space::Rgb, + }; + out.push(convert(rgb(&space, &n), is_stroke)); + count += 1; + } + Some(Space::Pattern) => out.push(op), + Some(space) => { + out.push(convert(rgb(&space, &numbers(&op)), is_stroke)); + count += 1; + } + } + } + _ => out.push(op), + } + } + ((count > 0).then(|| serialize_ops(&out)), count) +} + +/// Whether `space`'s colours are `target`'s (an indexed space's are its base's). +fn in_target(space: &Space, target: ColourTarget) -> bool { + match (space, target) { + (Space::Gray, ColourTarget::Gray) | (Space::Rgb, ColourTarget::Rgb) | (Space::Cmyk, ColourTarget::Cmyk) => true, + (Space::Indexed { base, .. }, _) => in_target(base, target), + _ => false, + } +} + +/// Whether the picture `s` is outside `target`: a stencil mask has no colours of its own (it +/// paints with the fill colour, which is converted). +fn needs_converting(doc: &Document, s: &Stream, target: ColourTarget) -> bool { + let stencil = s.dict.get(b"ImageMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Bool(true))); + !stencil && !s.dict.get(b"ColorSpace").is_some_and(|cs| in_target(&space_of(doc, cs, &Dict::default(), 0), target)) +} + +fn stream_of(doc: &Document, r: ObjRef) -> Option { + match &*doc.get(r) { + Object::Stream(s) => Some(s.clone()), + _ => None, + } +} + +fn cs_resources(doc: &Document, resources: Option<&Dict>) -> Dict { + resources.and_then(|r| r.get(b"ColorSpace")).map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()).unwrap_or_default() +} + +/// What a run keeps across pages: each XObject already handled, and the object that now +/// stands for it (itself, or the converted copy made because a page not being converted draws +/// it too); and the XObjects those other pages draw. +struct Run { + done: HashMap, + shared: HashSet, +} + +/// The XObjects (forms and images, and those the forms draw), and appearance streams, that +/// the pages not in `pages` draw: converting one of those in place would change those pages. +fn drawn_elsewhere(doc: &Document, pages: &[usize]) -> HashSet { + let mut seen: HashSet = HashSet::new(); + let mut todo: Vec = Vec::new(); + let xobjects_of = |res: Option<&Object>, todo: &mut Vec| { + if let Some(xo) = res + .map(|r| doc.resolve(r)) + .and_then(|r| r.as_dict().and_then(|d| d.get(b"XObject")).map(|x| doc.resolve(x))) + .and_then(|x| x.as_dict().cloned()) + { + todo.extend(xo.iter().map(|(_, o)| o.clone())); + } + }; + for (i, page) in page_list(doc).into_iter().enumerate() { + if pages.contains(&i) { + continue; + } + xobjects_of(page.dict.get(b"Resources"), &mut todo); + let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); + for a in annots { + let Some(ap) = doc.resolve(&a).as_dict().and_then(|d| d.get(b"AP").cloned()) else { continue }; + let Some(ap) = doc.resolve(&ap).as_dict().cloned() else { continue }; + for (_, v) in ap.iter() { + match &*doc.resolve(v) { + Object::Dict(states) => todo.extend(states.iter().map(|(_, s)| s.clone())), + _ => todo.push(v.clone()), + } + } + } + } + while let Some(o) = todo.pop() { + let Some(r) = o.as_ref() else { continue }; + if !seen.insert(r) { + continue; + } + let res = match &*doc.get(r) { + Object::Stream(s) => s.dict.get(b"Resources").cloned(), + _ => None, + }; + xobjects_of(res.as_ref(), &mut todo); + } + seen +} + +/// Store a converted XObject: in place, or, when another page draws `r` too, as a new object +/// the caller points to instead. Returns the object that now stands for `r`. +fn put(doc: &mut Document, run: &mut Run, r: ObjRef, obj: Object) -> ObjRef { + let to = if run.shared.contains(&r) { + doc.add(obj) + } else { + doc.set(r, obj); + r + }; + run.done.insert(r, to); + to +} + +/// Convert the XObjects of `resources`: forms rewritten (and their own XObjects after them), +/// images through `recolour`. Each object is converted once; one that a page not being +/// converted draws is converted as a copy, and the returned `/XObject` dictionary, when there +/// is one, points at the copies (the caller puts it in `resources`' place). When `may_copy` is +/// false the caller cannot do that, so such objects are left alone. +fn convert_xobjects( + doc: &mut Document, + resources: Option<&Dict>, + settings: &ConvertColours, + recolour: &mut ImageRecolour<'_>, + run: &mut Run, + result: &mut Converted, + may_copy: bool, +) -> Option { + let xo = resources.and_then(|r| r.get(b"XObject")).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())?; + let mut repointed = xo.clone(); + let mut changed = false; + for (name, o) in xo.iter() { + let Some(r) = o.as_ref() else { continue }; + if let Some(&to) = run.done.get(&r) { + if to != r { + repointed.set(name.clone(), Object::Ref(to)); + changed = true; + } + continue; + } + if !may_copy && run.shared.contains(&r) { + continue; + } + // Marked before converting, so a form that draws itself ends. + run.done.insert(r, r); + let Some(s) = stream_of(doc, r) else { continue }; + let stored = match s.dict.name(b"Subtype") { + Some(b"Form") => { + let res = s.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()); + let cs = cs_resources(doc, res.as_ref()); + let (new, n) = match s.decoded() { + Ok(data) => rewrite(&data, &mut State::default(), doc, &cs, settings), + Err(_) => (None, 0), + }; + let inner = convert_xobjects(doc, res.as_ref(), settings, recolour, run, result, true); + if new.is_none() && inner.is_none() { + None + } else { + let mut form = s.clone(); + if let Some(xo) = inner { + let mut res = res.unwrap_or_default(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + form.dict.set(b"Resources".to_vec(), Object::Dict(res)); + } + if let Some(bytes) = new { + let mut dict = form.dict; + for k in [&b"Length"[..], b"Filter", b"DecodeParms"] { + dict.remove(k); + } + form = Stream::flate(dict, &bytes); + result.colours += n; + } + Some(put(doc, run, r, Object::Stream(form))) + } + } + Some(b"Image") if settings.images => match recolour(doc, &s, settings.target) { + Some(new) => { + result.images += 1; + Some(put(doc, run, r, Object::Stream(new))) + } + None => { + result.images_left += usize::from(needs_converting(doc, &s, settings.target)); + None + } + }, + _ => None, + }; + if let Some(to) = stored + && to != r + { + repointed.set(name.clone(), Object::Ref(to)); + changed = true; + } + } + changed.then_some(repointed) +} + +/// Convert the colours on `pages` (0-based). An object that pages not in `pages` also draw is +/// converted as a copy for these pages, so the others keep their colours. +pub fn convert_colours( + doc: &mut Document, + pages: &[usize], + settings: &ConvertColours, + recolour: &mut ImageRecolour<'_>, +) -> Result { + let all = page_list(doc); + check(pages, all.len())?; + let mut result = Converted::default(); + let mut run = Run { done: HashMap::new(), shared: drawn_elsewhere(doc, pages) }; + for &i in pages { + let Some(page) = page_list(doc).get(i).cloned() else { continue }; + let resources = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); + let cs = cs_resources(doc, resources.as_ref()); + let before = (result.colours, result.images); + // Each content stream on its own (state carries across them); a page with a stream + // that cannot be read keeps its content as it is. + if let Some(streams) = PageStreams::read(doc, &page.dict) { + let mut state = State::default(); + let mut new: Vec>> = Vec::with_capacity(streams.data.len()); + for data in &streams.data { + let (bytes, n) = rewrite(data, &mut state, doc, &cs, settings); + result.colours += n; + new.push(bytes); + } + streams.write(doc, page.obj, new)?; + } + if let Some(xo) = convert_xobjects(doc, resources.as_ref(), settings, recolour, &mut run, &mut result, true) { + // This page's own resources point at the copies; a resource dictionary other pages + // share is not changed. + let mut res = resources.clone().unwrap_or_default(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + } + if settings.annotations { + let annots: Vec = page + .dict + .get(b"Annots") + .map(|a| doc.resolve(a)) + .and_then(|a| a.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|a| a.as_ref()) + .collect(); + for a in annots { + let Some(ap) = + doc.get(a).as_dict().and_then(|d| d.get(b"AP").cloned()).map(|ap| doc.resolve(&ap)).and_then(|ap| ap.as_dict().cloned()) + else { + continue; + }; + // Every appearance stream (and the states of a /N dictionary) as a form. + let mut streams: Vec = Vec::new(); + for (_, v) in ap.iter() { + match &*doc.resolve(v) { + Object::Stream(_) => streams.extend(v.as_ref()), + Object::Dict(states) => streams.extend(states.iter().filter_map(|(_, s)| s.as_ref())), + _ => {} + } + } + let mut fake = Dict::new(); + let mut xo = Dict::new(); + for (k, r) in streams.iter().enumerate() { + xo.set(format!("A{k}").into_bytes(), Object::Ref(*r)); + } + fake.set(b"XObject".to_vec(), Object::Dict(xo)); + // An appearance stream other pages draw is left alone (there is no resource + // dictionary here to point at a copy). + convert_xobjects(doc, Some(&fake), settings, recolour, &mut run, &mut result, false); + } + } + if (result.colours, result.images) != before { + result.pages += 1; + } + } + Ok(result) +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index aa11617c14..1dff30af3c 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -54,6 +54,10 @@ pub use text::{ BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping, clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines, }; +mod colour; +pub mod convert_colours; +mod page_streams; +pub use convert_colours::{ColourTarget, ConvertColours, Converted as ColoursConverted, ImageRecolour, convert_colours}; pub mod added; pub use added::{ Added, AddedImage, AddedText, Align, Content, Family, MarkerScan, add_content, delete_content, first_undrawable, isolate_content, list_added, diff --git a/crates/edit/src/page_streams.rs b/crates/edit/src/page_streams.rs new file mode 100644 index 0000000000..eae00399da --- /dev/null +++ b/crates/edit/src/page_streams.rs @@ -0,0 +1,69 @@ +//! A page's content streams rewritten one at a time: each stream keeps its own dictionary +//! (and so its marks, such as `/PCMark`), and only the streams that changed are replaced. + +use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; + +use crate::EditError; + +/// The decoded content streams of one page, in drawing order. +pub(crate) struct PageStreams { + /// Each stream's object and dictionary. + entries: Vec<(ObjRef, Dict)>, + /// Each stream's decoded data, in the same order. + pub(crate) data: Vec>, +} + +impl PageStreams { + /// The streams of `page_dict`'s `/Contents`. `None` when one of them is not a stream or + /// cannot be decoded: rewriting the others would draw the page differently, so the page is + /// left alone. A page without content has no streams. + pub(crate) fn read(doc: &Document, page_dict: &Dict) -> Option { + let refs: Vec = match page_dict.get(b"Contents") { + None => Vec::new(), + Some(c) => match &*doc.resolve(c) { + Object::Array(a) => a.clone(), + _ => vec![c.clone()], + }, + }; + let mut entries = Vec::with_capacity(refs.len()); + let mut data = Vec::with_capacity(refs.len()); + for e in &refs { + let r = e.as_ref()?; + let (dict, bytes) = match &*doc.get(r) { + Object::Stream(s) => (s.dict.clone(), s.decoded().ok()?), + _ => return None, + }; + data.push(bytes); + entries.push((r, dict)); + } + Some(Self { entries, data }) + } + + /// Put `new` in place of the streams that changed (`None` keeps a stream as it is). Each + /// replacement is a new object with the old dictionary (minus its encoding), so a stream + /// another page shares is not touched. Returns whether anything changed. + pub(crate) fn write(self, doc: &mut Document, page: ObjRef, new: Vec>>) -> Result { + if new.iter().all(Option::is_none) { + return Ok(false); + } + let mut refs: Vec = Vec::with_capacity(self.entries.len()); + for (i, (r, dict)) in self.entries.into_iter().enumerate() { + match new.get(i).and_then(Option::as_ref) { + Some(bytes) => { + let mut dict = dict; + for k in [&b"Length"[..], b"Filter", b"DecodeParms", b"DL"] { + dict.remove(k); + } + refs.push(Object::Ref(doc.add(Object::Stream(Stream::flate(dict, bytes))))); + } + None => refs.push(Object::Ref(r)), + } + } + let contents = match <[Object; 1]>::try_from(refs) { + Ok([one]) => one, + Err(many) => Object::Array(many), + }; + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(true) + } +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index f7740d60cd..49b2d32132 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -7816,6 +7816,67 @@ fn removing_marks_strips_wrappers_that_closed_open_state() { assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]); } +/// Convert colours: device, ICC-based and separation colours in page content +/// and forms become the target's operators; black is preserved; patterns stay; a second run +/// finds nothing. +#[test] +fn colours_are_converted_to_gray_rgb_and_cmyk() { + use crate::convert_colours::{ColourTarget, ConvertColours, convert_colours}; + let mut doc = fixture(); + let page = page_list(&doc)[0].clone(); + // An ICCBased RGB space and a spot colour (Separation to CMYK, Type 2 tint). + let mut icc = Dict::new(); + icc.set(b"N".to_vec(), Object::Int(3)); + let icc_ref = doc.add(Object::Stream(Stream::flate(icc, b"not a real profile"))); + let mut fun = Dict::new(); + fun.set(b"FunctionType".to_vec(), Object::Int(2)); + fun.set(b"Domain".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(1)])); + fun.set(b"C0".to_vec(), Object::Array(vec![Object::Int(0); 4])); + fun.set(b"C1".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(1), Object::Int(1), Object::Int(0)])); + fun.set(b"N".to_vec(), Object::Int(1)); + let mut cs = Dict::new(); + cs.set(b"CS0".to_vec(), Object::Array(vec![Object::name("ICCBased"), Object::Ref(icc_ref)])); + cs.set(b"Spot".to_vec(), Object::Array(vec![Object::name("Separation"), Object::name("Red"), Object::name("DeviceCMYK"), Object::Dict(fun)])); + let mut res = Dict::new(); + res.set(b"ColorSpace".to_vec(), Object::Dict(cs)); + let content = b"1 0 0 rg 10 10 50 50 re f 0 0 1 RG 0 0 m 100 100 l S /CS0 cs 0 1 0 scn 0 0 20 20 re f /Spot cs 1 scn 30 30 20 20 re f 0 g 60 60 10 10 re f 0.5 g 70 70 5 5 re f"; + let c = doc.add(Object::Stream(Stream::flate(Dict::new(), content))); + doc.update_dict(page.obj, |d| { + d.set(b"Contents".to_vec(), Object::Ref(c)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + }) + .unwrap(); + let mut no_images = |_: &Document, _: &Stream, _: ColourTarget| None; + let flat = |b: &[u8]| String::from_utf8_lossy(b).split_whitespace().collect::>().join(" "); + // Grayscale. + let mut gray = doc.clone(); + let done = convert_colours(&mut gray, &[0], &ConvertColours::default(), &mut no_images).unwrap(); + assert_eq!((done.colours, done.pages), (6, 1), "{done:?}"); + let t = flat(&page_content_bytes(&gray, 0)); + assert!(t.contains("0.299 g 10 10 50 50 re f"), "red → its luma: {t}"); + assert!(t.contains("0.114 G 0 0 m 100 100 l S"), "blue stroke: {t}"); + assert!(t.contains("0 g 0 1 0 scn") || t.contains("0.587 g 0 0 20 20 re f"), "ICC green through its space: {t}"); + assert!(t.contains("0.587 g 0 0 20 20 re f"), "{t}"); + assert!(t.contains("0.299 g 30 30 20 20 re f"), "the spot's tint through its CMYK alternate (0 1 1 0 = red): {t}"); + assert!(t.contains("0 g 60 60 10 10 re f") && t.contains("0.5 g 70 70 5 5 re f"), "gray stays gray: {t}"); + assert!(!t.contains("/CS0 cs") && !t.contains("/Spot cs"), "named spaces are gone: {t}"); + assert_eq!(convert_colours(&mut gray, &[0], &ConvertColours::default(), &mut no_images).unwrap().colours, 0, "nothing left"); + // CMYK with black preserved; without, black is still 100% K by the formula. + let mut cmyk = doc.clone(); + let s = ConvertColours { target: ColourTarget::Cmyk, ..ConvertColours::default() }; + convert_colours(&mut cmyk, &[0], &s, &mut no_images).unwrap(); + let t = flat(&page_content_bytes(&cmyk, 0)); + assert!(t.contains("0 1 1 0 k 10 10 50 50 re f"), "red: {t}"); + assert!(t.contains("0 0 0 1 k 60 60 10 10 re f"), "black preserved: {t}"); + assert!(t.contains("0 0 0 0.5 k 70 70 5 5 re f"), "gray 0.5 → 50% K: {t}"); + // RGB: the spot becomes its red. + let mut rgbd = doc.clone(); + convert_colours(&mut rgbd, &[0], &ConvertColours { target: ColourTarget::Rgb, ..ConvertColours::default() }, &mut no_images).unwrap(); + let t = flat(&page_content_bytes(&rgbd, 0)); + assert!(t.contains("1 0 0 rg 30 30 20 20 re f"), "{t}"); + assert!(t.contains("1 0 0 rg 10 10 50 50 re f"), "already RGB, kept: {t}"); +} + /// #3087: flattening comments leaves alone what isn't a comment: links, and the non-markup /// multimedia and print-production annotations (3D, Screen, Movie, RichMedia, PrinterMark, /// TrapNet, Watermark), which it used to bake into the page and delete with their data. diff --git a/crates/edit/tests/it/convert_colours_cmyk_initial.rs b/crates/edit/tests/it/convert_colours_cmyk_initial.rs new file mode 100644 index 0000000000..277aaaaf20 --- /dev/null +++ b/crates/edit/tests/it/convert_colours_cmyk_initial.rs @@ -0,0 +1,71 @@ +//! Convert colours: `/DeviceCMYK cs` starts at black (0 0 0 1, ISO 32000-1 8.6.5.5), so the +//! converted operator is black too, not the white that four zeros would be. + +use pdfcraft_cos::{Document, ObjRef, Object, Stream}; +use pdfcraft_edit::{ColourTarget, ConvertColours, convert_colours}; +use std::sync::Arc; + +/// A document of `objs` (object k is `k + 1 0 obj`; object 1 is the catalog). A `{stream:...}` +/// entry is written as a stream holding the text after the colon, with its `/Length`. +fn pdf(objs: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + let body = match o.split_once(">>stream:") { + Some((dict, data)) => format!("{dict} /Length {} >>\nstream\n{data}\nendstream", data.len()), + None => (*o).to_string(), + }; + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn flat(b: &[u8]) -> String { + String::from_utf8_lossy(b).split_whitespace().collect::>().join(" ") +} + +fn stream_text(doc: &Document, r: ObjRef) -> String { + match &*doc.get(r) { + Object::Stream(s) => flat(&s.decoded().unwrap()), + _ => panic!("not a stream"), + } +} + +/// The page's `/Contents` references. +fn contents(doc: &Document, page: usize) -> Vec { + let p = pdfcraft_model::pages(doc).swap_remove(page); + match &*doc.resolve(p.dict.get(b"Contents").unwrap()) { + Object::Array(a) => a.iter().filter_map(Object::as_ref).collect(), + _ => p.dict.get(b"Contents").and_then(Object::as_ref).into_iter().collect(), + } +} + +fn no_images(_: &Document, _: &Stream, _: ColourTarget) -> Option { + None +} + +fn page_with(content: &str) -> Document { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>", + &format!("<< >>stream:{content}"), + ]) +} + +#[test] +fn device_cmyk_starts_at_black() { + let mut doc = page_with("/DeviceCMYK cs 0 0 10 10 re f /DeviceCMYK CS 0 0 m 10 10 l S"); + let s = ConvertColours { preserve_black: false, ..ConvertColours::default() }; + let done = convert_colours(&mut doc, &[0], &s, &mut no_images).unwrap(); + assert_eq!(done.colours, 2); + let t = stream_text(&doc, contents(&doc, 0)[0]); + assert!(t.contains("0 g 0 0 10 10 re f") && t.contains("0 G 0 0 m 10 10 l S"), "black, not white: {t}"); +} diff --git a/crates/edit/tests/it/convert_colours_shared.rs b/crates/edit/tests/it/convert_colours_shared.rs new file mode 100644 index 0000000000..bd373011d6 --- /dev/null +++ b/crates/edit/tests/it/convert_colours_shared.rs @@ -0,0 +1,144 @@ +//! Convert colours on some pages leaves the others as they were: a form or picture they share +//! is converted as a copy for the pages being converted, and the resource dictionary they +//! share is not changed. + +use pdfcraft_cos::{Document, ObjRef, Object, Stream}; +use pdfcraft_edit::{ColourTarget, ConvertColours, convert_colours}; +use std::sync::Arc; + +/// A document of `objs` (object k is `k + 1 0 obj`; object 1 is the catalog). A `{stream:...}` +/// entry is written as a stream holding the text after the colon, with its `/Length`. +fn pdf(objs: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + let body = match o.split_once(">>stream:") { + Some((dict, data)) => format!("{dict} /Length {} >>\nstream\n{data}\nendstream", data.len()), + None => (*o).to_string(), + }; + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn flat(b: &[u8]) -> String { + String::from_utf8_lossy(b).split_whitespace().collect::>().join(" ") +} + +fn stream_text(doc: &Document, r: ObjRef) -> String { + match &*doc.get(r) { + Object::Stream(s) => flat(&s.decoded().unwrap()), + _ => panic!("not a stream"), + } +} + +fn no_images(_: &Document, _: &Stream, _: ColourTarget) -> Option { + None +} + +/// Two pages sharing one resource dictionary (inherited from the page tree) whose form draws +/// red, and a form inside it that draws blue. +fn two_pages() -> Document { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 600 800] /Resources 5 0 R >>", + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>", + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>", + "<< /XObject << /Fm1 7 0 R >> >>", + "<< >>stream:/Fm1 Do", + "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /XObject << /Fm2 8 0 R >> >> >>stream:1 0 0 rg 0 0 10 10 re f /Fm2 Do", + "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] >>stream:0 0 1 rg 0 0 5 5 re f", + ]) +} + +fn form_of(doc: &Document, page: usize, name: &[u8]) -> ObjRef { + let p = pdfcraft_model::pages(doc).swap_remove(page); + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + xo.as_dict().unwrap().get(name).and_then(Object::as_ref).unwrap() +} + +fn inner_form(doc: &Document, outer: ObjRef) -> ObjRef { + match &*doc.get(outer) { + Object::Stream(s) => { + let res = doc.resolve(s.dict.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + xo.as_dict().unwrap().get(b"Fm2").and_then(Object::as_ref).unwrap() + } + _ => panic!("not a stream"), + } +} + +#[test] +fn a_shared_form_is_converted_as_a_copy() { + let mut doc = two_pages(); + let done = convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap(); + assert_eq!(done.colours, 2); + // The other page still draws the original forms, in their colours. + let kept = form_of(&doc, 1, b"Fm1"); + assert!(stream_text(&doc, kept).contains("1 0 0 rg 0 0 10 10 re f"), "{}", stream_text(&doc, kept)); + assert!(stream_text(&doc, inner_form(&doc, kept)).contains("0 0 1 rg 0 0 5 5 re f")); + // The converted page draws copies, the inner one too. + let copy = form_of(&doc, 0, b"Fm1"); + assert_ne!(copy, kept); + assert!(stream_text(&doc, copy).contains("0.299 g 0 0 10 10 re f"), "{}", stream_text(&doc, copy)); + assert!(stream_text(&doc, inner_form(&doc, copy)).contains("0.114 g 0 0 5 5 re f")); +} + +#[test] +fn converting_every_page_changes_the_forms_in_place() { + let mut doc = two_pages(); + let before = form_of(&doc, 0, b"Fm1"); + convert_colours(&mut doc, &[0, 1], &ConvertColours::default(), &mut no_images).unwrap(); + assert_eq!(form_of(&doc, 0, b"Fm1"), before); + assert_eq!(form_of(&doc, 1, b"Fm1"), before); + assert!(stream_text(&doc, before).contains("0.299 g 0 0 10 10 re f")); +} + +/// One page of `content` with `res` as its resources, followed by `more` objects from 5 up. +fn page_of(res: &str, content: &str, more: &[&str]) -> Document { + let page = format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources {res} >>"); + let content = format!("<< >>stream:{content}"); + let mut objs = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", page.as_str(), content.as_str()]; + objs.extend_from_slice(more); + pdf(&objs) +} + +/// The `/None` colorant paints nothing; a colour of its own would make it appear. +#[test] +fn the_none_colorant_stays() { + let tint = "<< /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0 0 1] /N 1 >>"; + let res = format!( + "<< /ColorSpace << /S [/Separation /None /DeviceCMYK {tint}] /D [/DeviceN [/None /None] /DeviceCMYK {tint}] /Real [/Separation /Red /DeviceCMYK {tint}] >> >>" + ); + let content = "/S cs 1 scn 0 0 9 9 re f /D cs 1 1 scn 0 0 9 9 re f"; + let mut doc = page_of(&res, content, &[]); + assert_eq!(convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap().colours, 0); + assert_eq!(stream_text(&doc, ObjRef { num: 4, generation: 0 }), content); + let mut doc = page_of(&res, "/Real cs 1 scn 0 0 9 9 re f", &[]); + assert_eq!(convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap().colours, 2, "a real spot is converted"); +} + +/// Pictures the helper cannot re-encode are counted, unless they are in the target already or +/// have no colours of their own. +#[test] +fn pictures_left_alone_are_counted() { + const IMAGE: &str = "/Type /XObject /Subtype /Image /Width 1 /Height 1"; + let indexed = format!("<< {IMAGE} /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 0 ] >>stream:x"); + let gray_table = format!("<< {IMAGE} /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceGray 0 <80>] >>stream:x"); + let gray = format!("<< {IMAGE} /BitsPerComponent 8 /ColorSpace /DeviceGray >>stream:x"); + let stencil = format!("<< {IMAGE} /BitsPerComponent 1 /ImageMask true >>stream:x"); + let res = "<< /XObject << /A 5 0 R /B 6 0 R /C 7 0 R /D 8 0 R >> >>"; + let mut doc = page_of(res, "/A Do /B Do /C Do /D Do", &[&indexed, &gray_table, &gray, &stencil]); + let mut none = doc.clone(); + assert_eq!(convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap().images_left, 1); + let s = ConvertColours { images: false, ..ConvertColours::default() }; + assert_eq!(convert_colours(&mut none, &[0], &s, &mut no_images).unwrap().images_left, 0, "pictures were not asked for"); +} diff --git a/crates/edit/tests/it/convert_colours_streams.rs b/crates/edit/tests/it/convert_colours_streams.rs new file mode 100644 index 0000000000..edad0a32e8 --- /dev/null +++ b/crates/edit/tests/it/convert_colours_streams.rs @@ -0,0 +1,100 @@ +//! Convert colours rewrites each of a page's content streams on its own: a stream keeps its +//! dictionary (its `/PCMark` among it), unchanged streams stay as they are, the colour space +//! set in one stream holds in the next, and a page with an unreadable stream is left alone. + +use pdfcraft_cos::{Document, ObjRef, Object, Stream}; +use pdfcraft_edit::{ColourTarget, ConvertColours, convert_colours}; +use std::sync::Arc; + +/// A document of `objs` (object k is `k + 1 0 obj`; object 1 is the catalog). A `{stream:...}` +/// entry is written as a stream holding the text after the colon, with its `/Length`. +fn pdf(objs: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + let body = match o.split_once(">>stream:") { + Some((dict, data)) => format!("{dict} /Length {} >>\nstream\n{data}\nendstream", data.len()), + None => (*o).to_string(), + }; + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn flat(b: &[u8]) -> String { + String::from_utf8_lossy(b).split_whitespace().collect::>().join(" ") +} + +fn stream_text(doc: &Document, r: ObjRef) -> String { + match &*doc.get(r) { + Object::Stream(s) => flat(&s.decoded().unwrap()), + _ => panic!("not a stream"), + } +} + +/// The page's `/Contents` references. +fn contents(doc: &Document, page: usize) -> Vec { + let p = pdfcraft_model::pages(doc).swap_remove(page); + match &*doc.resolve(p.dict.get(b"Contents").unwrap()) { + Object::Array(a) => a.iter().filter_map(Object::as_ref).collect(), + _ => p.dict.get(b"Contents").and_then(Object::as_ref).into_iter().collect(), + } +} + +fn no_images(_: &Document, _: &Stream, _: ColourTarget) -> Option { + None +} + +/// One page drawing `streams`, with an indexed colour space `/CS0` (0 = red, 1 = green). +fn page_with(streams: &[&str]) -> Document { + let refs: Vec = (0..streams.len()).map(|i| format!("{} 0 R", i + 4)).collect(); + let page = format!( + "<< /Type /Page /Parent 2 0 R /Resources << /ColorSpace << /CS0 [/Indexed /DeviceRGB 1 ] >> >> /Contents [{}] >>", + refs.join(" ") + ); + let mut objs: Vec<&str> = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", &page]; + objs.extend_from_slice(streams); + pdf(&objs) +} + +#[test] +fn each_stream_keeps_its_dictionary_and_only_changed_ones_are_replaced() { + let mut doc = page_with(&["<< >>stream:0.5 g 0 0 10 10 re f", "<< /PCMark /Redaction >>stream:1 0 0 rg 0 0 10 10 re f"]); + let before = contents(&doc, 0); + let done = convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap(); + assert_eq!(done.colours, 1); + let after = contents(&doc, 0); + assert_eq!(after.len(), 2, "still two streams"); + assert_eq!(after[0], before[0], "the unchanged stream is the same object"); + let mark = match &*doc.get(after[1]) { + Object::Stream(s) => s.dict.name(b"PCMark").map(<[u8]>::to_vec), + _ => None, + }; + assert_eq!(mark.as_deref(), Some(&b"Redaction"[..]), "the mark is kept"); + assert!(stream_text(&doc, after[1]).contains("0.299 g 0 0 10 10 re f")); +} + +#[test] +fn the_colour_space_carries_from_one_stream_to_the_next() { + // Index 0 of the space set in the first stream is red; read as a gray, it would be black. + let mut doc = page_with(&["<< >>stream:/CS0 cs", "<< >>stream:0 sc 0 0 10 10 re f"]); + convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap(); + let after = contents(&doc, 0); + assert!(stream_text(&doc, after[1]).contains("0.299 g 0 0 10 10 re f"), "{}", stream_text(&doc, after[1])); +} + +#[test] +fn a_page_with_an_unreadable_stream_is_left_alone() { + let mut doc = page_with(&["<< >>stream:1 0 0 rg 0 0 10 10 re f", "<< /Filter /DCTDecode >>stream:not a picture"]); + let before = contents(&doc, 0); + let done = convert_colours(&mut doc, &[0], &ConvertColours::default(), &mut no_images).unwrap(); + assert_eq!(done.colours, 0); + assert_eq!(contents(&doc, 0), before, "no stream is dropped"); +} diff --git a/crates/edit/tests/it/main.rs b/crates/edit/tests/it/main.rs new file mode 100644 index 0000000000..b64db49da1 --- /dev/null +++ b/crates/edit/tests/it/main.rs @@ -0,0 +1,5 @@ +//! The crate's integration tests, linked into one test binary. + +mod convert_colours_cmyk_initial; +mod convert_colours_shared; +mod convert_colours_streams; diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index e479232bbb..6746a41fa7 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -409,7 +409,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ items: &[ item("Output preview", "layers", "prepress.output_preview", Planned("M11")), item("Preflight", "file-check", "preflight.run", Planned("M11")), - item("Convert colors", "palette", "prepress.convert_colors", Planned("M11")), + item("Convert colours", "palette", "prepress.convert_colors", Ready), item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready), item("Add printer marks", "ruler", "prepress.marks", Planned("M11")), item("Fix hairlines", "minus", "prepress.hairlines", Planned("M11")), diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0647726452..d482da896e 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -397,6 +397,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"), c("page.split", "Split document…", PAGES, None, Assembly, "scissors"), c("page.number", "Number pages…", PAGES, None, Assembly, "hash"), + c("prepress.convert_colors", "Convert colours…", None, None, Modification, "palette"), c("page.properties", "Page properties…", PAGES, None, Modification, "info"), c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"), c("help.discord", "Join the ArtCraft Discord", HELP, None, Nothing, "messages-square"), diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 8f43689aa9..da88048160 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -317,6 +317,11 @@ pub enum Edit { DeleteAttachment { key: String, }, + /// Print production ▸ Convert colours: every colour on the pages in grayscale, RGB or CMYK. + ConvertColours { + pages: Vec, + settings: pdfcraft_edit::ConvertColours, + }, /// Build a tagged structure tree for an untagged, text-only document. AutoTagText, /// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 5343804ef3..b024ff7032 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -240,6 +240,7 @@ impl Edit { Edit::SetInitialView(_) => Label::of("Change initial view"), Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()), Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()), + Edit::ConvertColours { .. } => Label::of("Convert colours"), Edit::AutoTagText => Label::of("Automatically tag text"), Edit::SetAltText { .. } => Label::of("Set alternate text"), Edit::AutoTagFormFields => Label::of("Tag form fields"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 5ed70aed7a..14ded42d55 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -85,6 +85,11 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re Err(EditError::NotPermitted("changing security")) } } + // Converting the appearances of comments and form fields changes them too, which + // content changes alone do not allow. + Edit::ConvertColours { settings, .. } if settings.annotations && !(p.modify() && p.annotate()) => { + Err(EditError::NotPermitted(if p.modify() { "comments" } else { "changes to the document" })) + } // Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted // document it is a change of security (#3921). Edit::ConvertPdfA { .. } => { @@ -128,6 +133,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::SetInitialView(_) | Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } + | Edit::ConvertColours { .. } | Edit::AutoTagText | Edit::SetAltText { .. } | Edit::AutoTagFormFields @@ -524,6 +530,20 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?; } Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, + Edit::ConvertColours { pages, settings } => { + let mut recolour = |doc: &pdfcraft_cos::Document, s: &pdfcraft_cos::Stream, t: pdfcraft_edit::ColourTarget| { + let target = match t { + pdfcraft_edit::ColourTarget::Gray => pdfcraft_optimize::ImageTarget::Gray, + pdfcraft_edit::ColourTarget::Rgb => pdfcraft_optimize::ImageTarget::Rgb, + pdfcraft_edit::ColourTarget::Cmyk => pdfcraft_optimize::ImageTarget::Cmyk, + }; + pdfcraft_optimize::recolour_image(doc, s, target) + }; + let done = pdfcraft_edit::convert_colours(doc, pages, settings, &mut recolour)?; + if done.colours == 0 && done.images == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("nothing on those pages needed converting".into()))); + } + } Edit::ReplaceText { page, quads, text, author, strike, caret } => { // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by // the strikeout's `/NM` (#2666). diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index fafa65b4c2..f70f12a5c0 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -86,7 +86,8 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } - | Edit::RemoveMarks { .. } => Scope::Content, + | Edit::RemoveMarks { .. } + | Edit::ConvertColours { .. } => Scope::Content, Edit::Batch { edits, .. } => { let mut scopes = edits.iter().map(scope_of); let first = scopes.next().unwrap_or(Scope::Full); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fe139164be..b0bc160635 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -86,7 +86,8 @@ pub use pdfcraft_edit::added::{ }; pub use pdfcraft_edit::paragraph_align; pub use pdfcraft_edit::{ - Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, + Added, AddedImage, AddedText, Align as TextAlign, Background, ColourTarget, ColoursConverted, Content as AddedContent, ConvertColours, + Family as FontFamily, HeaderFooter, MarkKind, Watermark, }; pub use pdfcraft_forms::{ BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 34d75e5c82..284997ff2c 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -114,6 +114,24 @@ impl Session { .map_err(EditError::Write) } + /// What Convert colours would change on `pages` of `id`, without changing the document. + pub fn count_colours(&self, id: DocId, pages: &[usize], settings: &pdfcraft_edit::ConvertColours) -> pdfcraft_edit::ColoursConverted { + let Some(doc) = self.get(id) else { return Default::default() }; + let Some(editor) = doc.editor.as_ref() else { return Default::default() }; + let mut cos = editor.cos.clone(); + let mut recolour = |doc: &pdfcraft_cos::Document, s: &pdfcraft_cos::Stream, t: pdfcraft_edit::ColourTarget| { + let target = match t { + pdfcraft_edit::ColourTarget::Gray => pdfcraft_optimize::ImageTarget::Gray, + pdfcraft_edit::ColourTarget::Rgb => pdfcraft_optimize::ImageTarget::Rgb, + pdfcraft_edit::ColourTarget::Cmyk => pdfcraft_optimize::ImageTarget::Cmyk, + }; + pdfcraft_optimize::recolour_image(doc, s, target) + }; + // The preview runs the image decoders over the document's own bytes; a panic in one is + // a count of nothing, not a crash. + guard(|| pdfcraft_edit::convert_colours(&mut cos, pages, settings, &mut recolour).unwrap_or_default()).unwrap_or_default() + } + /// The print-ready PDF for `settings` (sheets laid out for the paper; see `pdfcraft-print`). pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result, EditError> { let doc = self.get(id).ok_or(EditError::NoDocument)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6a2bae4ff9..3cc2b408e8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2200,6 +2200,29 @@ fn highlight_multiplies_over_text() { assert!(bg[0] > 240 && bg[1] > 240 && bg[2] < 30, "{bg:?}"); } +/// Converting the appearances of comments and form fields needs the permission to comment; +/// converting the page alone needs only the permission to modify. +#[test] +fn converting_colours_of_comments_needs_the_comment_permission() { + let mut cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + let params = pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "own", + permissions: !(1 << 5), + encrypt_metadata: true, + seed: [7; 32], + }; + cos.set_encryption(¶ms).unwrap(); + let bytes = pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let mut s = Session::new(); + let id = s.open("locked.pdf", None, Arc::new(bytes), None).unwrap(); + let with = ConvertColours { annotations: true, ..ConvertColours::default() }; + assert_eq!(s.apply(id, Edit::ConvertColours { pages: vec![0], settings: with }), Err(EditError::NotPermitted("comments"))); + let without = s.apply(id, Edit::ConvertColours { pages: vec![0], settings: ConvertColours::default() }); + assert!(!matches!(without, Err(EditError::NotPermitted(_))), "{without:?}"); +} + #[test] fn comment_permission_is_enforced() { let mut cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); @@ -8581,6 +8604,41 @@ fn cropping_an_existing_image_clips_it_and_moves_with_it() { assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([10.0, 10.0, 10.0, 50.0]) }).is_err()); } +/// Convert colours re-encodes pictures too (RGB → gray, the JPEG staying JPEG) and is one undo +/// step; a document with nothing to convert refuses rather than leaving an empty step. +#[test] +fn converting_colours_recolours_pictures() { + let mut s = Session::new(); + let mut px = image::RgbImage::new(8, 8); + for p in px.pixels_mut() { + *p = image::Rgb([200, 40, 40]); + } + let mut jpg = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgb8(px).write_to(&mut jpg, image::ImageFormat::Jpeg).unwrap(); + let pic = s.create_from_images(&[("p.jpg".into(), jpg.into_inner())]).unwrap(); + let id = s.open("p.pdf", None, pic, None).unwrap(); + // The picture's colour space and filter, read from the document's current bytes. + let picture = |s: &Session| -> (String, String) { + let doc = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + doc.scan_objects() + .find_map(|(_, o)| match &*o { + pdfcraft_cos::Object::Stream(st) if st.dict.name(b"Subtype") == Some(b"Image") => Some(( + String::from_utf8_lossy(st.dict.name(b"ColorSpace").unwrap_or_default()).into_owned(), + String::from_utf8_lossy(st.dict.name(b"Filter").unwrap_or_default()).into_owned(), + )), + _ => None, + }) + .expect("the picture") + }; + assert_eq!(picture(&s).0, "DeviceRGB"); + s.apply(id, Edit::ConvertColours { pages: vec![0], settings: ConvertColours::default() }).unwrap(); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Convert colours")); + assert_eq!(picture(&s), ("DeviceGray".to_string(), "DCTDecode".to_string())); + assert!(matches!(s.apply(id, Edit::ConvertColours { pages: vec![0], settings: ConvertColours::default() }), Err(EditError::Edit(_)))); + s.undo(id).unwrap(); + assert_eq!(picture(&s).0, "DeviceRGB"); +} + /// A 60 x 20 JPEG at 144 x 72 dpi, tagged with EXIF Orientation 6 (shown turned 90 degrees clockwise). fn phone_jpeg() -> Vec { use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit}; diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 613cc3f26f..0cb710111b 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -1305,6 +1305,76 @@ fn at_least_pdf_1_4(doc: &mut Document) -> Result<(), OptimizeError> { Ok(()) } +/// The space a picture is re-encoded in by [`recolour_image`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ImageTarget { + Gray, + Rgb, + Cmyk, +} + +/// Re-encode an 8-bit gray or RGB picture (Flate or JPEG, no mask, no decode array) in +/// `target`: gray and RGB pictures keep their compression (JPEG stays JPEG, compressed a second +/// time at quality 80, so with some loss), CMYK is written lossless. A soft mask stays as it is, +/// and so does a picture whose soft mask has a `/Matte`, which is a colour in the picture's own +/// space. `None` when the picture is not one this handles, or already in the target space. +pub fn recolour_image(doc: &Document, s: &Stream, target: ImageTarget) -> Option { + let d = &s.dict; + if d.contains(b"Mask") || matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) || d.int(b"BitsPerComponent") != Some(8) { + return None; + } + if d.get(b"SMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Stream(m) if m.dict.contains(b"Matte"))) { + return None; + } + if d.get(b"Decode").is_some_and(|a| !is_identity_decode(doc, a, 3)) && d.get(b"Decode").is_some_and(|a| !is_identity_decode(doc, a, 1)) { + return None; + } + let n = components(doc, d.get(b"ColorSpace")?)?; + let want = match target { + ImageTarget::Gray => 1, + ImageTarget::Rgb => 3, + ImageTarget::Cmyk => 4, + }; + if n == want { + return None; + } + let (w, h) = (d.int(b"Width")? as u32, d.int(b"Height")? as u32); + let px = pixels(doc, s, n)?; + let was_dct = is_dct(s)?; + let out: Vec = match (n, target) { + (3, ImageTarget::Gray) => { + px.as_chunks::<3>().0.iter().map(|p| ((299 * p[0] as u32 + 587 * p[1] as u32 + 114 * p[2] as u32) / 1000) as u8).collect() + } + (1, ImageTarget::Rgb) => px.iter().flat_map(|g| [*g, *g, *g]).collect(), + (1, ImageTarget::Cmyk) => px.iter().flat_map(|g| [0, 0, 0, 255 - *g]).collect(), + (3, ImageTarget::Cmyk) => px + .as_chunks::<3>() + .0 + .iter() + .flat_map(|p| { + let k = 255 - p.iter().copied().max().unwrap_or(0) as u32; + if k >= 255 { + return [0u8, 0, 0, 255]; + } + let f = |v: u8| (((255 - v as u32 - k) * 255) / (255 - k)).min(255) as u8; + [f(p[0]), f(p[1]), f(p[2]), k as u8] + }) + .collect(), + _ => return None, + }; + let mut dict = d.clone(); + dict.set( + b"ColorSpace".to_vec(), + Object::name(match target { + ImageTarget::Gray => "DeviceGray", + ImageTarget::Rgb => "DeviceRGB", + ImageTarget::Cmyk => "DeviceCMYK", + }), + ); + let compression = if want == 4 { Compression::Flate } else { Compression::Retain }; + image_stream(&dict, &out, want, w, h, compression, was_dct) +} + #[cfg(test)] mod dimension_tests { use super::*; diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 4c8845c82d..552c0a84b9 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -39,7 +39,7 @@ pub use audit::{SpaceCategory, SpaceUse, audit_space}; use pdfcraft_cos::{Dict, Document, MAX_PAGE_DECODED, ObjRef, Object, Stream, serialize}; use pdfcraft_filters::{Filter, Params}; -pub use images::effective_resolutions; +pub use images::{ImageTarget, effective_resolutions, recolour_image}; #[derive(Debug, thiserror::Error)] pub enum OptimizeError { diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 5bfa74aa8b..b384d34b62 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3741,3 +3741,29 @@ fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margin assert_eq!(render(&saved), before, "local halftone {side}x{side}"); } } + +/// Recolouring keeps a picture's soft mask, but not a picture whose mask has a `/Matte`: that is +/// a colour in the picture's own space. +#[test] +fn recolouring_keeps_soft_masks_and_leaves_matted_pictures() { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + use pdfcraft_optimize::{ImageTarget, recolour_image}; + let mut doc = Document::new_empty(); + let picture = |mask| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(1)); + d.set(b"Height".to_vec(), Object::Int(1)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + d.set(b"SMask".to_vec(), Object::Ref(mask)); + Stream::flate(d, &[255, 0, 0]) + }; + let plain = doc.add(Object::Stream(Stream::flate(Dict::new(), &[128]))); + let new = recolour_image(&doc, &picture(plain), ImageTarget::Gray).unwrap(); + assert_eq!(new.dict.get(b"SMask"), Some(&Object::Ref(plain))); + let mut matted = Dict::new(); + matted.set(b"Matte".to_vec(), Object::Array(vec![Object::Int(0); 3])); + let matted = doc.add(Object::Stream(Stream::flate(matted, &[128]))); + assert!(recolour_image(&doc, &picture(matted), ImageTarget::Gray).is_none()); +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 43d0066e63..e8f5b86fc3 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -295,6 +295,7 @@ impl PdfCraftApp { replace_draft: None, number_draft: NumberDraft { from: 1, to: 1, style: pdfcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 }, word_count_draft: word_count_ui::Draft::default(), + convert_colours_draft: Default::default(), } } diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index e4078efd13..d43c1d7de7 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -135,6 +135,7 @@ impl PdfCraftApp { self.execute("page.number"); Some(Dialog::NumberPages) } + "convert-colours" => Some(Dialog::ConvertColours), "page-properties" => { // Same path as the panel, so the selected pages and their order are seeded. self.execute("page.properties"); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index f072b7febe..316e773971 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -209,6 +209,7 @@ impl PdfCraftApp { self.protect_draft = Default::default(); self.dialog = Some(Dialog::Protect); } + "prepress.convert_colors" => self.dialog = Some(Dialog::ConvertColours), "protect.remove" => { if self.apply_edit(Edit::RemoveProtection) { self.notify_tr("Security will be removed when you save"); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 42bed83657..4da20bb40d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -10,6 +10,7 @@ use crate::{CloseRequest, Dialog, PdfCraftApp, PropsTab, panels::human_size, wid pub(super) mod appearance; pub mod bookmark_styles_ui; pub(super) mod comment_props; +pub(super) mod convert_colours_ui; pub(super) mod credits; pub(super) mod marks_ui; pub(super) mod pageboxes; @@ -39,6 +40,7 @@ struct Pending { link_command: Option<&'static str>, protect_now: bool, boxes_now: bool, + colours_now: bool, marks_now: bool, export_now: bool, props_now: bool, @@ -423,6 +425,12 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m req.close = req.apply || cancel; return false; } + Dialog::ConvertColours => { + let (go, cancel) = crate::convert_colours_ui::body(ui, app, &t); + req.colours_now = go; + req.close = go || cancel; + return false; + } Dialog::BookmarksFromTextStyles => { req.close = crate::bookmark_styles_ui::body(ui, app, &t); return false; @@ -1922,6 +1930,9 @@ fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { app.protect_draft = Default::default(); app.notify_tr("Password protection will be applied when you save"); } + if req.colours_now { + app.convert_colours(); + } if req.apply_number && let Some(edit) = req.number_now.take() { diff --git a/crates/ui-egui/src/dialogs/convert_colours_ui.rs b/crates/ui-egui/src/dialogs/convert_colours_ui.rs new file mode 100644 index 0000000000..3c1ad198b0 --- /dev/null +++ b/crates/ui-egui/src/dialogs/convert_colours_ui.rs @@ -0,0 +1,141 @@ +//! Print production ▸ Convert colours (Acrobat's Convert Colors): the target +//! space, what to convert, the pages. Convert counts first and says what changed. + +use pdfcraft_engine::{ColourTarget, ConvertColours, DocId, Edit}; + +use crate::pageboxes::Range; +use crate::theme::{self, Tokens}; +use crate::{PdfCraftApp, widgets}; + +#[derive(Clone, Debug, PartialEq)] +pub struct ConvertColoursDraft { + pub settings: ConvertColours, + pub range: Range, + /// The document `range` was chosen for. + pub doc: Option, +} + +impl Default for ConvertColoursDraft { + fn default() -> Self { + Self { settings: ConvertColours::default(), range: Range::All, doc: None } + } +} + +impl ConvertColoursDraft { + /// A range chosen for another document (pages 4–5 of a long one) means nothing on this one. + pub fn for_document(&mut self, id: DocId) { + if self.doc != Some(id) { + self.range = Range::All; + self.doc = Some(id); + } + } + + pub fn pages(&self, current: usize, count: usize) -> Vec { + match self.range { + Range::All => (0..count).collect(), + Range::Current => vec![current.min(count.saturating_sub(1))], + Range::Pages(a, b) => (a.max(1) - 1..b.min(count)).collect(), + } + } +} + +/// The dialog: (convert, cancel). +pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) { + let Some((i, id)) = app.active_ids() else { return (false, true) }; + let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); + let current = app.views[i].current; + let d = &mut app.convert_colours_draft; + d.for_document(id); + ui.label(egui::RichText::new(tl!("Convert colours")).font(theme::semibold(18.0))); + ui.add_space(8.0); + widgets::section_title(ui, tl!("Convert to")); + ui.horizontal(|ui| { + widgets::radio_value(ui, &mut d.settings.target, ColourTarget::Gray, tl!("Grayscale")); + widgets::radio_value(ui, &mut d.settings.target, ColourTarget::Rgb, tl!("RGB")); + widgets::radio_value(ui, &mut d.settings.target, ColourTarget::Cmyk, tl!("CMYK")); + }); + ui.label(egui::RichText::new(tl!("By the standard device formulas; profiles are not applied.")).small().color(t.text_muted)); + widgets::section_title(ui, tl!("Options")); + ui.horizontal(|ui| { + ui.add(widgets::checkbox(&mut d.settings.preserve_black, tl!("Preserve black"))); + ui.label(egui::RichText::new(tl!("pure black stays 100% K or gray 0 instead of a mix")).small().color(t.text_muted)); + }); + ui.horizontal(|ui| { + ui.add(widgets::checkbox(&mut d.settings.images, tl!("Pictures too"))); + ui.label(egui::RichText::new(tl!("re-encoded in the target space; JPEGs stay JPEG")).small().color(t.text_muted)); + }); + ui.horizontal(|ui| { + ui.add(widgets::checkbox(&mut d.settings.annotations, tl!("Comments and form fields"))); + ui.label(egui::RichText::new(tl!("their appearances as well as the page")).small().color(t.text_muted)); + }); + widgets::section_title(ui, tl!("Pages")); + ui.horizontal(|ui| { + widgets::radio_value(ui, &mut d.range, Range::All, tl!("All")); + widgets::radio_value(ui, &mut d.range, Range::Current, crate::i18n::fmt(tl!("Current page ({n})"), &[("n", &(current + 1).to_string())])); + let (mut a, mut b) = match d.range { + Range::Pages(a, b) => (a, b), + _ => (1, count.max(1)), + }; + let on = matches!(d.range, Range::Pages(..)); + let from = ui.add(widgets::radio(on, tl!("From"))); + if from.clicked() { + d.range = Range::Pages(a, b); + } + let ra = ui.add(egui::DragValue::new(&mut a).range(1..=count.max(1))).labelled_by(from.id); + let to = ui.label(tl!("to")); + let rb = ui.add(egui::DragValue::new(&mut b).range(1..=count.max(1))).labelled_by(to.id); + if ra.changed() || rb.changed() { + d.range = Range::Pages(a.min(b), b.max(a)); + } + }); + ui.add_space(12.0); + let (mut go, mut cancel) = (false, false); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.add_enabled_ui(count > 0, |ui| widgets::pill_button(ui, tl!("Convert"), true)).inner.clicked() { + go = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + cancel = true; + } + }); + (go, cancel) +} + +impl PdfCraftApp { + /// Convert: count first (the edit refuses when nothing changes), then apply and say. + pub(crate) fn convert_colours(&mut self) { + let Some((i, id)) = self.active_ids() else { return }; + let count = self.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); + let pages = self.convert_colours_draft.pages(self.views[i].current, count); + let settings = self.convert_colours_draft.settings.clone(); + let found = self.session.count_colours(id, &pages, &settings); + if found.colours == 0 && found.images == 0 { + self.notify_tr("Nothing on those pages needed converting"); + return; + } + if self.apply_edit(Edit::ConvertColours { pages, settings }) { + self.notify_fmt( + "Converted {c} colours and {i} pictures on {p} pages", + &[("c", &found.colours.to_string()), ("i", &found.images.to_string()), ("p", &found.pages.to_string())], + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_page_range_does_not_follow_the_user_to_another_document() { + let (a, b) = (DocId(1), DocId(2)); + let mut d = ConvertColoursDraft::default(); + d.for_document(a); + d.range = Range::Pages(4, 5); + d.for_document(a); + assert_eq!(d.range, Range::Pages(4, 5), "same document keeps its range"); + d.for_document(b); + assert_eq!(d.range, Range::All); + assert_eq!(d.pages(0, 1), vec![0]); + } +} diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 1b08929307..a5bf201cb5 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2815,6 +2815,21 @@ certificate Version الإصدار Edit this PDF تحرير هذا الملف PDF Export this PDF تصدير هذا الملف PDF Find text or tools البحث عن نص أو أدوات + By the standard device formulas; profiles are not applied. بالمعادلات القياسية للأجهزة؛ لا تُطبَّق ملفات التعريف. + CMYK CMYK + Comments and form fields التعليقات وحقول النماذج + Convert colours تحويل الألوان + Convert colours… تحويل الألوان… + Convert to التحويل إلى + Converted {c} colours and {i} pictures on {p} pages تم تحويل {c} لونًا و{i} صورة في {p} صفحة + Grayscale تدرج الرمادي + Nothing on those pages needed converting لا شيء في تلك الصفحات يحتاج إلى تحويل + Pictures too الصور أيضًا + Preserve black الحفاظ على الأسود + RGB RGB + pure black stays 100% K or gray 0 instead of a mix يبقى الأسود الخالص 100% K أو رمادي 0 بدلًا من مزيج + re-encoded in the target space; JPEGs stay JPEG يُعاد ترميزها في الفضاء الهدف؛ تبقى صور JPEG بصيغة JPEG + their appearances as well as the page مظاهرها إلى جانب الصفحة Create & Edit الإنشاء والتحرير Review & Compare المراجعة والمقارنة Forms & Signatures النماذج والتوقيعات diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e269febf18..7cd603a517 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2502,6 +2502,21 @@ animation effects Off Изключени Edit this PDF Редактирай този PDF Export this PDF Експортирай този PDF Find text or tools Търсене на текст или инструменти + By the standard device formulas; profiles are not applied. По стандартните формули за устройства; профили не се прилагат. + CMYK CMYK + Comments and form fields Коментари и полета на формуляри + Convert colours Преобразуване на цветовете + Convert colours… Преобразуване на цветовете… + Convert to Преобразуване в + Converted {c} colours and {i} pictures on {p} pages Преобразувани {c} цвята и {i} изображения на {p} страници + Grayscale Нюанси на сивото + Nothing on those pages needed converting Нищо на тези страници не се нуждаеше от преобразуване + Pictures too И изображенията + Preserve black Запазване на черното + RGB RGB + pure black stays 100% K or gray 0 instead of a mix чистото черно остава 100% K или сиво 0 вместо смес + re-encoded in the target space; JPEGs stay JPEG прекодирани в целевото пространство; JPEG остават JPEG + their appearances as well as the page техният вид, както и страницата Create & Edit Създаване и редактиране Review & Compare Преглед и сравнение Forms & Signatures Формуляри и подписи diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 7ff0f83fee..a799a1eba3 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2846,6 +2846,21 @@ theme Appearance Aspecte Unpin tool Desenganxa l'eina Arrange image Organitza la imatge Crop Escapça + By the standard device formulas; profiles are not applied. Segons les fórmules estàndard del dispositiu; no s'apliquen perfils. + CMYK CMYK + Comments and form fields Comentaris i camps de formulari + Convert colours Converteix els colors + Convert colours… Converteix els colors… + Convert to Converteix a + Converted {c} colours and {i} pictures on {p} pages S'han convertit {c} colors i {i} imatges en {p} pàgines + Grayscale Escala de grisos + Nothing on those pages needed converting No hi havia res a convertir en aquestes pàgines + Pictures too També les imatges + Preserve black Conserva el negre + RGB RGB + pure black stays 100% K or gray 0 instead of a mix el negre pur es manté al 100 % K o gris 0 en lloc d'una barreja + re-encoded in the target space; JPEGs stay JPEG es recodifiquen a l'espai de destinació; els JPEG continuen sent JPEG + their appearances as well as the page tant les seves aparences com la pàgina # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Avui diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 22f6e3e056..500f3ff586 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2613,6 +2613,21 @@ animation effects Off Vypnuto Edit this PDF Upravit toto PDF Export this PDF Exportovat toto PDF Find text or tools Hledat text nebo nástroje + By the standard device formulas; profiles are not applied. Podle standardních vzorců zařízení; profily se nepoužijí. + CMYK CMYK + Comments and form fields Komentáře a pole formuláře + Convert colours Převést barvy + Convert colours… Převést barvy… + Convert to Převést na + Converted {c} colours and {i} pictures on {p} pages Převedeno {c} barev a {i} obrázků na {p} stránkách + Grayscale Odstíny šedi + Nothing on those pages needed converting Na těchto stránkách nebylo co převádět + Pictures too I obrázky + Preserve black Zachovat černou + RGB RGB + pure black stays 100% K or gray 0 instead of a mix čistá černá zůstane 100 % K nebo šedá 0 místo směsi + re-encoded in the target space; JPEGs stay JPEG překódované do cílového prostoru; JPEG zůstanou JPEG + their appearances as well as the page jejich vzhled i stránka Create & Edit Vytváření a úpravy Review & Compare Revize a porovnání Forms & Signatures Formuláře a podpisy diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c9ce7ae36..8019dd7f6f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2540,6 +2540,21 @@ animation effects Off Aus Edit this PDF Dieses PDF bearbeiten Export this PDF Dieses PDF exportieren Find text or tools Text oder Werkzeuge suchen + By the standard device formulas; profiles are not applied. Nach den Standard-Geräteformeln; Profile werden nicht angewendet. + CMYK CMYK + Comments and form fields Kommentare und Formularfelder + Convert colours Farben konvertieren + Convert colours… Farben konvertieren… + Convert to Konvertieren in + Converted {c} colours and {i} pictures on {p} pages {c} Farben und {i} Bilder auf {p} Seiten konvertiert + Grayscale Graustufen + Nothing on those pages needed converting Auf diesen Seiten war nichts zu konvertieren + Pictures too Auch Bilder + Preserve black Schwarz beibehalten + RGB RGB + pure black stays 100% K or gray 0 instead of a mix reines Schwarz bleibt 100 % K oder Grau 0 statt einer Mischung + re-encoded in the target space; JPEGs stay JPEG im Zielfarbraum neu kodiert; JPEGs bleiben JPEG + their appearances as well as the page ihre Darstellungen sowie die Seite Create & Edit Erstellen & Bearbeiten Review & Compare Prüfen & Vergleichen Forms & Signatures Formulare & Signaturen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d83bf4aef7..ecd37a612b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2546,6 +2546,21 @@ animation effects Off Ανενεργά Edit this PDF Επεξεργασία αυτού του PDF Export this PDF Εξαγωγή αυτού του PDF Find text or tools Εύρεση κειμένου ή εργαλείων + By the standard device formulas; profiles are not applied. Με τους τυπικούς τύπους συσκευών· δεν εφαρμόζονται προφίλ. + CMYK CMYK + Comments and form fields Σχόλια και πεδία φόρμας + Convert colours Μετατροπή χρωμάτων + Convert colours… Μετατροπή χρωμάτων… + Convert to Μετατροπή σε + Converted {c} colours and {i} pictures on {p} pages Μετατράπηκαν {c} χρώματα και {i} εικόνες σε {p} σελίδες + Grayscale Κλίμακα του γκρι + Nothing on those pages needed converting Τίποτα σε αυτές τις σελίδες δεν χρειαζόταν μετατροπή + Pictures too Και οι εικόνες + Preserve black Διατήρηση του μαύρου + RGB RGB + pure black stays 100% K or gray 0 instead of a mix το καθαρό μαύρο μένει 100% K ή γκρι 0 αντί για μείγμα + re-encoded in the target space; JPEGs stay JPEG επανακωδικοποιούνται στον χώρο προορισμού· τα JPEG μένουν JPEG + their appearances as well as the page η εμφάνισή τους καθώς και η σελίδα Create & Edit Δημιουργία και επεξεργασία Review & Compare Αναθεώρηση και σύγκριση Forms & Signatures Φόρμες και υπογραφές diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 131d2dacaf..4584a27e16 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2709,6 +2709,21 @@ animation effects Off Desactivados Edit this PDF Editar este PDF Export this PDF Exportar este PDF Find text or tools Buscar texto o herramientas + By the standard device formulas; profiles are not applied. Con las fórmulas estándar de dispositivo; no se aplican perfiles. + CMYK CMYK + Comments and form fields Comentarios y campos de formulario + Convert colours Convertir colores + Convert colours… Convertir colores… + Convert to Convertir a + Converted {c} colours and {i} pictures on {p} pages Se convirtieron {c} colores y {i} imágenes en {p} páginas + Grayscale Escala de grises + Nothing on those pages needed converting Nada en esas páginas necesitaba conversión + Pictures too Las imágenes también + Preserve black Conservar el negro + RGB RGB + pure black stays 100% K or gray 0 instead of a mix el negro puro se queda en K 100 % o gris 0 en vez de una mezcla + re-encoded in the target space; JPEGs stay JPEG recodificadas en el espacio de destino; los JPEG siguen siendo JPEG + their appearances as well as the page sus apariencias además de la página Attach a file… Adjuntar un archivo… Delete attachment Eliminar adjunto Attaching files is not available on the web yet Adjuntar archivos aún no está disponible en la web diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 8c1a9baab9..b5442609b6 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2856,6 +2856,21 @@ theme Appearance Teema Unpin tool Irrota työkalu Arrange image Järjestä kuva Crop Rajaa + By the standard device formulas; profiles are not applied. Laitteiden vakiokaavoilla; profiileja ei käytetä. + CMYK CMYK + Comments and form fields Kommentit ja lomakekentät + Convert colours Muunna värit + Convert colours… Muunna värit… + Convert to Muunna muotoon + Converted {c} colours and {i} pictures on {p} pages Muunnettu {c} väriä ja {i} kuvaa {p} sivulla + Grayscale Harmaasävy + Nothing on those pages needed converting Näillä sivuilla ei ollut muunnettavaa + Pictures too Myös kuvat + Preserve black Säilytä musta + RGB RGB + pure black stays 100% K or gray 0 instead of a mix puhdas musta pysyy 100 % K:na tai harmaana 0:na sekoituksen sijaan + re-encoded in the target space; JPEGs stay JPEG koodattu uudelleen kohdeavaruuteen; JPEG-kuvat pysyvät JPEG-muodossa + their appearances as well as the page niiden ulkoasut sivun lisäksi # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Tänään diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1c22cf9d81..4c04072930 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2539,6 +2539,21 @@ animation effects Off Désactivés Edit this PDF Modifier ce PDF Export this PDF Exporter ce PDF Find text or tools Rechercher du texte ou des outils + By the standard device formulas; profiles are not applied. Selon les formules standard des périphériques ; aucun profil n'est appliqué. + CMYK CMJN + Comments and form fields Commentaires et champs de formulaire + Convert colours Convertir les couleurs + Convert colours… Convertir les couleurs… + Convert to Convertir en + Converted {c} colours and {i} pictures on {p} pages {c} couleurs et {i} images converties sur {p} pages + Grayscale Niveaux de gris + Nothing on those pages needed converting Rien sur ces pages ne nécessitait de conversion + Pictures too Les images aussi + Preserve black Conserver le noir + RGB RVB + pure black stays 100% K or gray 0 instead of a mix le noir pur reste à 100 % K ou gris 0 au lieu d'un mélange + re-encoded in the target space; JPEGs stay JPEG réencodées dans l'espace cible ; les JPEG restent JPEG + their appearances as well as the page leurs apparences en plus de la page Create & Edit Créer et modifier Review & Compare Réviser et comparer Forms & Signatures Formulaires et signatures diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 52c5450bb3..eb5645871b 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2816,6 +2816,21 @@ theme Appearance स्वरूप Remove crop क्रॉप हटाएँ Send backward पीछे भेजें Send to back सबसे पीछे भेजें + By the standard device formulas; profiles are not applied. मानक डिवाइस सूत्रों के अनुसार; प्रोफ़ाइल लागू नहीं की जातीं। + CMYK CMYK + Comments and form fields टिप्पणियाँ और फ़ॉर्म फ़ील्ड + Convert colours रंग रूपांतरित करें + Convert colours… रंग रूपांतरित करें… + Convert to इसमें रूपांतरित करें + Converted {c} colours and {i} pictures on {p} pages {p} पृष्ठों पर {c} रंग और {i} चित्र रूपांतरित किए गए + Grayscale ग्रेस्केल + Nothing on those pages needed converting इन पृष्ठों पर रूपांतरित करने के लिए कुछ नहीं था + Pictures too चित्र भी + Preserve black काला बनाए रखें + RGB RGB + pure black stays 100% K or gray 0 instead of a mix शुद्ध काला मिश्रण के बजाय 100% K या ग्रे 0 रहता है + re-encoded in the target space; JPEGs stay JPEG लक्ष्य स्पेस में दोबारा एन्कोड; JPEG, JPEG ही रहते हैं + their appearances as well as the page पृष्ठ के साथ उनके स्वरूप भी # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today आज diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 47d9c24b40..db918ed4a0 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2539,6 +2539,21 @@ animation effects Off Ki Edit this PDF Ezen PDF szerkesztése Export this PDF Ezen PDF exportálása Find text or tools Szöveg vagy eszközök keresése + By the standard device formulas; profiles are not applied. A szabványos eszközképletek szerint; profilok nincsenek alkalmazva. + CMYK CMYK + Comments and form fields Megjegyzések és űrlapmezők + Convert colours Színek átalakítása + Convert colours… Színek átalakítása… + Convert to Átalakítás erre + Converted {c} colours and {i} pictures on {p} pages {c} szín és {i} kép átalakítva {p} oldalon + Grayscale Szürkeárnyalat + Nothing on those pages needed converting Ezeken az oldalakon nem kellett semmit átalakítani + Pictures too A képek is + Preserve black Fekete megtartása + RGB RGB + pure black stays 100% K or gray 0 instead of a mix a tiszta fekete 100% K vagy 0 szürke marad keverék helyett + re-encoded in the target space; JPEGs stay JPEG újrakódolva a célszíntérben; a JPEG JPEG marad + their appearances as well as the page a megjelenésük és az oldal is Create & Edit Létrehozás és szerkesztés Review & Compare Áttekintés és összehasonlítás Forms & Signatures Űrlapok és aláírások diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 89152f62a4..f937fdf071 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2817,6 +2817,21 @@ theme Appearance Tampilan Remove crop Hapus pangkasan Send backward Mundur Selangkah Send to back Kirim ke Belakang + By the standard device formulas; profiles are not applied. Menurut rumus perangkat standar; profil tidak diterapkan. + CMYK CMYK + Comments and form fields Komentar dan kolom formulir + Convert colours Konversi warna + Convert colours… Konversi warna… + Convert to Konversi ke + Converted {c} colours and {i} pictures on {p} pages Mengonversi {c} warna dan {i} gambar pada {p} halaman + Grayscale Skala abu-abu + Nothing on those pages needed converting Tidak ada yang perlu dikonversi di halaman-halaman itu + Pictures too Gambar juga + Preserve black Pertahankan hitam + RGB RGB + pure black stays 100% K or gray 0 instead of a mix hitam murni tetap 100% K atau abu-abu 0, bukan campuran + re-encoded in the target space; JPEGs stay JPEG dienkode ulang di ruang target; JPEG tetap JPEG + their appearances as well as the page tampilannya serta halamannya # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Hari ini diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0cae8f3e98..f899a385da 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2751,6 +2751,21 @@ certificate SHA-256 digest Impronta SHA-256 Edit this PDF Modifica questo PDF Export this PDF Esporta questo PDF Find text or tools Trova testo o strumenti + By the standard device formulas; profiles are not applied. Con le formule standard dei dispositivi; i profili non vengono applicati. + CMYK CMYK + Comments and form fields Commenti e campi modulo + Convert colours Converti colori + Convert colours… Converti colori… + Convert to Converti in + Converted {c} colours and {i} pictures on {p} pages Convertiti {c} colori e {i} immagini in {p} pagine + Grayscale Scala di grigi + Nothing on those pages needed converting Niente in queste pagine richiedeva conversione + Pictures too Anche le immagini + Preserve black Mantieni il nero + RGB RGB + pure black stays 100% K or gray 0 instead of a mix il nero puro resta al 100% K o grigio 0 invece di una miscela + re-encoded in the target space; JPEGs stay JPEG ricodificate nello spazio di destinazione; i JPEG restano JPEG + their appearances as well as the page i loro aspetti oltre alla pagina Create & Edit Crea e modifica Review & Compare Revisione e confronto Forms & Signatures Moduli e firme diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index abc616b817..196cc6eaa2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2539,6 +2539,21 @@ animation effects Off オフ Edit this PDF この PDF を編集 Export this PDF この PDF を書き出す Find text or tools テキストまたはツールを検索 + By the standard device formulas; profiles are not applied. 標準のデバイス式で変換します。プロファイルは適用しません。 + CMYK CMYK + Comments and form fields 注釈とフォームフィールド + Convert colours カラーを変換 + Convert colours… カラーを変換… + Convert to 変換先 + Converted {c} colours and {i} pictures on {p} pages {p} ページで {c} 色と {i} 個の画像を変換しました + Grayscale グレースケール + Nothing on those pages needed converting これらのページには変換が必要なものはありません + Pictures too 画像も + Preserve black 黒を保持 + RGB RGB + pure black stays 100% K or gray 0 instead of a mix 純粋な黒は混色にせず K 100% またはグレー 0 のままにします + re-encoded in the target space; JPEGs stay JPEG 変換先の色空間で再エンコード。JPEG は JPEG のまま + their appearances as well as the page ページに加えてその表示 Create & Edit 作成と編集 Review & Compare レビューと比較 Forms & Signatures フォームと署名 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index fc6a2d783a..acd892865a 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2537,6 +2537,21 @@ animation effects Off Өшірулі Edit this PDF Осы PDF файлын түзету Export this PDF Осы PDF файлын экспорттау Find text or tools Мәтінді немесе құралдарды табу + By the standard device formulas; profiles are not applied. Құрылғылардың стандартты формулаларымен; профильдер қолданылмайды. + CMYK CMYK + Comments and form fields Түсініктемелер мен пішін өрістері + Convert colours Түстерді түрлендіру + Convert colours… Түстерді түрлендіру… + Convert to Мынаған түрлендіру + Converted {c} colours and {i} pictures on {p} pages {p} бетте {c} түс және {i} сурет түрлендірілді + Grayscale Сұр реңктер + Nothing on those pages needed converting Бұл беттерде түрлендіретін ештеңе болмады + Pictures too Суреттер де + Preserve black Қараны сақтау + RGB RGB + pure black stays 100% K or gray 0 instead of a mix таза қара қоспа емес, 100% K немесе 0 сұр болып қалады + re-encoded in the target space; JPEGs stay JPEG мақсатты кеңістікте қайта кодталады; JPEG JPEG болып қалады + their appearances as well as the page олардың көрінісі және бет Create & Edit Жасау және өңдеу Review & Compare Шолу және салыстыру Forms & Signatures Пішіндер және қолтаңбалар diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 20315b5fcf..2f34a60c01 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2816,6 +2816,21 @@ theme Appearance 모양 Remove crop 자르기 제거 Send backward 뒤로 보내기 Send to back 맨 뒤로 보내기 + By the standard device formulas; profiles are not applied. 표준 장치 공식을 사용하며, 프로파일은 적용되지 않습니다. + CMYK CMYK + Comments and form fields 주석 및 양식 필드 + Convert colours 색상 변환 + Convert colours… 색상 변환… + Convert to 변환 대상 + Converted {c} colours and {i} pictures on {p} pages {p}페이지의 색상 {c}개와 이미지 {i}개를 변환했습니다 + Grayscale 회색조 + Nothing on those pages needed converting 해당 페이지에는 변환할 항목이 없습니다 + Pictures too 이미지도 포함 + Preserve black 검정 유지 + RGB RGB + pure black stays 100% K or gray 0 instead of a mix 순수한 검정은 혼합색 대신 100% K 또는 회색 0으로 유지됩니다 + re-encoded in the target space; JPEGs stay JPEG 대상 공간에서 다시 인코딩되며, JPEG는 JPEG로 유지됩니다 + their appearances as well as the page 페이지뿐 아니라 모양도 포함 # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 오늘 diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index fd7f431154..0e4a22b6ab 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2809,6 +2809,21 @@ theme Appearance Išvaizda Edit this PDF Redaguoti šį PDF Export this PDF Eksportuoti šį PDF Find text or tools Rasti tekstą arba įrankius + By the standard device formulas; profiles are not applied. Pagal standartines įrenginių formules; profiliai netaikomi. + CMYK CMYK + Comments and form fields Komentarai ir formos laukai + Convert colours Konvertuoti spalvas + Convert colours… Konvertuoti spalvas… + Convert to Konvertuoti į + Converted {c} colours and {i} pictures on {p} pages Konvertuota {c} spalvų ir {i} paveikslėlių {p} puslapiuose + Grayscale Pilki atspalviai + Nothing on those pages needed converting Šiuose puslapiuose nebuvo ko konvertuoti + Pictures too Ir paveikslėliai + Preserve black Išlaikyti juodą + RGB RGB + pure black stays 100% K or gray 0 instead of a mix gryna juoda lieka 100 % K arba pilka 0, o ne mišinys + re-encoded in the target space; JPEGs stay JPEG perkoduoti tiksliojoje erdvėje; JPEG lieka JPEG + their appearances as well as the page jų išvaizda ir puslapis Attach a file… Pridėti failą… Delete attachment Ištrinti priedą Attaching files is not available on the web yet Failų pridėjimas žiniatinklyje dar negalimas diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 208dd1c61e..e16f10d4b8 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2515,6 +2515,21 @@ animation effects Off Wyłączone Edit this PDF Edytuj ten plik PDF Export this PDF Eksportuj ten plik PDF Find text or tools Znajdź tekst lub narzędzia + By the standard device formulas; profiles are not applied. Według standardowych wzorów urządzeń; profile nie są stosowane. + CMYK CMYK + Comments and form fields Komentarze i pola formularza + Convert colours Konwertuj kolory + Convert colours… Konwertuj kolory… + Convert to Konwertuj na + Converted {c} colours and {i} pictures on {p} pages Przekonwertowano {c} kolorów i {i} obrazów na {p} stronach + Grayscale Skala szarości + Nothing on those pages needed converting Na tych stronach nie było nic do konwersji + Pictures too Także obrazy + Preserve black Zachowaj czerń + RGB RGB + pure black stays 100% K or gray 0 instead of a mix czysta czerń zostaje 100% K lub szarością 0 zamiast mieszanki + re-encoded in the target space; JPEGs stay JPEG przekodowane w przestrzeni docelowej; JPEG zostają JPEG + their appearances as well as the page ich wygląd oraz strona Create & Edit Tworzenie i edycja Review & Compare Recenzja i porównanie Forms & Signatures Formularze i podpisy diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a172a03879..ee5363bc8c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2552,6 +2552,21 @@ animation effects Off Desativados Edit this PDF Editar este PDF Export this PDF Exportar este PDF Find text or tools Localizar texto ou ferramentas + By the standard device formulas; profiles are not applied. Pelas fórmulas padrão de dispositivo; perfis não são aplicados. + CMYK CMYK + Comments and form fields Comentários e campos de formulário + Convert colours Converter cores + Convert colours… Converter cores… + Convert to Converter para + Converted {c} colours and {i} pictures on {p} pages {c} cores e {i} imagens convertidas em {p} páginas + Grayscale Tons de cinza + Nothing on those pages needed converting Nada nessas páginas precisava de conversão + Pictures too Imagens também + Preserve black Preservar o preto + RGB RGB + pure black stays 100% K or gray 0 instead of a mix o preto puro fica em 100% K ou cinza 0 em vez de uma mistura + re-encoded in the target space; JPEGs stay JPEG recodificadas no espaço de destino; JPEGs continuam JPEG + their appearances as well as the page suas aparências além da página Create & Edit Criar e editar Review & Compare Revisar e comparar Forms & Signatures Formulários e assinaturas diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 0d0fbf16b4..31178b9ebc 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2846,6 +2846,21 @@ theme Appearance Aspect Unpin tool Anulează fixarea instrumentului Arrange image Aranjează imaginea Crop Decupare + By the standard device formulas; profiles are not applied. După formulele standard ale dispozitivelor; profilurile nu se aplică. + CMYK CMYK + Comments and form fields Comentarii și câmpuri de formular + Convert colours Conversie culori + Convert colours… Conversie culori… + Convert to Conversie în + Converted {c} colours and {i} pictures on {p} pages S-au convertit {c} culori și {i} imagini pe {p} pagini + Grayscale Tonuri de gri + Nothing on those pages needed converting Nimic de convertit pe aceste pagini + Pictures too Și imaginile + Preserve black Păstrare negru + RGB RGB + pure black stays 100% K or gray 0 instead of a mix negrul pur rămâne 100% K sau gri 0, în loc de amestec + re-encoded in the target space; JPEGs stay JPEG recodate în spațiul țintă; JPEG-urile rămân JPEG + their appearances as well as the page aspectul lor, nu doar pagina # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Astăzi diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b45d73ecab..c36e1e236c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2452,6 +2452,21 @@ animation effects Off Выкл Edit this PDF Редактировать этот PDF Export this PDF Экспортировать этот PDF Find text or tools Найти текст или инструменты + By the standard device formulas; profiles are not applied. По стандартным формулам устройств; профили не применяются. + CMYK CMYK + Comments and form fields Комментарии и поля форм + Convert colours Преобразовать цвета + Convert colours… Преобразовать цвета… + Convert to Преобразовать в + Converted {c} colours and {i} pictures on {p} pages Преобразовано цветов: {c}, изображений: {i} на страницах: {p} + Grayscale Оттенки серого + Nothing on those pages needed converting На этих страницах нечего было преобразовывать + Pictures too Изображения тоже + Preserve black Сохранять чёрный + RGB RGB + pure black stays 100% K or gray 0 instead of a mix чистый чёрный остаётся 100% K или серым 0, а не смесью + re-encoded in the target space; JPEGs stay JPEG перекодируются в целевое пространство; JPEG остаются JPEG + their appearances as well as the page их внешний вид, а также страница Create & Edit Создание и редактирование Review & Compare Рецензирование и сравнение Forms & Signatures Формы и подписи diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index c95f6d6bd6..13470e2f61 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2544,6 +2544,21 @@ animation effects Off Izklopljeni Edit this PDF Uredi ta PDF Export this PDF Izvozi ta PDF Find text or tools Poišči besedilo ali orodja + By the standard device formulas; profiles are not applied. Po standardnih formulah naprav; profili se ne uporabijo. + CMYK CMYK + Comments and form fields Komentarji in polja obrazca + Convert colours Pretvori barve + Convert colours… Pretvori barve… + Convert to Pretvori v + Converted {c} colours and {i} pictures on {p} pages Pretvorjenih {c} barv in {i} slik na {p} straneh + Grayscale Sivine + Nothing on those pages needed converting Na teh straneh ni bilo treba ničesar pretvoriti + Pictures too Tudi slike + Preserve black Ohrani črno + RGB RGB + pure black stays 100% K or gray 0 instead of a mix čista črna ostane 100 % K ali siva 0 namesto mešanice + re-encoded in the target space; JPEGs stay JPEG znova kodirane v ciljnem prostoru; JPEG ostanejo JPEG + their appearances as well as the page njihov videz in stran Create & Edit Ustvarjanje in urejanje Review & Compare Pregled in primerjava Forms & Signatures Obrazci in podpisi diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index eb9eed3a0e..f93573010e 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2571,6 +2571,21 @@ animation effects Off Joaktiv Remove crop Hiq prerjen Send backward Kalo prapa Send to back Dërgo mbrapa + By the standard device formulas; profiles are not applied. Sipas formulave standarde të pajisjes; profilet nuk zbatohen. + CMYK CMYK + Comments and form fields Komentet dhe fushat e formularit + Convert colours Konverto ngjyrat + Convert colours… Konverto ngjyrat… + Convert to Konverto në + Converted {c} colours and {i} pictures on {p} pages U konvertuan {c} ngjyra dhe {i} imazhe në {p} faqe + Grayscale Nuanca gri + Nothing on those pages needed converting Në ato faqe nuk kishte gjë për t'u konvertuar + Pictures too Edhe imazhet + Preserve black Ruaj të zezën + RGB RGB + pure black stays 100% K or gray 0 instead of a mix e zeza e pastër mbetet 100% K ose gri 0 në vend të një përzierjeje + re-encoded in the target space; JPEGs stay JPEG rikodohet në hapësirën e synuar; JPEG-et mbeten JPEG + their appearances as well as the page pamjet e tyre si dhe faqja # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Sot diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 9b81ffb8b9..94a282f223 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2448,6 +2448,21 @@ animation effects Off ఆఫ్ Edit this PDF ఈ PDFను సవరించు Export this PDF ఈ PDFను ఎగుమти చేయి Find text or tools వచనం లేదా సాధనాలను కనుగొనండి + By the standard device formulas; profiles are not applied. ప్రామాణిక పరికర సూత్రాలతో; ప్రొఫైల్‌లు వర్తించవు. + CMYK CMYK + Comments and form fields వ్యాఖ్యలు మరియు ఫారమ్ ఫీల్డ్‌లు + Convert colours రంగులను మార్చు + Convert colours… రంగులను మార్చు… + Convert to దీనికి మార్చు + Converted {c} colours and {i} pictures on {p} pages {p} పేజీలలో {c} రంగులు మరియు {i} చిత్రాలు మార్చబడ్డాయి + Grayscale గ్రేస్కేల్ + Nothing on those pages needed converting ఆ పేజీలలో మార్చవలసినది ఏదీ లేదు + Pictures too చిత్రాలు కూడా + Preserve black నలుపును ఉంచు + RGB RGB + pure black stays 100% K or gray 0 instead of a mix స్వచ్ఛమైన నలుపు మిశ్రమం కాకుండా 100% K లేదా బూడిద 0గా ఉంటుంది + re-encoded in the target space; JPEGs stay JPEG లక్ష్య స్థలంలో మళ్లీ ఎన్‌కోడ్ చేయబడతాయి; JPEGలు JPEGగానే ఉంటాయి + their appearances as well as the page వాటి రూపం మరియు పేజీ Create & Edit సృష్టించు & సవరించు Review & Compare సమీక్షించు & పోల్చు Forms & Signatures ఫారమ్‌లు & సంతకాలు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6921a6c33e..eb49c872a8 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2535,6 +2535,21 @@ animation effects Off Kapalı Edit this PDF Bu PDF'i düzenle Export this PDF Bu PDF'i dışa aktar Find text or tools Metin veya araç bul + By the standard device formulas; profiles are not applied. Standart cihaz formülleriyle; profiller uygulanmaz. + CMYK CMYK + Comments and form fields Yorumlar ve form alanları + Convert colours Renkleri dönüştür + Convert colours… Renkleri dönüştür… + Convert to Şuna dönüştür + Converted {c} colours and {i} pictures on {p} pages {p} sayfada {c} renk ve {i} resim dönüştürüldü + Grayscale Gri tonlama + Nothing on those pages needed converting Bu sayfalarda dönüştürülecek bir şey yoktu + Pictures too Resimler de + Preserve black Siyahı koru + RGB RGB + pure black stays 100% K or gray 0 instead of a mix saf siyah karışım yerine %100 K veya gri 0 kalır + re-encoded in the target space; JPEGs stay JPEG hedef alanda yeniden kodlanır; JPEG'ler JPEG kalır + their appearances as well as the page görünümleri ve sayfa Create & Edit Oluştur ve Düzenle Review & Compare İncele ve Karşılaştır Forms & Signatures Formlar ve İmzalar diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a824769600..5c8e01163e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2561,6 +2561,21 @@ animation effects Off Вимкнено Edit this PDF Редактировать цей PDF Export this PDF Експортувати цей PDF Find text or tools Знайти текст або інструменти + By the standard device formulas; profiles are not applied. За стандартними формулами пристроїв; профілі не застосовуються. + CMYK CMYK + Comments and form fields Коментарі та поля форм + Convert colours Перетворити кольори + Convert colours… Перетворити кольори… + Convert to Перетворити на + Converted {c} colours and {i} pictures on {p} pages Перетворено кольорів: {c}, зображень: {i} на сторінках: {p} + Grayscale Відтінки сірого + Nothing on those pages needed converting На цих сторінках нічого не треба було перетворювати + Pictures too Зображення теж + Preserve black Зберігати чорний + RGB RGB + pure black stays 100% K or gray 0 instead of a mix чистий чорний лишається 100% K або сірим 0, а не сумішшю + re-encoded in the target space; JPEGs stay JPEG перекодовуються в цільовий простір; JPEG лишаються JPEG + their appearances as well as the page їхній вигляд, а також сторінка Create & Edit Створення й редагування Review & Compare Рецензування й порівняння Forms & Signatures Форми й підписи diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e79a8aacc4..c6dc5be6c2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2819,6 +2819,21 @@ key Space Space Edit this PDF Chỉnh sửa PDF này Export this PDF Xuất PDF này Find text or tools Tìm văn bản hoặc công cụ + By the standard device formulas; profiles are not applied. Theo các công thức thiết bị tiêu chuẩn; không áp dụng cấu hình màu. + CMYK CMYK + Comments and form fields Bình luận và các trường biểu mẫu + Convert colours Chuyển đổi màu + Convert colours… Chuyển đổi màu… + Convert to Chuyển sang + Converted {c} colours and {i} pictures on {p} pages Đã chuyển đổi {c} màu và {i} hình ảnh trên {p} trang + Grayscale Thang độ xám + Nothing on those pages needed converting Không có gì trên các trang đó cần chuyển đổi + Pictures too Cả hình ảnh + Preserve black Giữ nguyên màu đen + RGB RGB + pure black stays 100% K or gray 0 instead of a mix màu đen thuần giữ ở 100% K hoặc xám 0 thay vì màu pha trộn + re-encoded in the target space; JPEGs stay JPEG được mã hóa lại trong không gian đích; ảnh JPEG vẫn là JPEG + their appearances as well as the page cả giao diện hiển thị của chúng lẫn trang Attach a file… Đính kèm tệp… Delete attachment Xóa tệp đính kèm Attaching files is not available on the web yet Chưa thể đính kèm tệp trên web diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 96e9c3bb75..c37657d276 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2503,6 +2503,21 @@ animation effects Off 关 Edit this PDF 编辑此 PDF Export this PDF 导出此 PDF Find text or tools 查找文本或工具 + By the standard device formulas; profiles are not applied. 按标准设备公式转换;不应用配置文件。 + CMYK CMYK + Comments and form fields 注释和表单字段 + Convert colours 转换颜色 + Convert colours… 转换颜色… + Convert to 转换为 + Converted {c} colours and {i} pictures on {p} pages 已在 {p} 页上转换 {c} 种颜色和 {i} 张图片 + Grayscale 灰度 + Nothing on those pages needed converting 这些页面上没有需要转换的内容 + Pictures too 图片也一起 + Preserve black 保留黑色 + RGB RGB + pure black stays 100% K or gray 0 instead of a mix 纯黑保持为 100% K 或灰度 0,而不是混合色 + re-encoded in the target space; JPEGs stay JPEG 在目标颜色空间中重新编码;JPEG 仍为 JPEG + their appearances as well as the page 连同其外观和页面 Create & Edit 创建和编辑 Review & Compare 审阅和比较 Forms & Signatures 表单和签名 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a1b933b830..ed232c9878 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2489,6 +2489,21 @@ animation effects Off 關 Edit this PDF 編輯此 PDF Export this PDF 匯出此 PDF Find text or tools 尋找文字或工具 + By the standard device formulas; profiles are not applied. 依標準裝置公式轉換;不套用描述檔。 + CMYK CMYK + Comments and form fields 註解和表單欄位 + Convert colours 轉換顏色 + Convert colours… 轉換顏色… + Convert to 轉換為 + Converted {c} colours and {i} pictures on {p} pages 已在 {p} 頁上轉換 {c} 種顏色和 {i} 張圖片 + Grayscale 灰階 + Nothing on those pages needed converting 這些頁面上沒有需要轉換的內容 + Pictures too 圖片也一起 + Preserve black 保留黑色 + RGB RGB + pure black stays 100% K or gray 0 instead of a mix 純黑保持為 100% K 或灰階 0,而非混合色 + re-encoded in the target space; JPEGs stay JPEG 在目標色彩空間中重新編碼;JPEG 仍為 JPEG + their appearances as well as the page 連同其外觀和頁面 Create & Edit 建立與編輯 Review & Compare 審閱與比較 Forms & Signatures 表單與簽名 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fe0138f27f..9e7a857685 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -101,6 +101,7 @@ use canvas::zoom_snap; use dialogs::appearance; pub use dialogs::bookmark_styles_ui; use dialogs::comment_props; +use dialogs::convert_colours_ui; use dialogs::credits; use dialogs::marks_ui; use dialogs::pageboxes; @@ -609,6 +610,7 @@ pub struct PdfCraftApp { /// Number pages dialog settings (1-based pages). pub number_draft: NumberDraft, pub(crate) word_count_draft: word_count_ui::Draft, + pub convert_colours_draft: convert_colours_ui::ConvertColoursDraft, /// Protect Using Password dialog state. pub protect_draft: protect::ProtectDraft, /// New bookmarks from text styles dialog state (#1693). diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e3a9c540a6..fe6921de9c 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -109,6 +109,8 @@ pub enum Dialog { Split, /// Pages ▸ Number pages… (page labels). NumberPages, + /// Print production ▸ Convert colours. + ConvertColours, /// Organize pages ▸ Page properties (the tab order of the selected pages). PageProps, /// Protect Using Password. diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 7b245a5a9e..4c5aeabfdc 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -8034,6 +8034,42 @@ fn test_cropped_image_selection_and_bounding_box() { assert!(h.state().views[0].picked_image.is_none(), "clicking cropped-away region must not hit-test or select the image"); } +/// Print production ▸ Convert colours: the dialog converts the page's colours +/// and says how many; with nothing to convert it says so and changes nothing. +#[test] +fn convert_colours_from_the_dialog() { + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let page = pdfcraft_model::pages(&doc).remove(0); + let cs = doc.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::flate( + pdfcraft_cos::Dict::new(), + b"1 0 0 rg 10 10 50 50 re f 0 0 1 RG 0 0 m 100 100 l S", + ))); + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::Object::Ref(cs))).unwrap(); + let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("colours.pdf", None, bytes.clone()).expect("opens"); + app + }); + h.run_steps(4); + let id = h.state().views[0].id; + assert!(h.state_mut().execute("prepress.convert_colors")); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(Dialog::ConvertColours)); + h.get_by_label("Grayscale"); + // The mode bar has a "Convert" too: the dialog's button is the last one. + h.query_all_by_label("Convert").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().toast.clone().unwrap().0, "Converted 2 colours and 0 pictures on 1 pages"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Convert colours")); + assert!(h.state_mut().execute("prepress.convert_colors")); + h.run_steps(2); + h.query_all_by_label("Convert").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().toast.clone().unwrap().0, "Nothing on those pages needed converting"); +} + /// The Read mode command (not the mode bar) applies a paragraph still open in the editor, as /// putting the Edit text tool down does; left open, it would stay drawn in Read mode with no tool. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cabfc7823a..aabdc635f3 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8406,11 +8406,11 @@ title = "Convert colours" area = "L" tier = "P1" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["prepress.convert_colors"] +tools = ["doc_convert_colours"] +evidence = ["crates/edit/src/tests.rs::colours_are_converted_to_gray_rgb_and_cmyk", "crates/engine/src/tests.rs::converting_colours_recolours_pictures", "crates/ui-egui/tests/it/editing.rs::convert_colours_from_the_dialog", "crates/automation/tests/it/automation.rs::convert_colours_through_tools"] +notes = "Print production ▸ Convert colours…: Grayscale, RGB or CMYK by the standard device formulas (no ICC profile applied): every colour operator in the pages' content and the Form XObjects it draws is rewritten (device spaces, ICCBased by component count, CalRGB/CalGray, Lab, Indexed, Separation/DeviceN through a Type 2 tint), black kept 100% K / gray 0 on request, 8-bit gray/RGB pictures re-encoded (JPEG stays JPEG, CMYK written lossless, soft masks kept) and the ones left alone counted, comments' and fields' appearances optionally; all, current or a range of pages; one undo step; tool doc_convert_colours, which also lists the PDF/A level and output intents the file may no longer meet (the dialog does not say so yet). Still missing against Acrobat: conversion through ICC profiles and rendering intents (it is no prepress separation and uses no output intent), keeping spot colours, sampled and PostScript tint transforms (a gray of the tint's darkness), per-object conversion rules, inline images, and indexed, CMYK and masked pictures or decode arrays." [[feature]] id = "print.ink-manager" From 07a5f3f3a133a46fe0792783a6233b67471decd4 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:08:47 +0800 Subject: [PATCH 064/101] M5: the first save after placing a signature asks to lock it into the page (#2149) A Fill & Sign signature stays a removable comment unless Flatten Fill & Sign when saving is on, which sat only in Preferences. The first save after a signature or initials is placed now asks: Lock and save flattens the document's Fill & Sign marks, Keep editable saves as before, Cancel saves nothing, and Don't ask again turns the answer into the preference. The Sign menu carries the same switch. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app.rs | 4 ++ crates/ui-egui/src/app/prefs.rs | 4 ++ crates/ui-egui/src/dialogs.rs | 39 +++++++++++++ crates/ui-egui/src/editing.rs | 7 +++ crates/ui-egui/src/editing/save.rs | 47 +++++++++++++++ crates/ui-egui/src/i18n/ar.tsv | 7 +++ crates/ui-egui/src/i18n/bg.tsv | 7 +++ crates/ui-egui/src/i18n/cs.tsv | 7 +++ crates/ui-egui/src/i18n/de.tsv | 7 +++ crates/ui-egui/src/i18n/el.tsv | 7 +++ crates/ui-egui/src/i18n/es.tsv | 7 +++ crates/ui-egui/src/i18n/fr.tsv | 7 +++ crates/ui-egui/src/i18n/hu.tsv | 7 +++ crates/ui-egui/src/i18n/it.tsv | 7 +++ crates/ui-egui/src/i18n/ja.tsv | 7 +++ crates/ui-egui/src/i18n/kk.tsv | 7 +++ crates/ui-egui/src/i18n/lt.tsv | 7 +++ crates/ui-egui/src/i18n/pl.tsv | 7 +++ crates/ui-egui/src/i18n/pt-br.tsv | 7 +++ crates/ui-egui/src/i18n/ru.tsv | 7 +++ crates/ui-egui/src/i18n/sl.tsv | 7 +++ crates/ui-egui/src/i18n/te.tsv | 8 +++ crates/ui-egui/src/i18n/tr.tsv | 7 +++ crates/ui-egui/src/i18n/uk.tsv | 7 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 7 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 8 +++ crates/ui-egui/src/lib.rs | 9 +++ crates/ui-egui/src/state.rs | 3 + crates/ui-egui/src/tools/fill_sign.rs | 2 + crates/ui-egui/src/workflows/js_ui.rs | 4 ++ crates/ui-egui/tests/it/fill_sign.rs | 84 ++++++++++++++++++++++++++- docs/target-app-parity.md | 2 +- parity/acrobat-features.toml | 4 +- 33 files changed, 354 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 43d0066e63..29801c4b76 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -106,6 +106,10 @@ impl PdfCraftApp { system_text_scale: 1.0, ui_scale_applied: None, flatten_fill_sign_on_save: false, + ask_to_lock_signatures: true, + placed_signatures: Default::default(), + pending_lock: None, + lock_remember: false, fill_text_size: fill_sign::TEXT_SIZE, fill_text_color: [0.0; 3], dialog: None, diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 7bd8943774..5e35547035 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -101,6 +101,7 @@ impl PdfCraftApp { // Null follows the system's text scaling. "ui_scale": self.ui_scale, "flatten_fill_sign": self.flatten_fill_sign_on_save, + "ask_to_lock_signatures": self.ask_to_lock_signatures, "fill_text_size": self.fill_text_size, "date_format": self.session.date_format(), // Null follows the interface language. @@ -284,6 +285,9 @@ impl PdfCraftApp { if let Some(on) = v["flatten_fill_sign"].as_bool() { self.flatten_fill_sign_on_save = on; } + if let Some(on) = v["ask_to_lock_signatures"].as_bool() { + self.ask_to_lock_signatures = on; + } if let Some(size) = v["fill_text_size"].as_f64().and_then(fill_sign::valid_text_size) { self.fill_text_size = size; } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 42bed83657..30985fab28 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -50,6 +50,7 @@ struct Pending { revert_now: bool, overwrite_now: bool, overwrite_elsewhere: bool, + lock_answer: Option, summarize_now: bool, optimize_now: bool, duplicate_now: Option, @@ -275,6 +276,11 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m return false; } } + Dialog::LockSignatures => { + if !dialog_lock_signatures(ui, app, req) { + return false; + } + } Dialog::Revert => { if !dialog_revert(ui, app, req) { return false; @@ -1246,6 +1252,31 @@ fn dialog_summarize_comments(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut false } +/// `Dialog::LockSignatures` content (#2149). Returns `false` when it has its own buttons and no +/// footer is wanted. +fn dialog_lock_signatures(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + ui.label(egui::RichText::new(tl!("Lock your signature into the page?")).font(theme::semibold(18.0))); + ui.add_space(8.0); + ui.label(tl!("A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page.")); + ui.add_space(8.0); + ui.add(widgets::checkbox(&mut app.lock_remember, tl!("Don't ask again"))); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, tl!("Lock and save"), true).clicked() { + req.lock_answer = Some(true); + req.close = true; + } + if widgets::pill_button(ui, tl!("Keep editable"), false).clicked() { + req.lock_answer = Some(false); + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + /// `Dialog::Revert` content. Returns `false` when it has its own buttons and no footer is wanted. fn dialog_revert(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { let name = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.name.clone()).unwrap_or_default(); @@ -1762,6 +1793,14 @@ fn finish_requests(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, re if dialog == Dialog::OverwriteChanged && (modal_closed || req.close) { app.pending_overwrite = None; } + if let Some(lock) = req.lock_answer { + let remember = std::mem::take(&mut app.lock_remember); + app.answer_lock_signatures(lock, remember); + } else if dialog == Dialog::LockSignatures && (modal_closed || req.close) { + // Cancelled or dismissed: nothing is saved. + app.pending_lock = None; + app.lock_remember = false; + } } /// Runs the document actions the dialog asked for. diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index b810e2abe5..f753335b72 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -10,6 +10,7 @@ mod close; mod save; pub use close::CloseRequest; +pub use save::PendingLock; #[cfg(not(target_arch = "wasm32"))] pub use save::PendingOverwrite; #[cfg(target_arch = "wasm32")] @@ -669,12 +670,18 @@ impl PdfCraftApp { fn apply_queued_edit(&mut self, i: usize) -> bool { let Some(edit) = self.views.get_mut(i).and_then(|v| v.pending_edit.take()) else { return true }; let signature_page = self.views.get_mut(i).and_then(|v| v.fill_signature_page.take()); + let doc_id = self.views.get(i).map(|v| v.id); let committed = self.views.get_mut(i).and_then(|v| v.forms.committed.take()); let typed = match (&edit, &committed) { (Edit::SetFieldValue { name, .. }, Some(draft)) => *name == draft.name, _ => false, }; if self.apply_edit(edit) { + if signature_page.is_some() + && let Some(id) = doc_id + { + self.placed_signatures.insert(id); + } // Tab can wrap to the same field (including another widget of that field). // Its new editor was opened before this edit ran, so it still holds the old // value. Refresh from the accepted form before Save can commit that stale draft. diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index f685247c9d..446aba1fc5 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -11,6 +11,14 @@ pub struct PendingOverwrite { after: Box, } +/// A save held back by [`Dialog::LockSignatures`](crate::Dialog::LockSignatures) (#2149). +pub struct PendingLock { + pub id: pdfcraft_engine::DocId, + target: SaveTarget, + /// What was to run once the document is written (close its tab, quit). + after: Box, +} + /// Where Save writes. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SaveTarget { @@ -46,6 +54,13 @@ impl PdfCraftApp { return false; } let Some(id) = self.views.get(index).map(|v| v.id) else { return false }; + // A signature just placed with Fill & Sign is a removable comment unless it is flattened: + // ask once whether to lock it into the page (#2149). + if self.ask_to_lock_signatures && !self.flatten_fill_sign_on_save && self.placed_signatures.contains(&id) { + self.pending_lock = Some(PendingLock { id, target, after: Box::new(after) }); + self.dialog = Some(crate::Dialog::LockSignatures); + return false; + } let Some(doc) = self.session.get(id) else { return false }; let (name, path) = (doc.name.clone(), doc.path.clone()); #[cfg(not(target_arch = "wasm32"))] @@ -154,6 +169,37 @@ impl PdfCraftApp { } } + /// The Lock signatures dialog answered (#2149): lock the document's Fill & Sign marks into + /// its pages first when `lock`, then save as was asked. With `remember`, the answer becomes + /// the preference (flatten on every save, or never ask again). + pub(crate) fn answer_lock_signatures(&mut self, lock: bool, remember: bool) { + let Some(p) = self.pending_lock.take() else { return }; + self.placed_signatures.remove(&p.id); + if remember { + if lock { + self.flatten_fill_sign_on_save = true; + } else { + self.ask_to_lock_signatures = false; + } + } + if lock && !self.flatten_fill_sign_on_save { + let before = self.session.get(p.id).map(|d| d.edit_generation()); + if let Err(e) = self.session.apply(p.id, pdfcraft_engine::Edit::FlattenFillSign) { + self.notify_error(e); + return; + } + if before != self.session.get(p.id).map(|d| d.edit_generation()) + && let Some(doc) = self.session.get(p.id) + && let Some(view) = self.views.iter_mut().find(|v| v.id == p.id) + { + view.document_changed(&doc.info); + } + } + if let Some(index) = self.views.iter().position(|v| v.id == p.id) { + self.save_then(index, p.target, p.after); + } + } + /// Write document `id` to `dest` and make it the document's file. Returns `true` if written. #[cfg(not(target_arch = "wasm32"))] fn save_doc_to(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget) -> bool { @@ -192,6 +238,7 @@ impl PdfCraftApp { match self.session.mark_saved(id, bytes, Some(dest.to_string())) { Ok(()) => { self.forget_recovery(id); + self.placed_signatures.remove(&id); if let Some(doc) = self.session.get(id) && let Some(view) = self.views.iter_mut().find(|v| v.id == id) { diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 1b08929307..50d633bd33 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2278,6 +2278,13 @@ calendar date December ديسمبر Smaller pages صفحات أصغر Reset page size إعادة ضبط حجم الصفحة Flatten Fill & Sign when saving تسطيح التعبئة والتوقيع عند الحفظ + Lock your signature into the page? هل تريد تثبيت توقيعك في الصفحة؟ + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. التوقيع الموضوع تعليق إلى أن يُثبَّت: يستطيع أي شخص لديه محرر PDF نقله أو إزالته. التثبيت يجعل نصوص التعبئة والتوقيع وعلاماتها وتواقيعها جزءًا من الصفحة. + Don't ask again عدم السؤال مرة أخرى + Lock and save التثبيت والحفظ + Keep editable إبقاؤه قابلًا للتحرير + Lock into the page when saving التثبيت في الصفحة عند الحفظ + Otherwise, ask on the first save after a signature is placed وإلا فاسأل عند أول حفظ بعد وضع توقيع Text, marks and signatures become part of the page. Other comments stay editable. النص والعلامات والتوقيعات تصبح جزءًا من الصفحة. التعليقات الأخرى تبقى قابلة للتحرير. Date format تنسيق التاريخ Fill & Sign dates تواريخ التعبئة والتوقيع diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e269febf18..56cd2f3fd1 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -138,6 +138,13 @@ Side panels Странични панели Enable Acrobat JavaScript Включи Acrobat JavaScript Flatten Fill & Sign when saving Сплескай „Попълни и подпиши“ при запазване + Lock your signature into the page? Да се заключи ли подписът ви в страницата? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Поставеният подпис е коментар, докато не бъде заключен: всеки с PDF редактор може да го премести или премахне. Заключването прави текста, отметките и подписите от „Попълване и подписване“ част от страницата. + Don't ask again Не питай отново + Lock and save Заключване и запис + Keep editable Да остане редактируем + Lock into the page when saving Заключване в страницата при запис + Otherwise, ask on the first save after a signature is placed Иначе питай при първия запис след поставяне на подпис Text, marks and signatures become part of the page. Other comments stay editable. Текстът, маркировките и подписите стават част от страницата. Другите коментари остават редактируеми. OK OK Cancel Отказ diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 22f6e3e056..89da6a9309 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2299,6 +2299,13 @@ calendar date December prosince Smaller pages Menší stránky Reset page size Obnovit velikost stránek Flatten Fill & Sign when saving Při ukládání sloučit obsah Vyplnit a podepsat se stránkou + Lock your signature into the page? Zamknout podpis do stránky? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Umístěný podpis je komentář, dokud není zamčen: kdokoli s editorem PDF ho může přesunout nebo odstranit. Zamčení udělá z textu, značek a podpisů Vyplnit a podepsat součást stránky. + Don't ask again Už se neptat + Lock and save Zamknout a uložit + Keep editable Ponechat upravitelné + Lock into the page when saving Při ukládání zamknout do stránky + Otherwise, ask on the first save after a signature is placed Jinak se zeptat při prvním uložení po umístění podpisu Text, marks and signatures become part of the page. Other comments stay editable. Text, značky a podpisy se stanou součástí stránky. Ostatní komentáře zůstanou upravitelné. Date format Formát data Fill & Sign dates Datum pro Vyplnit a podepsat diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c9ce7ae36..a80eb7b98d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -71,6 +71,13 @@ Side panels Seitenleisten Enable Acrobat JavaScript Acrobat-JavaScript aktivieren Flatten Fill & Sign when saving Ausfüllen und unterschreiben beim Speichern reduzieren + Lock your signature into the page? Unterschrift fest in die Seite übernehmen? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Eine platzierte Unterschrift ist ein Kommentar, bis sie fest übernommen wird: Jeder mit einem PDF-Editor kann sie verschieben oder entfernen. Beim Übernehmen werden Text, Markierungen und Unterschriften aus „Ausfüllen und unterschreiben“ Teil der Seite. + Don't ask again Nicht mehr fragen + Lock and save Übernehmen und speichern + Keep editable Bearbeitbar lassen + Lock into the page when saving Beim Speichern fest in die Seite übernehmen + Otherwise, ask on the first save after a signature is placed Andernfalls beim ersten Speichern nach dem Platzieren einer Unterschrift fragen Text, marks and signatures become part of the page. Other comments stay editable. Text, Markierungen und Unterschriften werden Teil der Seite. Andere Kommentare bleiben bearbeitbar. OK OK Join our Discord Unserem Discord beitreten diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d83bf4aef7..9952012e7a 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -286,6 +286,13 @@ Failed Αποτυχία Page {p} of {n} Σελίδα {p} από {n} Flatten Fill & Sign when saving Ενσωμάτωση συμπληρώσεων και υπογραφών κατά την αποθήκευση + Lock your signature into the page? Κλείδωμα της υπογραφής σας στη σελίδα; + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Μια τοποθετημένη υπογραφή είναι σχόλιο μέχρι να κλειδωθεί: οποιοσδήποτε με επεξεργαστή PDF μπορεί να τη μετακινήσει ή να την αφαιρέσει. Το κλείδωμα κάνει το κείμενο, τα σημάδια και τις υπογραφές της Συμπλήρωσης και υπογραφής μέρος της σελίδας. + Don't ask again Να μην ερωτηθώ ξανά + Lock and save Κλείδωμα και αποθήκευση + Keep editable Να παραμείνει επεξεργάσιμη + Lock into the page when saving Κλείδωμα στη σελίδα κατά την αποθήκευση + Otherwise, ask on the first save after a signature is placed Διαφορετικά, ερώτηση στην πρώτη αποθήκευση μετά την τοποθέτηση υπογραφής Text, marks and signatures become part of the page. Other comments stay editable. Το κείμενο, τα σημάδια και οι υπογραφές γίνονται μέρος της σελίδας. Τα υπόλοιπα σχόλια παραμένουν επεξεργάσιμα. Current page — type a page number or label (such as iv) and press Enter Τρέχουσα σελίδα — πληκτρολογήστε αριθμό ή ετικέτα σελίδας (π.χ. iv) και πατήστε Enter Zoom — type a percentage and press Enter Ζουμ — πληκτρολογήστε ένα ποσοστό και πατήστε Enter diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 131d2dacaf..cb6b90f278 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -94,6 +94,13 @@ Side panels Paneles laterales Enable Acrobat JavaScript Activar Acrobat JavaScript Flatten Fill & Sign when saving Acoplar Rellenar y firmar al guardar + Lock your signature into the page? ¿Fijar su firma en la página? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Una firma colocada es un comentario hasta que se fija: cualquiera con un editor de PDF puede moverla o quitarla. Al fijarla, el texto, las marcas y las firmas de Rellenar y firmar pasan a formar parte de la página. + Don't ask again No volver a preguntar + Lock and save Fijar y guardar + Keep editable Mantener editable + Lock into the page when saving Fijar en la página al guardar + Otherwise, ask on the first save after a signature is placed Si no, preguntar al guardar por primera vez tras colocar una firma Text, marks and signatures become part of the page. Other comments stay editable. El texto, las marcas y las firmas pasan a formar parte de la página. Los demás comentarios siguen siendo editables. OK Aceptar Join our Discord Únete a nuestro Discord diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1c22cf9d81..2cb821d804 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -71,6 +71,13 @@ Side panels Panneaux latéraux Enable Acrobat JavaScript Activer Acrobat JavaScript Flatten Fill & Sign when saving Aplatir Remplir et signer à l'enregistrement + Lock your signature into the page? Intégrer votre signature à la page ? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Une signature placée reste un commentaire tant qu’elle n’est pas intégrée : n’importe qui avec un éditeur PDF peut la déplacer ou la supprimer. L’intégration rend le texte, les marques et les signatures de Remplir et signer partie de la page. + Don't ask again Ne plus demander + Lock and save Intégrer et enregistrer + Keep editable Garder modifiable + Lock into the page when saving Intégrer à la page à l’enregistrement + Otherwise, ask on the first save after a signature is placed Sinon, demander au premier enregistrement après avoir placé une signature Text, marks and signatures become part of the page. Other comments stay editable. Le texte, les marques et les signatures deviennent partie de la page. Les autres commentaires restent modifiables. OK OK Join our Discord Rejoindre notre Discord diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 47d9c24b40..1b9a0c5d78 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2255,6 +2255,13 @@ action wizard Start Indítás Smaller pages Kisebb oldalak Reset page size Oldalméret visszaállítása Flatten Fill & Sign when saving Kitöltés és aláírás beégetése mentéskor + Lock your signature into the page? Rögzíti az aláírását az oldalba? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Az elhelyezett aláírás megjegyzés, amíg nincs rögzítve: bárki egy PDF-szerkesztővel áthelyezheti vagy eltávolíthatja. A rögzítés a Kitöltés és aláírás szövegét, jeleit és aláírásait az oldal részévé teszi. + Don't ask again Ne kérdezzen többet + Lock and save Rögzítés és mentés + Keep editable Szerkeszthető marad + Lock into the page when saving Mentéskor rögzítés az oldalba + Otherwise, ask on the first save after a signature is placed Különben kérdezzen rá az aláírás elhelyezése utáni első mentéskor Text, marks and signatures become part of the page. Other comments stay editable. A szöveg, a jelek és az aláírások az oldal részévé válnak. A többi megjegyzés szerkeszthető marad. Date format Dátumformátum Fill & Sign dates Kitöltés és aláírás dátumai diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0cae8f3e98..eb713baefd 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2215,6 +2215,13 @@ Files are combined in this order. Leave Pages empty to take every page. I file vengono combinati in quest'ordine. Lascia vuoto Pagine per prendere tutte le pagine. Fill & Sign dates Date di Compila e firma Flatten Fill & Sign when saving Appiattisci Compila e firma al salvataggio + Lock your signature into the page? Bloccare la firma nella pagina? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Una firma inserita è un commento finché non viene bloccata: chiunque abbia un editor PDF può spostarla o rimuoverla. Il blocco rende testo, segni e firme di Compila e firma parte della pagina. + Don't ask again Non chiedere più + Lock and save Blocca e salva + Keep editable Mantieni modificabile + Lock into the page when saving Blocca nella pagina al salvataggio + Otherwise, ask on the first save after a signature is placed Altrimenti, chiedi al primo salvataggio dopo aver inserito una firma H / Space (hold) H / Spazio (tieni premuto) Home / End Home / Fine Inserted text Testo inserito diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index abc616b817..9e151bbc62 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2270,6 +2270,13 @@ @plural {n} page|{n} pages {n} ページ signature pad Type 入力 Flatten Fill & Sign when saving 保存時に入力と署名を統合する + Lock your signature into the page? 署名をページに固定しますか? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. 配置した署名は固定するまで注釈です。PDF エディターを持つ人なら誰でも移動や削除ができます。固定すると、入力と署名のテキスト、マーク、署名がページの一部になります。 + Don't ask again 今後は確認しない + Lock and save 固定して保存 + Keep editable 編集可能のままにする + Lock into the page when saving 保存時にページに固定 + Otherwise, ask on the first save after a signature is placed それ以外は、署名を配置した後の最初の保存時に確認する Text, marks and signatures become part of the page. Other comments stay editable. テキスト、マーク、署名がページの一部になります。その他のコメントは引き続き編集できます。 Scroll tabs left タブを左にスクロール Scroll tabs right タブを右にスクロール diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index fc6a2d783a..87b0496d70 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2187,6 +2187,13 @@ calendar date December Желтоқсан Smaller pages Кішірек беттер Reset page size Бет өлшемін қалпына келтіру Flatten Fill & Sign when saving Сақтау кезінде «Толтыру және қол қою» мазмұнын жазықтау + Lock your signature into the page? Қолтаңбаңызды бетке бекіту керек пе? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Қойылған қолтаңба бекітілгенге дейін түсініктеме болып қалады: PDF өңдегіші бар кез келген адам оны жылжытып не жоя алады. Бекіту «Толтыру және қол қою» мәтінін, белгілерін және қолтаңбаларын беттің бөлігіне айналдырады. + Don't ask again Қайта сұрамау + Lock and save Бекіту және сақтау + Keep editable Өңделетін күйде қалдыру + Lock into the page when saving Сақтағанда бетке бекіту + Otherwise, ask on the first save after a signature is placed Әйтпесе, қолтаңба қойылғаннан кейінгі алғашқы сақтауда сұрау Text, marks and signatures become part of the page. Other comments stay editable. Мәтін, белгілер мен қолтаңбалар беттің бөлігіне айналады. Басқа түсініктемелер өңделетін күйде қалады. Date format Күн пішімі Fill & Sign dates «Толтыру және қол қою» күндері diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index fd7f431154..725d8ad22e 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -590,6 +590,13 @@ Fixed what could be fixed; 1 problem remains Ištaisyta tai, ką buvo galima; liko 1 problema Fixed what could be fixed; {n} problems remain Ištaisyta tai, ką buvo galima; liko problemų: {n} Flatten Fill & Sign when saving Įrašant suplokštinti pildymo ir pasirašymo turinį + Lock your signature into the page? Įtvirtinti parašą puslapyje? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Padėtas parašas yra komentaras, kol neįtvirtintas: bet kas su PDF redaktoriumi gali jį perkelti ar pašalinti. Įtvirtinus užpildymo ir pasirašymo tekstas, žymės ir parašai tampa puslapio dalimi. + Don't ask again Daugiau neklausti + Lock and save Įtvirtinti ir įrašyti + Keep editable Palikti redaguojamą + Lock into the page when saving Įrašant įtvirtinti puslapyje + Otherwise, ask on the first save after a signature is placed Kitaip klausti pirmą kartą įrašant po parašo padėjimo Flip Horizontal Apversti horizontaliai Flip Vertical Apversti vertikaliai Flip horizontal Apversti horizontaliai diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 208dd1c61e..ee94b2085a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2249,6 +2249,13 @@ @plural {n} page|{n} pages {n} strona|{n} strony|{n} stron signature pad Type Wpisz Flatten Fill & Sign when saving Spłaszczaj elementy Wypełnij i podpisz przy zapisie + Lock your signature into the page? Utrwalić podpis na stronie? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Umieszczony podpis jest komentarzem, dopóki nie zostanie utrwalony: każdy z edytorem PDF może go przenieść lub usunąć. Utrwalenie sprawia, że tekst, znaki i podpisy z Wypełnij i podpisz stają się częścią strony. + Don't ask again Nie pytaj ponownie + Lock and save Utrwal i zapisz + Keep editable Zachowaj edytowalny + Lock into the page when saving Utrwalaj na stronie przy zapisie + Otherwise, ask on the first save after a signature is placed W przeciwnym razie pytaj przy pierwszym zapisie po umieszczeniu podpisu Text, marks and signatures become part of the page. Other comments stay editable. Tekst, oznaczenia i podpisy stają się częścią strony. Pozostałe komentarze pozostają edytowalne. Scroll tabs left Przewiń karty w lewo Scroll tabs right Przewiń karty w prawo diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a172a03879..d98d77904e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2208,6 +2208,13 @@ action wizard Start Iniciar First Primeiro First commit Primeiro commit Flatten Fill & Sign when saving Nivelar Preencher e assinar ao salvar + Lock your signature into the page? Fixar sua assinatura na página? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Uma assinatura colocada é um comentário até ser fixada: qualquer pessoa com um editor de PDF pode movê-la ou removê-la. Fixar torna o texto, as marcas e as assinaturas de Preencher e assinar parte da página. + Don't ask again Não perguntar novamente + Lock and save Fixar e salvar + Keep editable Manter editável + Lock into the page when saving Fixar na página ao salvar + Otherwise, ask on the first save after a signature is placed Caso contrário, perguntar ao salvar pela primeira vez após colocar uma assinatura H / Space (hold) H / Espaço (manter pressionado) Home / End Home / End Inserted text Texto inserido diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b45d73ecab..bae4c18a5c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -91,6 +91,13 @@ Side panels Боковые панели Enable Acrobat JavaScript Включить Acrobat JavaScript Flatten Fill & Sign when saving Расплющить «Заполнить и подписать» при сохранении + Lock your signature into the page? Закрепить подпись на странице? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Размещённая подпись остаётся комментарием, пока не закреплена: любой с редактором PDF может её переместить или удалить. Закрепление делает текст, отметки и подписи из «Заполнить и подписать» частью страницы. + Don't ask again Больше не спрашивать + Lock and save Закрепить и сохранить + Keep editable Оставить редактируемой + Lock into the page when saving Закреплять на странице при сохранении + Otherwise, ask on the first save after a signature is placed Иначе спрашивать при первом сохранении после размещения подписи Text, marks and signatures become part of the page. Other comments stay editable. Текст, пометки и подписи становятся частью страницы. Остальные комментарии можно по-прежнему править. OK OK Join our Discord Присоединяйтесь к нашему Discord diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index c95f6d6bd6..cd08378b90 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2203,6 +2203,13 @@ signature pad Type Vtipkaj Document properties ({key}) Lastnosti dokumenta ({key}) Fill & Sign dates Datumi pri Izpolni in podpiši Flatten Fill & Sign when saving Splošči Izpolni in podpiši ob shranjevanju + Lock your signature into the page? Zakleniti podpis v stran? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Postavljen podpis je komentar, dokler ni zaklenjen: vsak z urejevalnikom PDF ga lahko premakne ali odstrani. Zaklepanje naredi besedilo, oznake in podpise iz Izpolni in podpiši del strani. + Don't ask again Ne sprašuj več + Lock and save Zakleni in shrani + Keep editable Ohrani urejljivo + Lock into the page when saving Ob shranjevanju zakleni v stran + Otherwise, ask on the first save after a signature is placed Sicer vprašaj ob prvem shranjevanju po postavitvi podpisa Font size 0 uses automatic sizing. Velikost pisave 0 pomeni samodejno velikost. Import list… Uvozi seznam… Larger pages Večje strani diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 9b81ffb8b9..3c7e4aa7b6 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2503,6 +2503,14 @@ animation effects Off ఆఫ్ Send backward వెనుకకు పంపు Send to back అందరికంటే వెనుకకు పంపు + Lock your signature into the page? మీ సంతకాన్ని పేజీలో లాక్ చేయాలా? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. ఉంచిన సంతకం లాక్ చేసే వరకు ఒక వ్యాఖ్య మాత్రమే: PDF ఎడిటర్ ఉన్న ఎవరైనా దాన్ని తరలించవచ్చు లేదా తొలగించవచ్చు. లాక్ చేయడం వల్ల నింపి సంతకం చేయి వచనం, గుర్తులు, సంతకాలు పేజీలో భాగమవుతాయి. + Don't ask again మళ్లీ అడగవద్దు + Lock and save లాక్ చేసి సేవ్ చేయి + Keep editable సవరించగలిగేలా ఉంచు + Lock into the page when saving సేవ్ చేసేటప్పుడు పేజీలో లాక్ చేయి + Otherwise, ask on the first save after a signature is placed లేకపోతే, సంతకం ఉంచిన తర్వాత మొదటి సేవ్‌లో అడుగు + # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today ఈరోజు @plural {n} reply|{n} replies {n} ప్రత్యుత్తరం|{n} ప్రత్యుత్తరాలు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6921a6c33e..e62d87e160 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -62,6 +62,13 @@ Side panels Yan paneller Enable Acrobat JavaScript Acrobat JavaScript'i etkinleştir Flatten Fill & Sign when saving Kaydederken Doldur ve İmzala'yı düzleştir + Lock your signature into the page? İmzanız sayfaya sabitlensin mi? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Yerleştirilen imza, sabitlenene kadar bir yorumdur: PDF düzenleyicisi olan herkes onu taşıyabilir veya kaldırabilir. Sabitlemek, Doldur ve İmzala metnini, işaretlerini ve imzalarını sayfanın parçası yapar. + Don't ask again Bir daha sorma + Lock and save Sabitle ve kaydet + Keep editable Düzenlenebilir bırak + Lock into the page when saving Kaydederken sayfaya sabitle + Otherwise, ask on the first save after a signature is placed Aksi halde, imza yerleştirildikten sonraki ilk kayıtta sor Text, marks and signatures become part of the page. Other comments stay editable. Metin, işaretler ve imzalar sayfanın bir parçası olur. Diğer yorumlar düzenlenebilir kalır. OK Tamam Join our Discord Discord'umuza katılın diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a824769600..863d1a69c1 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2278,6 +2278,13 @@ calendar date December грудня Smaller pages Менші сторінки Reset page size Скинути розмір сторінок Flatten Fill & Sign when saving Звести «Заповнення й підпис» під час збереження + Lock your signature into the page? Закріпити підпис на сторінці? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Розміщений підпис є коментарем, доки його не закріплено: будь-хто з редактором PDF може його перемістити або видалити. Закріплення робить текст, позначки й підписи з «Заповнити й підписати» частиною сторінки. + Don't ask again Більше не питати + Lock and save Закріпити й зберегти + Keep editable Залишити редагованим + Lock into the page when saving Закріплювати на сторінці під час збереження + Otherwise, ask on the first save after a signature is placed Інакше питати під час першого збереження після розміщення підпису Text, marks and signatures become part of the page. Other comments stay editable. Текст, позначки й підписи стають частиною сторінки. Інші коментарі залишаються редагованими. Date format Формат дати Fill & Sign dates Дати заповнення й підпису diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 96e9c3bb75..a3eee785a9 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -144,6 +144,13 @@ Side panels 侧边面板 Enable Acrobat JavaScript 启用 Acrobat JavaScript Flatten Fill & Sign when saving 保存时拼合填写与签名 + Lock your signature into the page? 将签名锁定到页面中? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. 放置的签名在锁定前只是注释:任何有 PDF 编辑器的人都能移动或删除它。锁定后,“填写和签名”的文字、标记和签名会成为页面的一部分。 + Don't ask again 不再询问 + Lock and save 锁定并保存 + Keep editable 保持可编辑 + Lock into the page when saving 保存时锁定到页面 + Otherwise, ask on the first save after a signature is placed 否则,在放置签名后首次保存时询问 Text, marks and signatures become part of the page. Other comments stay editable. 文字、标记和签名会成为页面内容。其他注释仍可编辑。 OK 确定 Cancel 取消 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a1b933b830..3695a90572 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2544,6 +2544,14 @@ animation effects Off 關 Send backward 下移一層 Send to back 移到最下層 + Lock your signature into the page? 將簽名鎖定到頁面中? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. 放置的簽名在鎖定前只是註解:任何有 PDF 編輯器的人都能移動或移除它。鎖定後,「填寫與簽署」的文字、標記與簽名會成為頁面的一部分。 + Don't ask again 不再詢問 + Lock and save 鎖定並儲存 + Keep editable 保持可編輯 + Lock into the page when saving 儲存時鎖定到頁面 + Otherwise, ask on the first save after a signature is placed 否則,在放置簽名後第一次儲存時詢問 + # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 今天 @plural {n} reply|{n} replies {n} 則回覆 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fe0138f27f..c847b01337 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -326,6 +326,15 @@ pub struct PdfCraftApp { ui_scale_applied: Option, /// Preferences: bake Fill & Sign marks into the page when saving. Off, so a normal save stays editable. pub flatten_fill_sign_on_save: bool, + /// Ask on the first save after Fill & Sign places a signature or initials whether to lock + /// them into the page (#2149). Off once the user says not to ask again. + pub ask_to_lock_signatures: bool, + /// Documents with a signature or initials placed by Fill & Sign since they were last saved + /// (or asked about). + pub(crate) placed_signatures: std::collections::HashSet, + /// A save waiting behind [`Dialog::LockSignatures`], and its Don't ask again box. + pub(crate) pending_lock: Option, + pub(crate) lock_remember: bool, /// Point size for new Fill & Sign text and dates, kept across launches. pub fill_text_size: f64, /// Colour for new Fill & Sign text and dates, kept across launches. diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e3a9c540a6..c034c51a8d 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -142,6 +142,9 @@ pub enum Dialog { Revert, /// Save over a file that changed on disk since the document came from it (#2166). OverwriteChanged, + /// Saving a document with a signature just placed by Fill & Sign: lock it into the page + /// first? (#2149) + LockSignatures, /// Link Properties. LinkProps, /// Organize ▸ Extract (options), Rotate Pages. diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index 045bb64974..225faca601 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -398,6 +398,8 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp, }); ui.add_space(4.0); } + // Where the signing happens, not only in Preferences (#2149). + ui.add(crate::widgets::checkbox(&mut app.flatten_fill_sign_on_save, tl!("Lock into the page when saving"))); command } diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 70c3f775e9..dce319d637 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -560,6 +560,10 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok ui.add_space(8.0); ui.label(egui::RichText::new(tl!("Fill & Sign")).font(theme::semibold(13.0))); ui.add(widgets::checkbox(&mut app.flatten_fill_sign_on_save, tl!("Flatten Fill & Sign when saving"))); + ui.add_enabled( + !app.flatten_fill_sign_on_save, + widgets::checkbox(&mut app.ask_to_lock_signatures, tl!("Otherwise, ask on the first save after a signature is placed")), + ); ui.label( egui::RichText::new(tl!("Text, marks and signatures become part of the page. Other comments stay editable.")).small().color(t.text_muted), ); diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index ce21bd9f74..1bd6f53ff9 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -4,7 +4,7 @@ use egui::{Pos2, pos2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use pdfcraft_ui_egui::fill_sign::{FillTool, SavedSig}; -use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool}; +use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool, SaveTarget}; const FIXTURE: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj @@ -1752,6 +1752,88 @@ fn photos_and_images_dropped_on_the_signature_pad_are_imported_not_opened() { std::fs::remove_file(photo).unwrap(); } +/// #2149: the first save after Fill & Sign places a signature asks whether to lock it into the +/// page. Lock and save flattens it; Keep editable keeps it a comment; Don't ask again becomes +/// the preference; the Sign menu has the switch too. +#[test] +fn the_first_save_after_placing_a_signature_asks_to_lock_it() { + let out = std::env::temp_dir().join(format!("pdfcraft-lock-signature-{}.pdf", std::process::id())); + let mut h = harness(); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + click(&mut h, 40.0, 300.0); + let signed = |h: &Harness<'static, PdfCraftApp>| { + h.state().session.get(h.state().views[0].id).unwrap().info.annotations.iter().filter(|a| a.fill_sign).count() + }; + assert_eq!(signed(&h), 1); + // Keep editable: saved, still a comment, and not asked again for this placement. + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "asks first"); + h.run_steps(2); + h.get_by_label("Lock your signature into the page?"); + h.get_by_label("Keep editable").click(); + h.run_steps(3); + assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty, "saved"); + assert_eq!(signed(&h), 1, "still a removable comment"); + assert!(h.state_mut().save_active(SaveTarget::InPlace), "no new signature: no question"); + // A new signature asks again; Lock and save with Don't ask again flattens and remembers. + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + click(&mut h, 40.0, 200.0); + assert!(!h.state_mut().save_active(SaveTarget::InPlace)); + h.run_steps(2); + h.get_by_label("Don't ask again").click(); + h.run_steps(1); + h.get_by_label("Lock and save").click(); + h.run_steps(3); + assert_eq!(signed(&h), 0, "locked into the page"); + assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty); + assert!(h.state().flatten_fill_sign_on_save, "remembered as the preference"); + // The Sign menu shows the same switch. + h.state_mut().flatten_fill_sign_on_save = false; + h.get_by_label("Fill & Sign").click(); + h.run_steps(2); + h.get_by_label("Fill & Sign").click(); + h.run_steps(2); + h.get_by_label("Lock into the page when saving").click(); + h.run_steps(2); + assert!(h.state().flatten_fill_sign_on_save); + let _ = std::fs::remove_file(&out); +} + +/// #2149: Cancel saves nothing, and Keep editable with Don't ask again turns the question off. +#[test] +fn cancelling_the_lock_question_saves_nothing() { + let out = std::env::temp_dir().join(format!("pdfcraft-lock-cancel-{}.pdf", std::process::id())); + let _ = std::fs::remove_file(&out); + let mut h = harness(); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + click(&mut h, 40.0, 300.0); + assert!(!h.state_mut().save_active(SaveTarget::InPlace)); + h.run_steps(2); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert!(!out.exists() && h.state().session.get(h.state().views[0].id).unwrap().dirty, "nothing saved"); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "asked again"); + h.run_steps(2); + h.get_by_label("Don't ask again").click(); + h.run_steps(1); + h.get_by_label("Keep editable").click(); + h.run_steps(3); + assert!(out.exists() && !h.state().ask_to_lock_signatures); + let restored = { + let mut a = PdfCraftApp::new(); + a.restore(&h.state().persist()); + a.ask_to_lock_signatures + }; + assert!(!restored, "kept with the settings"); + let _ = std::fs::remove_file(&out); +} + /// Where a mode bar tab is, by its label. fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab") diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d03c27c95c..03381a30fa 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -136,7 +136,7 @@ workflows, exchange edge cases and everything outside the mainstream slice are e | Print | 8% | 55% | Good on macOS and Linux; Windows prints since #847 (#756), not yet re-scored | | Highlight, sticky notes, text comments | 12% | 85% | All markup types, replies, properties | | Fill in forms | 10% | 85% | Every AcroForm field type, Acrobat's formatting and calculations | -| Fill & Sign: typed, drawn or image signature | 10% | 85% | Fill & Sign tools; flatten on save | +| Fill & Sign: typed, drawn or image signature | 10% | 85% | Fill & Sign tools; flatten on save (opt-in, and asked on the first save after signing) | | Rotate, delete, reorder and extract pages | 8% | 85% | Organize grid, drag reorder, thumbnails | | Combine files | 6% | 85% | Combine tab with bookmarks per file | | Fix a typo in existing text | 5% | 40% | Latin paragraphs only; font may be substituted | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cabfc7823a..66f21615d8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5441,8 +5441,8 @@ milestone = "M5" status = "shipped" commands = ["comment.flatten"] tools = ["doc_save", "doc_flatten"] -evidence = ["crates/edit/src/tests.rs::fill_sign_flatten_bakes_marks_keeps_other_comments_and_does_not_reuse_pcfl0", "crates/engine/src/tests.rs::flattening_fill_and_sign_keeps_other_comments_fields_and_undoes", "crates/engine/src/tests.rs::flattening_fill_and_sign_is_refused_without_permission_to_change", "crates/automation/tests/it/automation.rs::saving_with_flatten_fill_sign_bakes_marks_and_leaves_other_comments", "crates/ui-egui/tests/it/fill_sign.rs::saving_flattens_fill_and_sign_only_when_the_preference_is_on"] -notes = "Preferences ▸ Fill & Sign, and doc_save flatten_fill_sign, bake text, marks and signatures into the page. The option defaults off, so a normal save leaves them editable. Other comments and form fields stay. Flatten comments still flattens every comment." +evidence = ["crates/edit/src/tests.rs::fill_sign_flatten_bakes_marks_keeps_other_comments_and_does_not_reuse_pcfl0", "crates/engine/src/tests.rs::flattening_fill_and_sign_keeps_other_comments_fields_and_undoes", "crates/engine/src/tests.rs::flattening_fill_and_sign_is_refused_without_permission_to_change", "crates/automation/tests/it/automation.rs::saving_with_flatten_fill_sign_bakes_marks_and_leaves_other_comments", "crates/ui-egui/tests/it/fill_sign.rs::saving_flattens_fill_and_sign_only_when_the_preference_is_on", "crates/ui-egui/tests/it/fill_sign.rs::the_first_save_after_placing_a_signature_asks_to_lock_it", "crates/ui-egui/tests/it/fill_sign.rs::cancelling_the_lock_question_saves_nothing"] +notes = "Preferences ▸ Fill & Sign, and doc_save flatten_fill_sign, bake text, marks and signatures into the page. The option defaults off, so a normal save leaves them editable; the first save after a signature or initials is placed asks whether to lock it into the page (Lock and save / Keep editable, with Don't ask again), and the Sign menu carries the same switch (#2149). Other comments and form fields stay. Flatten comments still flattens every comment." [[feature]] id = "form.autofill-profile" From 97f514d632fa0a8376dd31618a23081dc743b169 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:08:47 +0800 Subject: [PATCH 065/101] M5: Vietnamese for the lock-signatures prompt (#2149) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/vi.tsv | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e79a8aacc4..2275b4b78a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2885,3 +2885,12 @@ key Space Space No image on clipboard to paste Không có hình ảnh trong bảng nhớ tạm để dán Pinned {tool} to Home Đã ghim {tool} vào Trang chủ Unpinned {tool} from Home Đã bỏ ghim {tool} khỏi Trang chủ + +# AI-assisted additions for the lock-signatures prompt (#2149) (2026-10-16); not reviewed by a native speaker. + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Chữ ký đã đặt chỉ là một bình luận cho đến khi được khóa: bất kỳ ai có trình chỉnh sửa PDF đều có thể di chuyển hoặc xóa nó. Khóa sẽ biến văn bản, dấu và chữ ký Điền & Ký của bạn thành một phần của trang. + Don't ask again Không hỏi lại + Keep editable Giữ để chỉnh sửa được + Lock and save Khóa và lưu + Lock into the page when saving Khóa vào trang khi lưu + Lock your signature into the page? Khóa chữ ký của bạn vào trang? + Otherwise, ask on the first save after a signature is placed Nếu không, hỏi ở lần lưu đầu tiên sau khi đặt chữ ký From c2b1de2b8bacb02350778c0aa12413740c1a9f7d Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:14:45 +0800 Subject: [PATCH 066/101] M7: a signature lock that fails keeps the question and the preference (#2149) Review follow-up on #2348: the document stays tracked as holding a placed signature and "Don't ask again" is remembered only once locking succeeded. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/editing/save.rs | 20 ++++++++------ crates/ui-egui/tests/it/fill_sign.rs | 41 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index 446aba1fc5..a2830d680d 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -171,17 +171,11 @@ impl PdfCraftApp { /// The Lock signatures dialog answered (#2149): lock the document's Fill & Sign marks into /// its pages first when `lock`, then save as was asked. With `remember`, the answer becomes - /// the preference (flatten on every save, or never ask again). + /// the preference (flatten on every save, or never ask again). Nothing changes when locking + /// fails: the document still counts as holding a placed signature, so the next save asks + /// again, and the answer isn't remembered. pub(crate) fn answer_lock_signatures(&mut self, lock: bool, remember: bool) { let Some(p) = self.pending_lock.take() else { return }; - self.placed_signatures.remove(&p.id); - if remember { - if lock { - self.flatten_fill_sign_on_save = true; - } else { - self.ask_to_lock_signatures = false; - } - } if lock && !self.flatten_fill_sign_on_save { let before = self.session.get(p.id).map(|d| d.edit_generation()); if let Err(e) = self.session.apply(p.id, pdfcraft_engine::Edit::FlattenFillSign) { @@ -195,6 +189,14 @@ impl PdfCraftApp { view.document_changed(&doc.info); } } + self.placed_signatures.remove(&p.id); + if remember { + if lock { + self.flatten_fill_sign_on_save = true; + } else { + self.ask_to_lock_signatures = false; + } + } if let Some(index) = self.views.iter().position(|v| v.id == p.id) { self.save_then(index, p.target, p.after); } diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index 1bd6f53ff9..54af2288f0 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -1802,6 +1802,47 @@ fn the_first_save_after_placing_a_signature_asks_to_lock_it() { let _ = std::fs::remove_file(&out); } +/// #2348 review: when locking the signature fails (here the document's security allows comments +/// but not changes), nothing is lost: the next save asks again, and "Don't ask again" isn't +/// remembered. +#[test] +fn a_lock_that_fails_keeps_the_question() { + let out = std::env::temp_dir().join(format!("pdfcraft-lock-fails-{}.pdf", std::process::id())); + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(FIXTURE.to_vec())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + // Everything but bit 4, modifying the document. + permissions: -1 ^ 8, + encrypt_metadata: true, + seed: [4; 32], + }) + .unwrap(); + let mut h = harness_bytes(&pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + click(&mut h, 40.0, 300.0); + let signed = |h: &Harness<'static, PdfCraftApp>| { + h.state().session.get(h.state().views[0].id).unwrap().info.annotations.iter().filter(|a| a.fill_sign).count() + }; + assert_eq!(signed(&h), 1, "comments are allowed"); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "asks first"); + h.run_steps(2); + h.get_by_label("Don't ask again").click(); + h.run_steps(1); + h.get_by_label("Lock and save").click(); + h.run_steps(3); + assert_eq!(signed(&h), 1, "changes aren't allowed: still a comment"); + assert!(!h.state().flatten_fill_sign_on_save, "the failed answer isn't remembered"); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "the next save asks again"); + h.run_steps(2); + h.get_by_label("Lock your signature into the page?"); + let _ = std::fs::remove_file(&out); +} + /// #2149: Cancel saves nothing, and Keep editable with Don't ask again turns the question off. #[test] fn cancelling_the_lock_question_saves_nothing() { From 30aa8793f69d2a357108b43f4114b79bf5cff808 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:10:23 +0800 Subject: [PATCH 067/101] M7: the lock question keeps the changed-on-disk question, skips signed documents and undone marks (#2149) Review follow-ups on #2348 (benandez): a dialog an answer opened this frame isn't closed with it; documents with digital signatures aren't asked, since locking changes them after signing; a placement that was undone asks nothing; the mark is forgotten when the tab closes; the other catalogues carry the AI-assisted header; parity evidence lists the new tests. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app/files.rs | 1 + crates/ui-egui/src/dialogs.rs | 6 ++- crates/ui-egui/src/editing/save.rs | 8 +++- crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/kk.tsv | 1 + crates/ui-egui/src/i18n/lt.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sl.tsv | 1 + crates/ui-egui/src/i18n/te.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/lib.rs | 4 +- crates/ui-egui/tests/it/fill_sign.rs | 65 ++++++++++++++++++++++++++++ parity/acrobat-features.toml | 2 +- 27 files changed, 101 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 29e6978461..28e82758a4 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -746,6 +746,7 @@ impl PdfCraftApp { } self.remember_view(index); let id = self.views.remove(index).id; + self.placed_signatures.remove(&id); #[cfg(not(target_arch = "wasm32"))] if let Some(path) = self.session.get(id).and_then(|d| d.path.clone()) { if self.closed_tabs.len() == 10 { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 30985fab28..58a7b560bb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1983,7 +1983,11 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: if modal_closed || req.close || replaces { // Only now: the number in Preferences can pass through 0 on its way to the one wanted. app.recent.truncate(app.recent_limit); - app.dialog = None; + // An answer may have opened another dialog this frame (saving a file that changed on + // disk asks about that): it stays. + if app.dialog == Some(dialog) { + app.dialog = None; + } app.props_draft = None; app.view_draft = None; app.custom_draft = None; diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index a2830d680d..520ce47477 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -55,8 +55,12 @@ impl PdfCraftApp { } let Some(id) = self.views.get(index).map(|v| v.id) else { return false }; // A signature just placed with Fill & Sign is a removable comment unless it is flattened: - // ask once whether to lock it into the page (#2149). - if self.ask_to_lock_signatures && !self.flatten_fill_sign_on_save && self.placed_signatures.contains(&id) { + // ask once whether to lock it into the page (#2149). Only while a Fill & Sign mark is still + // there (undo may have taken it away), and never on a digitally signed document: locking + // changes page content in an update, which validators report as a change after signing. + let still_marked = self.session.get(id).is_some_and(|d| d.info.annotations.iter().any(|a| a.fill_sign)); + let signed = self.session.get(id).is_some_and(|d| d.is_signed()); + if self.ask_to_lock_signatures && !self.flatten_fill_sign_on_save && self.placed_signatures.contains(&id) && still_marked && !signed { self.pending_lock = Some(PendingLock { id, target, after: Box::new(after) }); self.dialog = Some(crate::Dialog::LockSignatures); return false; diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 50d633bd33..746e20477d 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2278,6 +2278,7 @@ calendar date December ديسمبر Smaller pages صفحات أصغر Reset page size إعادة ضبط حجم الصفحة Flatten Fill & Sign when saving تسطيح التعبئة والتوقيع عند الحفظ +# AI-assisted additions for the lock-signatures prompt (#2149) (2026-10-16); not reviewed by a native speaker. Lock your signature into the page? هل تريد تثبيت توقيعك في الصفحة؟ A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. التوقيع الموضوع تعليق إلى أن يُثبَّت: يستطيع أي شخص لديه محرر PDF نقله أو إزالته. التثبيت يجعل نصوص التعبئة والتوقيع وعلاماتها وتواقيعها جزءًا من الصفحة. Don't ask again عدم السؤال مرة أخرى diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 56cd2f3fd1..516fbb62c9 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -138,6 +138,7 @@ Side panels Странични панели Enable Acrobat JavaScript Включи Acrobat JavaScript Flatten Fill & Sign when saving Сплескай „Попълни и подпиши“ при запазване +# AI-assisted additions for the lock-signatures prompt (#2149) (2026-10-16); not reviewed by a native speaker. Lock your signature into the page? Да се заключи ли подписът ви в страницата? A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Поставеният подпис е коментар, докато не бъде заключен: всеки с PDF редактор може да го премести или премахне. Заключването прави текста, отметките и подписите от „Попълване и подписване“ част от страницата. Don't ask again Не питай отново diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 89da6a9309..805b9c1885 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2299,6 +2299,7 @@ calendar date December prosince Smaller pages Menší stránky Reset page size Obnovit velikost stránek Flatten Fill & Sign when saving Při ukládání sloučit obsah Vyplnit a podepsat se stránkou +# AI-assisted additions for the lock-signatures prompt (#2149) (2026-10-16); not reviewed by a native speaker. Lock your signature into the page? Zamknout podpis do stránky? A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Umístěný podpis je komentář, dokud není zamčen: kdokoli s editorem PDF ho může přesunout nebo odstranit. Zamčení udělá z textu, značek a podpisů Vyplnit a podepsat součást stránky. Don't ask again Už se neptat diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a80eb7b98d..2a4a5b4e0e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -71,6 +71,7 @@ Side panels Seitenleisten Enable Acrobat JavaScript Acrobat-JavaScript aktivieren Flatten Fill & Sign when saving Ausfüllen und unterschreiben beim Speichern reduzieren +# AI-assisted additions for the lock-signatures prompt (#2149) (2026-10-16); not reviewed by a native speaker. Lock your signature into the page? Unterschrift fest in die Seite übernehmen? A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Eine platzierte Unterschrift ist ein Kommentar, bis sie fest übernommen wird: Jeder mit einem PDF-Editor kann sie verschieben oder entfernen. Beim Übernehmen werden Text, Markierungen und Unterschriften aus „Ausfüllen und unterschreiben“ Teil der Seite. Don't ask again Nicht mehr fragen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 9952012e7a..6e031b4e4f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -286,6 +286,7 @@ Failed Αποτυχία Page {p} of {n} Σελίδα {p} από {n} Flatten Fill & Sign when saving Ενσωμάτωση συμπληρώσεων και υπογραφών κατά την αποθήκευση +# AI-assisted additions for the lock-signatures prompt (#2149) (2026-10-16); not reviewed by a native speaker. Lock your signature into the page? Κλείδωμα της υπογραφής σας στη σελίδα; A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Μια τοποθετημένη υπογραφή είναι σχόλιο μέχρι να κλειδωθεί: οποιοσδήποτε με επεξεργαστή PDF μπορεί να τη μετακινήσει ή να την αφαιρέσει. Το κλείδωμα κάνει το κείμενο, τα σημάδια και τις υπογραφές της Συμπλήρωσης και υπογραφής μέρος της σελίδας. Don't ask again Να μην ερωτηθώ ξανά diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index cb6b90f278..10897567ac 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -94,6 +94,7 @@ Side panels Paneles laterales Enable Acrobat JavaScript Activar Acrobat JavaScript Flatten Fill & Sign when saving Acoplar Rellenar y firmar al guardar +# AI-assisted additions for the lock-signatures prompt (#2149) (2026-10-16); not reviewed by a native speaker. Lock your signature into the page? ¿Fijar su firma en la página? A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Una firma colocada es un comentario hasta que se fija: cualquiera con un editor de PDF puede moverla o quitarla. Al fijarla, el texto, las marcas y las firmas de Rellenar y firmar pasan a formar parte de la página. Don't ask again No volver a preguntar diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2cb821d804..0c4572fe22 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -71,6 +71,7 @@ Side panels Panneaux latéraux Enable Acrobat JavaScript Activer Acrobat JavaScript Flatten Fill & Sign when saving Aplatir Remplir et signer à l'enregistrement +# AI-assisted additions for the lock-signatures prompt (#2149) (2026-10-16); not reviewed by a native speaker. Lock your signature into the page? Intégrer votre signature à la page ? A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Une signature placée reste un commentaire tant qu’elle n’est pas intégrée : n’importe qui avec un éditeur PDF peut la déplacer ou la supprimer. L’intégration rend le texte, les marques et les signatures de Remplir et signer partie de la page. Don't ask again Ne plus demander diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 1b9a0c5d78..7a066c9ba3 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2255,6 +2255,7 @@ action wizard Start Indítás Smaller pages Kisebb oldalak Reset page size Oldalméret visszaállítása Flatten Fill & Sign when saving Kitöltés és aláírás beégetése mentéskor +# AI-assisted additions for the lock-signatures prompt (#2149) (2026-10-16); not reviewed by a native speaker. Lock your signature into the page? Rögzíti az aláírását az oldalba? A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Az elhelyezett aláírás megjegyzés, amíg nincs rögzítve: bárki egy PDF-szerkesztővel áthelyezheti vagy eltávolíthatja. A rögzítés a Kitöltés és aláírás szövegét, jeleit és aláírásait az oldal részévé teszi. Don't ask again Ne kérdezzen többet diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index eb713baefd..ae3ebd6693 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2215,6 +2215,7 @@ Files are combined in this order. Leave Pages empty to take every page. I file vengono combinati in quest'ordine. Lascia vuoto Pagine per prendere tutte le pagine. Fill & Sign dates Date di Compila e firma Flatten Fill & Sign when saving Appiattisci Compila e firma al salvataggio +# AI-assisted additions for the lock-signatures prompt (#2149) (2026-10-16); not reviewed by a native speaker. Lock your signature into the page? Bloccare la firma nella pagina? A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Una firma inserita è un commento finché non viene bloccata: chiunque abbia un editor PDF può spostarla o rimuoverla. Il blocco rende testo, segni e firme di Compila e firma parte della pagina. Don't ask again Non chiedere più diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9e151bbc62..ddeb261438 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2270,6 +2270,7 @@ @plural {n} page|{n} pages {n} ページ signature pad Type 入力 Flatten Fill & Sign when saving 保存時に入力と署名を統合する +# AI-assisted additions for the lock-signatures prompt (#2149) (2026-10-16); not reviewed by a native speaker. Lock your signature into the page? 署名をページに固定しますか? A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. 配置した署名は固定するまで注釈です。PDF エディターを持つ人なら誰でも移動や削除ができます。固定すると、入力と署名のテキスト、マーク、署名がページの一部になります。 Don't ask again 今後は確認しない diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 87b0496d70..8171e1cac4 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2187,6 +2187,7 @@ calendar date December Желтоқсан Smaller pages Кішірек беттер Reset page size Бет өлшемін қалпына келтіру Flatten Fill & Sign when saving Сақтау кезінде «Толтыру және қол қою» мазмұнын жазықтау +# AI-assisted additions for the lock-signatures prompt (#2149) (2026-10-16); not reviewed by a native speaker. Lock your signature into the page? Қолтаңбаңызды бетке бекіту керек пе? A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Қойылған қолтаңба бекітілгенге дейін түсініктеме болып қалады: PDF өңдегіші бар кез келген адам оны жылжытып не жоя алады. Бекіту «Толтыру және қол қою» мәтінін, белгілерін және қолтаңбаларын беттің бөлігіне айналдырады. Don't ask again Қайта сұрамау diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 725d8ad22e..d93109274e 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -590,6 +590,7 @@ Fixed what could be fixed; 1 problem remains Ištaisyta tai, ką buvo galima; liko 1 problema Fixed what could be fixed; {n} problems remain Ištaisyta tai, ką buvo galima; liko problemų: {n} Flatten Fill & Sign when saving Įrašant suplokštinti pildymo ir pasirašymo turinį +# AI-assisted additions for the lock-signatures prompt (#2149) (2026-10-16); not reviewed by a native speaker. Lock your signature into the page? Įtvirtinti parašą puslapyje? A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Padėtas parašas yra komentaras, kol neįtvirtintas: bet kas su PDF redaktoriumi gali jį perkelti ar pašalinti. Įtvirtinus užpildymo ir pasirašymo tekstas, žymės ir parašai tampa puslapio dalimi. Don't ask again Daugiau neklausti diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ee94b2085a..f071c3cda3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2249,6 +2249,7 @@ @plural {n} page|{n} pages {n} strona|{n} strony|{n} stron signature pad Type Wpisz Flatten Fill & Sign when saving Spłaszczaj elementy Wypełnij i podpisz przy zapisie +# AI-assisted additions for the lock-signatures prompt (#2149) (2026-10-16); not reviewed by a native speaker. Lock your signature into the page? Utrwalić podpis na stronie? A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Umieszczony podpis jest komentarzem, dopóki nie zostanie utrwalony: każdy z edytorem PDF może go przenieść lub usunąć. Utrwalenie sprawia, że tekst, znaki i podpisy z Wypełnij i podpisz stają się częścią strony. Don't ask again Nie pytaj ponownie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index d98d77904e..eae76f0a35 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2208,6 +2208,7 @@ action wizard Start Iniciar First Primeiro First commit Primeiro commit Flatten Fill & Sign when saving Nivelar Preencher e assinar ao salvar +# AI-assisted additions for the lock-signatures prompt (#2149) (2026-10-16); not reviewed by a native speaker. Lock your signature into the page? Fixar sua assinatura na página? A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Uma assinatura colocada é um comentário até ser fixada: qualquer pessoa com um editor de PDF pode movê-la ou removê-la. Fixar torna o texto, as marcas e as assinaturas de Preencher e assinar parte da página. Don't ask again Não perguntar novamente diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index bae4c18a5c..0b5274603c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -91,6 +91,7 @@ Side panels Боковые панели Enable Acrobat JavaScript Включить Acrobat JavaScript Flatten Fill & Sign when saving Расплющить «Заполнить и подписать» при сохранении +# AI-assisted additions for the lock-signatures prompt (#2149) (2026-10-16); not reviewed by a native speaker. Lock your signature into the page? Закрепить подпись на странице? A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Размещённая подпись остаётся комментарием, пока не закреплена: любой с редактором PDF может её переместить или удалить. Закрепление делает текст, отметки и подписи из «Заполнить и подписать» частью страницы. Don't ask again Больше не спрашивать diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index cd08378b90..41286d281a 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2203,6 +2203,7 @@ signature pad Type Vtipkaj Document properties ({key}) Lastnosti dokumenta ({key}) Fill & Sign dates Datumi pri Izpolni in podpiši Flatten Fill & Sign when saving Splošči Izpolni in podpiši ob shranjevanju +# AI-assisted additions for the lock-signatures prompt (#2149) (2026-10-16); not reviewed by a native speaker. Lock your signature into the page? Zakleniti podpis v stran? A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Postavljen podpis je komentar, dokler ni zaklenjen: vsak z urejevalnikom PDF ga lahko premakne ali odstrani. Zaklepanje naredi besedilo, oznake in podpise iz Izpolni in podpiši del strani. Don't ask again Ne sprašuj več diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 3c7e4aa7b6..0082655ae8 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2503,6 +2503,7 @@ animation effects Off ఆఫ్ Send backward వెనుకకు పంపు Send to back అందరికంటే వెనుకకు పంపు +# AI-assisted additions for the lock-signatures prompt (#2149) (2026-10-16); not reviewed by a native speaker. Lock your signature into the page? మీ సంతకాన్ని పేజీలో లాక్ చేయాలా? A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. ఉంచిన సంతకం లాక్ చేసే వరకు ఒక వ్యాఖ్య మాత్రమే: PDF ఎడిటర్ ఉన్న ఎవరైనా దాన్ని తరలించవచ్చు లేదా తొలగించవచ్చు. లాక్ చేయడం వల్ల నింపి సంతకం చేయి వచనం, గుర్తులు, సంతకాలు పేజీలో భాగమవుతాయి. Don't ask again మళ్లీ అడగవద్దు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e62d87e160..d40032efa8 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -62,6 +62,7 @@ Side panels Yan paneller Enable Acrobat JavaScript Acrobat JavaScript'i etkinleştir Flatten Fill & Sign when saving Kaydederken Doldur ve İmzala'yı düzleştir +# AI-assisted additions for the lock-signatures prompt (#2149) (2026-10-16); not reviewed by a native speaker. Lock your signature into the page? İmzanız sayfaya sabitlensin mi? A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Yerleştirilen imza, sabitlenene kadar bir yorumdur: PDF düzenleyicisi olan herkes onu taşıyabilir veya kaldırabilir. Sabitlemek, Doldur ve İmzala metnini, işaretlerini ve imzalarını sayfanın parçası yapar. Don't ask again Bir daha sorma diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 863d1a69c1..30c8000fc8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2278,6 +2278,7 @@ calendar date December грудня Smaller pages Менші сторінки Reset page size Скинути розмір сторінок Flatten Fill & Sign when saving Звести «Заповнення й підпис» під час збереження +# AI-assisted additions for the lock-signatures prompt (#2149) (2026-10-16); not reviewed by a native speaker. Lock your signature into the page? Закріпити підпис на сторінці? A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Розміщений підпис є коментарем, доки його не закріплено: будь-хто з редактором PDF може його перемістити або видалити. Закріплення робить текст, позначки й підписи з «Заповнити й підписати» частиною сторінки. Don't ask again Більше не питати diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a3eee785a9..c5c7918373 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -144,6 +144,7 @@ Side panels 侧边面板 Enable Acrobat JavaScript 启用 Acrobat JavaScript Flatten Fill & Sign when saving 保存时拼合填写与签名 +# AI-assisted additions for the lock-signatures prompt (#2149) (2026-10-16); not reviewed by a native speaker. Lock your signature into the page? 将签名锁定到页面中? A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. 放置的签名在锁定前只是注释:任何有 PDF 编辑器的人都能移动或删除它。锁定后,“填写和签名”的文字、标记和签名会成为页面的一部分。 Don't ask again 不再询问 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 3695a90572..9ef514a3b5 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2544,6 +2544,7 @@ animation effects Off 關 Send backward 下移一層 Send to back 移到最下層 +# AI-assisted additions for the lock-signatures prompt (#2149) (2026-10-16); not reviewed by a native speaker. Lock your signature into the page? 將簽名鎖定到頁面中? A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. 放置的簽名在鎖定前只是註解:任何有 PDF 編輯器的人都能移動或移除它。鎖定後,「填寫與簽署」的文字、標記與簽名會成為頁面的一部分。 Don't ask again 不再詢問 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c847b01337..21c487d361 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -329,8 +329,8 @@ pub struct PdfCraftApp { /// Ask on the first save after Fill & Sign places a signature or initials whether to lock /// them into the page (#2149). Off once the user says not to ask again. pub ask_to_lock_signatures: bool, - /// Documents with a signature or initials placed by Fill & Sign since they were last saved - /// (or asked about). + /// Documents with a Fill & Sign signature placed since their last save, asked about on + /// saving (#2149). Not part of the recovery data: a recovered document isn't asked. pub(crate) placed_signatures: std::collections::HashSet, /// A save waiting behind [`Dialog::LockSignatures`], and its Don't ask again box. pub(crate) pending_lock: Option, diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index 54af2288f0..3c7eb8a0d8 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -1843,6 +1843,71 @@ fn a_lock_that_fails_keeps_the_question() { let _ = std::fs::remove_file(&out); } +/// #2348 review: the lock question stays out of the way where it must. Answering it on a file +/// that changed on disk leaves the changed-on-disk question showing (it was closed with the lock +/// question, so the save silently didn't happen); a signature placed and then undone asks +/// nothing; and a digitally signed document isn't asked, since locking would change it after +/// signing. +#[test] +fn the_lock_question_is_skipped_or_kept_where_it_must_be() { + let place = |h: &mut Harness<'static, PdfCraftApp>| { + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + click(h, 40.0, 300.0); + }; + // A file that changed on disk. + let path = std::env::temp_dir().join(format!("pdfcraft-lock-changed-{}.pdf", std::process::id())); + std::fs::write(&path, FIXTURE).unwrap(); + let mut h = harness_bytes(FIXTURE); + h.state_mut().close_tab(0); + h.state_mut().open_path(&path.to_string_lossy()); + for _ in 0..100 { + if !h.state().views.is_empty() { + break; + } + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.run_steps(3); + place(&mut h); + std::fs::write(&path, [FIXTURE, b"\n% rewritten elsewhere\n".as_slice()].concat()).unwrap(); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "asks first"); + h.run_steps(2); + h.get_by_label("Keep editable").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::OverwriteChanged), "the changed-on-disk question shows"); + let _ = std::fs::remove_file(&path); + + // Placed, then undone: nothing to lock, so the save just saves. + let out = std::env::temp_dir().join(format!("pdfcraft-lock-undone-{}.pdf", std::process::id())); + let mut h = harness(); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + place(&mut h); + assert!(h.state_mut().execute("edit.undo")); + h.run_steps(2); + assert!(h.state_mut().save_active(SaveTarget::InPlace), "no question after the signature was undone"); + let _ = std::fs::remove_file(&out); + + // Digitally signed: never asked. + let mut doc = { + let mut h = harness(); + let app = h.state_mut(); + let id = app.views[0].id; + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = pdfcraft_engine::sign::pkcs12::open(&p12, "test").unwrap(); + let opts = pdfcraft_engine::SignOptions { page: 0, rect: Some([20.0, 20.0, 160.0, 50.0]), ..Default::default() }; + app.session.sign(id, &digital_id, opts).expect("signs") + }; + let mut h = harness_bytes(&std::mem::take(&mut doc)); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + place(&mut h); + let id = h.state().views[0].id; + assert!(h.state().session.get(id).unwrap().is_signed()); + assert!(h.state_mut().save_active(SaveTarget::InPlace), "a signed document isn't asked"); + let _ = std::fs::remove_file(&out); +} + /// #2149: Cancel saves nothing, and Keep editable with Don't ask again turns the question off. #[test] fn cancelling_the_lock_question_saves_nothing() { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 66f21615d8..cd0c858371 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5441,7 +5441,7 @@ milestone = "M5" status = "shipped" commands = ["comment.flatten"] tools = ["doc_save", "doc_flatten"] -evidence = ["crates/edit/src/tests.rs::fill_sign_flatten_bakes_marks_keeps_other_comments_and_does_not_reuse_pcfl0", "crates/engine/src/tests.rs::flattening_fill_and_sign_keeps_other_comments_fields_and_undoes", "crates/engine/src/tests.rs::flattening_fill_and_sign_is_refused_without_permission_to_change", "crates/automation/tests/it/automation.rs::saving_with_flatten_fill_sign_bakes_marks_and_leaves_other_comments", "crates/ui-egui/tests/it/fill_sign.rs::saving_flattens_fill_and_sign_only_when_the_preference_is_on", "crates/ui-egui/tests/it/fill_sign.rs::the_first_save_after_placing_a_signature_asks_to_lock_it", "crates/ui-egui/tests/it/fill_sign.rs::cancelling_the_lock_question_saves_nothing"] +evidence = ["crates/edit/src/tests.rs::fill_sign_flatten_bakes_marks_keeps_other_comments_and_does_not_reuse_pcfl0", "crates/engine/src/tests.rs::flattening_fill_and_sign_keeps_other_comments_fields_and_undoes", "crates/engine/src/tests.rs::flattening_fill_and_sign_is_refused_without_permission_to_change", "crates/automation/tests/it/automation.rs::saving_with_flatten_fill_sign_bakes_marks_and_leaves_other_comments", "crates/ui-egui/tests/it/fill_sign.rs::saving_flattens_fill_and_sign_only_when_the_preference_is_on", "crates/ui-egui/tests/it/fill_sign.rs::the_first_save_after_placing_a_signature_asks_to_lock_it", "crates/ui-egui/tests/it/fill_sign.rs::cancelling_the_lock_question_saves_nothing", "crates/ui-egui/tests/it/fill_sign.rs::a_lock_that_fails_keeps_the_question", "crates/ui-egui/tests/it/fill_sign.rs::the_lock_question_is_skipped_or_kept_where_it_must_be"] notes = "Preferences ▸ Fill & Sign, and doc_save flatten_fill_sign, bake text, marks and signatures into the page. The option defaults off, so a normal save leaves them editable; the first save after a signature or initials is placed asks whether to lock it into the page (Lock and save / Keep editable, with Don't ask again), and the Sign menu carries the same switch (#2149). Other comments and form fields stay. Flatten comments still flattens every comment." [[feature]] From 58e28e19508d42c334c2c1b06e7cbc1103b090f4 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:33:08 +0800 Subject: [PATCH 068/101] M7: a web save forgets the placed signature too (#2149) Review follow-up on #2348: as the native save does, so a later save with the question turned back on doesn't ask about what was already saved. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/editing/save.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index 520ce47477..0ab9bd5b5d 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -110,7 +110,10 @@ impl PdfCraftApp { }; match delivered { Ok(()) => { - let _ = self.session.mark_saved(id, bytes, None); + // As the native save does: what was saved isn't asked about again (#2149). + if self.session.mark_saved(id, bytes, None).is_ok() { + self.placed_signatures.remove(&id); + } if let Some(doc) = self.session.get(id) && let Some(view) = self.views.get_mut(index) { From b92d937c9199f021362337ff8045c1d0d10e4909 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:56:12 +0800 Subject: [PATCH 069/101] M7: offer to lock a signature right after it's placed, and keep the switch in the panel (#2149) benandez's review suggestion on #2348. Right after Fill & Sign places a signature or initials, a notice says "Signature added. Others can move it until it's locked." with Lock now, which flattens the marks into the page (undoable) without saving; the save that follows then has nothing to ask about. The placed-signature mark is kept, so undoing the lock brings back the notice and the save-time question, which stays as the fallback. Dismiss hides the notice until the next signature. No notice on a digitally signed document or when saves lock anyway. The "Lock into the page when saving" switch is now once, in the panel's Sign yourself section, no longer in the Sign menu over the page. The three new strings are in all 22 catalogues (AI-assisted, not reviewed by native speakers). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 20 +++++- crates/ui-egui/src/canvas/bars.rs | 24 ++++++- crates/ui-egui/src/editing.rs | 9 +++ crates/ui-egui/src/editing/save.rs | 45 +++++++++---- crates/ui-egui/src/i18n/ar.tsv | 3 + crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/kk.tsv | 3 + crates/ui-egui/src/i18n/lt.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sl.tsv | 3 + crates/ui-egui/src/i18n/te.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/panels/tools.rs | 3 + crates/ui-egui/src/tools/fill_sign.rs | 2 - crates/ui-egui/tests/it/fill_sign.rs | 97 ++++++++++++++++++++++++++- parity/acrobat-features.toml | 4 +- 30 files changed, 249 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a220644421..ac10aec000 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -642,6 +642,9 @@ pub struct DocView { pub fill_text: Option, /// A queued Fill & Sign signature: select it after its edit succeeds, then leave placement. pub(crate) fill_signature_page: Option, + /// The "Signature added … Lock now" notice was dismissed; placing another signature shows it + /// again (#2149). + pub(crate) lock_offer_dismissed: bool, pub(crate) signature_drag: crate::signature_drag::SignatureDrag, /// What the page modules claim for the cursor this frame; `document_area` sets it once. pub(crate) cursor: crate::page_cursor::PageCursor, @@ -736,6 +739,8 @@ pub enum ViewAction { CameraLevel, /// After Enhance page: leave the tool for this command (Crop pages, Organize pages). CameraNext(&'static str), + /// Lock now, on the notice shown after a signature is placed (#2149). + LockSignatures, } /// Default page right-click menu actions. @@ -1001,6 +1006,7 @@ impl DocView { marquee_done: None, fill_text: None, fill_signature_page: None, + lock_offer_dismissed: false, signature_drag: Default::default(), cursor: Default::default(), router: Default::default(), @@ -3030,6 +3036,12 @@ fn apply_notice(app: &mut PdfCraftApp, index: usize, notice: Option) { Some(Notice::Console) => app.dialog = Some(crate::Dialog::JsConsole), // Back on in Preferences ▸ General. Some(Notice::HideSecured) => app.show_secured_notice = false, + // Handled by the app with the other view actions (`ViewAction`). + Some(Notice::LockSignatures) => { + if let Some(view) = app.views.get_mut(index) { + view.pending_action = Some(ViewAction::LockSignatures); + } + } None => {} } } @@ -3499,13 +3511,19 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { (app.left_open && app.left == crate::LeftPanel::Tool("edit")) || matches!(app.quick_tool, QuickTool::AddText | QuickTool::EditText); let text_style = app.text_style.clone(); let show_secured_notice = app.show_secured_notice; + // Offer to lock a just-placed signature (#2149) on the same terms the save-time question + // asks: one is still there, saving doesn't lock it anyway, and the document isn't signed. + let lock_offer = !app.flatten_fill_sign_on_save + && app.placed_signatures.contains(&app.views[index].id) + && doc.info.annotations.iter().any(|a| a.fill_sign) + && !doc.is_signed(); let view = &mut app.views[index]; // Opened without the owner password and something is restricted. The notice says so unless // the user turned it off (#4035): the restrictions themselves, and Document Properties ▸ // Security, stay as they are. let secured = show_secured_notice && doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); let repaired = !doc.repair_log().is_empty(); - let notice = notices(view, doc, secured, repaired, crate::sign_ui::banner(&doc.signatures), ui, &t); + let notice = notices(view, doc, secured, repaired, lock_offer, crate::sign_ui::banner(&doc.signatures), ui, &t); apply_notice(app, index, notice); let Some(doc) = app.session.get(app.views[index].id) else { return }; let info = &doc.info; diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs index 4ad2bf05db..9d488721f5 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -59,16 +59,20 @@ pub(super) enum Notice { Close, /// Stop showing the secured-document notice, in every document (#4035). HideSecured, + /// Lock the placed signatures into the page (#2149). + LockSignatures, } /// The notice bar above the pages: a file changed on disk first, then what a historical -/// snapshot is, then the signature status bar, a form's scripts having been turned -/// off, security, forms and warnings. +/// snapshot is, then the offer to lock a signature just placed, then the signature status bar, +/// a form's scripts having been turned off, security, forms and warnings. +#[allow(clippy::too_many_arguments)] pub(super) fn notices( view: &mut DocView, doc: &pdfcraft_engine::Document, secured: bool, repaired: bool, + lock_offer: bool, signed: Option<(&str, Color32, &str, String)>, ui: &mut egui::Ui, t: &Tokens, @@ -122,6 +126,22 @@ pub(super) fn notices( return Some(Notice::Close); } } + // A signature just placed with Fill & Sign is a comment others can move (#2149): offer to + // lock it into the page now. `lock_offer` is false for a digitally signed document. + if lock_offer && !view.lock_offer_dismissed { + let mut lock = false; + notice_bar(ui, t, "lock", t.accent_text, tl!("Signature added. Others can move it until it's locked.").to_string(), |ui| { + if icons::button(ui, "x", 22.0, false, tl!("Dismiss")).clicked() { + view.lock_offer_dismissed = true; + } + if crate::widgets::pill_button(ui, tl!("Lock now"), true).clicked() { + lock = true; + } + }); + if lock { + return Some(Notice::LockSignatures); + } + } if let Some((icon, color, template, arg)) = signed { let mut open = false; notice_bar(ui, t, icon, color, crate::i18n::fmt(tl!(template), &[("by", &arg)]), |ui| { diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index f753335b72..c97cf934d4 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -478,6 +478,11 @@ impl PdfCraftApp { } } Some(crate::canvas::ViewAction::CameraLevel) => self.camera_level(i), + Some(crate::canvas::ViewAction::LockSignatures) => { + if let Some(id) = self.views.get(i).map(|v| v.id) { + self.lock_signatures_now(id); + } + } Some(crate::canvas::ViewAction::CameraNext(command)) => { self.leave_camera(i); self.execute(command); @@ -681,6 +686,10 @@ impl PdfCraftApp { && let Some(id) = doc_id { self.placed_signatures.insert(id); + // A new signature shows the Lock now notice again, even if the last was dismissed. + if let Some(view) = self.views.get_mut(i) { + view.lock_offer_dismissed = false; + } } // Tab can wrap to the same field (including another widget of that field). // Its new editor was opened before this edit ran, so it still holds the old diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index 0ab9bd5b5d..131cb815e1 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -183,18 +183,8 @@ impl PdfCraftApp { /// again, and the answer isn't remembered. pub(crate) fn answer_lock_signatures(&mut self, lock: bool, remember: bool) { let Some(p) = self.pending_lock.take() else { return }; - if lock && !self.flatten_fill_sign_on_save { - let before = self.session.get(p.id).map(|d| d.edit_generation()); - if let Err(e) = self.session.apply(p.id, pdfcraft_engine::Edit::FlattenFillSign) { - self.notify_error(e); - return; - } - if before != self.session.get(p.id).map(|d| d.edit_generation()) - && let Some(doc) = self.session.get(p.id) - && let Some(view) = self.views.iter_mut().find(|v| v.id == p.id) - { - view.document_changed(&doc.info); - } + if lock && !self.flatten_fill_sign_on_save && !self.flatten_fill_sign_in(p.id) { + return; } self.placed_signatures.remove(&p.id); if remember { @@ -209,6 +199,37 @@ impl PdfCraftApp { } } + /// Lock now, on the notice shown after a signature is placed (#2149): lock document `id`'s + /// Fill & Sign marks into its pages, as the save-time question's Lock does but without + /// saving. Undoable. The next save doesn't ask, as no Fill & Sign mark is left to lock; the + /// placed-signature mark itself stays, so undoing the lock brings back both the notice and the + /// save-time question. Never on a digitally signed document. + pub(crate) fn lock_signatures_now(&mut self, id: pdfcraft_engine::DocId) { + if self.session.get(id).is_none_or(|d| d.is_signed()) { + return; + } + if self.flatten_fill_sign_in(id) { + self.notify_tr("Signature locked into the page."); + } + } + + /// Lock document `id`'s Fill & Sign marks into its pages (an undoable edit). Returns false, + /// having told the user why, when that fails. + fn flatten_fill_sign_in(&mut self, id: pdfcraft_engine::DocId) -> bool { + let before = self.session.get(id).map(|d| d.edit_generation()); + if let Err(e) = self.session.apply(id, pdfcraft_engine::Edit::FlattenFillSign) { + self.notify_error(e); + return false; + } + if before != self.session.get(id).map(|d| d.edit_generation()) + && let Some(doc) = self.session.get(id) + && let Some(view) = self.views.iter_mut().find(|v| v.id == id) + { + view.document_changed(&doc.info); + } + true + } + /// Write document `id` to `dest` and make it the document's file. Returns `true` if written. #[cfg(not(target_arch = "wasm32"))] fn save_doc_to(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget) -> bool { diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 746e20477d..bec6e3aad6 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2286,6 +2286,9 @@ calendar date December ديسمبر Keep editable إبقاؤه قابلًا للتحرير Lock into the page when saving التثبيت في الصفحة عند الحفظ Otherwise, ask on the first save after a signature is placed وإلا فاسأل عند أول حفظ بعد وضع توقيع + Signature added. Others can move it until it's locked. تمت إضافة التوقيع. يمكن للآخرين نقله إلى أن يتم تثبيته. + Lock now التثبيت الآن + Signature locked into the page. تم تثبيت التوقيع في الصفحة. Text, marks and signatures become part of the page. Other comments stay editable. النص والعلامات والتوقيعات تصبح جزءًا من الصفحة. التعليقات الأخرى تبقى قابلة للتحرير. Date format تنسيق التاريخ Fill & Sign dates تواريخ التعبئة والتوقيع diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 516fbb62c9..47abedb819 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -146,6 +146,9 @@ Keep editable Да остане редактируем Lock into the page when saving Заключване в страницата при запис Otherwise, ask on the first save after a signature is placed Иначе питай при първия запис след поставяне на подпис + Signature added. Others can move it until it's locked. Подписът е добавен. Други могат да го преместват, докато не бъде заключен. + Lock now Заключи сега + Signature locked into the page. Подписът е заключен в страницата. Text, marks and signatures become part of the page. Other comments stay editable. Текстът, маркировките и подписите стават част от страницата. Другите коментари остават редактируеми. OK OK Cancel Отказ diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 805b9c1885..61bba6af53 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2307,6 +2307,9 @@ calendar date December prosince Keep editable Ponechat upravitelné Lock into the page when saving Při ukládání zamknout do stránky Otherwise, ask on the first save after a signature is placed Jinak se zeptat při prvním uložení po umístění podpisu + Signature added. Others can move it until it's locked. Podpis byl přidán. Ostatní ho mohou přesouvat, dokud nebude zamčen. + Lock now Zamknout nyní + Signature locked into the page. Podpis byl zamčen do stránky. Text, marks and signatures become part of the page. Other comments stay editable. Text, značky a podpisy se stanou součástí stránky. Ostatní komentáře zůstanou upravitelné. Date format Formát data Fill & Sign dates Datum pro Vyplnit a podepsat diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2a4a5b4e0e..c0fcca266e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -79,6 +79,9 @@ Keep editable Bearbeitbar lassen Lock into the page when saving Beim Speichern fest in die Seite übernehmen Otherwise, ask on the first save after a signature is placed Andernfalls beim ersten Speichern nach dem Platzieren einer Unterschrift fragen + Signature added. Others can move it until it's locked. Unterschrift hinzugefügt. Andere können sie verschieben, bis sie fest übernommen ist. + Lock now Jetzt übernehmen + Signature locked into the page. Unterschrift fest in die Seite übernommen. Text, marks and signatures become part of the page. Other comments stay editable. Text, Markierungen und Unterschriften werden Teil der Seite. Andere Kommentare bleiben bearbeitbar. OK OK Join our Discord Unserem Discord beitreten diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6e031b4e4f..83bd80433d 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -294,6 +294,9 @@ Keep editable Να παραμείνει επεξεργάσιμη Lock into the page when saving Κλείδωμα στη σελίδα κατά την αποθήκευση Otherwise, ask on the first save after a signature is placed Διαφορετικά, ερώτηση στην πρώτη αποθήκευση μετά την τοποθέτηση υπογραφής + Signature added. Others can move it until it's locked. Η υπογραφή προστέθηκε. Άλλοι μπορούν να τη μετακινήσουν μέχρι να κλειδωθεί. + Lock now Κλείδωμα τώρα + Signature locked into the page. Η υπογραφή κλειδώθηκε στη σελίδα. Text, marks and signatures become part of the page. Other comments stay editable. Το κείμενο, τα σημάδια και οι υπογραφές γίνονται μέρος της σελίδας. Τα υπόλοιπα σχόλια παραμένουν επεξεργάσιμα. Current page — type a page number or label (such as iv) and press Enter Τρέχουσα σελίδα — πληκτρολογήστε αριθμό ή ετικέτα σελίδας (π.χ. iv) και πατήστε Enter Zoom — type a percentage and press Enter Ζουμ — πληκτρολογήστε ένα ποσοστό και πατήστε Enter diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 10897567ac..90670ed8c3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -102,6 +102,9 @@ Keep editable Mantener editable Lock into the page when saving Fijar en la página al guardar Otherwise, ask on the first save after a signature is placed Si no, preguntar al guardar por primera vez tras colocar una firma + Signature added. Others can move it until it's locked. Firma añadida. Otros pueden moverla hasta que se fije. + Lock now Fijar ahora + Signature locked into the page. Firma fijada en la página. Text, marks and signatures become part of the page. Other comments stay editable. El texto, las marcas y las firmas pasan a formar parte de la página. Los demás comentarios siguen siendo editables. OK Aceptar Join our Discord Únete a nuestro Discord diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 0c4572fe22..ec03bf9065 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -79,6 +79,9 @@ Keep editable Garder modifiable Lock into the page when saving Intégrer à la page à l’enregistrement Otherwise, ask on the first save after a signature is placed Sinon, demander au premier enregistrement après avoir placé une signature + Signature added. Others can move it until it's locked. Signature ajoutée. D’autres peuvent la déplacer tant qu’elle n’est pas intégrée. + Lock now Intégrer maintenant + Signature locked into the page. Signature intégrée à la page. Text, marks and signatures become part of the page. Other comments stay editable. Le texte, les marques et les signatures deviennent partie de la page. Les autres commentaires restent modifiables. OK OK Join our Discord Rejoindre notre Discord diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 7a066c9ba3..62bdec8418 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2263,6 +2263,9 @@ action wizard Start Indítás Keep editable Szerkeszthető marad Lock into the page when saving Mentéskor rögzítés az oldalba Otherwise, ask on the first save after a signature is placed Különben kérdezzen rá az aláírás elhelyezése utáni első mentéskor + Signature added. Others can move it until it's locked. Aláírás hozzáadva. Mások áthelyezhetik, amíg nincs rögzítve. + Lock now Rögzítés most + Signature locked into the page. Az aláírás rögzítve az oldalba. Text, marks and signatures become part of the page. Other comments stay editable. A szöveg, a jelek és az aláírások az oldal részévé válnak. A többi megjegyzés szerkeszthető marad. Date format Dátumformátum Fill & Sign dates Kitöltés és aláírás dátumai diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ae3ebd6693..cb4af09c53 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2223,6 +2223,9 @@ Keep editable Mantieni modificabile Lock into the page when saving Blocca nella pagina al salvataggio Otherwise, ask on the first save after a signature is placed Altrimenti, chiedi al primo salvataggio dopo aver inserito una firma + Signature added. Others can move it until it's locked. Firma aggiunta. Altri possono spostarla finché non viene bloccata. + Lock now Blocca ora + Signature locked into the page. Firma bloccata nella pagina. H / Space (hold) H / Spazio (tieni premuto) Home / End Home / Fine Inserted text Testo inserito diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ddeb261438..bb485aa589 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2278,6 +2278,9 @@ signature pad Type 入力 Keep editable 編集可能のままにする Lock into the page when saving 保存時にページに固定 Otherwise, ask on the first save after a signature is placed それ以外は、署名を配置した後の最初の保存時に確認する + Signature added. Others can move it until it's locked. 署名を追加しました。固定するまでは他の人が移動できます。 + Lock now 今すぐ固定 + Signature locked into the page. 署名をページに固定しました。 Text, marks and signatures become part of the page. Other comments stay editable. テキスト、マーク、署名がページの一部になります。その他のコメントは引き続き編集できます。 Scroll tabs left タブを左にスクロール Scroll tabs right タブを右にスクロール diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 8171e1cac4..01cd4375f2 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2195,6 +2195,9 @@ calendar date December Желтоқсан Keep editable Өңделетін күйде қалдыру Lock into the page when saving Сақтағанда бетке бекіту Otherwise, ask on the first save after a signature is placed Әйтпесе, қолтаңба қойылғаннан кейінгі алғашқы сақтауда сұрау + Signature added. Others can move it until it's locked. Қолтаңба қосылды. Ол бекітілгенше басқалар оны жылжыта алады. + Lock now Қазір бекіту + Signature locked into the page. Қолтаңба бетке бекітілді. Text, marks and signatures become part of the page. Other comments stay editable. Мәтін, белгілер мен қолтаңбалар беттің бөлігіне айналады. Басқа түсініктемелер өңделетін күйде қалады. Date format Күн пішімі Fill & Sign dates «Толтыру және қол қою» күндері diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index d93109274e..20fa9230c7 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -598,6 +598,9 @@ Keep editable Palikti redaguojamą Lock into the page when saving Įrašant įtvirtinti puslapyje Otherwise, ask on the first save after a signature is placed Kitaip klausti pirmą kartą įrašant po parašo padėjimo + Signature added. Others can move it until it's locked. Parašas pridėtas. Kiti gali jį perkelti, kol jis neįtvirtintas. + Lock now Įtvirtinti dabar + Signature locked into the page. Parašas įtvirtintas puslapyje. Flip Horizontal Apversti horizontaliai Flip Vertical Apversti vertikaliai Flip horizontal Apversti horizontaliai diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f071c3cda3..9a7a5f0089 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2257,6 +2257,9 @@ signature pad Type Wpisz Keep editable Zachowaj edytowalny Lock into the page when saving Utrwalaj na stronie przy zapisie Otherwise, ask on the first save after a signature is placed W przeciwnym razie pytaj przy pierwszym zapisie po umieszczeniu podpisu + Signature added. Others can move it until it's locked. Dodano podpis. Inni mogą go przesunąć, dopóki nie zostanie utrwalony. + Lock now Utrwal teraz + Signature locked into the page. Podpis utrwalono na stronie. Text, marks and signatures become part of the page. Other comments stay editable. Tekst, oznaczenia i podpisy stają się częścią strony. Pozostałe komentarze pozostają edytowalne. Scroll tabs left Przewiń karty w lewo Scroll tabs right Przewiń karty w prawo diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index eae76f0a35..5f143e88b7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2216,6 +2216,9 @@ action wizard Start Iniciar Keep editable Manter editável Lock into the page when saving Fixar na página ao salvar Otherwise, ask on the first save after a signature is placed Caso contrário, perguntar ao salvar pela primeira vez após colocar uma assinatura + Signature added. Others can move it until it's locked. Assinatura adicionada. Outras pessoas podem movê-la até que seja fixada. + Lock now Fixar agora + Signature locked into the page. Assinatura fixada na página. H / Space (hold) H / Espaço (manter pressionado) Home / End Home / End Inserted text Texto inserido diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0b5274603c..0545896c55 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -99,6 +99,9 @@ Keep editable Оставить редактируемой Lock into the page when saving Закреплять на странице при сохранении Otherwise, ask on the first save after a signature is placed Иначе спрашивать при первом сохранении после размещения подписи + Signature added. Others can move it until it's locked. Подпись добавлена. Другие могут перемещать её, пока она не закреплена. + Lock now Закрепить сейчас + Signature locked into the page. Подпись закреплена на странице. Text, marks and signatures become part of the page. Other comments stay editable. Текст, пометки и подписи становятся частью страницы. Остальные комментарии можно по-прежнему править. OK OK Join our Discord Присоединяйтесь к нашему Discord diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 41286d281a..c938b17fbc 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2211,6 +2211,9 @@ signature pad Type Vtipkaj Keep editable Ohrani urejljivo Lock into the page when saving Ob shranjevanju zakleni v stran Otherwise, ask on the first save after a signature is placed Sicer vprašaj ob prvem shranjevanju po postavitvi podpisa + Signature added. Others can move it until it's locked. Podpis je dodan. Drugi ga lahko premaknejo, dokler ni zaklenjen. + Lock now Zakleni zdaj + Signature locked into the page. Podpis je zaklenjen v stran. Font size 0 uses automatic sizing. Velikost pisave 0 pomeni samodejno velikost. Import list… Uvozi seznam… Larger pages Večje strani diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 0082655ae8..4d08bfa192 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2511,6 +2511,9 @@ animation effects Off ఆఫ్ Keep editable సవరించగలిగేలా ఉంచు Lock into the page when saving సేవ్ చేసేటప్పుడు పేజీలో లాక్ చేయి Otherwise, ask on the first save after a signature is placed లేకపోతే, సంతకం ఉంచిన తర్వాత మొదటి సేవ్‌లో అడుగు + Signature added. Others can move it until it's locked. సంతకం జోడించబడింది. లాక్ చేసే వరకు ఇతరులు దానిని తరలించగలరు. + Lock now ఇప్పుడే లాక్ చేయి + Signature locked into the page. సంతకం పేజీలో లాక్ చేయబడింది. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today ఈరోజు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d40032efa8..20ab89ee2b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -70,6 +70,9 @@ Keep editable Düzenlenebilir bırak Lock into the page when saving Kaydederken sayfaya sabitle Otherwise, ask on the first save after a signature is placed Aksi halde, imza yerleştirildikten sonraki ilk kayıtta sor + Signature added. Others can move it until it's locked. İmza eklendi. Sabitlenene kadar başkaları onu taşıyabilir. + Lock now Şimdi sabitle + Signature locked into the page. İmza sayfaya sabitlendi. Text, marks and signatures become part of the page. Other comments stay editable. Metin, işaretler ve imzalar sayfanın bir parçası olur. Diğer yorumlar düzenlenebilir kalır. OK Tamam Join our Discord Discord'umuza katılın diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 30c8000fc8..3c181c5b2b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2286,6 +2286,9 @@ calendar date December грудня Keep editable Залишити редагованим Lock into the page when saving Закріплювати на сторінці під час збереження Otherwise, ask on the first save after a signature is placed Інакше питати під час першого збереження після розміщення підпису + Signature added. Others can move it until it's locked. Підпис додано. Інші можуть переміщувати його, доки його не закріплено. + Lock now Закріпити зараз + Signature locked into the page. Підпис закріплено на сторінці. Text, marks and signatures become part of the page. Other comments stay editable. Текст, позначки й підписи стають частиною сторінки. Інші коментарі залишаються редагованими. Date format Формат дати Fill & Sign dates Дати заповнення й підпису diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 2275b4b78a..095c6e8832 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2894,3 +2894,6 @@ key Space Space Lock into the page when saving Khóa vào trang khi lưu Lock your signature into the page? Khóa chữ ký của bạn vào trang? Otherwise, ask on the first save after a signature is placed Nếu không, hỏi ở lần lưu đầu tiên sau khi đặt chữ ký + Signature added. Others can move it until it's locked. Đã thêm chữ ký. Người khác có thể di chuyển nó cho đến khi được khóa. + Lock now Khóa ngay + Signature locked into the page. Đã khóa chữ ký vào trang. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c5c7918373..9959eb8a7e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -152,6 +152,9 @@ Keep editable 保持可编辑 Lock into the page when saving 保存时锁定到页面 Otherwise, ask on the first save after a signature is placed 否则,在放置签名后首次保存时询问 + Signature added. Others can move it until it's locked. 已添加签名。锁定之前,其他人可以移动它。 + Lock now 立即锁定 + Signature locked into the page. 签名已锁定到页面。 Text, marks and signatures become part of the page. Other comments stay editable. 文字、标记和签名会成为页面内容。其他注释仍可编辑。 OK 确定 Cancel 取消 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9ef514a3b5..4f5a08dcbd 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2552,6 +2552,9 @@ animation effects Off 關 Keep editable 保持可編輯 Lock into the page when saving 儲存時鎖定到頁面 Otherwise, ask on the first save after a signature is placed 否則,在放置簽名後第一次儲存時詢問 + Signature added. Others can move it until it's locked. 已加入簽名。鎖定之前,其他人可以移動它。 + Lock now 立即鎖定 + Signature locked into the page. 簽名已鎖定到頁面。 # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 今天 diff --git a/crates/ui-egui/src/panels/tools.rs b/crates/ui-egui/src/panels/tools.rs index 73326fe571..6ab469e123 100644 --- a/crates/ui-egui/src/panels/tools.rs +++ b/crates/ui-egui/src/panels/tools.rs @@ -291,6 +291,9 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static if let Some(cmd) = crate::fill_sign::signature_entries(ui, app, t) { run = Some(cmd); } + // Where the signing happens, not only in Preferences, and once: not also in the Sign + // menu over the page, where it was easy to miss (#2149). + ui.add(widgets::checkbox(&mut app.flatten_fill_sign_on_save, tl!("Lock into the page when saving"))); } }); if footer { diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index 225faca601..045bb64974 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -398,8 +398,6 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp, }); ui.add_space(4.0); } - // Where the signing happens, not only in Preferences (#2149). - ui.add(crate::widgets::checkbox(&mut app.flatten_fill_sign_on_save, tl!("Lock into the page when saving"))); command } diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index 3c7eb8a0d8..733fed5bde 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -1754,7 +1754,8 @@ fn photos_and_images_dropped_on_the_signature_pad_are_imported_not_opened() { /// #2149: the first save after Fill & Sign places a signature asks whether to lock it into the /// page. Lock and save flattens it; Keep editable keeps it a comment; Don't ask again becomes -/// the preference; the Sign menu has the switch too. +/// the preference; the Fill & Sign panel has the switch too, once, and the Sign menu no longer +/// does (review on #2348). #[test] fn the_first_save_after_placing_a_signature_asks_to_lock_it() { let out = std::env::temp_dir().join(format!("pdfcraft-lock-signature-{}.pdf", std::process::id())); @@ -1790,18 +1791,110 @@ fn the_first_save_after_placing_a_signature_asks_to_lock_it() { assert_eq!(signed(&h), 0, "locked into the page"); assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty); assert!(h.state().flatten_fill_sign_on_save, "remembered as the preference"); - // The Sign menu shows the same switch. + // The switch is in the panel's Sign yourself section, once; the Sign menu over the page, + // where it was easy to miss, no longer has it. h.state_mut().flatten_fill_sign_on_save = false; h.get_by_label("Fill & Sign").click(); h.run_steps(2); h.get_by_label("Fill & Sign").click(); h.run_steps(2); + h.get_by_label("Use signature"); + assert!(h.query_by_label("Lock into the page when saving").is_none(), "not in the Sign menu"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.query_by_label("Lock into the page when saving").is_none(), "the panel is closed"); + h.state_mut().left = pdfcraft_ui_egui::LeftPanel::Tool("fill_sign"); + h.state_mut().left_open = true; + h.run_steps(3); + h.get_by_label("Use signature"); + assert_eq!(h.query_all_by_label("Lock into the page when saving").count(), 1, "once, in the panel"); h.get_by_label("Lock into the page when saving").click(); h.run_steps(2); assert!(h.state().flatten_fill_sign_on_save); let _ = std::fs::remove_file(&out); } +/// #2348 review (benandez): right after a signature is placed, a notice offers to lock it into +/// the page now. Lock now flattens it (undoable) without saving, so the save that follows doesn't +/// ask; undoing the lock brings back both the notice and the question. Dismiss hides the notice +/// until the next signature. A digitally signed document, or one whose saves lock anyway, gets no +/// notice. +#[test] +fn placing_a_signature_offers_to_lock_it_now() { + const NOTICE: &str = "Signature added. Others can move it until it's locked."; + let out = std::env::temp_dir().join(format!("pdfcraft-lock-now-{}.pdf", std::process::id())); + let place = |h: &mut Harness<'static, PdfCraftApp>, y: f32| { + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + click(h, 40.0, y); + h.run_steps(2); + }; + let marks = |h: &Harness<'static, PdfCraftApp>| { + h.state().session.get(h.state().views[0].id).unwrap().info.annotations.iter().filter(|a| a.fill_sign).count() + }; + let mut h = harness(); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + assert!(h.query_by_label(NOTICE).is_none(), "nothing placed yet"); + place(&mut h, 300.0); + h.get_by_label(NOTICE); + h.get_by_label("Lock now").click(); + h.run_steps(3); + assert_eq!(marks(&h), 0, "locked into the page"); + assert!(h.query_by_label(NOTICE).is_none(), "nothing left to lock"); + assert!(h.state().session.get(h.state().views[0].id).unwrap().dirty, "locked, not saved"); + // Undo brings the movable signature back, with the notice and the save-time question. + assert!(h.state_mut().execute("edit.undo")); + h.run_steps(3); + assert_eq!(marks(&h), 1); + h.get_by_label(NOTICE); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "asks again after undoing the lock"); + h.run_steps(2); + h.get_by_label("Cancel").click(); + h.run_steps(2); + // Locked again, the save doesn't ask. + h.get_by_label("Lock now").click(); + h.run_steps(3); + assert!(h.state_mut().save_active(SaveTarget::InPlace), "already locked: no question"); + // Dismiss hides it until the next signature. + place(&mut h, 200.0); + h.get_by_label(NOTICE); + h.get_by_label("Dismiss").click(); + h.run_steps(2); + assert!(h.query_by_label(NOTICE).is_none(), "dismissed"); + place(&mut h, 100.0); + h.get_by_label(NOTICE); + // Once saved, a signature kept as a comment is no longer just placed: no notice. + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "asks first"); + h.run_steps(2); + h.get_by_label("Keep editable").click(); + h.run_steps(3); + assert!(marks(&h) > 0, "still a comment"); + assert!(h.query_by_label(NOTICE).is_none(), "saved: nothing just placed"); + place(&mut h, 50.0); + h.get_by_label(NOTICE); + // Saves that lock anyway need no offer. + h.state_mut().flatten_fill_sign_on_save = true; + h.run_steps(2); + assert!(h.query_by_label(NOTICE).is_none(), "saving locks it anyway"); + let _ = std::fs::remove_file(&out); + + // Digitally signed: no notice, as locking would change the document after signing. + let mut doc = { + let mut h = harness(); + let app = h.state_mut(); + let id = app.views[0].id; + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = pdfcraft_engine::sign::pkcs12::open(&p12, "test").unwrap(); + let opts = pdfcraft_engine::SignOptions { page: 0, rect: Some([20.0, 20.0, 160.0, 50.0]), ..Default::default() }; + app.session.sign(id, &digital_id, opts).expect("signs") + }; + let mut h = harness_bytes(&std::mem::take(&mut doc)); + place(&mut h, 300.0); + assert_eq!(marks(&h), 1); + assert!(h.query_by_label(NOTICE).is_none(), "a signed document isn't offered the lock"); +} + /// #2348 review: when locking the signature fails (here the document's security allows comments /// but not changes), nothing is lost: the next save asks again, and "Don't ask again" isn't /// remembered. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cd0c858371..bec5d3568f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5441,8 +5441,8 @@ milestone = "M5" status = "shipped" commands = ["comment.flatten"] tools = ["doc_save", "doc_flatten"] -evidence = ["crates/edit/src/tests.rs::fill_sign_flatten_bakes_marks_keeps_other_comments_and_does_not_reuse_pcfl0", "crates/engine/src/tests.rs::flattening_fill_and_sign_keeps_other_comments_fields_and_undoes", "crates/engine/src/tests.rs::flattening_fill_and_sign_is_refused_without_permission_to_change", "crates/automation/tests/it/automation.rs::saving_with_flatten_fill_sign_bakes_marks_and_leaves_other_comments", "crates/ui-egui/tests/it/fill_sign.rs::saving_flattens_fill_and_sign_only_when_the_preference_is_on", "crates/ui-egui/tests/it/fill_sign.rs::the_first_save_after_placing_a_signature_asks_to_lock_it", "crates/ui-egui/tests/it/fill_sign.rs::cancelling_the_lock_question_saves_nothing", "crates/ui-egui/tests/it/fill_sign.rs::a_lock_that_fails_keeps_the_question", "crates/ui-egui/tests/it/fill_sign.rs::the_lock_question_is_skipped_or_kept_where_it_must_be"] -notes = "Preferences ▸ Fill & Sign, and doc_save flatten_fill_sign, bake text, marks and signatures into the page. The option defaults off, so a normal save leaves them editable; the first save after a signature or initials is placed asks whether to lock it into the page (Lock and save / Keep editable, with Don't ask again), and the Sign menu carries the same switch (#2149). Other comments and form fields stay. Flatten comments still flattens every comment." +evidence = ["crates/edit/src/tests.rs::fill_sign_flatten_bakes_marks_keeps_other_comments_and_does_not_reuse_pcfl0", "crates/engine/src/tests.rs::flattening_fill_and_sign_keeps_other_comments_fields_and_undoes", "crates/engine/src/tests.rs::flattening_fill_and_sign_is_refused_without_permission_to_change", "crates/automation/tests/it/automation.rs::saving_with_flatten_fill_sign_bakes_marks_and_leaves_other_comments", "crates/ui-egui/tests/it/fill_sign.rs::saving_flattens_fill_and_sign_only_when_the_preference_is_on", "crates/ui-egui/tests/it/fill_sign.rs::placing_a_signature_offers_to_lock_it_now", "crates/ui-egui/tests/it/fill_sign.rs::the_first_save_after_placing_a_signature_asks_to_lock_it", "crates/ui-egui/tests/it/fill_sign.rs::cancelling_the_lock_question_saves_nothing", "crates/ui-egui/tests/it/fill_sign.rs::a_lock_that_fails_keeps_the_question", "crates/ui-egui/tests/it/fill_sign.rs::the_lock_question_is_skipped_or_kept_where_it_must_be"] +notes = "Preferences ▸ Fill & Sign, and doc_save flatten_fill_sign, bake text, marks and signatures into the page. The option defaults off, so a normal save leaves them editable; right after a signature or initials is placed, a notice offers Lock now (undoable, no save), and the first save after one is placed asks whether to lock it into the page (Lock and save / Keep editable, with Don't ask again); the Fill & Sign panel's Sign yourself section carries the same switch (#2149). Not on a digitally signed document. Other comments and form fields stay. Flatten comments still flattens every comment." [[feature]] id = "form.autofill-profile" From 78e15fc4cf32e4d08b138df51d5f228283822195 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:28:12 +0800 Subject: [PATCH 070/101] i18n: translate the signature-lock prompts into the newer catalogues (#2149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalan, Sorani, Finnish, Hindi, Indonesian, Korean, Lao, Norwegian Bokmål, Romanian, Albanian and Thai joined main after this branch was written; their coverage tests ask for these strings too. AI-assisted, as noted in each catalogue. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ca.tsv | 10 ++++++++++ crates/ui-egui/src/i18n/ckb.tsv | 10 ++++++++++ crates/ui-egui/src/i18n/fi.tsv | 10 ++++++++++ crates/ui-egui/src/i18n/hi.tsv | 10 ++++++++++ crates/ui-egui/src/i18n/id.tsv | 10 ++++++++++ crates/ui-egui/src/i18n/ko.tsv | 10 ++++++++++ crates/ui-egui/src/i18n/lo.tsv | 10 ++++++++++ crates/ui-egui/src/i18n/nb.tsv | 10 ++++++++++ crates/ui-egui/src/i18n/ro.tsv | 10 ++++++++++ crates/ui-egui/src/i18n/sq.tsv | 10 ++++++++++ crates/ui-egui/src/i18n/th.tsv | 10 ++++++++++ 11 files changed, 110 insertions(+) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 7ff0f83fee..aff4f22037 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2846,6 +2846,16 @@ theme Appearance Aspecte Unpin tool Desenganxa l'eina Arrange image Organitza la imatge Crop Escapça + Lock your signature into the page? Voleu fixar la signatura a la pàgina? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Una signatura col·locada és un comentari fins que es fixa: qualsevol persona amb un editor de PDF la pot moure o eliminar. En fixar-la, el text, les marques i les signatures d'Omple i signa passen a formar part de la pàgina. + Lock and save Fixa i desa + Keep editable Mantén editable + Don't ask again No ho tornis a preguntar + Lock into the page when saving Fixa a la pàgina en desar + Otherwise, ask on the first save after a signature is placed Si no, pregunta en el primer desament després de col·locar una signatura + Signature added. Others can move it until it's locked. S'ha afegit la signatura. Altres persones la poden moure fins que es fixi. + Lock now Fixa ara + Signature locked into the page. Signatura fixada a la pàgina. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Avui diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index 3b6a7671ed..28c37782a5 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -2754,6 +2754,16 @@ certificate Version وەشان No tools match your search. هیچ ئامرازێک لەگەڵ گەڕانەکەت ناگونجێت. Expand all tools فراوانکردنی هەموو ئامرازەکان Collapse all tools کۆکردنەوەی هەموو ئامرازەکان + Lock your signature into the page? واژووەکەت لە لاپەڕەکەدا جێگیر بکرێت؟ + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. واژوویەکی دانراو تا جێگیر نەکرێت لێدوانێکە: هەر کەسێک دەستکاریکەرێکی PDFی هەبێت دەتوانێت بیجووڵێنێت یان لایبەرێت. جێگیرکردن دەقەکان، نیشانەکان و واژووەکانی پڕکردنەوە و واژووکردن دەکاتە بەشێک لە لاپەڕەکە. + Lock and save جێگیرکردن و پاشەکەوتکردن + Keep editable با دەستکاریکراو بمێنێتەوە + Don't ask again دووبارە مەپرسە + Lock into the page when saving لە کاتی پاشەکەوتکردندا لە لاپەڕەکەدا جێگیر بکە + Otherwise, ask on the first save after a signature is placed ئەگەر نا، لە یەکەم پاشەکەوتکردن دوای دانانی واژوویەک بپرسە + Signature added. Others can move it until it's locked. واژوو زیادکرا. کەسانی تر دەتوانن بیجووڵێنن تا جێگیر دەکرێت. + Lock now ئێستا جێگیر بکە + Signature locked into the page. واژوو لە لاپەڕەکەدا جێگیرکرا. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today ئەمڕۆ diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 8c1a9baab9..cfd2add95e 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2856,6 +2856,16 @@ theme Appearance Teema Unpin tool Irrota työkalu Arrange image Järjestä kuva Crop Rajaa + Lock your signature into the page? Lukitaanko allekirjoitus sivuun? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Sijoitettu allekirjoitus on kommentti, kunnes se lukitaan: kuka tahansa PDF-muokkaimen käyttäjä voi siirtää tai poistaa sen. Lukitseminen tekee Täytä ja allekirjoita -tekstistä, -merkinnöistä ja -allekirjoituksista osan sivua. + Lock and save Lukitse ja tallenna + Keep editable Pidä muokattavana + Don't ask again Älä kysy uudelleen + Lock into the page when saving Lukitse sivuun tallennettaessa + Otherwise, ask on the first save after a signature is placed Muuten kysy ensimmäisellä tallennuskerralla allekirjoituksen sijoittamisen jälkeen + Signature added. Others can move it until it's locked. Allekirjoitus lisätty. Muut voivat siirtää sitä, kunnes se lukitaan. + Lock now Lukitse nyt + Signature locked into the page. Allekirjoitus lukittu sivuun. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Tänään diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 52c5450bb3..a3f4765491 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2816,6 +2816,16 @@ theme Appearance स्वरूप Remove crop क्रॉप हटाएँ Send backward पीछे भेजें Send to back सबसे पीछे भेजें + Lock your signature into the page? अपने हस्ताक्षर को पृष्ठ में लॉक करें? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. रखा गया हस्ताक्षर तब तक एक टिप्पणी है जब तक उसे लॉक न किया जाए: PDF संपादक वाला कोई भी व्यक्ति उसे हटा या खिसका सकता है। लॉक करने से आपके भरें व हस्ताक्षर करें के टेक्स्ट, चिह्न और हस्ताक्षर पृष्ठ का हिस्सा बन जाते हैं। + Lock and save लॉक करें और सहेजें + Keep editable संपादन योग्य रखें + Don't ask again फिर से न पूछें + Lock into the page when saving सहेजते समय पृष्ठ में लॉक करें + Otherwise, ask on the first save after a signature is placed अन्यथा, हस्ताक्षर रखने के बाद पहली बार सहेजने पर पूछें + Signature added. Others can move it until it's locked. हस्ताक्षर जोड़ा गया। लॉक होने तक दूसरे इसे खिसका सकते हैं। + Lock now अभी लॉक करें + Signature locked into the page. हस्ताक्षर पृष्ठ में लॉक कर दिया गया। # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today आज diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 89152f62a4..d9d5395ae9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2817,6 +2817,16 @@ theme Appearance Tampilan Remove crop Hapus pangkasan Send backward Mundur Selangkah Send to back Kirim ke Belakang + Lock your signature into the page? Kunci tanda tangan Anda ke halaman? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Tanda tangan yang ditempatkan adalah komentar sampai dikunci: siapa pun yang memiliki editor PDF dapat memindahkan atau menghapusnya. Mengunci menjadikan teks, tanda, dan tanda tangan Isi & Tanda Tangani Anda bagian dari halaman. + Lock and save Kunci dan simpan + Keep editable Biarkan dapat diedit + Don't ask again Jangan tanya lagi + Lock into the page when saving Kunci ke halaman saat menyimpan + Otherwise, ask on the first save after a signature is placed Jika tidak, tanyakan saat penyimpanan pertama setelah tanda tangan ditempatkan + Signature added. Others can move it until it's locked. Tanda tangan ditambahkan. Orang lain dapat memindahkannya sampai dikunci. + Lock now Kunci sekarang + Signature locked into the page. Tanda tangan dikunci ke halaman. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Hari ini diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 20315b5fcf..de83b8b64b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2816,6 +2816,16 @@ theme Appearance 모양 Remove crop 자르기 제거 Send backward 뒤로 보내기 Send to back 맨 뒤로 보내기 + Lock your signature into the page? 서명을 페이지에 고정하시겠습니까? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. 배치한 서명은 고정하기 전까지 주석입니다. PDF 편집기가 있는 사람은 누구나 이를 이동하거나 제거할 수 있습니다. 고정하면 채우기 및 서명의 텍스트, 표시 및 서명이 페이지의 일부가 됩니다. + Lock and save 고정 및 저장 + Keep editable 편집 가능하게 유지 + Don't ask again 다시 묻지 않음 + Lock into the page when saving 저장할 때 페이지에 고정 + Otherwise, ask on the first save after a signature is placed 그렇지 않으면 서명을 배치한 후 처음 저장할 때 묻기 + Signature added. Others can move it until it's locked. 서명이 추가되었습니다. 고정하기 전까지 다른 사람이 이동할 수 있습니다. + Lock now 지금 고정 + Signature locked into the page. 서명이 페이지에 고정되었습니다. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 오늘 diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index ea684f6d59..05e33773ee 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -2470,6 +2470,16 @@ animation effects Off ປິດ No tools match your search. ບໍ່ມີເຄື່ອງມືທີ່ກົງກັບການຄົ້ນຫາຂອງທ່ານ. Expand all tools ຂະຫຍາຍເຄື່ອງມືທັງໝົດ Collapse all tools ຫຍໍ້ເຄື່ອງມືທັງໝົດເຂົ້າ + Lock your signature into the page? ລັອກລາຍເຊັນຂອງທ່ານເຂົ້າໃນໜ້າບໍ່? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. ລາຍເຊັນທີ່ວາງໄວ້ເປັນຄຳເຫັນຈົນກວ່າຈະຖືກລັອກ: ທຸກຄົນທີ່ມີໂປຣແກຣມແກ້ໄຂ PDF ສາມາດຍ້າຍ ຫຼື ລຶບມັນອອກໄດ້. ການລັອກຈະເຮັດໃຫ້ຂໍ້ຄວາມ, ເຄື່ອງໝາຍ ແລະ ລາຍເຊັນຂອງ ຕື່ມຂໍ້ມູນ & ເຊັນ ກາຍເປັນສ່ວນໜຶ່ງຂອງໜ້າ. + Lock and save ລັອກ ແລະ ບັນທຶກ + Keep editable ໃຫ້ແກ້ໄຂໄດ້ຢູ່ + Don't ask again ບໍ່ຕ້ອງຖາມອີກ + Lock into the page when saving ລັອກເຂົ້າໃນໜ້າເມື່ອບັນທຶກ + Otherwise, ask on the first save after a signature is placed ຖ້າບໍ່ດັ່ງນັ້ນ, ຖາມເມື່ອບັນທຶກຄັ້ງທຳອິດຫຼັງຈາກວາງລາຍເຊັນ + Signature added. Others can move it until it's locked. ເພີ່ມລາຍເຊັນແລ້ວ. ຄົນອື່ນສາມາດຍ້າຍມັນໄດ້ຈົນກວ່າຈະຖືກລັອກ. + Lock now ລັອກດຽວນີ້ + Signature locked into the page. ລັອກລາຍເຊັນເຂົ້າໃນໜ້າແລ້ວ. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today ມື້ນີ້ diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index b54b3e3526..73b79fc4aa 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -2735,6 +2735,16 @@ key Space Mellomrom Remove crop Fjern beskjæring Send backward Flytt bakover Send to back Plasser lengst bak + Lock your signature into the page? Vil du låse signaturen inn i siden? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. En plassert signatur er en kommentar til den låses: alle med et PDF-redigeringsprogram kan flytte eller fjerne den. Låsing gjør teksten, merkene og signaturene fra Fyll ut og signer til en del av siden. + Lock and save Lås og lagre + Keep editable Behold redigerbar + Don't ask again Ikke spør igjen + Lock into the page when saving Lås inn i siden ved lagring + Otherwise, ask on the first save after a signature is placed Ellers spør ved første lagring etter at en signatur er plassert + Signature added. Others can move it until it's locked. Signatur lagt til. Andre kan flytte den til den er låst. + Lock now Lås nå + Signature locked into the page. Signaturen er låst inn i siden. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today I dag diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 0d0fbf16b4..8740b22401 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2846,6 +2846,16 @@ theme Appearance Aspect Unpin tool Anulează fixarea instrumentului Arrange image Aranjează imaginea Crop Decupare + Lock your signature into the page? Blocați semnătura în pagină? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. O semnătură plasată este un comentariu până când este blocată: oricine are un editor PDF o poate muta sau elimina. Blocarea face ca textul, marcajele și semnăturile din Completare și semnare să devină parte a paginii. + Lock and save Blocare și salvare + Keep editable Păstrare editabilă + Don't ask again Nu mai întreba + Lock into the page when saving Blocare în pagină la salvare + Otherwise, ask on the first save after a signature is placed Altfel, întreabă la prima salvare după plasarea unei semnături + Signature added. Others can move it until it's locked. Semnătură adăugată. Alții o pot muta până când este blocată. + Lock now Blocare acum + Signature locked into the page. Semnătura a fost blocată în pagină. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Astăzi diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index eb9eed3a0e..6936db86d3 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2571,6 +2571,16 @@ animation effects Off Joaktiv Remove crop Hiq prerjen Send backward Kalo prapa Send to back Dërgo mbrapa + Lock your signature into the page? Të kyçet nënshkrimi në faqe? + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. Një nënshkrim i vendosur është koment derisa të kyçet: kushdo me një redaktues PDF mund ta zhvendosë ose ta heqë. Kyçja i bën tekstin, shenjat dhe nënshkrimet e Plotëso dhe nënshkruaj pjesë të faqes. + Lock and save Kyç dhe ruaj + Keep editable Mbaje të redaktueshëm + Don't ask again Mos pyet më + Lock into the page when saving Kyç në faqe gjatë ruajtjes + Otherwise, ask on the first save after a signature is placed Përndryshe, pyet në ruajtjen e parë pasi të vendoset një nënshkrim + Signature added. Others can move it until it's locked. Nënshkrimi u shtua. Të tjerët mund ta zhvendosin derisa të kyçet. + Lock now Kyç tani + Signature locked into the page. Nënshkrimi u kyç në faqe. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Sot diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index f7f8a65c5c..1bf9d79c3a 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -2722,6 +2722,16 @@ theme Appearance ลักษณะที่ปรากฏ Remove crop เอาการครอบตัดออก Send backward ส่งไปข้างหลัง Send to back ส่งไปไว้ข้างหลังสุด + Lock your signature into the page? ล็อกลายเซ็นของคุณเข้ากับหน้าหรือไม่ + A placed signature is a comment until it is locked: anyone with a PDF editor can move or remove it. Locking makes your Fill & Sign text, marks and signatures part of the page. ลายเซ็นที่วางไว้เป็นความคิดเห็นจนกว่าจะล็อก: ใครก็ตามที่มีโปรแกรมแก้ไข PDF สามารถย้ายหรือเอาออกได้ การล็อกจะทำให้ข้อความ เครื่องหมาย และลายเซ็นจากกรอกและเซ็นกลายเป็นส่วนหนึ่งของหน้า + Lock and save ล็อกและบันทึก + Keep editable คงไว้ให้แก้ไขได้ + Don't ask again ไม่ต้องถามอีก + Lock into the page when saving ล็อกเข้ากับหน้าเมื่อบันทึก + Otherwise, ask on the first save after a signature is placed หรือถามเมื่อบันทึกครั้งแรกหลังจากวางลายเซ็น + Signature added. Others can move it until it's locked. เพิ่มลายเซ็นแล้ว ผู้อื่นสามารถย้ายได้จนกว่าจะล็อก + Lock now ล็อกเดี๋ยวนี้ + Signature locked into the page. ล็อกลายเซ็นเข้ากับหน้าแล้ว # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today วันนี้ From a06ff3769f80c22b8102d23b09850908fca87034 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:00:03 +0800 Subject: [PATCH 071/101] M7: locking a signature clears the comment selection (#2149) Review follow-up on #2348: flatten_fill_sign_in refreshed the view after Edit::FlattenFillSign but kept view.comments.selected. The placed signature is usually the selected comment, and once it is part of the page its (page, index) names nothing, or another comment after the indices shift. Both Lock now and the save-time Lock and save go through flatten_fill_sign_in, so both clear it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/editing/save.rs | 3 +++ crates/ui-egui/tests/it/fill_sign.rs | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index 131cb815e1..adf0c96f35 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -226,6 +226,9 @@ impl PdfCraftApp { && let Some(view) = self.views.iter_mut().find(|v| v.id == id) { view.document_changed(&doc.info); + // The marks left the comments: a selected one is gone, and its (page, index) could + // now name another comment. + view.comments.selected = None; } true } diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index 733fed5bde..4498f7cd16 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -1895,6 +1895,22 @@ fn placing_a_signature_offers_to_lock_it_now() { assert!(h.query_by_label(NOTICE).is_none(), "a signed document isn't offered the lock"); } +/// #2348 review: locking takes the placed signature out of the comments, so its selection goes +/// too; a stale (page, index) would point at nothing, or at another comment. +#[test] +fn locking_a_signature_clears_the_comment_selection() { + let mut h = harness(); + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + click(&mut h, 40.0, 300.0); + h.run_steps(2); + assert!(h.state().views[0].comments.selected.is_some(), "the placed signature is selected"); + h.get_by_label("Lock now").click(); + h.run_steps(3); + assert!(h.state().views[0].comments.selected.is_none(), "nothing left to select"); +} + /// #2348 review: when locking the signature fails (here the document's security allows comments /// but not changes), nothing is lost: the next save asks again, and "Don't ask again" isn't /// remembered. From c7404f44bc6b5db832ae656db917be130841c4e2 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:41:35 +0800 Subject: [PATCH 072/101] M7: reverting forgets a signature placed since the last save (#2149) File > Revert discards a signature placed since the last save, but its marker stayed, so the next save asked to lock a signature that was gone whenever the saved file held an earlier Fill & Sign mark. A successful revert now clears the marker. Review on #2348 (CodeRabbit). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/editing/close.rs | 3 +++ crates/ui-egui/tests/it/fill_sign.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/crates/ui-egui/src/editing/close.rs b/crates/ui-egui/src/editing/close.rs index 55d884cd9a..53f5b04eec 100644 --- a/crates/ui-egui/src/editing/close.rs +++ b/crates/ui-egui/src/editing/close.rs @@ -55,6 +55,9 @@ impl PdfCraftApp { view.forms.unbaked.clear(); view.pending_edit = None; } + // A signature placed since the last save is gone with the rest: the next save + // doesn't ask to lock it (#2149). + self.placed_signatures.remove(&id); self.notify_tr("Reverted to the last saved version"); } Err(e) => self.notify_error(e), diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index 4498f7cd16..ff1bcbcdb4 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -1814,6 +1814,34 @@ fn the_first_save_after_placing_a_signature_asks_to_lock_it() { let _ = std::fs::remove_file(&out); } +/// #2348 review (CodeRabbit): File ▸ Revert discards a signature just placed, so the save after it +/// doesn't ask to lock one, even when the saved file holds an earlier Fill & Sign signature. +#[test] +fn reverting_forgets_a_signature_just_placed() { + let out = std::env::temp_dir().join(format!("pdfcraft-lock-revert-{}.pdf", std::process::id())); + let mut h = harness(); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + // An earlier signature, saved as a comment: the file keeps a Fill & Sign mark. + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + click(&mut h, 40.0, 300.0); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "asks first"); + h.run_steps(2); + h.get_by_label("Keep editable").click(); + h.run_steps(3); + // A new signature, then Revert: the new one is gone, the saved one stays. + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + click(&mut h, 40.0, 200.0); + h.state_mut().revert_active(); + h.run_steps(3); + let marks = h.state().session.get(h.state().views[0].id).unwrap().info.annotations.iter().filter(|a| a.fill_sign).count(); + assert_eq!(marks, 1, "the saved signature is back, the new one is gone"); + assert!(h.state_mut().save_active(SaveTarget::InPlace), "no signature was placed since the last save: no question"); + let _ = std::fs::remove_file(&out); +} + /// #2348 review (benandez): right after a signature is placed, a notice offers to lock it into /// the page now. Lock now flattens it (undoable) without saving, so the save that follows doesn't /// ask; undoing the lock brings back both the notice and the question. Dismiss hides the notice From 40260c4a6df6592305582b428676ae3ae9e685a7 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:42:26 +0800 Subject: [PATCH 073/101] M7: the signature mark stays until the save succeeds, and a reload forgets it (#2149) Answering the lock question removed the placed-signature mark before the save ran, so a save that then failed or was cancelled didn't ask again. The answer now only lets the save that follows go ahead without asking; the mark is removed when a save succeeds, as before. A reload from disk replaces the document, so it forgets the mark too (a failed reload keeps it). Review on #2348 (CodeRabbit). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/editing/save.rs | 14 ++++++++--- crates/ui-egui/src/lib.rs | 4 ++++ crates/ui-egui/src/platform/file_watch.rs | 20 +++++++++++++++- crates/ui-egui/tests/it/fill_sign.rs | 29 +++++++++++++++++++++++ 5 files changed, 64 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 29801c4b76..fd3bc035d8 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -109,6 +109,7 @@ impl PdfCraftApp { ask_to_lock_signatures: true, placed_signatures: Default::default(), pending_lock: None, + lock_answered: None, lock_remember: false, fill_text_size: fill_sign::TEXT_SIZE, fill_text_color: [0.0; 3], diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index adf0c96f35..638bd13f1c 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -60,7 +60,14 @@ impl PdfCraftApp { // changes page content in an update, which validators report as a change after signing. let still_marked = self.session.get(id).is_some_and(|d| d.info.annotations.iter().any(|a| a.fill_sign)); let signed = self.session.get(id).is_some_and(|d| d.is_signed()); - if self.ask_to_lock_signatures && !self.flatten_fill_sign_on_save && self.placed_signatures.contains(&id) && still_marked && !signed { + let answered = self.lock_answered.take() == Some(id); + if self.ask_to_lock_signatures + && !self.flatten_fill_sign_on_save + && self.placed_signatures.contains(&id) + && still_marked + && !signed + && !answered + { self.pending_lock = Some(PendingLock { id, target, after: Box::new(after) }); self.dialog = Some(crate::Dialog::LockSignatures); return false; @@ -180,13 +187,14 @@ impl PdfCraftApp { /// its pages first when `lock`, then save as was asked. With `remember`, the answer becomes /// the preference (flatten on every save, or never ask again). Nothing changes when locking /// fails: the document still counts as holding a placed signature, so the next save asks - /// again, and the answer isn't remembered. + /// again, and the answer isn't remembered. The placed-signature mark stays until the save + /// succeeds, so a save that fails or is cancelled asks again too. pub(crate) fn answer_lock_signatures(&mut self, lock: bool, remember: bool) { let Some(p) = self.pending_lock.take() else { return }; if lock && !self.flatten_fill_sign_on_save && !self.flatten_fill_sign_in(p.id) { return; } - self.placed_signatures.remove(&p.id); + self.lock_answered = Some(p.id); if remember { if lock { self.flatten_fill_sign_on_save = true; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 21c487d361..6aa5e9ddeb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -334,6 +334,10 @@ pub struct PdfCraftApp { pub(crate) placed_signatures: std::collections::HashSet, /// A save waiting behind [`Dialog::LockSignatures`], and its Don't ask again box. pub(crate) pending_lock: Option, + /// The document whose lock question was just answered: the save that follows the answer + /// doesn't ask again. The placed-signature mark itself stays until that save succeeds, so a + /// save that fails or is cancelled asks again next time. + pub(crate) lock_answered: Option, pub(crate) lock_remember: bool, /// Point size for new Fill & Sign text and dates, kept across launches. pub fill_text_size: f64, diff --git a/crates/ui-egui/src/platform/file_watch.rs b/crates/ui-egui/src/platform/file_watch.rs index 57de3428a3..a532b1d1ed 100644 --- a/crates/ui-egui/src/platform/file_watch.rs +++ b/crates/ui-egui/src/platform/file_watch.rs @@ -451,8 +451,10 @@ impl PdfCraftApp { let Some(id) = self.views.get(i).map(|v| v.id) else { return }; match self.session.reload(id, bytes) { Ok(()) => { - // Whatever was unsaved is gone, so there is nothing left to recover. + // Whatever was unsaved is gone, so there is nothing left to recover, and no + // signature placed since the last save is left to ask about locking (#2149). self.forget_recovery(id); + self.placed_signatures.remove(&id); if let (Some(view), Some(doc)) = (self.views.get_mut(i), self.session.get(id)) { view.document_replaced(&doc.info); } @@ -556,6 +558,22 @@ mod tests { app.session.get(app.views[0].id).unwrap() } + /// #2348 review (CodeRabbit): a reload from disk replaces the document, so a signature + /// placed since the last save is gone and the next save doesn't ask to lock it. A reload + /// that fails keeps the document, and the mark with it. + #[test] + fn reloading_from_disk_forgets_a_signature_just_placed() { + let (_dir, path) = TempDir::new("lock-reload", &fixture(1)); + let mut h = harness(&path); + h.run_steps(3); + let id = h.state().views[0].id; + h.state_mut().placed_signatures.insert(id); + h.state_mut().reload_view(0, Arc::new(b"not a pdf".to_vec())); + assert!(h.state().placed_signatures.contains(&id), "a failed reload keeps the mark"); + h.state_mut().reload_view(0, Arc::new(fixture(2))); + assert!(!h.state().placed_signatures.contains(&id), "the reloaded document has no placed signature"); + } + /// Association recovery changes ctime without replacing PDF contents. It must neither /// discard undo history nor make the next Save ask to overwrite our own metadata change. #[cfg(target_os = "macos")] diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index ff1bcbcdb4..5f821e4ea0 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -1814,6 +1814,35 @@ fn the_first_save_after_placing_a_signature_asks_to_lock_it() { let _ = std::fs::remove_file(&out); } +/// #2348 review (CodeRabbit): answering the lock question doesn't forget the signature; only a +/// save that succeeds does. When the save after the answer fails, the next save asks again. +#[test] +fn a_save_that_fails_after_the_lock_question_asks_again() { + let bad = std::env::temp_dir().join(format!("pdfcraft-no-such-dir-{}", std::process::id())).join("out.pdf"); + let good = std::env::temp_dir().join(format!("pdfcraft-lock-retry-{}.pdf", std::process::id())); + let mut h = harness(); + h.state_mut().save_override = Some(bad.to_string_lossy().into_owned()); + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); + click(&mut h, 40.0, 300.0); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "asks first"); + h.run_steps(2); + h.get_by_label("Keep editable").click(); + h.run_steps(3); + assert!(h.state().session.get(h.state().views[0].id).unwrap().dirty, "the save failed"); + // The save after the answer doesn't ask twice in a row, but the failed one left the signature + // unsaved: the next save asks again. + h.state_mut().save_override = Some(good.to_string_lossy().into_owned()); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "asks again"); + h.run_steps(2); + h.get_by_label("Keep editable").click(); + h.run_steps(3); + assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty, "saved this time"); + assert!(h.state_mut().save_active(SaveTarget::InPlace), "saved: no question"); + let _ = std::fs::remove_file(&good); +} + /// #2348 review (CodeRabbit): File ▸ Revert discards a signature just placed, so the save after it /// doesn't ask to lock one, even when the saved file holds an earlier Fill & Sign signature. #[test] From 24e0d070efc855d900fb04be4f4b39707628bfdd Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:23:34 +0800 Subject: [PATCH 074/101] M10: the print crate's tests delay-load the print DLLs too (#2630) The pdfcraft-print unit-test executable imported windows.data.pdf.dll and prntvpt.dll at load time (the windows crate binds them with raw-dylib), so on a Windows installation without them it could not start, and the tests of the missing-DLL guard never ran there. crates/print/build.rs now passes /DELAYLOAD for both and delayimp.lib on MSVC, as the applications' build scripts do. Link arguments from a build script reach only this package's own executables, so dependents are unaffected. dumpbin /imports: before, both DLLs under "the following imports"; after, under "the following delay load imports". A copy of each test binary with prntvpt.dll renamed to a DLL that doesn't exist: the old one exits with 0xC0000135 (STATUS_DLL_NOT_FOUND) before main, the new one starts and runs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- apps/pdfcraft-cli/build.rs | 2 +- apps/pdfcraft/build.rs | 2 +- crates/print/build.rs | 23 +++++++++++++++++++++++ crates/print/src/spool.rs | 6 +++--- 4 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 crates/print/build.rs diff --git a/apps/pdfcraft-cli/build.rs b/apps/pdfcraft-cli/build.rs index 1e8e69258a..9a6500b7f3 100644 --- a/apps/pdfcraft-cli/build.rs +++ b/apps/pdfcraft-cli/build.rs @@ -10,7 +10,7 @@ /// driver's print capabilities). Trimmed Windows installations (NTLite) can lack them, and a /// load-time import of a missing DLL stops the program before `main` (#2630). Delay-loaded, they /// load on the first print; `pdfcraft_print::spool` checks they exist before calling in. -/// Same list in `apps/pdfcraft{,-cli}/build.rs` and `crates/print/src/spool.rs`. +/// Same list in `apps/pdfcraft{,-cli}/build.rs`, `crates/print/build.rs` and `crates/print/src/spool.rs`. const DELAY_LOADED: [&str; 2] = ["windows.data.pdf.dll", "prntvpt.dll"]; fn main() { diff --git a/apps/pdfcraft/build.rs b/apps/pdfcraft/build.rs index ce2981ac20..b6892e7b9d 100644 --- a/apps/pdfcraft/build.rs +++ b/apps/pdfcraft/build.rs @@ -13,7 +13,7 @@ mod windows_manifest; /// driver's print capabilities). Trimmed Windows installations (NTLite) can lack them, and a /// load-time import of a missing DLL stops the program before `main` (#2630). Delay-loaded, they /// load on the first print; `pdfcraft_print::spool` checks they exist before calling in. -/// Same list in `apps/pdfcraft{,-cli}/build.rs` and `crates/print/src/spool.rs`. +/// Same list in `apps/pdfcraft{,-cli}/build.rs`, `crates/print/build.rs` and `crates/print/src/spool.rs`. const DELAY_LOADED: [&str; 2] = ["windows.data.pdf.dll", "prntvpt.dll"]; fn main() { diff --git a/crates/print/build.rs b/crates/print/build.rs new file mode 100644 index 0000000000..82b3ce3fac --- /dev/null +++ b/crates/print/build.rs @@ -0,0 +1,23 @@ +//! Windows (MSVC) only: delay-load the print DLLs in this crate's own test executable, as the +//! applications do (`apps/pdfcraft{,-cli}/build.rs`), so it starts on a Windows installation that +//! lacks them (#2630) and its tests of the missing-DLL guard in `src/spool.rs` run there. Link +//! arguments from a build script reach only this package's own executables (its tests), never +//! the crates that depend on it; each application sets its own. +//! +//! On every other target this does nothing. + +/// The in-box DLLs only printing uses (Windows.Data.Pdf draws the job, prntvpt reads the +/// driver's print capabilities). Same list in `apps/pdfcraft{,-cli}/build.rs` and +/// `src/spool.rs`. +const DELAY_LOADED: [&str; 2] = ["windows.data.pdf.dll", "prntvpt.dll"]; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") || std::env::var("CARGO_CFG_TARGET_ENV").as_deref() != Ok("msvc") { + return; + } + for dll in DELAY_LOADED { + println!("cargo:rustc-link-arg=/DELAYLOAD:{dll}"); + } + println!("cargo:rustc-link-arg=delayimp.lib"); +} diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index 8c2d35335a..23a77bdd2f 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -7,9 +7,9 @@ pub use craft_print::{ }; /// Windows: the in-box DLLs printing draws and describes jobs with. The executables delay-load -/// them (`apps/pdfcraft{,-cli}/build.rs`, the same list) so a Windows installation without them -/// still starts (#2630); a call into a missing delay-loaded DLL would end the process, so -/// printing checks for them first. +/// them (`apps/pdfcraft{,-cli}/build.rs`, and `build.rs` for this crate's tests: the same list) +/// so a Windows installation without them still starts (#2630); a call into a missing +/// delay-loaded DLL would end the process, so printing checks for them first. #[cfg(windows)] pub(crate) const PRINT_DLLS: [&str; 2] = ["windows.data.pdf.dll", "prntvpt.dll"]; From 2379fb70dbd29c20a3d229f46b121ae44fb29b0f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:02:08 +0000 Subject: [PATCH 075/101] Search: a test that a scroll by hand lets the find step's match go (#3530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a find step, a wheel scroll and then a ⌘-wheel zoom anchor on the pointer, not back on the old match (FredApps on #4139). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR --- crates/ui-egui/tests/it/search_position.rs | 46 ++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/crates/ui-egui/tests/it/search_position.rs b/crates/ui-egui/tests/it/search_position.rs index 688b9f4299..9215d997e5 100644 --- a/crates/ui-egui/tests/it/search_position.rs +++ b/crates/ui-egui/tests/it/search_position.rs @@ -300,3 +300,49 @@ fn every_page_display_shows_the_match_after_zooming() { } } } + +/// FredApps on #4139: the match a find step went to is let go as soon as the reader moves the +/// view by hand. After a step, a scroll by the wheel and then a ⌘-wheel zoom: the zoom keeps the +/// point under the pointer, and the view does not jump back to the old match. +#[test] +fn a_scroll_by_hand_after_a_step_lets_the_match_go() { + let mut h = harness(); + h.state_mut().views[0].set_zoom(3.0); + settle(&mut h); + h.state_mut().views[0].find_step(true); + settle(&mut h); + assert_match_shown(&h, "after the step"); + let w = h.state().views[0].viewport_rect(); + let pointer = w.min + egui::vec2(80.0, 80.0); + h.hover_at(pointer); + h.run_steps(1); + // A plain wheel scroll: the reader moves on. + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -300.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(4); + settle(&mut h); + // Then a ⌘-wheel zoom with the pointer in the corner. + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, 60.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::COMMAND, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(20); + settle(&mut h); + let v = &h.state().views[0]; + let f = v.find.as_ref().unwrap(); + let (page, range) = f.matches[f.current.unwrap()].clone(); + let centre = v.glyph_screen_pos(page, range.start).map(|a| a.lerp(v.glyph_screen_pos(page, range.end - 1).unwrap_or(a), 0.5)); + assert!( + centre.is_none_or(|c| c.distance(v.viewport_rect().center()) > 100.0), + "the zoom went back to the old match at {centre:?} (window centre {:?})", + v.viewport_rect().center() + ); +} From dcef0aebff204dd86670eb48cefc72ae365df688 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:36:21 +0800 Subject: [PATCH 076/101] M8: bound the removed text kept for the metadata check per document (#2448) The run and byte limits were per page, and a glyph's /ToUnicode text can be 256 UTF-16 units, so a short content stream could keep gigabytes of removed text across pages. The document now keeps at most 16 MiB; past that the rest is not kept and the metadata check fails closed, as for any other text it couldn't keep (CodeRabbit's retained concern on #2539). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP --- crates/redact/src/interp.rs | 19 +++++++++++++- crates/redact/src/lib.rs | 9 +++++-- crates/redact/src/tests.rs | 49 +++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index b0029ac912..540ee703a9 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -49,6 +49,11 @@ const _: () = assert!(MAX_DEPTH >= pdfcraft_edit::text::MAX_FORM_DEPTH); /// Bounds on the removed text collected for the metadata check (#2448). const MAX_RUNS: usize = 100_000; const MAX_RUN_BYTES: usize = 64 * 1024; +/// The removed text kept for the whole document, in bytes. A glyph's `/ToUnicode` text has no +/// length limit, so a short content stream can make many runs of nearly [`MAX_RUN_BYTES`] each, +/// on every page; past this the rest is not kept and the metadata check fails closed, as for +/// any other text it couldn't keep (review on #2539). +pub(crate) const MAX_REMOVED_BYTES: usize = 16 * 1024 * 1024; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Mode { @@ -132,6 +137,9 @@ pub(crate) struct Scope<'a> { /// Some removed text was not kept (too many runs, or a run too long): the metadata check /// can't search for it and must fail closed. pub removed_cut: bool, + /// Bytes of removed text this scope may still keep: what is left of the document's + /// [`MAX_REMOVED_BYTES`] (see [`Scope::with_removed_room`]). + pub removed_room: usize, /// The run being collected, and where its last glyph ended (device space) with its height. run: String, run_end: Option<((f64, f64), f64)>, @@ -161,6 +169,7 @@ impl<'a> Scope<'a> { layer_blocks: 0, removed_text: Vec::new(), removed_cut: false, + removed_room: MAX_REMOVED_BYTES, run: String::new(), run_end: None, fonts: HashMap::new(), @@ -170,12 +179,20 @@ impl<'a> Scope<'a> { } } + /// Keep at most `room` bytes of removed text: what earlier pages left of the document's + /// [`MAX_REMOVED_BYTES`]. + pub fn with_removed_room(mut self, room: usize) -> Self { + self.removed_room = room; + self + } + /// Close the run of removed text being collected. pub fn flush(&mut self) { self.run_end = None; let run = std::mem::take(&mut self.run); if !run.trim().is_empty() { - if self.removed_text.len() < MAX_RUNS { + if self.removed_text.len() < MAX_RUNS && run.len() <= self.removed_room { + self.removed_room -= run.len(); self.removed_text.push(run); } else { self.removed_cut = true; diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 5925878430..61030a2bd5 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -438,6 +438,7 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result, /// Some removed text was too long to keep whole for that check. removed_cut: bool, + /// Bytes of removed text later pages may still keep (`interp::MAX_REMOVED_BYTES` for the + /// document). + removed_room: usize, } /// Step 1 for one page: rewrites its content so nothing readable stays under a mark, and cleans the tags it no longer shows. @@ -582,17 +586,18 @@ fn redact_page_content( page: &pdfcraft_model::Page, rects: &[[f64; 4]], ) -> Result<(), RedactError> { - let Applying { report, doomed_xobjects, retired_properties, removed_text, removed_cut, .. } = applying; + let Applying { report, doomed_xobjects, retired_properties, removed_text, removed_cut, removed_room, .. } = applying; // 1. Content. let mut budget = pdfcraft_cos::DecodeBudget::page(); let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let out = { - let mut scope = Scope::new(rects, Mode::Apply, report, budget); + let mut scope = Scope::new(rects, Mode::Apply, report, budget).with_removed_room(*removed_room); let out = process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY); scope.flush(); removed_text.append(&mut scope.removed_text); *removed_cut |= scope.removed_cut; + *removed_room = scope.removed_room; out }; // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 7a19040e52..1f0826db32 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -289,6 +289,55 @@ fn metadata_is_cleared_when_the_removed_text_was_cut_short() { assert!(!has_xmp(&doc)); } +/// #2539 review: the removed text kept for the metadata check is bounded for the whole document, +/// not per page. One glyph code can stand for 256 UTF-16 units of `/ToUnicode` text (768 bytes of +/// UTF-8), so here each page makes 150 runs of 80 such glyphs (9.2 MB, under the page's run and +/// byte limits); two pages pass `MAX_REMOVED_BYTES`, so the rest isn't kept and the check fails +/// closed. +#[test] +fn removed_text_kept_for_the_metadata_check_is_bounded_per_document() { + let long = "4E00".repeat(256); + let to_unicode = format!("/CIDInit /ProcSet findresource begin 1 beginbfchar <41> <{long}> endbfchar end"); + let run = "A".repeat(80); + let mut content = b"BT /F1 1 Tf 10 100 Td".to_vec(); + for _ in 0..75 { + content.extend_from_slice(format!(" ({run}) Tj 30 0 Td ({run}) Tj -30 0 Td").as_bytes()); + } + content.extend_from_slice(b" ET"); + let page = "/Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >>"; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 8 0 R] /Count 2 >>".to_vec(), + format!("<< {page} >>").into_bytes(), + stream("", &content), + FONT.replace("95 0 R", "6 0 R").replace(" /Widths", " /ToUnicode 7 0 R /Widths").into_bytes(), + widths(), + stream("", to_unicode.as_bytes()), + format!("<< {page} >>").into_bytes(), + ]; + let mut doc = pdf(objs); + let mut applying = Applying { + report: Report::default(), + doomed_fields: Vec::new(), + doomed_xobjects: Vec::new(), + retired_properties: Vec::new(), + widget_owner: Vec::new(), + removed_text: Vec::new(), + removed_cut: false, + removed_room: interp::MAX_REMOVED_BYTES, + removed_everywhere: HashSet::new(), + }; + let rects = [[5.0, 99.0, 200.0, 102.0]]; + let kept = |a: &Applying| a.removed_text.iter().map(String::len).sum::(); + let first = pdfcraft_model::pages(&doc).swap_remove(0); + redact_page_content(&mut doc, &mut applying, 0, &first, &rects).unwrap(); + assert_eq!((kept(&applying), applying.removed_cut), (9_216_000, false), "one page fits"); + let second = pdfcraft_model::pages(&doc).swap_remove(1); + redact_page_content(&mut doc, &mut applying, 1, &second, &rects).unwrap(); + assert!(applying.removed_cut, "the second page passes the document's limit"); + assert!(kept(&applying) <= interp::MAX_REMOVED_BYTES, "kept {} bytes", kept(&applying)); +} + /// #2539 review: the searching a scrub may do is one budget for all of it, not a check per /// value: very many small `/Info` values each fit, but together they don't, and past the budget /// the rest counts as matching (fail-closed). With room to search, nothing that doesn't mention From 0c6860e2582c94a1be4b8cd58cf23b011e69cbc4 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:36:53 +0800 Subject: [PATCH 077/101] M8: the redact README lists the per-document collection limit (#2448) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP --- crates/redact/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/redact/README.md b/crates/redact/README.md index 8063f1a9f4..2a8f3a879e 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -66,8 +66,8 @@ clear_marks(&mut doc, None)?; // remove marks without app letters with the removed text, and isn't wholly part of it, stays: Sanitize's metadata category removes the rest. `Report::metadata` counts what went. - **Fail-closed.** When the check can't know what the removed text was, every `/Info` - string and every catalog and page XMP stream goes: more than 100,000 runs or a run over - 64 KiB removed (the collection limit); a removed glyph whose text isn't known (no + string and every catalog and page XMP stream goes: more than 100,000 runs on a page, a run + over 64 KiB, or more than 16 MiB in the whole document removed (the collection limits); a removed glyph whose text isn't known (no `/ToUnicode` or encoding gives it); a form XObject removed without being read, because it is nested past the depth limit (`MAX_DEPTH`, 50) or its content can't be read; or more than 20,000 terms, or a run longer than 4,096 characters, to search for. Once the From cbb2fc80afd9d26b860598f7b3c21fd993243b18 Mon Sep 17 00:00:00 2001 From: Osvaldo Duran Melendez Date: Mon, 2 Nov 2026 19:55:56 -0500 Subject: [PATCH 078/101] Follow upstream: the mark kinds the new replacement tests match on Co-Authored-By: Claude Sonnet 5.5 --- crates/edit/src/mark_transaction_tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/edit/src/mark_transaction_tests.rs b/crates/edit/src/mark_transaction_tests.rs index 2623da88ba..22530c8836 100644 --- a/crates/edit/src/mark_transaction_tests.rs +++ b/crates/edit/src/mark_transaction_tests.rs @@ -121,6 +121,7 @@ fn apply_replacement(doc: &mut Document, kind: MarkKind, source: MarkSource, rep MarkKind::Background => { add_background(doc, SELECTED, &Background { color: [0.1, 0.3, 0.7], replace_backdrop: true, ..Default::default() }, replace) } + MarkKind::PrinterMarks => Ok(()), } } @@ -172,6 +173,7 @@ fn replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_re MarkKind::HeaderFooter => "(New header)".to_string(), MarkKind::Watermark => format!("/PCPic{} Do", source.xobject.num), MarkKind::Background => "0.1 0.3 0.7 rg".to_string(), + MarkKind::PrinterMarks => String::new(), }; apply_replacement(&mut doc, kind, source, true).unwrap(); let doc = reopen(&doc); From 3ef82b55eb3fa595e7cfbfd50fe5c49f632f9176 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 20:15:58 -0800 Subject: [PATCH 079/101] Preserve action chains when discarding external references --- crates/optimize/src/external_refs.rs | 13 ++++++---- crates/optimize/tests/optimize.rs | 39 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/crates/optimize/src/external_refs.rs b/crates/optimize/src/external_refs.rs index 4a61f37e88..3981b81b81 100644 --- a/crates/optimize/src/external_refs.rs +++ b/crates/optimize/src/external_refs.rs @@ -219,7 +219,7 @@ impl Walk { match rest { Some(Kept::Now(v)) => { let mut d = d.clone(); - set_next(&mut d, v); + set_next(doc, &mut d, v); Kept::Now(vec![Object::Dict(d)]) } _ => Kept::Same, @@ -230,13 +230,16 @@ impl Walk { } } -fn set_next(d: &mut Dict, mut v: Vec) { +fn set_next(doc: &mut Document, d: &mut Dict, mut v: Vec) { match v.len() { 0 => { d.remove(b"Next"); } 1 => d.set(b"Next".to_vec(), v.remove(0)), - _ => d.set(b"Next".to_vec(), Object::Array(v)), + _ => { + let next = v.into_iter().map(|a| if matches!(a, Object::Dict(_) | Object::Array(_)) { Object::Ref(doc.add(a)) } else { a }).collect(); + d.set(b"Next".to_vec(), Object::Array(next)); + } } } @@ -278,8 +281,8 @@ fn strip_action(doc: &mut Document, a: &Object, owner: ObjRef) -> Result = std::iter::repeat_n("Named".to_string(), 127).chain(["URI".to_string()]).collect(); + assert_eq!(chain(&reopened, &a), expected); +} + #[test] fn shared_actions_that_multiply_stop_the_optimization_when_one_opens_another_document() { use pdfcraft_optimize::OptimizeError; From a94808ffe6bf87e7be926579d0e539d8e605c5f8 Mon Sep 17 00:00:00 2001 From: Osvaldo Duran Melendez Date: Tue, 3 Nov 2026 00:40:54 -0500 Subject: [PATCH 080/101] Convert colours: pass the image budget to pixels after the merge with upstream Co-Authored-By: Claude Sonnet 5.5 --- crates/optimize/src/images.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 2ac43a8995..1c658482a7 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -1368,7 +1368,8 @@ pub fn recolour_image(doc: &Document, s: &Stream, target: ImageTarget) -> Option return None; } let (w, h) = (d.int(b"Width")? as u32, d.int(b"Height")? as u32); - let px = pixels(doc, s, n)?; + // One picture a call: `pixels` itself refuses one past MAX_PIXELS, so no shared allowance is kept here. + let px = pixels(doc, s, n, &mut ImageBudget::with_work(u64::MAX))?; let was_dct = is_dct(s)?; let out: Vec = match (n, target) { (3, ImageTarget::Gray) => { From 6ee92add8f7ed7f3908460f93c5bb80b09ffb78f Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:23:51 -0600 Subject: [PATCH 081/101] Test Unicode attachment descriptions --- crates/automation/tests/it/automation.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index b14dd4a75b..ab14794448 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9598,12 +9598,12 @@ fn attachment_descriptions_can_be_edited_cleared_undone_and_saved() { let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap(); ok(&mut a, "attachment_add", json!({"doc": doc, "path": "notes.txt", "description": "Original"})); - ok(&mut a, "attachment_edit", json!({"doc": doc, "name": "notes.txt", "description": "Updated"})); - assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"], "Updated"); + ok(&mut a, "attachment_edit", json!({"doc": doc, "name": "notes.txt", "description": "Résumé 東京"})); + assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"], "Résumé 東京"); ok(&mut a, "edit_undo", json!({"doc": doc})); assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"], "Original"); ok(&mut a, "edit_redo", json!({"doc": doc})); - assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"], "Updated"); + assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"], "Résumé 東京"); ok(&mut a, "attachment_edit", json!({"doc": doc, "name": "notes.txt", "description": ""})); assert!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"].is_null()); @@ -9612,10 +9612,10 @@ fn attachment_descriptions_can_be_edited_cleared_undone_and_saved() { let saved = std::fs::read(dir.join("edited.pdf")).unwrap(); let info = pdfcraft_render::inspect(std::sync::Arc::new(saved.clone()), None).unwrap(); let attachment = info.attachments.iter().find(|item| item.name == "notes.txt").unwrap(); - assert_eq!(attachment.description.as_deref(), Some("Updated")); + assert_eq!(attachment.description.as_deref(), Some("Résumé 東京")); assert_eq!(pdfcraft_render::attachment_data(&saved, None, attachment).unwrap(), bytes); let reopened = ok(&mut a, "doc_open", json!({"path": "edited.pdf"}))["doc"].as_u64().unwrap(); - assert_eq!(ok(&mut a, "doc_info", json!({"doc": reopened}))["attachments"][0]["description"], "Updated"); + assert_eq!(ok(&mut a, "doc_info", json!({"doc": reopened}))["attachments"][0]["description"], "Résumé 東京"); } #[test] From 22778b2be7a5e2719cb3b3e4471d38cd0e83a482 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:06:40 -0600 Subject: [PATCH 082/101] Guard attachment description edits against stale drafts --- crates/automation/src/dispatch.rs | 19 +++++++++- crates/automation/tests/it/automation.rs | 43 +++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 28 +++++++++++---- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/panels/right.rs | 2 +- crates/ui-egui/tests/it/attachment_row.rs | 33 +++++++++++++++++ 6 files changed, 118 insertions(+), 9 deletions(-) diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 3e29e7d861..6e97b4d4d7 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -755,7 +755,24 @@ impl Automation { self.apply(&a, edit)? } "attachment_edit" => { - let edit = Edit::EditAttachmentDescription { key: a.str("name")?.into(), description: Some(a.str("description")?.into()) }; + let name = a.str("name")?; + let keys: Vec = self + .doc(&a)? + .info + .attachments + .iter() + .filter(|attachment| attachment.name == name) + .filter_map(|attachment| match &attachment.source { + pdfcraft_render::AttachmentSource::Document { key } => Some(key.clone()), + pdfcraft_render::AttachmentSource::Annotation { .. } => None, + }) + .collect(); + let key = match keys.as_slice() { + [key] => key.clone(), + [] => return Err(ToolError::InvalidArgs(format!("there is no document attachment named {name:?}; use a name from doc_info"))), + _ => return Err(ToolError::InvalidArgs(format!("more than one document attachment is named {name:?}; choose an unambiguous name"))), + }; + let edit = Edit::EditAttachmentDescription { key, description: Some(a.str("description")?.into()) }; self.apply(&a, edit)? } "doc_remove_hidden" => self.doc_remove_hidden(&a)?, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index ab14794448..16284baeab 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9618,6 +9618,49 @@ fn attachment_descriptions_can_be_edited_cleared_undone_and_saved() { assert_eq!(ok(&mut a, "doc_info", json!({"doc": reopened}))["attachments"][0]["description"], "Résumé 東京"); } +#[test] +fn attachment_edit_resolves_the_listed_name_to_its_name_tree_key() { + let dir = workdir("attachment-name-tree-key"); + std::fs::write(dir.join("payload.txt"), b"payload").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "attachment_add", json!({"doc":doc,"path":"payload.txt","name":"storage-key.txt"})); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"seed.pdf","full":true})); + let mut source = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("seed.pdf")).unwrap())).unwrap(); + let key = "storage-key.txt"; + let root = source.root().unwrap(); + let catalog = source.get(root).as_dict().unwrap().clone(); + let names = source.resolve(catalog.get(b"Names").unwrap()).as_dict().unwrap().clone(); + let tree = source.resolve(names.get(b"EmbeddedFiles").unwrap()).as_dict().unwrap().clone(); + let pairs = source.resolve(tree.get(b"Names").unwrap()).as_array().unwrap().clone(); + let spec_ref = pairs.get(1).and_then(pdfcraft_cos::Object::as_ref).unwrap(); + source + .update_dict(spec_ref, |spec| { + let name = pdfcraft_cos::Object::String(pdfcraft_cos::PdfString::text("visible-name.txt")); + spec.set(b"F".to_vec(), name.clone()); + spec.set(b"UF".to_vec(), name); + }) + .unwrap(); + let bytes = pdfcraft_cos::write_full(&source, &pdfcraft_cos::SaveOptions::default()).unwrap(); + std::fs::write(dir.join("named.pdf"), bytes).unwrap(); + + let doc = ok(&mut a, "doc_open", json!({"path":"named.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "doc_info", json!({"doc":doc}))["attachments"][0]["name"], "visible-name.txt"); + ok(&mut a, "attachment_edit", json!({"doc":doc,"name":"visible-name.txt","description":"Updated"})); + assert_eq!(ok(&mut a, "doc_info", json!({"doc":doc}))["attachments"][0]["description"], "Updated"); + assert!(matches!( + a.call("attachment_edit", &json!({"doc":doc,"name":"missing.txt","description":"x"})), + Err(ToolError::InvalidArgs(message)) if message.contains("no document attachment named") + )); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"edited.pdf","full":true})); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("edited.pdf")).unwrap())).unwrap(); + let catalog = saved.get(saved.root().unwrap()).as_dict().unwrap().clone(); + let names = saved.resolve(catalog.get(b"Names").unwrap()).as_dict().unwrap().clone(); + let tree = saved.resolve(names.get(b"EmbeddedFiles").unwrap()).as_dict().unwrap().clone(); + let pairs = saved.resolve(tree.get(b"Names").unwrap()).as_array().unwrap().clone(); + assert_eq!(pairs.first().and_then(pdfcraft_cos::Object::as_string).map(pdfcraft_cos::PdfString::to_text).as_deref(), Some(key)); +} + #[test] fn fdf_nested_comment_values_survive_export_import_and_save_through_tools() { use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full}; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0450fa9d83..92e60dfe89 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -342,15 +342,18 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m return false; } Dialog::AttachmentDescription => { - let Some((doc, _, _)) = app.attachment_description_draft.as_ref() else { + let Some((doc, generation, _, _)) = app.attachment_description_draft.as_ref() else { req.close = true; return false; }; - if app.active_ids().is_none_or(|(_, active)| active != *doc) { + if app.active_ids().is_none_or(|(_, active)| active != *doc) + || app.session.get(*doc).is_none_or(|current| current.edit_generation() != *generation) + { + app.attachment_description_draft = None; req.close = true; return false; } - let Some((_, key, description)) = app.attachment_description_draft.as_mut() else { + let Some((_, _, key, description)) = app.attachment_description_draft.as_mut() else { req.close = true; return false; }; @@ -1735,9 +1738,22 @@ fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: } } else if dialog == Dialog::AttachmentDescription { if widgets::pill_button(ui, tl!("OK"), true).clicked() { - if let Some((doc, key, description)) = app.attachment_description_draft.as_ref() { - req.attachment_description_now = - Some((*doc, Edit::EditAttachmentDescription { key: key.clone(), description: Some(description.clone()) })); + if let Some((doc, generation, key, description)) = app.attachment_description_draft.as_ref() { + let current = app.session.get(*doc).and_then(|doc| { + doc.info + .attachments + .iter() + .find(|attachment| matches!(&attachment.source, pdfcraft_render::AttachmentSource::Document { key: attachment_key } if attachment_key == key)) + .map(|attachment| attachment.description.as_deref().unwrap_or_default().trim().to_owned()) + }); + let normalized = description.trim(); + let still_current = app.session.get(*doc).is_some_and(|doc| doc.edit_generation() == *generation); + if still_current && current.as_deref().is_some_and(|current| current != normalized) { + req.attachment_description_now = Some(( + *doc, + Edit::EditAttachmentDescription { key: key.clone(), description: Some(normalized.to_owned()) }, + )); + } } req.close = true; } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b936c56a00..745fb0e87b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -462,7 +462,7 @@ pub struct PdfCraftApp { /// Document Properties ▸ Description fields being edited: (document, Title/Author/Subject/Keywords). pub props_draft: Option<(DocId, [String; 4])>, /// Attachment description being edited: (document, name-tree key, description). - pub attachment_description_draft: Option<(DocId, String, String)>, + pub attachment_description_draft: Option<(DocId, u64, String, String)>, /// Document Properties ▸ Initial View (and reading options) being edited. pub view_draft: Option<(DocId, pdfcraft_engine::InitialView)>, /// Document Properties ▸ Custom being edited. diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 500398651f..3e5fb18ad1 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -364,7 +364,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if let Some(key) = deletable && ui.button(tl!("Edit description")).clicked() { - app.attachment_description_draft = Some((id, key.clone(), a.description.clone().unwrap_or_default())); + app.attachment_description_draft = Some((id, doc.edit_generation(), key.clone(), a.description.clone().unwrap_or_default())); app.dialog = Some(crate::Dialog::AttachmentDescription); ui.close(); } diff --git a/crates/ui-egui/tests/it/attachment_row.rs b/crates/ui-egui/tests/it/attachment_row.rs index 144e03ab78..ec21a59cb4 100644 --- a/crates/ui-egui/tests/it/attachment_row.rs +++ b/crates/ui-egui/tests/it/attachment_row.rs @@ -170,6 +170,39 @@ fn the_description_can_be_edited_from_the_row_menu() { assert!(doc.can_undo().is_some_and(|label| label == "Edit attachment description")); } +#[test] +fn keeping_the_attachment_description_does_not_create_an_edit() { + let mut h = harness(); + right_click(&mut h, "notes.txt"); + h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Edit description").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + let state = h.state(); + let doc = state.session.get(state.views[0].id).unwrap(); + assert_eq!(doc.info.attachments.iter().find(|a| a.name == "notes.txt").unwrap().description.as_deref(), Some("Original")); + assert!(!doc.dirty, "confirming the unchanged description does not dirty the document"); + assert_eq!(doc.can_undo(), None, "confirming the unchanged description does not add undo history"); +} + +#[test] +fn editing_the_document_while_description_dialog_is_open_discards_its_stale_draft() { + let mut h = harness(); + right_click(&mut h, "notes.txt"); + h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Edit description").click(); + h.run_steps(2); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::EditAttachmentDescription { + key: "notes.txt".into(), + description: Some("Changed elsewhere".into()), + })); + h.get_by_label("OK").click(); + h.run_steps(3); + let state = h.state(); + let doc = state.session.get(state.views[0].id).unwrap(); + assert_eq!(doc.info.attachments.iter().find(|a| a.name == "notes.txt").unwrap().description.as_deref(), Some("Changed elsewhere")); + assert_eq!(doc.can_undo(), Some("Edit attachment description")); +} + #[test] fn the_row_buttons_still_take_their_own_clicks() { let dir = Dir::new("buttons"); From 963d3f1f8c323d32a591b25bfc0b9390c4f844c8 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:10:05 -0600 Subject: [PATCH 083/101] Format attachment description safeguards --- crates/automation/src/dispatch.rs | 6 +++++- crates/ui-egui/src/panels/right.rs | 3 ++- crates/ui-egui/tests/it/attachment_row.rs | 8 ++++---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 6e97b4d4d7..d7c29d09bf 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -770,7 +770,11 @@ impl Automation { let key = match keys.as_slice() { [key] => key.clone(), [] => return Err(ToolError::InvalidArgs(format!("there is no document attachment named {name:?}; use a name from doc_info"))), - _ => return Err(ToolError::InvalidArgs(format!("more than one document attachment is named {name:?}; choose an unambiguous name"))), + _ => { + return Err(ToolError::InvalidArgs(format!( + "more than one document attachment is named {name:?}; choose an unambiguous name" + ))); + } }; let edit = Edit::EditAttachmentDescription { key, description: Some(a.str("description")?.into()) }; self.apply(&a, edit)? diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 3e5fb18ad1..97256ac725 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -364,7 +364,8 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if let Some(key) = deletable && ui.button(tl!("Edit description")).clicked() { - app.attachment_description_draft = Some((id, doc.edit_generation(), key.clone(), a.description.clone().unwrap_or_default())); + app.attachment_description_draft = + Some((id, doc.edit_generation(), key.clone(), a.description.clone().unwrap_or_default())); app.dialog = Some(crate::Dialog::AttachmentDescription); ui.close(); } diff --git a/crates/ui-egui/tests/it/attachment_row.rs b/crates/ui-egui/tests/it/attachment_row.rs index ec21a59cb4..b45a3e50cf 100644 --- a/crates/ui-egui/tests/it/attachment_row.rs +++ b/crates/ui-egui/tests/it/attachment_row.rs @@ -191,10 +191,10 @@ fn editing_the_document_while_description_dialog_is_open_discards_its_stale_draf right_click(&mut h, "notes.txt"); h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Edit description").click(); h.run_steps(2); - assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::EditAttachmentDescription { - key: "notes.txt".into(), - description: Some("Changed elsewhere".into()), - })); + assert!( + h.state_mut() + .apply_edit(pdfcraft_engine::Edit::EditAttachmentDescription { key: "notes.txt".into(), description: Some("Changed elsewhere".into()) }) + ); h.get_by_label("OK").click(); h.run_steps(3); let state = h.state(); From 48b95e6494e065adc3e5996d92acf5e7ca91d480 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 4 Nov 2026 04:07:59 +0000 Subject: [PATCH 084/101] fix(a11y): annotation checks and form tagging stop copying shared annotation data --- crates/a11y/src/autotag_fields.rs | 8 ++++++++ crates/a11y/src/content.rs | 20 ++++++++++-------- crates/a11y/src/tests.rs | 34 +++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/crates/a11y/src/autotag_fields.rs b/crates/a11y/src/autotag_fields.rs index 3203aca376..11dcbbd651 100644 --- a/crates/a11y/src/autotag_fields.rs +++ b/crates/a11y/src/autotag_fields.rs @@ -40,7 +40,15 @@ pub fn tag_form_fields(doc: &mut Document) -> Result { let mut widgets = Vec::new(); // A widget listed in more than one page's /Annots is tagged once (one key, one /Form). let mut seen = std::collections::HashSet::new(); + // A shared indirect /Annots array is scanned for the first page that lists it: its widgets + // are tagged once anyway, so scanning it again for every page only repeats the work. + let mut arrays = std::collections::HashSet::new(); for page in &pages { + if let Some(array) = page.dict.get(b"Annots").and_then(Object::as_ref) + && !arrays.insert(array) + { + continue; + } let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).as_deref().and_then(Object::as_array).cloned().unwrap_or_default(); for annot in annots { let Some(reference) = annot.as_ref() else { continue }; diff --git a/crates/a11y/src/content.rs b/crates/a11y/src/content.rs index d548c57f9d..f069d7b0cc 100644 --- a/crates/a11y/src/content.rs +++ b/crates/a11y/src/content.rs @@ -261,11 +261,13 @@ fn annotation_rect(doc: &Document, annot: &Dict) -> Option<[f64; 4]> { pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usize], scan: &Scan, tagged: bool) -> Vec { let mut out = Vec::new(); - let annots = |p: &Page| -> Vec<(ObjRef, Dict)> { + // The annotation objects are shared, never deep-copied: a reference repeated across a page's + // Annots array (or its pages) must not copy the dictionary each time. + let annots = |p: &Page| -> Vec<(ObjRef, std::sync::Arc)> { let a = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); a.iter() - .filter_map(|x| x.as_ref().and_then(|r| doc.get(r).as_dict().cloned().map(|d| (r, d)))) - .filter(|(_, d)| d.name(b"Subtype") != Some(b"Popup")) + .filter_map(|x| x.as_ref().map(|r| (r, doc.get(r)))) + .filter(|(_, o)| o.as_dict().is_some_and(|d| d.name(b"Subtype") != Some(b"Popup"))) .collect() }; match rule { @@ -283,7 +285,8 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz Rule::TaggedAnnotations | Rule::TaggedMultimedia | Rule::TaggedFormFields => { for &p in list { let Some(page) = pages.get(p) else { continue }; - for (_, a) in annots(page) { + for (_, obj) in annots(page) { + let Some(a) = obj.as_dict() else { continue }; let st = a.name(b"Subtype").unwrap_or(b""); let applies = match rule { Rule::TaggedFormFields => st == b"Widget", @@ -298,11 +301,11 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz let pattern = if st == b"Widget" { "Form field on page {p} isn't tagged" } else { - values.push(("{}", subtype_name(&a))); + values.push(("{}", subtype_name(a))); "{} annotation on page {p} isn't tagged" }; let mut finding = Finding::worded(Some(p), pattern, values); - finding.bbox = annotation_rect(doc, &a); + finding.bbox = annotation_rect(doc, a); out.push(finding); } } @@ -344,7 +347,8 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz let mut seen = HashSet::new(); for &p in list { let Some(page) = pages.get(p) else { continue }; - for (r, a) in annots(page) { + for (r, obj) in annots(page) { + let Some(a) = obj.as_dict() else { continue }; if a.name(b"Subtype") != Some(b"Widget") { continue; } @@ -361,7 +365,7 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz "Field \"{}\" on page {p} has no description (tooltip)", vec![("{}", full_name(doc, field)), ("{p}", (p + 1).to_string())], ); - finding.bbox = annotation_rect(doc, &a); + finding.bbox = annotation_rect(doc, a); out.push(finding); } } diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 3024319ec3..18993cd983 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1504,3 +1504,37 @@ fn marking_a_tag_artifact_refuses_content_past_the_decode_budget() { assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::TooLarge)); assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents"), Some(&contents), "the page keeps its content"); } + +#[test] +fn autotag_form_fields_tags_widgets_of_a_shared_annots_array_once() { + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(), + "<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >>".into(), + "[5 0 R 5 0 R]".into(), + ], + "", + ); + assert_eq!(tag_form_fields(&mut doc).unwrap(), 1); +} + +#[test] +fn checker_reports_each_untagged_annotation_without_copying_shared_dictionaries() { + let doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R 4 0 R 5 0 R] >>".into(), + "<< /Type /Annot /Subtype /Text /Rect [20 60 120 80] >>".into(), + "<< /Type /Annot /Subtype /Popup /Rect [20 60 120 80] >>".into(), + ], + "", + ); + let pages = pdfcraft_model::pages(&doc); + let found = content::page_rule(&doc, Rule::TaggedAnnotations, &pages, &[0], &Default::default(), false); + assert_eq!(found.len(), 2, "the popup is skipped, each listed text annotation reported"); +} From 03a16ddea6d2ca99a55f6d571e2267e3a219ac24 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 4 Nov 2026 04:56:35 +0000 Subject: [PATCH 085/101] fix(forms): AFSimple_Calculate folds field values as it reads them, within an allowance --- crates/forms/src/af/calc.rs | 28 ++++++++++++++++------------ crates/forms/src/af/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/crates/forms/src/af/calc.rs b/crates/forms/src/af/calc.rs index d29956cbc2..3187c4effd 100644 --- a/crates/forms/src/af/calc.rs +++ b/crates/forms/src/af/calc.rs @@ -525,6 +525,9 @@ pub(super) fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option (i == toks.len() && v.is_finite()).then_some(v) } +/// Most field values one `AFSimple_Calculate` list may expand to (a form has some thousands). +const MAX_EXPANDED_VALUES: usize = 1 << 20; + /// The Calculate event: the new value, given the current values of the other fields /// (`values(name)` returns every field named `name` or below it, for groups). pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option { @@ -532,20 +535,21 @@ pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option< match c { Calculate::None => None, Calculate::Simple { op, fields } => { - let xs: Vec = fields.iter().flat_map(|f| values(f)).map(|v| num(&v)).collect(); + // The values are folded as they are read, never collected: a group name listed many + // times would otherwise be held once per listing. A list that expands past + // MAX_EXPANDED_VALUES is not calculated. + let mut n = 0usize; + let xs = fields.iter().flat_map(|f| values(f)).map(|v| num(&v)).inspect(|_| n += 1).take(MAX_EXPANDED_VALUES + 1); let v = match op { - CalcOp::Sum => xs.iter().sum(), - CalcOp::Product => xs.iter().product(), - CalcOp::Average => { - if xs.is_empty() { - 0.0 - } else { - xs.iter().sum::() / xs.len() as f64 - } - } - CalcOp::Minimum => xs.iter().copied().fold(f64::INFINITY, f64::min), - CalcOp::Maximum => xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), + CalcOp::Sum | CalcOp::Average => xs.sum(), + CalcOp::Product => xs.product(), + CalcOp::Minimum => xs.fold(f64::INFINITY, f64::min), + CalcOp::Maximum => xs.fold(f64::NEG_INFINITY, f64::max), }; + if n > MAX_EXPANDED_VALUES { + return None; + } + let v = if *op == CalcOp::Average { if n == 0 { 0.0 } else { v / n as f64 } } else { v }; Some(if v.is_finite() { js_number(v) } else { "0".into() }) } Calculate::Notation(expr) => { diff --git a/crates/forms/src/af/tests.rs b/crates/forms/src/af/tests.rs index 79c5c7be02..7c5021b63b 100644 --- a/crates/forms/src/af/tests.rs +++ b/crates/forms/src/af/tests.rs @@ -383,3 +383,23 @@ fn validation_and_calculation() { assert_eq!(calculate(&Calculate::Notation("(a + b) * 2 - Unit\\ Price / 4".into()), &vals), Some("10".into())); assert_eq!(calculate(&Calculate::Notation("a +".into()), &vals), None); } + +#[test] +fn simple_calculations_fold_a_repeated_group_without_collecting_it() { + let group = |_: &str| vec!["2".to_string(); 3]; + let list = |op, n: usize| Calculate::Simple { op, fields: vec!["g".to_string(); n] }; + assert_eq!(calculate(&list(CalcOp::Sum, 4), &group).as_deref(), Some("24")); + assert_eq!(calculate(&list(CalcOp::Average, 4), &group).as_deref(), Some("2")); + assert_eq!(calculate(&list(CalcOp::Product, 2), &group).as_deref(), Some("64")); + assert_eq!(calculate(&list(CalcOp::Maximum, 2), &group).as_deref(), Some("2")); + assert_eq!(calculate(&list(CalcOp::Sum, 0), &group).as_deref(), Some("0")); +} + +#[test] +fn a_simple_calculation_that_expands_past_the_allowance_is_not_calculated() { + let thousand = |_: &str| vec!["1".to_string(); 1000]; + let within = Calculate::Simple { op: CalcOp::Sum, fields: vec!["g".to_string(); 1000] }; + let past = Calculate::Simple { op: CalcOp::Sum, fields: vec!["g".to_string(); 2000] }; + assert_eq!(calculate(&within, &thousand).as_deref(), Some("1000000")); + assert_eq!(calculate(&past, &thousand), None); +} From a47c825befcd6caa642566b95518338a6925aa51 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:23:19 -0600 Subject: [PATCH 086/101] Fix attachment description selection and clearing --- crates/automation/src/dispatch.rs | 27 ++++++++++++--- crates/automation/src/json.rs | 2 +- crates/automation/src/tools.rs | 4 +-- crates/automation/tests/it/automation.rs | 6 ++-- crates/render/src/inspect/model.rs | 2 ++ crates/render/src/inspect/resources.rs | 3 +- crates/ui-egui/src/dialogs.rs | 12 +++---- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/panels/right.rs | 10 ++++-- crates/ui-egui/tests/it/attachment_row.rs | 42 +++++++++++++++++++++-- 10 files changed, 88 insertions(+), 22 deletions(-) diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index d7c29d09bf..dcd0ae15c6 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -755,24 +755,41 @@ impl Automation { self.apply(&a, edit)? } "attachment_edit" => { - let name = a.str("name")?; + let key = a.opt_str("key")?; + let name = a.opt_str("name")?; + if key.is_some() == name.is_some() { + return Err(ToolError::InvalidArgs("provide exactly one of key or name".into())); + } let keys: Vec = self .doc(&a)? .info .attachments .iter() - .filter(|attachment| attachment.name == name) .filter_map(|attachment| match &attachment.source { - pdfcraft_render::AttachmentSource::Document { key } => Some(key.clone()), + pdfcraft_render::AttachmentSource::Document { key: attachment_key } + if key.is_some_and(|key| key == attachment_key) || name.is_some_and(|name| name == attachment.name) => + { + Some(attachment_key.clone()) + } + pdfcraft_render::AttachmentSource::Document { .. } => None, pdfcraft_render::AttachmentSource::Annotation { .. } => None, }) .collect(); let key = match keys.as_slice() { [key] => key.clone(), - [] => return Err(ToolError::InvalidArgs(format!("there is no document attachment named {name:?}; use a name from doc_info"))), + [] if key.is_some() => { + return Err(ToolError::InvalidArgs(format!("there is no document attachment with key {:?}", key.unwrap_or_default()))); + } + [] => { + return Err(ToolError::InvalidArgs(format!( + "there is no document attachment named {:?}; use a name from doc_info", + name.unwrap_or_default() + ))); + } _ => { return Err(ToolError::InvalidArgs(format!( - "more than one document attachment is named {name:?}; choose an unambiguous name" + "more than one document attachment is named {:?}; provide its key from doc_info", + name.unwrap_or_default() ))); } }; diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs index 9e93d4c347..7360785be7 100644 --- a/crates/automation/src/json.rs +++ b/crates/automation/src/json.rs @@ -364,7 +364,7 @@ pub(crate) fn info(d: &Document) -> Value { "horizontal": t.horizontal, "inward": t.inward, "direction": t.direction, "scale": t.scale, "opaque": t.opaque, "display": t.display, })).collect::>(), - "attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::>(), + "attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "key": match &a.source { pdfcraft_render::AttachmentSource::Document { key } => Some(key), pdfcraft_render::AttachmentSource::Annotation { .. } => None }, "description": a.description, "size": a.size })).collect::>(), // `substitute` names the face standing in for a program the document does not embed, and // `substitution` says why. Both are null for an embedded font: nothing stands in. A null // `substitute` with a non-null `substitution` means no face was available at all, so diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index d3b1030cb8..a8ef3a445b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -146,8 +146,8 @@ pub fn tools() -> Vec { t("attachment_delete", "Delete an attachment", "Remove the document-level attachment with this name (as doc_info lists it). Undoable.") .destructive() .with(schema(json!({"doc": doc(), "name": {"type": "string"}}), &["doc", "name"])), - t("attachment_edit", "Edit an attachment description", "Change the description of the document-level attachment with this name (as doc_info lists it). An empty description clears it. The file and other attachment data are preserved. Undoable.") - .with(schema(json!({"doc": doc(), "name": {"type": "string"}, "description": {"type": "string"}}), &["doc", "name", "description"])), + t("attachment_edit", "Edit an attachment description", "Change the description of a document attachment by its exact key from doc_info, or by a unique displayed name. An empty description clears it. The file and other attachment data are preserved. Undoable.") + .with(schema(json!({"doc": doc(), "key": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}}), &["doc", "description"])), t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited.") .cmd("file.open") .with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 16284baeab..93c062526c 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9645,8 +9645,10 @@ fn attachment_edit_resolves_the_listed_name_to_its_name_tree_key() { std::fs::write(dir.join("named.pdf"), bytes).unwrap(); let doc = ok(&mut a, "doc_open", json!({"path":"named.pdf"}))["doc"].as_u64().unwrap(); - assert_eq!(ok(&mut a, "doc_info", json!({"doc":doc}))["attachments"][0]["name"], "visible-name.txt"); - ok(&mut a, "attachment_edit", json!({"doc":doc,"name":"visible-name.txt","description":"Updated"})); + let attachment = &ok(&mut a, "doc_info", json!({"doc":doc}))["attachments"][0]; + assert_eq!(attachment["name"], "visible-name.txt"); + assert_eq!(attachment["key"], key); + ok(&mut a, "attachment_edit", json!({"doc":doc,"key":key,"description":"Updated"})); assert_eq!(ok(&mut a, "doc_info", json!({"doc":doc}))["attachments"][0]["description"], "Updated"); assert!(matches!( a.call("attachment_edit", &json!({"doc":doc,"name":"missing.txt","description":"x"})), diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index 490897b60f..63aac3d4cc 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -506,6 +506,8 @@ pub struct Layer { pub struct Attachment { pub name: String, pub description: Option, + /// Whether the file specification contained `/Desc`, including an empty value. + pub has_description: bool, pub size: Option, pub source: AttachmentSource, } diff --git a/crates/render/src/inspect/resources.rs b/crates/render/src/inspect/resources.rs index 0895c64f8a..a91439885d 100644 --- a/crates/render/src/inspect/resources.rs +++ b/crates/render/src/inspect/resources.rs @@ -227,7 +227,8 @@ impl<'a> Inspector<'a> { .and_then(|d| self.budgeted_text(d, b"UF", text_budget).or_else(|| self.budgeted_text(d, b"F", text_budget))) .or_else(|| fallback(text_budget)) .unwrap_or_else(|| "attachment".into()); - Attachment { name: display, description: d.and_then(|d| self.budgeted_text(d, b"Desc", text_budget)), size, source } + let has_description = d.is_some_and(|d| d.get(b"Desc").is_ok()); + Attachment { name: display, description: d.and_then(|d| self.budgeted_text(d, b"Desc", text_budget)), has_description, size, source } } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 92e60dfe89..67d225323a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -342,7 +342,7 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m return false; } Dialog::AttachmentDescription => { - let Some((doc, generation, _, _)) = app.attachment_description_draft.as_ref() else { + let Some((doc, generation, _, _, _, _)) = app.attachment_description_draft.as_ref() else { req.close = true; return false; }; @@ -353,12 +353,12 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m req.close = true; return false; } - let Some((_, _, key, description)) = app.attachment_description_draft.as_mut() else { + let Some((_, _, _, display_name, description, _)) = app.attachment_description_draft.as_mut() else { req.close = true; return false; }; ui.heading(tl!("Edit attachment description")); - ui.label(key.as_str()); + ui.label(display_name.as_str()); let label = ui.label(tl!("Description")); ui.add(widgets::line(description)).labelled_by(label.id); } @@ -1738,17 +1738,17 @@ fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: } } else if dialog == Dialog::AttachmentDescription { if widgets::pill_button(ui, tl!("OK"), true).clicked() { - if let Some((doc, generation, key, description)) = app.attachment_description_draft.as_ref() { + if let Some((doc, generation, key, _, description, _)) = app.attachment_description_draft.as_ref() { let current = app.session.get(*doc).and_then(|doc| { doc.info .attachments .iter() .find(|attachment| matches!(&attachment.source, pdfcraft_render::AttachmentSource::Document { key: attachment_key } if attachment_key == key)) - .map(|attachment| attachment.description.as_deref().unwrap_or_default().trim().to_owned()) + .map(|attachment| (attachment.description.as_deref().unwrap_or_default().trim().to_owned(), attachment.has_description)) }); let normalized = description.trim(); let still_current = app.session.get(*doc).is_some_and(|doc| doc.edit_generation() == *generation); - if still_current && current.as_deref().is_some_and(|current| current != normalized) { + if still_current && current.as_ref().is_some_and(|(current, has_description)| current != normalized || (normalized.is_empty() && *has_description)) { req.attachment_description_now = Some(( *doc, Edit::EditAttachmentDescription { key: key.clone(), description: Some(normalized.to_owned()) }, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 745fb0e87b..f841059311 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -462,7 +462,7 @@ pub struct PdfCraftApp { /// Document Properties ▸ Description fields being edited: (document, Title/Author/Subject/Keywords). pub props_draft: Option<(DocId, [String; 4])>, /// Attachment description being edited: (document, name-tree key, description). - pub attachment_description_draft: Option<(DocId, u64, String, String)>, + pub attachment_description_draft: Option<(DocId, u64, String, String, String, bool)>, /// Document Properties ▸ Initial View (and reading options) being edited. pub view_draft: Option<(DocId, pdfcraft_engine::InitialView)>, /// Document Properties ▸ Custom being edited. diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 97256ac725..677a2bdf73 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -364,8 +364,14 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if let Some(key) = deletable && ui.button(tl!("Edit description")).clicked() { - app.attachment_description_draft = - Some((id, doc.edit_generation(), key.clone(), a.description.clone().unwrap_or_default())); + app.attachment_description_draft = Some(( + id, + doc.edit_generation(), + key.clone(), + a.name.clone(), + a.description.clone().unwrap_or_default(), + a.has_description, + )); app.dialog = Some(crate::Dialog::AttachmentDescription); ui.close(); } diff --git a/crates/ui-egui/tests/it/attachment_row.rs b/crates/ui-egui/tests/it/attachment_row.rs index b45a3e50cf..5fdc7cfda4 100644 --- a/crates/ui-egui/tests/it/attachment_row.rs +++ b/crates/ui-egui/tests/it/attachment_row.rs @@ -44,10 +44,14 @@ fn attachments_pdf() -> Vec { /// The app with [`attachments_pdf`] open and the Attachments panel showing, at 60 frames a /// second so two clicks a frame apart make a double-click. fn harness() -> Harness<'static, PdfCraftApp> { - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(|_cc| { + harness_with_pdf(attachments_pdf()) +} + +fn harness_with_pdf(pdf: Vec) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("doc.pdf", None, attachments_pdf()).unwrap(); + app.open_bytes("doc.pdf", None, pdf).unwrap(); app.set_option("panel", "attachments").unwrap(); app }); @@ -55,6 +59,40 @@ fn harness() -> Harness<'static, PdfCraftApp> { h } +#[test] +fn submitting_blank_removes_an_existing_whitespace_description() { + let mut pdf = attachments_pdf(); + let desc = pdf.windows(b"(Original)".len()).position(|window| window == b"(Original)").unwrap(); + pdf[desc..desc + b"(Original)".len()].copy_from_slice(b"( )"); + let mut h = harness_with_pdf(pdf); + let doc_id = h.state().views[0].id; + assert!(h.state().session.get(doc_id).unwrap().info.attachments.iter().find(|a| a.name == "notes.txt").unwrap().has_description); + right_click(&mut h, "notes.txt"); + h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Edit description").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + let doc = h.state().session.get(doc_id).unwrap(); + let attachment = doc.info.attachments.iter().find(|a| a.name == "notes.txt").unwrap(); + assert!(!attachment.has_description); + assert!(doc.dirty); +} + +#[test] +fn submitting_blank_leaves_an_absent_description_untouched() { + let mut h = harness(); + right_click(&mut h, "inner.pdf"); + h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Edit description").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + let doc = h.state().session.get(h.state().views[0].id).unwrap(); + let attachment = doc.info.attachments.iter().find(|a| a.name == "inner.pdf").unwrap(); + assert!(!attachment.has_description); + assert!(!doc.dirty); + assert_eq!(doc.can_undo(), None); +} + /// A folder of its own for one test's saved file, removed afterwards. struct Dir(std::path::PathBuf); From d6650acab10f74fce530fdb1b2576e822cdc0968 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:48:52 +0100 Subject: [PATCH 087/101] Block unfocused Ctrl+wheel zoom behind every modal prompt (#3736) --- crates/ui-egui/src/canvas.rs | 6 ++++-- crates/ui-egui/src/canvas/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 49d2c44b03..a6f3d55bc8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3583,9 +3583,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let zoom_delta = crate::motion::zoom_delta(ui.ctx()); // Exclude the previous frame's scrollbar tracks from gesture activation. let navigation_rect = if view.viewport_screen.is_positive() { avail.intersect(view.viewport_screen) } else { avail }; - let navigation_enabled = app.dialog.is_none() && app.close_request.is_none() && !app.palette_open && ui.is_enabled() && ui.input(|i| i.focused); + // Wheel zoom works in a visible window without focus, as wheel scrolling does; the other gestures need focus. + let zoom_enabled = unobstructed && ui.is_enabled(); + let navigation_enabled = zoom_enabled && ui.input(|i| i.focused); 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); + pinch_zoom(ui, view, zoom_delta, navigation_rect, zoom_enabled); view.viewport_screen = avail; let (auto_delta, edge_delta) = auto_scroll_deltas(ui, view, avail, unobstructed, invert_middle); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 461ae954c2..5c8b57e4ae 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1619,6 +1619,34 @@ fn edit_text_boxes_stay_drawn_while_page_input_is_blocked() { assert_eq!(app.views[0].edit_lines.get(&0).map(|(g, _)| *g), Some(generation), "the boxes follow the edit while input is blocked"); } +/// Ctrl+wheel zooms a visible window that another application has focus over, as plain +/// wheel scrolling does; the palette still blocks it. +#[test] +fn ctrl_wheel_zooms_an_unfocused_window() { + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + let bytes = app.session.create_blank(300.0, 400.0, 1).unwrap(); + app.open_bytes("blank.pdf", None, bytes.as_ref().clone()).unwrap(); + app + }); + h.run_steps(3); + let center = h.state().views[0].viewport_rect().center(); + h.hover_at(center); + h.input_mut().focused = false; + h.run_steps(2); + let ctrl_wheel = egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, 1.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::COMMAND }; + h.state_mut().palette_open = true; + let before = h.state().views[0].zoom; + h.event(ctrl_wheel.clone()); + h.run_steps(10); + assert_eq!(h.state().views[0].zoom, before, "the palette still blocks zoom"); + h.state_mut().palette_open = false; + h.run_steps(2); + h.event(ctrl_wheel); + h.run_steps(10); + assert!(h.state().views[0].zoom > before, "Ctrl+wheel zooms without window focus"); +} + #[test] fn virtualized_organizer_keeps_insert_gaps_at_document_positions() { use egui_kittest::kittest::Queryable; From 8314fa12a162751dd339909863cd614718341551 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:49:03 +0100 Subject: [PATCH 088/101] Wrap the Ctrl+wheel test event (#3736) --- crates/ui-egui/src/canvas/tests.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 5c8b57e4ae..7c7b470353 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1634,7 +1634,12 @@ fn ctrl_wheel_zooms_an_unfocused_window() { h.hover_at(center); h.input_mut().focused = false; h.run_steps(2); - let ctrl_wheel = egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, 1.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::COMMAND }; + let ctrl_wheel = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::COMMAND, + }; h.state_mut().palette_open = true; let before = h.state().views[0].zoom; h.event(ctrl_wheel.clone()); From 41d819e2d3bc59ee9fddaa3789f371724772dda3 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:50:45 +0100 Subject: [PATCH 089/101] Give the Ctrl+wheel test event its wheel source (#3736) --- crates/ui-egui/src/canvas/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 7c7b470353..f11ebd125e 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1639,6 +1639,7 @@ fn ctrl_wheel_zooms_an_unfocused_window() { delta: vec2(0.0, 1.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::COMMAND, + source: egui::MouseWheelSource::Unknown, }; h.state_mut().palette_open = true; let before = h.state().views[0].zoom; From 97174c91ebe092a78ddf9ebc17991b285bec13fa Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 06:25:12 +0800 Subject: [PATCH 090/101] M3: the full-screen field click test waits until page 1 is shown Full screen veils a page until its raster is in, and a click on a veiled page turns it. The test clicked after six frames; on a slow CI runner the raster wasn't in yet, so the click on the field turned the page. It now waits (up to 600 frames) until DocView::presentation_settled says the page is wholly in view. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/ui-egui/src/canvas/xform.rs | 6 ++++++ crates/ui-egui/tests/it/view.rs | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/crates/ui-egui/src/canvas/xform.rs b/crates/ui-egui/src/canvas/xform.rs index 0f6249c8a8..71f397bdfb 100644 --- a/crates/ui-egui/src/canvas/xform.rs +++ b/crates/ui-egui/src/canvas/xform.rs @@ -6,6 +6,12 @@ impl DocView { self.screen_xforms.iter().find(|(p, _)| *p == page).map(|(_, xf)| xf.rect) } + /// Whether the page full screen shows is wholly in view: no transition playing or waiting for + /// its raster, so a click on it reaches what is on it. Always true outside full screen. + pub fn presentation_settled(&self) -> bool { + self.presentation.is_none() || self.presenter.settled_on(self.current) + } + /// The document area on screen. pub fn viewport_rect(&self) -> Rect { self.viewport_screen diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 47047d08cf..4bc6c70194 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2761,6 +2761,15 @@ fn full_screen_click_on_a_field_fills_it_and_turns_no_page() { h.key_press_modifiers(Modifiers::COMMAND, Key::L); h.run_steps(6); let n = h.state().views.len() - 1; + // Until page 1's raster is in, full screen veils it and a click turns the page; on a slow + // machine that takes more frames than the six above. + for _ in 0..600 { + if h.state().views[n].presentation_settled() { + break; + } + h.run_steps(1); + } + assert!(h.state().views[n].presentation_settled(), "page 1 is shown"); let r = h.state().views[n].page_screen_rect(0).expect("page 1 on screen"); let on_field = egui::pos2(r.left() + r.width() * 0.5, r.top() + r.height() * (400.0 - 120.0) / 400.0); click_with(&mut h, on_field, Modifiers::NONE); From 9e08c8f78fb742ca289ce9509215de54d3ce12cc Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 01:11:38 +0800 Subject: [PATCH 091/101] M3: the full-screen note test waits for the page's raster (#379) Full screen takes no press on a page whose raster is not in yet; under load six frames were not always enough, and the double-click turned the page instead of opening the note. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/ui-egui/tests/it/view.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 4bc6c70194..5a8938de91 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2886,6 +2886,8 @@ trailer << /Root 1 0 R >> h.run_steps(8); h.key_press_modifiers(Modifiers::COMMAND, Key::L); h.run_steps(6); + // Until the page's raster is in: before it, full screen takes no press on the page. + settle(&mut h); let n = h.state().views.len() - 1; let r = h.state().views[n].page_screen_rect(0).expect("page 1 on screen"); let p = egui::pos2(r.left() + r.width() * 112.0 / 300.0, r.top() + r.height() * (400.0 - 112.0) / 400.0); From 9948e5c8a65f89ff16cd8db87402b44f92a337e3 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 01:47:50 +0800 Subject: [PATCH 092/101] Retrigger CI after the timing-dependent Fill & Sign test (#6066) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA From 0aded601af527596d5939e667f9e0d497f6cf9e4 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 02:17:57 +0800 Subject: [PATCH 093/101] Retrigger CI after an unrelated flaky test (automation batch concurrency) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA From 74e7b2b62fb7668413ee389ada7d8c15f2aac254 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 02:48:00 +0800 Subject: [PATCH 094/101] Retrigger CI after the timing-dependent Fill & Sign test (#6066) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA From 6b1d42d43fbf5daba6bd311923c84208a8247a79 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Mon, 9 Nov 2026 01:08:50 +0800 Subject: [PATCH 095/101] M4: the OCR Verify screen keeps F8 for the next word to review (doc.initial-view) Main's OCR Verify screen walks the words to review with F8 and Shift+F8; the registry's view.toggle_interface shortcut now skips while that screen is open, so both keep working after the merge. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/ui-egui/src/commands.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 9484e709b2..9de9f6d34e 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -819,6 +819,11 @@ impl PdfCraftApp { if matches!(spec.id, "view.previous_view" | "view.next_view") && !self.command_enabled(spec) { continue; } + // The OCR Verify screen keeps the window's toolbars and owns F8 and Shift+F8 (the next + // and previous word to review), so they don't show or hide the interface there. + if spec.id == crate::viewer_prefs::TOGGLE && self.ocr_verify.open { + continue; + } // A box typed into on the page (a paragraph being edited, added text) keeps Undo and // Redo only while it holds typing the page doesn't have, or Redo has typing to put // back; then they reach the document. ⌘⇧Z is also ⌘Z to the Undo shortcut, so a From 690bd7549a6686b2caf2ad0dddedee53e6f904e8 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Mon, 9 Nov 2026 01:23:37 +0800 Subject: [PATCH 096/101] Re-run CI (flaky Fill & Sign test, #6066) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA From d92046f8158d8de35d92e733ccdac208ac88f65f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 9 Nov 2026 18:32:42 +0700 Subject: [PATCH 097/101] Fit the landed changes together Format the attachment-key numbering kept from both sides of #5508's merge. --- crates/organize/src/import/carry.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/organize/src/import/carry.rs b/crates/organize/src/import/carry.rs index 7edc63d12b..8f0e349ea2 100644 --- a/crates/organize/src/import/carry.rs +++ b/crates/organize/src/import/carry.rs @@ -39,7 +39,8 @@ fn write_attachments(dst: &mut Document, entries: &mut [(Vec, Object)], uniq // Names are compared and numbered as text: a key written as UTF-16 (a name with an accent // or Chinese letters) must not get ASCII bytes appended to it. if uniquify { - let reserved: std::collections::HashSet = entries.iter().map(|(k, _)| pdfcraft_cos::PdfString::literal(k.clone()).to_text()).collect(); + let reserved: std::collections::HashSet = + entries.iter().map(|(k, _)| pdfcraft_cos::PdfString::literal(k.clone()).to_text()).collect(); let mut seen = std::collections::HashSet::new(); for (k, _) in entries.iter_mut() { let base = pdfcraft_cos::PdfString::literal(k.clone()).to_text(); From 74b3452328c5399faf3aa8b376874f1595437c2e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 9 Nov 2026 18:37:28 +0700 Subject: [PATCH 098/101] Fit the landed changes together: the print-settings discard is its own function --- crates/optimize/src/lib.rs | 39 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 6817d79877..97296844a2 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -442,26 +442,7 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: if settings.discard_print_settings && let Some(root) = root { - const PRINT: [&[u8]; 7] = [b"PrintScaling", b"Duplex", b"PickTrayByPDFSize", b"PrintPageRange", b"NumCopies", b"PrintArea", b"PrintClip"]; - let vp = doc.get(root).as_dict().and_then(|c| c.get(b"ViewerPreferences").cloned()); - let edit = |d: &mut pdfcraft_cos::Dict| PRINT.iter().filter(|k| d.remove(k).is_some()).count(); - match vp { - Some(Object::Ref(r)) => { - let mut n = 0; - doc.update_dict(r, |d| n = edit(d))?; - report.print_settings += n; - } - Some(Object::Dict(_)) => { - let mut n = 0; - doc.update_dict(root, |c| { - if let Some(Object::Dict(d)) = c.get_mut(b"ViewerPreferences") { - n = edit(d); - } - })?; - report.print_settings += n; - } - _ => {} - } + report.print_settings += discard_print_settings(doc, root)?; } if settings.discard_form_actions { report.form_actions = form_actions::discard(doc)?; @@ -654,6 +635,24 @@ pub(crate) fn stored_len(s: &Stream) -> Option { out.len().checked_add(s.raw.len()) } +/// Removes the print entries from the catalog's /ViewerPreferences, returning how many went. +fn discard_print_settings(doc: &mut Document, root: ObjRef) -> Result { + const PRINT: [&[u8]; 7] = [b"PrintScaling", b"Duplex", b"PickTrayByPDFSize", b"PrintPageRange", b"NumCopies", b"PrintArea", b"PrintClip"]; + let vp = doc.get(root).as_dict().and_then(|c| c.get(b"ViewerPreferences").cloned()); + let edit = |d: &mut pdfcraft_cos::Dict| PRINT.iter().filter(|k| d.remove(k).is_some()).count(); + let mut n = 0; + match vp { + Some(Object::Ref(r)) => doc.update_dict(r, |d| n = edit(d))?, + Some(Object::Dict(_)) => doc.update_dict(root, |c| { + if let Some(Object::Dict(d)) = c.get_mut(b"ViewerPreferences") { + n = edit(d); + } + })?, + _ => {} + } + Ok(n) +} + /// Remove the search index entries of the catalog's `/PieceInfo` ([`Settings::discard_search_index`]) /// and return how many there were. An indirect `/PieceInfo` is not edited: the catalog gets an /// indirect copy without those entries, so other objects that refer to it keep theirs. The objects From fb387d95d924ad50b6b7c5bfd27a5d0c0acbf239 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 9 Nov 2026 18:54:56 +0700 Subject: [PATCH 099/101] Fit the landed changes together: the lock-question test opens the Sign menu by its current label --- crates/ui-egui/tests/it/fill_sign.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index 1ae967ea44..5e0051b126 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -2177,9 +2177,7 @@ fn the_first_save_after_placing_a_signature_asks_to_lock_it() { // The switch is in the panel's Sign yourself section, once; the Sign menu over the page, // where it was easy to miss, no longer has it. h.state_mut().flatten_fill_sign_on_save = false; - h.get_by_label("Fill & Sign").click(); - h.run_steps(2); - h.get_by_label("Fill & Sign").click(); + h.get_by_label("Fill & Sign: Sign").click(); h.run_steps(2); h.get_by_label("Use signature"); assert!(h.query_by_label("Lock into the page when saving").is_none(), "not in the Sign menu"); From f8225052505be9cdd694cb14f70cd7b70e9a7d99 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 9 Nov 2026 21:04:55 +0700 Subject: [PATCH 100/101] Fit the landed changes together: the rotation keys get their own function, keeping the shortcuts under clippy's complexity limit --- crates/ui-egui/src/canvas/navigation.rs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index a67a8dc641..47196ca140 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -330,6 +330,18 @@ impl DocView { } } +/// ⇧⌘+ / ⇧⌘−: rotate the view clockwise / counterclockwise. +fn rotation_shortcuts(view: &mut DocView, pressed: &impl Fn(egui::KeyboardShortcut) -> bool) { + use egui::{Key, KeyboardShortcut, Modifiers}; + let shift_cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, k); + if pressed(shift_cmd(Key::Plus)) || pressed(shift_cmd(Key::Equals)) { + view.rotate_view(true); + } + if pressed(shift_cmd(Key::Minus)) { + view.rotate_view(false); + } +} + /// ⌘+ / ⌘− / ⌘0 / ⌘1 / ⌘2: zoom the page, or size the pages in the page grid. fn zoom_shortcuts(view: &mut DocView, pressed: &impl Fn(egui::KeyboardShortcut) -> bool) { use egui::{Key, KeyboardShortcut, Modifiers}; @@ -394,14 +406,7 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s } let arrows = !form_box.is_some_and(|(_, radio)| radio); // Rotation first: egui matches ⌘+ loosely with respect to Shift. - if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Plus)) - || pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Equals)) - { - view.rotate_view(true); - } - if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Minus)) { - view.rotate_view(false); - } + rotation_shortcuts(view, &pressed); zoom_shortcuts(view, &pressed); // Alt+← / Alt+→ (⌥ on macOS) too, as in Acrobat and browsers, back to where a link was // followed from (#3632). Consumed here, before ← / → turn the page. egui matches the pattern From e52b2de518e40a2945351f986f4328c914110736 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 9 Nov 2026 21:42:59 +0700 Subject: [PATCH 101/101] Fit the landed changes together: the full-screen note test expects its pop-up, and the lock-question test turns the signature tool on before opening the Sign menu --- crates/ui-egui/tests/it/fill_sign.rs | 3 +++ crates/ui-egui/tests/it/view.rs | 7 +++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index 5e0051b126..da69bb37c3 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -2177,6 +2177,9 @@ fn the_first_save_after_placing_a_signature_asks_to_lock_it() { // The switch is in the panel's Sign yourself section, once; the Sign menu over the page, // where it was easy to miss, no longer has it. h.state_mut().flatten_fill_sign_on_save = false; + // With the signature tool on, the quick tool button opens the Sign menu. + h.state_mut().execute("sign.fill.signature"); + h.run_steps(2); h.get_by_label("Fill & Sign: Sign").click(); h.run_steps(2); h.get_by_label("Use signature"); diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index aac51037bd..fa099ed379 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2976,7 +2976,7 @@ fn full_screen_press_made_during_a_transition_opens_nothing_when_released() { assert!(!h.ctx.egui_wants_keyboard_input(), "released on the field once page 2 is in: nothing opens"); } -/// In full screen a double-click on a note opens it to edit, and turns no page: the pointer stays +/// In full screen a double-click on a note opens its pop-up, and turns no page: the pointer stays /// active "so that you can click links and open notes" (*Adobe Acrobat Help*, 2019, p. 65). #[test] fn full_screen_double_click_on_a_note_opens_it_and_turns_no_page() { @@ -3009,7 +3009,10 @@ trailer << /Root 1 0 R >> // Past the double-click time, when a turn would have come. h.run_steps(30); assert_eq!(h.state().views[n].current, 0, "no page turned"); - assert!(h.ctx.egui_wants_keyboard_input(), "the note is open to edit"); + // A double-click on a note opens its pop-up window, as in Acrobat (comment.popups). + let note = + h.state().session.get(h.state().views[n].id).unwrap().info.annotations.iter().find(|a| a.subtype == "Text").cloned().expect("the note"); + assert_eq!(note.popup.map(|w| w.open), Some(true), "the note's pop-up is open"); } /// With Animation effects following the system and the system asking for no animations (Windows'