From 6882275ee5d154528040983ec5d8e7642d0961c9 Mon Sep 17 00:00:00 2001 From: mlkonrad Date: Thu, 15 Oct 2026 09:28:56 +0300 Subject: [PATCH 001/108] M3: touchpad swipes glide on after the fingers lift on Wayland (#531) (#1909) Wayland compositors leave momentum to the client: libinput reports the swipe as pixel deltas and the lift as an axis stop (TouchPhase::End), and the page view stopped dead there (#1708). It now measures the gesture's speed over its last 0.1 s and keeps scrolling through the #759 1:1 path, slowing with a 0.35 s time constant. - No glide for a slow drag, a pause before lifting, zoom or Shift swipes, single-page paging, or with Animation effects off. - Any wheel input, click, key, zoom, dialog or the document's edge ends it. - Off on macOS (it sends its own momentum) and the web; Windows and X11 report no touch phases through winit, so nothing starts a glide there. Not yet: fingers resting on the touchpad don't catch the glide; that needs Wayland hold gestures, which the vendored winit doesn't bind. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 29 +++- crates/ui-egui/src/fling.rs | 276 +++++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/tests/view.rs | 80 ++++++++++ parity/acrobat-features.toml | 4 +- 6 files changed, 386 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/fling.rs diff --git a/ROADMAP.md b/ROADMAP.md index ed84b6ab4e..e34a00af69 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -159,6 +159,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-12 (M3, page-label navigation):** The page box displays logical labels and keeps the current page when its unchanged value is submitted, including duplicate labels. Editable-control regressions also cover numeric fallback, invalid input, active typing and focused same-document replacement with changed labels or fewer pages. Clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Estimates unchanged. - **2026-10-13 (community, M2/M6, form-heavy documents):** An image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN spot-colour image, a 1-4-bit gray or indexed one) is converted once per possible pixel value instead of once per pixel (vendored hayro-interpret patch 28). A fillable character sheet whose 3304 × 4105 DeviceN background ran a PostScript tint transform per pixel drew each page in 3.8 s and 430 MB, on opening and again after every field was filled; it now draws in 0.11 s and 100 MB, pixel for pixel the same. The inspector places widgets without `/P` in one pass over the pages (a form of 8,000 such fields opened in 1.17 s), and the Fields panel pairs its rows with the form through one map a frame and draws only the rows in view (8,000 fields: about 15× less CPU a frame with the panel open). Regressions fail before each fix. - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. +- **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged. - **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged. - **2026-10-12 (M4, #1310, #1311, #1312):** Undo back to the opened file left it marked unsaved, so recovery and close treated unchanged work as dirty. After a save, Revert followed the restored snapshot's older base and the next save dropped the latest saved revision. The last saved file is remembered on its own: undo and redo are clean only when they land on it, Revert opens it, and a later save appends to it. Estimates unchanged. - **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f2f60a742e..fc4366ff06 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -396,6 +396,8 @@ pub struct DocView { /// (#759). The window outlasts a single frame so egui's internal easing tail is suppressed as /// it decays, instead of adding on top of the 1:1 motion. precise_scroll_until: f64, + /// Touchpad momentum after the fingers lift (#531). + fling: crate::fling::Fling, pub(crate) auto_scroll: crate::autoscroll::AutoScroll, /// Pages selected in the organize grid or the Pages panel (0-based). Empty means "the current page". pub selected: BTreeSet, @@ -650,6 +652,7 @@ impl DocView { shown: false, wheel: Default::default(), precise_scroll_until: 0.0, + fling: Default::default(), auto_scroll: Default::default(), selected: BTreeSet::new(), select_anchor: None, @@ -2278,6 +2281,22 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } else { precise_scroll = Vec2::ZERO; } + // Touchpad momentum (#531): a swipe over the page lifted at speed glides on and slows to a + // stop, through the same un-eased path. Never while the pager owns the wheel, a gesture or + // dialog has the input, or animations are off; any click, key, zoom or other scrolling ends it. + // Asked of the preference, not of egui's animation time, which test harnesses zero. + let animates = app.animation_effects.animates(app.motion.reduced); + let may_glide = !single_paging && !middle_gesture && navigation_enabled && unobstructed && animates; + let over_page = ui.rect_contains_pointer(avail); + ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page)); + let interrupted = ui.input(|i| i.pointer.any_pressed() || i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. }))); + if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 { + view.fling.stop(); + } + let glide = view.fling.step(now); + if view.fling.gliding() { + ui.ctx().request_repaint(); + } let visible_pages: Vec = if view.continuous { (0..rects.len()).collect() } else { row.clone() }; let (y_shift, content_h) = if view.continuous { (0.0, rects.iter().map(|r| r.bottom()).reduce(f32::max).map(|bottom| bottom + MARGIN).unwrap_or(0.0)) @@ -2434,9 +2453,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Only interactions pause; the document must keep its original colours. ui.set_opacity(opacity); } - // Middle-button auto-scroll, the keyboard and precise touchpad input (#759); positive y - // moves the content down. All three scroll without easing, unlike egui's wheel default. - let delta = auto_delta - vec2(0.0, key_scroll) + precise_scroll; + // Middle-button auto-scroll, the keyboard, precise touchpad input (#759) and its glide + // (#531); positive y moves the content down. All scroll without easing, unlike egui's + // wheel default. + let delta = auto_delta - vec2(0.0, key_scroll) + precise_scroll + glide; if delta != Vec2::ZERO { ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); } @@ -3227,6 +3247,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.pan_hold_until = now_settle + 0.25; ui.ctx().request_repaint_after(std::time::Duration::from_millis(280)); } + if glide != Vec2::ZERO { + view.fling.moved((scroll_offset - view.last_scroll).length() > 0.01); + } view.last_scroll = scroll_offset; let pan_held = now_settle < view.pan_hold_until; let (mut wanted, visible_now) = out.inner; diff --git a/crates/ui-egui/src/fling.rs b/crates/ui-egui/src/fling.rs new file mode 100644 index 0000000000..cce004ce53 --- /dev/null +++ b/crates/ui-egui/src/fling.rs @@ -0,0 +1,276 @@ +//! Touchpad momentum (#531): a two-finger swipe keeps gliding after the fingers lift, and slows +//! to a stop. +//! +//! Wayland compositors leave momentum to the client. libinput reports the swipe as pixel deltas +//! (`MouseWheelUnit::Point`) and the lift as an axis stop, which winit passes on as +//! `TouchPhase::End`; GTK and Firefox glide on from there, and so does the page view. +//! +//! macOS sends its own momentum as a second `Start`…`End`, so gliding there would glide twice, +//! and the web has no touch phases: [`Fling::enabled`] is off on both. Windows and X11 report no +//! touch phases through winit either, so no `End` ever starts a glide there. + +use egui::{Event, MouseWheelUnit, TouchPhase, Vec2}; + +/// Seconds of the gesture the lift speed is measured over. +const WINDOW: f64 = 0.1; +/// A lift this long after the last motion is a finger that stopped first: no glide. +const STILL: f64 = 0.05; +/// The glide slows by `e` every this many seconds; it travels the lift speed times this. +const TAU: f32 = 0.35; +/// Below this speed (points per second) a lift doesn't glide, and a glide stops. +const START_SPEED: f32 = 150.0; +const STOP_SPEED: f32 = 10.0; +/// Fastest glide, in points per second: a flick the system measured over a hiccup can't throw +/// the document to its end. +const MAX_SPEED: f32 = 8000.0; +/// A pass this long after the last one (the view was hidden, the app stalled) ends a glide. +const STALE: f64 = 0.1; + +pub(crate) struct Fling { + /// Whether a lift glides on this platform (see the module docs). + pub(crate) enabled: bool, + /// The gesture in progress: when each pass's motion arrived, and how far it went. + samples: Vec<(f64, Vec2)>, + /// The glide's speed in points per second (positive moves the content down), or zero. + velocity: Vec2, + /// When the glide last moved. + at: f64, + /// Passes in a row the glide moved the view not at all: it is against an edge. + stalled: u8, +} + +impl Default for Fling { + fn default() -> Self { + Self::new(!cfg!(any(target_os = "macos", target_arch = "wasm32"))) + } +} + +impl Fling { + pub(crate) fn new(enabled: bool) -> Self { + Self { enabled, samples: Vec::new(), velocity: Vec2::ZERO, at: 0.0, stalled: 0 } + } + + pub(crate) fn gliding(&self) -> bool { + self.velocity != Vec2::ZERO + } + + pub(crate) fn stop(&mut self) { + self.velocity = Vec2::ZERO; + self.stalled = 0; + } + + /// Reads one pass's wheel events, at time `now`. Any wheel input stops a glide; a touchpad + /// gesture's `End` may start one. `glide` is false where the view must not glide this pass + /// (animations off, single-page paging, a zoom, a dialog). + pub(crate) fn read(&mut self, events: &[Event], now: f64, glide: bool) { + for event in events { + let Event::MouseWheel { unit, delta, phase, modifiers } = event else { continue }; + self.stop(); + // Zooming, a mouse wheel and sideways Shift scrolling never glide. + if *unit != MouseWheelUnit::Point || modifiers.command || modifiers.ctrl || modifiers.shift { + self.samples.clear(); + continue; + } + match phase { + TouchPhase::Start => { + self.samples.clear(); + self.push(now, *delta); + } + TouchPhase::Move => self.push(now, *delta), + TouchPhase::End => { + if glide && self.enabled { + self.velocity = self.lift_velocity(now); + self.at = now; + } + self.samples.clear(); + } + TouchPhase::Cancel => self.samples.clear(), + } + } + } + + fn push(&mut self, now: f64, delta: Vec2) { + if !(delta.x.is_finite() && delta.y.is_finite()) { + return; + } + match self.samples.last_mut() { + // Several events in one pass are one sample: they share its time. + Some((at, d)) if *at == now => *d += delta, + _ => self.samples.push((now, delta)), + } + self.samples.retain(|(at, _)| now - at <= WINDOW); + } + + /// The finger's speed as it lifted at `now`, or zero when it should not glide. + fn lift_velocity(&self, now: f64) -> Vec2 { + let recent: Vec<&(f64, Vec2)> = self.samples.iter().filter(|(at, _)| now - at <= WINDOW).collect(); + let (Some(first), Some(last)) = (recent.first(), recent.last()) else { return Vec2::ZERO }; + let span = (last.0 - first.0) as f32; + if recent.len() < 2 || span <= 0.0 || now - last.0 > STILL { + return Vec2::ZERO; + } + // Each sample is the motion since the pass before it, so the first one's motion + // happened before the span starts. + let moved = recent.iter().skip(1).fold(Vec2::ZERO, |sum, (_, d)| sum + *d); + let v = moved / span; + if !(v.x.is_finite() && v.y.is_finite()) || v.length() < START_SPEED { + return Vec2::ZERO; + } + if v.length() > MAX_SPEED { v * (MAX_SPEED / v.length()) } else { v } + } + + /// How far the glide moves the view in the pass at `now` (zero when not gliding), as the + /// exact distance a speed decaying with [`TAU`] covers since the last pass. + pub(crate) fn step(&mut self, now: f64) -> Vec2 { + if !self.gliding() { + return Vec2::ZERO; + } + let dt = now - self.at; + if !(0.0..=STALE).contains(&dt) { + self.stop(); + return Vec2::ZERO; + } + self.at = now; + let decay = (-(dt as f32) / TAU).exp(); + let moved = self.velocity * TAU * (1.0 - decay); + self.velocity *= decay; + if self.velocity.length() < STOP_SPEED { + self.stop(); + } + moved + } + + /// After a pass the glide moved the view in: whether the offset changed. Two passes that + /// didn't move it mean an edge, and the glide ends there. + pub(crate) fn moved(&mut self, moved: bool) { + if moved { + self.stalled = 0; + } else { + self.stalled = self.stalled.saturating_add(1); + if self.stalled >= 2 { + self.stop(); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{Modifiers, vec2}; + + fn wheel(dy: f32, phase: TouchPhase) -> Event { + Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, dy), phase, modifiers: Modifiers::NONE } + } + + /// A swipe of `moves` passes of `dy` each, 60 per second, lifted the pass after the last. + fn swipe(fling: &mut Fling, dy: f32, moves: usize) -> f64 { + let dt = 1.0 / 60.0; + let mut now = 0.0; + fling.read(&[wheel(dy, TouchPhase::Start)], now, true); + for _ in 0..moves { + now += dt; + fling.read(&[wheel(dy, TouchPhase::Move)], now, true); + } + now += dt; + fling.read(&[wheel(0.0, TouchPhase::End)], now, true); + now + } + + #[test] + fn a_flick_glides_on_at_its_lift_speed_and_stops() { + let mut fling = Fling::new(true); + let mut now = swipe(&mut fling, -25.0, 6); + assert!(fling.gliding()); + // 25 points a pass at 60 passes a second. + assert!((fling.velocity.y + 1500.0).abs() < 1.0, "{:?}", fling.velocity); + let mut travelled = 0.0; + let mut passes = 0; + while fling.gliding() { + now += 1.0 / 60.0; + let d = fling.step(now); + assert!(d.y <= 0.0 && d.x == 0.0, "keeps the swipe's direction: {d:?}"); + travelled += d.y; + passes += 1; + assert!(passes < 600, "the glide ends"); + } + // Speed × TAU, less the tail under STOP_SPEED. + assert!((-travelled - 1500.0 * TAU).abs() < 10.0, "travelled {travelled}"); + assert_eq!(fling.step(now + 1.0 / 60.0), Vec2::ZERO); + } + + #[test] + fn a_slow_drag_or_a_pause_before_lifting_does_not_glide() { + let mut fling = Fling::new(true); + swipe(&mut fling, -1.0, 6); + assert!(!fling.gliding(), "60 points a second is below the start speed"); + + let mut fling = Fling::new(true); + let dt = 1.0 / 60.0; + fling.read(&[wheel(-25.0, TouchPhase::Start)], 0.0, true); + fling.read(&[wheel(-25.0, TouchPhase::Move)], dt, true); + fling.read(&[wheel(0.0, TouchPhase::End)], dt + 0.2, true); + assert!(!fling.gliding(), "the finger rested before it lifted"); + } + + #[test] + fn new_input_stops_a_glide() { + let mut fling = Fling::new(true); + let now = swipe(&mut fling, -25.0, 6); + fling.read(&[wheel(-2.0, TouchPhase::Start)], now + 0.1, true); + assert!(!fling.gliding(), "fingers back on the touchpad"); + + let mut fling = Fling::new(true); + let now = swipe(&mut fling, -25.0, 6); + let notch = Event::MouseWheel { unit: MouseWheelUnit::Line, delta: vec2(0.0, -1.0), phase: TouchPhase::Move, modifiers: Modifiers::NONE }; + fling.read(&[notch], now + 0.1, true); + assert!(!fling.gliding(), "a wheel notch"); + } + + #[test] + fn no_glide_where_it_is_off_or_not_wanted() { + let mut off = Fling::new(false); + swipe(&mut off, -25.0, 6); + assert!(!off.gliding(), "macOS and the web glide by themselves or not at all"); + + let mut fling = Fling::new(true); + let dt = 1.0 / 60.0; + fling.read(&[wheel(-25.0, TouchPhase::Start)], 0.0, true); + fling.read(&[wheel(-25.0, TouchPhase::Move)], dt, true); + fling.read(&[wheel(-25.0, TouchPhase::Move)], 2.0 * dt, true); + fling.read(&[wheel(0.0, TouchPhase::End)], 3.0 * dt, false); + assert!(!fling.gliding(), "animations off, paging or a dialog"); + + let mut zoom = Fling::new(true); + let ctrl = |phase| Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, -25.0), phase, modifiers: Modifiers::COMMAND }; + zoom.read(&[ctrl(TouchPhase::Start)], 0.0, true); + zoom.read(&[ctrl(TouchPhase::Move)], dt, true); + zoom.read(&[ctrl(TouchPhase::Move)], 2.0 * dt, true); + zoom.read(&[ctrl(TouchPhase::End)], 3.0 * dt, true); + assert!(!zoom.gliding(), "a pinch or ⌘/Ctrl swipe zooms"); + } + + #[test] + fn an_edge_a_stall_or_a_wild_speed_ends_or_caps_the_glide() { + let mut fling = Fling::new(true); + let now = swipe(&mut fling, -25.0, 6); + fling.step(now + 1.0 / 60.0); + fling.moved(false); + assert!(fling.gliding(), "one pass may not show the move yet"); + fling.moved(false); + assert!(!fling.gliding(), "against the edge"); + + let mut fling = Fling::new(true); + let now = swipe(&mut fling, -25.0, 6); + assert_eq!(fling.step(now + 1.0), Vec2::ZERO, "a second-long gap (the tab was hidden)"); + assert!(!fling.gliding()); + + let mut fling = Fling::new(true); + swipe(&mut fling, -5000.0, 6); + assert!((fling.velocity.length() - MAX_SPEED).abs() < 1.0, "{:?}", fling.velocity); + + let mut fling = Fling::new(true); + swipe(&mut fling, f32::NAN, 6); + assert!(!fling.gliding(), "a speed that isn't a number"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index aef83403df..8aacc7eea0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -78,6 +78,7 @@ mod editing; pub mod file_watch; mod files; pub mod fill_sign; +mod fling; pub mod folders_ui; pub mod forms_ui; mod home; diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index a48a86c07a..1f27a7314c 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -256,6 +256,86 @@ fn precise_touchpad_wheel_scrolls_one_to_one() { assert!(drift.abs() < 2.0, "no easing tail after the swipe: drifted {drift}"); } +/// Steps `frames` frames and returns how far the document scrolled down over them, from the +/// pages on screen in each pair of consecutive frames (a page can leave the screen on the way). +fn scroll_over(h: &mut Harness<'static, PdfCraftApp>, frames: usize) -> f32 { + let tops = |h: &Harness<'static, PdfCraftApp>| (0..3).map(|p| rect(h, p).map(|r| r.min.y)).collect::>(); + let mut moved = 0.0; + for _ in 0..frames { + let before = tops(h); + h.step(); + let after = tops(h); + let step = before.iter().zip(&after).find_map(|(b, a)| Some(b.as_ref()? - a.as_ref()?)); + moved += step.expect("a page stays on screen from one frame to the next"); + } + moved +} + +/// A two-finger swipe down the page, 25 points a frame for six frames, lifted the frame after +/// (the axis stop Wayland sends), at 300 % so the document is long enough to glide in: how far +/// it scrolled by the frame after the lift (the page is drawn a frame behind its scroll). +fn swipe_and_lift(h: &mut Harness<'static, PdfCraftApp>) -> f32 { + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(2); + h.event(wheel(MouseWheelUnit::Point, -25.0, TouchPhase::Start)); + let mut moved = scroll_over(h, 1); + for _ in 0..6 { + h.event(wheel(MouseWheelUnit::Point, -25.0, TouchPhase::Move)); + moved += scroll_over(h, 1); + } + h.event(wheel(MouseWheelUnit::Point, 0.0, TouchPhase::End)); + moved + scroll_over(h, 2) +} + +/// The touchpad glide is Wayland's: macOS sends its own momentum and the web none (#531). +#[cfg(not(target_os = "macos"))] +#[test] +fn touchpad_swipe_glides_on_after_the_fingers_lift_and_stops() { + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "300")]); + let swiped = swipe_and_lift(&mut h); + // The seven 25-point motions, 1:1 (#759). + assert!(swiped >= 174.0, "the swipe scrolls 1:1: moved {swiped}"); + // 1500 points a second at the lift: about 190 points over the next 10 frames. + let glided = scroll_over(&mut h, 10); + assert!(glided > 100.0, "the page glides on after the lift: moved {glided} more"); + let rest = scroll_over(&mut h, 180); + let drift = scroll_over(&mut h, 20); + assert!(drift.abs() < 0.5, "the glide slows to a stop: still drifting {drift}"); + let total = swiped - 175.0 + glided + rest; + assert!(total > 400.0 && total < 600.0, "about the lift speed × 0.35 s: glided {total}"); +} + +#[cfg(not(target_os = "macos"))] +#[test] +fn touching_the_touchpad_again_or_turning_animations_off_stops_the_glide() { + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "300")]); + swipe_and_lift(&mut h); + assert!(scroll_over(&mut h, 3) > 30.0, "gliding"); + h.event(wheel(MouseWheelUnit::Point, 0.0, TouchPhase::Start)); + scroll_over(&mut h, 2); + let drift = scroll_over(&mut h, 20); + assert!(drift.abs() < 0.5, "fingers back down catch the page: drifted {drift}"); + + // A click (a pressed touchpad, or a tap with tap-to-click) catches it too. + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "300")]); + swipe_and_lift(&mut h); + assert!(scroll_over(&mut h, 3) > 30.0, "gliding"); + let at = h.state().views[0].viewport_rect().center(); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + scroll_over(&mut h, 2); + let drift = scroll_over(&mut h, 20); + assert!(drift.abs() < 0.5, "a click catches the page: drifted {drift}"); + + // Animation effects off: the page stops with the fingers, as before #531. + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "300")]); + h.state_mut().set_option("animation_effects", "off").unwrap(); + let swiped = swipe_and_lift(&mut h); + assert!((swiped - 175.0).abs() < 1.0, "the swipe still scrolls 1:1: moved {swiped}"); + let drift = scroll_over(&mut h, 20); + assert!(drift.abs() < 0.5, "no glide with animations off: drifted {drift}"); +} + /// The 40 frames from one wheel notch on, with the system asking for no animations (Windows' /// Animation effects turned off) or not, and Preferences ▸ Animation effects as given: how far /// page 1 has moved up in each frame, or with ⌘/Ctrl held, the view's zoom. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ad337a87e8..0a38160463 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -854,8 +854,8 @@ milestone = "M3" status = "shipped" commands = ["view.continuous"] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/src/canvas.rs::fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past"] -notes = "the default for PDFs that don't ask for a layout (Preferences ▸ Default page display); pages stack with no snapping. Fit page and Fit height fit the largest page, so the zoom holds still across mixed page sizes" +evidence = ["crates/ui-egui/tests/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/src/canvas.rs::fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past", "crates/ui-egui/tests/view.rs::touchpad_swipe_glides_on_after_the_fingers_lift_and_stops", "crates/ui-egui/tests/view.rs::touching_the_touchpad_again_or_turning_animations_off_stops_the_glide"] +notes = "the default for PDFs that don't ask for a layout (Preferences ▸ Default page display); pages stack with no snapping. Fit page and Fit height fit the largest page, so the zoom holds still across mixed page sizes. A touchpad swipe glides on after the fingers lift on Wayland (#531; macOS glides by itself; Windows and X11 report no lift through winit), unless Animation effects is off; a click, key, zoom or new scroll catches it. Not yet: fingers resting on the touchpad don't catch it (needs Wayland hold gestures, which the vendored winit doesn't bind)" [[feature]] id = "view.layout-single-page" From 1ae12090d00e2da9cb6951c3619f579e6ea90dfe Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 15 Oct 2026 14:29:06 +0800 Subject: [PATCH 002/108] fix(redact): the tail of a longer number is not a phone number (#1884) (#1911) The phone pattern tried every position, so the last groups of a French social security number, an IBAN or a SIRET matched as a trunk-0 or NANP number once the groups before them were skipped, and the start of the identifier stayed visible. A phone number is now a whole run of digit groups: one reached through separators from a digit before it, or going on past the 28-character cap, is not one. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Fable 5.1 --- crates/redact/src/patterns.rs | 41 ++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/redact/src/patterns.rs b/crates/redact/src/patterns.rs index 0af3b02a95..18fc65e339 100644 --- a/crates/redact/src/patterns.rs +++ b/crates/redact/src/patterns.rs @@ -68,18 +68,41 @@ fn digit_run(s: &[char], start: usize, seps: &[char], max_len: usize) -> (usize, (i, n) } +/// Whether the separated digit run goes on past `k` in the direction `step` (1 or −1): the next +/// digit after `k` is reached through separators only. +fn run_continues(s: &[char], k: usize, step: isize, seps: &[char]) -> bool { + let mut j = k; + loop { + let Some(c) = j.checked_add_signed(step).and_then(|j| s.get(j)) else { return false }; + if c.is_ascii_digit() { + return true; + } + if !seps.contains(c) { + return false; + } + j = j.wrapping_add_signed(step); + } +} + fn phone(s: &[char], i: usize) -> Option { + const SEPS: [char; 5] = [' ', '-', '.', '(', ')']; let start_ok = s[i] == '+' && digit(s.get(i + 1)) || s[i] == '(' && digit(s.get(i + 1)) || s[i].is_ascii_digit(); if !start_ok || word(i.checked_sub(1).and_then(|p| s.get(p))) || s.get(i.wrapping_sub(1)) == Some(&'+') { return None; } + // A phone number is a whole run of digit groups. The tail of a longer one (an IBAN, a + // company or social security number) is not a phone number, however it is grouped (#1884). + if run_continues(s, i, -1, &SEPS) { + return None; + } let plus = s[i] == '+'; let first = if plus { i + 1 } else { i }; - let (end, n) = digit_run(s, first, &[' ', '-', '.', '(', ')'], 28); + let (end, n) = digit_run(s, first, &SEPS, 28); let run = s.get(first..end)?; let seps = run.iter().filter(|c| !c.is_ascii_digit()).count(); - // Always grouped by at least one separator, so plain numbers never match. - if seps == 0 || digit(s.get(end)) { + // Always grouped by at least one separator, so plain numbers never match; nor does the + // head of a run too long to be a phone number. + if seps == 0 || digit(s.get(end)) || run_continues(s, end - 1, 1, &SEPS) { return None; } let ok = if plus { @@ -358,6 +381,18 @@ mod tests { ); } + #[test] + fn the_tail_of_a_longer_number_is_not_a_phone_number() { + // #1884: the last groups of a French social security number, an IBAN and a SIRET used + // to be marked as phone numbers (and the start left visible). Example numbers. + for s in ["NIR 1 85 05 78 006 084 36 end", "IBAN FR76 3000 6000 0112 3456 7890 189", "SIRET 123 456 782 00010"] { + assert_eq!(found(Pattern::Phone, s), Vec::::new(), "{s}"); + } + assert_eq!(found(Pattern::Phone, "tel 01 23 45 67 89 / +33 1 23 45 67 89"), ["01 23 45 67 89", "+33 1 23 45 67 89"]); + // Nor the head of a run too long to be one: 28 characters in, this one still goes on. + assert_eq!(found(Pattern::Phone, "01 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7"), Vec::::new()); + } + #[test] fn emails() { assert_eq!( From 824837915205ee9bd19de641ee6597d0b8adf2c2 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 15 Oct 2026 14:29:16 +0800 Subject: [PATCH 003/108] fix(forms): new text and date fields take Acrobat's Auto font size (#1888) (#1912) add_field gave text and date fields a fixed /Helv 12 Tf, which is clipped in a box shorter than 12 pt, and a script can't see the clipping. They now get /Helv 0 Tf (Auto), as Acrobat's new text fields do: 12 pt where there is room, smaller where there isn't. An explicit size set afterwards still wins; choice fields keep 12 pt. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Fable 5.1 --- crates/forms/src/author.rs | 10 ++++++---- crates/forms/src/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 6d49be1001..0b60506c76 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -1,8 +1,9 @@ //! Prepare a form: create, change and delete fields (execution plan M6.6). //! //! New fields follow Acrobat's conventions: names "Text1", "Check Box1", "Group1" (radio groups), -//! "Dropdown1", "List Box1", "Button1", "Signature1", "Date1"; 12 pt Helvetica (auto-size for -//! check boxes), a thin grey border and a white background, and appearance streams generated +//! "Dropdown1", "List Box1", "Button1", "Signature1", "Date1"; Helvetica at Acrobat's Auto size +//! for text and date fields (12 pt in a box that has room, smaller in a short one), 12 pt in +//! choice fields, a thin grey border and a white background, and appearance streams generated //! immediately so every viewer shows the empty field. use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; @@ -545,14 +546,15 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel match kind { NewField::Text { multiline } => { w.set(b"FT".to_vec(), Object::name("Tx")); - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 12 Tf 0 g".to_vec())); + // Auto size (0): a fixed 12 pt is clipped in a box shorter than that (#1888). + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); if *multiline { w.set(b"Ff".to_vec(), Object::Int(flags::MULTILINE as i64)); } } NewField::Date => { w.set(b"FT".to_vec(), Object::name("Tx")); - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 12 Tf 0 g".to_vec())); + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); // Acrobat's format and keystroke actions for mm/dd/yyyy (run by its JavaScript engine). let js = |s: &str| { let mut a = Dict::new(); diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 0256bfafb9..d434957eb0 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -895,6 +895,34 @@ fn formats_validation_and_calculations_run_like_acrobat() { assert_eq!(field(&fields(&doc), "Price").actions.format, Format::None); } +#[test] +fn new_text_fields_fit_their_box() { + // #1888: a fixed 12 pt was clipped in a short box. New text and date fields take Acrobat's + // Auto size: 12 pt where there is room, smaller where there isn't. + let mut doc = one_page(); + let tf_size = |ap: &str| { + ap.split("/Helv ") + .nth(1) + .and_then(|s| s.split(' ').next()) + .and_then(|n| n.parse::().ok()) + .unwrap_or_else(|| panic!("no /Helv size in {ap}")) + }; + for (kind, name, value) in [(NewField::Text { multiline: false }, "short", "Hello world"), (NewField::Date, "date", "10/13/2026")] { + add_field(&mut doc, 0, [20.0, 20.0, 200.0, 28.0], &kind, Some(name)).unwrap(); + set_value(&mut doc, name, &FieldValue::Text(value.into())).unwrap(); + let f = field(&fields(&doc), name).clone(); + assert_eq!(f.da, "/Helv 0 Tf 0 g", "{name}"); + let size = tf_size(&ap(&doc, &f.widgets[0])); + assert!(size > 0.0 && size <= 8.0, "{name}: {size} pt in an 8 pt box"); + } + add_field(&mut doc, 0, [20.0, 50.0, 200.0, 72.0], &NewField::Text { multiline: false }, Some("tall")).unwrap(); + set_value(&mut doc, "tall", &FieldValue::Text("Hello world".into())).unwrap(); + assert_eq!(tf_size(&ap(&doc, &field(&fields(&doc), "tall").widgets[0])), 12.0, "a box with room keeps the 12 pt look"); + // An explicit size still wins. + set_props(&mut doc, "short", &FieldProps { font_size: Some(12.0), ..FieldProps::default() }).unwrap(); + assert_eq!(tf_size(&ap(&doc, &field(&fields(&doc), "short").widgets[0])), 12.0); +} + #[test] fn appearance_properties_restyle_every_widget() { let mut doc = one_page(); From 668f491771497efc4fdf9c7e0da58969563b93f2 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 15 Oct 2026 14:29:26 +0800 Subject: [PATCH 004/108] feat(ui): a middle click closes a tab (#1853) (#1920) As in Acrobat and browsers. A document tab with unsaved changes asks first, through the same close request as its close button; the Combine files tab closes too. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Fable 5.1 --- crates/ui-egui/src/chrome.rs | 7 ++++++- crates/ui-egui/tests/ui.rs | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index f4bfa22c35..9e376e373d 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -170,6 +170,11 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if response.clicked() { app.active = Some(i); } + // A middle click closes the tab, as in Acrobat and browsers (#1853); + // unsaved changes ask first, as the close button does. + if response.middle_clicked() { + close = Some(i); + } } // Dragging a tab moves it (#1537): a line marks the gap it will land // in, and releasing the button drops it there. @@ -222,7 +227,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if response.clicked() { app.open_combine_tab(); } - if close.is_some() { + if close.is_some() || response.middle_clicked() { app.close_combine_tab(); } } diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 4c1bdffbec..b45e7775b8 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -270,6 +270,28 @@ fn a_tab_with_an_arabic_file_name_keeps_its_logical_accessible_name() { h.get_by_label(long); } +/// A middle click on a tab closes it, as in Acrobat and browsers (#1853); a tab with unsaved +/// changes asks first, as its close button does. +#[test] +fn a_middle_click_closes_the_tab() { + let mut h = harness(|app| { + app.open_bytes("first.pdf", None, FIXTURE.to_vec()).unwrap(); + app.open_bytes("second.pdf", None, FIXTURE.to_vec()).unwrap(); + }); + h.get_by_label("first.pdf").click_button(egui::PointerButton::Middle); + h.run_steps(3); + let app = h.state(); + assert_eq!(app.views.len(), 1); + assert_eq!(app.session.get(app.views[0].id).unwrap().name, "second.pdf"); + assert!(app.close_request.is_none(), "a clean document closes without asking"); + h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); + h.run_steps(2); + h.get_by_label("second.pdf (edited)").click_button(egui::PointerButton::Middle); + h.run_steps(3); + assert_eq!(h.state().views.len(), 1, "unsaved changes ask first"); + assert!(h.state().close_request.is_some()); +} + #[test] fn overflowing_tabs_can_be_reached_by_scrolling() { let mut h = harness(|app| { From af62daeaf8914dbf8ef41cbcfe6b974597e1b6f8 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 15 Oct 2026 14:29:36 +0800 Subject: [PATCH 005/108] fix(organize): bound how deep an import follows the object graph (#1800) (#1921) The page Copier recursed once per reference and once per nested array or dictionary with no bound, so a source whose page reached a chain of thousands of shallow indirect objects overflowed the stack in Insert Pages, Combine, Extract, Split and Duplicate; the memo map only cut cycles. The copy now keeps a depth of at most 256, refuses with an OrganizeError beyond it, and import_pages_with frees the objects a refused copy added, so the destination is as it was. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Fable 5.1 --- crates/organize/src/import.rs | 138 ++++++++++++++++++++-------------- crates/organize/src/pdfx.rs | 2 +- crates/organize/src/tests.rs | 40 ++++++++++ 3 files changed, 123 insertions(+), 57 deletions(-) diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index c420d2d59c..4527aab690 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -28,8 +28,16 @@ use crate::{INHERITABLE, OrganizeError, pages_root, rebuild, walk}; const SKIP_ON_PAGE: &[&[u8]] = &[b"Parent", b"B", b"StructParents", b"Thumb", b"PieceInfo"]; const SKIP_ON_ANNOT: &[&[u8]] = &[b"StructParent", b"P"]; +/// How deep a copy follows references and nested arrays and dictionaries, counted together. +/// Real documents stay well under this (a page's resources, fonts and descriptors, nested +/// form XObjects and field trees are a few levels each); a chain of thousands of indirect +/// objects, each shallow enough for the parser, would otherwise exhaust the stack (#1800). +const MAX_COPY_DEPTH: usize = 256; + struct Copier<'a> { src: &'a Document, + /// Current recursion depth, bounded by [`MAX_COPY_DEPTH`]. + depth: usize, map: HashMap, /// Source page → destination page, for rewriting `/P` and destinations. pages: HashMap, @@ -41,11 +49,15 @@ struct Copier<'a> { ocgs: Vec<(ObjRef, ObjRef)>, } -impl Copier<'_> { +impl<'a> Copier<'a> { + fn new(src: &'a Document, map: HashMap, pages: HashMap) -> Self { + Copier { src, depth: 0, map, pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() } + } + /// Copy an indirect object (once), returning its destination reference. - fn copy_ref(&mut self, dst: &mut Document, r: ObjRef) -> ObjRef { + fn copy_ref(&mut self, dst: &mut Document, r: ObjRef) -> Result { if let Some(d) = self.map.get(&r) { - return *d; + return Ok(*d); } // Reserve the number first so cycles (field ↔ widget) terminate. let new = dst.add(Object::Null); @@ -54,26 +66,41 @@ impl Copier<'_> { if obj.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OCG")) { self.ocgs.push((r, new)); } - let copied = self.copy_value(dst, &obj, Some(new)); + let copied = self.copy_value(dst, &obj, Some(new))?; dst.set(new, copied); - new + Ok(new) } - fn copy_value(&mut self, dst: &mut Document, o: &Object, this: Option) -> Object { - match o { - Object::Ref(r) => Object::Ref(self.copy_ref(dst, *r)), - Object::Array(a) => Object::Array(a.iter().map(|x| self.copy_value(dst, x, None)).collect()), - Object::Dict(d) => Object::Dict(self.copy_dict(dst, d, this)), - Object::Stream(s) => { - let mut s = s.clone(); - s.dict = self.copy_dict(dst, &s.dict, None); - Object::Stream(s) - } - other => other.clone(), + /// One level deeper into the object graph, or the refusal when there are too many. + fn descend(&mut self) -> Result<(), OrganizeError> { + if self.depth >= MAX_COPY_DEPTH { + return Err(OrganizeError::Invalid(format!("the pages refer to objects nested more than {MAX_COPY_DEPTH} levels deep"))); } + self.depth += 1; + Ok(()) } - fn copy_dict(&mut self, dst: &mut Document, d: &Dict, this: Option) -> Dict { + fn copy_value(&mut self, dst: &mut Document, o: &Object, this: Option) -> Result { + if !matches!(o, Object::Ref(_) | Object::Array(_) | Object::Dict(_) | Object::Stream(_)) { + return Ok(o.clone()); + } + self.descend()?; + let copied = match o { + Object::Ref(r) => self.copy_ref(dst, *r).map(Object::Ref), + Object::Array(a) => a.iter().map(|x| self.copy_value(dst, x, None)).collect::, _>>().map(Object::Array), + Object::Dict(d) => self.copy_dict(dst, d, this).map(Object::Dict), + Object::Stream(s) => self.copy_dict(dst, &s.dict, None).map(|dict| { + let mut s = s.clone(); + s.dict = dict; + Object::Stream(s) + }), + other => Ok(other.clone()), + }; + self.depth = self.depth.saturating_sub(1); + copied + } + + fn copy_dict(&mut self, dst: &mut Document, d: &Dict, this: Option) -> Result { let is_annot = d.name(b"Type") == Some(b"Annot") || (d.contains(b"Subtype") && d.contains(b"Rect") && !d.contains(b"Type")); let is_field = d.contains(b"FT") || (d.contains(b"T") && d.contains(b"Kids")); let mut out = Dict::new(); @@ -86,7 +113,7 @@ impl Copier<'_> { out.set(k.clone(), v.clone()); continue; } - out.set(k.clone(), self.copy_value(dst, v, None)); + out.set(k.clone(), self.copy_value(dst, v, None)?); } if let Some(this) = this { if is_annot { @@ -97,7 +124,7 @@ impl Copier<'_> { self.fields.push(this); } } - out + Ok(out) } /// Map a source destination (explicit array or reference to one) to the destination @@ -162,15 +189,8 @@ impl Copier<'_> { } } else { // Other actions (URI, Launch, JavaScript…) carry no page references. - let mut c = Copier { - src: self.src, - map: self.map.clone(), - pages: HashMap::new(), - annots: Vec::new(), - fields: Vec::new(), - ocgs: Vec::new(), - }; - let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None); + let mut c = Copier::new(self.src, self.map.clone(), HashMap::new()); + let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None)?; d.set(b"A".to_vec(), copied); } } @@ -183,9 +203,8 @@ impl Copier<'_> { /// Copy `o` from `src` into `dst`, reusing the copies listed in `map` (source → destination), so /// an object the copied pages already brought along (an ICC profile) is not stored twice. -pub(crate) fn copy_object(dst: &mut Document, src: &Document, o: &Object, map: HashMap) -> Object { - let mut c = Copier { src, map, pages: HashMap::new(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() }; - c.copy_value(dst, o, None) +pub(crate) fn copy_object(dst: &mut Document, src: &Document, o: &Object, map: HashMap) -> Result { + Copier::new(src, map, HashMap::new()).copy_value(dst, o, None) } /// How colliding fully-qualified field names are resolved when pages are imported. @@ -222,7 +241,17 @@ pub fn import_pages_with( conflicts: FieldConflicts, ) -> Result { let first_new = dst.object_numbers().last().map_or(1, |n| n + 1); - let imported = import_pages_mapped(dst, src, src_pages, at, conflicts)?; + let imported = match import_pages_mapped(dst, src, src_pages, at, conflicts) { + Ok(imported) => imported, + Err(e) => { + // A refusal while copying (a source too deep to follow) has only added objects: + // take them back, so the destination is as it was. + for n in dst.object_numbers().into_iter().filter(|n| *n >= first_new) { + dst.free(ObjRef::new(n, dst.generation(n))); + } + return Err(e); + } + }; let created: Vec = dst.object_numbers().into_iter().filter(|n| *n >= first_new).map(|n| ObjRef::new(n, dst.generation(n))).collect(); crate::dedupe::dedupe_resources(dst, &created, true); Ok(ImportOutcome { pages: imported.pages, renamed_fields: imported.renamed_fields }) @@ -268,7 +297,7 @@ fn import_pages_mapped( } let mut existing = walk(dst)?; let root = pages_root(dst)?; - let mut copier = Copier { src, map: HashMap::new(), pages: HashMap::new(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() }; + let mut copier = Copier::new(src, HashMap::new(), HashMap::new()); // Allocate every destination page first, so destinations between copied pages resolve. let targets: Vec<(ObjRef, ObjRef, Dict)> = src_pages .iter() @@ -291,14 +320,7 @@ fn import_pages_mapped( // popup, a widget's place). Destinations still map to the pages copied. let repeat = done.contains_key(&page); let new = if repeat { dst.add(Object::Null) } else { new }; - let mut own = repeat.then(|| Copier { - src, - map: HashMap::new(), - pages: copier.pages.clone(), - annots: Vec::new(), - fields: Vec::new(), - ocgs: Vec::new(), - }); + let mut own = repeat.then(|| Copier::new(src, HashMap::new(), copier.pages.clone())); let src_dict = src.get(page).as_dict().cloned().unwrap_or_default(); let resources = resources_with_ancestors(src, &src_dict); let mut d = Dict::new(); @@ -321,7 +343,7 @@ fn import_pages_mapped( && let Some(copied) = copier.map.get(&parent).copied() { let r = dst.add(Object::Null); - let c = copier.copy_value(dst, &resolved, Some(r)); + let c = copier.copy_value(dst, &resolved, Some(r))?; dst.set(r, c); // `/Kids` may be an array object of its own: add to it there, so the // field keeps its other widgets. @@ -348,10 +370,10 @@ fn import_pages_mapped( // Direct (inline) annotation dictionaries become indirect objects, so every // copied annotation can be fixed up the same way. let r = match a { - Object::Ref(r) => annots.copy_ref(dst, r), + Object::Ref(r) => annots.copy_ref(dst, r)?, Object::Dict(_) => { let r = dst.add(Object::Null); - let c = annots.copy_value(dst, &a, Some(r)); + let c = annots.copy_value(dst, &a, Some(r))?; dst.set(r, c); r } @@ -363,17 +385,17 @@ fn import_pages_mapped( d.set(b"Annots".to_vec(), Object::Array(out)); continue; } - d.set(k.clone(), copier.copy_value(dst, v, None)); + d.set(k.clone(), copier.copy_value(dst, v, None)?); } if let Some(r) = resources { - let r = copier.copy_value(dst, &r, None); + let r = copier.copy_value(dst, &r, None)?; d.set(b"Resources".to_vec(), r); } for k in INHERITABLE { if !d.contains(k) && let Some(v) = inherited.get(k) { - let v = copier.copy_value(dst, v, None); + let v = copier.copy_value(dst, v, None)?; d.set(k.to_vec(), v); } } @@ -936,7 +958,7 @@ impl Combiner { let outline = source_outline(src, imported.page_map); self.marks.push(Mark { title: title.to_string(), page: *first, outline }); } - collect_attachments(&mut self.out, src, &mut self.attachments); + collect_attachments(&mut self.out, src, &mut self.attachments)?; let declared = crate::pdfx::PrintStandard::of(src); match self.standard.as_ref().map(|first| first.declared.same_as(&declared)) { Some(same) => self.agreed &= same, @@ -1030,7 +1052,7 @@ pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Resu if let Some(first) = imported.pages.first() { marks.push(Mark { title: file.title.to_string(), page: *first, outline: source_outline(file.src, imported.page_map) }); } - collect_attachments(&mut out, file.src, &mut attachments); + collect_attachments(&mut out, file.src, &mut attachments)?; let declared = crate::pdfx::PrintStandard::of(file.src); match &standard { None => standard = Some((declared, file.src, imported.objects)), @@ -1056,22 +1078,23 @@ pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Resu } /// Copy a source's document-level attachments (`/Names /EmbeddedFiles`) into `dst`. -fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec, Object)>) { +fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec, Object)>) -> Result<(), OrganizeError> { let Some(tree) = src .root() .and_then(|r| src.get(r).as_dict().cloned()) .and_then(|c| c.get(b"Names").map(|n| src.resolve(n))) .and_then(|n| n.as_dict().and_then(|d| d.get(b"EmbeddedFiles").cloned())) else { - return; + return Ok(()); }; let mut entries = Vec::new(); name_tree_entries(src, &tree, 0, &mut entries); - let mut copier = Copier { src, map: HashMap::new(), pages: HashMap::new(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() }; + let mut copier = Copier::new(src, HashMap::new(), HashMap::new()); for (k, v) in entries { - let v = copier.copy_value(dst, &v, None); + let v = copier.copy_value(dst, &v, None)?; out.push((k, v)); } + Ok(()) } /// Write attachments as a flat, sorted `/EmbeddedFiles` name tree; duplicate names get " (2)"… @@ -1137,7 +1160,7 @@ fn source_outline(src: &Document, pages: HashMap) -> Vec Result<( } } } - let mut copier = Copier { src, map: HashMap::new(), pages: HashMap::new(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() }; - let resources = d.get(b"Resources").map(|r| copier.copy_value(dst, r, None)).unwrap_or(Object::Dict(Dict::new())); + let mut copier = Copier::new(src, HashMap::new(), HashMap::new()); + let resources = match d.get(b"Resources") { + Some(r) => copier.copy_value(dst, r, None)?, + None => Object::Dict(Dict::new()), + }; register_layers(dst, src, &copier.ocgs)?; let mut fd = Dict::new(); fd.set(b"Type".to_vec(), Object::name("XObject")); diff --git a/crates/organize/src/pdfx.rs b/crates/organize/src/pdfx.rs index 1aa4fc503c..501d969bec 100644 --- a/crates/organize/src/pdfx.rs +++ b/crates/organize/src/pdfx.rs @@ -93,7 +93,7 @@ pub(crate) fn carry(dst: &mut Document, src: Option<&Document>, standard: &Print let root = dst.root().ok_or(OrganizeError::NoPageTree)?; if let Some(intents) = &standard.intents { let src = src.ok_or_else(|| OrganizeError::Invalid("the output intents' source document was not kept".into()))?; - let copied = crate::import::copy_object(dst, src, intents, map); + let copied = crate::import::copy_object(dst, src, intents, map)?; dst.update_dict(root, |c| c.set(b"OutputIntents".to_vec(), copied))?; } let Some(version) = &standard.version else { return Ok(()) }; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 9f493e296f..7b271dc6c4 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -2444,3 +2444,43 @@ fn combine_field_conflicts_previews_each_file_in_order() { assert_eq!(preview[1], [("Text1".to_string(), "Text1-1".to_string())]); assert_eq!(preview[2], [("Text1".to_string(), "Text1-2".to_string())], "the cascade continues past the earlier rename"); } + +/// #1800: thousands of shallow objects, each referring to the next, used to recurse once per +/// object and exhaust the stack. The copy is refused instead, and the destination is as it was; +/// a chain within the budget and a short cycle copy as before. +#[test] +fn a_chain_of_objects_too_deep_to_copy_is_refused_and_leaves_the_destination_alone() { + fn chained(tail: &[String]) -> Document { + let mut b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Foo 6 0 R >>".into(), + body("C1"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + ]; + b.extend_from_slice(tail); + open(build(&b, "/Root 1 0 R")) + } + fn chain(n: usize) -> Document { + let mut tail: Vec = (0..n).map(|i| format!("<< /Next {} 0 R >>", 7 + i)).collect(); + tail.push("<< /End true >>".into()); + chained(&tail) + } + // On a thread with less stack than any platform's main thread, so the budget is proven. + let run = std::thread::Builder::new() + .stack_size(512 << 10) + .spawn(|| { + let mut dst = open(fixture()); + let before = (labels(&dst), dst.object_numbers()); + let err = import_pages(&mut dst, &chain(20_000), &[0], 0).unwrap_err(); + assert!(matches!(&err, OrganizeError::Invalid(m) if m.contains("levels deep")), "{err:?}"); + assert_eq!((labels(&dst), dst.object_numbers()), before, "the destination is as it was"); + import_pages(&mut dst, &chain(100), &[0], 0).unwrap(); + let cycle = chained(&["<< /Next 7 0 R >>".into(), "<< /Next 6 0 R >>".into()]); + import_pages(&mut dst, &cycle, &[0], 0).unwrap(); + assert_eq!(labels(&dst)[..3], ["C1", "C1", "Page 1"]); + }) + .unwrap() + .join(); + assert!(run.is_ok(), "the copy must not overflow the stack"); +} From c948d09c6a5ffc6b36221b1142c6c2205ee544f5 Mon Sep 17 00:00:00 2001 From: "Sir Clangington III." Date: Thu, 15 Oct 2026 08:29:46 +0200 Subject: [PATCH 006/108] M2: bound page-tree traversal memory for repeated children (#1926) Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com> --- crates/model/README.md | 4 + crates/model/src/lib.rs | 187 ++++++++++++++++++++++++++++++++-------- 2 files changed, 155 insertions(+), 36 deletions(-) diff --git a/crates/model/README.md b/crates/model/README.md index 5872b25665..bfc6e42e3b 100644 --- a/crates/model/README.md +++ b/crates/model/README.md @@ -14,5 +14,9 @@ L3/L4 crates need: `view_matrix_for(rotation, rect)` is the same matrix for any rectangle, e.g. to draw a comment's picture upright inside its user-space `/Rect` on a turned page. +The page-tree walk keeps one cursor per ancestor and shares inherited attribute owners. +Repeated child references are skipped before copying attributes, with first depth-first +occurrence order preserved. Returned page dictionaries still own their effective attributes. + `organize`, `annot` and `forms` still carry their own small walkers; they move here as the model grows (ADR-0004). diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 211b44492b..38bf36f222 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -84,44 +84,97 @@ pub fn view_matrix_for(rotation: i64, crop: [f64; 4]) -> [f64; 6] { /// Leaf pages in document order, with inherited attributes resolved. Cycles and absurdly deep /// trees are cut off rather than followed. pub fn pages(doc: &Document) -> Vec { - let Some(root) = doc.root() else { return Vec::new() }; - let Some(top) = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")) else { return Vec::new() }; - let mut out = Vec::new(); - let mut seen = std::collections::HashSet::new(); - let mut stack: Vec<(ObjRef, Dict)> = vec![(top, Dict::new())]; - while let Some((node, inherited)) = stack.pop() { - if !seen.insert(node) || seen.len() > 1_000_000 { - continue; - } - let obj = doc.get(node); - let Some(d) = obj.as_dict() else { continue }; - let mut attrs = inherited.clone(); - for k in INHERITABLE { - if let Some(v) = d.get(k) { - attrs.set(k.to_vec(), v.clone()); - } - } - let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); - if is_pages { - let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - for k in kids.iter().rev() { - if let Some(r) = k.as_ref() { - stack.push((r, attrs.clone())); - } - } - } else { - let mut dict = d.clone(); - for k in INHERITABLE { - if !dict.contains(k) - && let Some(v) = attrs.get(k) - { - dict.set(k.to_vec(), v.clone()); - } - } - out.push(Page { obj: node, dict }); + PageTree::new(doc).collect() +} + +// Retain the owner of each inherited value rather than deep-cloning direct Resources on +// every edge. Only the final Page dictionaries need owned copies of those values. +type Inherited = [Option>; 4]; + +struct Children { + owner: std::sync::Arc, + next: usize, + inherited: Inherited, +} + +impl Children { + fn kids(&self) -> Option<&Vec> { + match &*self.owner { + Object::Dict(d) => d.get(b"Kids")?.as_array(), + other => other.as_array(), } } - out +} + +struct PageTree<'a> { + doc: &'a Document, + seen: std::collections::HashSet, + // One cursor per ancestor, never one entry per child. This also preserves the first + // depth-first occurrence's attributes when malformed trees share a child. + pending: Vec, +} + +impl<'a> PageTree<'a> { + fn new(doc: &'a Document) -> Self { + let top = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))); + let pending = top + .into_iter() + .map(|r| Children { owner: std::sync::Arc::new(Object::Array(vec![Object::Ref(r)])), next: 0, inherited: Default::default() }) + .collect(); + Self { doc, seen: Default::default(), pending } + } +} + +impl Iterator for PageTree<'_> { + type Item = Page; + + fn next(&mut self) -> Option { + while let Some(parent) = self.pending.last_mut() { + let Some(child) = parent.kids().and_then(|kids| kids.get(parent.next)) else { + self.pending.pop(); + continue; + }; + let node = child.as_ref(); + // A successful get means next is strictly below the allocated array length. + parent.next += 1; + let Some(node) = node else { continue }; + if self.seen.len() >= 1_000_000 { + self.pending.clear(); + return None; + } + if !self.seen.insert(node) { + continue; + } + let obj = self.doc.get(node); + let Some(d) = obj.as_dict() else { continue }; + let mut inherited = parent.inherited.clone(); + for (key, owner) in INHERITABLE.iter().zip(&mut inherited) { + if d.contains(key) { + *owner = Some(obj.clone()); + } + } + let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); + if is_pages { + let owner = match d.get(b"Kids") { + Some(Object::Ref(r)) => self.doc.get(*r), + Some(Object::Array(_)) => obj.clone(), + _ => continue, + }; + self.pending.push(Children { owner, next: 0, inherited }); + } else { + let mut dict = d.clone(); + for (key, owner) in INHERITABLE.iter().zip(&inherited) { + if !dict.contains(key) + && let Some(value) = owner.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(key)) + { + dict.set(key.to_vec(), value.clone()); + } + } + return Some(Page { obj: node, dict }); + } + } + None + } } #[cfg(test)] @@ -156,6 +209,68 @@ mod tests { Document::open(Arc::new(out)).unwrap() } + #[test] + fn repeated_children_do_not_queue_resource_copies() { + let mut d = doc(0); + let root = ObjRef { num: 2, generation: 0 }; + let leaf = ObjRef { num: 3, generation: 0 }; + d.update_dict(root, |dict| { + // Modest fixture: the assertion measures retained work, not an OOM or timing. + dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(leaf); 128])); + let mut resources = Dict::new(); + resources.set(b"Payload".to_vec(), Object::Array(vec![Object::Int(42); 1024])); + dict.set(b"Resources".to_vec(), Object::Dict(resources)); + }) + .unwrap(); + let mut walk = PageTree::new(&d); + let p = walk.next().unwrap(); + assert_eq!(p.obj, leaf); + assert_eq!(p.dict.get(b"Resources").unwrap().as_dict().unwrap().get(b"Payload").unwrap().as_array().unwrap().len(), 1024); + assert!(walk.pending.len() <= 2, "duplicate edges retained {} pending entries", walk.pending.len()); + assert!(walk.next().is_none()); + } + + #[test] + fn shared_children_keep_depth_first_order_and_nearest_attributes() { + let mut d = doc(0); + let root = ObjRef { num: 2, generation: 0 }; + let leaf = ObjRef { num: 3, generation: 0 }; + let mut second = d.get(leaf).as_dict().unwrap().clone(); + second.set(b"Rotate".to_vec(), Object::Int(180)); + let second = d.add(second); + let mut branch = Dict::new(); + branch.set(b"Type".to_vec(), Object::name("Pages")); + branch.set(b"Rotate".to_vec(), Object::Int(90)); + // A cycle, a shared child, an invalid entry, and an indirect Kids array. + let kids = d.add(Object::Array(vec![Object::Ref(root), Object::Null, Object::Ref(leaf), Object::Ref(second)])); + branch.set(b"Kids".to_vec(), Object::Ref(kids)); + let branch = d.add(branch); + d.update_dict(root, |dict| { + dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(branch), Object::Ref(leaf), Object::Ref(second)])); + }) + .unwrap(); + let pages = pages(&d); + assert_eq!(pages.iter().map(|p| p.obj).collect::>(), vec![leaf, second]); + assert_eq!(pages[0].rotation(&d), 90, "first depth-first occurrence supplies inheritance"); + assert_eq!(pages[1].rotation(&d), 180, "a leaf's own attribute wins"); + assert!(pages.iter().all(|p| p.dict.contains(b"Resources"))); + } + + #[test] + fn wide_page_tree_retains_only_ancestor_cursors() { + let mut d = doc(0); + let root = ObjRef { num: 2, generation: 0 }; + let leaf = d.get(ObjRef { num: 3, generation: 0 }).as_dict().unwrap().clone(); + let kids: Vec = (0..64).map(|_| Object::Ref(d.add(leaf.clone()))).collect(); + d.update_dict(root, |dict| dict.set(b"Kids".to_vec(), Object::Array(kids.clone()))).unwrap(); + let mut walk = PageTree::new(&d); + for kid in kids { + assert_eq!(walk.next().unwrap().obj, kid.as_ref().unwrap()); + assert!(walk.pending.len() <= 2); + } + assert!(walk.next().is_none()); + } + fn apply(m: [f64; 6], x: f64, y: f64) -> [f64; 2] { [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]] } From 9caddc6e9b82e4cb4125fce6f8af4934b7f6f22c Mon Sep 17 00:00:00 2001 From: regendorf-ux Date: Thu, 15 Oct 2026 02:30:08 -0400 Subject: [PATCH 007/108] Compare files: side by side, scrolled and zoomed together (#1927) * Compare files: side by side, scrolled and zoomed together After Compare files, when the old version is open in a tab and the window is at least 1000 px wide, the document area splits: the old file on the left, the new on the right, each a page view with its own shading (deleted and replaced on the old, inserted and replaced on the new, visual differences on both). The half under the pointer leads; the other takes its zoom, fit, layout and rotation, and its scroll offset on the next frame. A change clicked in the panel jumps both halves; Side by side / New file only toggle the mode; Clear ends it. The old file keeps rendering while shown. Rows in every language, parity misc.compare-side-by-side shipped. Co-Authored-By: Claude Opus 5.5 * ATTRIBUTION.toml: the language files' checksums after the new rows cargo xtask assets checks each catalog's sha256; the rows this branch adds changed the files without updating it. Co-Authored-By: Claude Opus 5.5 * Refresh catalog hashes after merging main --------- Co-authored-by: Osvaldo Duran <338149330+regendorf-ux@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 42 ++++----- crates/ui-egui/src/canvas.rs | 8 +- crates/ui-egui/src/compare_ui.rs | 140 +++++++++++++++++++++++++++- crates/ui-egui/src/docking.rs | 5 +- crates/ui-egui/src/i18n/ar.tsv | 4 + crates/ui-egui/src/i18n/bg.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/fr.tsv | 4 + crates/ui-egui/src/i18n/hu.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/lt.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sl.tsv | 4 + crates/ui-egui/src/i18n/te.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.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 | 23 +++-- crates/ui-egui/tests/compare.rs | 18 ++++ parity/acrobat-features.toml | 8 +- 28 files changed, 294 insertions(+), 34 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 5734c03954..290dea2ccf 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0ef505ad4ae53d404d4474d2881f54bc3692739fe9200692fad2b3ace9036a03" +sha256 = "a4ab30e3f0e7e65c93dadf7c4dd88b15efbad5ae1f218d77f639cf490c900bdb" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "73efe98c3a4190715f3f76d714390396887f45d2092926729832b79a194488c0" +sha256 = "3ed54e2ed759fbcf7fbff39a70820de78a7e79ce0cd8133e0d870ad0f4b62ac3" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e0959af2ee8e317372d9076e487e41d025015460b99906482efa1a0e528802c3" +sha256 = "74ce914c682d54480f986a007ae4140ec19bdfa2ac9729418f04a8779a98330b" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0a327af10b0bb916ef1b912e6797214bd0b2c18c1e0ef27d9f7a92a3cbeac5b7" +sha256 = "06bb8f0ce964ac41355c589ba278c941b281aca249741672d513ca2033bd818d" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c2cf9490a9f9c828382e2f24df3eaea511b4dfab6543ca00ac61e0014029f051" +sha256 = "615c578b6da67dc5c23fe6f290464aabb327da68027bd7ebd4e065a61f047b4b" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fccce321ccb64bdfcb75e1d6d09f661fb6a738b30cc0ec1133134ea91959e3ad" +sha256 = "c7156ba4a06220d697c908e173f6bbd9ef1475069f90dcb8f72bc5a620e51cb3" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "64722973eaf542884ddd170dd9541649c1b1b365ad8c63d0d2864ffbecd20941" +sha256 = "8286c0e834e0a87fc1909dba2d974aa8993230e5cdb9ce89abdab3cd1ecf0d22" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0475c03f0b2190998e247d6996d5473d3fd85ea6c55e582b7159b42096c63f9e" +sha256 = "8b65824b11221ec61fc42a32093d61f301a57e206f69e166c491f26333cd593f" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6423cc0904fd4e49af25121d9fe4d67d09d684f45acbb8165d4ea7a474a3275c" +sha256 = "85eb2eb9f43b773fe76747b51d288a590a1d5251b19c189e818c38bc88a5781c" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "588adcef83d5ec8e9a1e1fac093afa6afee30d94ac68e8b428ebd487cdc23c3e" +sha256 = "32c0f3141dab1aa960690e38a9414b88252a8477e9dd9c19e6b21a7dd099c6b0" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9fda2deb8db4e919199ae032b9a9a220f32896462640578cd155074809e71f0d" +sha256 = "8b47ba586f779b4fd42d483b52a81260e52e10c4135fe541f6a62148f9b64812" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b60244d234bcec0602a10a077167861d60c86ab3a364c3dfc4b4e0466dd54872" +sha256 = "0a9864a30413a28ba90c11eb4b2760686adf0cb7d060365c2936a5e0c1201009" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "18416c5dc7b28bfb999e1f8127337d37915c67a91d8b8b3fc52e3286a11205ba" +sha256 = "09e2c640908567e8a56ec9ac857ad5fb1e09d4c3ad68b1ad873f098a55396bb5" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e50991d9d81a9a45e78c719c790029486e4c03045c30b10b4fee349fcc6702d0" +sha256 = "7d36195963293e514d9a40242a8fd2aaed479fa4642fed13f9648916a5b1fde1" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "73fb4046cd4fdccd8c1da4abfab90ecd0ca2395fc9a6302dd00dd4f810a14418" +sha256 = "9c42561f5279e1e5c14b23e35328c69d800750ade22981e9524fc748412328bd" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e090ca970a518bbf667959181167ff8f8058b78e4239c0c62654c58450738a99" +sha256 = "64bba70d41277aeba09c1af425347e67586aa52d5d7758148c679af208a6a63b" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a5e4cbf2fd2b1f840d31482a090a68fe58dbbd03e2187deecd1398d091c9fb8f" +sha256 = "f055211d06eee77906ddea5e53c7f29c7b110ee58a031a8359a4ce7485edfe46" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "036dfc2952a758352933ec9ab7f1668a699ed108c8902340566f149df4bc0b79" +sha256 = "eecf66414e14f29b989e7b6de748810609002dc62b71e7789a2eb2daed5ec845" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d4ac259fc9dad897c9ca8a55eb830d2027f3387eafd641744dd7b9d43a5d1cb4" +sha256 = "34ce3af08c6afc0bbc3ec37ec1923aa4f8a51db44b4f08d6b45674e91cd1bed4" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b0e3dacf8ce437663e3cb9946ced5b9f3cde6a62a2bf5ff8dd3d45268b264bf5" +sha256 = "9d76dc284dd281e3d7a2fb7ea7636ae2c3650519713665f02bf96e8c65d86374" [[asset]] @@ -3363,4 +3363,4 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "eb1f9d2990ab5e177be78d5d5e6d4c3cbdb0961c64df04bbd34e5d9ad5b42a3d" +sha256 = "fb217509688595c77fcf8c5d256adb931a7a21c87c23dcc31bc79ba5710a4ffc" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index fc4366ff06..f9e9aeca9e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -491,7 +491,9 @@ pub struct DocView { /// interpret, so a drag would otherwise queue a storm of them (one per frame) while the /// in-flight ones cannot be cancelled mid-operator. Older tiles and the backdrop stay /// stretched meanwhile; fresh tiles go out once the pan settles. - last_scroll: Vec2, + pub(crate) last_scroll: Vec2, + /// Side-by-side compare: scroll to here this frame, following the other half. + pub(crate) sync_scroll: Option, pan_hold_until: f64, /// Pages whose raster proved slow (see `SLOW_MILLIS`): only these get the zoom/pan /// settle holds. Fast pages render straight through, with no added latency. @@ -679,6 +681,7 @@ impl DocView { last_scale: 0.0, sharp_hold_until: 0.0, last_scroll: Vec2::ZERO, + sync_scroll: None, pan_hold_until: 0.0, slow_pages: HashSet::new(), comments: Default::default(), @@ -2326,6 +2329,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if !std::mem::replace(&mut view.shown, true) { scroll = scroll.scroll_offset(Vec2::ZERO); } + if let Some(v) = view.sync_scroll.take() { + scroll = scroll.scroll_offset(v); + } if let Some((page, fx, fy, rel)) = view.zoom_anchor.take() { let r = rects[page.min(rects.len() - 1)]; let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height()); diff --git a/crates/ui-egui/src/compare_ui.rs b/crates/ui-egui/src/compare_ui.rs index e84cc34dc3..f9c1cbcee4 100644 --- a/crates/ui-egui/src/compare_ui.rs +++ b/crates/ui-egui/src/compare_ui.rs @@ -17,6 +17,13 @@ pub struct CompareState { /// Regions that look different (page, user-space box), page n against page n. pub visual: Vec<(usize, [f64; 4])>, pub selected: Option, + /// Side by side: the old file on the left, the new on the right, scrolled and zoomed + /// together. + pub side_by_side: bool, + /// Which half leads the other (the one the pointer was over last): 0 old, 1 new. + pub leader: usize, + /// Each half's scroll offset last frame, to tell which one moved. + pub prev_scroll: [egui::Vec2; 2], } /// Visual differences are shaded orange. @@ -28,6 +35,7 @@ pub enum PanelAction { Mark, Report, Clear, + SideBySide(bool), } pub fn colour(kind: Kind) -> Color32 { @@ -99,6 +107,16 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, state: &Option, ui.label(crate::i18n::fmt(tl!("{n} {kind}"), &[("n", &s.visual.len().to_string()), ("kind", tl!("Visual"))])); }); ui.add_space(6.0); + // Side by side, or the new file alone. + ui.horizontal(|ui| { + if widgets::pill_button(ui, tl!("Side by side"), s.side_by_side).clicked() { + action = Some(PanelAction::SideBySide(true)); + } + if widgets::pill_button(ui, tl!("New file only"), !s.side_by_side).clicked() { + action = Some(PanelAction::SideBySide(false)); + } + }); + ui.add_space(4.0); ui.horizontal(|ui| { if ui.button(tl!("Mark as comments")).clicked() { action = Some(PanelAction::Mark); @@ -166,8 +184,37 @@ impl PdfCraftApp { self.views[i].compare_marks.push((*p, r, VISUAL)); } } + // The old file's pages too: what was deleted or replaced, and the visual + // differences, so both versions read with their own shading. + if let Some(oi) = self.views.iter().position(|v| v.id == old) { + let mut marks: Vec<(usize, [f32; 4], Color32)> = result + .changes + .iter() + .filter(|ch| ch.kind != Kind::Inserted) + .flat_map(|ch| ch.old.rects.iter().map(move |r| (ch.old.page, r.map(|v| v as f32), colour(ch.kind)))) + .collect(); + for (p, r) in &visual { + let r = r.map(|v| v as f32); + let covered = marks.iter().any(|(mp, m, _)| *mp == *p && m[0] <= r[2] && r[0] <= m[2] && m[1] <= r[3] && r[1] <= m[3]); + if !covered { + marks.push((*p, r, VISUAL)); + } + } + self.views[oi].compare_marks = marks; + } let n = result.changes.len(); - self.compare = Some(crate::compare_ui::CompareState { old, new, result, visual, selected: None }); + // Side by side when the old version is open in a tab and the window has the room. + let side_by_side = self.views.iter().any(|v| v.id == old) && self.ctx.as_ref().is_none_or(|c| c.content_rect().width() >= 1000.0); + self.compare = Some(crate::compare_ui::CompareState { + old, + new, + result, + visual, + selected: None, + side_by_side, + leader: 1, + prev_scroll: [egui::Vec2::ZERO; 2], + }); self.right = Some(RightPanel::Compare); self.notify(if n == 0 { tl!("The text is the same").to_string() @@ -190,9 +237,20 @@ impl PdfCraftApp { let Some(ch) = s.result.changes.get(i) else { return }; let rect = ch.new.rects.first().copied().or(ch.new.near).unwrap_or_default(); let page = ch.new.page; + let old_target = (ch.old.page, ch.old.rects.first().copied().or(ch.old.near).unwrap_or_default()); let v = &mut self.views[index]; v.go_to_page(page); v.flash = Some((page, rect.map(|x| x as f32), 0.0)); + // The old file's half jumps to its side of the change too. + if let Some(oi) = self.views.iter().position(|v| v.id == old) { + let v = &mut self.views[oi]; + v.go_to_page(old_target.0); + v.flash = Some((old_target.0, old_target.1.map(|x| x as f32), 0.0)); + } + } + PanelAction::SideBySide(on) => { + s.side_by_side = on; + s.leader = 1; } PanelAction::Mark => match self.session.mark_differences(old, new) { Ok(n) => { @@ -218,8 +276,88 @@ impl PdfCraftApp { PanelAction::Clear => { self.compare = None; self.views[index].compare_marks.clear(); + if let Some(oi) = self.views.iter().position(|v| v.id == old) { + self.views[oi].compare_marks.clear(); + } self.right = None; } } } } + +/// The two halves of a side-by-side comparison, as view indexes (old, new), when the active +/// document is the new file of a comparison shown side by side and the old file is open too. +pub(crate) fn side_by_side(app: &PdfCraftApp, index: usize) -> Option<(usize, usize)> { + let c = app.compare.as_ref().filter(|c| c.side_by_side)?; + let new = app.views.get(index).filter(|v| v.id == c.new)?; + let old = app.views.iter().position(|v| v.id == c.old)?; + (old != index).then_some((old, app.views.iter().position(|v| v.id == new.id)?)) +} + +/// Side by side: the old file on the left, the new on the right, each a page view of its own +/// with its shading, scrolled and zoomed together. The half the pointer is over leads; the +/// other follows its zoom, layout and scroll offset. +pub(crate) fn compare_area(app: &mut PdfCraftApp, old: usize, new: usize, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let rect = ui.available_rect_before_wrap(); + let gap = 1.0; + let half_w = ((rect.width() - gap) / 2.0).max(1.0); + let caption_h = 22.0; + let left = egui::Rect::from_min_size(rect.min, egui::vec2(half_w, rect.height())); + let right = egui::Rect::from_min_size(egui::pos2(rect.min.x + half_w + gap, rect.min.y), egui::vec2(half_w, rect.height())); + // Who leads: the half under the pointer (remembered), the new file at first. + if let Some(p) = ui.input(|i| i.pointer.hover_pos()) + && let Some(c) = app.compare.as_mut() + { + if left.contains(p) { + c.leader = 0; + } else if right.contains(p) { + c.leader = 1; + } + } + let leader = app.compare.as_ref().map_or(1, |c| c.leader); + let (lead, follow) = if leader == 0 { (old, new) } else { (new, old) }; + // The follower takes the leader's zoom and layout before either is drawn. + let (zoom, fit, layout, continuous, rotation) = { + let v = &app.views[lead]; + (v.zoom, v.fit, v.layout, v.continuous, v.rotation) + }; + { + let v = &mut app.views[follow]; + if v.zoom != zoom || v.fit != fit || v.layout != layout || v.continuous != continuous || v.rotation != rotation { + v.zoom = zoom; + v.fit = fit; + v.layout = layout; + v.continuous = continuous; + v.rotation = rotation; + } + } + let names = [old, new].map(|i| app.session.get(app.views[i].id).map(|d| d.name.clone()).unwrap_or_default()); + ui.painter().vline(rect.min.x + half_w, rect.y_range(), egui::Stroke::new(gap, t.border)); + for (k, (r, index)) in [(left, old), (right, new)].into_iter().enumerate() { + let caption = egui::Rect::from_min_size(r.min, egui::vec2(r.width(), caption_h)); + ui.painter().rect_filled(caption, 0.0, t.panel); + let label = crate::i18n::fmt(if k == 0 { tl!("Old file: {name}") } else { tl!("New file: {name}") }, &[("name", &names[k])]); + ui.painter().text( + egui::pos2(caption.min.x + 10.0, caption.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(12.0), + t.text_muted, + ); + let body = egui::Rect::from_min_max(egui::pos2(r.min.x, r.min.y + caption_h), r.max); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt(("compare-half", k))); + child.set_clip_rect(body); + crate::canvas::document_area(app, index, &mut child); + } + // After both drew: the leader's scroll, when it moved, is the follower's next offset. + let offsets = [app.views[old].last_scroll, app.views[new].last_scroll]; + if let Some(c) = app.compare.as_mut() { + let lead_k = if leader == 0 { 0 } else { 1 }; + if (offsets[lead_k] - c.prev_scroll[lead_k]).length() > 0.5 && (offsets[1 - lead_k] - offsets[lead_k]).length() > 0.5 { + app.views[follow].sync_scroll = Some(offsets[lead_k]); + ui.ctx().request_repaint(); + } + c.prev_scroll = offsets; + } +} diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index fe4c0390df..58fd71a678 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -409,7 +409,10 @@ pub fn show(app: &mut crate::PdfCraftApp, ui: &mut egui::Ui) { match app.active { None if app.combine_showing() => crate::combine_ui::page(app, ui), None => crate::home::show(app, ui), - Some(index) => crate::canvas::document_area(app, index, ui), + Some(index) => match crate::compare_ui::side_by_side(app, index) { + Some((old, new)) => crate::compare_ui::compare_area(app, old, new, ui), + None => crate::canvas::document_area(app, index, ui), + }, } } Panel::Tools => crate::panels::left_panel(app, ui), diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index c67df55718..093757cdc9 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2398,3 +2398,7 @@ animation effects Off معطّلة Mobile الهاتف المحمول Merge image fragments دمج أجزاء الصور Optimizing… merging image fragments جارٍ التحسين… دمج أجزاء الصور + Side by side جنبًا إلى جنب + New file only الملف الجديد فقط + Old file: {name} الملف القديم: {name} + New file: {name} الملف الجديد: {name} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 84f2d6eae8..e75a7bc087 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2340,3 +2340,7 @@ animation effects Off Изключени Mobile Мобилни устройства Merge image fragments Обединяване на фрагменти от изображения Optimizing… merging image fragments Оптимизиране… обединяване на фрагменти от изображения + Side by side Един до друг + New file only Само новият файл + Old file: {name} Стар файл: {name} + New file: {name} Нов файл: {name} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3e5e3680c8..6037cf2816 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2451,3 +2451,7 @@ animation effects Off Vypnuto Mobile Mobilní zařízení Merge image fragments Sloučit fragmenty obrázků Optimizing… merging image fragments Optimalizace… slučování fragmentů obrázků + Side by side Vedle sebe + New file only Jen nový soubor + Old file: {name} Starý soubor: {name} + New file: {name} Nový soubor: {name} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d21a24d6b0..ee0a5418cb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2378,3 +2378,7 @@ animation effects Off Aus Mobile Mobilgerät Merge image fragments Bildfragmente zusammenführen Optimizing… merging image fragments Optimieren… Bildfragmente werden zusammengeführt + Side by side Nebeneinander + New file only Nur neue Datei + Old file: {name} Alte Datei: {name} + New file: {name} Neue Datei: {name} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c9680ed94a..679c2e2e98 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2387,3 +2387,7 @@ animation effects Off Ανενεργά Mobile Κινητό Merge image fragments Συγχώνευση τμημάτων εικόνας Optimizing… merging image fragments Βελτιστοποίηση… συγχώνευση τμημάτων εικόνας + Side by side Δίπλα-δίπλα + New file only Μόνο το νέο αρχείο + Old file: {name} Παλιό αρχείο: {name} + New file: {name} Νέο αρχείο: {name} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8c63c182af..3ca46ff114 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2507,3 +2507,7 @@ animation effects Off Desactivados Mobile Móvil Merge image fragments Combinar fragmentos de imagen Optimizing… merging image fragments Optimizando… combinando fragmentos de imagen + Side by side Lado a lado + New file only Solo el archivo nuevo + Old file: {name} Archivo antiguo: {name} + New file: {name} Archivo nuevo: {name} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3d6bcce62f..1408fddf8f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2377,3 +2377,7 @@ animation effects Off Désactivés Mobile Mobile Merge image fragments Fusionner les fragments d’image Optimizing… merging image fragments Optimisation… fusion des fragments d’image + Side by side Côte à côte + New file only Nouveau fichier seul + Old file: {name} Ancien fichier : {name} + New file: {name} Nouveau fichier : {name} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 1961246f0e..0ca2ec150a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2376,3 +2376,7 @@ animation effects Off Ki Mobile Mobil Merge image fragments Képtöredékek egyesítése Optimizing… merging image fragments Optimalizálás… képtöredékek egyesítése + Side by side Egymás mellett + New file only Csak az új fájl + Old file: {name} Régi fájl: {name} + New file: {name} Új fájl: {name} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bfed12bf07..d762d9869d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2386,3 +2386,7 @@ animation effects Off Disattivati Mobile Cellulare Merge image fragments Unisci frammenti di immagine Optimizing… merging image fragments Ottimizzazione… unione dei frammenti di immagine + Side by side Affiancati + New file only Solo il nuovo file + Old file: {name} File vecchio: {name} + New file: {name} File nuovo: {name} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2ac86df72d..4a80d277f7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2377,3 +2377,7 @@ animation effects Off オフ Mobile モバイル Merge image fragments 画像の断片を結合 Optimizing… merging image fragments 最適化中… 画像の断片を結合しています + Side by side 並べて表示 + New file only 新しいファイルのみ + Old file: {name} 古いファイル: {name} + New file: {name} 新しいファイル: {name} diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 443b756a9a..67631f3374 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2375,3 +2375,7 @@ animation effects Off Өшірулі Mobile Мобильді құрылғы Merge image fragments Сурет фрагменттерін біріктіру Optimizing… merging image fragments Оңтайландыру… сурет фрагменттерін біріктіру + Side by side Қатар + New file only Тек жаңа файл + Old file: {name} Ескі файл: {name} + New file: {name} Жаңа файл: {name} diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index a658a2c9a3..f67f0bd8c8 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2621,3 +2621,7 @@ key Space Tarpas Mobile Mobilusis Merge image fragments Sujungti vaizdo fragmentus Optimizing… merging image fragments Optimizuojama… jungiami vaizdo fragmentai + Side by side Greta + New file only Tik naujas failas + Old file: {name} Senas failas: {name} + New file: {name} Naujas failas: {name} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 1824e17424..dc28911163 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2357,3 +2357,7 @@ animation effects Off Wyłączone Mobile Urządzenie mobilne Merge image fragments Scal fragmenty obrazów Optimizing… merging image fragments Optymalizacja… scalanie fragmentów obrazów + Side by side Obok siebie + New file only Tylko nowy plik + Old file: {name} Stary plik: {name} + New file: {name} Nowy plik: {name} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a6461661c1..fe507b6b45 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2391,3 +2391,7 @@ animation effects Off Desativados Mobile Celular Merge image fragments Mesclar fragmentos de imagem Optimizing… merging image fragments Otimizando… mesclando fragmentos de imagem + Side by side Lado a lado + New file only Somente o arquivo novo + Old file: {name} Arquivo antigo: {name} + New file: {name} Arquivo novo: {name} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 299590f466..d8d23a3adb 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2290,3 +2290,7 @@ animation effects Off Выкл Mobile Мобильные устройства Merge image fragments Объединять фрагменты изображений Optimizing… merging image fragments Оптимизация… объединение фрагментов изображений + Side by side Рядом + New file only Только новый файл + Old file: {name} Старый файл: {name} + New file: {name} Новый файл: {name} diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 742f77046f..c0bb304593 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2382,3 +2382,7 @@ animation effects Off Izklopljeni Mobile Mobilna naprava Merge image fragments Združi delce slik Optimizing… merging image fragments Optimiziranje… združevanje delcev slik + Side by side Drug ob drugem + New file only Samo nova datoteka + Old file: {name} Stara datoteka: {name} + New file: {name} Nova datoteka: {name} diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 0079cd69ab..5bd6fa1506 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2290,3 +2290,7 @@ animation effects Off ఆఫ్ Mobile మొబైల్ Merge image fragments చిత్ర శకలాలను విలీనం చేయి Optimizing… merging image fragments ఆప్టిమైజ్ చేస్తోంది… చిత్ర శకలాలను విలీనం చేస్తోంది + Side by side పక్కపక్కన + New file only కొత్త ఫైల్ మాత్రమే + Old file: {name} పాత ఫైల్: {name} + New file: {name} కొత్త ఫైల్: {name} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 320db7fb26..b400033d0b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2373,3 +2373,7 @@ animation effects Off Kapalı Mobile Mobil Merge image fragments Görüntü parçalarını birleştir Optimizing… merging image fragments İyileştiriliyor… görüntü parçaları birleştiriliyor + Side by side Yan yana + New file only Yalnızca yeni dosya + Old file: {name} Eski dosya: {name} + New file: {name} Yeni dosya: {name} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index abb939813e..5dbbf01a61 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2399,3 +2399,7 @@ animation effects Off Вимкнено Mobile Мобільні пристрої Merge image fragments Об’єднувати фрагменти зображень Optimizing… merging image fragments Оптимізація… об’єднання фрагментів зображень + Side by side Поруч + New file only Лише новий файл + Old file: {name} Старий файл: {name} + New file: {name} Новий файл: {name} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 931a422c31..c24a7fea7f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2341,3 +2341,7 @@ animation effects Off 关 Mobile 移动设备 Merge image fragments 合并图像碎片 Optimizing… merging image fragments 正在优化… 正在合并图像碎片 + Side by side 并排 + New file only 仅新文件 + Old file: {name} 旧文件:{name} + New file: {name} 新文件:{name} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c6e9f368da..6aa5270ab3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2331,3 +2331,7 @@ animation effects Off 關 Mobile 行動裝置 Merge image fragments 合併影像片段 Optimizing… merging image fragments 正在最佳化… 正在合併影像片段 + Side by side 並排 + New file only 僅新檔案 + Old file: {name} 舊檔案:{name} + New file: {name} 新檔案:{name} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8aacc7eea0..1c94877814 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2978,7 +2978,10 @@ impl eframe::App for PdfCraftApp { ui, |ui| { if let Some(i) = self.active { - canvas::document_area(self, i, ui); + match compare_ui::side_by_side(self, i) { + Some((old, new)) => compare_ui::compare_area(self, old, new, ui), + None => canvas::document_area(self, i, ui), + } } }, ); @@ -3015,7 +3018,10 @@ impl eframe::App for PdfCraftApp { match self.active { None if self.combine_showing() => combine_ui::page(self, ui), None => home::show(self, ui), - Some(index) => canvas::document_area(self, index, ui), + Some(index) => match compare_ui::side_by_side(self, index) { + Some((old, new)) => compare_ui::compare_area(self, old, new, ui), + None => canvas::document_area(self, index, ui), + }, } } else { docking::show(self, ui); @@ -3036,17 +3042,22 @@ impl PdfCraftApp { self.combine_thumbs_frame(ctx); // Retire hidden documents before admitting the visible document's textures, so the // three cache limits apply to the application, regardless of how many tabs are open. + // Side by side: the comparison's old file renders alongside the active one. + let partner = self.active.and_then(|i| compare_ui::side_by_side(self, i)).map(|(old, _)| old); for (i, view) in self.views.iter_mut().enumerate() { if Some(i) != self.active + && Some(i) != partner && let Some(doc) = self.session.get(view.id) { view.suspend_rendering(&doc.renderer); } } - if let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) - && let Some(doc) = self.session.get(view.id) - { - view.finish_render_frame(ctx, &doc.info, &doc.renderer); + for i in self.active.into_iter().chain(partner) { + if let Some(view) = self.views.get_mut(i) + && let Some(doc) = self.session.get(view.id) + { + view.finish_render_frame(ctx, &doc.info, &doc.renderer); + } } } } diff --git a/crates/ui-egui/tests/compare.rs b/crates/ui-egui/tests/compare.rs index 089d3b8825..e735668a1b 100644 --- a/crates/ui-egui/tests/compare.rs +++ b/crates/ui-egui/tests/compare.rs @@ -33,6 +33,24 @@ fn compare_two_versions() { let id = h.state().active_ids().unwrap().1; let i = h.state().active_ids().unwrap().0; assert_eq!(h.state().views[i].compare_marks.len(), 2); + // Side by side: the old file's half shades its side of the changes and follows the new + // file's zoom; "New file only" ends it. + assert!(h.state().compare.as_ref().unwrap().side_by_side, "side by side on a wide window"); + let old_i = h.state().views.iter().position(|v| v.id == h.state().compare.as_ref().unwrap().old).unwrap(); + assert_eq!(h.state().views[old_i].compare_marks.len(), 2, "replaced words shaded on the old file too"); + assert!(h.state().views[old_i].flash.is_some(), "the old half jumped to the change"); + h.state_mut().views[i].zoom = 1.5; + h.state_mut().views[i].fit = pdfcraft_ui_egui::canvas::Fit::None; + h.run_steps(3); + assert_eq!((h.state().views[old_i].zoom, h.state().views[old_i].fit), (1.5, pdfcraft_ui_egui::canvas::Fit::None), "the old half follows"); + h.get_by_label("New file only").click(); + h.run_steps(2); + assert!(!h.state().compare.as_ref().unwrap().side_by_side); + h.get_by_label("Side by side").click(); + h.run_steps(2); + assert!(h.state().compare.as_ref().unwrap().side_by_side); + h.get_by_label("New file only").click(); + h.run_steps(2); h.get_by_label("Mark as comments").click(); h.run_steps(3); assert_eq!(h.state().session.get(id).unwrap().info.annotations.len(), 2); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0a38160463..dfcdb9aab9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9155,11 +9155,11 @@ title = "Side-by-side compare view with synchronised scrolling" area = "N" tier = "P1" milestone = "M12" -status = "planned" -commands = [] +status = "shipped" +commands = ["doc.compare"] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/compare.rs::compare_two_versions"] +notes = "After Compare files the document area splits (when the old version is open in a tab and the window is at least 1000 px wide; the Compare panel's Side by side / New file only toggles it): the old file on the left, the new on the right, each a page view of its own with its shading (deleted and replaced on the old, inserted and replaced on the new, visual differences on both). The half the pointer is over leads: the other takes its zoom, fit, layout, rotation and scroll offset; a change clicked in the panel jumps both halves to their side of it. Clear ends it. Still missing against Acrobat: scrolling by matching content rather than by offset (pages of different sizes drift), and the comparison kept when the old file's tab closes." [[feature]] id = "misc.compare-report" From 3f25d55a96380ffc7ff83bb9ff1eeeaf1c958ed3 Mon Sep 17 00:00:00 2001 From: mlkonrad Date: Thu, 15 Oct 2026 09:30:30 +0300 Subject: [PATCH 008/108] M11: measure every use of an image drawn through deeply nested forms (#1811) (#1933) The effective-resolution walk skipped any form XObject already visited once when it was nested more than four deep, with one seen-set for the whole document. A later, larger use of the same form was never measured, so Reduce File Size and Optimize PDF resampled the image for its smallest use: a 1200 px image drawn 1 in and 8 in wide through six nested forms came out 150 px wide, about 19 ppi on the large copy. - Cycles are detected on the current path of forms, not document-wide. - A form with its own resources drawn again at the same scale (only moved) is skipped, as that can't change any image's resolution; forms without resources draw their caller's images and are always walked. - Repeat walks are capped at 100,000. If the cap or the 12-level depth limit leaves a use unmeasured, every image gets INFINITY: recompressed as the settings say, never resampled. Fixes #1811 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/optimize/README.md | 3 +- crates/optimize/src/images.rs | 154 ++++++++++++++++++++---------- crates/optimize/tests/optimize.rs | 107 +++++++++++++++++++++ parity/acrobat-features.toml | 2 + 4 files changed, 213 insertions(+), 53 deletions(-) diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 8f943d41e4..32915af6bc 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -20,7 +20,8 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going printed this way went from 4.3 MB to 231 KB (Standard) and 161 KB (Mobile). Not merged yet: side-by-side tiles, rotated strips, masks. - **Images:** the effective resolution of each image is measured where pages draw it (the CTM - through form XObjects; the smallest over all uses). Colour and grayscale images above a + through form XObjects, up to 12 deep; the smallest over all uses). If a use can't be + measured (forms nested deeper, or drawn too many times), no image is resampled. Colour and grayscale images above a threshold are resampled with a bicubic (Catmull-Rom) filter to a target resolution and stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image. An image is replaced only if the result is smaller. Left alone: CMYK and other colour spaces, diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index e4976a81b6..8cfa82674a 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -41,73 +41,123 @@ pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Vec { } } -/// Walk one content stream, recording each image's displayed resolution (pixels per inch, the -/// smaller of the two axes) and descending into form XObjects. -fn walk(doc: &Document, data: &[u8], resources: &Dict, ctm: Matrix, depth: usize, seen: &mut HashSet, out: &mut HashMap) { - if depth > 12 { - return; - } - let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); - let mut stack = vec![ctm]; - for op in pdfcraft_content::parse(data).ops { - let top = stack.last().copied().unwrap_or(ctm); - match op.op.as_slice() { - b"q" => stack.push(top), - b"Q" if stack.len() > 1 => { - stack.pop(); - } - b"cm" => { - if let Some(m) = Matrix::from_operands(&op.operands) - && let Some(t) = stack.last_mut() - { - *t = m.then(&top); +/// Forms nested deeper than this aren't followed. +const MAX_FORM_DEPTH: usize = 12; +/// Walks of a form already walked once (other scale or caller) before the measurement gives up. +/// Each form's first walk is free: their number is bounded by the file's object count. +const MAX_REWALKS: usize = 100_000; + +/// State of one [`effective_resolutions`] pass. +struct Walk<'a> { + doc: &'a Document, + /// The forms being walked right now, outermost first: a form drawing one of these is a cycle. + path: Vec, + /// Forms with their own resources already walked, by the linear part of their matrix. Drawing + /// one again at the same scale (only moved) can't change any image's resolution. + walked: HashSet<(ObjRef, [u64; 4])>, + /// Forms walked at least once. + seen: HashSet, + rewalks: usize, + /// False once a use may have been missed (depth or work limit). + complete: bool, + out: HashMap, +} + +impl Walk<'_> { + /// Walk one content stream, recording each image's displayed resolution (pixels per inch, the + /// smaller of the two axes) and descending into form XObjects. + fn content(&mut self, data: &[u8], resources: &Dict, ctm: Matrix) { + let doc = self.doc; + let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let mut stack = vec![ctm]; + for op in pdfcraft_content::parse(data).ops { + let top = stack.last().copied().unwrap_or(ctm); + match op.op.as_slice() { + b"q" => stack.push(top), + b"Q" if stack.len() > 1 => { + stack.pop(); } - } - b"Do" => { - let Some(name) = op.name(0) else { continue }; - let Some(r) = xobjects.get(name).and_then(Object::as_ref) else { continue }; - let obj = doc.get(r); - let Object::Stream(s) = &*obj else { continue }; - match s.dict.name(b"Subtype") { - Some(b"Image") => { - let (w, h) = (s.dict.int(b"Width").unwrap_or(0) as f64, s.dict.int(b"Height").unwrap_or(0) as f64); - let [a, b, c, d, ..] = top.0; - let (sx, sy) = ((a * a + b * b).sqrt(), (c * c + d * d).sqrt()); - if w > 0.0 && h > 0.0 && sx > 1e-6 && sy > 1e-6 { - let ppi = (w / (sx / 72.0)).min(h / (sy / 72.0)); - let e = out.entry(r).or_insert(f64::INFINITY); - *e = e.min(ppi); - } + b"cm" => { + if let Some(m) = Matrix::from_operands(&op.operands) + && let Some(t) = stack.last_mut() + { + *t = m.then(&top); } - Some(b"Form") => { - if !seen.insert(r) && depth > 4 { - continue; - } - let m = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| m.as_array().map(|a| Matrix::from_operands(a))).flatten(); - let res = - s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_else(|| resources.clone()); - let inner = m.unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])).then(&top); - walk(doc, &s.decoded().unwrap_or_default(), &res, inner, depth + 1, seen, out); - } - _ => {} } + b"Do" => { + let Some(name) = op.name(0) else { continue }; + let Some(r) = xobjects.get(name).and_then(Object::as_ref) else { continue }; + let obj = doc.get(r); + let Object::Stream(s) = &*obj else { continue }; + match s.dict.name(b"Subtype") { + Some(b"Image") => { + let (w, h) = (s.dict.int(b"Width").unwrap_or(0) as f64, s.dict.int(b"Height").unwrap_or(0) as f64); + let [a, b, c, d, ..] = top.0; + let (sx, sy) = ((a * a + b * b).sqrt(), (c * c + d * d).sqrt()); + if w > 0.0 && h > 0.0 && sx > 1e-6 && sy > 1e-6 { + let ppi = (w / (sx / 72.0)).min(h / (sy / 72.0)); + let e = self.out.entry(r).or_insert(f64::INFINITY); + *e = e.min(ppi); + } + } + Some(b"Form") => self.form(r, s, resources, top), + _ => {} + } + } + _ => {} } - _ => {} } } + + /// Descend into form `r` drawn with `ctm` from a stream whose resources are `resources`. + fn form(&mut self, r: ObjRef, s: &Stream, resources: &Dict, ctm: Matrix) { + if self.path.contains(&r) { + return; + } + if self.path.len() >= MAX_FORM_DEPTH { + self.complete = false; + return; + } + let doc = self.doc; + let m = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| m.as_array().map(|a| Matrix::from_operands(a))).flatten(); + let inner = m.unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])).then(&ctm); + let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); + // A form without resources uses its caller's, so the same form can draw other images. + if own.is_some() { + let [a, b, c, d, ..] = inner.0; + if !self.walked.insert((r, [a.to_bits(), b.to_bits(), c.to_bits(), d.to_bits()])) { + return; + } + } + if !self.seen.insert(r) { + if self.rewalks >= MAX_REWALKS { + self.complete = false; + return; + } + self.rewalks += 1; + } + self.path.push(r); + self.content(&s.decoded().unwrap_or_default(), own.as_ref().unwrap_or(resources), inner); + self.path.pop(); + } } /// The effective resolution (ppi) of every image drawn by `pages`: the smallest over its uses. +/// +/// If forms nest too deeply or are drawn too many times to measure every use, every image is +/// given `INFINITY` (never resampled), since its largest use may be among those missed. pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap { - let mut out = HashMap::new(); - let mut seen = HashSet::new(); + let mut walk = Walk { doc, path: Vec::new(), walked: HashSet::new(), seen: HashSet::new(), rewalks: 0, complete: true, out: HashMap::new() }; for p in pages { let Some(d) = doc.get(*p).as_dict().cloned() else { continue }; let Some(contents) = d.get(b"Contents") else { continue }; let res = page_resources(doc, *p).unwrap_or_default(); - walk(doc, &content_bytes(doc, contents), &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]), 0, &mut seen, &mut out); + walk.content(&content_bytes(doc, contents), &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); } - out + if !walk.complete { + walk.out.values_mut().for_each(|v| *v = f64::INFINITY); + } + walk.out } /// Colour components of a colour space PdfCraft resamples: gray (1) or RGB (3). diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 4058a026dd..72553f4962 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -821,3 +821,110 @@ fn images_that_do_not_tile_a_picture_are_left_alone() { let (mut off, _) = printed_page(40, |_| false, |_, s| s); unmerged(&mut off, &Settings { merge_fragments: false, ..Settings::default() }); } + +/// `levels` nested forms with their own resources, the innermost drawing `img` over the unit square. +fn nested_forms(doc: &mut Document, img: ObjRef, levels: usize) -> ObjRef { + let mut inner = form(doc, Some(Object::Dict(resources(xobjects(&[("Im", img)])))), "/Im Do"); + for _ in 1..levels { + inner = form(doc, Some(Object::Dict(resources(xobjects(&[("F", inner)])))), "/F Do"); + } + inner +} + +#[test] +fn a_deeply_nested_form_is_measured_at_its_largest_use() { + // 1200 px drawn 1 in wide (1200 ppi) and then 8 in wide (150 ppi): 150 ppi, so it isn't resampled. + let small = "q 72 0 0 72 36 36 cm /F Do Q"; + let large = "q 576 0 0 576 18 100 cm /F Do Q"; + for levels in [1, 6, 12] { + for (pages, label) in [(vec![format!("{small} {large}")], "one page"), (vec![small.into(), large.into()], "two pages")] { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 1200, 1200, 3, false, None); + let outer = nested_forms(&mut doc, img, levels); + for c in &pages { + page(&mut doc, &[("F", outer)], c); + } + let refs = pdfcraft_annot::page_refs(&doc).unwrap(); + assert_eq!(effective_resolutions(&doc, &refs)[&img].round(), 150.0, "{levels} levels, {label}"); + optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(stream(&doc, img).dict.int(b"Width"), Some(1200), "{levels} levels, {label}"); + } + } +} + +#[test] +fn a_form_drawing_itself_is_walked_once_per_use() { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 1200, 1200, 3, false, None); + let f = form(&mut doc, None, "/Im Do q 2 0 0 2 0 0 cm /F Do Q"); + let mut x = xobjects(&[("Im", img)]); + x.set(b"F".to_vec(), Object::Ref(f)); + let mut d = match &*doc.get(f) { + Object::Stream(s) => s.clone(), + _ => unreachable!(), + }; + d.dict.set(b"Resources".to_vec(), Object::Dict(resources(x))); + doc.set(f, Object::Stream(d)); + page(&mut doc, &[("F", f)], "q 72 0 0 72 36 36 cm /F Do Q"); + let refs = pdfcraft_annot::page_refs(&doc).unwrap(); + // Only the outer use: the cycle isn't followed, and doesn't count as a missed use. + assert_eq!(effective_resolutions(&doc, &refs)[&img].round(), 1200.0); +} + +#[test] +fn a_shared_form_without_resources_draws_each_callers_image() { + // G has no /Resources, so its /Im is whichever image the form drawing it names. + let mut doc = Document::new_empty(); + let a = image(&mut doc, 1200, 1200, 3, false, None); + let b = image(&mut doc, 1200, 1200, 3, false, None); + let g = form(&mut doc, None, "/Im Do"); + let mut outers = Vec::new(); + for img in [a, b] { + let mut inner = form(&mut doc, Some(Object::Dict(resources(xobjects(&[("Im", img), ("G", g)])))), "/G Do"); + for _ in 1..6 { + inner = form(&mut doc, Some(Object::Dict(resources(xobjects(&[("F", inner)])))), "/F Do"); + } + outers.push(inner); + } + page(&mut doc, &[("A", outers[0]), ("B", outers[1])], "q 72 0 0 72 36 36 cm /A Do Q q 576 0 0 576 18 100 cm /B Do Q"); + let refs = pdfcraft_annot::page_refs(&doc).unwrap(); + let ppi = effective_resolutions(&doc, &refs); + assert_eq!(ppi[&a].round(), 1200.0); + assert_eq!(ppi.get(&b).map(|p| p.round()), Some(150.0)); +} + +#[test] +fn images_are_not_resampled_when_some_uses_could_not_be_measured() { + // Too deep: the 13th form isn't followed, so its use of the image is unknown. + let mut doc = Document::new_empty(); + let img = image(&mut doc, 1200, 1200, 3, false, None); + let other = image(&mut doc, 1200, 1200, 3, false, None); + let deep = nested_forms(&mut doc, img, 13); + page( + &mut doc, + &[("F", deep), ("Im", img), ("O", other)], + "q 72 0 0 72 36 36 cm /Im Do Q q 72 0 0 72 200 36 cm /O Do Q q 576 0 0 576 18 100 cm /F Do Q", + ); + let refs = pdfcraft_annot::page_refs(&doc).unwrap(); + let ppi = effective_resolutions(&doc, &refs); + assert_eq!((ppi[&img], ppi[&other]), (f64::INFINITY, f64::INFINITY)); + optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(stream(&doc, img).dict.int(b"Width"), Some(1200)); + + // Too much work: forms without resources (so nothing can be skipped), each drawing the next + // three times, twelve deep, would be (3^12 - 1) / 2 walks. + let mut doc = Document::new_empty(); + let img = image(&mut doc, 1200, 1200, 3, false, None); + let mut names = vec![("Im", img)]; + let mut next = "Im"; + let labels = ["F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"]; + for (i, label) in labels.iter().enumerate().rev() { + let s = 1.0 + i as f64 / 100.0; + let f = form(&mut doc, None, &format!("q {s} 0 0 {s} 0 0 cm /{next} Do Q /{next} Do q 1 0 0 1 1 0 cm /{next} Do Q")); + names.push((label, f)); + next = label; + } + page(&mut doc, &names, "q 72 0 0 72 36 36 cm /F1 Do Q"); + let refs = pdfcraft_annot::page_refs(&doc).unwrap(); + assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index dfcdb9aab9..e270a8f211 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7264,6 +7264,8 @@ commands = ["optimize.advanced"] tools = ["doc_optimize"] evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone"] notes = "colour and grayscale, bicubic, effective resolution measured where drawn; image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome not yet" +evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/automation.rs::optimizing_through_tools"] +notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); monochrome not yet" [[feature]] id = "optimize.recompress-images" From 6b7abc61d117278162c173b250a44ac7ee16159d Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 15 Oct 2026 14:30:42 +0800 Subject: [PATCH 009/108] fix(edit): flattening keeps a widget it cannot draw and an undrawn reply (#1794) (#1934) A widget with no appearance to draw (none stored, or none for its selected state) was removed from the page and the field tree with nothing in its place, so the field's value was lost; a reply kept because it could not be drawn was then deleted anyway as a reply of the flattened comment. Both now stay as they are. A widget whose appearance draws nothing is still removed, as is a reply note's empty appearance with its comment. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Fable 5.1 --- crates/edit/src/flatten.rs | 26 ++++++++++++++---- crates/edit/src/tests.rs | 52 ++++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 8 +++--- 3 files changed, 77 insertions(+), 9 deletions(-) diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index a3f6c886a2..8202c18d83 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -6,7 +6,8 @@ //! the appearance's bounding box, transformed by its `/Matrix`, mapped onto `/Rect`). Hidden //! annotations are left alone; links are kept (they have no ink and stay clickable). Flattened //! comments take their pop-ups and replies with them; flattened widgets are removed from the -//! form's field tree. +//! form's field tree. An annotation whose appearance cannot be drawn is not flattened: it stays +//! as it is, so no comment or field value is lost with nothing in its place. use std::collections::HashSet; @@ -154,6 +155,8 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result = HashSet::new(); let mut gone_inline: HashSet = HashSet::new(); + // Selected, but nothing was drawn for it: it stays, pop-up and reply handling included. + let mut undrawn: HashSet = HashSet::new(); // A viewer draws many comments from their dictionary even when the file stores no // appearance (notes, FreeText, ink, stamps…). Build the appearance it would show, so // flattening keeps a comment that still has ink on screen instead of deleting it. @@ -199,9 +202,18 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result { @@ -218,9 +230,13 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result