mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 18:01:08 -06:00
354 lines
16 KiB
Rust
354 lines
16 KiB
Rust
//! Decorative page shadows are app preferences, never PDF edits.
|
|
|
|
use egui_kittest::{Harness, kittest::Queryable};
|
|
use pdfcraft_ui_egui::PdfCraftApp;
|
|
|
|
#[test]
|
|
fn viewer_shadows_follow_the_preference_without_moving_pages() {
|
|
for theme in ["light", "dark"] {
|
|
for layout in ["single", "two-page", "continuous", "two-up"] {
|
|
let mut h = harness(theme);
|
|
h.state_mut().set_option("layout", layout).unwrap();
|
|
h.run_steps(3);
|
|
let rectangles = h.state().views[0].visible_page_rects().to_vec();
|
|
let original = shadows(&h, false);
|
|
assert!(!original.is_empty(), "{theme} {layout}");
|
|
h.state_mut().set_option("show_page_edge_shadows", "false").unwrap();
|
|
h.run_steps(2);
|
|
assert!(shadows(&h, false).is_empty(), "{theme} {layout}");
|
|
assert_eq!(h.state().views[0].visible_page_rects(), rectangles);
|
|
h.state_mut().set_option("zoom", "125").unwrap();
|
|
h.state_mut().set_option("page", "2").unwrap();
|
|
h.run_steps(3);
|
|
assert!(shadows(&h, false).is_empty());
|
|
h.state_mut().set_option("show_page_edge_shadows", "true").unwrap();
|
|
h.run_steps(2);
|
|
assert!(!shadows(&h, false).is_empty());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn viewport_fades_follow_the_preference_and_keep_other_scroll_areas_styled() {
|
|
for theme in ["light", "dark"] {
|
|
for organize in [false, true] {
|
|
let mut h = harness(theme);
|
|
if organize {
|
|
h.state_mut().set_option("organize", "on").unwrap();
|
|
// Make the three-page grid overflow vertically.
|
|
h.state_mut().set_option("grid-zoom", "300").unwrap();
|
|
}
|
|
h.run_steps(4);
|
|
let original_style = h.ctx.global_style().spacing.scroll.fade;
|
|
assert!(original_style.strength > 0.0);
|
|
assert!(!viewport_fades(&h).is_empty(), "bottom fade at start: {theme}, organize={organize}");
|
|
scroll_down(&mut h, 100.0);
|
|
let original = viewport_fades(&h);
|
|
let viewport = h.state().views[0].viewport_rect();
|
|
assert!(original.iter().any(|r| (r.top() - viewport.top()).abs() < 2.0), "top fade: {original:?}");
|
|
assert!(original.iter().any(|r| (r.bottom() - viewport.bottom()).abs() < 2.0), "bottom fade: {original:?}");
|
|
let pages = h.state().views[0].visible_page_rects().to_vec();
|
|
h.state_mut().set_option("show_page_edge_shadows", "false").unwrap();
|
|
h.run_steps(2);
|
|
assert!(viewport_fades(&h).is_empty(), "viewport fades remain: {theme}, organize={organize}");
|
|
assert_eq!(h.state().views[0].visible_page_rects(), pages);
|
|
assert_eq!(h.ctx.global_style().spacing.scroll.fade, original_style);
|
|
h.state_mut().set_option("show_page_edge_shadows", "true").unwrap();
|
|
h.run_steps(2);
|
|
assert_eq!(viewport_fades(&h), original);
|
|
scroll_down(&mut h, 100_000.0);
|
|
let at_end = viewport_fades(&h);
|
|
assert!(at_end.iter().any(|r| (r.top() - viewport.top()).abs() < 2.0));
|
|
assert!(!at_end.iter().any(|r| (r.bottom() - viewport.bottom()).abs() < 2.0), "no bottom fade at the end");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn horizontal_viewport_fades_also_follow_the_preference() {
|
|
let mut h = harness("dark");
|
|
h.state_mut().set_option("zoom", "400").unwrap();
|
|
h.run_steps(4);
|
|
let viewport = h.state().views[0].viewport_rect();
|
|
let horizontal: Vec<_> = viewport_fades(&h).into_iter().filter(|r| r.width() <= 21.0).collect();
|
|
assert!(horizontal.iter().any(|r| (r.left() - viewport.left()).abs() < 2.0), "left fade: {horizontal:?}");
|
|
assert!(horizontal.iter().any(|r| (r.right() - viewport.right()).abs() < 2.0), "right fade: {horizontal:?}");
|
|
let pages = h.state().views[0].visible_page_rects().to_vec();
|
|
h.state_mut().set_option("show_page_edge_shadows", "false").unwrap();
|
|
h.run_steps(2);
|
|
assert!(viewport_fades(&h).is_empty());
|
|
assert_eq!(h.state().views[0].visible_page_rects(), pages);
|
|
}
|
|
|
|
fn scroll_down(h: &mut Harness<'static, PdfCraftApp>, points: f32) {
|
|
h.hover_at(h.state().views[0].viewport_rect().center());
|
|
h.run_steps(1);
|
|
h.event(egui::Event::MouseWheel {
|
|
unit: egui::MouseWheelUnit::Point,
|
|
delta: egui::vec2(0.0, -points),
|
|
phase: egui::TouchPhase::Move,
|
|
modifiers: egui::Modifiers::NONE,
|
|
source: egui::MouseWheelSource::Unknown,
|
|
});
|
|
h.run_steps(12);
|
|
}
|
|
|
|
/// Inspect the actual egui painter output: untextured four-vertex gradients
|
|
/// with a transparent edge, spanning one dimension of the document viewport.
|
|
fn viewport_fades(h: &Harness<'static, PdfCraftApp>) -> Vec<egui::Rect> {
|
|
let viewport = h.state().views[0].viewport_rect();
|
|
h.output()
|
|
.shapes
|
|
.iter()
|
|
.filter_map(|s| {
|
|
let egui::Shape::Mesh(mesh) = &s.shape else { return None };
|
|
if mesh.texture_id != egui::TextureId::default() || mesh.vertices.len() != 4 || mesh.indices.len() != 6 {
|
|
return None;
|
|
}
|
|
let rect = mesh.calc_bounds();
|
|
let transparent = mesh.vertices.iter().filter(|v| v.color == egui::Color32::TRANSPARENT).count();
|
|
(transparent == 2
|
|
&& (((rect.width() - viewport.width()).abs() < 2.0 && rect.height() <= 21.0)
|
|
|| ((rect.height() - viewport.height()).abs() < 2.0 && rect.width() <= 21.0))
|
|
&& rect.intersects(viewport))
|
|
.then_some(rect)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn organize_shadows_follow_the_preference() {
|
|
for theme in ["light", "dark"] {
|
|
let mut h = harness(theme);
|
|
h.state_mut().set_option("organize", "on").unwrap();
|
|
h.run_steps(3);
|
|
let original = shadows(&h, true);
|
|
assert_eq!(original.len(), 3);
|
|
h.state_mut().set_option("show_page_edge_shadows", "false").unwrap();
|
|
h.run_steps(2);
|
|
assert!(shadows(&h, true).is_empty());
|
|
h.state_mut().set_option("show_page_edge_shadows", "true").unwrap();
|
|
h.run_steps(2);
|
|
assert_eq!(shadows(&h, true), original);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn checkbox_and_multiple_documents_do_not_edit_pdf_state() {
|
|
let mut h = harness("light");
|
|
h.state_mut().open_bytes("second.pdf", None, pdf()).unwrap();
|
|
let ids: Vec<_> = h.state().views.iter().map(|v| v.id).collect();
|
|
h.state_mut().session.apply(ids[0], pdfcraft_engine::Edit::SetInfo { key: "Title".into(), value: "Keep existing edits".into() }).unwrap();
|
|
assert!(h.state().session.get(ids[0]).unwrap().dirty);
|
|
assert!(h.state().session.get(ids[0]).unwrap().can_undo().is_some());
|
|
let saved: Vec<_> = ids.iter().map(|id| h.state_mut().session.save_bytes(*id).unwrap()).collect();
|
|
let before: Vec<_> = h
|
|
.state()
|
|
.views
|
|
.iter()
|
|
.map(|v| {
|
|
let d = h.state().session.get(v.id).unwrap();
|
|
(
|
|
d.bytes.clone(),
|
|
d.dirty,
|
|
d.can_undo().map(str::to_owned),
|
|
d.can_redo().map(str::to_owned),
|
|
d.info.pages.iter().map(|p| (p.width, p.height, p.rotation)).collect::<Vec<_>>(),
|
|
)
|
|
})
|
|
.collect();
|
|
h.state_mut().set_option("dialog", "preferences").unwrap();
|
|
h.run_steps(3);
|
|
let checkbox = h.get_by_label("Show Page Edge Shadows");
|
|
checkbox.click();
|
|
h.run_steps(2);
|
|
assert_eq!(serde_json::from_str::<serde_json::Value>(&h.state().persist()).unwrap()["show_page_edge_shadows"], false);
|
|
h.get_by_label("Show Page Edge Shadows").focus();
|
|
h.key_press(egui::Key::Space);
|
|
h.run_steps(2);
|
|
assert_eq!(serde_json::from_str::<serde_json::Value>(&h.state().persist()).unwrap()["show_page_edge_shadows"], true);
|
|
let dialog_shadow = h.ctx.global_style().visuals.window_shadow;
|
|
assert_ne!(dialog_shadow, egui::Shadow::NONE);
|
|
h.state_mut().set_option("show_page_edge_shadows", "false").unwrap();
|
|
h.run_steps(2);
|
|
assert_eq!(h.ctx.global_style().visuals.window_shadow, dialog_shadow);
|
|
h.state_mut().set_option("dialog", "none").unwrap();
|
|
for tab in [0, 1] {
|
|
h.state_mut().active = Some(tab);
|
|
h.run_steps(2);
|
|
assert!(shadows(&h, false).is_empty());
|
|
let d = h.state().session.get(h.state().views[tab].id).unwrap();
|
|
assert_eq!(
|
|
(&d.bytes, d.dirty, d.can_undo(), d.can_redo()),
|
|
(&before[tab].0, before[tab].1, before[tab].2.as_deref(), before[tab].3.as_deref())
|
|
);
|
|
assert_eq!(d.info.pages.iter().map(|p| (p.width, p.height, p.rotation)).collect::<Vec<_>>(), before[tab].4);
|
|
}
|
|
for (id, bytes) in ids.iter().zip(&saved) {
|
|
assert_eq!(h.state_mut().session.save_bytes(*id).unwrap(), *bytes);
|
|
}
|
|
h.state_mut().open_bytes("new.pdf", None, pdf()).unwrap();
|
|
h.run_steps(2);
|
|
assert!(shadows(&h, false).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn rendered_page_content_is_identical_with_and_without_decoration() {
|
|
let mut h = harness("dark");
|
|
h.state_mut().set_option("panel", "pages").unwrap();
|
|
h.state_mut().set_option("zoom", "175").unwrap();
|
|
h.run_steps(3);
|
|
scroll_down(&mut h, 20.0);
|
|
for _ in 0..400 {
|
|
h.run_steps(2);
|
|
if !h.state().render_pending() {
|
|
break;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
}
|
|
assert!(!h.state().render_pending(), "the actual PDF raster must be ready");
|
|
let rect = h.state().views[0].page_screen_rect(0).unwrap();
|
|
let fades = viewport_fades(&h);
|
|
assert_eq!(fades.iter().filter(|r| r.height() <= 21.0).count(), 2, "the screenshot comparison must contain both vertical fades");
|
|
let enabled = h.render().unwrap();
|
|
h.state_mut().set_option("show_page_edge_shadows", "false").unwrap();
|
|
h.run_steps(2);
|
|
let disabled = h.render().unwrap();
|
|
assert_eq!(h.state().views[0].page_screen_rect(0), Some(rect));
|
|
let viewport = h.state().views[0].viewport_rect();
|
|
assert!(viewport_fades(&h).is_empty());
|
|
// Only the painter's actual decorative fade strips may change over the page.
|
|
// All remaining artwork, annotation and form pixels must be identical.
|
|
for (_, page) in h.state().views[0].visible_page_rects() {
|
|
let visible = page.intersect(viewport).shrink(2.0);
|
|
if !visible.is_positive() {
|
|
continue;
|
|
}
|
|
for y in visible.top().ceil() as u32..visible.bottom().floor() as u32 {
|
|
for x in visible.left().ceil() as u32..visible.right().floor() as u32 {
|
|
if !fades.iter().any(|fade| fade.expand(1.0).contains(egui::pos2(x as f32, y as f32))) {
|
|
assert_eq!(enabled.get_pixel(x, y), disabled.get_pixel(x, y), "page content at {x},{y}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// The top overlaps the first page's blue artwork; the bottom overlaps the
|
|
// blank second page. Sample clear of the unchanged scrollbar at the edge.
|
|
let x = rect.intersect(viewport).center().x as u32;
|
|
for (y, expected) in [(viewport.top() + 8.0, [204, 230, 255, 255]), (viewport.bottom() - 10.0, [255, 255, 255, 255])] {
|
|
assert_eq!(disabled.get_pixel(x, y as u32).0, expected, "unobscured PDF at y={y}");
|
|
assert_ne!(enabled.get_pixel(x, y as u32).0, expected, "fade overlays the PDF at y={y}");
|
|
}
|
|
// The Pages sidebar, including its thumbnails and selection borders, is unaffected.
|
|
for y in 0..enabled.height() {
|
|
for x in (0..viewport.left().floor() as u32).chain(viewport.right().ceil() as u32..enabled.width()) {
|
|
assert_eq!(enabled.get_pixel(x, y), disabled.get_pixel(x, y), "sidebar at {x},{y}");
|
|
}
|
|
}
|
|
assert_ne!(enabled.as_raw(), disabled.as_raw(), "the decoration really changes the image");
|
|
if let Ok(directory) = std::env::var("PDFCRAFT_SHADOW_SCREENSHOTS") {
|
|
enabled.save(format!("{directory}/page-shadows-enabled.png")).unwrap();
|
|
disabled.save(format!("{directory}/page-shadows-disabled.png")).unwrap();
|
|
}
|
|
}
|
|
|
|
fn harness(theme: &str) -> Harness<'static, PdfCraftApp> {
|
|
let theme = theme.to_string();
|
|
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_| {
|
|
let mut app = PdfCraftApp::new();
|
|
// Save comparisons must not depend on crossing a /ModDate second boundary.
|
|
app.session = app.session.with_clock(|| 1_700_000_000);
|
|
app.set_option("language", "en").unwrap();
|
|
app.set_option("theme", &theme).unwrap();
|
|
app.open_bytes("pages.pdf", None, pdf()).unwrap();
|
|
app.set_option("zoom", "100").unwrap();
|
|
app
|
|
});
|
|
h.run_steps(3);
|
|
h
|
|
}
|
|
|
|
fn shadows(h: &Harness<'static, PdfCraftApp>, grid: bool) -> Vec<egui::Rect> {
|
|
let color = pdfcraft_ui_egui::theme::Tokens::for_kind(h.state().theme).page_shadow;
|
|
fn collect(shape: &egui::Shape, color: egui::Color32, grid: bool, out: &mut Vec<egui::Rect>) {
|
|
match shape {
|
|
egui::Shape::Vec(shapes) => {
|
|
for s in shapes {
|
|
collect(s, color, grid, out);
|
|
}
|
|
}
|
|
egui::Shape::Rect(r)
|
|
if r.fill == color
|
|
&& r.rect.width() > 20.0
|
|
&& r.rect.height() > 20.0
|
|
&& if grid {
|
|
r.blur_width == 0.0 && r.corner_radius == egui::CornerRadius::same(1)
|
|
} else {
|
|
r.blur_width == 14.0 && r.corner_radius == egui::CornerRadius::ZERO
|
|
} =>
|
|
{
|
|
out.push(r.rect)
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
let mut out = Vec::new();
|
|
for s in &h.output().shapes {
|
|
collect(&s.shape, color, grid, &mut out);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Synthetic pages include colored artwork, an annotation and a form field.
|
|
fn pdf() -> Vec<u8> {
|
|
let objects = [
|
|
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [7 0 R] >> >>".to_string(),
|
|
"<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>".to_string(),
|
|
"<< /Type /Page /Parent 2 0 R /Contents 8 0 R /Annots [6 0 R 7 0 R] >>".to_string(),
|
|
"<< /Type /Page /Parent 2 0 R >>".to_string(),
|
|
"<< /Type /Page /Parent 2 0 R >>".to_string(),
|
|
"<< /Type /Annot /Subtype /Text /Rect [20 20 40 40] /Contents (Note) >>".to_string(),
|
|
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (field) /V (value) /Rect [50 50 100 70] /P 3 0 R >>".to_string(),
|
|
{
|
|
let body = "0.8 0.9 1 rg 0 0 300 400 re f 0.3 g 10 10 280 8 re f";
|
|
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())
|
|
},
|
|
];
|
|
let mut bytes = b"%PDF-1.7\n".to_vec();
|
|
let mut offsets = Vec::new();
|
|
for (i, object) in objects.iter().enumerate() {
|
|
offsets.push(bytes.len());
|
|
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
|
}
|
|
let xref = bytes.len();
|
|
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
|
for offset in offsets {
|
|
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
|
}
|
|
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
|
bytes
|
|
}
|
|
|
|
#[test]
|
|
fn page_shadow_preference_round_trips_and_ignores_invalid_settings() {
|
|
let mut app = PdfCraftApp::new();
|
|
let enabled = |app: &PdfCraftApp| serde_json::from_str::<serde_json::Value>(&app.persist()).unwrap()["show_page_edge_shadows"].clone();
|
|
assert_eq!(enabled(&app), true);
|
|
app.restore("{}");
|
|
assert_eq!(enabled(&app), true);
|
|
for value in [false, true] {
|
|
app.set_option("show_page_edge_shadows", if value { "true" } else { "false" }).unwrap();
|
|
let mut restored = PdfCraftApp::new();
|
|
restored.restore(&app.persist());
|
|
assert_eq!(enabled(&restored), value);
|
|
}
|
|
for invalid in ["null", "0", "\"false\"", "[]", "{}"] {
|
|
app.restore(&format!("{{\"show_page_edge_shadows\":{invalid}}}"));
|
|
assert_eq!(enabled(&app), true);
|
|
}
|
|
app.restore("not json");
|
|
assert_eq!(enabled(&app), true);
|
|
assert!(app.set_option("show_page_edge_shadows", "maybe").is_err());
|
|
assert_eq!(enabled(&app), true);
|
|
}
|