Merge pull request #4759 from mockydevs/fix/ai-layers-vs-page

.ai import: compare a PDF of some artboards on the artboard its page shows
This commit is contained in:
BFlat
2026-10-31 05:09:48 +07:00
committed by GitHub
3 changed files with 72 additions and 5 deletions
+40 -4
View File
@@ -1,4 +1,4 @@
use super::compare::{MAX_DIFFERENCE, PageView, compare};
use super::compare::{MAX_DIFFERENCE, PageView, compare, difference};
use super::plan::plan;
use super::slots::{
ART_LEFT_OUT, Content, MAX_PAIRS, MAX_SLOTS, MAX_TEXT, OUTLINE_REACH, TEXT_LEFT_OUT, TYPE_LEFT_OUT, collect_slots, extent, few, fill_slots,
@@ -85,6 +85,10 @@ pub(super) fn build_from(
// scale, which `to_doc` carries, so its art and the page reach the same size (#2031).
let fits = |r: &Rect| (r.width() - page_rect.width()).abs() <= 0.5 && (r.height() - page_rect.height()).abs() <= 0.5;
let mut rect = to_doc.transform_rect_bbox(art_box);
// A PDF saved with some of its artboards has pages for those only, the first of them not
// always the first artboard: the editing data keeps them all (`first`, the page's).
let first = shown_artboard(&doc, visible, page, page_rect, fits);
rect = doc.artboards.get(first).filter(|_| first > 0).map_or(rect, |a| a.rect);
// 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. Each page then holds all the art
@@ -127,9 +131,7 @@ pub(super) fn build_from(
// A `.ai` read as its PDF part has a page for each artboard, in a row: the type on each
// goes where the layers have that artboard.
let pages: Vec<Rect> = visible.artboards.iter().map(|a| a.rect).collect();
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);
let boards = page_boards(&doc.artboards, first, &pages);
// 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| {
@@ -414,6 +416,40 @@ fn in_units(board: Option<Rect>, page: &Document, unit: f64) -> bool {
unit.is_finite() && unit > 1.0 && fits(unit) && !fits(1.0)
}
/// The most artboards [`shown_artboard`] weighs, a render each.
const MAX_BOARDS_WEIGHED: usize = 16;
/// Which of the artboards of `doc` (the layers' document) the page `page_rect` of `visible` shows,
/// when `visible` has fewer pages than `doc` has artboards (a PDF saved with a range of them): of
/// those of the page's size (`fits`), the one whose art draws most like the page, the first of
/// them on a tie. Else (and for an EPS, whose page is its art) the first artboard (0).
fn shown_artboard(doc: &Document, visible: &Document, page: Page, page_rect: Rect, fits: impl Fn(&Rect) -> bool) -> usize {
if matches!(page, Page::Art) || visible.artboards.len() >= doc.artboards.len() {
return 0;
}
let sized: Vec<usize> = doc.artboards.iter().enumerate().filter(|(_, a)| fits(&a.rect)).map(|(i, _)| i).take(MAX_BOARDS_WEIGHED).collect();
if let [only] = sized.as_slice() {
return *only;
}
let mut best: Option<(usize, f64)> = None;
for (i, a) in sized.iter().filter_map(|&i| Some((i, doc.artboards.get(i)?))) {
let d = difference(visible, page_rect, doc, a.rect).any;
if best.is_none_or(|(_, b)| d < b) {
best = Some((i, d));
}
}
best.map_or(0, |(i, _)| i)
}
/// The artboards of the layers' document (`boards`) that the pages show, one a page from the
/// first page's (`first`), when they are the pages' sizes: every artboard, or those of a PDF saved
/// with some of them (`first` after the first artboard).
fn page_boards<'a>(boards: &'a [vectorcraft_doc::Artboard], first: usize, pages: &[Rect]) -> Option<&'a [vectorcraft_doc::Artboard]> {
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 shown = boards.get(first..first.saturating_add(pages.len()))?;
((first > 0 || pages.len() == boards.len()) && shown.iter().zip(pages).all(same)).then_some(shown)
}
/// Draw `doc`'s layers and artboards `k` times as large, from the origin, with their strokes,
/// effects and pattern tiles.
fn scale_document(doc: &mut Document, k: f64) {
+30
View File
@@ -500,6 +500,36 @@ fn type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once() {
assert!((e - 30.0).abs() < 1e-6 && (f + 10.0).abs() < 1e-6, "{:?}", texts[0].xf);
}
/// A PDF saved with only the second of its two artboards has a page for that one, while its
/// editing data keeps both: the layers are compared with the page on that artboard, and the page's
/// type goes there.
#[test]
fn a_pdf_of_its_second_artboard_is_compared_on_that_artboard() {
// Two 100-point artboards side by side, 50 apart (art space, y up), a square on each, and a
// text object with no story in the file.
let board = |x0: i32| {
format!(
"%_/Dictionary :\n%_{x0} 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n",
x0 + 100
)
};
let boards = format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{}{}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n", board(0), board(150));
let art = native(&(boards + &layer("Art", true, &(square(10, 10) + "\n" + &square(180, 50) + "\n" + &text_object(0)))));
// The page: the second artboard, its square 30 from the left and 46 down, and the type.
let mut pdf = pdf_part(30.0, 46.0);
let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() };
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(40.0, 20.0), "Hi", style);
let (layer_id, id) = (pdf.layers[0].id, pdf.alloc_id());
pdf.insert(Some(layer_id), 1, 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:?}");
assert_eq!(d.artboards.len(), 2, "both artboards are kept");
let texts = text_of(&d.layers[0]);
assert_eq!(texts.iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["Hi"], "{w:?}");
let [.., e, f] = texts[0].xf.as_coeffs();
assert!((e - 190.0).abs() < 1e-6 && (f - 20.0).abs() < 1e-6, "on the second artboard: {:?}", 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`/EPS editing data: a blend takes the transparency written after its record) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (PDF with editing data saved for some of its artboards: the layers are compared on the artboard its page shows) · **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-31 | minor | PDF (and `.ai`) with Illustrator editing data saved with only some of its artboards: its pages are those artboards, while the editing data keeps them all, and the layers were compared with the first page on the first artboard, so a page of the second artboard came in as the PDF part, without its layers. With fewer pages than artboards, the first page is now taken to be the artboard of its size whose art draws most like it (up to 16 weighed), and the pages' type goes to that artboard and the ones after it. All artboards come in, as before. Test `import::tests_native::a_pdf_of_its_second_artboard_is_compared_on_that_artboard`; scores unchanged |
| 2026-10-30 | minor | `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout; blends are written with knockout on) in the state block after its record, as a group takes the one after its end; it was left for the next object, so a blend's steps of falling opacity drew over each other instead of knocking each other out, and every such blend came in too dark (a user's file has 143, its bubbles came in as dark rings). Both a live blend and drawn steps kept as a group take it. Test `import::ai::tests::a_blends_transparency_follows_its_record`; checked locally against that file: it comes 1.3% → 0.6% nearer its page, though a mesh seen through a mesh-shaped opacity mask still keeps it just over the limit; scores unchanged |
| 2026-10-30 | minor | `.ai`/EPS editing data: a gradient mesh with an opacity mask takes the transparency (`Xy`) written after the mask's dictionary, as one without a mask takes the one right after its `/End X!`; the operators of the mask's art (on `%_` lines) hid it, so the state was read as the next object's and each masked mesh in a run drew with the blend mode of the one before it (a Multiply shading drawn Normal, an Overlay tint drawn Multiply over the art below). Test `import::ai::tests::a_masked_gradient_mesh_takes_the_transparency_written_after_its_mask`; checked locally against a user's files: one opens from its layers 0.1% from its page (it was 0.6%, just inside the limit), another comes 1.6% → 1.3% nearer; scores unchanged |
| 2026-10-30 | minor | `.ai`/EPS editing data: a layer's own state block (`Xy`, `Xd`, `7 () XW` before `LB`) can have the stroke style (`J`, `j`, `w`, `M`, `d`, `XR`) between its `Xy` and `Xd`; read as the art moving on, it lost the layer's opacity, so a layer at 17% drew opaque over the art under it and the file came in as its page. When the layer's last object is a gradient mesh, the mesh keeps its own state through the stroke style too (#4748). An object's own state block (`6 () XW`) still isn't the layer's. Of 408 users' files whose editing data was checked, 3 have such a block: an EPS that now opens from its layers 0.5% from its page (it was 18.8%), one whose layer now draws at 85% as its page does, and one whose layers are all at 100%; the others draw as before. Test `import::ai::tests::a_layers_state_keeps_through_the_stroke_style_written_in_it`; scores unchanged |