Merge pull request #675 from fastmikey99/fix/pdf-import-past-page-clip

PDF import: clip art reaching past the page to the crop box
This commit is contained in:
BFlat
2026-10-10 08:41:47 +07:00
committed by GitHub
4 changed files with 65 additions and 2 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+26
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,29 @@ 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. Not in a
// PDF-compatible `.ai`: its PDF part stands in for the editable document, whose art
// crossing the artboard edge isn't clipped, so it opens whole (#646).
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 parts.iter_mut().filter(|_| !ai) {
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;
+18
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);
}
+19
View File
@@ -367,6 +367,25 @@ fn art_past_the_scan_limit_of_shown_layers_stays_shown() {
assert!(r.warnings.iter().any(|w| w.contains("went to its page's layer")), "{:?}", r.warnings);
}
/// Art reaching past the page keeps a clip to it in a plain PDF (as viewers show it), but not in
/// a PDF-compatible `.ai`, whose art crossing the artboard edge opens whole and editable (#646).
#[test]
fn art_past_the_page_is_clipped_in_a_pdf_but_not_in_an_ai_file() {
for ai in [false, true] {
let page = PdfPage {
entries: if ai { "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>".into() } else { String::new() },
..PdfPage::new(100.0, 100.0, "0 0 100 100 re W n 1 0 0 rg 50 50 200 200 re f")
};
let r = import_with_report(&pdf_with_catalog(&[page], &[], "", None), &ImportOptions::default()).unwrap();
let mut clips = 0;
r.document.walk(|n| clips += usize::from(matches!(n.kind, NodeKind::Group { clip: true, .. })));
let past = r.warnings.iter().any(|w| w == crate::import::PAST_PAGE_NOTE);
assert_eq!((clips, past), if ai { (0, false) } else { (1, true) }, "ai: {ai}: {:?}", r.warnings);
let art = r.document.art_bounds().unwrap();
assert_eq!(art.x1 > 100.5, ai, "ai: {ai}: {art:?}");
}
}
/// The editor's private data of a `.ai` is its `AIPrivateData` streams, joined in order.
#[test]
fn an_ai_files_private_data_is_its_streams_joined() {