mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 00:41:21 +00:00
3196 lines
155 KiB
Rust
3196 lines
155 KiB
Rust
#[test]
|
||
fn zoom_anchor_scroll_stays_within_the_content() {
|
||
let (content, viewport) = (vec2(1000.0, 2000.0), vec2(800.0, 600.0));
|
||
// Past the end: clamped to the maximum (200, 1400), not left for egui to snap back.
|
||
assert_eq!(anchored_scroll(pos2(900.0, 1900.0), vec2(0.0, 0.0), content, viewport), vec2(200.0, 1400.0));
|
||
// Before the start.
|
||
assert_eq!(anchored_scroll(pos2(10.0, 10.0), vec2(300.0, 300.0), content, viewport), Vec2::ZERO);
|
||
// Inside: unchanged.
|
||
assert_eq!(anchored_scroll(pos2(150.0, 800.0), vec2(100.0, 100.0), content, viewport), vec2(50.0, 700.0));
|
||
// Content smaller than the viewport: no scrolling.
|
||
assert_eq!(anchored_scroll(pos2(400.0, 400.0), Vec2::ZERO, vec2(500.0, 500.0), viewport), vec2(0.0, 0.0));
|
||
}
|
||
|
||
#[test]
|
||
fn page_size_hint_zone_is_the_bottom_left_quadrant() {
|
||
let area = Rect::from_min_max(pos2(100.0, 50.0), pos2(500.0, 450.0));
|
||
assert!(in_size_hint_zone(area, pos2(110.0, 440.0)));
|
||
assert!(!in_size_hint_zone(area, pos2(110.0, 100.0)), "top-left");
|
||
assert!(!in_size_hint_zone(area, pos2(400.0, 440.0)), "bottom-right");
|
||
assert!(!in_size_hint_zone(area, pos2(50.0, 440.0)), "outside");
|
||
assert!(!in_size_hint_zone(area, pos2(f32::NAN, 440.0)));
|
||
}
|
||
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn typed_zoom_percentages_parse() {
|
||
for (typed, zoom) in [("150", 1.5), ("150%", 1.5), (" 62.5 % ", 0.625), ("6400%", 64.0)] {
|
||
assert_eq!(DocView::parse_zoom_percent(typed), Some(zoom), "{typed}");
|
||
}
|
||
for typed in ["", "%", "abc", "0", "-50", "NaN", "inf", "1e40", "150%%x", "%150"] {
|
||
assert_eq!(DocView::parse_zoom_percent(typed), None, "{typed}");
|
||
}
|
||
}
|
||
|
||
/// A rotated (landscape) page is drawn as wide as the cell allows and centred in the picture
|
||
/// area, as Acrobat draws it, not as a small strip pinned to the top; a portrait page still
|
||
/// fills the area's height; neither covers the checkbox corner or the label below.
|
||
#[test]
|
||
fn grid_pages_are_centred_and_as_large_as_the_cell_allows() {
|
||
let cell = Rect::from_min_size(pos2(100.0, 50.0), vec2(190.0, 282.0));
|
||
let area = Rect::from_min_max(pos2(cell.left(), cell.top() + 16.0), pos2(cell.right(), cell.bottom() - 72.0));
|
||
let checkbox = Rect::from_min_size(cell.min + vec2(8.0, 8.0), Vec2::splat(20.0));
|
||
let landscape = grid_page_rect(cell, vec2(842.0, 595.0));
|
||
assert!((landscape.center().y - area.center().y).abs() < 0.01, "centred vertically: {landscape:?}");
|
||
assert!((landscape.center().x - cell.center().x).abs() < 0.01);
|
||
assert!((landscape.width() - (cell.width() - 32.0)).abs() < 0.01, "as wide as the cell allows: {landscape:?}");
|
||
assert!(!landscape.intersects(checkbox));
|
||
let portrait = grid_page_rect(cell, vec2(595.0, 842.0));
|
||
assert!(area.contains_rect(portrait), "inside the picture area: {portrait:?}");
|
||
assert!((portrait.center().y - area.center().y).abs() < 0.01);
|
||
assert!(portrait.left() >= checkbox.right(), "clear of the checkbox: {portrait:?}");
|
||
// Degenerate sizes still give a finite rectangle inside the cell.
|
||
for page in [vec2(0.0, 0.0), vec2(1e9, 1.0), vec2(1.0, 1e9), vec2(f32::NAN, 10.0), vec2(f32::INFINITY, 10.0)] {
|
||
let r = grid_page_rect(cell, page);
|
||
assert!(r.is_finite() && cell.contains_rect(r), "{page:?} -> {r:?}");
|
||
}
|
||
}
|
||
|
||
fn signature(signed: bool, visible: bool, page: Option<usize>, rect: Option<[f64; 4]>) -> pdfcraft_engine::SignatureInfo {
|
||
pdfcraft_engine::SignatureInfo {
|
||
field: "Signature1".into(),
|
||
signed,
|
||
page,
|
||
rect,
|
||
visible,
|
||
signer: None,
|
||
certificate: None,
|
||
chain: Vec::new(),
|
||
date: None,
|
||
signing_time: None,
|
||
reason: None,
|
||
location: None,
|
||
contact: None,
|
||
sub_filter: None,
|
||
doc_timestamp: false,
|
||
certify: None,
|
||
revision: 1,
|
||
signed_len: 0,
|
||
digest: None,
|
||
algorithm: None,
|
||
timestamp: false,
|
||
timestamp_time: None,
|
||
status: pdfcraft_engine::SignatureStatus::Valid,
|
||
modification: pdfcraft_engine::sign::Modification::None,
|
||
verdict_shown: false,
|
||
details: Vec::new(),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn signature_states_change_with_a_verdict_or_what_draws_it() {
|
||
let shown = signature(true, true, Some(0), Some([10.0, 10.0, 100.0, 50.0]));
|
||
let before = vec![shown.clone()];
|
||
assert!(!signature_states_changed(&before, &before), "identical lists report no change");
|
||
let mut invalid = shown.clone();
|
||
invalid.status = pdfcraft_engine::SignatureStatus::Invalid;
|
||
let mut drawn = shown.clone();
|
||
drawn.verdict_shown = true;
|
||
let mut other = shown.clone();
|
||
other.field = "Signature2".into();
|
||
for (after, why) in [(vec![invalid], "verdict"), (vec![drawn], "drawn"), (vec![other], "field"), (Vec::new(), "count")] {
|
||
assert!(signature_states_changed(&before, &after), "{why}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn signature_badges_keep_signed_visible_widgets_on_the_page() {
|
||
let rect = [10.0, 10.0, 100.0, 50.0];
|
||
let signatures =
|
||
vec![signature(true, true, Some(0), Some(rect)), signature(true, true, Some(1), Some(rect)), signature(true, true, Some(0), Some(rect))];
|
||
let badges = signature_badges(&signatures, 0);
|
||
assert_eq!(badges.len(), 2);
|
||
assert_eq!(badges[0].rect, rect);
|
||
}
|
||
|
||
#[test]
|
||
fn signature_badges_skip_unsigned_invisible_and_unplaced_fields() {
|
||
let rect = [10.0, 10.0, 100.0, 50.0];
|
||
let signatures = vec![
|
||
signature(false, true, Some(0), Some(rect)),
|
||
signature(true, false, Some(0), Some(rect)),
|
||
signature(true, true, Some(0), None),
|
||
signature(true, true, Some(0), Some(rect)),
|
||
];
|
||
let badges = signature_badges(&signatures, 0);
|
||
assert_eq!(badges.len(), 1);
|
||
assert_eq!(badges[0].rect, rect);
|
||
}
|
||
|
||
#[test]
|
||
fn signature_badges_skip_rectangles_that_cannot_reach_the_screen() {
|
||
let hostile = vec![
|
||
[f64::NAN, 10.0, 100.0, 50.0],
|
||
[10.0, f64::INFINITY, 100.0, 50.0],
|
||
[10.0, 10.0, f64::NEG_INFINITY, 50.0],
|
||
[10.0, 10.0, 1e300, 50.0],
|
||
[10.0, 10.0, 100.0, 50.0],
|
||
];
|
||
let signatures: Vec<_> = hostile.into_iter().map(|rect| signature(true, true, Some(0), Some(rect))).collect();
|
||
let badges = signature_badges(&signatures, 0);
|
||
assert_eq!(badges.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn signature_badges_keep_every_verdict_the_appearance_shows() {
|
||
// Whether the redrawn layers are visible (not clipped or painted over) is not known, so a
|
||
// valid verdict keeps its badge as well.
|
||
use pdfcraft_engine::SignatureStatus::{Invalid, Unknown, Valid};
|
||
for status in [Valid, Unknown, Invalid] {
|
||
let mut shown = signature(true, true, Some(0), Some([10.0, 10.0, 100.0, 50.0]));
|
||
(shown.verdict_shown, shown.status) = (true, status);
|
||
assert_eq!(signature_badges(&[shown.clone()], 0).len(), 1, "{status:?}");
|
||
shown.verdict_shown = false;
|
||
assert_eq!(signature_badges(&[shown], 0).len(), 1, "{status:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_verdict_mark_takes_the_corner_nearest_its_anchor() {
|
||
let widget = Rect::from_min_max(pos2(40.0, 60.0), pos2(540.0, 760.0));
|
||
let centre = widget.center();
|
||
for anchor in [widget.left_top(), widget.right_top(), widget.right_bottom(), widget.left_bottom()] {
|
||
let mark = verdict_mark_rect(widget, Rect::EVERYTHING, anchor).expect("a widget this size carries a mark");
|
||
assert!(widget.contains_rect(mark), "{mark:?} in {widget:?}");
|
||
assert!(!mark.contains(centre), "the mark covers the artwork it annotates: {mark:?}");
|
||
let (right, below) = (mark.center().x > centre.x, mark.center().y > centre.y);
|
||
assert_eq!((right, below), (anchor.x > centre.x, anchor.y > centre.y), "{mark:?} for {anchor:?}");
|
||
}
|
||
// Without a usable anchor it sits bottom-right.
|
||
let mark = verdict_mark_rect(widget, Rect::EVERYTHING, pos2(f32::NAN, f32::NAN)).unwrap();
|
||
assert!(mark.min.x > centre.x && mark.min.y > centre.y, "{mark:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn the_verdict_mark_turns_with_the_page_and_the_view() {
|
||
// A named-layer caption runs across the widget's top on the page; the mark takes the
|
||
// opposite corner however the page's /Rotate and the view turn the two.
|
||
let rect = [100.0, 100.0, 300.0, 220.0];
|
||
for page_turn in [0_u16, 90, 180, 270] {
|
||
for view_turn in [0_u16, 90, 180, 270] {
|
||
let page =
|
||
pdfcraft_render::PageInfo { width: 612.0, height: 792.0, label: "1".into(), crop: [0.0, 0.0, 612.0, 792.0], rotation: page_turn };
|
||
let info = DocInfo { pages: vec![page], ..Default::default() };
|
||
let xf = PageXform { rect: Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 800.0)), rot: view_turn, pw: 612.0, ph: 792.0 };
|
||
let widget = xf.user_rect(&info, 0, rect.map(|v: f64| v as f32));
|
||
let mark = verdict_mark_rect(widget, xf.rect, mark_anchor(&xf, &info, 0, rect)).expect("a widget this size carries a mark");
|
||
let (right, below) = (mark.center().x > widget.center().x, mark.center().y > widget.center().y);
|
||
let expected = match (page_turn + view_turn) % 360 {
|
||
0 => (true, true),
|
||
90 => (false, true),
|
||
180 => (false, false),
|
||
_ => (true, false),
|
||
};
|
||
assert_eq!((right, below), expected, "page /Rotate {page_turn}, view {view_turn}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_verdict_mark_scales_with_the_widget_until_the_cap() {
|
||
let side_of = |shorter: f32| {
|
||
let widget = Rect::from_min_max(pos2(0.0, 0.0), pos2(shorter, shorter * 2.0));
|
||
verdict_mark_rect(widget, Rect::EVERYTHING, widget.right_bottom()).expect("a widget this size carries a mark").width()
|
||
};
|
||
// Literal values, not the constants, so a change to the share or the cap fails here.
|
||
assert!((side_of(96.0) - 24.0).abs() < 1e-3, "under the cap the mark is a quarter of the widget");
|
||
assert!((side_of(384.0) - 48.0).abs() <= 1e-3, "the mark stops at the 48pt the documentation promises");
|
||
}
|
||
|
||
#[test]
|
||
fn the_legible_floor_is_the_exact_shortest_side_that_still_holds_a_mark() {
|
||
// The sweep below skips widgets without a mark, so the floor is pinned from both sides
|
||
// here.
|
||
let at_floor = MARK_SIDE_MIN / MARK_SIDE_SHARE;
|
||
let widget = |shorter: f32| Rect::from_min_max(pos2(10.0, 10.0), pos2(10.0 + shorter, 70.0));
|
||
let mark =
|
||
verdict_mark_rect(widget(at_floor), Rect::EVERYTHING, widget(at_floor).right_bottom()).expect("a widget exactly at the floor holds a mark");
|
||
assert!((mark.width() - MARK_SIDE_MIN).abs() < 1e-3, "the mark at the floor is the floor, not less: {mark:?}");
|
||
assert_eq!(verdict_mark_rect(widget(at_floor - 0.5), Rect::EVERYTHING, pos2(0.0, 0.0)), None, "a notch under the floor is not legible");
|
||
}
|
||
|
||
#[test]
|
||
fn the_verdict_mark_is_none_for_a_rectangle_the_screen_cannot_hold() {
|
||
// Each of these leaves the shorter side finite (`f32::min` returns the other operand for
|
||
// NaN), so only the `is_finite` check refuses them; one per coordinate.
|
||
let unplaceable = [
|
||
Rect { min: pos2(f32::NAN, 0.0), max: pos2(400.0, 200.0) },
|
||
Rect { min: pos2(0.0, 0.0), max: pos2(40.0, f32::NAN) },
|
||
Rect { min: pos2(0.0, f32::NEG_INFINITY), max: pos2(40.0, 100.0) },
|
||
Rect { min: pos2(0.0, 0.0), max: pos2(f32::INFINITY, 100.0) },
|
||
];
|
||
for widget in unplaceable {
|
||
assert_eq!(verdict_mark_rect(widget, Rect::EVERYTHING, pos2(0.0, 0.0)), None, "{widget:?} has no place to sit at");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_verdict_mark_stays_inside_its_widget_at_every_finite_size() {
|
||
// 50 and 56 are where a mark always taking the 48pt cap would no longer fit.
|
||
let sizes = [0.5, 3.0, 6.0, 12.0, 20.0, 32.0, 40.0, 50.0, 56.0, 90.0, 200.0, 500.0, 5_000.0];
|
||
for &w in &sizes {
|
||
for &h in &sizes {
|
||
let widget = Rect::from_min_max(pos2(10.0, 10.0), pos2(10.0 + w, 10.0 + h));
|
||
for anchor in [widget.left_top(), widget.right_bottom()] {
|
||
let Some(mark) = verdict_mark_rect(widget, Rect::EVERYTHING, anchor) else { continue };
|
||
assert!(widget.contains_rect(mark), "{mark:?} is not inside {widget:?} for a {w}x{h} widget");
|
||
}
|
||
let Some(mark) = verdict_mark_rect(widget, Rect::EVERYTHING, widget.right_bottom()) else { continue };
|
||
// A quarter of the shorter side each way: at most 6.25% of a square widget.
|
||
let share = mark.width() * mark.height() / (widget.width() * widget.height());
|
||
assert!(share <= MARK_SIDE_SHARE * MARK_SIDE_SHARE * 1.0001, "the mark covers {:.2}% of a {w}x{h} widget", share * 100.0);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_verdict_mark_stays_on_the_page_when_its_widget_crosses_the_edge() {
|
||
let page = Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 300.0));
|
||
let widget = Rect::from_min_max(pos2(200.0, 150.0), pos2(600.0, 450.0));
|
||
let mark = verdict_mark_rect(widget, page, widget.right_bottom()).expect("the part on the page holds a mark");
|
||
assert!(page.contains_rect(mark), "{mark:?} is off {page:?}");
|
||
assert!(mark.max.x > 350.0 && mark.max.y > 250.0, "the corner of the part on the page nearest the anchor: {mark:?}");
|
||
// Wholly off the page, or a sliver on it too thin for a legible mark: none.
|
||
let off = widget.translate(vec2(400.0, 0.0));
|
||
assert_eq!(verdict_mark_rect(off, page, off.right_bottom()), None);
|
||
let sliver = Rect::from_min_max(pos2(390.0, 0.0), pos2(600.0, 300.0));
|
||
assert_eq!(verdict_mark_rect(sliver, page, sliver.right_bottom()), None);
|
||
}
|
||
|
||
#[test]
|
||
fn page_zoom_reads_the_side_the_page_width_is_on() {
|
||
// A 200 x 100 pt page at 2 pixels per point: 400 x 200 on screen, or 200 x 400 turned.
|
||
let xf = |rot: u16, size: Vec2| PageXform { rect: Rect::from_min_size(Pos2::ZERO, size), rot, pw: 200.0, ph: 100.0 };
|
||
for rot in [0, 180] {
|
||
assert_eq!(xf(rot, vec2(400.0, 200.0)).zoom(), 2.0, "{rot}");
|
||
}
|
||
for rot in [90, 270] {
|
||
assert_eq!(xf(rot, vec2(200.0, 400.0)).zoom(), 2.0, "{rot}: the width runs down the screen");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn snapshot_tabs_read_like_their_source_plus_the_suffix() {
|
||
let snap = Snapshot { kind: SnapshotKind::SignedVersion, revision: 1, from: "report.pdf".into() };
|
||
assert_eq!(snapshot_tab_name("Report", "report.pdf", &snap), "Report (signed version)");
|
||
let rev = Snapshot { kind: SnapshotKind::Revision, revision: 2, from: "report.pdf".into() };
|
||
assert_eq!(snapshot_tab_name("Report", "report.pdf", &rev), "Report (revision 2)");
|
||
assert_eq!(snapshot_tab_name("report.pdf", "report.pdf", &snap), "report.pdf");
|
||
assert_eq!(snapshot_tab_name("report (revision 2).pdf", "report (revision 2).pdf", &rev), "report (revision 2).pdf");
|
||
}
|
||
|
||
#[test]
|
||
fn grayscale_converts_colours_to_their_luminance() {
|
||
let img = egui::ColorImage::new([2, 1], vec![Color32::from_rgb(255, 0, 0), Color32::from_rgba_premultiplied(0, 51, 102, 128)]);
|
||
let gray = grayscale(img);
|
||
let opaque = gray.pixels[0];
|
||
assert_eq!(opaque.to_array(), [76, 76, 76, 255]);
|
||
// Unmultiply RGB first, so a translucent colour keeps its paper-relative luminance.
|
||
let translucent = gray.pixels[1];
|
||
assert_eq!(translucent.to_array(), [42, 42, 42, 128]);
|
||
}
|
||
|
||
#[test]
|
||
fn black_white_and_transparents_are_preserved_by_grayscale() {
|
||
let img = egui::ColorImage::new([3, 1], vec![Color32::BLACK, Color32::WHITE, Color32::TRANSPARENT]);
|
||
let gray = grayscale(img);
|
||
assert_eq!(gray.pixels[0].to_array(), [0, 0, 0, 255]);
|
||
assert_eq!(gray.pixels[1].to_array(), [255, 255, 255, 255]);
|
||
assert_eq!(gray.pixels[2].to_array(), [0, 0, 0, 0]);
|
||
}
|
||
|
||
#[test]
|
||
fn rendered_rasters_become_texture_images_without_a_copy() {
|
||
let pdf = b"%PDF-1.4
|
||
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 100 50] /Contents 4 0 R >> endobj
|
||
4 0 obj << /Length 35 >> stream
|
||
0 0 1 rg 10 10 30 20 re f
|
||
endstream endobj
|
||
trailer << /Root 1 0 R >>
|
||
%%EOF";
|
||
let mut r = pdfcraft_render::PageRenderer::new(Arc::new(pdf.to_vec()), Default::default());
|
||
let out = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.5, tag: 0, preview: false });
|
||
assert!(out.error.is_none(), "{:?}", out.error);
|
||
let size = [out.width as usize, out.height as usize];
|
||
let copied = egui::ColorImage::from_rgba_premultiplied(size, &out.rgba);
|
||
let at = out.rgba.as_ptr();
|
||
let img = texture_image(size, out.rgba);
|
||
assert_eq!(img, copied);
|
||
assert_eq!(img.pixels.as_ptr().cast::<u8>(), at, "the renderer's own buffer");
|
||
}
|
||
|
||
#[test]
|
||
fn tiles_stay_granular_up_close_and_cover_giant_sheets_in_a_handful() {
|
||
// Ordinary zooms keep 1024 px tiles; degenerate scales fall back to it, never zero.
|
||
assert_eq!(tile_side(1.0, 2000), TILE);
|
||
assert_eq!(tile_side(4.0, 2000), TILE);
|
||
assert_eq!(tile_side(f32::NAN, 2000), TILE);
|
||
assert_eq!(tile_side(f32::INFINITY, 2000), TILE);
|
||
assert_eq!(tile_side(0.0, 2000), TILE);
|
||
// Zoomed viewports need a few large tiles instead of many small ones.
|
||
assert_eq!(tile_side(12.0, 2000), 2048);
|
||
assert_eq!(tile_side(20.0, 2000), 3413);
|
||
assert_eq!(tile_side(100.0, 2000), 4096);
|
||
// A giant sheet at fit width (6740 px long side) is covered four tiles a side.
|
||
assert_eq!(tile_side(96.0 / 72.0, 6740), 1685);
|
||
// The backend clamps tile sides at 4096.
|
||
assert_eq!(tile_side(20.0, 100_000), 4096);
|
||
}
|
||
|
||
#[test]
|
||
fn grid_zoom_is_clamped_and_sharp_renders_tolerate_a_pinch() {
|
||
assert!(sharp_enough(900, 876.0) && sharp_enough(800, 876.0) && sharp_enough(1300, 876.0));
|
||
assert!(!sharp_enough(264, 876.0), "a thumbnail stretched over a zoomed page");
|
||
assert!(!sharp_enough(876, 200.0), "far larger than drawn: wasteful");
|
||
assert!(!sharp_enough(100, 0.0) && !sharp_enough(100, f32::NAN));
|
||
let mut v = view(3, PageLayout::Single, true);
|
||
for odd in [f32::NAN, f32::INFINITY] {
|
||
v.set_grid_zoom(odd);
|
||
assert_eq!(v.grid_zoom(), 1.0);
|
||
}
|
||
v.set_grid_zoom(99.0);
|
||
assert_eq!(v.grid_zoom(), 3.0);
|
||
v.set_grid_zoom(-4.0);
|
||
assert_eq!(v.grid_zoom(), 0.5);
|
||
}
|
||
|
||
fn view(pages: usize, layout: PageLayout, continuous: bool) -> DocView {
|
||
let info = pdfcraft_render::DocInfo {
|
||
pages: (0..pages)
|
||
.map(|_| pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 })
|
||
.collect(),
|
||
..Default::default()
|
||
};
|
||
DocView::new(DocId(1), &info, ViewDefaults { layout, continuous, ..Default::default() })
|
||
}
|
||
|
||
/// Delivers a raw move event to the same viewport path used by native input.
|
||
fn viewport_wheel_event(unit: egui::MouseWheelUnit, delta: Vec2) -> egui::Event {
|
||
egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, source: egui::MouseWheelSource::Unknown }
|
||
}
|
||
|
||
/// Runs actual UI passes while explicitly discarding the unused texture output.
|
||
fn viewport_wheel_frame(ctx: &egui::Context, view: &mut DocView, time: f64, events: Vec<egui::Event>, passes: usize, animate: bool) -> Vec<Vec2> {
|
||
let screen = Rect::from_min_size(Pos2::ZERO, vec2(600.0, 800.0));
|
||
let mut events = events;
|
||
events.insert(0, egui::Event::PointerMoved(screen.center()));
|
||
let input = egui::RawInput { screen_rect: Some(screen), time: Some(time), predicted_dt: 1.0 / 60.0, events, ..Default::default() };
|
||
let mut deltas = Vec::new();
|
||
ctx.run_ui(input, |ui| {
|
||
let avail = ui.max_rect();
|
||
assert!(ui.rect_contains_pointer(avail), "the synthetic pointer owns the viewport");
|
||
let wheel = read_wheel(ui, view, avail, false, false, true, animate);
|
||
deltas.push(wheel.precise_scroll + wheel.coarse_scroll);
|
||
assert_eq!(ui.input(|i| i.smooth_scroll_delta), Vec2::ZERO, "egui's wheel copy is drained on every owned pass");
|
||
if deltas.len() < passes {
|
||
ui.ctx().request_discard("exercise viewport wheel ownership across passes");
|
||
}
|
||
})
|
||
.drop_without_applying_deltas();
|
||
assert_eq!(deltas.len(), passes);
|
||
deltas
|
||
}
|
||
|
||
/// Fixes line distance and discard allowance independently of host preferences.
|
||
fn viewport_wheel_context() -> egui::Context {
|
||
let ctx = egui::Context::default();
|
||
ctx.options_mut(|options| {
|
||
options.max_passes = std::num::NonZeroUsize::new(3).unwrap();
|
||
options.input_options.line_scroll_speed = 100.0;
|
||
});
|
||
ctx
|
||
}
|
||
|
||
/// A frame's admitted distance is unchanged by additional layout passes.
|
||
#[test]
|
||
fn viewport_wheel_discard_passes_neither_repeat_nor_advance_motion() {
|
||
for animate in [false, true] {
|
||
let (one, three) = (viewport_wheel_context(), viewport_wheel_context());
|
||
let (mut single_view, mut multi_view) = (view(2, PageLayout::Single, true), view(2, PageLayout::Single, true));
|
||
let mut total = Vec2::ZERO;
|
||
for frame in 0..90 {
|
||
let events = if frame == 0 {
|
||
vec![
|
||
viewport_wheel_event(egui::MouseWheelUnit::Line, vec2(0.0, -1.0)),
|
||
viewport_wheel_event(egui::MouseWheelUnit::Point, vec2(0.0, -7.0)),
|
||
]
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
let time = 1.0 + f64::from(frame) / 60.0;
|
||
let expected = viewport_wheel_frame(&one, &mut single_view, time, events.clone(), 1, animate);
|
||
let actual = viewport_wheel_frame(&three, &mut multi_view, time, events, 3, animate);
|
||
assert!((actual[0] - expected[0]).length() < 0.001, "frame {frame}, animations {animate}: {actual:?} versus {expected:?}");
|
||
assert_eq!(&actual[1..], &[Vec2::ZERO, Vec2::ZERO], "discarded passes do not spend the retained queue");
|
||
if frame == 0 {
|
||
if animate {
|
||
assert!(actual[0].y < -7.0 && actual[0].y > -107.0, "fine input is direct and the whole coarse amount is eased");
|
||
} else {
|
||
assert_eq!(actual[0], vec2(0.0, -107.0));
|
||
}
|
||
}
|
||
total += actual[0];
|
||
}
|
||
assert!((total.y + 107.0).abs() < 0.002, "one admitted mixed bucket preserves distance: {total:?}");
|
||
assert!(!multi_view.wheel_motion.has_pending());
|
||
}
|
||
}
|
||
|
||
/// Mixed current input survives an older same-axis queue being interrupted.
|
||
#[test]
|
||
fn viewport_precise_input_cancels_only_older_motion_on_its_axis() {
|
||
let (actual_ctx, reference_ctx) = (viewport_wheel_context(), viewport_wheel_context());
|
||
let (mut actual, mut reference) = (view(2, PageLayout::Single, true), view(2, PageLayout::Single, true));
|
||
viewport_wheel_frame(&actual_ctx, &mut actual, 1.0, vec![viewport_wheel_event(egui::MouseWheelUnit::Line, vec2(1.0, -1.0))], 1, true);
|
||
viewport_wheel_frame(&reference_ctx, &mut reference, 1.0, vec![viewport_wheel_event(egui::MouseWheelUnit::Line, vec2(1.0, 0.0))], 1, true);
|
||
let mixed =
|
||
vec![viewport_wheel_event(egui::MouseWheelUnit::Line, vec2(0.0, -1.0)), viewport_wheel_event(egui::MouseWheelUnit::Point, vec2(0.0, 2.0))];
|
||
let mut total = Vec2::ZERO;
|
||
for frame in 1..90 {
|
||
let events = if frame == 1 { mixed.clone() } else { Vec::new() };
|
||
let time = 1.0 + f64::from(frame) / 60.0;
|
||
let output = viewport_wheel_frame(&actual_ctx, &mut actual, time, events.clone(), 3, true)[0];
|
||
let expected = viewport_wheel_frame(&reference_ctx, &mut reference, time, events, 1, true)[0];
|
||
assert!((output - expected).length() < 0.001, "older y motion is canceled, x motion and new coarse input survive");
|
||
total += output;
|
||
}
|
||
assert!(total.x > 0.0, "the untouched x tail continues");
|
||
assert!((total.y + 98.0).abs() < 0.002, "only the new y bucket is admitted: {total:?}");
|
||
}
|
||
|
||
fn history_search_view() -> DocView {
|
||
let mut v = view(5, PageLayout::Single, true);
|
||
v.texts.insert(
|
||
3,
|
||
pdfcraft_render::PageText {
|
||
glyphs: vec![pdfcraft_render::TextGlyph {
|
||
text: "match".into(),
|
||
rect: [20.0, 260.0, 60.0, 280.0],
|
||
quad: [[20.0, 260.0], [60.0, 260.0], [60.0, 280.0], [20.0, 280.0]],
|
||
direction: [1.0, 0.0],
|
||
}],
|
||
line_of: vec![0],
|
||
space_before: vec![false],
|
||
truncated: false,
|
||
}
|
||
.into(),
|
||
);
|
||
v.find = Some(Find { matches: vec![(3, 0..1), (3, 0..1), (1, 0..1)], current: Some(2), ..Default::default() });
|
||
v
|
||
}
|
||
|
||
#[test]
|
||
fn explicit_search_jumps_record_history_and_keep_match_destination() {
|
||
for step in [None, Some(true), Some(false)] {
|
||
let mut v = history_search_view();
|
||
v.page_input = "draft in progress".into();
|
||
v.goto_point = Some((3, Some(0.8), Some(0.9), Vec2::ZERO));
|
||
if let Some(forward) = step {
|
||
v.find_step(forward);
|
||
} else {
|
||
v.go_to_match(0);
|
||
}
|
||
assert_eq!(v.back, vec![0], "explicit search jump must remember its origin");
|
||
assert_eq!(v.goto, None, "the match's point alone: a zoom that frame cannot put a page-top offset off to the next");
|
||
assert_eq!(v.current, 3);
|
||
assert_eq!(v.current_page_label(None), "4");
|
||
// Chrome refreshes the idle page box; navigation must not overwrite a draft
|
||
// that may still be focused. DocView does not own the editor's focus state.
|
||
assert_eq!(v.page_input, "draft in progress");
|
||
let middle = vec2(v.viewport_w, v.viewport_h) / 2.0;
|
||
assert_eq!(v.goto_point, Some((3, Some(40.0 / 300.0), Some(0.675), middle)), "the match centre goes to the middle of the viewport");
|
||
v.go_to_match(1);
|
||
assert_eq!(v.back, vec![0], "same-page matches do not duplicate history");
|
||
assert!(v.view_history(false));
|
||
assert_eq!(v.current, 0);
|
||
assert!(v.view_history(true));
|
||
assert_eq!(v.current, 3);
|
||
assert!(v.view_history(false));
|
||
v.go_to_match(2);
|
||
assert!(v.forward.is_empty(), "a new explicit jump discards Forward");
|
||
assert_eq!(v.back, vec![0]);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn search_centres_the_match_on_a_rotated_view() {
|
||
// The match's centre is 40 pt from the left and 270 pt down a 300 × 400 page.
|
||
for (rotation, fx, fy) in [(90, 1.0 - 0.675, 40.0 / 300.0), (180, 1.0 - 40.0 / 300.0, 1.0 - 0.675), (270, 0.675, 1.0 - 40.0 / 300.0)] {
|
||
let mut v = history_search_view();
|
||
v.rotation = rotation;
|
||
v.find_step(true);
|
||
let middle = vec2(v.viewport_w, v.viewport_h) / 2.0;
|
||
assert_eq!(v.goto_point, Some((3, Some(fx), Some(fy), middle)), "view rotated {rotation}°");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_zoom_just_after_a_find_step_keeps_the_match_in_the_middle() {
|
||
let mut v = history_search_view();
|
||
v.screen_rects = vec![(0, Rect::from_min_size(pos2(100.0, 20.0), vec2(300.0, 400.0)))];
|
||
// A zoom anchored at the pointer was pending when the step came: the step drops it.
|
||
v.zoom_at(2.0, pos2(150.0, 50.0));
|
||
assert!(v.zoom_anchor.is_some());
|
||
v.find_step(true);
|
||
assert_eq!((v.goto, v.zoom_anchor), (None, None), "the step alone decides where the view goes");
|
||
let middle = vec2(v.viewport_w, v.viewport_h) / 2.0;
|
||
let target = Some((3, Some(40.0 / 300.0), Some(0.675), middle));
|
||
assert_eq!(v.goto_point, target);
|
||
// The rest of an eased wheel zoom, or a zoom in the same frame, anchors on the match
|
||
// at the window's centre, not on the pointer.
|
||
v.zoom_at(3.0, pos2(150.0, 50.0));
|
||
assert_eq!(v.zoom_anchor, Some((3, 40.0 / 300.0, 0.675, middle)));
|
||
// Another jump lets go of the match: the next zoom is at the pointer again.
|
||
v.go_to_page(1);
|
||
v.zoom_at(4.0, pos2(150.0, 50.0));
|
||
assert_eq!(v.zoom_anchor.map(|a| a.0), Some(0), "anchored on the page under the pointer");
|
||
}
|
||
|
||
#[test]
|
||
fn previous_view_after_a_find_step_lets_go_of_the_match() {
|
||
let mut v = history_search_view();
|
||
v.screen_rects = vec![(0, Rect::from_min_size(pos2(100.0, 20.0), vec2(300.0, 400.0)))];
|
||
v.find_step(true);
|
||
// Previous view while a wheel zoom is still easing: the zoom must not hold the old match
|
||
// and put the history jump off until it stops.
|
||
assert!(v.view_history(false));
|
||
assert_eq!(v.goto, Some((0, 0.0)));
|
||
v.zoom_at(2.0, pos2(150.0, 50.0));
|
||
assert_eq!(v.zoom_anchor.map(|a| a.0), Some(0), "anchored on the page under the pointer");
|
||
}
|
||
|
||
#[test]
|
||
fn a_match_with_unbounded_glyphs_falls_back_to_its_page() {
|
||
let mut v = history_search_view();
|
||
// A hostile page: a glyph from -inf to inf, whose centre is NaN.
|
||
if let Some(text) = v.texts.get_mut(&3).and_then(Arc::get_mut) {
|
||
text.glyphs[0].rect = [f32::NEG_INFINITY, 260.0, f32::INFINITY, 280.0];
|
||
}
|
||
v.zoom_at(2.0, pos2(150.0, 50.0));
|
||
v.find_step(true);
|
||
assert!(v.goto_point.is_none(), "no NaN scroll target");
|
||
assert_eq!(v.goto.map(|g| g.0), Some(3), "the match's page instead");
|
||
v.zoom_at(3.0, pos2(150.0, 50.0));
|
||
assert!(v.zoom_anchor.is_none_or(|a| a.1.is_finite() && a.2.is_finite()), "no NaN zoom anchor");
|
||
}
|
||
|
||
#[test]
|
||
fn automatic_search_updates_do_not_record_history_or_discard_forward() {
|
||
let mut v = history_search_view();
|
||
v.forward = vec![4];
|
||
v.find = Some(Find { query: "match".into(), ..Default::default() });
|
||
v.rerun_find();
|
||
assert_eq!(v.current, 3);
|
||
assert_eq!((v.goto, v.goto_point.map(|g| g.0)), (None, Some(3)), "the match's point on its page");
|
||
assert!(v.back.is_empty());
|
||
assert_eq!(v.forward, vec![4]);
|
||
v.find.as_mut().unwrap().current = None;
|
||
v.texts.insert(1, Arc::new(find_test_text("match")));
|
||
v.refresh_find_page(1);
|
||
assert_eq!(v.current, 1, "an automatic late match still follows its anchor");
|
||
assert!(v.back.is_empty(), "automatic search updates do not record history");
|
||
assert_eq!(v.forward, vec![4]);
|
||
}
|
||
|
||
/// One glyph per character on one line, for the search-order tests below.
|
||
fn find_test_text(s: &str) -> PageText {
|
||
PageText {
|
||
glyphs: s
|
||
.chars()
|
||
.map(|c| pdfcraft_render::TextGlyph { text: c.into(), rect: [0.0, 0.0, 10.0, 10.0], quad: [[0.0, 0.0]; 4], direction: [1.0, 0.0] })
|
||
.collect(),
|
||
line_of: s.chars().map(|_| 0).collect(),
|
||
space_before: s.chars().map(|_| false).collect(),
|
||
truncated: false,
|
||
}
|
||
}
|
||
|
||
/// A text-layer result for `page`, for the search tests below.
|
||
fn find_text_result(page: usize, text: &str) -> pdfcraft_render::RenderedPage {
|
||
pdfcraft_render::RenderedPage {
|
||
request: RenderRequest { page, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() },
|
||
width: 0,
|
||
height: 0,
|
||
rgba: Default::default(),
|
||
error: None,
|
||
text: Some(Arc::new(find_test_text(text))),
|
||
warnings: Vec::new(),
|
||
millis: 0,
|
||
}
|
||
}
|
||
|
||
/// Text layers arrive in whatever order the workers finish, not in page order. A search
|
||
/// started on page 1 must settle on page 1's match even when page 2's text arrives first,
|
||
/// instead of keeping the later match picked only because page 1 had not been read yet.
|
||
#[test]
|
||
fn automatic_search_match_follows_the_viewed_page_when_text_arrives_out_of_order() {
|
||
let mut v = view(2, PageLayout::Single, true);
|
||
v.current = 0;
|
||
v.find = Some(Find { query: "sample".into(), ..Default::default() });
|
||
v.rerun_find();
|
||
assert_eq!(v.find.as_ref().unwrap().current, None, "no text yet, so no match");
|
||
|
||
let ctx = egui::Context::default();
|
||
let request = |page| RenderRequest { page, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() };
|
||
let result = |page, text: &str| pdfcraft_render::RenderedPage {
|
||
request: request(page),
|
||
width: 0,
|
||
height: 0,
|
||
rgba: Default::default(),
|
||
error: None,
|
||
text: Some(Arc::new(find_test_text(text))),
|
||
warnings: Vec::new(),
|
||
millis: 0,
|
||
};
|
||
v.last_queue = vec![request(1), request(0)];
|
||
// Page 2's text finishes first, as it can when the workers race.
|
||
v.receive_result(&ctx, result(1, "Second page sample text"));
|
||
assert_eq!(v.find.as_ref().unwrap().current, Some(0), "the only known match is picked while page 1 is pending");
|
||
|
||
v.receive_result(&ctx, result(0, "Panel sample draft notes"));
|
||
let f = v.find.as_ref().unwrap();
|
||
assert_eq!(f.matches.iter().map(|(p, _)| *p).collect::<Vec<_>>(), vec![0, 1]);
|
||
assert_eq!(f.current, Some(0), "page 1's match takes over once its text arrives");
|
||
assert_eq!(v.current, 0, "the view follows the first match at or after the page being viewed");
|
||
}
|
||
|
||
/// A match the user picked (a click in the Search panel, or ⌘G) is not moved by a text
|
||
/// layer that arrives later: only automatic choices follow the viewed page.
|
||
#[test]
|
||
fn a_user_picked_search_match_stays_put_when_earlier_text_arrives() {
|
||
let mut v = view(2, PageLayout::Single, true);
|
||
v.current = 0;
|
||
v.find = Some(Find { query: "sample".into(), ..Default::default() });
|
||
v.rerun_find();
|
||
v.texts.insert(1, Arc::new(find_test_text("Second page sample text")));
|
||
v.refresh_find_page(1);
|
||
v.go_to_match(0);
|
||
assert_eq!(v.find.as_ref().unwrap().auto_from, None, "an explicit choice is not automatic");
|
||
|
||
v.texts.insert(0, Arc::new(find_test_text("Panel sample draft notes")));
|
||
v.refresh_find_page(0);
|
||
let f = v.find.as_ref().unwrap();
|
||
assert_eq!(f.matches.iter().map(|(p, _)| *p).collect::<Vec<_>>(), vec![0, 1]);
|
||
assert_eq!(f.current, Some(1), "the picked match keeps its identity after page 1 is inserted");
|
||
assert_eq!(v.current, 1, "the user's page does not change under them");
|
||
}
|
||
|
||
/// The automatic choice may move the view only while the reader has left it where that
|
||
/// choice put it. Navigating by the page box/Pages panel/outline or by view history is the
|
||
/// reader's decision: a text layer arriving afterwards must not pull them back to an
|
||
/// anchor-derived match. (The harness covers same-page wheel and pointer scrolling.)
|
||
#[test]
|
||
fn a_late_automatic_match_does_not_override_the_readers_navigation() {
|
||
enum Nav {
|
||
Page,
|
||
History,
|
||
}
|
||
for nav in [Nav::Page, Nav::History] {
|
||
let mut v = view(3, PageLayout::Single, true);
|
||
v.current = 0;
|
||
v.texts.insert(2, Arc::new(find_test_text("Second page sample")));
|
||
v.find = Some(Find { query: "sample".into(), ..Default::default() });
|
||
v.rerun_find();
|
||
assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (2, Some(2)), "the automatic match was shown");
|
||
match nav {
|
||
// The page box, the Pages panel and outline destinations all end here.
|
||
Nav::Page => v.go_to_page(1),
|
||
// View ▸ Page navigation ▸ Previous view writes `current` directly.
|
||
Nav::History => {
|
||
v.back = vec![1];
|
||
assert!(v.view_history(false));
|
||
}
|
||
}
|
||
assert_eq!(v.current, 1);
|
||
v.texts.insert(0, Arc::new(find_test_text("First page sample")));
|
||
v.refresh_find_page(0);
|
||
let f = v.find.as_ref().unwrap();
|
||
assert_eq!(f.auto_from, None, "the reader's navigation ends automatic following");
|
||
assert_eq!(f.auto_page, None);
|
||
assert_eq!(v.current, 1, "a late match cannot pull the reader back");
|
||
}
|
||
}
|
||
|
||
/// Typing on while the results arrive must not re-anchor the search on a page the automatic
|
||
/// flash picked. The anchor stays the page the reader was on when they started searching.
|
||
#[test]
|
||
fn refining_the_query_keeps_the_page_the_search_started_from() {
|
||
let mut v = view(3, PageLayout::Single, true);
|
||
v.current = 0;
|
||
v.texts.insert(2, Arc::new(find_test_text("Second page sample text")));
|
||
v.find = Some(Find { query: "sample".into(), ..Default::default() });
|
||
v.rerun_find();
|
||
assert_eq!((v.current, v.find.as_ref().unwrap().auto_from), (2, Some(0)), "the first query's match scrolled the view");
|
||
|
||
// Page 1 has a match for the refined query, and so has page 3. Anchored on the page the
|
||
// automatic flash reached, the search would pick page 3; the starting page picks page 1.
|
||
v.texts.insert(0, Arc::new(find_test_text("Panel draft text")));
|
||
v.find.as_mut().unwrap().query = "text".into();
|
||
v.rerun_find();
|
||
let f = v.find.as_ref().unwrap();
|
||
assert_eq!(f.auto_from, Some(0), "refinement keeps the page the reader searched from");
|
||
assert_eq!(f.matches.first().map(|(p, _)| *p), Some(0));
|
||
assert_eq!(f.current, Some(0));
|
||
assert_eq!(v.current, 0);
|
||
}
|
||
|
||
/// ⌘G / Ctrl+G goes through `find_step`, an explicit choice like a Search-panel click: a
|
||
/// text layer arriving later must not undo it.
|
||
#[test]
|
||
fn keyboard_find_step_is_explicit_and_a_late_text_layer_cannot_undo_it() {
|
||
let mut v = view(3, PageLayout::Single, true);
|
||
v.current = 0;
|
||
v.texts.insert(1, Arc::new(find_test_text("first sample")));
|
||
v.texts.insert(2, Arc::new(find_test_text("second sample")));
|
||
v.find = Some(Find { query: "sample".into(), ..Default::default() });
|
||
v.rerun_find();
|
||
assert_eq!((v.current, v.find.as_ref().unwrap().auto_from), (1, Some(0)));
|
||
|
||
let ctx = egui::Context::default();
|
||
ctx.input_mut(|i| {
|
||
i.events.push(egui::Event::Key { key: egui::Key::G, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND });
|
||
});
|
||
shortcuts(&mut v, &ctx, Default::default(), true, Default::default());
|
||
|
||
assert_eq!(v.current, 2, "the keyboard step chose the next match");
|
||
assert_eq!(v.find.as_ref().unwrap().auto_from, None, "the keyboard step is the user's choice");
|
||
assert_eq!(v.back, vec![1], "an explicit search jump records its origin");
|
||
|
||
v.texts.insert(0, Arc::new(find_test_text("intro sample")));
|
||
v.refresh_find_page(0);
|
||
let f = v.find.as_ref().unwrap();
|
||
assert_eq!(v.current, 2, "the chosen match keeps the view");
|
||
assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(2));
|
||
}
|
||
|
||
/// A page whose text extraction failed keeps the automatic anchor: the failure selects the
|
||
/// next match without moving the view, and a later text layer can still follow the anchor.
|
||
#[test]
|
||
fn a_failed_text_layer_keeps_the_automatic_anchor_without_jitter() {
|
||
let mut v = view(3, PageLayout::Single, true);
|
||
v.current = 0;
|
||
v.texts.insert(1, Arc::new(find_test_text("first sample")));
|
||
v.texts.insert(2, Arc::new(find_test_text("second sample")));
|
||
v.find = Some(Find { query: "sample".into(), ..Default::default() });
|
||
v.rerun_find();
|
||
assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (1, Some(1)));
|
||
|
||
let goto = v.goto;
|
||
let ctx = egui::Context::default();
|
||
let request = RenderRequest { page: 1, kind: RequestKind::Text, ..Default::default() };
|
||
v.last_queue = vec![request];
|
||
v.receive_result(
|
||
&ctx,
|
||
pdfcraft_render::RenderedPage {
|
||
request,
|
||
width: 0,
|
||
height: 0,
|
||
rgba: Default::default(),
|
||
error: Some("extraction failed".into()),
|
||
text: None,
|
||
warnings: Vec::new(),
|
||
millis: 0,
|
||
},
|
||
);
|
||
let f = v.find.as_ref().unwrap();
|
||
assert_eq!(f.matches.iter().map(|(p, _)| *p).collect::<Vec<_>>(), vec![2]);
|
||
assert_eq!(f.current, Some(0), "the remaining match is selected");
|
||
assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(1)), "a failure does not end automatic following");
|
||
assert_eq!(v.current, 1, "a failure never moves the view");
|
||
assert_eq!(v.goto, goto, "a failure never flashes the match");
|
||
|
||
// The search still follows its original page when that page's text arrives.
|
||
v.last_queue = vec![RenderRequest { page: 0, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }];
|
||
v.receive_result(&ctx, find_text_result(0, "intro sample"));
|
||
assert_eq!(v.current, 0, "the anchor's earlier match takes over");
|
||
let f = v.find.as_ref().unwrap();
|
||
assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(0));
|
||
}
|
||
|
||
/// A reader who navigated before a page's text failed is not pulled back by a later
|
||
/// matching layer: the failure path must preserve the same `Ended` state as a successful
|
||
/// one, and the result list may select the late match without revealing it.
|
||
#[test]
|
||
fn a_failed_text_layer_after_navigation_does_not_reveal_a_late_match() {
|
||
let mut v = view(3, PageLayout::Single, true);
|
||
v.current = 0;
|
||
v.find = Some(Find { query: "sample".into(), ..Default::default() });
|
||
v.rerun_find();
|
||
v.go_to_page(2);
|
||
let moved = v.goto;
|
||
assert!(v.find.as_ref().unwrap().follow_ended, "the reader navigated before any match arrived");
|
||
|
||
let ctx = egui::Context::default();
|
||
let failed = RenderRequest { page: 1, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() };
|
||
v.last_queue = vec![failed];
|
||
v.receive_result(
|
||
&ctx,
|
||
pdfcraft_render::RenderedPage {
|
||
request: failed,
|
||
width: 0,
|
||
height: 0,
|
||
rgba: Default::default(),
|
||
error: Some("extraction failed".into()),
|
||
text: None,
|
||
warnings: Vec::new(),
|
||
millis: 0,
|
||
},
|
||
);
|
||
let f = v.find.as_ref().unwrap();
|
||
assert!(f.follow_ended, "a failure after navigation does not restart following");
|
||
assert_eq!((f.auto_from, f.auto_page), (None, None));
|
||
assert_eq!(v.current, 2, "a failure never moves the view");
|
||
assert_eq!(v.goto, moved);
|
||
|
||
let arrived = RenderRequest { page: 0, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() };
|
||
v.last_queue = vec![arrived];
|
||
v.receive_result(&ctx, find_text_result(0, "intro sample"));
|
||
let f = v.find.as_ref().unwrap();
|
||
assert!(f.follow_ended);
|
||
assert_eq!((f.auto_from, f.auto_page), (None, None));
|
||
assert_eq!(v.current, 2, "the late matching layer does not pull the reader back");
|
||
assert_eq!(v.goto, moved, "no flash was issued");
|
||
assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(0), "the result list still selects the late match");
|
||
}
|
||
|
||
/// The reader can navigate before any text layer has supplied a match. That navigation
|
||
/// still ends automatic following, so the first late match is listed but not revealed by
|
||
/// a flash that would pull the reader back.
|
||
#[test]
|
||
fn navigation_before_the_first_match_is_not_overridden_by_a_late_text_layer() {
|
||
let mut v = view(3, PageLayout::Single, true);
|
||
v.current = 0;
|
||
v.find = Some(Find { query: "sample".into(), ..Default::default() });
|
||
v.rerun_find();
|
||
let f = v.find.as_ref().unwrap();
|
||
assert_eq!(f.current, None, "no text layer has arrived yet");
|
||
assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(0)));
|
||
|
||
v.go_to_page(2);
|
||
assert_eq!(v.goto, Some((2, 0.0)), "the reader moved to page 3");
|
||
assert!(v.find.as_ref().unwrap().follow_ended, "moving the view ends automatic following");
|
||
|
||
let ctx = egui::Context::default();
|
||
let request = RenderRequest { page: 1, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() };
|
||
v.last_queue = vec![request];
|
||
v.receive_result(&ctx, find_text_result(1, "late sample"));
|
||
let f = v.find.as_ref().unwrap();
|
||
assert_eq!(v.current, 2, "the late first match does not pull the reader back");
|
||
assert_eq!(v.goto, Some((2, 0.0)), "no flash was issued");
|
||
assert_eq!((f.auto_from, f.auto_page), (None, None));
|
||
assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(1), "the result list still selects the match");
|
||
}
|
||
|
||
/// A page-box jump to the page already shown is still the reader's navigation: automatic
|
||
/// following must end, so a late earlier match is listed without moving the view. (The
|
||
/// harness covers wheel, scrollbar and pointer scrolling through the production paint path.)
|
||
#[test]
|
||
fn a_same_page_page_box_jump_ends_automatic_following() {
|
||
let mut v = view(3, PageLayout::Single, true);
|
||
v.current = 0;
|
||
v.texts.insert(1, Arc::new(find_test_text("first sample")));
|
||
v.find = Some(Find { query: "sample".into(), ..Default::default() });
|
||
v.rerun_find();
|
||
assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (1, Some(1)), "the automatic choice is on page 2");
|
||
|
||
// Choosing the page already shown still ends following; it stays on page 2 at its top.
|
||
v.go_to_page(1);
|
||
let shown = v.goto;
|
||
assert_eq!(shown, Some((1, 0.0)));
|
||
|
||
v.texts.insert(0, Arc::new(find_test_text("intro sample")));
|
||
v.refresh_find_page(0);
|
||
let f = v.find.as_ref().unwrap();
|
||
assert!(f.follow_ended);
|
||
assert_eq!((f.auto_from, f.auto_page), (None, None));
|
||
assert_eq!(v.current, 1, "the late earlier match does not move the reader within the page");
|
||
assert_eq!(v.goto, shown, "no flash was issued");
|
||
assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(1), "the page-2 match stays selected");
|
||
}
|
||
|
||
/// A document-wide invalidation (a layer toggle, an edit, undo/redo) clears results for
|
||
/// re-extraction but is not the reader navigating. Automatic following keeps its anchor so
|
||
/// the re-extracted layers still reveal the automatic choice; a reader who already took
|
||
/// over stays in control. If `document_changed` clamps the view away from that anchor, the
|
||
/// next layer ends following instead of revealing a match from the stale page.
|
||
#[test]
|
||
fn content_invalidation_keeps_the_following_state() {
|
||
let mut v = view(3, PageLayout::Single, true);
|
||
v.current = 0;
|
||
v.texts.insert(1, Arc::new(find_test_text("first sample")));
|
||
v.find = Some(Find { query: "sample".into(), ..Default::default() });
|
||
v.rerun_find();
|
||
assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (1, Some(1)), "the automatic choice is on page 2");
|
||
|
||
v.invalidate_content();
|
||
let f = v.find.as_ref().unwrap();
|
||
assert!(f.matches.is_empty() && f.current.is_none(), "results wait for the re-extracted text");
|
||
assert!(!f.follow_ended, "a content change is not reader navigation");
|
||
assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(1)));
|
||
|
||
// Re-extracted text still follows the page the search started from.
|
||
let ctx = egui::Context::default();
|
||
let request = RenderRequest { page: 0, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() };
|
||
v.last_queue = vec![request];
|
||
v.receive_result(&ctx, find_text_result(0, "intro sample"));
|
||
assert_eq!(v.current, 0, "the re-extracted match is revealed by the still-active search");
|
||
let f = v.find.as_ref().unwrap();
|
||
assert!(!f.follow_ended);
|
||
assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(0)));
|
||
|
||
// A reader who navigated first keeps the view they chose across the invalidation.
|
||
v.go_to_page(2);
|
||
let moved = v.goto;
|
||
assert!(v.find.as_ref().unwrap().follow_ended);
|
||
v.invalidate_content();
|
||
let f = v.find.as_ref().unwrap();
|
||
assert!(f.follow_ended);
|
||
assert_eq!((f.auto_from, f.auto_page), (None, None));
|
||
v.last_queue = vec![request];
|
||
v.receive_result(&ctx, find_text_result(0, "late sample"));
|
||
assert_eq!(v.current, 2, "no delayed reveal after the reader navigated");
|
||
assert_eq!(v.goto, moved, "no flash was issued");
|
||
let f = v.find.as_ref().unwrap();
|
||
assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(0), "the result list still selects the late match");
|
||
}
|
||
|
||
/// A document edit can shrink the page count. `document_changed` clamps `current` but leaves
|
||
/// the automatic anchor's page index alone; a re-extracted layer must then end following
|
||
/// rather than reveal a match by scrolling from a page that no longer exists.
|
||
#[test]
|
||
fn a_page_count_shrink_ends_following_before_a_late_reveal() {
|
||
let mut v = view(5, PageLayout::Single, true);
|
||
v.current = 0;
|
||
v.texts.insert(4, Arc::new(find_test_text("last sample")));
|
||
v.find = Some(Find { query: "sample".into(), ..Default::default() });
|
||
v.rerun_find();
|
||
assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (4, Some(4)), "the automatic choice is on the last page");
|
||
|
||
let info = pdfcraft_render::DocInfo {
|
||
pages: (0..2)
|
||
.map(|_| pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 })
|
||
.collect(),
|
||
..Default::default()
|
||
};
|
||
v.document_changed(&info);
|
||
assert_eq!(v.current, 1, "the view clamps to the last remaining page");
|
||
let f = v.find.as_ref().unwrap();
|
||
assert!(!f.follow_ended, "the content change itself keeps the automatic state");
|
||
assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(4)), "the anchor still names the removed page");
|
||
let goto = v.goto;
|
||
|
||
let ctx = egui::Context::default();
|
||
let request = RenderRequest { page: 0, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() };
|
||
v.last_queue = vec![request];
|
||
v.receive_result(&ctx, find_text_result(0, "intro sample"));
|
||
let f = v.find.as_ref().unwrap();
|
||
assert!(f.follow_ended, "a layer for the remaining pages ends the stale follow");
|
||
assert_eq!((f.auto_from, f.auto_page), (None, None));
|
||
assert_eq!(v.current, 1, "the late layer cannot pull the view back");
|
||
assert_eq!(v.goto, goto, "no flash was issued");
|
||
assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(0), "the result list still selects the match");
|
||
}
|
||
|
||
/// Typing refinements while text layers keep arriving must keep the page the search began
|
||
/// on, not re-anchor on whichever page an earlier automatic flash happened to show.
|
||
#[test]
|
||
fn rapid_query_edits_interleaved_with_text_results_keep_the_start_anchor() {
|
||
let mut v = view(3, PageLayout::Single, true);
|
||
v.current = 0;
|
||
v.find = Some(Find { query: "sam".into(), ..Default::default() });
|
||
v.rerun_find();
|
||
assert!(v.find.as_ref().unwrap().current.is_none());
|
||
|
||
let edits = [("samp", 2, "Page three sample", 2), ("sampl", 1, "Page two sample", 1), ("sample", 0, "Page one sample", 0)];
|
||
for (query, page, text, shown) in edits {
|
||
v.find.as_mut().unwrap().query = query.into();
|
||
v.rerun_find();
|
||
let f = v.find.as_ref().unwrap();
|
||
assert_eq!(f.auto_from, Some(0), "refinement keeps the page the reader started from");
|
||
assert!(!f.follow_ended);
|
||
v.texts.insert(page, Arc::new(find_test_text(text)));
|
||
v.refresh_find_page(page);
|
||
let f = v.find.as_ref().unwrap();
|
||
assert_eq!(v.current, shown, "the late page follows the automatic anchor {shown}");
|
||
assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(shown)));
|
||
assert!(!f.follow_ended, "the automatic flash itself does not end following");
|
||
}
|
||
assert!(v.back.is_empty(), "automatic search updates do not record history");
|
||
}
|
||
|
||
#[test]
|
||
fn text_layers_evict_far_pages_but_keep_visible_and_matches() {
|
||
let info = thumbnail_info(100);
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
v.current = 50;
|
||
v.frame_visible = HashSet::from([50, 51]);
|
||
for p in 0..100 {
|
||
v.texts.insert(p, Arc::new(PageText::default()));
|
||
}
|
||
v.text_failed.insert(0);
|
||
v.text_failed.insert(99);
|
||
v.find = Some(Find { matches: vec![(0, 0..1)], ..Default::default() });
|
||
let _ = v.prepare_render_queue(&info, 1.0);
|
||
assert!(v.texts.contains_key(&50) && v.texts.contains_key(&51), "visible pages stay");
|
||
assert!(v.texts.contains_key(&0), "matched pages stay");
|
||
assert!(v.texts.contains_key(&49), "nearest prefetch window stays");
|
||
assert!(!v.texts.contains_key(&99), "far pages go");
|
||
assert_eq!(v.texts.len(), 2 + 1 + MAX_CACHED_TEXT_PAGES, "visible + matched + prefetch window");
|
||
assert!(v.text_failed.contains(&0) && v.text_failed.contains(&99), "failures are bytes-free: never evict");
|
||
}
|
||
|
||
/// A PDF of one line of text on each page, `lines[i]` on page `i`.
|
||
fn text_pages_pdf(lines: &[&str]) -> Vec<u8> {
|
||
let n = lines.len();
|
||
// 1 catalog, 2 pages, 3 font, then a page and its content stream for each line.
|
||
let mut objects = vec![
|
||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||
format!("<< /Type /Pages /Count {n} /Kids [{}] >>", (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect::<Vec<_>>().join(" ")),
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
|
||
];
|
||
for (i, line) in lines.iter().enumerate() {
|
||
let content = format!("BT /F1 18 Tf 72 700 Td ({line}) Tj ET");
|
||
objects.push(format!(
|
||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents {} 0 R >>",
|
||
5 + 2 * i
|
||
));
|
||
objects.push(format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()));
|
||
}
|
||
let mut out = b"%PDF-1.7\n".to_vec();
|
||
let mut offsets = Vec::new();
|
||
for (i, o) in objects.iter().enumerate() {
|
||
offsets.push(out.len());
|
||
out.extend(format!("{} 0 obj\n{o}\nendobj\n", i + 1).bytes());
|
||
}
|
||
let xref = out.len();
|
||
out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).bytes());
|
||
for o in offsets {
|
||
out.extend(format!("{o:010} 00000 n \n").bytes());
|
||
}
|
||
out.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).bytes());
|
||
out
|
||
}
|
||
|
||
/// #2639: scrolling continuously, Select All takes every page's text, and Copy gets all of it
|
||
/// in page order, reading the pages whose text wasn't read for display.
|
||
#[test]
|
||
fn select_all_copies_the_whole_document_while_scrolling_continuously() {
|
||
let mut app = PdfCraftApp::new();
|
||
app.open_bytes("three.pdf", None, text_pages_pdf(&["Alpha one", "Bravo two", "Charlie three"])).unwrap();
|
||
let v = &mut app.views[0];
|
||
assert!(v.continuous, "continuous scrolling is the default");
|
||
assert!(v.texts.is_empty(), "no page's text read yet");
|
||
// Nothing shown is selected yet (false, as for a page whose text isn't read), but the whole
|
||
// document is: Copy reads every page.
|
||
assert!(!v.select_all());
|
||
assert!(v.all_text && v.selection.is_none());
|
||
assert_eq!(v.pages_without_text(), vec![0, 1, 2]);
|
||
// Copy reads the pages off the UI thread; the clipboard gets the text once they're read.
|
||
let ctx = egui::Context::default();
|
||
assert!(app.copy_selected_text(0, &ctx), "the copy starts");
|
||
assert!(app.document_copy.is_some(), "reading the pages, not on this thread");
|
||
let mut text = None;
|
||
for _ in 0..500 {
|
||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.finish_document_copy(ui.ctx()));
|
||
out.textures_delta.clear();
|
||
text = out.platform_output.commands.iter().find_map(|c| match c {
|
||
egui::OutputCommand::CopyText(t) => Some(t.clone()),
|
||
_ => None,
|
||
});
|
||
if text.is_some() {
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||
}
|
||
let text = text.expect("the copied text arrives");
|
||
assert!(app.document_copy.is_none(), "done");
|
||
let at = |w: &str| text.find(w).unwrap_or_else(|| panic!("{w:?} missing from {text:?}"));
|
||
assert!(at("Alpha") < at("Bravo") && at("Bravo") < at("Charlie"), "page order: {text:?}");
|
||
assert_eq!(text.lines().count(), 3, "a line per page: {text:?}");
|
||
// A new scope ends it: one page at a time, Select All is the page's again.
|
||
app.views[0].set_continuous(false);
|
||
assert!(!app.views[0].all_text, "not after switching to one page at a time");
|
||
// So do a page selection and a change of the document's content (an edit, undo).
|
||
app.views[0].set_continuous(true);
|
||
app.views[0].select_all();
|
||
app.views[0].select_text(0, 0, 0);
|
||
assert!(!app.views[0].all_text, "a page selection replaces it");
|
||
app.views[0].select_all();
|
||
app.views[0].invalidate_content();
|
||
assert!(!app.views[0].all_text, "not past an edit of the document");
|
||
}
|
||
|
||
/// A whole-document copy reads the pages without text, keeps those already read and skips
|
||
/// those that failed before.
|
||
#[test]
|
||
fn a_document_copy_reads_only_the_pages_it_needs() {
|
||
let mut app = PdfCraftApp::new();
|
||
app.open_bytes("three.pdf", None, text_pages_pdf(&["Alpha one", "Bravo two", "Charlie three"])).unwrap();
|
||
app.views[0].text_failed.insert(2);
|
||
let job = crate::DocumentCopyJob::of(&app.views[0], app.session.get(app.views[0].id).unwrap());
|
||
let kinds: Vec<_> = job.pages.iter().map(|p| matches!(p, crate::CopyPage::ToRead)).collect();
|
||
assert_eq!(kinds, vec![true, true, false], "pages 1 and 2 to read, page 3 failed before");
|
||
assert!(matches!(job.pages[2], crate::CopyPage::Skip));
|
||
}
|
||
|
||
/// A later copy wins: a whole-document copy still reading its pages never replaces what was
|
||
/// copied after it started, from the page or anywhere else (a text field, a comment).
|
||
#[test]
|
||
fn a_later_copy_cancels_a_document_copy_still_reading() {
|
||
let mut app = PdfCraftApp::new();
|
||
app.open_bytes("three.pdf", None, text_pages_pdf(&["Alpha one", "Bravo two", "Charlie three"])).unwrap();
|
||
let ctx = egui::Context::default();
|
||
// Another copy in the same frame (here a text field's) drops it.
|
||
app.views[0].select_all();
|
||
assert!(app.copy_selected_text(0, &ctx) && app.document_copy.is_some());
|
||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||
ui.ctx().copy_text("a word".to_owned());
|
||
app.finish_document_copy(ui.ctx());
|
||
});
|
||
out.textures_delta.clear();
|
||
assert!(app.document_copy.is_none(), "cancelled");
|
||
let copies: Vec<_> = out.platform_output.commands.iter().filter(|c| matches!(c, egui::OutputCommand::CopyText(_))).collect();
|
||
assert_eq!(copies, vec![&egui::OutputCommand::CopyText("a word".to_owned())], "the later copy stays");
|
||
// So does copying a page selection.
|
||
app.views[0].select_all();
|
||
assert!(app.copy_selected_text(0, &ctx) && app.document_copy.is_some());
|
||
app.views[0].select_text(0, 0, 5);
|
||
app.copy_selected_text(0, &ctx);
|
||
assert!(app.document_copy.is_none(), "a page selection's copy cancels it");
|
||
}
|
||
|
||
/// A press anywhere on a page ends Select All of the document, whatever the tool (here the
|
||
/// Hand, which selects no text) and whether the page's text is read yet.
|
||
#[test]
|
||
fn a_press_on_a_page_ends_select_all_of_the_document() {
|
||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.open_bytes("three.pdf", None, text_pages_pdf(&["Alpha one", "Bravo two", "Charlie three"])).unwrap();
|
||
app
|
||
});
|
||
let mut on_screen = None;
|
||
for _ in 0..600 {
|
||
h.run_steps(2);
|
||
on_screen = h.state().views[0].page_screen_rect(0);
|
||
if on_screen.is_some() {
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
}
|
||
let r = on_screen.expect("the first page is on screen");
|
||
h.state_mut().quick_tool = crate::QuickTool::Hand;
|
||
h.state_mut().views[0].select_all();
|
||
assert!(h.state().views[0].all_text);
|
||
let at = r.center();
|
||
h.event(egui::Event::PointerMoved(at));
|
||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
|
||
h.run_steps(1);
|
||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
|
||
h.run_steps(2);
|
||
assert!(!h.state().views[0].all_text, "a press on the page ends it");
|
||
|
||
// A press while a dialog is up lands on the dialog, not the page: the selection stays.
|
||
h.state_mut().views[0].select_all();
|
||
h.state_mut().dialog = Some(crate::Dialog::JsConsole);
|
||
h.run_steps(2);
|
||
h.event(egui::Event::PointerMoved(at));
|
||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
|
||
h.run_steps(1);
|
||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
|
||
h.run_steps(2);
|
||
assert!(h.state().views[0].all_text, "a press beneath a dialog doesn't end it");
|
||
}
|
||
|
||
/// The pages of a whole-document copy come out in order whatever was read before, and a
|
||
/// cancelled copy gives nothing (a new copy or a closed document cancels the old one).
|
||
#[test]
|
||
fn a_document_copy_reads_in_page_order_and_stops_when_cancelled() {
|
||
let mut app = PdfCraftApp::new();
|
||
app.open_bytes("three.pdf", None, text_pages_pdf(&["Alpha one", "Bravo two", "Charlie three"])).unwrap();
|
||
// The middle page's text read for display already; the others read by the copy.
|
||
let doc = app.session.get(app.views[0].id).unwrap();
|
||
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default());
|
||
let middle = r.render(RenderRequest { page: 1, kind: RequestKind::Text, ..Default::default() }).text.unwrap();
|
||
app.views[0].texts.insert(1, middle);
|
||
let job = || crate::DocumentCopyJob::of(&app.views[0], app.session.get(app.views[0].id).unwrap());
|
||
let text = job().text(&std::sync::atomic::AtomicBool::new(false)).unwrap();
|
||
assert!(text.find("Alpha").unwrap() < text.find("Bravo").unwrap() && text.find("Bravo").unwrap() < text.find("Charlie").unwrap(), "{text:?}");
|
||
assert_eq!(job().text(&std::sync::atomic::AtomicBool::new(true)), None, "cancelled");
|
||
}
|
||
|
||
/// Select All keeps to the page shown one page at a time, and in a document of one page; of
|
||
/// the document, it reads the text already read and doesn't ask again for failed pages.
|
||
#[test]
|
||
fn select_all_of_the_document_only_while_scrolling_through_several_pages() {
|
||
let info = thumbnail_info(3);
|
||
let one_line = |s: &str| PageText {
|
||
glyphs: s
|
||
.chars()
|
||
.map(|c| pdfcraft_render::TextGlyph { text: c.into(), rect: [0.0, 0.0, 10.0, 10.0], quad: [[0.0, 0.0]; 4], direction: [1.0, 0.0] })
|
||
.collect(),
|
||
line_of: s.chars().map(|_| 0).collect(),
|
||
space_before: s.chars().map(|_| false).collect(),
|
||
truncated: false,
|
||
};
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
for p in 0..3 {
|
||
v.texts.insert(p, Arc::new(one_line("abc")));
|
||
}
|
||
v.continuous = false;
|
||
v.current = 1;
|
||
assert!(v.select_all());
|
||
assert!(!v.all_text && v.selection.is_some_and(|s| s.page == 1), "one page at a time: the page shown");
|
||
let single = thumbnail_info(1);
|
||
let mut w = DocView::new(DocId(2), &single, ViewDefaults::default());
|
||
w.texts.insert(0, Arc::new(one_line("abc")));
|
||
assert!(w.select_all());
|
||
assert!(!w.all_text && w.selection.is_some(), "a one-page document: that page");
|
||
// Text read for display is used; pages that failed aren't asked for again.
|
||
let mut d = DocView::new(DocId(3), &info, ViewDefaults::default());
|
||
d.texts.insert(1, Arc::new(one_line("abc")));
|
||
d.text_failed.insert(2);
|
||
assert!(d.select_all() && d.all_text);
|
||
assert_eq!(d.pages_without_text(), vec![0]);
|
||
}
|
||
|
||
#[test]
|
||
fn text_layers_keep_the_selection_page() {
|
||
let info = thumbnail_info(100);
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
let text = PageText {
|
||
glyphs: vec![pdfcraft_render::TextGlyph { text: "x".into(), rect: [0.0, 0.0, 10.0, 10.0], quad: [[0.0, 0.0]; 4], direction: [1.0, 0.0] }],
|
||
line_of: vec![0],
|
||
space_before: vec![false],
|
||
truncated: false,
|
||
};
|
||
for p in 0..100 {
|
||
v.texts.insert(p, Arc::new(text.clone()));
|
||
}
|
||
v.select_text(0, 0, 0);
|
||
v.current = 90;
|
||
v.frame_visible = HashSet::from([90, 91]);
|
||
let _ = v.prepare_render_queue(&info, 1.0);
|
||
assert_eq!(v.selected_text().as_deref(), Some("x"), "stale selections still copy");
|
||
}
|
||
|
||
#[test]
|
||
fn text_layers_stay_while_a_search_is_active() {
|
||
let info = thumbnail_info(100);
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
v.current = 50;
|
||
v.frame_visible = HashSet::from([50]);
|
||
for p in 0..100 {
|
||
v.texts.insert(p, Arc::new(PageText::default()));
|
||
}
|
||
v.find = Some(Find { query: "dup".into(), case_query: "dup".into(), matches: vec![(0, 0..1)], ..Default::default() });
|
||
let _ = v.prepare_render_queue(&info, 1.0);
|
||
assert_eq!(v.texts.len(), 100, "evicting mid-search would churn the workers");
|
||
}
|
||
|
||
fn thumbnail_info(count: usize) -> DocInfo {
|
||
DocInfo {
|
||
pages: (0..count)
|
||
.map(|i| {
|
||
let (width, height) = match i % 3 {
|
||
0 => (612.0, 792.0),
|
||
1 => (200.0, 4000.0),
|
||
_ => (4000.0, 200.0),
|
||
};
|
||
pdfcraft_render::PageInfo { width, height, label: (i + 1).to_string(), crop: [0.0, 0.0, width, height], rotation: 0 }
|
||
})
|
||
.collect(),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn ten_thousand_pages_only_demand_viewport_and_prefetch_at_both_densities() {
|
||
let info = thumbnail_info(10_000);
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
let cols = 4;
|
||
let rows = info.pages.len().div_ceil(cols);
|
||
for ppp in [1.0, 2.0] {
|
||
for row in [0, 1234, rows - 1] {
|
||
v.begin_render_frame();
|
||
let range = thumbnail_rows(row as f32 * 250.0, row as f32 * 250.0 + 750.0, 250.0, rows);
|
||
for p in range.start * cols..(range.end * cols).min(info.pages.len()) {
|
||
v.need_thumbnail(p, p / cols == row);
|
||
}
|
||
let queue = v.prepare_render_queue(&info, ppp);
|
||
assert!(queue.len() <= 5 * cols, "only three visible rows and two prefetch rows");
|
||
assert_eq!(queue.first().unwrap().page / cols, row, "visible cells precede prefetch");
|
||
let bytes: usize = queue
|
||
.iter()
|
||
.map(|r| {
|
||
let p = &info.pages[r.page];
|
||
let (w, h) = (device_pixels(p.width, r.scale), device_pixels(p.height, r.scale));
|
||
assert!(w <= (THUMB_W * ppp).ceil() as u32);
|
||
assert!(h <= (THUMB_H * ppp).ceil() as u32);
|
||
rgba_bytes(w as usize, h as usize)
|
||
})
|
||
.sum();
|
||
assert!(bytes <= THUMB_BYTES);
|
||
assert_eq!(queue, v.prepare_render_queue(&info, ppp), "unchanged demand reconstructs the same work");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn oversized_thumbnail_viewport_scales_all_admitted_cells_to_one_budget() {
|
||
let info = thumbnail_info(10_000);
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
for page in 0..MAX_THUMB_DEMAND {
|
||
v.need_thumbnail(page, true);
|
||
}
|
||
let queue = v.prepare_render_queue(&info, 2.0);
|
||
assert_eq!(queue.len(), MAX_THUMB_DEMAND);
|
||
let bytes: usize = queue
|
||
.iter()
|
||
.map(|r| {
|
||
let p = &info.pages[r.page];
|
||
rgba_bytes(device_pixels(p.width, r.scale) as usize, device_pixels(p.height, r.scale) as usize)
|
||
})
|
||
.sum();
|
||
assert!(bytes <= THUMB_BYTES);
|
||
}
|
||
|
||
fn solid_result(request: RenderRequest, w: u32, h: u32) -> pdfcraft_render::RenderedPage {
|
||
pdfcraft_render::RenderedPage {
|
||
request,
|
||
width: w,
|
||
height: h,
|
||
rgba: pdfcraft_render::Pixels::from(vec![u32::MAX; rgba_bytes(w as usize, h as usize) / 4]),
|
||
text: None,
|
||
error: None,
|
||
warnings: Vec::new(),
|
||
millis: 0,
|
||
}
|
||
}
|
||
|
||
/// #3149: the replacement dialog commits after the canvas collected this frame's demand.
|
||
#[test]
|
||
fn replacing_a_page_after_layout_never_uploads_an_oversized_texture() {
|
||
let ctx = egui::Context::default();
|
||
ctx.input_mut(|i| i.max_texture_side = 2048);
|
||
let mut app = PdfCraftApp::new();
|
||
let original = app.session.create_blank(200.0, 300.0, 1).unwrap();
|
||
let replacement = app.session.create_blank(400.0, 500.0, 1).unwrap();
|
||
app.open_bytes("original.pdf", None, original.as_ref().clone()).unwrap();
|
||
let request = RenderRequest { scale: 4.7, tag: scale_tag(4.7), ..Default::default() };
|
||
app.views[0].frame_queue = vec![request];
|
||
app.views[0].frame_visible.insert(0);
|
||
assert!(app.apply_edit(Edit::ReplacePages { pages: vec![0], name: "replacement.pdf".into(), bytes: replacement, src_pages: vec![0] }));
|
||
let doc = app.session.get(app.views[0].id).unwrap();
|
||
assert_eq!((doc.info.pages[0].width, doc.info.pages[0].height), (400.0, 500.0));
|
||
let edited_bytes = doc.bytes.clone();
|
||
let pool = RenderPool::new_inline(doc.display.clone(), Default::default());
|
||
app.views[0].finish_render_frame(&ctx, &doc.info, &pool);
|
||
assert!(app.views[0].last_queue.is_empty(), "old geometry must not reach the replacement renderer");
|
||
|
||
// The next layout can render a bounded backdrop and sharp tiles at the same zoom.
|
||
let backdrop = RenderRequest { scale: 2048.0 / 500.0, tag: scale_tag(2048.0 / 500.0), ..request };
|
||
let tile = RenderRequest { tile: Some(Tile { x: 0, y: 0, w: 1024, h: 1024 }), ..request };
|
||
for _ in 0..2 {
|
||
let view = &mut app.views[0];
|
||
view.begin_render_frame();
|
||
view.frame_visible.insert(0);
|
||
view.frame_queue = vec![backdrop, tile];
|
||
view.finish_render_frame(&ctx, &doc.info, &pool);
|
||
}
|
||
let view = &app.views[0];
|
||
assert_eq!(view.pages.get(&0).unwrap().tex.size(), [1639, 2048]);
|
||
assert_eq!(view.tiles.get(&(0, tile.tag, 0, 0)).unwrap().size(), [1024, 1024]);
|
||
assert!(view.errors.is_empty());
|
||
assert_eq!(doc.bytes, edited_bytes, "rendering preserves the unsaved edit");
|
||
assert!(doc.dirty);
|
||
assert_eq!(doc.can_undo(), Some("Replace page"));
|
||
}
|
||
|
||
#[test]
|
||
fn oversized_raster_results_do_not_replace_cached_textures() {
|
||
let ctx = egui::Context::default();
|
||
ctx.input_mut(|i| i.max_texture_side = 64);
|
||
for request in [
|
||
RenderRequest::default(),
|
||
RenderRequest { tag: THUMB_TAG, ..Default::default() },
|
||
RenderRequest { tag: GRID_TAG, ..Default::default() },
|
||
RenderRequest { tag: PRINT_TAG, ..Default::default() },
|
||
RenderRequest { tile: Some(Tile { x: 0, y: 0, w: 64, h: 32 }), ..Default::default() },
|
||
] {
|
||
let mut view = DocView::new(DocId(1), &thumbnail_info(1), ViewDefaults::default());
|
||
view.last_queue = vec![request];
|
||
let texture = |v: &DocView| {
|
||
v.pages
|
||
.values()
|
||
.map(|p| &p.tex)
|
||
.chain(v.thumbs.values().map(|p| &p.tex))
|
||
.chain(v.grid_pages.values().map(|(_, t)| t))
|
||
.chain(v.print_pages.values().map(|(_, t)| t))
|
||
.chain(v.tiles.values())
|
||
.map(|t| (t.id(), t.size()))
|
||
.collect::<Vec<_>>()
|
||
};
|
||
assert_eq!(view.receive_result(&ctx, solid_result(request, 64, 32)), 64 * 32 * 4);
|
||
let cached = texture(&view);
|
||
assert_eq!(cached.len(), 1);
|
||
for (w, h) in [(65, 32), (32, 65), (65, 65), (0, 32), (32, 0)] {
|
||
assert_eq!(view.receive_result(&ctx, solid_result(request, w, h)), 0, "{request:?}: {w}x{h}");
|
||
assert_eq!(texture(&view), cached, "an invalid result must keep the previous raster");
|
||
}
|
||
assert_eq!(view.receive_result(&ctx, solid_result(request, 32, 64)), 32 * 64 * 4);
|
||
assert_eq!(texture(&view)[0].1, [32, 64], "valid results still make progress");
|
||
}
|
||
}
|
||
|
||
/// A print preview carries the renderer's print bit; the bit that asks for luminance is
|
||
/// its own, so a colour preview is not converted to monochrome and the two modes are told
|
||
/// apart by their tags.
|
||
#[test]
|
||
fn print_preview_tag_distinguishes_grayscale_from_colour() {
|
||
let mut v = view(1, PageLayout::Single, true);
|
||
v.set_print_grayscale(false);
|
||
v.queue_print_previews(&[(0, 1.0)]);
|
||
let colour = v.frame_queue.last().expect("a print preview was queued").tag;
|
||
assert_ne!(colour & PRINT_TAG, 0, "a print preview carries the renderer's print bit");
|
||
assert_eq!(colour & GRAYSCALE_TAG, 0, "a colour print preview must not ask for luminance");
|
||
|
||
v.set_print_grayscale(true);
|
||
v.queue_print_previews(&[(0, 1.0)]);
|
||
let gray = v.frame_queue.last().expect("a grayscale print preview was queued").tag;
|
||
assert_ne!(gray & GRAYSCALE_TAG, 0, "a grayscale print preview asks for luminance");
|
||
assert_ne!(gray, colour, "the two print preview modes must not share a tag");
|
||
}
|
||
|
||
#[test]
|
||
fn thumbnails_release_on_scroll_panel_close_and_request_again_after_eviction() {
|
||
let ctx = egui::Context::default();
|
||
let info = thumbnail_info(10_000);
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
v.need_thumbnail(0, true);
|
||
let first = v.prepare_render_queue(&info, 1.0);
|
||
v.last_queue = first.clone();
|
||
assert_eq!(v.receive_result(&ctx, solid_result(first[0], 132, 171)), 132 * 171 * 4);
|
||
assert!(v.prepare_render_queue(&info, 1.0).is_empty());
|
||
v.begin_render_frame();
|
||
v.need_thumbnail(9999, true);
|
||
v.prepare_render_queue(&info, 1.0);
|
||
assert!(v.thumbs.is_empty(), "jump releases old handles");
|
||
v.begin_render_frame();
|
||
v.need_thumbnail(0, true);
|
||
assert_eq!(v.prepare_render_queue(&info, 1.0), first, "an evicted result must be requested again");
|
||
v.begin_render_frame();
|
||
assert!(v.prepare_render_queue(&info, 1.0).is_empty(), "closing consumers cancels thumbnail work");
|
||
}
|
||
|
||
#[test]
|
||
fn closing_the_last_thumbnail_consumer_releases_thumbnails_while_a_visible_page_renders() {
|
||
let ctx = egui::Context::default();
|
||
let info = thumbnail_info(1);
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
v.need_thumbnail(0, true);
|
||
let first = v.prepare_render_queue(&info, 1.0);
|
||
v.last_queue = first.clone();
|
||
assert_eq!(v.receive_result(&ctx, solid_result(first[0], 132, 171)), 132 * 171 * 4);
|
||
let main = RenderRequest { scale: 1.0, tag: 1000, ..Default::default() };
|
||
v.begin_render_frame();
|
||
v.need_thumbnail(0, true);
|
||
v.frame_queue = vec![main];
|
||
v.frame_visible.insert(0);
|
||
assert_eq!(v.prepare_render_queue(&info, 1.0), vec![main]);
|
||
assert!(!v.thumbs.is_empty(), "a thumbnail still shown waits for the page");
|
||
v.begin_render_frame();
|
||
v.frame_queue = vec![main];
|
||
v.frame_visible.insert(0);
|
||
v.stale_thumbs.insert(0);
|
||
assert_eq!(v.prepare_render_queue(&info, 1.0), vec![main]);
|
||
assert!(v.thumbs.is_empty(), "nothing shows it any more, whatever the page is doing");
|
||
assert!(v.stale_thumbs.is_empty(), "nor is it remembered as out of date");
|
||
// Shown again once the page is drawn: the thumbnail is asked for afresh.
|
||
v.last_queue = vec![main];
|
||
assert!(v.receive_result(&ctx, solid_result(main, 100, 130)) > 0);
|
||
v.begin_render_frame();
|
||
v.frame_visible.insert(0);
|
||
v.need_thumbnail(0, true);
|
||
let queue = v.prepare_render_queue(&info, 1.0);
|
||
assert!(queue.iter().any(|r| r.page == 0 && r.tag & THUMB_TAG != 0), "the thumbnail is asked for again: {queue:?}");
|
||
}
|
||
|
||
/// While anything still shows a thumbnail, the thumbnails all wait for the visible page, as before:
|
||
/// only the last consumer closing lets them go early.
|
||
#[test]
|
||
fn a_thumbnail_still_shown_keeps_the_others_while_a_visible_page_renders() {
|
||
let ctx = egui::Context::default();
|
||
// Pages 0 and 3 are the same size.
|
||
let info = thumbnail_info(4);
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
v.need_thumbnail(0, true);
|
||
v.need_thumbnail(3, true);
|
||
let first = v.prepare_render_queue(&info, 1.0);
|
||
v.last_queue = first.clone();
|
||
for r in &first {
|
||
assert!(v.receive_result(&ctx, solid_result(*r, 132, 171)) > 0, "thumbnail of page {} taken", r.page);
|
||
}
|
||
assert_eq!(v.thumbs.len(), 2);
|
||
let main = RenderRequest { scale: 1.0, tag: 1000, ..Default::default() };
|
||
v.begin_render_frame();
|
||
v.need_thumbnail(0, true);
|
||
v.frame_queue = vec![main];
|
||
v.frame_visible.insert(0);
|
||
assert_eq!(v.prepare_render_queue(&info, 1.0), vec![main]);
|
||
assert!(v.thumbs.contains_key(&0) && v.thumbs.contains_key(&3), "both wait for the page");
|
||
}
|
||
|
||
#[test]
|
||
fn stale_results_are_rejected_before_conversion_after_dpi_invalidation_and_suspend() {
|
||
let ctx = egui::Context::default();
|
||
let info = thumbnail_info(1);
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
v.need_thumbnail(0, true);
|
||
let first = v.prepare_render_queue(&info, 1.0)[0];
|
||
v.last_queue = v.prepare_render_queue(&info, 2.0);
|
||
// Valid pixels: only the request gate can keep these out (a size check can't).
|
||
let obsolete = || solid_result(first, 132, 171);
|
||
assert_eq!(v.receive_result(&ctx, obsolete()), 0);
|
||
assert!(v.thumbs.is_empty(), "a result for the old DPI is not uploaded");
|
||
// The same result is uploaded while its request is current.
|
||
v.last_queue = vec![first];
|
||
assert_eq!(v.receive_result(&ctx, obsolete()), 132 * 171 * 4);
|
||
v.thumbs.clear();
|
||
v.invalidate_content();
|
||
assert_eq!(v.receive_result(&ctx, obsolete()), 0);
|
||
assert!(v.thumbs.is_empty(), "nor after the content changed");
|
||
let pool = RenderPool::new_inline(Arc::new(Vec::new()), Default::default());
|
||
v.last_queue = vec![first];
|
||
v.suspend_rendering(&pool);
|
||
assert_eq!(v.receive_result(&ctx, obsolete()), 0);
|
||
assert!(v.thumbs.is_empty(), "nor after the tab was suspended");
|
||
}
|
||
|
||
#[test]
|
||
fn editing_after_layout_preserves_stale_page_until_replacement() {
|
||
let ctx = egui::Context::default();
|
||
let info = thumbnail_info(1);
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
let req = RenderRequest { scale: 1.0, tag: 1000, ..Default::default() };
|
||
v.last_queue = vec![req];
|
||
v.receive_result(&ctx, solid_result(req, 10, 10));
|
||
v.frame_queue = vec![req];
|
||
v.page_changed(0);
|
||
assert_eq!(v.prepare_render_queue(&info, 1.0), vec![req]);
|
||
assert_eq!(v.pages.get(&0).unwrap().tag, STALE_TAG);
|
||
}
|
||
|
||
/// #1858: a released drawing stays painted from its points until a raster that has it
|
||
/// arrives. A page raster that lands before the drawing's edit is applied (in full screen the
|
||
/// edit waits for the next frame, and a render already under way can finish first) is of the
|
||
/// document without it, and must not end the preview.
|
||
#[test]
|
||
fn a_raster_from_before_a_drawing_is_committed_keeps_it_drawn() {
|
||
let ctx = egui::Context::default();
|
||
let info = thumbnail_info(1);
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
let req = RenderRequest { scale: 1.0, tag: 1000, ..Default::default() };
|
||
v.comments.settle(0.0, 0, crate::comments::CommentTool::Ink, &[[10.0, 10.0], [40.0, 30.0]]);
|
||
v.last_queue = vec![req];
|
||
assert!(v.receive_result(&ctx, solid_result(req, 10, 10)) > 0, "the raster is taken");
|
||
assert_eq!(v.comments.settling_len(), 1, "a raster from before the commit doesn't have the stroke");
|
||
v.comments.drawings_committed();
|
||
v.last_queue = vec![req];
|
||
assert!(v.receive_result(&ctx, solid_result(req, 10, 10)) > 0);
|
||
assert_eq!(v.comments.settling_len(), 0, "a raster after the commit draws it");
|
||
// An edit that fails leaves nothing to wait for.
|
||
v.comments.settle(0.0, 0, crate::comments::CommentTool::Ink, &[[10.0, 10.0], [40.0, 30.0]]);
|
||
v.comments.drawings_refused();
|
||
assert_eq!(v.comments.settling_len(), 0, "a refused drawing isn't left painted");
|
||
}
|
||
|
||
#[test]
|
||
fn page_and_tile_admission_accounts_for_bytes_before_count_limits() {
|
||
let mut info = thumbnail_info(40);
|
||
for p in &mut info.pages {
|
||
p.width = 4000.0;
|
||
p.height = 4000.0;
|
||
}
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
v.frame_queue = (0..40).map(|page| RenderRequest { page, scale: 1.0, tag: 1000, ..Default::default() }).collect();
|
||
let queue = v.prepare_render_queue(&info, 1.0);
|
||
assert_eq!(queue.len(), 2, "128 MiB admits two 4000-square pages, not 24");
|
||
v.frame_queue = (0..40)
|
||
.map(|page| RenderRequest { page, scale: 1.0, tag: 1000, tile: Some(Tile { x: 0, y: 0, w: 1024, h: 1024 }), ..Default::default() })
|
||
.collect();
|
||
assert_eq!(v.prepare_render_queue(&info, 1.0).len(), 32);
|
||
assert!(!v.admit_texture(RenderRequest::default(), PAGE_BYTES + 1));
|
||
}
|
||
|
||
/// The frame demand of a continuous view showing `shown`, with its prefetch, in queue order.
|
||
fn scroll_demand(v: &DocView, shown: &[usize]) -> Vec<RenderRequest> {
|
||
let mut wanted: Vec<(usize, f32, u64, Option<Tile>)> = shown.iter().map(|&p| (p, 1.0, 1000, None)).collect();
|
||
wanted.extend(scroll_prefetch(v, shown).into_iter().map(|p| (p, 1.0, 1000, None)));
|
||
order_demand(v, &mut wanted, shown);
|
||
wanted.into_iter().map(|(page, scale, tag, tile)| RenderRequest { page, scale, tag, tile, ..Default::default() }).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn continuous_view_prefetches_the_rows_ahead_of_the_way_it_moved() {
|
||
let mut v = view(20, PageLayout::Single, true);
|
||
v.current = 10;
|
||
assert!(scroll_prefetch(&v, &[9, 10]).is_empty(), "nothing before the view moves");
|
||
v.scroll_dir = 1;
|
||
assert_eq!(scroll_prefetch(&v, &[9, 10]), [11, 12]);
|
||
v.scroll_dir = -1;
|
||
assert_eq!(scroll_prefetch(&v, &[9, 10]), [8, 7]);
|
||
assert_eq!(scroll_prefetch(&v, &[0, 1]), Vec::<usize>::new(), "nothing above the first page");
|
||
let mut two = view(20, PageLayout::TwoUp, true);
|
||
two.scroll_dir = 1;
|
||
assert_eq!(scroll_prefetch(&two, &[8, 9]), [10, 11, 12, 13], "two rows of two pages");
|
||
let mut single = view(20, PageLayout::Single, false);
|
||
single.scroll_dir = 1;
|
||
assert!(scroll_prefetch(&single, &[9]).is_empty(), "single-page view keeps its own prefetch");
|
||
}
|
||
|
||
#[test]
|
||
fn continuous_two_page_prefetch_counts_rows_from_the_end_of_the_row_shown() {
|
||
let mut two = view(20, PageLayout::TwoUp, true);
|
||
two.scroll_dir = 1;
|
||
assert_eq!(scroll_prefetch(&two, &[8]), [9, 10, 11, 12, 13], "the right page off to the side, then two rows");
|
||
two.scroll_dir = -1;
|
||
assert_eq!(scroll_prefetch(&two, &[9]), [8, 7, 6, 5, 4], "the left page off to the side, then two rows");
|
||
two.cover = true;
|
||
two.scroll_dir = 1;
|
||
assert_eq!(scroll_prefetch(&two, &[0]), [1, 2, 3, 4], "the cover is a row of its own");
|
||
assert_eq!(scroll_prefetch(&two, &[3]), [4, 5, 6, 7, 8]);
|
||
two.scroll_dir = -1;
|
||
assert_eq!(scroll_prefetch(&two, &[4]), [3, 2, 1, 0]);
|
||
}
|
||
|
||
#[test]
|
||
fn scrolling_the_other_way_drops_the_prefetch_from_the_queue() {
|
||
let info = DocInfo {
|
||
pages: (0..20)
|
||
.map(|_| pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 })
|
||
.collect(),
|
||
..Default::default()
|
||
};
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults { continuous: true, ..Default::default() });
|
||
v.current = 10;
|
||
v.scroll_dir = 1;
|
||
v.frame_visible = [9, 10].into();
|
||
v.frame_queue = scroll_demand(&v, &[9, 10]);
|
||
let down: Vec<usize> = v.prepare_render_queue(&info, 1.0).iter().map(|r| r.page).collect();
|
||
assert_eq!(down, [10, 9, 11, 12], "on-screen pages first, then the pages below");
|
||
v.scroll_dir = -1;
|
||
v.frame_queue = scroll_demand(&v, &[9, 10]);
|
||
let up: Vec<usize> = v.prepare_render_queue(&info, 1.0).iter().map(|r| r.page).collect();
|
||
assert_eq!(up, [10, 9, 8, 7], "the pages below leave the queue, which cancels their renders");
|
||
}
|
||
|
||
#[test]
|
||
fn the_way_the_view_moved_does_not_reorder_the_pages_on_screen() {
|
||
let mut v = view(20, PageLayout::Single, true);
|
||
v.current = 10;
|
||
let shown = [8, 9, 10, 11, 12];
|
||
let still: Vec<usize> = scroll_demand(&v, &shown).iter().map(|r| r.page).collect();
|
||
v.scroll_dir = 1;
|
||
let down: Vec<usize> = scroll_demand(&v, &shown).iter().map(|r| r.page).collect();
|
||
assert_eq!(down[..5], still[..5], "on-screen pages keep their order");
|
||
assert_eq!(down[5..], [13, 14]);
|
||
// Off-screen pages ahead of the move come before nearer ones behind it.
|
||
let mut wanted = vec![(9, 1.0, 1000, None), (13, 1.0, 1000, None), (10, 1.0, 1000, None)];
|
||
order_demand(&v, &mut wanted, &[10]);
|
||
assert_eq!(wanted.iter().map(|w| w.0).collect::<Vec<_>>(), [10, 13, 9]);
|
||
}
|
||
|
||
#[test]
|
||
fn the_prefetch_ahead_stays_within_the_page_allowance() {
|
||
let mut info = thumbnail_info(20);
|
||
for p in &mut info.pages {
|
||
p.width = 2000.0;
|
||
p.height = 2000.0;
|
||
}
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults { continuous: true, ..Default::default() });
|
||
v.current = 3;
|
||
v.scroll_dir = 1;
|
||
// Eight 16 MB pages on screen leave no room in the 128 MiB allowance for another.
|
||
let shown: Vec<usize> = (0..8).collect();
|
||
v.frame_visible = shown.iter().copied().collect();
|
||
v.frame_queue = scroll_demand(&v, &shown);
|
||
assert!(v.frame_queue.iter().any(|r| r.page == 8), "the prefetch asks for the next page");
|
||
let pages: Vec<usize> = v.prepare_render_queue(&info, 1.0).iter().map(|r| r.page).collect();
|
||
assert_eq!(pages.iter().copied().max(), Some(7), "no prefetch past the allowance: {pages:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn an_arriving_raster_evicts_pages_nobody_asks_for_and_then_pages_behind_the_scroll() {
|
||
let ctx = egui::Context::default();
|
||
ctx.input_mut(|i| i.max_texture_side = 4096);
|
||
let mut v = view(20, PageLayout::Single, true);
|
||
let texture = |name| ctx.load_texture(name, egui::ColorImage::filled([4000, 4000], Color32::WHITE), TextureOptions::LINEAR);
|
||
v.current = 10;
|
||
v.scroll_dir = 1;
|
||
v.frame_visible = [10].into();
|
||
// A prefetch off screen: a raster arriving on screen would widen the allowance to hold it.
|
||
let page = RenderRequest { page: 11, scale: 1.0, tag: 1000, ..Default::default() };
|
||
let bytes = rgba_bytes(4000, 4000);
|
||
// Page 7 is behind the scroll and no longer asked for; page 14 is the prefetch ahead.
|
||
v.frame_demanded = [10, 11, 14].into();
|
||
v.pages.insert(7, PageTex { tag: 1000, tex: texture("behind") });
|
||
v.pages.insert(14, PageTex { tag: 1000, tex: texture("ahead") });
|
||
assert!(v.admit_texture(page, bytes));
|
||
assert!(v.pages.contains_key(&14) && !v.pages.contains_key(&7), "the page nobody asks for goes first");
|
||
// Both asked for: the page behind the scroll goes, though it is nearer.
|
||
v.frame_demanded = [7, 10, 11, 14].into();
|
||
v.pages.insert(7, PageTex { tag: 1000, tex: texture("behind") });
|
||
assert!(v.admit_texture(page, bytes));
|
||
assert!(v.pages.contains_key(&14) && !v.pages.contains_key(&7), "the page behind the scroll goes first");
|
||
}
|
||
|
||
#[test]
|
||
fn a_tiled_page_ahead_keeps_its_sharper_raster() {
|
||
let ctx = egui::Context::default();
|
||
let mut v = view(20, PageLayout::Single, true);
|
||
let p = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 };
|
||
let (scale, max_side) = (12.0, 16384.0);
|
||
let backdrop = BASE_SIDE / 400.0;
|
||
// At 12× the page is 4800 pixels tall: tiled, with a 2048-pixel backdrop.
|
||
assert_eq!(prefetch_demand(&v, &p, 3, scale, scale_tag(scale), max_side), (3, backdrop, scale_tag(backdrop), None));
|
||
assert_eq!(prefetch_demand(&v, &p, 3, 2.0, scale_tag(2.0), max_side), (3, 2.0, scale_tag(2.0), None), "not tiled: the page as drawn");
|
||
let texture = |name| ctx.load_texture(name, egui::ColorImage::filled([1, 1], Color32::WHITE), TextureOptions::LINEAR);
|
||
v.pages.insert(3, PageTex { tag: scale_tag(8.0), tex: texture("sharper") });
|
||
assert_eq!(prefetch_demand(&v, &p, 3, scale, scale_tag(scale), max_side), (3, 8.0, scale_tag(8.0), None), "the sharper raster is kept");
|
||
v.pages.insert(3, PageTex { tag: scale_tag(2.0), tex: texture("softer") });
|
||
assert_eq!(prefetch_demand(&v, &p, 3, scale, scale_tag(scale), max_side), (3, backdrop, scale_tag(backdrop), None));
|
||
v.pages.insert(3, PageTex { tag: STALE_TAG, tex: texture("stale") });
|
||
assert_eq!(prefetch_demand(&v, &p, 3, scale, scale_tag(scale), max_side), (3, backdrop, scale_tag(backdrop), None));
|
||
}
|
||
|
||
#[test]
|
||
fn a_zoom_or_layout_change_clears_the_scroll_direction() {
|
||
assert_eq!(scroll_direction(0, 40.0, false), 1);
|
||
assert_eq!(scroll_direction(1, -40.0, false), -1);
|
||
assert_eq!(scroll_direction(-1, 0.2, false), -1, "a still view keeps its direction");
|
||
assert_eq!(scroll_direction(1, -300.0, true), 0, "a zoom out moves the offset up without a scroll");
|
||
assert_eq!(scroll_direction(-1, 300.0, true), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn on_screen_pages_and_tiles_are_admitted_beyond_the_prefetch_allowance() {
|
||
let ctx = egui::Context::default();
|
||
let mut info = thumbnail_info(40);
|
||
for p in &mut info.pages {
|
||
p.width = 2000.0;
|
||
p.height = 2000.0;
|
||
}
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
// Ten 16 MB pages on screen (a zoomed-out HiDPI view) exceed the 128 MiB allowance.
|
||
v.frame_visible = (0..10).collect();
|
||
v.frame_queue = (0..40).map(|page| RenderRequest { page, scale: 1.0, tag: 1000, ..Default::default() }).collect();
|
||
let queue = v.prepare_render_queue(&info, 1.0);
|
||
let pages: Vec<usize> = queue.iter().map(|r| r.page).collect();
|
||
assert_eq!(pages, (0..10).collect::<Vec<_>>(), "every on-screen page, and no prefetch past the allowance");
|
||
v.last_queue = queue.clone();
|
||
for req in &queue {
|
||
assert!(v.receive_result(&ctx, solid_result(*req, 2000, 2000)) > 0, "on-screen page {} uploaded", req.page);
|
||
}
|
||
assert_eq!(v.pages.len(), 10, "none evicted another on-screen page");
|
||
// A 5K viewport needs more 1024-pixel tiles than the tile allowance holds.
|
||
v.frame_queue = (0..40)
|
||
.map(|x| RenderRequest { page: 0, scale: 1.0, tag: 1000, tile: Some(Tile { x: x * TILE, y: 0, w: 1024, h: 1024 }), ..Default::default() })
|
||
.collect();
|
||
assert_eq!(v.prepare_render_queue(&info, 1.0).len(), 40);
|
||
}
|
||
|
||
/// `count` pages that each make a 2,980 x 3,850 raster (about 43.8 MiB, a Letter page at fit
|
||
/// width in a 3440 x 1440 window) at scale `1 / size`.
|
||
fn fit_width_pages(count: usize, size: f32) -> DocInfo {
|
||
let mut info = thumbnail_info(count);
|
||
for p in &mut info.pages {
|
||
p.width = 2980.0 * size;
|
||
p.height = 3850.0 * size;
|
||
}
|
||
info
|
||
}
|
||
|
||
#[test]
|
||
fn large_pages_on_screen_widen_the_allowance_for_the_pages_around_them() {
|
||
// A wide window at fit width, or smaller pages zoomed in: the raster's size is what counts.
|
||
for (size, scale) in [(1.0, 1.0), (0.5, 2.0)] {
|
||
let info = fit_width_pages(5, size);
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
v.frame_visible = HashSet::from([3, 4]);
|
||
v.frame_queue = (0..5).map(|page| RenderRequest { page, scale, tag: 1000, ..Default::default() }).collect();
|
||
let pages: Vec<usize> = v.prepare_render_queue(&info, 1.0).iter().map(|r| r.page).collect();
|
||
assert_eq!(pages, (0..5).collect::<Vec<_>>(), "two pages on screen and three off screen at scale {scale}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn tiled_pages_on_screen_keep_the_128_mib_page_allowance() {
|
||
let info = fit_width_pages(8, 1.0);
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
v.frame_visible = HashSet::from([3, 4]);
|
||
// Pages 3 and 4 are on screen as a backdrop and tiles, beside whole pages off screen.
|
||
let mut queue: Vec<RenderRequest> = [3, 4]
|
||
.into_iter()
|
||
.flat_map(|page| {
|
||
[
|
||
RenderRequest { page, scale: 0.25, tag: 2000, ..Default::default() },
|
||
RenderRequest { page, scale: 1.0, tag: 1000, tile: Some(Tile { x: 0, y: 0, w: 1024, h: 1024 }), ..Default::default() },
|
||
]
|
||
})
|
||
.collect();
|
||
queue.extend([2, 5, 1, 6, 0, 7].map(|page| RenderRequest { page, scale: 1.0, tag: 1000, ..Default::default() }));
|
||
v.frame_queue = queue;
|
||
let pages: Vec<usize> = v.prepare_render_queue(&info, 1.0).iter().filter(|r| r.tile.is_none()).map(|r| r.page).collect();
|
||
assert_eq!(pages, vec![3, 4, 2, 5], "two backdrops and two whole pages fit in 128 MiB");
|
||
}
|
||
|
||
#[test]
|
||
fn rasters_on_screen_size_the_allowance_for_arriving_pages() {
|
||
let ctx = egui::Context::default();
|
||
ctx.begin_pass(egui::RawInput { max_texture_side: Some(4096), ..Default::default() });
|
||
let info = fit_width_pages(8, 1.0);
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
let off_screen = RenderRequest { page: 0, scale: 1.0, tag: 1000, ..Default::default() };
|
||
let mib = 1024 * 1024;
|
||
assert!(!v.admit_texture(off_screen, 150 * mib), "with nothing on screen the allowance is 128 MiB");
|
||
v.frame_visible = HashSet::from([3, 4]);
|
||
v.last_queue = [3, 4].map(|page| RenderRequest { page, scale: 1.0, tag: 1000, ..Default::default() }).to_vec();
|
||
for req in v.last_queue.clone() {
|
||
assert!(v.receive_result(&ctx, solid_result(req, 2980, 3850)) > 0, "on-screen page {} uploaded", req.page);
|
||
}
|
||
assert!(v.admit_texture(off_screen, 150 * mib), "two 43.8 MiB pages on screen allow 350 MiB, or 256 MiB on the web");
|
||
assert!(!v.admit_texture(off_screen, 400 * mib), "past the largest allowance");
|
||
ctx.end_pass().textures_delta.clear();
|
||
}
|
||
|
||
#[test]
|
||
fn a_raster_held_on_screen_without_a_request_sizes_the_allowance() {
|
||
let ctx = egui::Context::default();
|
||
ctx.begin_pass(egui::RawInput { max_texture_side: Some(4096), ..Default::default() });
|
||
let info = fit_width_pages(8, 1.0);
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
v.frame_visible = HashSet::from([3]);
|
||
let req = RenderRequest { page: 3, scale: 1.0, tag: 1000, ..Default::default() };
|
||
v.last_queue = vec![req];
|
||
assert!(v.receive_result(&ctx, solid_result(req, 2980, 3850)) > 0);
|
||
// A zoom hold leaves page 3's request out while its raster stays on screen.
|
||
v.frame_queue = [2, 4, 1].map(|page| RenderRequest { page, scale: 1.0, tag: 1000, ..Default::default() }).to_vec();
|
||
let pages: Vec<usize> = v.prepare_render_queue(&info, 1.0).iter().map(|r| r.page).collect();
|
||
assert_eq!(pages, vec![2, 4, 1]);
|
||
assert!(v.pages.contains_key(&3), "the held raster is kept beside the prefetch");
|
||
ctx.end_pass().textures_delta.clear();
|
||
}
|
||
|
||
#[test]
|
||
fn the_page_allowance_is_eight_on_screen_rasters_within_its_limits() {
|
||
let mib = 1024 * 1024;
|
||
assert_eq!(cache::page_budget(0), PAGE_BYTES);
|
||
assert_eq!(cache::page_budget(10 * mib), PAGE_BYTES);
|
||
assert_eq!(cache::page_budget(20 * mib), 160 * mib);
|
||
let ceiling = cache::page_budget(usize::MAX);
|
||
assert_eq!(cache::page_budget(64 * mib), ceiling);
|
||
assert_eq!(ceiling, if cfg!(target_arch = "wasm32") { 256 * mib } else { 384 * mib });
|
||
}
|
||
|
||
#[test]
|
||
fn a_page_turned_back_to_is_still_cached() {
|
||
let ctx = egui::Context::default();
|
||
let info = thumbnail_info(10);
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
let req = |page| RenderRequest { page, scale: 1.0, tag: 1000, ..Default::default() };
|
||
v.frame_visible = [0].into();
|
||
v.frame_queue = vec![req(0)];
|
||
v.last_queue = v.prepare_render_queue(&info, 1.0);
|
||
v.receive_result(&ctx, solid_result(req(0), 100, 130));
|
||
// Single-page view moves on to page 1: page 0 is no longer demanded.
|
||
v.begin_render_frame();
|
||
v.current = 1;
|
||
v.frame_visible = [1].into();
|
||
v.frame_queue = vec![req(1)];
|
||
assert_eq!(v.prepare_render_queue(&info, 1.0), vec![req(1)]);
|
||
assert!(v.pages.contains_key(&0), "kept while the allowance has room");
|
||
// And back: nothing to render.
|
||
v.begin_render_frame();
|
||
v.current = 0;
|
||
v.frame_visible = [0].into();
|
||
v.frame_queue = vec![req(0)];
|
||
assert!(v.prepare_render_queue(&info, 1.0).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn visible_pages_render_before_thumbnails_and_text() {
|
||
let ctx = egui::Context::default();
|
||
let info = thumbnail_info(4);
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
let main = RenderRequest { page: 0, scale: 1.0, tag: 1000, ..Default::default() };
|
||
let text = RenderRequest { page: 0, kind: RequestKind::Text, scale: 1.0, tag: TEXT_TAG, ..Default::default() };
|
||
// Opening page 0 queues its pixels and text alongside Pages-panel thumbnails.
|
||
v.begin_render_frame();
|
||
v.frame_visible = [0].into();
|
||
v.frame_queue = vec![main, text];
|
||
for p in 1..4 {
|
||
v.need_thumbnail(p, true);
|
||
}
|
||
let queue = v.prepare_render_queue(&info, 1.0);
|
||
assert_eq!(queue, vec![main], "visible pixels first: no thumbnails or text yet");
|
||
// Once the visible raster arrives, background work is admitted.
|
||
v.last_queue = queue;
|
||
assert!(v.receive_result(&ctx, solid_result(main, 100, 130)) > 0);
|
||
v.begin_render_frame();
|
||
v.frame_visible = [0].into();
|
||
v.frame_queue = vec![text];
|
||
for p in 1..4 {
|
||
v.need_thumbnail(p, true);
|
||
}
|
||
let queue = v.prepare_render_queue(&info, 1.0);
|
||
assert!(queue.iter().any(|r| r.kind == RequestKind::Text), "text admitted after pixels");
|
||
assert!(queue.iter().any(|r| r.tag & THUMB_TAG != 0), "thumbnails admitted after pixels");
|
||
assert!(!queue.iter().any(|r| r.kind == RequestKind::Pixels && r.tag & THUMB_TAG == 0), "no main pixels re-requested");
|
||
// An errored visible page never holds background work back.
|
||
v.errors.insert(0, "gone".to_string());
|
||
v.begin_render_frame();
|
||
v.frame_visible = [0].into();
|
||
v.frame_queue = vec![main, text];
|
||
for p in 1..4 {
|
||
v.need_thumbnail(p, true);
|
||
}
|
||
let queue = v.prepare_render_queue(&info, 1.0);
|
||
assert!(queue.iter().any(|r| r.kind == RequestKind::Text), "text admitted despite the error");
|
||
assert!(queue.iter().any(|r| r.tag & THUMB_TAG != 0), "thumbnails still admitted");
|
||
}
|
||
|
||
#[test]
|
||
fn thumbnail_tags_do_not_change_as_a_row_scrolls_in_or_out() {
|
||
let info = thumbnail_info(10_000);
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
let tags = |v: &mut DocView, cells: usize| {
|
||
v.begin_render_frame();
|
||
for page in 0..cells {
|
||
v.need_thumbnail(page, true);
|
||
}
|
||
v.thumbnail_requests(&info, 2.0).into_iter().map(|r| (r.page, r.tag)).collect::<HashMap<_, _>>()
|
||
};
|
||
// 2x on a large screen: about 70 cells, give or take a row of six.
|
||
let (a, b) = (tags(&mut v, 70), tags(&mut v, 76));
|
||
assert!((0..70).all(|p| a[&p] == b[&p]), "a row more or less re-renders nothing");
|
||
}
|
||
|
||
#[test]
|
||
fn many_open_documents_share_one_raster_allowance_and_close_releases_handles() {
|
||
let ctx = egui::Context::default();
|
||
let mut app = PdfCraftApp::new();
|
||
let bytes = app.session.create_blank(612.0, 792.0, 1).unwrap();
|
||
let mut expected = Vec::new();
|
||
for index in 0..6 {
|
||
app.open_bytes(&format!("synthetic-{index}.pdf"), None, bytes.as_ref().clone()).unwrap();
|
||
let info = app.session.get(app.views.last().unwrap().id).unwrap().info.clone();
|
||
let view = app.views.last_mut().unwrap();
|
||
view.begin_render_frame();
|
||
view.need_thumbnail(0, true);
|
||
let request = view.thumbnail_requests(&info, ctx.pixels_per_point()).into_iter().next().unwrap();
|
||
let page = info.pages.get(request.page).unwrap();
|
||
let dimensions = (device_pixels(page.width, request.scale), device_pixels(page.height, request.scale));
|
||
view.last_queue = vec![request];
|
||
assert_eq!(
|
||
view.receive_result(&ctx, solid_result(request, dimensions.0, dimensions.1)),
|
||
rgba_bytes(dimensions.0 as usize, dimensions.1 as usize)
|
||
);
|
||
assert_eq!(view.thumbs.get(&0).unwrap().tag, request.tag);
|
||
assert_eq!(view.thumbs.get(&0).unwrap().tex.size(), [dimensions.0 as usize, dimensions.1 as usize]);
|
||
|
||
view.begin_render_frame();
|
||
view.need_thumbnail(0, true);
|
||
let queue = view.prepare_render_queue(&info, ctx.pixels_per_point());
|
||
assert!(queue.is_empty(), "the synthetic thumbnail satisfies current demand");
|
||
view.last_queue = queue;
|
||
expected.push((request, dimensions));
|
||
}
|
||
assert_eq!(app.views.len(), 6, "all six documents were opened");
|
||
assert_eq!(app.active_ids().map(|(index, _)| index), Some(5));
|
||
assert!(app.views.iter().zip(&expected).all(|(view, (request, dimensions))| {
|
||
view.thumbs.len() == 1
|
||
&& view.thumbs.get(&0).is_some_and(|thumb| thumb.tag == request.tag && thumb.tex.size() == [dimensions.0 as usize, dimensions.1 as usize])
|
||
}));
|
||
|
||
app.finish_render_frame(&ctx);
|
||
let total: usize = app.views.iter().map(|v| v.thumbs.values().map(|p| texture_bytes(&p.tex)).sum::<usize>()).sum();
|
||
let (active_request, active_dimensions) = expected.last().unwrap();
|
||
let active_bytes = rgba_bytes(active_dimensions.0 as usize, active_dimensions.1 as usize);
|
||
assert_eq!(total, active_bytes, "only the active view retains a thumbnail");
|
||
assert!(total <= THUMB_BYTES, "retained thumbnails stay within the existing allowance");
|
||
assert!(app.views.iter().take(5).all(|v| v.thumbs.is_empty() && v.last_queue.is_empty()));
|
||
let active = app.views.last().unwrap();
|
||
assert_eq!(active.thumbs.len(), 1);
|
||
assert_eq!(active.thumbs.get(&0).unwrap().tag, active_request.tag);
|
||
assert!(active.last_queue.is_empty(), "the current thumbnail is already cached");
|
||
for view in &app.views {
|
||
let doc = app.session.get(view.id).unwrap();
|
||
assert_eq!(doc.renderer.stats().render_requests, 0, "the fixture must not launch a native render");
|
||
}
|
||
|
||
app.close_tab(5);
|
||
assert_eq!(app.views.len(), 5, "closing the active tab drops its owning view");
|
||
assert!(app.views.iter().all(|v| v.thumbs.is_empty()));
|
||
}
|
||
|
||
#[test]
|
||
fn suspended_signature_preview_releases_layers_and_rebuilds_without_editing() {
|
||
let ctx = egui::Context::default();
|
||
let mut png = std::io::Cursor::new(Vec::new());
|
||
image::RgbaImage::from_pixel(12, 4, image::Rgba([20, 30, 40, 255])).write_to(&mut png, image::ImageFormat::Png).unwrap();
|
||
let image = pdfcraft_engine::SignatureImage::from_bytes(png.get_ref()).unwrap();
|
||
let mut session = pdfcraft_engine::Session::new();
|
||
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
|
||
let id = session.open_new("signature-preview.pdf", blank).unwrap();
|
||
let info = session.get(id).unwrap().info.pages[0].clone();
|
||
session.apply(id, image.edit(0, &info, [20.0, 100.0], false, "Test").unwrap()).unwrap();
|
||
let doc = session.get(id).unwrap();
|
||
let bytes = doc.bytes.clone();
|
||
let generation = doc.edit_generation();
|
||
let mut v = DocView::new(id, &doc.info, ViewDefaults::default());
|
||
v.comments.selected = Some((0, 0));
|
||
v.signature_drag.prepare(&ctx, doc, v.comments.selected, 1.0);
|
||
assert!(v.signature_drag.contains(0, 0));
|
||
|
||
v.suspend_rendering(&doc.renderer);
|
||
assert!(!v.signature_drag.contains(0, 0), "inactive tabs release the preview pool and textures");
|
||
assert_eq!(v.comments.selected, Some((0, 0)), "the user's selection survives suspension");
|
||
v.signature_drag.prepare(&ctx, doc, v.comments.selected, 1.0);
|
||
assert!(v.signature_drag.contains(0, 0), "reactivating the view rebuilds the selected image's preview");
|
||
assert!(Arc::ptr_eq(&bytes, &doc.bytes));
|
||
assert_eq!(doc.edit_generation(), generation);
|
||
assert_eq!(doc.can_undo(), Some("Add signature"));
|
||
assert!(doc.dirty);
|
||
}
|
||
|
||
#[test]
|
||
fn suspending_a_view_releases_owned_raster_textures_and_queue_state() {
|
||
let ctx = egui::Context::default();
|
||
let mut view = view(2, PageLayout::Single, true);
|
||
let texture = |name| ctx.load_texture(name, egui::ColorImage::filled([1, 1], Color32::WHITE), TextureOptions::LINEAR);
|
||
let request = RenderRequest { page: 0, scale: 1.0, tag: 1, ..Default::default() };
|
||
view.pages.insert(0, PageTex { tag: request.tag, tex: texture("suspended-page") });
|
||
view.thumbs.insert(1, PageTex { tag: THUMB_TAG, tex: texture("suspended-thumbnail") });
|
||
view.tiles.insert((0, request.tag, 0, 0), texture("suspended-tile"));
|
||
view.grid_pages.insert(1, (1, texture("suspended-grid-page")));
|
||
view.print_pages.insert(1, (PRINT_TAG, texture("suspended-print-page")));
|
||
view.stale_thumbs.insert(1);
|
||
view.waiting_since.insert(0, 1.0);
|
||
view.last_queue = vec![request];
|
||
view.frame_queue = vec![request];
|
||
view.frame_visible.insert(0);
|
||
view.need_thumbnail(1, true);
|
||
|
||
let pool = RenderPool::new_inline(Arc::new(Vec::new()), Default::default());
|
||
view.suspend_rendering(&pool);
|
||
|
||
assert!(view.pages.is_empty(), "page handles are released");
|
||
assert!(view.thumbs.is_empty(), "thumbnail handles are released");
|
||
assert!(view.tiles.is_empty(), "tile handles are released");
|
||
assert!(view.grid_pages.is_empty(), "organize-grid handles are released");
|
||
assert!(view.print_pages.is_empty(), "print-preview handles are released");
|
||
assert!(view.stale_thumbs.is_empty(), "stale thumbnail bookkeeping is cleared");
|
||
assert!(view.waiting_since.is_empty(), "waiting bookkeeping is cleared");
|
||
assert!(view.last_queue.is_empty() && !view.render_pending(), "queued demand is retired");
|
||
assert!(view.frame_queue.is_empty() && view.frame_visible.is_empty() && view.thumb_demand.is_empty());
|
||
}
|
||
|
||
/// Edit text & images keeps its boxes while page input is blocked (the window unfocused, a
|
||
/// dialog or the palette open): the page's paragraphs are still measured for the latest edit
|
||
/// and drawn, instead of vanishing until the pointer comes back.
|
||
#[test]
|
||
fn edit_text_boxes_stay_drawn_while_page_input_is_blocked() {
|
||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
let bytes = app.session.create_blank(300.0, 400.0, 1).unwrap();
|
||
app.open_bytes("blank.pdf", None, bytes.as_ref().clone()).unwrap();
|
||
app.quick_tool = QuickTool::EditText;
|
||
app
|
||
});
|
||
h.run_steps(3);
|
||
// Block page input the way the window losing focus does.
|
||
h.state_mut().palette_open = true;
|
||
h.run_steps(2);
|
||
assert!(h.state_mut().apply_edit(Edit::RotatePages { pages: vec![0], degrees: 90 }));
|
||
h.run_steps(3);
|
||
let app = h.state();
|
||
let generation = app.session.get(app.views[0].id).unwrap().edit_generation();
|
||
assert_eq!(app.views[0].edit_lines.get(&0).map(|(g, _)| *g), Some(generation), "the boxes follow the edit while input is blocked");
|
||
}
|
||
|
||
/// A cut page (#623) is named in a bar of its own: a static XFA form's notice, which comes
|
||
/// first among the form notices, still shows beside it instead of replacing it (review on #2113).
|
||
#[test]
|
||
fn a_cut_page_is_named_beside_an_xfa_form_notice() {
|
||
use egui_kittest::kittest::Queryable;
|
||
|
||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
let bytes = pdfcraft_xfa::fixtures::static_shell("<form1><page1><name>Ada</name></page1></form1>");
|
||
app.open_bytes("form.pdf", None, bytes).unwrap();
|
||
app
|
||
});
|
||
h.run_steps(4);
|
||
h.get_by_label_contains("XFA data are kept in step");
|
||
h.state_mut().views[0].cut_pages.insert(0);
|
||
h.run_steps(2);
|
||
h.get_by_label_contains("Page 1 holds more content than PdfCraft can read");
|
||
h.get_by_label_contains("XFA data are kept in step");
|
||
}
|
||
|
||
/// Dismissing another notice doesn't hide a page found cut later (#623), and dismissing the
|
||
/// cut-page notice lasts until another page is found cut (review on #2113).
|
||
#[test]
|
||
fn a_page_found_cut_after_a_dismissal_is_still_named() {
|
||
use egui_kittest::kittest::Queryable;
|
||
|
||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
let bytes = app.session.create_blank(300.0, 400.0, 2).unwrap();
|
||
app.open_bytes("blank.pdf", None, bytes.as_ref().clone()).unwrap();
|
||
app
|
||
});
|
||
h.run_steps(3);
|
||
// The reader dismissed the document's notice before the render found the cut page.
|
||
h.state_mut().views[0].notice_dismissed = true;
|
||
h.state_mut().views[0].cut_pages.insert(0);
|
||
h.run_steps(2);
|
||
h.get_by_label_contains("Page 1 holds more content than PdfCraft can read");
|
||
h.get_by_label("Dismiss").click();
|
||
h.run_steps(2);
|
||
assert!(h.query_by_label_contains("holds more content").is_none(), "the cut-page notice can be dismissed");
|
||
// Re-reading the same page (an undo, a layer toggle) doesn't bring it back.
|
||
h.state_mut().views[0].invalidate_content();
|
||
h.state_mut().views[0].cut_pages.insert(0);
|
||
h.run_steps(2);
|
||
assert!(h.query_by_label_contains("holds more content").is_none(), "the dismissed page stays dismissed");
|
||
h.state_mut().views[0].cut_pages.insert(1);
|
||
h.run_steps(2);
|
||
h.get_by_label_contains("2 pages hold more content than PdfCraft can read, page 1 first");
|
||
}
|
||
|
||
/// Ctrl+wheel zooms a visible window that another application has focus over, as plain
|
||
/// wheel scrolling does; the palette still blocks it.
|
||
#[test]
|
||
fn ctrl_wheel_zooms_an_unfocused_window() {
|
||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
let bytes = app.session.create_blank(300.0, 400.0, 1).unwrap();
|
||
app.open_bytes("blank.pdf", None, bytes.as_ref().clone()).unwrap();
|
||
app
|
||
});
|
||
h.run_steps(3);
|
||
let center = h.state().views[0].viewport_rect().center();
|
||
h.hover_at(center);
|
||
h.input_mut().focused = false;
|
||
h.run_steps(2);
|
||
let ctrl_wheel = egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Line,
|
||
delta: vec2(0.0, 1.0),
|
||
phase: egui::TouchPhase::Move,
|
||
modifiers: egui::Modifiers::COMMAND,
|
||
source: egui::MouseWheelSource::Unknown,
|
||
};
|
||
h.state_mut().palette_open = true;
|
||
let before = h.state().views[0].zoom;
|
||
h.event(ctrl_wheel.clone());
|
||
h.run_steps(10);
|
||
assert_eq!(h.state().views[0].zoom, before, "the palette still blocks zoom");
|
||
h.state_mut().palette_open = false;
|
||
h.run_steps(2);
|
||
h.event(ctrl_wheel);
|
||
h.run_steps(10);
|
||
assert!(h.state().views[0].zoom > before, "Ctrl+wheel zooms without window focus");
|
||
}
|
||
|
||
#[test]
|
||
fn virtualized_organizer_keeps_insert_gaps_at_document_positions() {
|
||
use egui_kittest::kittest::Queryable;
|
||
|
||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
let bytes = app.session.create_blank(300.0, 400.0, 100).unwrap();
|
||
app.open_bytes("organize-gaps.pdf", None, bytes.as_ref().clone()).unwrap();
|
||
app.views[0].organize = true;
|
||
app
|
||
});
|
||
h.run_steps(4);
|
||
assert!(h.state().views[0].thumb_demand.len() < 50);
|
||
assert_eq!(h.query_all_by_label("Insert a file at the end").count(), 0, "the last virtualized cell is not the final page");
|
||
let center = h.state().views[0].viewport_rect().center();
|
||
h.hover_at(center);
|
||
h.event(egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: vec2(0.0, -100_000.0),
|
||
phase: egui::TouchPhase::Move,
|
||
modifiers: egui::Modifiers::NONE,
|
||
source: egui::MouseWheelSource::Unknown,
|
||
});
|
||
// Well past egui's longest scroll animation (about 0.3 s).
|
||
h.run_steps(40);
|
||
h.get_by_label("Insert a file at the end");
|
||
let before_last = h.get_by_label("Insert a file before page 100").rect().center();
|
||
h.hover_at(before_last);
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().views[0].grid_gap, Some(99), "external drops use the document index after virtualized scrolling");
|
||
assert!(h.state().views[0].thumb_demand.len() < 50);
|
||
}
|
||
|
||
#[test]
|
||
fn real_shell_pages_organizer_and_print_only_collect_current_thumbnail_consumers() {
|
||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
let bytes = app.session.create_blank(612.0, 792.0, 10_000).unwrap();
|
||
app.open_bytes("synthetic-ten-thousand.pdf", None, bytes.as_ref().clone()).unwrap();
|
||
app.right = Some(RightPanel::Pages);
|
||
app
|
||
});
|
||
h.run_steps(4);
|
||
assert!((1..20).contains(&h.state().views[0].thumb_demand.len()));
|
||
h.state_mut().right = None;
|
||
h.state_mut().views[0].organize = true;
|
||
h.run_steps(2);
|
||
assert!((1..50).contains(&h.state().views[0].thumb_demand.len()));
|
||
h.state_mut().views[0].organize = false;
|
||
h.state_mut().dialog = Some(crate::Dialog::Print);
|
||
h.state_mut().print_draft.sheet = 9999;
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().views[0].thumb_demand.keys().copied().collect::<Vec<_>>(), vec![9999]);
|
||
h.state_mut().print_draft.handling = crate::PrintHandling::Booklet;
|
||
h.state_mut().print_draft.sheet = 0;
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().views[0].thumb_demand.len(), 2, "booklet only needs its two placed pages");
|
||
h.state_mut().dialog = None;
|
||
h.run_steps(2);
|
||
assert!(h.state().views[0].thumb_demand.is_empty());
|
||
assert!(h.state().views[0].thumbs.is_empty());
|
||
}
|
||
|
||
/// One mouse-wheel notch (a line) at `at` seconds.
|
||
fn notch(v: &mut DocView, dy: f32, at: f64) -> bool {
|
||
v.single_page_wheel(egui::MouseWheelUnit::Line, dy, egui::TouchPhase::Move, at, true)
|
||
}
|
||
|
||
#[test]
|
||
fn thumbnails_cover_the_panel_on_a_high_dpi_screen() {
|
||
// Pages panel slot is at most 150 logical points. At 2 px/pt a letter page's thumbnail
|
||
// must be at least that wide, so the panel samples it down instead of stretching it.
|
||
let info = thumbnail_info(1);
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
v.need_thumbnail(0, true);
|
||
let queue = v.prepare_render_queue(&info, 2.0);
|
||
let scale = queue.first().expect("one thumbnail").scale;
|
||
assert!(612.0 * scale >= 150.0 * 2.0, "{scale}");
|
||
}
|
||
|
||
#[test]
|
||
fn notches_turn_pages_and_stop_at_the_ends() {
|
||
let mut v = view(3, PageLayout::Single, false);
|
||
assert!(notch(&mut v, -1.0, 1.0) && notch(&mut v, -1.0, 1.05));
|
||
assert_eq!(v.current, 2);
|
||
assert!(!notch(&mut v, -1.0, 1.1), "nothing turns past the last page");
|
||
assert!(notch(&mut v, 1.0, 1.2));
|
||
assert_eq!(v.current, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn notches_turn_two_page_spreads() {
|
||
// Two-page view, shown one spread at a time (not continuous): a notch turns a whole
|
||
// spread at once, not a single page (#752).
|
||
let mut v = view(4, PageLayout::TwoUp, false);
|
||
assert!(notch(&mut v, -1.0, 1.0));
|
||
assert_eq!(v.current, 2, "a notch turns from the first spread to the second");
|
||
assert!(notch(&mut v, 1.0, 1.1));
|
||
assert_eq!(v.current, 0, "a notch back turns to the previous spread");
|
||
}
|
||
|
||
#[test]
|
||
fn continuous_views_ignore_the_wheel_for_paging() {
|
||
// Scrolling views let the ScrollArea handle the wheel; it never turns pages.
|
||
for layout in [PageLayout::Single, PageLayout::TwoUp] {
|
||
let mut v = view(3, layout, true);
|
||
assert!(!notch(&mut v, -1.0, 1.0), "{layout:?} continuous ignores the wheel");
|
||
assert_eq!(v.current, 0);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn navigation_drops_partial_wheel_motion() {
|
||
use egui::{MouseWheelUnit::Point, TouchPhase::Move, TouchPhase::Start};
|
||
let mut v = view(3, PageLayout::Single, false);
|
||
assert!(!v.single_page_wheel(Point, 0.0, Start, 1.0, true));
|
||
assert!(!v.single_page_wheel(Point, -30.0, Move, 1.02, true));
|
||
v.go_to_page(1);
|
||
// The same touch has to move a full notch's worth again before it turns.
|
||
assert!(!v.single_page_wheel(Point, -30.0, Move, 1.04, true));
|
||
assert!(v.single_page_wheel(Point, -30.0, Move, 1.06, true));
|
||
assert_eq!(v.current, 2);
|
||
}
|
||
|
||
#[test]
|
||
fn reselecting_the_layout_does_not_scroll_to_top() {
|
||
let mut v = view(3, PageLayout::Single, true);
|
||
v.goto = None;
|
||
v.set_layout(PageLayout::Single);
|
||
assert!(v.goto.is_none(), "re-selecting the current layout is a no-op");
|
||
v.set_continuous(true);
|
||
assert!(v.goto.is_none(), "re-selecting the current continuity is a no-op");
|
||
v.set_layout(PageLayout::TwoUp);
|
||
assert_eq!(v.goto, Some((0, 0.0)), "switching the base layout aligns the current page");
|
||
v.goto = None;
|
||
v.set_continuous(false);
|
||
assert_eq!(v.goto, Some((0, 0.0)), "toggling continuous scrolling aligns the current page");
|
||
}
|
||
|
||
#[test]
|
||
fn fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past() {
|
||
let page =
|
||
|width: f32, height: f32| pdfcraft_render::PageInfo { width, height, label: String::new(), crop: [0.0, 0.0, width, height], rotation: 0 };
|
||
let info = DocInfo { pages: vec![page(300.0, 400.0), page(600.0, 800.0)], ..Default::default() };
|
||
for fit in [Fit::Page, Fit::Height] {
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
(v.fit, v.viewport_w, v.viewport_h) = (fit, 1000.0, 800.0);
|
||
v.fit_zoom(&info);
|
||
let zoom = v.zoom;
|
||
v.current = 1;
|
||
v.fit_zoom(&info);
|
||
assert_eq!(v.zoom, zoom, "{fit:?}: scrolling onto a bigger page keeps the zoom");
|
||
// A single page shown on its own (not scrolling) fits each one.
|
||
(v.layout, v.continuous) = (PageLayout::Single, false);
|
||
v.fit_zoom(&info);
|
||
let big = v.zoom;
|
||
v.current = 0;
|
||
v.fit_zoom(&info);
|
||
assert!(v.zoom > big, "{fit:?}: single-page view fits the page it shows");
|
||
}
|
||
}
|
||
|
||
/// Review on #2070: full screen in two-page view fits the spread it shows, the cover page
|
||
/// on its own included; two-page view in the window keeps one zoom for the document.
|
||
#[test]
|
||
fn full_screen_two_page_view_fits_the_spread_it_shows() {
|
||
let page =
|
||
|width: f32, height: f32| pdfcraft_render::PageInfo { width, height, label: String::new(), crop: [0.0, 0.0, width, height], rotation: 0 };
|
||
// Spreads [0, 1] small, [2, 3] large; with a cover page [0], [1, 2] mixed, [3].
|
||
let info = DocInfo { pages: vec![page(300.0, 400.0), page(300.0, 400.0), page(600.0, 800.0), page(600.0, 800.0)], ..Default::default() };
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
(v.layout, v.continuous, v.viewport_w, v.viewport_h) = (PageLayout::TwoUp, false, 1000.0, 800.0);
|
||
v.fit = Fit::Page;
|
||
v.fit_zoom(&info);
|
||
let windowed = v.zoom;
|
||
v.enter_presentation();
|
||
v.fit_zoom(&info);
|
||
let small = v.zoom;
|
||
assert!(small > windowed * 1.9, "the small spread fills the screen: {small} vs {windowed}");
|
||
v.current = 3;
|
||
v.fit_zoom(&info);
|
||
assert!((v.zoom - windowed).abs() < 1e-4, "the large spread fits as before");
|
||
v.current = 1;
|
||
v.fit_zoom(&info);
|
||
assert_eq!(v.zoom, small, "page 2 is in the first spread");
|
||
// With a cover page: [0] alone, then [1, 2] (the large page decides), then [3].
|
||
v.cover = true;
|
||
v.current = 0;
|
||
v.fit_zoom(&info);
|
||
assert_eq!(v.zoom, small, "the cover page alone");
|
||
v.current = 1;
|
||
v.fit_zoom(&info);
|
||
assert!((v.zoom - windowed).abs() < 1e-4, "page 2 shares a spread with the large page 3");
|
||
// Scrolling while in full screen fits the document, as scrolling does in the window.
|
||
(v.continuous, v.current) = (true, 0);
|
||
v.fit_zoom(&info);
|
||
assert!((v.zoom - windowed).abs() < 1e-4, "continuous full screen keeps one zoom");
|
||
v.continuous = false;
|
||
// The cover page is a spread of its own even when the next page is larger.
|
||
let mixed = DocInfo { pages: vec![page(300.0, 400.0), page(600.0, 800.0), page(600.0, 800.0)], ..Default::default() };
|
||
v.current = 0;
|
||
v.fit_zoom(&mixed);
|
||
assert_eq!(v.zoom, small, "the small cover alone, not with page 2");
|
||
// Leaving full screen brings the document-wide fit back.
|
||
v.leave_presentation();
|
||
(v.fit, v.current) = (Fit::Page, 0);
|
||
(v.layout, v.continuous) = (PageLayout::TwoUp, false);
|
||
v.fit_zoom(&info);
|
||
assert!((v.zoom - windowed).abs() < 1e-4, "in the window, one zoom for the document");
|
||
}
|
||
|
||
#[test]
|
||
fn fit_width_fits_the_shown_page_in_single_page_view_only() {
|
||
let page =
|
||
|width: f32, height: f32| pdfcraft_render::PageInfo { width, height, label: String::new(), crop: [0.0, 0.0, width, height], rotation: 0 };
|
||
let info = DocInfo { pages: vec![page(300.0, 400.0), page(600.0, 400.0)], ..Default::default() };
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
(v.fit, v.viewport_w, v.viewport_h) = (Fit::Width, 1000.0, 800.0);
|
||
v.layout = PageLayout::Single;
|
||
v.continuous = false;
|
||
v.fit_zoom(&info);
|
||
let narrow = v.zoom;
|
||
// The shown page fills the width between the side gutters, not centred for the wide page.
|
||
let shown = v.layout(&info, v.content_width(&info, 1000.0), 1.0)[0];
|
||
assert!((shown.left() - SIDE).abs() < 1e-2 && (shown.width() - 860.0).abs() < 1e-2, "{shown:?}");
|
||
v.current = 1;
|
||
v.fit_zoom(&info);
|
||
assert!((narrow / v.zoom - 2.0).abs() < 1e-3, "the narrow page gets twice the zoom of the wide one");
|
||
// Scrolling views keep one zoom for the whole document.
|
||
v.continuous = true;
|
||
v.fit_zoom(&info);
|
||
let wide = v.zoom;
|
||
v.current = 0;
|
||
v.fit_zoom(&info);
|
||
assert_eq!(v.zoom, wide, "continuous fit width holds still across page sizes");
|
||
}
|
||
|
||
#[test]
|
||
fn layouts_match_their_commands_and_names() {
|
||
for l in PageLayout::ORDER {
|
||
let spec = pdfcraft_engine::commands::command(l.command()).expect("registered");
|
||
assert_eq!((l.label(), l.icon()), (spec.label, spec.icon), "{l:?}");
|
||
assert_eq!(PageLayout::from_command(l.command()), Some(l));
|
||
assert_eq!(PageLayout::try_parse(l.as_str()), Some(l));
|
||
}
|
||
assert_eq!(PageLayout::try_parse(" Two-Up "), Some(PageLayout::TwoUp));
|
||
assert_eq!(PageLayout::try_parse("facing"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn a_null_xyz_coordinate_keeps_the_readers_place_on_the_new_page() {
|
||
// CAD exports link each cross-reference as `/XYZ null null 0`. Acrobat keeps the
|
||
// point at the window's top-left (and the zoom) when following one to another sheet.
|
||
let info = pdfcraft_render::DocInfo {
|
||
pages: (0..3)
|
||
.map(|_| pdfcraft_render::PageInfo { width: 842.0, height: 595.0, label: String::new(), crop: [0.0, 0.0, 842.0, 595.0], rotation: 0 })
|
||
.collect(),
|
||
..Default::default()
|
||
};
|
||
// Page 1 at 400 %, scrolled so its point 200 pt across and 125 pt down is at the corner
|
||
// where /XYZ puts its point.
|
||
let zoomed = |rot: u16, size: Vec2| {
|
||
let mut v = view(3, PageLayout::Single, true);
|
||
v.rotation = rot;
|
||
v.viewport_screen = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 600.0));
|
||
v.screen_xforms = vec![(0, PageXform { rect: Rect::from_min_size(pos2(SIDE - 800.0, -500.0), size), rot, pw: 842.0, ph: 595.0 })];
|
||
v
|
||
};
|
||
let near = |a: Option<f32>, b: f32| a.is_some_and(|a| (a - b).abs() < 1e-4);
|
||
let mut v = zoomed(0, vec2(4.0 * 842.0, 4.0 * 595.0));
|
||
v.go_to_dest(1, DestView::Xyz { left: None, top: None, zoom: None }, &info);
|
||
let (page, fx, fy, rel) = v.goto_point.expect("the place is kept");
|
||
assert_eq!((v.current, page, rel), (1, 1, vec2(SIDE, 0.0)));
|
||
assert!(near(fx, 200.0 / 842.0) && near(fy, 125.0 / 595.0), "{fx:?} {fy:?}");
|
||
// A coordinate the destination gives wins; only the null one is kept.
|
||
let mut v = zoomed(0, vec2(4.0 * 842.0, 4.0 * 595.0));
|
||
v.go_to_dest(2, DestView::Xyz { left: Some(421.0), top: None, zoom: Some(4.0) }, &info);
|
||
let (_, fx, fy, _) = v.goto_point.expect("the place is kept");
|
||
assert!(near(fx, 0.5) && near(fy, 125.0 / 595.0), "{fx:?} {fy:?}");
|
||
// With the view turned a quarter, the point under the corner is still the one kept.
|
||
let mut v = zoomed(90, vec2(4.0 * 595.0, 4.0 * 842.0));
|
||
v.go_to_dest(1, DestView::Xyz { left: None, top: None, zoom: None }, &info);
|
||
let (_, fx, fy, _) = v.goto_point.expect("the place is kept");
|
||
assert!(near(fx, 800.0 / (4.0 * 595.0)) && near(fy, 500.0 / (4.0 * 842.0)), "{fx:?} {fy:?}");
|
||
// A page that fits the window keeps the layout's placement: no point, as before.
|
||
let mut v = zoomed(0, vec2(800.0, 565.0));
|
||
v.go_to_dest(1, DestView::Xyz { left: None, top: None, zoom: None }, &info);
|
||
assert_eq!((v.current, v.goto, v.goto_point), (1, Some((1, 0.0)), None));
|
||
}
|
||
|
||
#[test]
|
||
fn destinations_set_the_zoom_mode_and_the_point_to_show() {
|
||
let info = pdfcraft_render::DocInfo {
|
||
pages: (0..3)
|
||
.map(|_| pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 })
|
||
.collect(),
|
||
..Default::default()
|
||
};
|
||
let mut v = view(3, PageLayout::Single, true);
|
||
v.fit = Fit::Width;
|
||
// No position (or a malformed one): the top of the page, zoom as it was.
|
||
v.go_to_dest(1, DestView::Top, &info);
|
||
assert_eq!((v.current, v.goto, v.goto_point, v.fit), (1, Some((1, 0.0)), None, Fit::Width));
|
||
// /XYZ with a null zoom keeps the zoom mode; its point is a quarter down page 3.
|
||
v.go_to_dest(2, DestView::Xyz { left: None, top: Some(300.0), zoom: None }, &info);
|
||
assert_eq!((v.current, v.fit, v.goto_point), (2, Fit::Width, Some((2, None, Some(0.25), vec2(SIDE, 0.0)))));
|
||
v.go_to_dest(0, DestView::Xyz { left: Some(150.0), top: None, zoom: Some(2.5) }, &info);
|
||
assert_eq!((v.fit, v.zoom, v.goto_point), (Fit::None, 2.5, Some((0, Some(0.5), None, vec2(SIDE, 0.0)))));
|
||
// A zoom beyond the view's range is clamped to it.
|
||
v.go_to_dest(0, DestView::Xyz { left: None, top: None, zoom: Some(1e30) }, &info);
|
||
assert_eq!((v.zoom, v.goto_point), (64.0, None));
|
||
v.go_to_dest(0, DestView::Fit, &info);
|
||
assert_eq!((v.fit, v.goto_point), (Fit::Page, None));
|
||
v.go_to_dest(0, DestView::FitH { top: Some(100.0) }, &info);
|
||
assert_eq!((v.fit, v.goto_point), (Fit::Width, Some((0, None, Some(0.75), vec2(SIDE, 0.0)))));
|
||
v.go_to_dest(0, DestView::FitV { left: Some(75.0) }, &info);
|
||
assert_eq!((v.fit, v.goto_point), (Fit::Height, Some((0, Some(0.25), None, vec2(SIDE, 0.0)))));
|
||
// /FitR: the zoom that fits a 100×50 pt rectangle in an 800×600 window, centred.
|
||
(v.viewport_w, v.viewport_h) = (800.0, 600.0);
|
||
v.go_to_dest(0, DestView::FitR { rect: [0.0, 300.0, 100.0, 350.0] }, &info);
|
||
assert_eq!(v.fit, Fit::None);
|
||
assert!((v.zoom - 8.0 / PT).abs() < 1e-4, "{}", v.zoom);
|
||
let (page, fx, fy, rel) = v.goto_point.expect("a point to show");
|
||
assert_eq!((page, rel), (0, vec2(400.0, 300.0)));
|
||
assert!((fx.unwrap_or(-1.0) - 50.0 / 300.0).abs() < 1e-4 && (fy.unwrap_or(-1.0) - 75.0 / 400.0).abs() < 1e-4, "{fx:?} {fy:?}");
|
||
// A page past the end goes to the last page; a page the document info lacks, to its top.
|
||
v.go_to_dest(usize::MAX, DestView::FitH { top: Some(100.0) }, &info);
|
||
assert_eq!((v.current, v.goto_point.map(|g| g.0)), (2, Some(2)));
|
||
let empty = pdfcraft_render::DocInfo::default();
|
||
v.go_to_dest(1, DestView::FitH { top: Some(100.0) }, &empty);
|
||
assert_eq!((v.current, v.goto_point), (1, None));
|
||
}
|
||
|
||
#[test]
|
||
fn page_text_failure_preserves_valid_search_match_without_jitter() {
|
||
let mut v = view(3, PageLayout::Single, true);
|
||
v.current = 1;
|
||
v.find =
|
||
Some(Find { query: "test".into(), case_query: "test".into(), matches: vec![(0, 0..4), (1, 5..9)], current: Some(0), ..Default::default() });
|
||
let req = RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() };
|
||
v.last_queue = vec![req];
|
||
let ctx = egui::Context::default();
|
||
v.receive_result(
|
||
&ctx,
|
||
pdfcraft_render::RenderedPage {
|
||
request: req,
|
||
width: 0,
|
||
height: 0,
|
||
rgba: Default::default(),
|
||
error: Some("extraction failed".into()),
|
||
text: None,
|
||
warnings: Vec::new(),
|
||
millis: 0,
|
||
},
|
||
);
|
||
// Active match was on page 0 which failed: current should fall back to match on page 1 without jitter
|
||
assert_eq!(v.find.as_ref().unwrap().current, Some(0));
|
||
assert_eq!(v.find.as_ref().unwrap().matches, vec![(1, 5..9)]);
|
||
assert!(v.goto.is_none());
|
||
}
|
||
|
||
/// A page whose picture moved can get its whole-page raster back before its last stale tile.
|
||
/// From then on the raster has the picture where it went: it is drawn from there, not from
|
||
/// where it was (which the raster now shows empty), and another drag starts from there too.
|
||
#[test]
|
||
fn a_moved_picture_is_drawn_from_the_fresh_raster_while_a_tile_is_still_stale() {
|
||
let content = b"q 60 0 0 60 40 260 cm /Im0 Do Q";
|
||
let objs: Vec<Vec<u8>> = vec![
|
||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_vec(),
|
||
b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >>".to_vec(),
|
||
[format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content.as_slice(), b"\nendstream"].concat(),
|
||
[
|
||
b"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceRGB /Length 3 >>\nstream\n".as_slice(),
|
||
&[200, 30, 30],
|
||
b"\nendstream",
|
||
]
|
||
.concat(),
|
||
];
|
||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||
let mut offsets = Vec::new();
|
||
for (i, o) in objs.iter().enumerate() {
|
||
offsets.push(bytes.len());
|
||
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||
bytes.extend_from_slice(o);
|
||
bytes.extend_from_slice(b"\nendobj\n");
|
||
}
|
||
let xref = bytes.len();
|
||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||
for o in offsets {
|
||
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||
}
|
||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 1100.0)).build_eframe(move |_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("picture.pdf", None, bytes.clone()).unwrap();
|
||
app.set_option("zoom", "100").unwrap();
|
||
app.quick_tool = QuickTool::EditText;
|
||
app
|
||
});
|
||
let settle = |h: &mut egui_kittest::Harness<'static, PdfCraftApp>| {
|
||
for _ in 0..400 {
|
||
h.step();
|
||
if !h.state().render_pending() {
|
||
return;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
}
|
||
};
|
||
h.run_steps(4);
|
||
settle(&mut h);
|
||
let page = h.state().views[0].page_screen_rect(0).unwrap();
|
||
let at = |x: f32, y: f32| egui::pos2(page.left() + x / 300.0 * page.width(), page.top() + (400.0 - y) / 400.0 * page.height());
|
||
let red = |pixels: &image::RgbaImage, r: Rect| {
|
||
let (w, hh) = (pixels.width(), pixels.height());
|
||
(r.top().max(0.0) as u32..(r.bottom() as u32).min(hh))
|
||
.flat_map(|y| (r.left().max(0.0) as u32..(r.right() as u32).min(w)).map(move |x| (x, y)))
|
||
.filter(|(x, y)| matches!(pixels.get_pixel(*x, *y).0, [r, g, b, _] if r > 150 && g < 90 && b < 90))
|
||
.count()
|
||
};
|
||
let (old, new) = (Rect::from_two_pos(at(42.0, 262.0), at(98.0, 318.0)), Rect::from_two_pos(at(182.0, 82.0), at(238.0, 138.0)));
|
||
// Select the picture and drag it, without overlap, from (40, 260) to (180, 80).
|
||
let drag = |h: &mut egui_kittest::Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2, release: bool| {
|
||
h.hover_at(from);
|
||
h.run_steps(1);
|
||
h.drag_at(from);
|
||
h.run_steps(1);
|
||
for k in 1..=4 {
|
||
h.hover_at(from + (to - from) * (k as f32 / 4.0));
|
||
h.run_steps(1);
|
||
}
|
||
if release {
|
||
h.drop_at(to);
|
||
h.step();
|
||
}
|
||
};
|
||
let (grab, put) = (at(70.0, 290.0), at(210.0, 110.0));
|
||
h.hover_at(grab);
|
||
h.run_steps(1);
|
||
h.drag_at(grab);
|
||
h.run_steps(1);
|
||
h.drop_at(grab);
|
||
h.run_steps(3);
|
||
assert!(h.state().views[0].image_selection.is_some(), "the picture is selected");
|
||
h.state_mut().views[0].hold_rasters = true;
|
||
drag(&mut h, grab, put, true);
|
||
// One tile stays stale, as a tile result arriving after the whole-page raster would; then
|
||
// the whole-page raster is let through.
|
||
let tile = h.ctx.load_texture("stale-tile", egui::ColorImage::new([1, 1], vec![Color32::TRANSPARENT]), TextureOptions::LINEAR);
|
||
h.state_mut().views[0].tiles.insert((0, 1, 0, 0), tile);
|
||
h.state_mut().views[0].stale_tiles.insert((0, 1, 0, 0));
|
||
h.state_mut().views[0].hold_rasters = false;
|
||
for _ in 0..400 {
|
||
h.step();
|
||
if !h.state().views[0].page_raster_out_of_date(0) {
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
}
|
||
assert!(!h.state().views[0].page_raster_out_of_date(0), "the whole-page raster is fresh");
|
||
assert!(h.state().views[0].page_out_of_date(0), "a tile is still stale");
|
||
h.run_steps(2);
|
||
let pixels = h.render().unwrap();
|
||
assert!(red(&pixels, new) > 100, "the picture shows where it went");
|
||
assert_eq!(red(&pixels, old), 0, "and not where it was");
|
||
// Another drag in that interval takes its pixels from where the raster has them.
|
||
let further = at(210.0, 200.0);
|
||
drag(&mut h, put, further, false);
|
||
let pixels = h.render().unwrap();
|
||
let moving = Rect::from_two_pos(at(182.0, 172.0), at(238.0, 228.0));
|
||
assert!(red(&pixels, moving) > 100, "the dragged picture is drawn from the fresh raster");
|
||
assert_eq!(red(&pixels, old), 0, "nothing shows where it first was");
|
||
h.state_mut().views[0].stale_tiles.clear();
|
||
h.state_mut().views[0].tiles.clear();
|
||
}
|
||
|
||
/// Review on #4378: `FitWindow` measures the spread the view shows, its pages side by side
|
||
/// and as tall as the taller one, with one page in a spread that has only one.
|
||
#[test]
|
||
fn first_page_now_measures_the_pages_in_the_spread() {
|
||
let page =
|
||
|width: f32, height: f32| pdfcraft_render::PageInfo { width, height, label: String::new(), crop: [0.0, 0.0, width, height], rotation: 0 };
|
||
let info = DocInfo { pages: vec![page(300.0, 400.0), page(600.0, 800.0), page(200.0, 500.0)], ..Default::default() };
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||
let one = v.first_page_now(&info).expect("a page");
|
||
assert_eq!((one.size, one.margins.x), (vec2(300.0 * PT, 400.0 * PT), 2.0 * SIDE));
|
||
v.set_display(PageLayout::TwoUp, false);
|
||
let gap = v.page_gap();
|
||
// [0, 1]: 300 + 600 wide, 800 high, one gap between them.
|
||
let spread = v.first_page_now(&info).expect("a spread");
|
||
assert_eq!((spread.size, spread.margins.x), (vec2(900.0 * PT, 800.0 * PT), 2.0 * SIDE + gap));
|
||
// [2] on its own at the end: one page, no gap.
|
||
v.current = 2;
|
||
let last = v.first_page_now(&info).expect("the last page");
|
||
assert_eq!((last.size, last.margins.x), (vec2(200.0 * PT, 500.0 * PT), 2.0 * SIDE));
|
||
// With a cover page: [0] alone, then [1, 2].
|
||
v.set_cover(true);
|
||
assert_eq!(v.first_page_now(&info).expect("a spread").size, vec2(800.0 * PT, 800.0 * PT));
|
||
v.current = 0;
|
||
let cover = v.first_page_now(&info).expect("the cover");
|
||
assert_eq!((cover.size, cover.margins.x), (vec2(300.0 * PT, 400.0 * PT), 2.0 * SIDE));
|
||
v.current = 9;
|
||
assert!(v.first_page_now(&info).is_none());
|
||
}
|
||
|
||
/// Full screen's presenter for one frame of `ctx`, at time `now`: whether the frame asked to be
|
||
/// drawn again at once.
|
||
fn presenter_frame(ctx: &egui::Context, v: &mut DocView, info: &DocInfo, now: f64) -> bool {
|
||
presenter_frame_animating(ctx, v, info, now, true)
|
||
}
|
||
|
||
/// [`presenter_frame`] with the Animation effects preference as given (`false` also when the
|
||
/// system asks for no animations).
|
||
fn presenter_frame_animating(ctx: &egui::Context, v: &mut DocView, info: &DocInfo, now: f64, animates: bool) -> bool {
|
||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||
let painter = ui.ctx().layer_painter(egui::LayerId::background());
|
||
presenter::frame(v, info, &painter, ui.ctx(), now, animates, None);
|
||
});
|
||
// No renderer here to upload textures: drop the frame's texture changes.
|
||
out.textures_delta.clear();
|
||
out.viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|o| o.repaint_delay.is_zero())
|
||
}
|
||
|
||
/// A transition into a page the renderer could not draw ends at once: the page before is not held
|
||
/// on screen waiting for it, and the page's own time can start.
|
||
#[test]
|
||
fn a_transition_into_a_page_that_failed_to_render_ends_at_once() {
|
||
use pdfcraft_render::{PageTransition, TransitionStyle};
|
||
let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 };
|
||
let wipe = PageTransition {
|
||
page: 1,
|
||
style: Some(TransitionStyle::Wipe),
|
||
duration: 1.0,
|
||
horizontal: false,
|
||
inward: false,
|
||
direction: Some(0),
|
||
scale: 1.0,
|
||
opaque: false,
|
||
display: None,
|
||
};
|
||
let info = DocInfo { pages: vec![page.clone(), page], transitions: vec![wipe], ..Default::default() };
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::Single, continuous: false, ..Default::default() });
|
||
v.enter_presentation();
|
||
let ctx = egui::Context::default();
|
||
let tex = ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default());
|
||
v.pages.insert(0, super::cache::PageTex { tag: 0, tex });
|
||
presenter_frame(&ctx, &mut v, &info, 0.0);
|
||
v.current = 1;
|
||
v.screen_rects = vec![(1, Rect::from_min_size(Pos2::ZERO, egui::vec2(300.0, 400.0)))];
|
||
v.errors.insert(1, "the renderer failed".into());
|
||
presenter_frame(&ctx, &mut v, &info, 0.1);
|
||
assert!(!v.presenting_a_transition(), "no wait for a page that won't render");
|
||
}
|
||
|
||
/// While the incoming page is still rendering, the presenter holds the page before without asking
|
||
/// for a frame after frame: the render cache wakes the window while pages are queued.
|
||
#[test]
|
||
fn a_transition_waiting_for_its_page_does_not_redraw_every_frame() {
|
||
use pdfcraft_render::{PageTransition, TransitionStyle};
|
||
let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 };
|
||
let wipe = PageTransition {
|
||
page: 1,
|
||
style: Some(TransitionStyle::Wipe),
|
||
duration: 1.0,
|
||
horizontal: false,
|
||
inward: false,
|
||
direction: Some(0),
|
||
scale: 1.0,
|
||
opaque: false,
|
||
display: None,
|
||
};
|
||
let info = DocInfo { pages: vec![page.clone(), page], transitions: vec![wipe], ..Default::default() };
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::Single, continuous: false, ..Default::default() });
|
||
v.enter_presentation();
|
||
let ctx = egui::Context::default();
|
||
let tex = ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default());
|
||
v.pages.insert(0, super::cache::PageTex { tag: 0, tex });
|
||
presenter_frame(&ctx, &mut v, &info, 0.0);
|
||
v.current = 1;
|
||
v.screen_rects = vec![(1, Rect::from_min_size(Pos2::ZERO, egui::vec2(300.0, 400.0)))];
|
||
// egui asks for its first frames itself: past them, only the presenter asks.
|
||
for now in [0.1, 0.2, 0.3] {
|
||
presenter_frame(&ctx, &mut v, &info, now);
|
||
}
|
||
let again = presenter_frame(&ctx, &mut v, &info, 0.4);
|
||
assert!(v.presenting_a_transition(), "still waiting for page 2");
|
||
assert!(!again, "without asking for a frame after frame");
|
||
}
|
||
|
||
/// A display time on the first page of the last two-page spread can't advance (there is no spread
|
||
/// after it): the presenter then stays on it without asking for a frame after frame.
|
||
#[test]
|
||
fn a_display_time_on_the_last_spread_does_not_redraw_every_frame() {
|
||
use pdfcraft_render::PageTransition;
|
||
let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 };
|
||
let timed = PageTransition {
|
||
page: 2,
|
||
style: None,
|
||
duration: 1.0,
|
||
horizontal: false,
|
||
inward: false,
|
||
direction: Some(0),
|
||
scale: 1.0,
|
||
opaque: false,
|
||
display: Some(1.0),
|
||
};
|
||
let info = DocInfo { pages: vec![page.clone(), page.clone(), page.clone(), page], transitions: vec![timed], ..Default::default() };
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::TwoUp, continuous: false, ..Default::default() });
|
||
v.enter_presentation();
|
||
v.current = 2;
|
||
let ctx = egui::Context::default();
|
||
let tex = ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default());
|
||
v.pages.insert(2, super::cache::PageTex { tag: 0, tex });
|
||
// egui asks for its first frames itself: past them, only the presenter asks.
|
||
for now in [0.0, 0.5, 0.6] {
|
||
presenter_frame(&ctx, &mut v, &info, now);
|
||
}
|
||
let again = presenter_frame(&ctx, &mut v, &info, 5.0);
|
||
assert_eq!(v.current, 2, "the last spread stays");
|
||
assert!(!again, "and asks for no frame after frame");
|
||
}
|
||
|
||
/// A page's display time (ISO 32000-2 §12.4.4.1, `/Dur`) counts from when the page is on screen:
|
||
/// a slow render that still shows Rendering… does not use it up.
|
||
#[test]
|
||
fn a_display_time_starts_once_the_page_has_rendered() {
|
||
use pdfcraft_render::PageTransition;
|
||
let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 };
|
||
let timed = PageTransition {
|
||
page: 0,
|
||
style: None,
|
||
duration: 1.0,
|
||
horizontal: false,
|
||
inward: false,
|
||
direction: Some(0),
|
||
scale: 1.0,
|
||
opaque: false,
|
||
display: Some(1.0),
|
||
};
|
||
let info = DocInfo { pages: vec![page.clone(), page], transitions: vec![timed], ..Default::default() };
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::Single, continuous: false, ..Default::default() });
|
||
v.enter_presentation();
|
||
let ctx = egui::Context::default();
|
||
for now in [0.0, 1.0, 2.0, 5.0] {
|
||
presenter_frame(&ctx, &mut v, &info, now);
|
||
}
|
||
assert_eq!(v.current, 0, "page 1 is still rendering: its second has not started");
|
||
let tex = ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default());
|
||
v.pages.insert(0, super::cache::PageTex { tag: 0, tex });
|
||
presenter_frame(&ctx, &mut v, &info, 5.5);
|
||
presenter_frame(&ctx, &mut v, &info, 6.0);
|
||
assert_eq!(v.current, 0, "half of its second on screen");
|
||
presenter_frame(&ctx, &mut v, &info, 6.6);
|
||
assert_eq!(v.current, 1, "its second on screen is up");
|
||
}
|
||
|
||
/// In two-page view a display time counts from when the whole spread is on screen: one page of
|
||
/// it still showing Rendering… holds the spread (#5316 review).
|
||
#[test]
|
||
fn a_display_time_in_two_page_view_waits_for_the_whole_spread() {
|
||
use pdfcraft_render::PageTransition;
|
||
let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 };
|
||
let timed = PageTransition {
|
||
page: 0,
|
||
style: None,
|
||
duration: 1.0,
|
||
horizontal: false,
|
||
inward: false,
|
||
direction: Some(0),
|
||
scale: 1.0,
|
||
opaque: false,
|
||
display: Some(1.0),
|
||
};
|
||
let info = DocInfo { pages: vec![page.clone(), page.clone(), page.clone(), page], transitions: vec![timed], ..Default::default() };
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::TwoUp, continuous: false, ..Default::default() });
|
||
v.enter_presentation();
|
||
let ctx = egui::Context::default();
|
||
let tex = |ctx: &egui::Context| ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default());
|
||
let r = Rect::from_min_size(Pos2::ZERO, egui::vec2(300.0, 400.0));
|
||
v.screen_rects = vec![(0, r), (1, r.translate(egui::vec2(300.0, 0.0)))];
|
||
v.pages.insert(0, super::cache::PageTex { tag: 0, tex: tex(&ctx) });
|
||
for now in [0.0, 1.0, 2.0, 5.0] {
|
||
presenter_frame(&ctx, &mut v, &info, now);
|
||
}
|
||
assert_eq!(v.current, 0, "page 2 of the spread is still rendering: the spread's second has not started");
|
||
v.pages.insert(1, super::cache::PageTex { tag: 0, tex: tex(&ctx) });
|
||
presenter_frame(&ctx, &mut v, &info, 5.5);
|
||
presenter_frame(&ctx, &mut v, &info, 6.0);
|
||
assert_eq!(v.current, 0, "half of its second on screen");
|
||
presenter_frame(&ctx, &mut v, &info, 6.6);
|
||
assert_eq!(v.current, 2, "its second on screen is up: the next spread");
|
||
}
|
||
|
||
/// Page 1 of `pages` 300×400 pt pages, with `transitions`, in full screen (single page), its
|
||
/// raster in. The other pages get theirs with [`give_raster`].
|
||
fn presenting_view(pages: usize, transitions: Vec<pdfcraft_render::PageTransition>, ctx: &egui::Context) -> (DocView, DocInfo) {
|
||
let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 };
|
||
let info = DocInfo { pages: vec![page; pages], transitions, ..Default::default() };
|
||
let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::Single, continuous: false, ..Default::default() });
|
||
v.enter_presentation();
|
||
give_raster(ctx, &mut v, 0);
|
||
(v, info)
|
||
}
|
||
|
||
/// Page `page`'s raster has arrived (a blank one).
|
||
fn give_raster(ctx: &egui::Context, v: &mut DocView, page: usize) {
|
||
let tex = ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default());
|
||
v.pages.insert(page, super::cache::PageTex { tag: 0, tex });
|
||
}
|
||
|
||
/// Turn to `page` as a key would, with its raster in and its place on screen.
|
||
fn turn_to(ctx: &egui::Context, v: &mut DocView, page: usize) {
|
||
give_raster(ctx, v, page);
|
||
v.current = page;
|
||
v.screen_rects = vec![(page, Rect::from_min_size(Pos2::ZERO, egui::vec2(300.0, 400.0)))];
|
||
}
|
||
|
||
/// A page's `/Trans` (style and `/D`) and `/Dur`, the rest as the spec's defaults.
|
||
fn trans(page: usize, style: Option<pdfcraft_render::TransitionStyle>, duration: f32, display: Option<f32>) -> pdfcraft_render::PageTransition {
|
||
pdfcraft_render::PageTransition { page, style, duration, horizontal: true, inward: true, direction: Some(0), scale: 1.0, opaque: false, display }
|
||
}
|
||
|
||
/// A page with a transition but no display time (ISO 32000-2 Table 31, `Dur`: "By default, the
|
||
/// viewer shall not advance automatically") stays until the user turns it, however long it shows.
|
||
#[test]
|
||
fn a_page_without_a_display_time_never_advances_by_itself() {
|
||
use pdfcraft_render::TransitionStyle;
|
||
let ctx = egui::Context::default();
|
||
let (mut v, info) = presenting_view(3, vec![trans(0, Some(TransitionStyle::Wipe), 1.0, None)], &ctx);
|
||
for now in [0.0, 1.0, 60.0, 86_400.0, 1.0e7] {
|
||
presenter_frame(&ctx, &mut v, &info, now);
|
||
}
|
||
assert_eq!(v.current, 0, "no /Dur: the page waits for the presenter");
|
||
}
|
||
|
||
/// A display time of 0 seconds advances as soon as the page is on screen: zero is a number the
|
||
/// spec allows, the time a page "shall be displayed" at most.
|
||
#[test]
|
||
fn a_display_time_of_zero_advances_once_the_page_is_on_screen() {
|
||
let ctx = egui::Context::default();
|
||
let (mut v, info) = presenting_view(3, vec![trans(0, None, 1.0, Some(0.0))], &ctx);
|
||
presenter_frame(&ctx, &mut v, &info, 0.0);
|
||
assert_eq!(v.current, 1, "page 1's zero seconds are up at once");
|
||
presenter_frame(&ctx, &mut v, &info, 0.1);
|
||
assert_eq!(v.current, 1, "page 2 has no display time");
|
||
}
|
||
|
||
/// A display time from the file that is negative, not a number or endless is no display time, or
|
||
/// at most a day: it neither turns the page at once nor reaches a timer that can't hold it.
|
||
#[test]
|
||
fn a_negative_or_endless_display_time_is_no_display_time() {
|
||
for display in [-1.0, -0.001, f32::NAN, f32::NEG_INFINITY, f32::INFINITY] {
|
||
let ctx = egui::Context::default();
|
||
let (mut v, info) = presenting_view(3, vec![trans(0, None, 1.0, Some(display))], &ctx);
|
||
for now in [0.0, 0.1, 5.0, 3600.0] {
|
||
presenter_frame(&ctx, &mut v, &info, now);
|
||
}
|
||
assert_eq!(v.current, 0, "/Dur {display}");
|
||
}
|
||
}
|
||
|
||
/// A display time longer than a day is cut to a day; the largest number the file can hold would
|
||
/// otherwise be past what a timer can wait for.
|
||
#[test]
|
||
fn a_display_time_longer_than_a_day_is_cut_to_a_day() {
|
||
for display in [1.0e9, f32::MAX] {
|
||
let ctx = egui::Context::default();
|
||
let (mut v, info) = presenting_view(3, vec![trans(0, None, 1.0, Some(display))], &ctx);
|
||
presenter_frame(&ctx, &mut v, &info, 0.0);
|
||
presenter_frame(&ctx, &mut v, &info, 86_399.0);
|
||
assert_eq!(v.current, 0, "/Dur {display}: not yet a day");
|
||
presenter_frame(&ctx, &mut v, &info, 86_400.5);
|
||
assert_eq!(v.current, 1, "/Dur {display}: a day is up");
|
||
}
|
||
}
|
||
|
||
/// A transition the file says lasts a day (or longer) ends within ten seconds: it can't hold the
|
||
/// screen.
|
||
#[test]
|
||
fn a_transition_of_a_day_ends_within_ten_seconds() {
|
||
use pdfcraft_render::TransitionStyle;
|
||
for duration in [86_400.0, f32::MAX] {
|
||
let ctx = egui::Context::default();
|
||
let (mut v, info) = presenting_view(3, vec![trans(1, Some(TransitionStyle::Wipe), duration, None)], &ctx);
|
||
presenter_frame(&ctx, &mut v, &info, 0.0);
|
||
turn_to(&ctx, &mut v, 1);
|
||
presenter_frame(&ctx, &mut v, &info, 0.1);
|
||
presenter_frame(&ctx, &mut v, &info, 5.0);
|
||
assert!(v.presenting_a_transition(), "/D {duration}: playing");
|
||
presenter_frame(&ctx, &mut v, &info, 10.2);
|
||
assert!(!v.presenting_a_transition(), "/D {duration}: over after ten seconds");
|
||
}
|
||
}
|
||
|
||
/// A transition of no length, a negative one or one that is not a number changes the page at once
|
||
/// (a negative one would otherwise never end).
|
||
#[test]
|
||
fn a_transition_of_zero_or_negative_length_cuts() {
|
||
use pdfcraft_render::TransitionStyle;
|
||
for duration in [0.0, -1.0, f32::NAN, f32::NEG_INFINITY] {
|
||
let ctx = egui::Context::default();
|
||
let (mut v, info) = presenting_view(3, vec![trans(1, Some(TransitionStyle::Wipe), duration, None)], &ctx);
|
||
presenter_frame(&ctx, &mut v, &info, 0.0);
|
||
turn_to(&ctx, &mut v, 1);
|
||
presenter_frame(&ctx, &mut v, &info, 0.1);
|
||
assert!(!v.presenting_a_transition(), "/D {duration}: cut");
|
||
assert_eq!(v.current, 1);
|
||
}
|
||
}
|
||
|
||
/// A style the spec doesn't name is read as its default R, Replace (ISO 32000-2 Table 164: "The
|
||
/// new page simply replaces the old one with no special transition effect"): the page changes at
|
||
/// once, and its display time still counts.
|
||
#[test]
|
||
fn an_unknown_transition_style_cuts_and_keeps_the_display_time() {
|
||
use pdfcraft_render::TransitionStyle;
|
||
let ctx = egui::Context::default();
|
||
let unknown = TransitionStyle::from_name("Spiral");
|
||
let (mut v, info) = presenting_view(3, vec![trans(1, Some(unknown), 5.0, Some(1.0))], &ctx);
|
||
presenter_frame(&ctx, &mut v, &info, 0.0);
|
||
turn_to(&ctx, &mut v, 1);
|
||
presenter_frame(&ctx, &mut v, &info, 0.1);
|
||
assert!(!v.presenting_a_transition(), "no effect for an unknown style");
|
||
presenter_frame(&ctx, &mut v, &info, 0.9);
|
||
assert_eq!(v.current, 1, "its display time is not up");
|
||
presenter_frame(&ctx, &mut v, &info, 1.2);
|
||
assert_eq!(v.current, 2, "its display time is up");
|
||
}
|
||
|
||
/// With animations off (the preference, or the system asking for none), a transition is a cut and
|
||
/// the page's display time still counts from when it is on screen.
|
||
#[test]
|
||
fn without_animations_a_transition_cuts_and_the_display_time_counts() {
|
||
use pdfcraft_render::TransitionStyle;
|
||
let ctx = egui::Context::default();
|
||
let (mut v, info) = presenting_view(3, vec![trans(1, Some(TransitionStyle::Wipe), 5.0, Some(1.0))], &ctx);
|
||
presenter_frame_animating(&ctx, &mut v, &info, 0.0, false);
|
||
turn_to(&ctx, &mut v, 1);
|
||
presenter_frame_animating(&ctx, &mut v, &info, 0.1, false);
|
||
assert!(!v.presenting_a_transition(), "cut");
|
||
presenter_frame_animating(&ctx, &mut v, &info, 1.2, false);
|
||
assert_eq!(v.current, 2, "page 2's second is up");
|
||
}
|
||
|
||
/// A page turned by hand while a display time counts starts its own time afresh (ISO 32000-2
|
||
/// §12.4.4.1, NOTE 1: "The user can advance the page manually before the specified time has
|
||
/// expired"); the time left on the page before is not carried over.
|
||
#[test]
|
||
fn a_page_turned_by_hand_starts_its_own_display_time() {
|
||
let ctx = egui::Context::default();
|
||
let timed = (0..4).map(|p| trans(p, None, 1.0, Some(2.0))).collect();
|
||
let (mut v, info) = presenting_view(4, timed, &ctx);
|
||
presenter_frame(&ctx, &mut v, &info, 0.0);
|
||
presenter_frame(&ctx, &mut v, &info, 1.5);
|
||
turn_to(&ctx, &mut v, 1);
|
||
presenter_frame(&ctx, &mut v, &info, 1.5);
|
||
presenter_frame(&ctx, &mut v, &info, 3.0);
|
||
assert_eq!(v.current, 1, "page 2 has shown 1.5 of its 2 seconds");
|
||
presenter_frame(&ctx, &mut v, &info, 3.6);
|
||
assert_eq!(v.current, 2, "page 2's own 2 seconds are up");
|
||
}
|
||
|
||
#[test]
|
||
fn image_handles_account_for_document_and_view_rotation() {
|
||
for page_rotation in [0, 90, 180, 270] {
|
||
for view_rotation in [0, 90, 180, 270] {
|
||
let (width, height) = if page_rotation % 180 == 0 { (300.0, 400.0) } else { (400.0, 300.0) };
|
||
let info = DocInfo {
|
||
pages: vec![pdfcraft_render::PageInfo {
|
||
width,
|
||
height,
|
||
label: String::new(),
|
||
crop: [0.0, 0.0, 300.0, 400.0],
|
||
rotation: page_rotation,
|
||
}],
|
||
..Default::default()
|
||
};
|
||
let size = if view_rotation % 180 == 0 { vec2(width, height) } else { vec2(height, width) };
|
||
let xf = PageXform { rect: Rect::from_min_size(pos2(10.0, 20.0), size), rot: view_rotation, pw: width, ph: height };
|
||
let obb = ImageObb::from_matrix([100.0, 0.0, 0.0, 50.0, 50.0, 70.0], &xf, &info, 0);
|
||
let total = (page_rotation + view_rotation) % 360;
|
||
assert_eq!(obb.is_rotated, total != 0, "page={page_rotation} view={view_rotation}: user-labelled grips need matrix resize when turned");
|
||
for (handle, point) in obb.handles() {
|
||
let direction = point - obb.screen_center;
|
||
let cursor = match handle {
|
||
ImageHandle::TopLeft | ImageHandle::TopRight | ImageHandle::BottomLeft | ImageHandle::BottomRight => {
|
||
if direction.x * direction.y > 0.0 { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }
|
||
}
|
||
_ if direction.x.abs() > direction.y.abs() => egui::CursorIcon::ResizeHorizontal,
|
||
_ => egui::CursorIcon::ResizeVertical,
|
||
};
|
||
assert_eq!(obb.handle_cursor(handle), cursor, "page={page_rotation} view={view_rotation} handle={handle:?}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Cancelling the pages' pointer gestures (Escape, automation's cancel) lets go of a
|
||
/// camera corner being dragged: the quadrilateral stays, and no corner follows the pointer.
|
||
#[test]
|
||
fn cancelling_pointer_gestures_lets_go_of_a_camera_corner() {
|
||
let mut v = view(1, PageLayout::Single, true);
|
||
v.camera = Some(crate::camera_ui::CameraQuad { grab: Some(2), ..crate::camera_ui::CameraQuad::inset(0, 300.0, 400.0) });
|
||
v.cancel_pointer_gestures();
|
||
assert_eq!(v.camera.map(|q| q.grab), Some(None));
|
||
}
|
||
|
||
/// A camera corner at the page's edge is hit on the page only: a press off the page is no page's,
|
||
/// so its grab cursor does not show there.
|
||
#[test]
|
||
fn a_camera_corner_at_the_page_edge_is_hit_on_the_page_only() {
|
||
let mut v = view(1, PageLayout::Single, true);
|
||
v.camera = Some(crate::camera_ui::CameraQuad { page: 0, corners: [[0.0, 0.0], [300.0, 0.0], [300.0, 400.0], [0.0, 400.0]], grab: None });
|
||
let xf = PageXform { rect: Rect::from_min_size(pos2(10.0, 20.0), egui::vec2(300.0, 400.0)), rot: 0, pw: 300.0, ph: 400.0 };
|
||
assert!(crate::camera_ui::hit(&xf, 0, &v, pos2(14.0, 24.0)).is_some(), "on the page");
|
||
assert!(crate::camera_ui::hit(&xf, 0, &v, pos2(6.0, 16.0)).is_none(), "just off it");
|
||
}
|
||
|
||
/// #5125: after Enhance scanned file, the page shown is re-rendered, not the raster of the
|
||
/// scan before it was cleaned (same scale, so nothing else would replace it).
|
||
#[test]
|
||
fn enhancing_a_scan_re_renders_the_page_shown() {
|
||
let (w, h) = (64usize, 64usize);
|
||
let px: Vec<u8> = (0..w * h).map(|i| if i % w < 4 { 30 } else { 198 }).collect();
|
||
let mut pdf = format!(
|
||
"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>\nendobj\n4 0 obj\n<< /Type /XObject /Subtype /Image /Width {w} /Height {h} /ColorSpace /DeviceGray /BitsPerComponent 8 /Length {} >>\nstream\n",
|
||
px.len()
|
||
)
|
||
.into_bytes();
|
||
pdf.extend_from_slice(&px);
|
||
let content = format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q");
|
||
pdf.extend_from_slice(
|
||
format!(
|
||
"\nendstream\nendobj\n5 0 obj\n<< /Length {} >>\nstream\n{content}\nendstream\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n",
|
||
content.len()
|
||
)
|
||
.as_bytes(),
|
||
);
|
||
let mut app = PdfCraftApp::new();
|
||
app.open_bytes("scan.pdf", None, pdf).unwrap();
|
||
let (i, doc) = app.active_ids().unwrap();
|
||
let ctx = egui::Context::default();
|
||
let tex = ctx.load_texture("before", egui::ColorImage::filled([1, 1], Color32::WHITE), TextureOptions::LINEAR);
|
||
app.views[i].pages.insert(0, PageTex { tag: 1000, tex });
|
||
let job = app.session.scan_clean_job(doc, &[], pdfcraft_engine::scan_clean::ScanCleanSettings::default()).unwrap();
|
||
let cleaned = job.run(|_, _| true).unwrap();
|
||
assert_eq!(cleaned.report.pages, vec![0], "the page is a scan and gets cleaned");
|
||
let progress =
|
||
std::sync::Arc::new(std::sync::Mutex::new(crate::scan_clean_ui::ScanCleanProgress { result: Some(Ok(cleaned)), ..Default::default() }));
|
||
app.scan_clean_run = Some(crate::scan_clean_ui::ScanCleanRun { doc, progress });
|
||
app.poll_scan_clean();
|
||
assert!(app.scan_clean_run.is_none());
|
||
assert_eq!(app.views[i].pages.get(&0).unwrap().tag, STALE_TAG, "the old raster is re-rendered");
|
||
}
|
||
|
||
/// #5708 review: a page that fits is centred with nothing to scroll, so only pixel snapping may
|
||
/// pass for fitting. A page half a pixel or more too tall scrolls, or its ends would be cut off.
|
||
#[test]
|
||
fn only_pixel_snapping_passes_for_fitting_the_view() {
|
||
let page = |height: f32| [Rect::from_min_size(pos2(0.0, MARGIN), vec2(300.0, height))];
|
||
for ppp in [1.0, 2.0] {
|
||
let slack = 0.5 / ppp;
|
||
let (_, content) = vertical_placement(false, &page(600.0 + slack), &[0], 600.0, ppp);
|
||
assert_eq!(content, 600.0, "at {ppp}x, snapping's half pixel still fits");
|
||
for over in [slack + 0.1, 1.0] {
|
||
let (shift, content) = vertical_placement(false, &page(600.0 + over), &[0], 600.0, ppp);
|
||
assert_eq!((shift, content), (0.0, 600.0 + over + 2.0 * MARGIN), "at {ppp}x, {over} pt too tall scrolls");
|
||
}
|
||
}
|
||
// A scale that isn't one has no slack and never panics.
|
||
for ppp in [0.0, f32::NAN, f32::INFINITY] {
|
||
assert!(!fits_height(Rect::from_min_size(pos2(0.0, 0.0), vec2(1.0, 600.5)), 600.0, ppp));
|
||
}
|
||
}
|