Merge pull request #4727 from mockydevs/fix/ai-cropped-page-units-and-pages

.ai import: pages cropped to the art in UserUnit units, and their copies' type
This commit is contained in:
BFlat
2026-10-31 00:51:31 +07:00
committed by GitHub
3 changed files with 80 additions and 9 deletions
+30 -8
View File
@@ -61,7 +61,11 @@ pub(super) fn build_from(
// Editing data in the units of the page's user space ([`in_units`]) is compared with the page
// scaled down to those units, and the document read from it is scaled up by `unit`.
let board = artboard.map(|a| to_doc.transform_rect_bbox(a));
let small = visible.filter(|v| matches!(page, Page::Artboard) && in_units(board, v, unit)).map(|v| {
// A page cropped to the art (see below) is in those units as well, when the art's box is and
// the artboard isn't the page in points.
let art = bbox.map(|[a, b, c, d]| to_doc.transform_rect_bbox(Rect::new(a, b, c, d)));
let units = |v: &Document| in_units(board, v, unit) || (!same_size(board, v) && in_units(art, v, unit));
let small = visible.filter(|v| matches!(page, Page::Artboard) && units(v)).map(|v| {
let mut v = v.clone();
scale_document(&mut v, 1.0 / unit);
v
@@ -83,12 +87,15 @@ pub(super) fn build_from(
let mut rect = to_doc.transform_rect_bbox(art_box);
// A `.ai` whose PDF part's pages are cropped to its art (each page the box of all of it,
// the editing data's bounding box) rather than to its artboards: the layers are compared
// with the page on that box, and keep their artboards.
// with the page on that box, and keep their artboards. Each page then holds all the art
// again, so every page must be that box.
let mut cropped = false;
if matches!(page, Page::Artboard)
&& !fits(&rect)
&& let Some(art) = bbox.map(|[a, b, c, d]| to_doc.transform_rect_bbox(Rect::new(a, b, c, d))).filter(fits)
&& visible.artboards.iter().all(|a| fits(&a.rect))
&& let Some(art) = art.filter(fits)
{
rect = art;
(rect, cropped) = (art, true);
}
if !fits(&rect) {
// Both sizes in points as the fields show them, and the editing data's page to the
@@ -123,12 +130,21 @@ pub(super) fn build_from(
let same =
|(a, p): (&vectorcraft_doc::Artboard, &Rect)| (a.rect.width() - p.width()).abs() <= 0.5 && (a.rect.height() - p.height()).abs() <= 0.5;
let boards = (pages.len() == doc.artboards.len() && doc.artboards.iter().zip(&pages).all(same)).then_some(&doc.artboards);
for object in shown.iter_mut().chain(hidden.iter_mut()).chain(lone.iter_mut()) {
// The page with most of it, the first for what is on none.
let on = extent(object).and_then(|r| {
// The page with most of an object, the first for what is on none.
let page_of = |object: &[Arc<Node>]| {
extent(object).and_then(|r| {
let inside = |p: &Rect| p.intersect(r).area().max(0.0);
pages.iter().enumerate().skip(1).filter(|(_, p)| inside(p) > inside(&page_rect)).max_by(|a, b| inside(a.1).total_cmp(&inside(b.1)))
});
})
};
// Pages cropped to the art are copies of one another: the type is the first page's.
if cropped {
for objects in [&mut shown, &mut hidden, &mut lone] {
objects.retain(|o| page_of(o).is_none());
}
}
for object in shown.iter_mut().chain(hidden.iter_mut()).chain(lone.iter_mut()) {
let on = page_of(object);
let shift = match on.and_then(|(i, page)| Some((boards?.get(i)?, page))) {
Some((board, page)) => Affine::translate(board.rect.origin() - page.origin()),
None => shift,
@@ -268,6 +284,12 @@ fn elsewhere(doc: &Document, placed: &[(NodeId, Affine)]) -> Option<Document> {
Some(doc)
}
/// Has `r` the size of `page`'s first artboard (in points)?
fn same_size(r: Option<Rect>, page: &Document) -> bool {
let (Some(r), Some(p)) = (r, page.artboards.first().map(|a| a.rect)) else { return false };
(r.width() - p.width()).abs() <= 0.5 && (r.height() - p.height()).abs() <= 0.5
}
/// Does the editing data's first artboard `board` (in the document read from it: art space through
/// `to_doc`) have the size of `page`'s first artboard in units of `unit` points, `unit` being above
/// 1, and not its size in points? `page` is a PDF part read in points from pages of `unit` points a
+48
View File
@@ -374,6 +374,54 @@ fn editing_data_in_the_units_of_a_user_unit_page_comes_in_at_the_pages_size() {
assert!(near(first(&d, 0), [100.0, 860.0, 140.0, 900.0]), "{:?}", first(&d, 0));
}
/// A PDF part read from a `UserUnit` page cropped to the art: the art's box, not the artboard, is
/// the page in the editing data's units, so the layers are read at the page's size, with their
/// artboard.
#[test]
fn a_user_unit_page_cropped_to_the_art_is_read_in_its_units() {
// A 50 × 50 artboard, art over 100 × 100 (its bounding box), and a page of that box at 10 pt a
// unit: 1000 × 1000 pt.
let art = native(&layer("Jig", true, &square(10, 10))).replace("%AI3_Cropmarks: 0 0 100 100", "%AI3_Cropmarks: 0 0 50 50");
let page = in_points(pdf_part(10.0, 86.0), 10.0);
let (d, w, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 10.0);
assert!(read, "{w:?}");
assert_eq!(d.artboards[0].rect.size(), vectorcraft_geom::Size::new(500.0, 500.0));
// The square, 36 down the artboard, ten times as large and as far.
let b = d.layers[0].children().unwrap()[0].visual_bounds().unwrap();
assert!([b.x0 - 100.0, b.y0 - 360.0, b.x1 - 140.0, b.y1 - 400.0].iter().all(|v| v.abs() < 1e-6), "{b:?}");
}
/// A PDF part whose pages are each cropped to all of the art holds the art once a page: the type
/// of the pages after the first is that type again, and only the first page's goes into the
/// layers.
#[test]
fn type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once() {
// A 50 × 50 artboard, art over 100 × 100, a text object with no story in the file, and two
// pages of the art's box in a row, 20 apart, each with the square and the type.
let art = native(&layer("Art", true, &(square(10, 10) + &text_object(0)))).replace("%AI3_Cropmarks: 0 0 100 100", "%AI3_Cropmarks: 0 0 50 50");
let mut pdf = pdf_part(10.0, 86.0);
let mut second = pdf.artboards[0].clone();
(second.id, second.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0));
pdf.artboards.push(second);
let copy = pdf_part(130.0, 86.0).layers[0].children().unwrap()[0].as_ref().clone();
let layer_id = pdf.layers[0].id;
let id = pdf.alloc_id();
pdf.insert(Some(layer_id), 1, Node { id, ..copy }).unwrap();
for x in [30.0, 150.0] {
let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() };
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(x, 40.0), "Hi", style);
let id = pdf.alloc_id();
pdf.insert(Some(layer_id), 2, Node::new(id, NodeKind::Text(Box::new(text)))).unwrap();
}
let (d, w, read) = crate::layered_ai(&private(&art), pdf, vec![], false, false, 1.0);
assert!(read, "{w:?}");
let texts = text_of(&d.layers[0]);
// The first page's type, where the art has it: 50 above the artboard's top.
assert_eq!(texts.iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["Hi"], "{w:?}");
let [.., e, f] = texts[0].xf.as_coeffs();
assert!((e - 30.0).abs() < 1e-6 && (f + 10.0).abs() < 1e-6, "{:?}", texts[0].xf);
}
#[test]
fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() {
let pdf = pdf_part(10.0, 86.0);
+2 -1
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai` import: the placed files' other placement weighs the page's unseen text again) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai` import: pages cropped to the art in `UserUnit` units, and their copies' type) · **Target:** Adobe Illustrator 2026 (30.x)
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
@@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | `.ai` import: a PDF part whose pages are cropped to the art is recognised in a `UserUnit` page's units too (the art's box in those units, the artboard not the page in points), and its type is read from the first page only, since every such page holds all the art again: the copies on later pages were moved as the first page's and could be put into the layers' text objects. A page cropped to the art is taken only when every page is that box. Tests `import::tests_native::a_user_unit_page_cropped_to_the_art_is_read_in_its_units`, `type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`; scores unchanged |
| 2026-10-30 | minor | `.ai` import: when the layers are compared with the page again with placed files at the corner of their box, the text objects the page doesn't draw are weighed with the images there, from the layers as read: before, the text an image covered at its first placement had already been left out, so a file whose image covered type only there came in without that type or as its PDF part. Test `import::tests_native::text_a_placed_file_covers_only_at_its_first_placement_is_kept`; scores unchanged |
| 2026-10-30 | minor | `.ai` import: type read from the editing data's text document keeps its case setting (character attribute 12: 1 Small Caps, at Document Setup's default size, 2 All Caps); it came in as typed, so a heading set in All Caps showed in lower or mixed case where the page draws capitals. Test `import::tests_native::type_from_the_text_document_keeps_its_case_setting`; checked locally against a user's files; scores unchanged |
| 2026-10-30 | minor | PDF import: a patch or triangle mesh shading of up to 16,384 patches comes in as gradient meshes (it was 4,096; Illustrator writes up to 14,355 in files users sent), and a stroke painted with one (how Illustrator writes a freeform gradient on a stroke) is the meshes clipped to the area the stroke paints, with its width, caps, joins and dashes; both came in as a flat grey. In two files of a user's set, the formerly grey area now matches a reference rendering in 99% of its pixels. Own-fixture tests in `tests::import_fidelity`; scores unchanged |