diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 0ade04c72a..46e8561f4c 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -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] diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 0cf1c64a89..bc1b82c22d 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -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 diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 426416b754..8e670b3f72 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -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") diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 22a9833e07..7013468d25 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -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 diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 407ca72e9a..4c5b8dde8a 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -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. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 06405fa620..466491552f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 552b8b654c..430f323fc5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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`).