Merge pull request #7724 from dotCooCoo/fix/ai-off-artboard-art

.ai import: symbol instances in older files are read, so art outside the artboard opens
This commit is contained in:
BFlat
2026-11-15 12:37:39 +07:00
committed by GitHub
7 changed files with 163 additions and 8 deletions
+51
View File
@@ -4601,6 +4601,10 @@ proptest! {
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat();
survive("mutated .ai symbols", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
survive("mutated .ai symbols, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?;
// The same symbols as files of versions 10 to 13 place them, by a `TransformMatrix`.
let older = mutate_text(&older_symbols_text(), cut, &edits);
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&older)].concat();
survive("mutated older .ai symbols", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
// A symbol library: the same symbols and list, no layers.
let library = mutate_text(&symbol_library_text(), cut, &edits);
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&library)].concat();
@@ -4855,6 +4859,53 @@ fn the_symbols_fuzz_data_opens_live() {
assert_eq!(instances, 3);
}
/// [`symbols_text`] with its instances placed by a `TransformMatrix`, as files of versions 10 to
/// 13 write them. Art space's origin is at canvas (8091, 8242), from the template box: on the page
/// the matrix takes the symbol's art to the canvas (y down), and in S2's art to S2's space.
fn older_symbols_text() -> String {
let mut text = symbols_text();
for (newer, older) in [
("1 0 0 -1 -8081 8237 /RTransformMatrix", "1 0 0 1 10 5 /TransformMatrix"),
("1 0 0 -1 20 50 /RTransformMatrix", "1 0 0 1 8111 8192 /TransformMatrix"),
("2 0 0 -2 100 50 /RTransformMatrix", "2 0 0 2 8191 8192 /TransformMatrix"),
("1 0 0 -1 20 20 /RTransformMatrix", "1 0 0 1 8111 8222 /TransformMatrix"),
] {
assert!(text.contains(newer), "{newer}");
text = text.replace(newer, older);
}
text
}
/// The older data [`ai_symbols_never_panic`] damages, as it is, opens with its instances live and
/// placed as [`symbols_text`] places them, on the page and in S2's art.
#[test]
fn the_older_symbols_fuzz_data_opens_live() {
use vectorcraft_doc::{Node, NodeKind};
fn xfs(n: &Node, out: &mut Vec<[f64; 6]>) {
match &n.kind {
NodeKind::SymbolInstance { xf, .. } => out.push(xf.as_coeffs()),
_ => n.children().into_iter().flatten().for_each(|c| xfs(c, out)),
}
}
let open = |text: &str| {
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(text)].concat();
let (doc, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0);
assert!(layered, "{warnings:?}");
let (mut page, mut nested) = (vec![], vec![]);
doc.layers.iter().for_each(|l| xfs(l, &mut page));
let s2 = doc.symbols.iter().find(|s| s.name == "S2").expect("S2");
xfs(&s2.art, &mut nested);
(page, nested)
};
let text = older_symbols_text();
assert!(!text.contains("/RTransformMatrix"));
let (older, newer) = (open(&text), open(&symbols_text()));
assert_eq!((older.0.len(), older.1.len()), (3, 1));
let same = |a: &[[f64; 6]], b: &[[f64; 6]]| a.len() == b.len() && a.iter().flatten().zip(b.iter().flatten()).all(|(x, y)| (x - y).abs() < 1e-9);
assert!(same(&older.0, &newer.0), "{older:?} {newer:?}");
assert!(same(&older.1, &newer.1), "{older:?} {newer:?}");
}
/// Symbols the reader must stop at: sections that don't end, references and matrices it can't
/// use, definitions nested deeper than it reads, and symbols that hold each other.
#[test]
+4 -2
View File
@@ -102,8 +102,10 @@
//! in the Symbols panel's order. An instance: `/SymbolInstance : (name) /SymbolRef , a b c d tx
//! ty /RTransformMatrix ;`, the matrix taking the symbol's art to art space; in a symbol's art,
//! it takes it to art space about the canvas point of art space's origin, as a pattern's
//! gradients are. Its hide and lock come before it (`Xw`, `A`) and its transparency (`Xy`) after
//! it.
//! gradients are. Files of versions 10 to 13 write `a b c d tx ty /TransformMatrix` instead,
//! the matrix taking the symbol's art to the canvas (y down, art space's origin at the canvas
//! point the template box gives); in a symbol's art, it takes the art to that symbol's space.
//! Its hide and lock come before it (`Xw`, `A`) and its transparency (`Xy`) after it.
//!
//! Sections the reader doesn't need (styles, filters) are skipped; type, instances of symbols it
//! can't read, fills with a pattern it can't read, placed files without a preview and operators
+10 -5
View File
@@ -636,17 +636,22 @@ impl<'a> Reader<'a> {
/// A symbol instance (`/SymbolInstance`): the symbol its `SymbolRef` names, placed by its
/// `RTransformMatrix`, hidden and locked as the state says. In a symbol's art the matrix is
/// written about the canvas point of art space's origin, and the name is the file's until the
/// symbols are checked ([`Self::check_symbols`]). An instance of a symbol that wasn't read, one
/// whose appearance differs from its symbol's, or one placed only by a `Matrix` or `Transform`,
/// comes in as the page draws it, within the symbol's box placed by the matrix when the box is
/// known.
/// symbols are checked ([`Self::check_symbols`]). Files of versions 10 to 13 place an instance
/// by a `TransformMatrix` instead, which takes the symbol's art to the canvas (y down); in a
/// symbol's art, it takes the art to that symbol's space. The reader converts it with the canvas
/// point of art space's origin, from the template box. An instance of a symbol that wasn't
/// read, one whose appearance differs from its symbol's, or one placed only by a `Matrix` or
/// `Transform`, comes in as the page draws it, within the symbol's box placed by the matrix
/// when the box is known.
fn symbol_instance(&mut self, o: &Obj) -> Result<(), String> {
let name = self.names.entry(o, "SymbolRef");
let affine = |n: &[f64]| match n {
[a, b, c, d, e, f] if [a, b, c, d, e, f].iter().all(|v| v.is_finite()) => Some(super::Affine::new([*a, *b, *c, *d, *e, *f])),
_ => None,
};
let live = affine(&o.nums("RTransformMatrix"));
// The canvas → art space: y turned up about the canvas point of art space's origin.
let from_canvas = self.canvas_origin().map(|o| super::Affine::new([1.0, 0.0, 0.0, -1.0, -o.x, o.y]));
let live = affine(&o.nums("RTransformMatrix")).or_else(|| Some(from_canvas? * affine(&o.nums("TransformMatrix"))?));
let boxed_only = live.is_none();
let matrix = live.or_else(|| {
o.get("Matrix")
+67
View File
@@ -5115,6 +5115,73 @@ fn an_instance_in_a_symbols_art_is_placed_from_the_canvas() {
assert_eq!(instance_box(&doc, &a[0]), Rect::new(58.0, 53.0, 62.0, 57.0));
}
/// Files of versions 10 to 13 place an instance by a `TransformMatrix` that takes the symbol's art
/// to the canvas (y down): the instance stays live and lands where the `RTransformMatrix` of a
/// newer file puts it, on the page and in a symbol's art.
#[test]
fn an_instance_placed_on_the_canvas_by_an_older_file_stays_live() {
// The template box's center is (100.5, 50.5): art space's origin is at canvas (8091, 8242).
let older = |name: &str, m: [f64; 6]| {
let [a, b, c, d, e, f] = m;
format!("/SymbolInstance :\n({name}) /SymbolRef ,\n{a} {b} {c} {d} {e} {f} /TransformMatrix\n;\n")
};
let page = older("Dot", [1.0, 0.0, 0.0, 1.0, 8111.0, 8192.0]) + &older("Dot", [2.0, 0.0, 0.0, 2.0, 8191.0, 8192.0]);
let body = format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{}{}", symbols(&symbol("Dot", &dot(), 5)), layer(&page));
let doc = read_ok(&body).doc;
let a = art(&doc);
assert_eq!(a.len(), 2);
assert!(a.iter().all(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })), "{a:#?}");
assert_eq!(instance_box(&doc, &a[0]), Rect::new(18.0, 48.0, 22.0, 52.0));
assert_eq!(instance_box(&doc, &a[1]), Rect::new(96.0, 46.0, 104.0, 54.0));
// Turned and scaled, as the newer file writes the same instance: canvas x is art x + 8091 and
// canvas y is 8242 - art y, so the second row of the matrix changes sign.
let turned = [1.299, -0.4, 0.75, 0.6928, 8111.0, 8192.0];
let newer = [1.299, 0.4, 0.75, -0.6928, 20.0, 50.0];
let older_doc =
read_ok(&format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{}{}", symbols(&symbol("Dot", &dot(), 5)), layer(&older("Dot", turned)))).doc;
let newer_doc =
read_ok(&format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{}{}", symbols(&symbol("Dot", &dot(), 5)), layer(&instance("Dot", newer)))).doc;
let xf = |d: &Document| match &art(d)[0].kind {
NodeKind::SymbolInstance { xf, .. } => xf.as_coeffs(),
k => panic!("{k:?}"),
};
let (o, n) = (xf(&older_doc), xf(&newer_doc));
assert!(o.iter().zip(n).all(|(a, b)| (a - b).abs() < 1e-9), "{o:?} {n:?}");
// In a symbol's art the matrix takes the art to the symbol's own space: Dot at (10, 5) in Pair's
// art, which the page instance puts at (50, 50).
let nested = older("Dot", [1.0, 0.0, 0.0, 1.0, 10.0, 5.0]);
let defs = symbols(&(symbol("Dot", &dot(), 5) + &symbol("Pair", &nested, 1)));
let body = format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{defs}{}", layer(&older("Pair", [1.0, 0.0, 0.0, 1.0, 8141.0, 8192.0])));
let doc = read_ok(&body).doc;
let a = art(&doc);
assert_eq!(instance_box(&doc, &a[0]), Rect::new(58.0, 53.0, 62.0, 57.0));
// One document saved in both forms: the template box's center is (100.5, 99.5), so art space's
// origin is at canvas (8091, 8291). Dot sits at (5, 2.5) in Pair's art, and the page instance
// puts Pair at (57, 145.5). Both instances get the newer file's matrices, coefficient by
// coefficient.
let saved = |inner: String, pair: String| {
let defs = symbols(&(symbol("Dot", &dot(), 5) + &symbol("Pair", &inner, 1)));
read_ok(&format!("%AI3_TemplateBox: 100.5 99.5 100.5 99.5\n{defs}{}", layer(&pair))).doc
};
let older_doc = saved(older("Dot", [1.0, 0.0, 0.0, 1.0, 5.0, 2.5]), older("Pair", [1.0, 0.0, 0.0, 1.0, 8148.0, 8145.5]));
let newer_doc = saved(instance("Dot", [1.0, 0.0, 0.0, -1.0, -8086.0, 8288.5]), instance("Pair", [1.0, 0.0, 0.0, -1.0, 57.0, 145.5]));
fn first_xf(n: &Node) -> Option<[f64; 6]> {
match &n.kind {
NodeKind::SymbolInstance { xf, .. } => Some(xf.as_coeffs()),
_ => n.children()?.iter().find_map(|c| first_xf(c)),
}
}
let both = |d: &Document| (xf(d), first_xf(&d.symbols[1].art).expect("Dot's instance in Pair's art"));
let ((op, on), (np, nn)) = (both(&older_doc), both(&newer_doc));
assert!(op.iter().zip(np).all(|(a, b)| (a - b).abs() < 1e-9), "{op:?} {np:?}");
assert!(on.iter().zip(nn).all(|(a, b)| (a - b).abs() < 1e-9), "{on:?} {nn:?}");
// Without a template box the canvas's place isn't known: the instance isn't read, though Dot is
// defined and the same instance with a template box is read above.
let defs = symbols(&symbol("Dot", &dot(), 5));
let e = read(stream(&format!("{defs}{}", layer(&older("Dot", [1.0, 0.0, 0.0, 1.0, 8111.0, 8192.0])))).as_bytes());
assert!(e.is_err_and(|e| e.contains("symbols")));
}
#[test]
fn an_image_in_a_symbols_art_is_placed_from_the_canvas() {
// The template box's center is (100.5, 50.5): art space's origin is at canvas (8091, 8242). The
+28
View File
@@ -2197,6 +2197,34 @@ fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() {
assert!(!d.layers[2].visible);
}
/// A `.ai` of versions 10 to 13, whose symbol instances are placed by a `TransformMatrix` on the
/// canvas, opens from its layers: the art outside its artboard, an instance among it, comes in,
/// rather than the PDF part alone, which has none of it.
#[test]
fn an_older_ai_files_symbols_keep_its_layers_and_the_art_outside_its_artboard() {
// The template box's center is (50.5, 50.5): art space's origin is at canvas (8141, 8242). The
// instance puts Box at (150, 50) in art space, right of the artboard (x 0 to 100).
let instance = "/SymbolInstance :\n(Box) /SymbolRef ,\n1 0 0 1 8291 8192 /TransformMatrix\n;\n9 () XW";
let layers = format!(
"{}{}{}",
stroked_box_symbol(),
layer("Jig", true, &square(10, 10)),
layer("Parts", true, &format!("{}\n{instance}", square(30, 110)))
);
let art = native(&layers).replacen("%AI5_NumLayers", "%AI3_TemplateBox: 50.5 50.5 50.5 50.5\n%AI5_NumLayers", 1);
let (d, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0);
assert!(read, "{warnings:?}");
assert_eq!(names(&d), ["Jig", "Parts"], "{warnings:?}");
let parts = d.layers[1].children().unwrap();
let above = parts[0].visual_bounds().unwrap();
assert!(above.y1 <= 1e-6, "the square above the artboard is kept, outside it: {above:?}");
let NodeKind::SymbolInstance { symbol, .. } = &parts[1].kind else { panic!("an instance: {:?} {warnings:?}", parts[1].kind) };
assert_eq!(symbol, "Box");
let b = parts[1].visual_bounds_with(&d.symbol_boxes()).unwrap();
let near = [b.x0 - 149.0, b.y0 - 49.5, b.x1 - 151.0, b.y1 - 50.5].iter().all(|v| v.abs() < 0.3);
assert!(near, "the instance right of the artboard: {b:?}");
}
/// A `.ai` whose PDF part was read in points from a page of 10 points a unit (its `UserUnit`), and
/// whose editing data has the page in those units: its layers come in at the page's size, ten
/// times as large as the editing data has them, with stroke weights and dashes ten times as large.
+2 -1
View File
@@ -190,7 +190,8 @@ private editing data. VectorCraft:
14; #868: round trip with Illustrator 2018 loses layers and groups), some files 4× slower to open since the
editing data is read whole (#758), live effects that are not read or have settings that are not read, and
brushes come in as their drawn look (#637; pattern fills are read since #1025, and symbols and their instances
open live, as do the symbols of a symbol library without layers); legacy `.ai` without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868; guides
open live, those that files of versions 10 to 13 place with a `TransformMatrix` included, as do the symbols of a
symbol library without layers); legacy `.ai` without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868; guides
open as ruler guides and guide paths since #5057 (#779), hidden, locked and named ruler guides with their state.
Type under a Warp effect is compared with the art saved with it: type that draws as that art stays editable with
the effect live, and type that draws differently, or that the file's limit of checks has no check left for, opens
+1
View File
@@ -1817,6 +1817,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
In `.ai` and EPS editing-data import, a clipping group, layer or opacity mask's art with several clipping paths (`W`) clips to their intersection: each clipping path after the first clips a clipping group nested inside the one before it, and each keeps its own stroke and transparency. A compound path of clipping paths (`*u` … `*U`) is still one clipping path, and clipping paths that would nest the art past the document's 32 levels stop the editing data from being read (`crates/eps/src/import/ai/tests.rs`).
`.ai` and EPS editing-data import keeps template layers, and opens canvas and artboard ruler guides as ruler guides on their layers; guide objects stay editable guide paths (#779; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
A hidden (`1 Xw`) ruler guide path in `.ai` and EPS editing data opens as a hidden ruler guide rather than a guide path, and every ruler guide keeps its path's hide, lock and name, so its Layers row shows them (`hidden_locked_and_named_guide_paths_are_ruler_guides`).
`.ai` editing-data import reads symbol instances that files of versions 10 to 13 place with a `TransformMatrix` on the canvas, on the page and in a symbol's art, so such a file opens from its layers with the art outside its artboards instead of from its PDF part alone (`an_instance_placed_on_the_canvas_by_an_older_file_stays_live`, `an_older_ai_files_symbols_keep_its_layers_and_the_art_outside_its_artboard`).
`.ai` and EPS editing-data import keeps each path's Attributes panel note (`%AI3_Note:`, `%%Note:`, more lines in `%AI5_NoteMore:`, up to 1,024 characters) and Show Center (`1 Ap`); a note or Show Center applies to every path after it until the next one, and a note before a group or compound path is not theirs (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_illustrator.rs`).
`.ai` and EPS editing-data import reads object tags (`/identifier (string) XT`) on the object before them, whatever kind of object it is: `AdobeURL` becomes the object's URL, and every other tag is kept in the object's data in order, up to 64 per object; a tag longer than 64 KiB, and tags in a pattern's or brush's art, are ignored. A tag on art that shows no longer stops the editing data from being read, and brush art that holds tags is read (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_illustrator.rs`).
A `.ai` saved without PDF compatibility (its PDF part only a placeholder page) is placed and pasted from its editing data, as it opens: File › Place places the art of the layers that show on the first artboard (`file.place`'s `page` places another one), and Paste of it as PDF data (`clipboard.importPdf`) takes every object. `document.open` with `pages` naming every page of such a file opens it the same way (`crates/engine/src/cmd/fileio/tests_aiimport.rs`).