From 1d0305fc2a563beafb6170801aa115510209147b Mon Sep 17 00:00:00 2001 From: fastmikey99 <218057203+fastmikey99@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:11:07 +1300 Subject: [PATCH] PDF import: clip art reaching past the page to the crop box A clip to the page box is dropped as redundant (by hayro, and by push_clip_path), so art a file hides outside the page - a mock-up backdrop thousands of points across, say - came in unclipped: it showed, and it sized Fit to Artwork Bounds and CropTo::Bounding to metres. Viewers show a page through its crop box, so each object reaching past it now keeps its own clip to it, with a warning. Art inside the page is unchanged (no clip groups). Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/import.rs | 24 ++++++++++++++++++++++++ crates/pdf/src/tests_import_fidelity.rs | 18 ++++++++++++++++++ crates/pdf/src/tests_import_layers.rs | 3 ++- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index b59f28c3..a570db28 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -135,6 +135,9 @@ fn group_layer( pub const OFF_ARTBOARD_NOTE: &str = "only the PDF-compatible part of this file was read: art outside its artboards is kept in the editor's private data alone, so it doesn't open"; +/// Warning: art reaching past the page box was clipped to it. +pub(crate) const PAST_PAGE_NOTE: &str = "art reaching past the page was clipped to it, as PDF viewers show it"; + /// Import a PDF, returning the document plus warnings about content that was approximated or skipped. pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result { let original = crate::pages::open(bytes, opts.password.as_deref())?; @@ -227,6 +230,27 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect(); placeholder &= ai && only_text(&children); let mut right = ab.x1; diff --git a/crates/pdf/src/tests_import_fidelity.rs b/crates/pdf/src/tests_import_fidelity.rs index b6098eed..1881bfa4 100644 --- a/crates/pdf/src/tests_import_fidelity.rs +++ b/crates/pdf/src/tests_import_fidelity.rs @@ -446,3 +446,21 @@ fn a_stroke_under_a_stretched_transform_stays_a_stroke() { let b = objs[0].geometric_bounds().unwrap(); assert!((b.x0 - 20.0).abs() < 1e-6 && (b.x1 - 80.0).abs() < 1e-6, "{b:?}"); } + +#[test] +fn art_past_the_page_clip_keeps_the_clip() { + // A page-sized clip, then a backdrop far larger than the page and a square on it: the square + // needs no clip, the backdrop keeps it, so the art's bounds stay on the page. + let r = open_with(&one_page("0 0 100 100 re W n 0 g -500 -500 1100 1100 re f 1 0 0 rg 10 10 20 20 re f", "", &[]), |_| {}); + let d = &r.document; + let art = d.art_bounds().expect("art"); + assert!(art.x0 >= -0.01 && art.y0 >= -0.01 && art.x1 <= 100.01 && art.y1 <= 100.01, "the art stays on the page: {art:?}"); + let clips: Vec = all(d).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect(); + let [c] = clips.as_slice() else { panic!("one clip group, the backdrop's: {clips:?}") }; + assert_eq!(c.children().map(Vec::len), Some(2), "the clip path and the backdrop"); + assert!(r.warnings.iter().any(|w| w == crate::import::PAST_PAGE_NOTE), "{:?}", r.warnings); + // Art inside the page gets no clip and no warning. + let r = open_with(&one_page("0 0 100 100 re W n 1 0 0 rg 10 10 20 20 re f", "", &[]), |_| {}); + assert!(!all(&r.document).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. }))); + assert!(!r.warnings.iter().any(|w| w == crate::import::PAST_PAGE_NOTE), "{:?}", r.warnings); +} diff --git a/crates/pdf/src/tests_import_layers.rs b/crates/pdf/src/tests_import_layers.rs index 937a65d9..690ff1b0 100644 --- a/crates/pdf/src/tests_import_layers.rs +++ b/crates/pdf/src/tests_import_layers.rs @@ -165,9 +165,10 @@ fn an_ai_file_notes_that_its_art_off_the_artboards_is_not_read() { } } +/// Paths that are art (not clipping paths: art reaching past the page keeps a clip to it). fn path_count(d: &Document) -> usize { let mut n = 0; - d.walk(|c| n += usize::from(matches!(c.kind, NodeKind::Path { .. }))); + d.walk(|c| n += usize::from(matches!(c.kind, NodeKind::Path { clipping: false, .. }))); n }