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 <noreply@anthropic.com>
This commit is contained in:
fastmikey99 2026-10-09 17:11:07 +13:00 committed by bflatastic
parent 57932f1204
commit 1d0305fc2a
3 changed files with 44 additions and 1 deletions

View File

@ -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<ImportReport, PdfError> {
let original = crate::pages::open(bytes, opts.password.as_deref())?;
@ -227,6 +230,27 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
}
parts.retain(|(_, art)| !art.is_empty());
}
// Viewers show a page through its crop box, so art reaching past it (a mock-up backdrop
// around the page, say) never showed. A clip to the page box that the file states is
// dropped as redundant (by hayro and by `push_clip_path`), so restore the box here:
// each object reaching past it keeps its own clip, which Fit to Artwork Bounds and
// `CropTo::Bounding` then respect.
// The crop box is what viewers show, whichever box the artboard takes.
let shown = xf.transform_rect_bbox(crate::pages::page_box(page, CropTo::Crop));
let page_box = shown.inflate(0.01, 0.01);
let mut clipped = false;
for (_, art) in &mut parts {
for n in art.iter_mut() {
if n.visual_bounds().is_some_and(|r| !contains(page_box, r)) {
let clip = Arc::new(b.clip_node(&shown.to_path(0.1), FillRule::NonZero));
*n = Arc::new(Node::new(b.id(), NodeKind::Group { children: vec![clip, n.clone()], clip: true }));
clipped = true;
}
}
}
if clipped {
b.warn(PAST_PAGE_NOTE);
}
let children: Vec<Arc<Node>> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect();
placeholder &= ai && only_text(&children);
let mut right = ab.x1;

View File

@ -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<Node> = 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);
}

View File

@ -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
}