mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 21:48:09 -06:00
* fix(ui): validated signature display, snapshot tab reuse Signature widgets with layered appearances baked the unverified state into the page, so a valid signature kept reading 'Signature Not Verified' while the banner and panel said valid. Validated verdicts are now drawn into the icon and caption layers in the display copy only: the signed bytes are untouched, saving keeps the original appearance, and files without those layers or without a valid verdict render exactly as before. Other valid signatures still get the verdict mark over their widget, sharing one style with the Signatures panel, and hide it again once the appearance itself shows the verdict. View signed version and View revision opened a new tab on every click, and every tab showed the document title, so repeats piled up identical tabs. Repeats now reuse the snapshot tab, snapshot tabs read like their source plus the suffix in the tab strip, window title and Combine picker, and carry a notice bar naming the revision with a Close that asks about saving first. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * Address review comments on validated appearances and trust refresh Count every field when checking shared appearance streams, so a caption shared with an unvalidated field is never rewritten for the valid one alone. Pick the icon slot by drawing instead of length, and only when a single layer qualifies. Bare rectangles never count as the icon, and ambiguous or missing slots fall back to the overlay badge. Revalidate and invalidate together through one helper, so trusting a certificate always drops rendered pages. Trust alone still marks nothing dirty. Tests: mixed-verdict sharing, longer decorative borders, competing and doubled layers, capture-before preservation, trust invalidation without zooming, and signed Session round trips with rendering, saving and passwords. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * Prove the icon slot geometrically and cover real layered signatures A lone line drawing is still not the icon: frames and borders span the box while the status mark lives in a corner of it. The icon now has to sit strictly inside the appearance frame and cover at most half of it; anything else falls back to the overlay badge. Widgets without a measurable frame veto the rewrite the same way. Tests: parsed extents and inhabitant checks as units, both reported border repros plus a frameless widget, and real layered signatures built with cms::sign_detached through Session open, trust, rendering, save and reopen, plain and password-protected. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * Identify the icon by shape instead of geometry A lone line drawing is still not the icon, but size and position were the wrong signals: the real status mark is large, and geometry blocked it. Frames, borders and panels are rectangles by nature while the mark is not, so a drawing made only of axis-aligned rectangles is decoration however it is stroked. Singularity, shared-stream and template checks are unchanged; anything ambiguous still falls back to the overlay badge. Tests: rectangularity as a unit, nested line-drawn frames, and a triangular fixture mark in place of the rectangular one. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * Budget the appearance walk and keep the marks with the bytes The walk that finds a signature's icon and caption capped depth, layers and fields, but not the work: a dictionary whose entries all point at the next one is visited once per entry per level, so a small hostile file never finishes opening. The walk now counts its visits and gives up, and a field whose tree cannot be read to the end stops the whole pass, because a layer we never read may be the one two fields share and a rewritten shared layer puts one field's verdict on another field's page. The sharing count is exact again with that: two widgets of one field pointing at the same appearance claim it once, or the field vetoes its own rewrite. A mark used to outlive the bytes it described. A failed display write left the page showing the file's own caption while the verdict still claimed its state was drawn; a metadata edit dropped the mark while the page kept drawing the state, so the badge landed on top of it. The write fallback now drops the marks and logs the loss, and metadata carries them over. Tests: a self-referential appearance tree, which never finished before the budget, two widgets sharing one appearance, a metadata edit that keeps its mark, a save that goes through the writer instead of short-circuiting on the opened file, and the snapshot bar's Close button pressed for real. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * Cite the new regressions in parity, restore main's installer note The appearance entry now cites the bounded walk and the save through the writer, and says what a tree that cannot be read to the end does. The installer entry carried main's older text, which still claims signed installers are planned; main's newer note is taken verbatim. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: preserve rounded decoration beside raster status glyphs * M9: place appearance progress beside signature history * M9: retain appearances with ambiguous curved layers * M9: keep signature verdicts in viewer overlays Defer generic appearance rewriting: caption text and a unique drawing do not establish an icon role. Preserve original artwork for saves, image and text exports, and both print modes while retaining snapshot reuse and trust refresh. Cover chamfered borders and mixed signer/caption streams through signed Session round trips. --------- Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
1384 lines
62 KiB
Rust
1384 lines
62 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 single_page_wheel_at_the_bottom_edge_turns_to_the_next_page_top_and_back() {
|
||
// #1924: zoomed in so the page is taller than the window, the wheel pans; at the bottom
|
||
// edge one more notch shows the next page at its top, and at the top edge one notch up
|
||
// shows the previous page at its bottom.
|
||
let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single"), ("zoom", "200")]);
|
||
let at = h.state().views[0].viewport_rect().center();
|
||
h.hover_at(at);
|
||
h.run_steps(4);
|
||
// Notches pan page 1 down; the page turns only after the view stopped at the bottom.
|
||
let (mut notches, mut stalled) = (0, 0);
|
||
let mut last = rect(&h, 0).expect("page 1 on screen").min.y;
|
||
while h.state().views[0].current == 0 && notches < 80 {
|
||
h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move));
|
||
h.run_steps(12);
|
||
notches += 1;
|
||
if let Some(r) = rect(&h, 0) {
|
||
stalled = if (r.min.y - last).abs() < 0.01 { stalled + 1 } else { 0 };
|
||
last = r.min.y;
|
||
}
|
||
}
|
||
assert!(notches > 2, "the page panned before it turned: {notches} notches");
|
||
assert_eq!(h.state().views[0].current, 1, "a notch at the bottom edge shows the next page");
|
||
assert!(stalled <= 1, "no notch was wasted at the edge");
|
||
let top = rect(&h, 1).expect("page 2 on screen").min.y;
|
||
assert!(top > h.state().views[0].viewport_rect().min.y, "page 2 shows its top edge: {top}");
|
||
h.event(wheel(MouseWheelUnit::Line, 1.0, TouchPhase::Move));
|
||
h.run_steps(30);
|
||
assert_eq!(h.state().views[0].current, 0, "a notch at the top edge shows the previous page");
|
||
let r = rect(&h, 0).expect("page 1 on screen");
|
||
assert!(r.max.y < h.state().views[0].viewport_rect().max.y + 40.0, "page 1 shows its bottom edge: {r:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn single_page_swipe_that_reaches_the_edge_does_not_turn_but_a_fresh_one_does() {
|
||
// Momentum that carries a swipe to the bottom edge must not go on to turn the page.
|
||
let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single"), ("zoom", "200")]);
|
||
let at = h.state().views[0].viewport_rect().center();
|
||
h.hover_at(at);
|
||
h.run_steps(4);
|
||
let send = |h: &mut Harness<'static, PdfCraftApp>, phase, dy| {
|
||
h.event(wheel(MouseWheelUnit::Point, dy, phase));
|
||
h.step();
|
||
};
|
||
// One long swipe: far more motion than the page has, in one gesture.
|
||
send(&mut h, TouchPhase::Start, 0.0);
|
||
for _ in 0..120 {
|
||
send(&mut h, TouchPhase::Move, -40.0);
|
||
}
|
||
send(&mut h, TouchPhase::End, 0.0);
|
||
h.run_steps(30);
|
||
assert_eq!(h.state().views[0].current, 0, "the swipe that panned to the edge does not turn");
|
||
// A new swipe at the edge turns exactly one page.
|
||
send(&mut h, TouchPhase::Start, 0.0);
|
||
for _ in 0..6 {
|
||
send(&mut h, TouchPhase::Move, -25.0);
|
||
}
|
||
send(&mut h, TouchPhase::End, 0.0);
|
||
h.run_steps(30);
|
||
assert_eq!(h.state().views[0].current, 1, "a fresh swipe at the edge turns one page");
|
||
let top = rect(&h, 1).expect("page 2 on screen").min.y;
|
||
assert!(
|
||
top >= h.state().views[0].viewport_rect().min.y,
|
||
"page 2 starts at its top after the swipe: {top} in {:?}",
|
||
h.state().views[0].viewport_rect()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn stepping_past_either_end_leaves_the_view_alone() {
|
||
// Paging on past the first or last page must not jump the page back to its top.
|
||
for (page, forward) in [(2, true), (0, false)] {
|
||
let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single"), ("zoom", "200")]);
|
||
h.state_mut().views[0].go_to_page(page);
|
||
h.run_steps(20);
|
||
let at = h.state().views[0].viewport_rect().center();
|
||
h.hover_at(at);
|
||
h.run_steps(4);
|
||
// Pan the page off its edge so a jump would show.
|
||
h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move));
|
||
h.run_steps(30);
|
||
let before = rect(&h, page).expect("page on screen");
|
||
h.state_mut().views[0].step_page(forward);
|
||
h.run_steps(30);
|
||
assert_eq!(h.state().views[0].current, page);
|
||
assert_eq!(rect(&h, page), Some(before), "stepping past the end (forward={forward}) moved 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}");
|
||
}
|
||
|
||
/// 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).
|
||
/// Only the non-macOS glide tests below use it, hence the matching gate.
|
||
#[cfg(not(target_os = "macos"))]
|
||
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::<Vec<_>>();
|
||
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).
|
||
/// Same gate as its callers below.
|
||
#[cfg(not(target_os = "macos"))]
|
||
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.
|
||
fn frames_after_a_notch(system_reduced: bool, animation_effects: &str, zooming: bool) -> Vec<f32> {
|
||
let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "100")]);
|
||
h.state_mut().motion.reduced = system_reduced;
|
||
h.state_mut().set_option("animation_effects", animation_effects).unwrap();
|
||
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;
|
||
let (delta, modifiers) = if zooming { (egui::vec2(0.0, 1.0), Modifiers::COMMAND) } else { (egui::vec2(0.0, -1.0), Modifiers::NONE) };
|
||
h.event(egui::Event::MouseWheel { unit: MouseWheelUnit::Line, delta, phase: TouchPhase::Move, modifiers });
|
||
(0..40)
|
||
.map(|_| {
|
||
h.step();
|
||
if zooming { h.state().views[0].zoom } else { before - rect(&h, 0).expect("page 1 still laid out").min.y }
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The system's answer and the Animation effects choice, for each way to have them off and
|
||
/// each way to have them on.
|
||
const AT_ONCE: [(bool, &str); 3] = [(true, "system"), (false, "off"), (true, "off")];
|
||
const EASED: [(bool, &str); 3] = [(false, "system"), (false, "on"), (true, "on")];
|
||
|
||
#[test]
|
||
fn a_wheel_notch_scrolls_at_once_with_animation_effects_off() {
|
||
// Eased, egui spreads a notch over about a quarter of a second. At once, the page moves the
|
||
// notch's whole distance in one frame.
|
||
let notch = pdfcraft_ui_egui::scroll_speed::ScrollSpeed::default().line_points();
|
||
for (system_reduced, effects) in AT_ONCE {
|
||
let moved = frames_after_a_notch(system_reduced, effects, false);
|
||
// The frame the notch arrives in scrolls; the next one draws the page where it stays.
|
||
assert!(
|
||
moved[2..].iter().all(|y| (y - notch).abs() < 0.5),
|
||
"{effects}, system reduced {system_reduced}: one notch is {notch} points, all at once: {moved:?}"
|
||
);
|
||
}
|
||
for (system_reduced, effects) in EASED {
|
||
let moved = frames_after_a_notch(system_reduced, effects, false);
|
||
assert!(moved[2] < notch * 0.75, "{effects}, system reduced {system_reduced}: egui eases the notch: {moved:?}");
|
||
assert!(
|
||
(moved[39] - notch).abs() < 1.0,
|
||
"{effects}, system reduced {system_reduced}: easing changes how long a notch takes, not how far it goes: {moved:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_wheel_zooms_a_whole_step_at_once_with_animation_effects_off() {
|
||
// ⌘/Ctrl with the wheel: egui eases the zoom as it eases scrolling.
|
||
let step = frames_after_a_notch(true, "system", true)[0];
|
||
assert!(step > 1.1, "the notch zoomed in: {step}");
|
||
for (system_reduced, effects) in AT_ONCE {
|
||
let zooms = frames_after_a_notch(system_reduced, effects, true);
|
||
assert!(
|
||
zooms.iter().all(|zoom| *zoom == step),
|
||
"{effects}, system reduced {system_reduced}: the whole step in the frame the notch arrives, and no more after it: {zooms:?}"
|
||
);
|
||
}
|
||
for (system_reduced, effects) in EASED {
|
||
let zooms = frames_after_a_notch(system_reduced, effects, true);
|
||
assert!(
|
||
zooms[0] > 1.0 && zooms[0] < 1.0 + (step - 1.0) * 0.75,
|
||
"{effects}, system reduced {system_reduced}: egui eases the notch: {zooms:?}"
|
||
);
|
||
assert!(
|
||
(zooms[39] / step - 1.0).abs() < 0.01,
|
||
"{effects}, system reduced {system_reduced}: easing changes how long a zoom step takes, not how far it goes: {zooms:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[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)
|
||
};
|
||
// Fit width is about 4 px in a point, and the stroke is 2 px. Chrome width can shift
|
||
// that stroke by a fraction of a pixel, so one sample may land on its antialiased edge
|
||
// (still red, mixed with the field tint) instead of the solid stroke.
|
||
let stroke = |p: image::Rgba<u8>| p[0] > 180 && p[2] < 140 && i16::from(p[0]) - i16::from(p[2]) > 80;
|
||
assert!(
|
||
[0.05_f32, 0.2].into_iter().any(|dx| stroke(at(50.0 + dx, 320.0))),
|
||
"the left of the circle is red: {:?} {:?}",
|
||
at(50.05, 320.0),
|
||
at(50.2, 320.0)
|
||
);
|
||
assert!(!stroke(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,
|
||
preview: false,
|
||
});
|
||
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)
|
||
}
|
||
|
||
/// A 1200 pt square page of fine lines (0.5 pt every 1.7 pt): tiled at 300 %, whole-page at 150 %.
|
||
fn fine_grid_pdf() -> Vec<u8> {
|
||
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");
|
||
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()
|
||
}
|
||
|
||
fn grid_harness(pdf: Vec<u8>, zoom: &str) -> Harness<'static, PdfCraftApp> {
|
||
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", zoom).unwrap();
|
||
app
|
||
})
|
||
}
|
||
|
||
fn settle(h: &mut Harness<'static, PdfCraftApp>) {
|
||
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.
|
||
fn centre_detail(h: &mut Harness<'static, PdfCraftApp>) -> u64 {
|
||
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
|
||
}
|
||
|
||
#[test]
|
||
fn zooming_a_tiled_page_shows_its_earlier_tiles_until_new_ones_arrive() {
|
||
let _gpu = gpu();
|
||
let mut h = grid_harness(fine_grid_pdf(), "300");
|
||
settle(&mut h);
|
||
let sharp = centre_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 = centre_detail(&mut h);
|
||
assert!(first * 10 > sharp * 7, "the first frame after zooming lost its detail: {first} against {sharp}");
|
||
}
|
||
|
||
/// #2196: zooming away from a tiled page and back must not re-blur an area already rendered
|
||
/// sharp. The tiles of the zoom just left used to be dropped while the page was shown whole,
|
||
/// so returning to it was a cold miss and every tile cost a full page interpret again.
|
||
#[test]
|
||
fn returning_to_a_visited_zoom_reuses_its_cached_tiles() {
|
||
let _gpu = gpu();
|
||
let mut h = grid_harness(fine_grid_pdf(), "300");
|
||
settle(&mut h);
|
||
let before = h.state().views[0].tiles_debug(0);
|
||
assert!(!before.is_empty(), "the 300 % page should be tiled, but it cached no tiles");
|
||
// Zoom right out, where the 1200 pt page fits in one raster and is not tiled at all, so
|
||
// none of its tiles is demanded...
|
||
h.state_mut().set_option("zoom", "50").unwrap();
|
||
settle(&mut h);
|
||
// ...then back to the first zoom, which was already rendered sharp.
|
||
h.state_mut().set_option("zoom", "300").unwrap();
|
||
h.step();
|
||
let after = h.state().views[0].tiles_debug(0);
|
||
// Every tile rendered sharp before the round trip is still cached: zooming away must not
|
||
// drop them, so returning shows them at once instead of re-interpreting the page per tile.
|
||
let lost: Vec<_> = before.iter().copied().filter(|k| !after.contains(k)).collect();
|
||
assert!(lost.is_empty(), "returning to a visited zoom dropped its cached tiles {lost:?} (had {before:?}, now {after:?})");
|
||
}
|
||
|
||
#[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}");
|
||
}
|
||
|
||
/// Synthetic unequal-height spreads, generated without font or image assets.
|
||
fn final_spread_at_scroll_end(heights: &[u32], cover: bool) -> (Vec<(usize, egui::Rect)>, egui::Rect) {
|
||
let kids = (0..heights.len()).map(|i| format!("{} 0 R", i + 3)).collect::<Vec<_>>().join(" ");
|
||
let mut pdf = format!(
|
||
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [{kids}] /Count {} >> endobj\n",
|
||
heights.len()
|
||
);
|
||
for (i, height) in heights.iter().enumerate() {
|
||
pdf += &format!("{} 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 {height}] >> endobj\n", i + 3);
|
||
}
|
||
pdf += "trailer << /Root 1 0 R >>\n%%EOF";
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("unequal-spreads.pdf", None, pdf.as_bytes().to_vec()).unwrap();
|
||
for (key, value) in [("left", "closed"), ("panel", "none"), ("layout", "two-up"), ("zoom", "200")] {
|
||
app.set_option(key, value).unwrap();
|
||
}
|
||
app.set_option("cover", if cover { "on" } else { "off" }).unwrap();
|
||
app
|
||
});
|
||
h.run_steps(12);
|
||
let at = h.state().views[0].viewport_rect().center();
|
||
h.hover_at(at);
|
||
h.run_steps(2);
|
||
for _ in 0..4 {
|
||
h.event(wheel(MouseWheelUnit::Line, -1000.0, TouchPhase::Move));
|
||
h.run_steps(45);
|
||
}
|
||
let view = &h.state().views[0];
|
||
let last = heights.len() - 1;
|
||
let first = if cover { last - (last + 1) % 2 } else { last - last % 2 };
|
||
let pages = (first..=last).map(|page| (page, view.page_screen_rect(page).expect("final spread is visible"))).collect();
|
||
(pages, view.viewport_rect())
|
||
}
|
||
|
||
fn assert_final_spread_bottom_reachable(heights: &[u32], cover: bool) {
|
||
let (pages, viewport) = final_spread_at_scroll_end(heights, cover);
|
||
let bottom = pages.iter().map(|(_, rect)| rect.bottom()).fold(0.0_f32, f32::max);
|
||
assert!(
|
||
bottom <= viewport.bottom() + 1.0,
|
||
"final spread remains below the viewport: heights={heights:?}, cover={cover}, pages={pages:?}, viewport={viewport:?}"
|
||
);
|
||
// The maximum-height page reaches the bottom margin after the scroll offset settles.
|
||
assert!((viewport.bottom() - bottom - 28.0).abs() < 1.0, "scroll end leaves the bottom margin: pages={pages:?}, viewport={viewport:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn two_up_scroll_end_reaches_a_taller_left_page() {
|
||
assert_final_spread_bottom_reachable(&[400, 400, 400, 100], false);
|
||
}
|
||
|
||
#[test]
|
||
fn two_up_scroll_end_reaches_a_taller_right_page() {
|
||
assert_final_spread_bottom_reachable(&[400, 400, 100, 400], false);
|
||
}
|
||
|
||
#[test]
|
||
fn two_up_scroll_end_reaches_equal_height_pages() {
|
||
assert_final_spread_bottom_reachable(&[400, 400, 400, 400], false);
|
||
}
|
||
|
||
#[test]
|
||
fn two_up_scroll_end_reaches_an_unpaired_final_page() {
|
||
assert_final_spread_bottom_reachable(&[400, 400, 400], false);
|
||
}
|
||
|
||
#[test]
|
||
fn two_up_scroll_end_with_cover_reaches_a_taller_left_page() {
|
||
assert_final_spread_bottom_reachable(&[400, 400, 400, 400, 100], true);
|
||
}
|