From 669196a67e9840656960cf100bde1f5ac77d355a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 12:29:05 -0400 Subject: [PATCH] M3.4: view tests (layouts, actual size/fit width/fit page, link clicks, field list jump, field highlighting pixels, fonts tab); parity: 10 features to shipped (P0 35.2%) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 10 ++ crates/ui-egui/tests/view.rs | 182 +++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 62 ++++++------ 3 files changed, 223 insertions(+), 31 deletions(-) create mode 100644 crates/ui-egui/tests/view.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 883c3396..3c52b2fe 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -135,6 +135,16 @@ pub enum ViewAction { } impl DocView { + /// Where `page` is drawn on screen this frame (`None` when it is not on screen). + pub fn page_screen_rect(&self, page: usize) -> Option { + self.screen_xforms.iter().find(|(p, _)| *p == page).map(|(_, xf)| xf.rect) + } + + /// The document area on screen. + pub fn viewport_rect(&self) -> Rect { + self.viewport_screen + } + /// Pages that could not be rendered, with the reason (for automation; 0-based pages). pub fn page_errors(&self) -> Vec<(usize, &str)> { let mut v: Vec<(usize, &str)> = self.errors.iter().map(|(p, e)| (*p, e.as_str())).collect(); diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs new file mode 100644 index 00000000..68d6ac8b --- /dev/null +++ b/crates/ui-egui/tests/view.rs @@ -0,0 +1,182 @@ +//! Page layouts, zoom modes and link navigation, checked by where pages land on screen. + +use egui::{Key, Modifiers}; +use egui_kittest::Harness; +use printcraft_ui_egui::PrintCraftApp; + +/// Three 300×400 pt pages. Page 1 links to page 3. +const PAGES: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [6 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R >> endobj +5 0 obj << /Type /Page /Parent 2 0 R >> endobj +6 0 obj << /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /Dest [5 0 R /Fit] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +fn harness(options: &'static [(&'static str, &'static str)]) -> Harness<'static, PrintCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PrintCraftApp::new(); + app.open_bytes("pages.pdf", None, PAGES.to_vec()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + for (k, v) in options { + app.set_option(k, v).unwrap(); + } + app + }); + h.run_steps(8); + h +} + +fn rect(h: &Harness<'static, PrintCraftApp>, page: usize) -> Option { + h.state().views[0].page_screen_rect(page) +} + +#[test] +fn continuous_layout_stacks_pages_vertically() { + let mut h = harness(&[("layout", "continuous"), ("zoom", "50")]); + h.run_steps(4); + let (a, b) = (rect(&h, 0).expect("page 1 on screen"), rect(&h, 1).expect("page 2 on screen")); + assert!(b.min.y > a.max.y, "page 2 below page 1: {a:?} {b:?}"); + assert!((a.center().x - b.center().x).abs() < 1.0, "same column"); +} + +#[test] +fn two_up_layout_places_pages_side_by_side() { + let mut h = harness(&[("layout", "two-up"), ("zoom", "50")]); + h.run_steps(4); + let (a, b) = (rect(&h, 0).expect("page 1"), rect(&h, 1).expect("page 2")); + assert!((a.min.y - b.min.y).abs() < 1.0, "same row: {a:?} {b:?}"); + assert!(b.min.x > a.max.x, "page 2 right of page 1"); + let c = rect(&h, 2).expect("page 3 on the next row"); + assert!(c.min.y > a.max.y); +} + +#[test] +fn single_page_layout_shows_one_page_at_a_time() { + let mut h = harness(&[("layout", "single"), ("zoom", "50")]); + h.run_steps(4); + assert!(rect(&h, 0).is_some() && rect(&h, 1).is_none(), "only page 1"); + h.state_mut().set_option("page", "2").unwrap(); + h.run_steps(4); + assert!(rect(&h, 0).is_none() && rect(&h, 1).is_some(), "only page 2"); +} + +#[test] +fn actual_size_fit_width_and_fit_page() { + let mut h = harness(&[]); + let pt = 96.0 / 72.0; + + h.key_press_modifiers(Modifiers::COMMAND, Key::Num1); // actual size + h.run_steps(4); + let r = rect(&h, 0).expect("page 1"); + assert!((r.width() - 300.0 * pt).abs() < 1.0, "100% is 300 pt at 96 dpi: {}", r.width()); + + h.key_press_modifiers(Modifiers::COMMAND, Key::Num2); // fit width + h.run_steps(4); + let (r, vp) = (rect(&h, 0).expect("page 1"), h.state().views[0].viewport_rect()); + assert!(r.width() > vp.width() * 0.85 && r.width() <= vp.width(), "fit width: page {} in viewport {}", r.width(), vp.width()); + + h.key_press_modifiers(Modifiers::COMMAND, Key::Num0); // fit page + h.run_steps(4); + let (r, vp) = (rect(&h, 0).expect("page 1"), h.state().views[0].viewport_rect()); + assert!(r.height() <= vp.height() && r.height() > vp.height() * 0.85, "fit page: page {} in viewport {}", r.height(), vp.height()); + assert!(r.width() < vp.width(), "a portrait page fits by height"); +} + +#[test] +fn clicking_a_link_goes_to_its_destination() { + // Default fit-width zoom: pages are taller than the window, so the target can scroll to the top. + let mut h = harness(&[]); + h.run_steps(4); + let r = rect(&h, 0).expect("page 1"); + // The link spans x 50..250, y 300..350 in PDF space (y up) on a 300×400 page. + let at = egui::pos2(r.min.x + r.width() * (150.0 / 300.0), r.min.y + r.height() * (1.0 - 325.0 / 400.0)); + h.hover_at(at); + h.run_steps(2); + h.drag_at(at); // press + h.drop_at(at); // release + h.run_steps(8); + assert_eq!(h.state().views[0].current, 2, "the link targets page 3"); + let (target, vp) = (rect(&h, 2).expect("page 3 on screen"), h.state().views[0].viewport_rect()); + assert!((target.min.y - vp.min.y).abs() < 40.0, "page 3 is scrolled to the top: {target:?} in {vp:?}"); +} + +/// Two pages with a text field on each and a non-embedded Helvetica. +const FORM: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 7 0 R] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [6 0 R] /Contents 5 0 R /Resources << /Font << /F1 8 0 R >> >> >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /Annots [7 0 R] >> endobj +5 0 obj << /Length 37 >> stream +BT /F1 12 Tf 20 20 Td (Form) Tj ET +endstream endobj +6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (fullname) /V (Ada) /Rect [50 300 250 330] /P 3 0 R >> endobj +7 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (postcode) /Rect [50 300 250 330] /P 4 0 R >> endobj +8 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +fn form_harness() -> Harness<'static, PrintCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PrintCraftApp::new(); + app.open_bytes("form.pdf", None, FORM.to_vec()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app + }); + h.run_steps(8); + h +} + +#[test] +fn field_list_jumps_to_a_fields_page() { + use egui_kittest::kittest::Queryable; + let mut h = form_harness(); + h.state_mut().set_option("panel", "fields").unwrap(); + h.run_steps(4); + h.get_by_label("fullname"); + h.get_by_label("postcode").click(); + h.run_steps(8); + assert_eq!(h.state().views[0].current, 1, "postcode is on page 2"); +} + +#[test] +fn highlight_fields_tints_the_field_area() { + use egui_kittest::kittest::Queryable; + let mut h = form_harness(); + h.state_mut().set_option("panel", "none").unwrap(); + for _ in 0..100 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let r = rect(&h, 0).expect("page 1"); + // The field spans x 50..250, y 300..330 (PDF space, y up) on a 300×400 page; sample inside it. + let at = egui::pos2(r.min.x + r.width() * (60.0 / 300.0), r.min.y + r.height() * (1.0 - 305.0 / 400.0)); + let pixel = |h: &mut Harness<'static, PrintCraftApp>| { + let img = h.render().expect("renders"); + let ppp = h.ctx.pixels_per_point(); + *img.get_pixel((at.x * ppp) as u32, (at.y * ppp) as u32) + }; + let before = pixel(&mut h); + h.get_by_label("Highlight fields").click(); + h.run_steps(4); + assert!(h.state().views[0].highlight_fields); + h.get_by_label("Hide field highlights"); + let after = pixel(&mut h); + assert_ne!(before, after, "the field area is tinted when highlighting is on"); +} + +#[test] +fn fonts_tab_lists_fonts_and_embedding() { + use egui_kittest::kittest::Queryable; + let mut h = form_harness(); + h.state_mut().set_option("dialog", "fonts").unwrap(); + h.run_steps(4); + h.get_by_label("Helvetica"); + h.get_by_label_contains("Not embedded"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ce2e7176..29b6b096 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -782,7 +782,7 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::missing_page_and_garbage_input_fail_gracefully", "crates/render/src/raster.rs::scale_is_clamped_for_huge_pages"] +evidence = ["crates/render/src/raster.rs::missing_page_and_garbage_input_fail_gracefully", "crates/render/src/raster.rs::scale_is_clamped_for_huge_pages", "crates/render/src/raster.rs::watchdog_skips_a_stuck_page_and_keeps_rendering"] notes = "" [[feature]] @@ -815,11 +815,11 @@ title = "Single page continuous scrolling" area = "B" tier = "P0" milestone = "M3" -status = "partial" +status = "shipped" commands = [] tools = [] -evidence = [] -notes = "works; no layout test yet" +evidence = ["crates/ui-egui/tests/view.rs::continuous_layout_stacks_pages_vertically"] +notes = "" [[feature]] id = "view.layout-single-page" @@ -827,11 +827,11 @@ title = "Single page view" area = "B" tier = "P0" milestone = "M3" -status = "partial" +status = "shipped" commands = [] tools = [] -evidence = [] -notes = "works; no layout test yet" +evidence = ["crates/ui-egui/tests/view.rs::single_page_layout_shows_one_page_at_a_time"] +notes = "" [[feature]] id = "view.layout-two-up" @@ -839,11 +839,11 @@ title = "Two-page view" area = "B" tier = "P0" milestone = "M3" -status = "partial" +status = "shipped" commands = [] tools = [] -evidence = [] -notes = "works; no layout test yet" +evidence = ["crates/ui-egui/tests/view.rs::two_up_layout_places_pages_side_by_side"] +notes = "" [[feature]] id = "view.layout-two-up-scrolling" @@ -923,11 +923,11 @@ title = "Actual size" area = "B" tier = "P0" milestone = "M3" -status = "partial" +status = "shipped" commands = [] tools = [] -evidence = [] -notes = "menu item exists; no test" +evidence = ["crates/ui-egui/tests/view.rs::actual_size_fit_width_and_fit_page"] +notes = "" [[feature]] id = "view.zoom-fit-page" @@ -935,11 +935,11 @@ title = "Fit page" area = "B" tier = "P0" milestone = "M3" -status = "partial" +status = "shipped" commands = [] tools = [] -evidence = [] -notes = "fit page / fit width toggle; no test" +evidence = ["crates/ui-egui/tests/view.rs::actual_size_fit_width_and_fit_page"] +notes = "" [[feature]] id = "view.zoom-fit-width" @@ -947,11 +947,11 @@ title = "Fit width" area = "B" tier = "P0" milestone = "M3" -status = "partial" +status = "shipped" commands = [] tools = [] -evidence = [] -notes = "fit page / fit width toggle; no test" +evidence = ["crates/ui-egui/tests/view.rs::actual_size_fit_width_and_fit_page"] +notes = "" [[feature]] id = "view.zoom-fit-height" @@ -1139,11 +1139,11 @@ title = "Follow links and destinations" area = "B" tier = "P0" milestone = "M3" -status = "partial" +status = "shipped" commands = [] tools = [] -evidence = [] -notes = "link clicks work in the viewer; no test; actions beyond GoTo/URI pending" +evidence = ["crates/ui-egui/tests/view.rs::clicking_a_link_goes_to_its_destination"] +notes = "GoTo and URI links; other action types (JavaScript, Launch, named actions) arrive with M6" [[feature]] id = "view.annotation-hover" @@ -3343,11 +3343,11 @@ title = "List fonts with type and embedding status" area = "D" tier = "P1" milestone = "M3" -status = "partial" +status = "shipped" commands = ["file.properties"] tools = ["doc_info"] -evidence = [] -notes = "Fonts tab works; no test yet" +evidence = ["crates/ui-egui/tests/view.rs::fonts_tab_lists_fonts_and_embedding"] +notes = "" [[feature]] id = "organize.initial-view" @@ -4263,11 +4263,11 @@ title = "Form fields panel listing every field and value" area = "F" tier = "P0" milestone = "M3" -status = "partial" +status = "shipped" commands = ["form.fields"] tools = ["doc_info"] -evidence = [] -notes = "read-only list works; no panel test yet; editing in M6" +evidence = ["crates/ui-egui/tests/view.rs::field_list_jumps_to_a_fields_page"] +notes = "read-only list with jump-to-field; filling and editing are separate features (M6)" [[feature]] id = "form.highlight-fields" @@ -4275,11 +4275,11 @@ title = "Highlight existing fields" area = "F" tier = "P0" milestone = "M3" -status = "partial" +status = "shipped" commands = [] tools = [] -evidence = [] -notes = "toggle exists; no test" +evidence = ["crates/ui-egui/tests/view.rs::highlight_fields_tints_the_field_area"] +notes = "" [[feature]] id = "form.field-appearance-display"