From 710b6058caaee6bb7a9da2dc309e2602afb19525 Mon Sep 17 00:00:00 2001 From: EricKuck Date: Wed, 14 Oct 2026 13:59:21 -0400 Subject: [PATCH] AI import: non-native art stays upright unless its placement mirrors it Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/ai/read.rs | 15 +++++++++------ crates/eps/src/import/ai/tests.rs | 32 +++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 6956c01ab..b1b2379a5 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -1283,12 +1283,15 @@ impl<'a> Reader<'a> { if bbox.width() <= 0.0 || bbox.height() <= 0.0 { return Ok(()); } - // The art's own box onto the object's box (its measure of the same art), upside down: - // the object's space has y down, and the PDF's content is kept in its own y-up - // coordinates, so the PDF's bottom is the top of the box. - let fit = Affine::translate((target.x0, target.y1)) - * Affine::scale_non_uniform(target.width() / bbox.width(), -target.height() / bbox.height()) - * Affine::translate((-bbox.x0, -bbox.y0)); + // The art's own box onto the object's box (its measure of the same art). The PDF importer + // hands the art back as the page shows it, and it keeps that orientation unless its + // placement mirrors it (a negative determinant): the usual `/RTransform` `1 0 0 -1 …` with + // the origin's flip doesn't, and the art then shows as the file's own PDF part draws it. + let fit = if place.determinant() < 0.0 { + Affine::translate((target.x0, target.y1)) * Affine::scale_non_uniform(target.width() / bbox.width(), -target.height() / bbox.height()) + } else { + Affine::translate((target.x0, target.y0)) * Affine::scale_non_uniform(target.width() / bbox.width(), target.height() / bbox.height()) + } * Affine::translate((-bbox.x0, -bbox.y0)); for n in &mut art { n.transform(fit, true); // The PDF's swatches stay with it: its colours keep their values, unlinked. diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 5b201fd68..19ff88193 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -718,8 +718,8 @@ fn ascii85_comment_lines(bytes: &[u8]) -> String { /// `/Bounds` in a space measured from `/Origin` with y down, then `/RTransform`. Two squares at /// 1..5 × 2..8 of a 10-point page, blue below red: bounds (1, 2)–(5, 8) → from the origin /// (10, 20), y down, (11, 18)–(15, 12) → twice that plus (100, 50): x 122..130, y 74..86 in art -/// space, which is y 14..26 in the document (crop marks 100 high). The space has y down, so the -/// PDF's bottom (blue) is the top of the box. +/// space, which is y 14..26 in the document (crop marks 100 high). That placement mirrors (`2 0 0 2` +/// with the origin's flip), so the PDF's bottom (blue) is the top of the box. #[test] fn non_native_art_is_drawn_from_its_pdf_fitted_to_its_bounds() { let pdf = tiny_pdf(10.0, "0 0 1 rg 1 2 4 3 re f 1 0 0 rg 1 5 4 3 re f"); @@ -756,6 +756,34 @@ fn non_native_art_is_drawn_from_its_pdf_fitted_to_its_bounds() { assert_ne!(paths[0].id, paths[1].id); } +/// The usual placement, `/RTransform` `1 0 0 -1 …`, doesn't mirror (with the origin's flip its +/// determinant is positive), so the art shows upright, as the file's own PDF part draws it: the +/// PDF's top (red) at the top. Bounds +/// (1, 2)–(5, 8) from the origin (10, 20), y down, → (11, 18)–(15, 12) → x 111..115, y 32..38 in +/// art space, y 62..68 in the document (crop marks 100 high). +#[test] +fn non_native_art_placed_without_a_mirror_stays_upright() { + let pdf = tiny_pdf(10.0, "0 0 1 rg 1 2 4 3 re f 1 0 0 rg 1 5 4 3 re f"); + let foreign = format!( + "q\n/ForeignObject :\n1 /Version ,\n1 0 0 -1 100 50 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n{};\n0 0 Xd\n6 () XW\n0 Ae\n{}h\nW\nn\nQ\n", + ascii85_comment_lines(&pdf), + rect_path(110.0, 30.0, 116.0, 40.0) + ); + let s = read_ok(&layer(&foreign)); + assert!(s.warnings.is_empty(), "{:?}", s.warnings); + let objects = art(&s.doc); + let NodeKind::Group { children, clip: true } = &objects[0].kind else { panic!("{:?}", objects[0].kind) }; + let group = &children[1]; + let bounds = group.geometric_bounds().unwrap(); + for (got, want) in [(bounds.x0, 111.0), (bounds.y0, 62.0), (bounds.x1, 115.0), (bounds.y1, 68.0)] { + assert!((got - want).abs() < 1e-6, "{bounds:?}"); + } + let paths = group.children().unwrap(); + let (blue, red) = (paths[0].geometric_bounds().unwrap(), paths[1].geometric_bounds().unwrap()); + assert!((red.y0 - 62.0).abs() < 1e-6 && (red.y1 - 65.0).abs() < 1e-6, "red {red:?}"); + assert!((blue.y0 - 65.0).abs() < 1e-6 && (blue.y1 - 68.0).abs() < 1e-6, "blue {blue:?}"); +} + #[test] fn cmyk_documents() { let doc = read_ok(&format!("%AI9_ColorModel: 2\n{}", layer(""))).doc;