1015 lines
45 KiB
Rust
1015 lines
45 KiB
Rust
//! Page layouts, zoom modes and link navigation, checked by where pages land on screen.
|
||
|
||
use egui::{Key, Modifiers, MouseWheelUnit, TouchPhase};
|
||
use egui_kittest::Harness;
|
||
use pdfcraft_ui_egui::PdfCraftApp;
|
||
|
||
/// 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";
|
||
|
||
/// Tests that render pixels (`h.render()`) take this first, so only one wgpu device compiles
|
||
/// shaders at a time. On machines without a GPU, wgpu falls back to Microsoft's WARP, whose ARM64
|
||
/// pixel-shader JIT crashes (access violation in `d3d10warp!PixelJitProgram::ClassifyVars`) when
|
||
/// two devices compile at once, as on GitHub's Windows 11 ARM64 runner. Hold it for the whole
|
||
/// test (declared before the harness, so it's released after the device is dropped).
|
||
static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||
|
||
fn gpu() -> std::sync::MutexGuard<'static, ()> {
|
||
// A test that panicked while holding it leaves nothing to clean up.
|
||
GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||
}
|
||
|
||
fn harness(options: &'static [(&'static str, &'static str)]) -> Harness<'static, PdfCraftApp> {
|
||
harness_stepping(1.0 / 4.0, options)
|
||
}
|
||
|
||
/// [`harness`] with frames `step_dt` seconds apart (a quarter second is kittest's default).
|
||
fn harness_stepping(step_dt: f32, options: &'static [(&'static str, &'static str)]) -> Harness<'static, PdfCraftApp> {
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(step_dt).build_eframe(move |_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
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, PdfCraftApp>, page: usize) -> Option<egui::Rect> {
|
||
h.state().views[0].page_screen_rect(page)
|
||
}
|
||
|
||
/// Two pages of different sizes: 300×400 then 600×300.
|
||
const MIXED: &[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] /Count 2 >> endobj
|
||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] >> endobj
|
||
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 600 300] >> endobj
|
||
trailer << /Root 1 0 R >>
|
||
%%EOF";
|
||
|
||
fn mixed_harness(options: &'static [(&'static str, &'static str)]) -> Harness<'static, PdfCraftApp> {
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("mixed.pdf", None, MIXED.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
|
||
}
|
||
|
||
/// A vertical wheel event; negative `dy` scrolls down.
|
||
fn wheel(unit: MouseWheelUnit, dy: f32, phase: TouchPhase) -> egui::Event {
|
||
egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, dy), phase, modifiers: Modifiers::NONE }
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn middle_button_scrolling_keeps_page_colours_in_both_themes() {
|
||
use egui_kittest::kittest::Queryable;
|
||
let _gpu = gpu();
|
||
for theme in ["light", "dark"] {
|
||
for organize in [false, true] {
|
||
let mut h = harness(&[("zoom", "50")]);
|
||
h.state_mut().set_option("theme", theme).unwrap();
|
||
h.state_mut().set_option("organize", if organize { "on" } else { "off" }).unwrap();
|
||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||
loop {
|
||
assert!(
|
||
std::time::Instant::now() < deadline,
|
||
"render readiness timed out: theme={theme}, organize={organize}, errors={:?}",
|
||
h.state().views[0].page_errors()
|
||
);
|
||
h.run_steps(2);
|
||
if !h.state().render_pending() {
|
||
// Paint one fresh frame after all requested rasters have arrived.
|
||
h.run_steps(1);
|
||
if h.state().render_pending() {
|
||
continue;
|
||
}
|
||
assert!(
|
||
h.state().views[0].page_errors().is_empty(),
|
||
"page render errors: theme={theme}, organize={organize}, errors={:?}",
|
||
h.state().views[0].page_errors()
|
||
);
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
}
|
||
let page = if organize { h.get_by_label("Page 1").rect() } else { rect(&h, 0).unwrap() };
|
||
let at = page.center();
|
||
let ppp = h.ctx.pixels_per_point();
|
||
let before = *h.render().unwrap().get_pixel((at.x * ppp) as u32, (at.y * ppp) as u32);
|
||
assert_eq!(
|
||
before,
|
||
image::Rgba([255, 255, 255, 255]),
|
||
"the synthetic page is white: theme={theme}, organize={organize}, page={page:?}, ppp={ppp}"
|
||
);
|
||
let anchor = h.state().views[0].viewport_rect().center();
|
||
h.event(egui::Event::PointerMoved(anchor));
|
||
h.event(egui::Event::PointerButton { pos: anchor, button: egui::PointerButton::Middle, pressed: true, modifiers: Modifiers::NONE });
|
||
h.run_steps(1);
|
||
h.event(egui::Event::PointerButton { pos: anchor, button: egui::PointerButton::Middle, pressed: false, modifiers: Modifiers::NONE });
|
||
h.run_steps(1);
|
||
assert!(h.state().views[0].auto_scrolling());
|
||
let during = *h.render().unwrap().get_pixel((at.x * ppp) as u32, (at.y * ppp) as u32);
|
||
assert_eq!(during, before, "scrolling preserves page colours: theme={theme}, organize={organize}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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 two_page_view_shows_one_spread_at_a_time_until_continuous() {
|
||
use pdfcraft_ui_egui::canvas::PageLayout;
|
||
// Two-page view without continuous scrolling shows just the current spread; the continuous
|
||
// checkbox reveals the rest (#752).
|
||
let mut h = harness(&[("layout", "two-page"), ("zoom", "50")]);
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().views[0].layout, PageLayout::TwoUp);
|
||
assert!(!h.state().views[0].continuous(), "`two-page` is the non-scrolling two-page display");
|
||
assert!(rect(&h, 0).is_some() && rect(&h, 1).is_some(), "the first spread is on screen");
|
||
assert!(rect(&h, 2).is_none(), "the next spread is not shown until paged or scrolled");
|
||
// Turning on continuous scrolling lays every spread out in one column.
|
||
h.state_mut().set_option("continuous", "on").unwrap();
|
||
h.run_steps(4);
|
||
assert!(rect(&h, 2).is_some(), "continuous scrolling reveals the later spreads");
|
||
assert_eq!(h.state().views[0].layout, PageLayout::TwoUp, "the base layout is unchanged");
|
||
}
|
||
|
||
#[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 single_page_wheel_pans_instead_when_zoomed_in() {
|
||
// The zoomed-in guard: with room to pan, the wheel pans and must not turn pages.
|
||
let mut h = harness(&[("layout", "single"), ("zoom", "200")]);
|
||
h.run_steps(4);
|
||
let at = h.state().views[0].viewport_rect().center();
|
||
h.hover_at(at);
|
||
h.run_steps(1);
|
||
h.event(wheel(MouseWheelUnit::Point, -60.0, TouchPhase::Move));
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().views[0].current, 0, "zoomed-in wheel pans, it does not turn the page");
|
||
}
|
||
|
||
#[test]
|
||
fn wheel_turns_one_page_per_notch_and_per_trackpad_swipe() {
|
||
// At a real frame rate: brisk notches each turn a page, and one trackpad swipe turns one
|
||
// page, its momentum included (macOS sends the momentum as a second Start…End).
|
||
let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single"), ("zoom", "50")]);
|
||
let at = h.state().views[0].viewport_rect().center();
|
||
h.hover_at(at);
|
||
h.run_steps(2);
|
||
for _ in 0..2 {
|
||
h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move));
|
||
h.run_steps(5);
|
||
}
|
||
h.run_steps(30);
|
||
assert_eq!(h.state().views[0].current, 2, "two notches 80 ms apart turn two pages");
|
||
let mut send = |phase, dy| {
|
||
h.event(wheel(MouseWheelUnit::Point, dy, phase));
|
||
h.step();
|
||
};
|
||
send(TouchPhase::Start, 0.0);
|
||
for _ in 0..6 {
|
||
send(TouchPhase::Move, 25.0);
|
||
}
|
||
send(TouchPhase::End, 0.0);
|
||
send(TouchPhase::Start, 0.0);
|
||
let mut d = 25.0;
|
||
while d > 0.5 {
|
||
send(TouchPhase::Move, d);
|
||
d *= 0.92;
|
||
}
|
||
send(TouchPhase::End, 0.0);
|
||
h.run_steps(30);
|
||
assert_eq!(h.state().views[0].current, 1, "one swipe back turns one page back");
|
||
}
|
||
|
||
#[test]
|
||
fn precise_touchpad_wheel_scrolls_one_to_one() {
|
||
// A precision touchpad's pixel delta scrolls the page by that amount in the same frame,
|
||
// instead of egui easing it over several frames so the page lags the finger (#759). A single
|
||
// 200 px precise event is well over egui's 8 px smoothing threshold, so without the fix only
|
||
// a fraction would land on the first frame.
|
||
let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "100")]);
|
||
let at = h.state().views[0].viewport_rect().center();
|
||
h.hover_at(at);
|
||
h.run_steps(2);
|
||
let before = rect(&h, 0).expect("page 1 on screen").min.y;
|
||
h.event(wheel(MouseWheelUnit::Point, -200.0, TouchPhase::Move));
|
||
// A few frames for the event to reach the ScrollArea and the new offset to be drawn. The
|
||
// page jumps the full 200 px at once; an eased path would ramp there over ~0.1 s (6 frames).
|
||
h.run_steps(4);
|
||
let moved = before - rect(&h, 0).expect("page 1 still laid out").min.y;
|
||
assert!(moved > 190.0, "a 200 px precise swipe scrolls ~200 px at once, not an eased fraction: moved {moved}");
|
||
// And it stops with the finger: idle frames must not drift on a leftover easing tail.
|
||
let settled = rect(&h, 0).unwrap().min.y;
|
||
h.run_steps(20);
|
||
let drift = settled - rect(&h, 0).unwrap().min.y;
|
||
assert!(drift.abs() < 2.0, "no easing tail after the swipe: drifted {drift}");
|
||
}
|
||
|
||
#[test]
|
||
fn page_display_commands_switch_layouts() {
|
||
let mut h = harness(&[]);
|
||
h.state_mut().execute("view.layout.single");
|
||
assert_eq!(h.state().views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::Single);
|
||
// The cover toggle refuses outside two-page view instead of arming a hidden flag.
|
||
assert!(!h.state().views[0].cover);
|
||
assert!(!h.state_mut().execute("view.layout.cover"));
|
||
assert!(!h.state().views[0].cover);
|
||
h.state_mut().execute("view.layout.two_up");
|
||
assert_eq!(h.state().views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::TwoUp);
|
||
assert!(h.state_mut().execute("view.layout.cover"));
|
||
assert!(h.state().views[0].cover, "the cover toggle flips");
|
||
assert!(h.state_mut().execute("view.layout.cover"));
|
||
assert!(!h.state().views[0].cover, "toggling twice restores");
|
||
// Continuous scrolling is an independent toggle over the base layout (#752).
|
||
assert!(h.state().views[0].continuous(), "the default view scrolls continuously");
|
||
h.state_mut().execute("view.continuous");
|
||
h.run_steps(3);
|
||
assert!(!h.state().views[0].continuous(), "the checkbox turns continuous scrolling off");
|
||
assert_eq!(h.state().views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::TwoUp, "toggling scrolling leaves the base layout");
|
||
h.state_mut().execute("view.layout.single");
|
||
assert_eq!(h.state().views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::Single);
|
||
assert!(!h.state().views[0].continuous(), "switching the base layout leaves scrolling off");
|
||
}
|
||
|
||
#[test]
|
||
fn layout_option_rejects_typos() {
|
||
let mut h = harness(&[]);
|
||
assert!(h.state_mut().set_option("layout", "singel").is_err());
|
||
assert!(h.state_mut().set_option("default-layout", "bogus").is_err());
|
||
assert!(h.state_mut().set_option("cover", "maybe").is_err());
|
||
assert_eq!(h.state().views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::Single);
|
||
assert!(h.state().views[0].continuous(), "a rejected typo leaves the default display unchanged");
|
||
assert!(h.state_mut().set_option("cover", "on").is_err(), "cover needs two-page view first");
|
||
h.state_mut().set_option("layout", "two-up").unwrap();
|
||
h.state_mut().set_option("cover", "on").unwrap();
|
||
assert!(h.state().views[0].cover);
|
||
h.state_mut().set_option("cover", "off").unwrap();
|
||
assert!(!h.state().views[0].cover);
|
||
}
|
||
|
||
#[test]
|
||
fn view_options_without_a_document() {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
assert!(app.set_option("default-layout", "single").is_ok(), "the default needs no document");
|
||
assert_eq!(app.view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::Single);
|
||
assert!(app.set_option("cover", "on").is_err(), "cover needs an open document");
|
||
assert!(app.set_option("layout", "single").is_err(), "layout needs an open document");
|
||
}
|
||
|
||
#[test]
|
||
fn default_page_display_is_used_for_new_documents() {
|
||
use pdfcraft_ui_egui::canvas::PageLayout;
|
||
// The factory default is single page, scrolling continuously, without snap jumps.
|
||
assert_eq!(PdfCraftApp::new().view_defaults.layout, PageLayout::Single);
|
||
assert!(PdfCraftApp::new().view_defaults.continuous);
|
||
let mut h = harness(&[]);
|
||
h.state_mut().set_option("default-layout", "single").unwrap();
|
||
h.state_mut().open_bytes("other.pdf", None, PAGES.to_vec()).expect("opens");
|
||
assert_eq!(h.state().views[1].layout, PageLayout::Single);
|
||
// So do documents the app creates itself (blank, combined, extracted, from images…).
|
||
assert!(h.state_mut().execute("create.blank"));
|
||
assert_eq!(h.state().views[2].layout, PageLayout::Single);
|
||
// Persisted preferences survive a restart.
|
||
let saved = h.state().persist();
|
||
let mut fresh = PdfCraftApp::new();
|
||
fresh.set_option("language", "en").unwrap();
|
||
fresh.restore(&saved);
|
||
assert_eq!(fresh.view_defaults.layout, PageLayout::Single);
|
||
// Garbage keeps the previous default; casing is forgiven; legacy files lack the key.
|
||
fresh.restore(r#"{"default_layout":"bogus"}"#);
|
||
assert_eq!(fresh.view_defaults.layout, PageLayout::Single);
|
||
fresh.restore(r#"{"default_layout":"TWO-UP"}"#);
|
||
assert_eq!(fresh.view_defaults.layout, PageLayout::TwoUp);
|
||
let mut legacy = PdfCraftApp::new();
|
||
legacy.set_option("language", "en").unwrap();
|
||
legacy.restore("{}");
|
||
assert_eq!(legacy.view_defaults.layout, PageLayout::Single);
|
||
assert!(legacy.view_defaults.continuous, "a legacy file without the key keeps the factory default");
|
||
}
|
||
|
||
#[test]
|
||
fn default_zoom_is_used_for_new_documents() {
|
||
use pdfcraft_ui_egui::canvas::Fit;
|
||
let fit_zoom = |app: &PdfCraftApp, i: usize| (app.views[i].fit, app.views[i].zoom);
|
||
let mut h = harness(&[]);
|
||
// The factory default fits the width.
|
||
assert_eq!(h.state().views[0].fit, Fit::Width);
|
||
h.state_mut().set_option("default-zoom", "fit-page").unwrap();
|
||
assert!(h.state_mut().execute("create.blank"));
|
||
assert_eq!(h.state().views[1].fit, Fit::Page);
|
||
h.state_mut().set_option("default-zoom", "150%").unwrap();
|
||
h.state_mut().open_bytes("other.pdf", None, PAGES.to_vec()).expect("opens");
|
||
assert_eq!(fit_zoom(h.state(), 2), (Fit::None, 1.5));
|
||
// A PDF that asks for a zoom gets it.
|
||
let asks = String::from_utf8_lossy(PAGES).replace("/Pages 2 0 R >>", "/Pages 2 0 R /OpenAction [3 0 R /Fit] >>");
|
||
h.state_mut().open_bytes("asks.pdf", None, asks.into_bytes()).expect("opens");
|
||
assert_eq!(h.state().views[3].fit, Fit::Page);
|
||
// Typos and zooms the view can't show are refused.
|
||
for bad in ["huge", "fit-height", "5", "9000%"] {
|
||
assert!(h.state_mut().set_option("default-zoom", bad).is_err(), "{bad}");
|
||
}
|
||
// Saved settings keep it, and garbage leaves it alone.
|
||
let saved = h.state().persist();
|
||
let mut fresh = PdfCraftApp::new();
|
||
fresh.set_option("language", "en").unwrap();
|
||
fresh.restore(&saved);
|
||
fresh.restore(r#"{"default_zoom":"bogus"}"#);
|
||
fresh.open_bytes("again.pdf", None, PAGES.to_vec()).expect("opens");
|
||
assert_eq!(fit_zoom(&fresh, 0), (Fit::None, 1.5));
|
||
}
|
||
|
||
#[test]
|
||
fn preferences_set_the_default_zoom() {
|
||
use egui_kittest::kittest::Queryable;
|
||
let mut h = harness(&[]);
|
||
assert!(h.state_mut().execute("app.preferences"));
|
||
h.run_steps(3);
|
||
h.get_by_label("Zoom to page level").scroll_to_me();
|
||
h.run_steps(4);
|
||
h.get_by_label("Zoom to page level").click();
|
||
h.run_steps(2);
|
||
assert!(h.state_mut().execute("create.blank"));
|
||
assert_eq!(h.state().views[1].fit, pdfcraft_ui_egui::canvas::Fit::Page);
|
||
}
|
||
|
||
#[test]
|
||
fn rail_page_display_menu_offers_acrobats_view_choices() {
|
||
use egui_kittest::kittest::Queryable;
|
||
use pdfcraft_ui_egui::canvas::{Fit, PageLayout};
|
||
let mut h = harness(&[]);
|
||
h.run_steps(4);
|
||
let pick = |h: &mut Harness<'static, PdfCraftApp>, item: &str| {
|
||
h.get_by_label_contains("Page display:").click();
|
||
h.run_steps(3);
|
||
h.get_by_label(item).click();
|
||
h.run_steps(4);
|
||
};
|
||
let shown = |h: &Harness<'static, PdfCraftApp>| (h.state().views[0].layout, h.state().views[0].continuous(), h.state().views[0].fit);
|
||
pick(&mut h, "Fit one full page");
|
||
assert_eq!(shown(&h), (PageLayout::Single, false, Fit::Page), "one full page at a time, not scrolling");
|
||
// The whole page fits at once, so the wheel turns pages without zooming out first.
|
||
let at = h.state().views[0].viewport_rect().center();
|
||
h.hover_at(at);
|
||
h.run_steps(1);
|
||
h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move));
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().views[0].current, 1);
|
||
pick(&mut h, "Actual size");
|
||
assert_eq!((h.state().views[0].fit, h.state().views[0].zoom), (Fit::None, 1.0));
|
||
pick(&mut h, "Zoom to page level");
|
||
assert_eq!(h.state().views[0].fit, Fit::Page);
|
||
pick(&mut h, "Fit to width scrolling");
|
||
assert_eq!(shown(&h), (PageLayout::Single, true, Fit::Width), "single page, scrolling continuously");
|
||
}
|
||
|
||
#[test]
|
||
fn rail_page_display_button_switches_layouts() {
|
||
use egui_kittest::kittest::Queryable;
|
||
let mut h = harness(&[]);
|
||
h.run_steps(4);
|
||
h.get_by_label_contains("Page display").click();
|
||
h.run_steps(3);
|
||
h.get_by_label("Single page").click();
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::Single);
|
||
}
|
||
|
||
#[test]
|
||
fn view_menu_page_display_uses_the_same_commands() {
|
||
use egui_kittest::kittest::Queryable;
|
||
let mut h = harness(&[]);
|
||
h.run_steps(4);
|
||
h.get_by_label("Menu").click();
|
||
h.run_steps(2);
|
||
h.get_by_label("View ⏵").hover();
|
||
h.run_steps(3);
|
||
h.get_by_label("Single page").click();
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::Single);
|
||
}
|
||
|
||
#[test]
|
||
fn palette_runs_page_display_commands_from_the_keyboard() {
|
||
use egui_kittest::kittest::Queryable;
|
||
let mut h = harness(&[]);
|
||
h.state_mut().set_option("palette", "single page").unwrap();
|
||
h.run_steps(3);
|
||
h.get_by_label("Single page");
|
||
h.key_press(Key::Enter);
|
||
h.run_steps(3);
|
||
assert!(!h.state().palette_open, "Enter runs the top hit and closes the palette");
|
||
assert_eq!(h.state().views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::Single);
|
||
}
|
||
|
||
#[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 fit_page_holds_zoom_across_mixed_page_sizes() {
|
||
// Scrolling views fit their largest page, so scrolling past other sizes must not jump
|
||
// the zoom (#214).
|
||
let mut h = mixed_harness(&[("layout", "continuous")]);
|
||
h.key_press_modifiers(Modifiers::COMMAND, Key::Num0); // fit page
|
||
h.run_steps(4);
|
||
let z1 = h.state().views[0].zoom;
|
||
h.state_mut().set_option("page", "2").unwrap();
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().views[0].current, 1);
|
||
assert_eq!(h.state().views[0].zoom, z1, "another size scrolls past at the same zoom");
|
||
}
|
||
|
||
#[test]
|
||
fn single_page_fit_page_fits_the_shown_page() {
|
||
// Single-page view fits the page it shows: each page fills the viewport on its own.
|
||
let mut h = mixed_harness(&[("layout", "single")]);
|
||
h.key_press_modifiers(Modifiers::COMMAND, Key::Num0); // fit page
|
||
h.run_steps(4);
|
||
let vp = h.state().views[0].viewport_rect();
|
||
let r1 = rect(&h, 0).expect("page 1");
|
||
assert!(r1.width() <= vp.width() && r1.height() <= vp.height(), "page 1 fits: {r1:?} in {vp:?}");
|
||
let z1 = h.state().views[0].zoom;
|
||
h.state_mut().set_option("page", "2").unwrap();
|
||
h.run_steps(4);
|
||
let r2 = rect(&h, 1).expect("page 2");
|
||
assert!(r2.width() <= vp.width() && r2.height() <= vp.height(), "page 2 fits: {r2:?} in {vp:?}");
|
||
assert_ne!(h.state().views[0].zoom, z1, "the wider page refits instead of keeping page 1's zoom");
|
||
}
|
||
|
||
#[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:?}");
|
||
}
|
||
|
||
/// Bookmarks and a link with positioned destinations (ISO 32000-2 §12.3.2.2). Page 2's crop box
|
||
/// is away from the origin (x 50..350, y 100..500), so its user-space y 420 is 20% down the page.
|
||
const DESTS: &[u8] = b"%PDF-1.7
|
||
1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 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 [20 0 R] >> endobj
|
||
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 600] /CropBox [50 100 350 500] >> endobj
|
||
5 0 obj << /Type /Page /Parent 2 0 R >> endobj
|
||
10 0 obj << /Type /Outlines /First 11 0 R /Last 13 0 R /Count 3 >> endobj
|
||
11 0 obj << /Title (Section 1.1 Shapes) /Parent 10 0 R /Next 12 0 R /Dest [4 0 R /XYZ 0 420 0] >> endobj
|
||
12 0 obj << /Title (Zoomed in) /Parent 10 0 R /Next 13 0 R /Dest [4 0 R /XYZ 125 300 3] >> endobj
|
||
13 0 obj << /Title (Broken) /Parent 10 0 R /Dest [5 0 R /XYZ (left) /top true] >> endobj
|
||
20 0 obj << /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /Dest [4 0 R /FitH 420] >> endobj
|
||
trailer << /Root 1 0 R >>
|
||
%%EOF";
|
||
|
||
fn dests_harness() -> Harness<'static, PdfCraftApp> {
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("dests.pdf", None, DESTS.to_vec()).expect("opens");
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("panel", "bookmarks").unwrap();
|
||
app
|
||
});
|
||
// The Bookmarks panel widens to fit its titles over many frames; fit-width zoom follows it.
|
||
// Positions are checked once the window's layout holds still.
|
||
let mut width = 0.0;
|
||
for _ in 0..400 {
|
||
h.run_steps(1);
|
||
let w = h.state().views[0].viewport_rect().width();
|
||
if w == width {
|
||
break;
|
||
}
|
||
width = w;
|
||
}
|
||
h
|
||
}
|
||
|
||
/// Where the point a fraction (`fx`, `fy`) across and down `page` is on screen, relative to the
|
||
/// viewport's top-left corner.
|
||
fn from_viewport_corner(h: &Harness<'static, PdfCraftApp>, page: usize, fx: f32, fy: f32) -> egui::Vec2 {
|
||
let (r, vp) = (rect(h, page).expect("page on screen"), h.state().views[0].viewport_rect());
|
||
egui::pos2(r.min.x + fx * r.width(), r.min.y + fy * r.height()) - vp.min
|
||
}
|
||
|
||
#[test]
|
||
fn bookmark_destinations_scroll_to_their_position() {
|
||
use egui_kittest::kittest::Queryable;
|
||
use pdfcraft_ui_egui::canvas::Fit;
|
||
let mut h = dests_harness();
|
||
// Fit width: the pages are taller than the window, so any point of them can reach its top.
|
||
h.get_by_label("Section 1.1 Shapes").click();
|
||
h.run_steps(6);
|
||
let v = &h.state().views[0];
|
||
assert_eq!(v.current, 1, "the bookmark targets page 2");
|
||
assert_eq!(v.fit, Fit::Width, "/XYZ with zoom 0 keeps the zoom");
|
||
let d = from_viewport_corner(&h, 1, 0.0, 0.2);
|
||
assert!(d.y.abs() < 2.0, "y 420 on page 2 is at the top of the window, not the page top: {d:?}");
|
||
|
||
// /XYZ left top zoom: 300%, with (125, 300), a quarter across and half way down, at the
|
||
// window's top-left (the page is now wider than the window): its left is the edge of the
|
||
// gutter that keeps pages clear of the quick-action bar.
|
||
h.get_by_label("Zoomed in").click();
|
||
h.run_steps(6);
|
||
let v = &h.state().views[0];
|
||
assert_eq!((v.current, v.fit, v.zoom), (1, Fit::None, 3.0));
|
||
let d = from_viewport_corner(&h, 1, 0.25, 0.5);
|
||
assert!((d.x - 70.0).abs() < 2.0 && d.y.abs() < 2.0, "the destination point is at the window's top-left: {d:?}");
|
||
|
||
// Malformed operands fall back to the top of the page at the current zoom.
|
||
h.get_by_label("Broken").click();
|
||
h.run_steps(6);
|
||
let v = &h.state().views[0];
|
||
assert_eq!((v.current, v.zoom), (2, 3.0));
|
||
let d = from_viewport_corner(&h, 2, 0.0, 0.0);
|
||
assert!(d.y > 0.0 && d.y < 40.0, "page 3 is scrolled to its top: {d:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn link_destinations_scroll_to_their_position() {
|
||
use pdfcraft_ui_egui::canvas::Fit;
|
||
let mut h = dests_harness();
|
||
h.state_mut().set_option("zoom", "100%").unwrap();
|
||
h.run_steps(4);
|
||
let r = rect(&h, 0).expect("page 1");
|
||
// The link spans x 50..250, y 300..350 in PDF space on the 300×400 page 1.
|
||
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);
|
||
let v = &h.state().views[0];
|
||
assert_eq!((v.current, v.fit), (1, Fit::Width), "/FitH goes to page 2 and fits its width");
|
||
let d = from_viewport_corner(&h, 1, 0.0, 0.2);
|
||
assert!(d.y.abs() < 2.0, "y 420 on page 2 is at the top of the window: {d:?}");
|
||
}
|
||
|
||
/// 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, PdfCraftApp> {
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.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 _gpu = gpu();
|
||
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, PdfCraftApp>| {
|
||
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");
|
||
}
|
||
|
||
/// A required radio group (`/Ff` 32768 radio + 2 required) with two 40 pt round buttons.
|
||
const RADIOS: &[u8] = b"%PDF-1.7
|
||
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >> endobj
|
||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj
|
||
3 0 obj << /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >> endobj
|
||
4 0 obj << /FT /Btn /Ff 32770 /T (choice) /V /Off /Kids [5 0 R 6 0 R] >> endobj
|
||
5 0 obj << /Type /Annot /Subtype /Widget /Parent 4 0 R /Rect [50 300 90 340] /AS /Off /P 3 0 R >> endobj
|
||
6 0 obj << /Type /Annot /Subtype /Widget /Parent 4 0 R /Rect [150 300 190 340] /AS /Off /P 3 0 R >> endobj
|
||
trailer << /Root 1 0 R >>
|
||
%%EOF";
|
||
|
||
/// #260 (page 8): with fields highlighted, a required radio button gets a round red border, as
|
||
/// Acrobat draws it, not a square one around its corners.
|
||
#[test]
|
||
fn required_radio_buttons_get_a_round_red_border() {
|
||
let _gpu = gpu();
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("radios.pdf", None, RADIOS.to_vec()).expect("opens");
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("panel", "none").unwrap();
|
||
app.set_option("fields", "on").unwrap();
|
||
app
|
||
});
|
||
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");
|
||
let ppp = h.ctx.pixels_per_point();
|
||
let img = h.render().expect("renders");
|
||
// The first button spans x 50..90, y 300..340 on the 300×400 pt page (y up).
|
||
let at = |x: f32, y: f32| {
|
||
let p = egui::pos2(r.min.x + r.width() * (x / 300.0), r.min.y + r.height() * (1.0 - y / 400.0));
|
||
*img.get_pixel((p.x * ppp) as u32, (p.y * ppp) as u32)
|
||
};
|
||
let red = |p: image::Rgba<u8>| p[0] > 180 && p[1] < 100 && p[2] < 100;
|
||
// Fit width puts about 4 px in a point: 0.2 to 0.3 pt from an edge is inside a 2 px border.
|
||
assert!(red(at(50.2, 320.0)), "the left of the circle is red: {:?}", at(50.2, 320.0));
|
||
assert!(!red(at(50.3, 339.7)), "the widget's corner, outside the circle, is not: {:?}", at(50.3, 339.7));
|
||
}
|
||
|
||
/// Acrobat's Highlight existing fields is a preference, not a per-document choice: turned on
|
||
/// once, the next document (and the next session) opens with fields highlighted.
|
||
#[test]
|
||
fn field_highlighting_is_remembered() {
|
||
use egui_kittest::kittest::Queryable;
|
||
let mut h = form_harness();
|
||
h.state_mut().set_option("panel", "none").unwrap();
|
||
h.run_steps(4);
|
||
assert!(!h.state().views[0].highlight_fields, "off by default");
|
||
h.get_by_label("Highlight fields").click();
|
||
h.run_steps(4);
|
||
assert!(h.state().views[0].highlight_fields);
|
||
h.state_mut().open_bytes("radios.pdf", None, RADIOS.to_vec()).expect("opens");
|
||
h.run_steps(4);
|
||
assert!(h.state().views[1].highlight_fields, "the next document opens highlighted");
|
||
let saved = h.state().persist();
|
||
let mut next = PdfCraftApp::new();
|
||
next.set_option("language", "en").unwrap();
|
||
next.restore(&saved);
|
||
next.open_bytes("form.pdf", None, FORM.to_vec()).expect("opens");
|
||
assert!(next.views[0].highlight_fields, "and so does the next session");
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
|
||
#[test]
|
||
fn the_hand_tool_pans_by_dragging() {
|
||
let mut h = harness(&[("layout", "continuous"), ("zoom", "150"), ("quick", "hand")]);
|
||
h.run_steps(4);
|
||
let before = rect(&h, 0).expect("page 1 on screen");
|
||
let start = egui::pos2(700.0, 700.0);
|
||
h.hover_at(start);
|
||
h.run_steps(1);
|
||
h.drag_at(start);
|
||
h.run_steps(1);
|
||
for k in 1..=5 {
|
||
h.hover_at(start - egui::vec2(0.0, 60.0 * k as f32));
|
||
h.run_steps(1);
|
||
}
|
||
h.drop_at(start - egui::vec2(0.0, 300.0));
|
||
h.run_steps(4);
|
||
let after = rect(&h, 0).expect("still on screen");
|
||
assert!(before.top() - after.top() > 200.0, "dragging up scrolls down: {before:?} → {after:?}");
|
||
assert!(h.state().views[0].selected_text().is_none(), "no text selection with the hand");
|
||
}
|
||
|
||
#[test]
|
||
fn required_fields_get_a_red_border_when_highlighting() {
|
||
let _gpu = gpu();
|
||
let mut h = form_harness();
|
||
h.state_mut().set_option("panel", "none").unwrap();
|
||
h.state_mut().set_option("fields", "on").unwrap();
|
||
let props = pdfcraft_engine::FieldProps { required: Some(true), ..Default::default() };
|
||
h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { name: "fullname".into(), props: Box::new(props) });
|
||
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");
|
||
// Just inside the left edge of the field (x 50, y 300..330 on a 300×400 page).
|
||
let ppp = h.ctx.pixels_per_point();
|
||
let at = egui::pos2(r.min.x + r.width() * (50.0 / 300.0) + 0.75, r.min.y + r.height() * (1.0 - 315.0 / 400.0));
|
||
let img = h.render().expect("renders");
|
||
let px = *img.get_pixel((at.x * ppp) as u32, (at.y * ppp) as u32);
|
||
assert!(px[0] > 180 && px[1] < 100 && px[2] < 100, "a red border: {px:?}");
|
||
}
|
||
|
||
/// The page raster is shown texel for texel. At a zoom whose device scale isn't on the old 1/64
|
||
/// grid (87 % at 1 px per point: 1.16, rendered at 1.15625) the page was rendered at the
|
||
/// quantized scale and stretched onto a rectangle that didn't start on a whole pixel, so every
|
||
/// line and glyph was resampled and looked soft (#260, page 9).
|
||
#[test]
|
||
fn page_raster_maps_one_to_one_onto_screen_pixels() {
|
||
let _gpu = gpu();
|
||
// Fine vertical and horizontal lines (0.5 pt every 1.7 pt) fill most of a 300×400 pt page.
|
||
let mut content = String::from("0 g\n");
|
||
for i in 0..140 {
|
||
let v = 30.0 + 1.7 * i as f32;
|
||
content.push_str(&format!("{v} 40 0.5 320 re 30 {v} 240 0.5 re\n"));
|
||
}
|
||
content.push_str("f\n");
|
||
let pdf = format!(
|
||
"%PDF-1.7
|
||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] /Contents 4 0 R >> endobj
|
||
4 0 obj << /Length {} >> stream
|
||
{content}
|
||
endstream endobj
|
||
trailer << /Root 1 0 R >>
|
||
%%EOF",
|
||
content.len()
|
||
)
|
||
.into_bytes();
|
||
for ppp in [1.0, 2.0] {
|
||
let bytes = pdf.clone();
|
||
let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).with_pixels_per_point(ppp).build_eframe(move |_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("lines.pdf", None, bytes.clone()).expect("opens");
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("panel", "none").unwrap();
|
||
app.set_option("zoom", "87").unwrap();
|
||
app
|
||
});
|
||
let (mean, worst) = compare_page_with_raster(&mut h, &pdf, 0.87);
|
||
assert!(mean < 1.0 && worst < 16, "{ppp} px/pt: the page on screen differs from its raster: mean {mean:.2}, max {worst}");
|
||
}
|
||
}
|
||
|
||
/// Mean and largest difference between page 1 on screen and the page rendered directly at the
|
||
/// view's exact device scale, over the lined area of `page_raster_maps_one_to_one_onto_screen_pixels`.
|
||
fn compare_page_with_raster(h: &mut Harness<'static, PdfCraftApp>, pdf: &[u8], zoom: f32) -> (f64, u8) {
|
||
for _ in 0..100 {
|
||
h.run_steps(2);
|
||
if !h.state().render_pending() {
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
}
|
||
let ppp = h.ctx.pixels_per_point();
|
||
let r = rect(h, 0).expect("page 1");
|
||
let img = h.render().expect("renders");
|
||
let scale = zoom * 96.0 / 72.0 * ppp;
|
||
let mut renderer = pdfcraft_render::PageRenderer::new(std::sync::Arc::new(pdf.to_vec()), pdfcraft_render::RenderConfig::default());
|
||
let page = renderer.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Pixels, tile: None, scale, tag: 0 });
|
||
assert!(page.error.is_none(), "{:?}", page.error);
|
||
// Compare the lined area (page x 40..270 pt, y 60..330 pt from the top), screen against raster.
|
||
let (ox, oy) = ((r.min.x * ppp).round() as i64, (r.min.y * ppp).round() as i64);
|
||
let (mut sum, mut worst, mut n) = (0u64, 0u8, 0u64);
|
||
for py in (60.0 * scale) as u32..(330.0 * scale) as u32 {
|
||
for px in (40.0 * scale) as u32..(270.0 * scale) as u32 {
|
||
let want = page.rgba[((py * page.width + px) * 4) as usize];
|
||
let got = img.get_pixel((ox + px as i64) as u32, (oy + py as i64) as u32)[0];
|
||
let d = want.abs_diff(got);
|
||
sum += u64::from(d);
|
||
worst = worst.max(d);
|
||
n += 1;
|
||
}
|
||
}
|
||
(sum as f64 / n as f64, worst)
|
||
}
|
||
|
||
#[test]
|
||
fn zooming_a_tiled_page_shows_its_earlier_tiles_until_new_ones_arrive() {
|
||
let _gpu = gpu();
|
||
// A 1200 pt square page of fine lines (0.5 pt every 1.7 pt): tiled at 300 %.
|
||
let mut content = String::from("0 g\n");
|
||
for i in 0..670 {
|
||
let v = 30.0 + 1.7 * i as f32;
|
||
content.push_str(&format!("{v} 30 0.5 1140 re 30 {v} 1140 0.5 re\n"));
|
||
}
|
||
content.push_str("f\n");
|
||
let pdf = format!(
|
||
"%PDF-1.7
|
||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 1200 1200] /Contents 4 0 R >> endobj
|
||
4 0 obj << /Length {} >> stream
|
||
{content}
|
||
endstream endobj
|
||
trailer << /Root 1 0 R >>
|
||
%%EOF",
|
||
content.len()
|
||
)
|
||
.into_bytes();
|
||
let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_eframe(move |_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.open_bytes("grid.pdf", None, pdf.clone()).expect("opens");
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("panel", "none").unwrap();
|
||
app.set_option("zoom", "300").unwrap();
|
||
app
|
||
});
|
||
for _ in 0..400 {
|
||
h.run_steps(2);
|
||
if !h.state().render_pending() {
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
}
|
||
// Detail in the middle of the window: the summed difference between neighbouring pixels.
|
||
let detail = |h: &mut Harness<'static, PdfCraftApp>| {
|
||
let img = h.render().expect("renders");
|
||
let (cx, cy) = (img.width() / 2, img.height() / 2);
|
||
let mut sum = 0u64;
|
||
for y in cy - 100..cy + 100 {
|
||
for x in cx - 150..cx + 150 {
|
||
sum += u64::from(img.get_pixel(x, y)[0].abs_diff(img.get_pixel(x + 1, y)[0]));
|
||
}
|
||
}
|
||
sum
|
||
};
|
||
let sharp = detail(&mut h);
|
||
// The first frame at a new zoom has no tiles for it yet (they are requested at its end).
|
||
h.state_mut().set_option("zoom", "310").unwrap();
|
||
h.step();
|
||
let first = detail(&mut h);
|
||
assert!(first * 10 > sharp * 7, "the first frame after zooming lost its detail: {first} against {sharp}");
|
||
}
|
||
|
||
#[test]
|
||
fn sidebar_collapse_and_resize_preserves_scroll_position() {
|
||
use pdfcraft_ui_egui::canvas::Fit;
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("pages.pdf", None, PAGES.to_vec()).expect("opens");
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("panel", "bookmarks").unwrap();
|
||
app.set_option("layout", "continuous").unwrap();
|
||
app
|
||
});
|
||
let mut width = 0.0;
|
||
for _ in 0..400 {
|
||
h.run_steps(1);
|
||
let w = h.state().views[0].viewport_rect().width();
|
||
if w == width {
|
||
break;
|
||
}
|
||
width = w;
|
||
}
|
||
h.state_mut().views[0].go_to_page(2);
|
||
h.run_steps(6);
|
||
|
||
let v = &h.state().views[0];
|
||
assert_eq!(v.current, 2);
|
||
assert_eq!(v.fit, Fit::Width);
|
||
let zoom_before = v.zoom;
|
||
let rect_before = v.page_screen_rect(2).expect("page 2 on screen");
|
||
let vp_before = v.viewport_rect();
|
||
let rel_y_before = rect_before.min.y - vp_before.min.y;
|
||
|
||
// Collapse bookmarks panel: available width increases, so fit-width zoom increases.
|
||
h.state_mut().set_option("panel", "none").unwrap();
|
||
h.run_steps(6);
|
||
|
||
let v = &h.state().views[0];
|
||
let zoom_after = v.zoom;
|
||
assert!(zoom_after > zoom_before, "zoom increased when panel collapsed: {zoom_after} vs {zoom_before}");
|
||
assert_eq!(v.current, 2, "still on page 2 after panel collapsed");
|
||
let rect_after = v.page_screen_rect(2).expect("page 2 still on screen");
|
||
let vp_after = v.viewport_rect();
|
||
let rel_y_after = rect_after.min.y - vp_after.min.y;
|
||
assert!((rel_y_after - rel_y_before).abs() < 1.0, "page 2 scroll position preserved: before {rel_y_before}, after {rel_y_after}");
|
||
|
||
// Reopen bookmarks panel: available width decreases, so fit-width zoom decreases.
|
||
h.state_mut().set_option("panel", "bookmarks").unwrap();
|
||
for _ in 0..400 {
|
||
h.run_steps(1);
|
||
let w = h.state().views[0].viewport_rect().width();
|
||
if w == width {
|
||
break;
|
||
}
|
||
width = w;
|
||
}
|
||
let v = &h.state().views[0];
|
||
assert_eq!(v.current, 2, "still on page 2 after panel reopened");
|
||
let rect_reopened = v.page_screen_rect(2).expect("page 2 still on screen");
|
||
let vp_reopened = v.viewport_rect();
|
||
let rel_y_reopened = rect_reopened.min.y - vp_reopened.min.y;
|
||
assert!((rel_y_reopened - rel_y_before).abs() < 1.0, "page 2 scroll position preserved on reopen: before {rel_y_before}, after {rel_y_reopened}");
|
||
}
|