From 3824b15a77144925b96b6372d7a5f8bffc2139cf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 22:44:06 -0800 Subject: [PATCH] .ai and EPS import: large canvas symbols come in at the file's size VectorCraft reads the symbol definitions of a large canvas .ai or EPS, and of a .ai whose page is in larger units, in the same units as its layers. Opening such a file scaled the layers and graphic styles up to full size but left the symbols' art as it was. Instances on the layers drew at the right size, but each one's transform carried the factor N, and a newly placed instance came in at 1/N of the size, where N is the file's large canvas scale or its page's unit. - Opening the file now scales each symbol's art, strokes and effects with the layers. Instances on the layers, in symbol art, in pattern tiles and in opacity masks keep their own scale and rotation and draw where they did. - A symbol library without layers from such a file gets the same scale as its graphic styles. - Opacity masks on the layers that do not move with their object are scaled with the layers too, as are those in symbol art. - Editing data that VectorCraft wrote (its %%Creator line) keeps its symbols and those masks at their own size, as the .ai save writes them, so files saved by earlier versions open as before. --- crates/eps/src/ai_editing.rs | 5 +- crates/eps/src/import.rs | 2 +- crates/eps/src/import/ai/read.rs | 6 + crates/eps/src/import/ai/read/directives.rs | 4 + crates/eps/src/import/ai/read/document.rs | 12 +- crates/eps/src/import/native.rs | 2 +- crates/eps/src/import/native/build.rs | 192 +++++++++++++++++++- crates/eps/src/import/native/layers.rs | 81 ++++++--- crates/eps/src/import/tests_native.rs | 126 +++++++++++++ docs/target-app-parity.md | 1 + 10 files changed, 390 insertions(+), 41 deletions(-) diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 0048cb945f..3df902b97f 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -228,8 +228,9 @@ const GRID_GRAY: f64 = 200.0 / 255.0; /// `%%Creator`: the creator line VectorCraft writes in the header of the editing data. It names /// VectorCraft and the format's app, and holds no version number; the version is written on its -/// own in `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]). -const CREATOR_TEXT: &str = "VectorCraft, editing data for Illustrator"; // brand-ok: the creator line VectorCraft writes +/// own in `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]). The reader reads the symbols of data with +/// this creator at their own size, as [`export_ai_editing`] writes them on a large canvas. +pub(crate) const CREATOR_TEXT: &str = "VectorCraft, editing data for Illustrator"; // brand-ok: the creator line VectorCraft writes /// The first line of a PostScript file (the DSC version comment). const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header comment diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index bdb3b9cf50..c791f7c459 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -94,7 +94,7 @@ pub(crate) use ai::{ }; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES, record_version}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from}; -pub(crate) use native::{MAX_TEXT, scale_document}; +pub(crate) use native::{MAX_TEXT, scale_document, scale_symbols}; pub use text::family_style; pub(crate) use text::{document_font, postscript_face}; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 9d66bd2d17..59aaf3df92 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -215,6 +215,9 @@ pub struct Structure { pub text_patterns: HashMap, /// The file's `%AI24_LargeCanvasScale` (1 when it declares none), applied or not. pub canvas_scale: f64, + /// The data's symbols are at their own size, whatever its scale: VectorCraft wrote it (its + /// `%%Creator` is [`crate::ai_editing::CREATOR_TEXT`]). + pub symbols_own_size: bool, /// The objects by their XML id (`AI10_ArtUID`), a text object by its slot's id: `None` for an /// id two objects have. pub xml_ids: HashMap>, @@ -644,6 +647,8 @@ struct Reader<'a> { page_art: bool, /// The declared `%AI24_LargeCanvasScale` (1 without one), applied or not. canvas_scale: f64, + /// [`Structure::symbols_own_size`], from the header's `%%Creator`. + symbols_own_size: bool, /// `%AI5_RulerUnits` from the header (Illustrator 7 spec table 3, plus centimeters and pixels). ruler_units: Option, /// This reader reads a pattern's definition (see [`Reader::define_pattern`]). @@ -818,6 +823,7 @@ impl<'a> Reader<'a> { large_canvas: None, page_art: false, canvas_scale: 1.0, + symbols_own_size: false, ruler_units: None, pattern: None, pattern_tiles: HashMap::new(), diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index f86ee6603b..0fe6b9da87 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -79,6 +79,10 @@ impl<'a> Reader<'a> { } return Ok(()); } + if let Some(v) = bytes.strip_prefix(b"%Creator:") { + self.symbols_own_size = v.trim_ascii() == crate::ai_editing::CREATOR_TEXT.as_bytes(); + return Ok(()); + } if bytes == b"%EndComments" { self.header_done = true; } diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index a7451c8b99..3810625b0d 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -467,6 +467,10 @@ pub struct Symbols { /// The global and spot colors the symbols' art is linked to, as swatches, as [`read`] adds /// them to a document read from the layers. pub swatches: Vec, + /// The data's declared large canvas scale (1 when it declares none), not applied to the art. + pub canvas_scale: f64, + /// [`Structure::symbols_own_size`]. + pub own_size: bool, } /// The symbols of the editing data `data`, for a file whose layers aren't read (a symbol library @@ -529,7 +533,7 @@ pub fn symbols(data: &[u8], names: &mut obj::Names) -> Option { .chain(art_names(symbols.iter().map(|(s, _)| &*s.art))) .collect(); names.absorb_among(std::mem::take(&mut r.names), &used); - Some(Symbols { symbols, images, swatches }) + Some(Symbols { symbols, images, swatches, canvas_scale: r.canvas_scale, own_size: r.symbols_own_size }) } /// [`artboards`] as far as reading `data` from its start gets: those read before an error, and @@ -1194,6 +1198,11 @@ impl<'a> Reader<'a> { drop_commented_copies(&mut self.doc.layers, &self.hidden_ids); super::on_path::apply(&mut self.doc, &self.on_path); let mut doc = std::mem::replace(&mut self.doc, Document::new(1.0, 1.0)); + // A Large Canvas copy's symbols are at its art's scale too ([`Self::ensure_space`]), except + // in data VectorCraft wrote. + if let Some(s) = self.large_canvas.filter(|s| *s != 1.0 && !self.symbols_own_size) { + crate::import::scale_symbols(&mut doc, s); + } // The active artboard, when it is not the first (the default). let active = self.active_board.filter(|i| *i > 0 && *i < self.artboards.len()); let boards: Vec = if self.artboards.is_empty() { @@ -1374,6 +1383,7 @@ impl<'a> Reader<'a> { styles_read, text_patterns, canvas_scale: self.canvas_scale, + symbols_own_size: self.symbols_own_size, xml_ids: self.xml_ids, bindings, bindings_skipped, diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index eb5e815e80..5acaba34a4 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -47,9 +47,9 @@ mod layers; mod plan; mod slots; -pub(crate) use build::scale_document; #[cfg(test)] use build::{Frame, MADE, prune_unseen}; +pub(crate) use build::{scale_document, scale_symbols}; #[cfg(test)] use compare::{ MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, PageView, Shares, compare, compared, comparison_render, comparison_samples, differ, differ_in_colour, diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 1b212194a1..c469f02cd4 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -10,7 +10,7 @@ use super::slots::{ use super::{ Affine, Arc, BTreeSet, Document, Node, NodeId, NodeKind, PathData, Rect, Scaling, Story, Structure, Texts, Unit, VariableKind, Vec2, ai, }; -use vectorcraft_doc::PlacedDocument; +use vectorcraft_doc::{PlacedDocument, Symbol}; /// Which box of the editing copy is the page. #[derive(Clone, Copy)] @@ -83,12 +83,11 @@ pub(super) fn build_on_page(data: &[u8], page: (f64, f64), unit: f64, names: ai: }; let in_units = |r: Option, k: f64| k.is_finite() && k > 1.0 && fits(r, k) && !fits(r, 1.0); let scale = [unit, structure.canvas_scale].into_iter().find(|&k| in_units(board, k) || (!fits(board, 1.0) && in_units(art, k))); - let styles = structure.styles_read; + let (styles, own_size) = (structure.styles_read, structure.symbols_own_size); let (mut doc, notes) = build_from(structure, data, None, Page::Artboard, false, false, 1.0)?; if let Some(k) = scale { // The graphic styles read from the file are drawn in its units too. - scale_document(&mut doc, k, styles); - relayout_wrapped(&mut doc); + scale_read_document(&mut doc, k, styles, own_size); } Ok((doc, notes)) } @@ -108,12 +107,11 @@ pub(super) fn build_without_page(structure: Structure, data: &[u8], pages: &[(f6 let Some(scale) = alone_scale(&structure, data, pages, unit) else { return Err(Box::new(structure)); }; - let styles = structure.styles_read; + let (styles, own_size) = (structure.styles_read, structure.symbols_own_size); let built = build_from(structure, data, None, Page::Artboard, false, false, 1.0).map(|(mut doc, notes)| { if scale != 1.0 { // The graphic styles read from the file are drawn in its units too. - scale_document(&mut doc, scale, styles); - relayout_wrapped(&mut doc); + scale_read_document(&mut doc, scale, styles, own_size); } (doc, notes) }); @@ -268,6 +266,7 @@ pub(super) fn build_weighing( styles_read, text_patterns, canvas_scale, + symbols_own_size, xml_ids, bindings, bindings_skipped, @@ -577,8 +576,7 @@ pub(super) fn build_weighing( bind_variables(&mut doc, &bindings, bindings_skipped, &xml_ids, &filled, &mut notes); placed_documents(&mut doc, &placed_pages); if small.is_some() { - scale_document(&mut doc, unit, styles_read); - relayout_wrapped(&mut doc); + scale_read_document(&mut doc, unit, styles_read, symbols_own_size); } Ok((doc, notes)) } @@ -1916,7 +1914,7 @@ fn drop_guides_off_boards(doc: &mut Document) { /// styles. pub(crate) fn scale_document(doc: &mut Document, k: f64, styles: bool) { let a = Affine::scale(k); - let scaling = Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() }; + let scaling = document_scaling(); for layer in &mut doc.layers { let layer = Arc::make_mut(layer); layer.transform(a, scaling); @@ -1935,6 +1933,101 @@ pub(crate) fn scale_document(doc: &mut Document, k: f64, styles: bool) { } } +/// What [`scale_document`] and [`scale_symbols`] scale with the art: strokes, effects and pattern +/// tiles. +fn document_scaling() -> Scaling { + Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() } +} + +/// A document read in units of `1/k` points drawn at its size: [`scale_document`], and its wrapped +/// type laid out again. Unless `own_size` (data VectorCraft wrote, whose symbols and opacity masks +/// that don't move with their object are at their own size), also the art of those masks on the +/// layers and its symbols ([`scale_symbols`]). +fn scale_read_document(doc: &mut Document, k: f64, styles: bool, own_size: bool) { + scale_document(doc, k, styles); + if !own_size { + let a = Affine::scale(k); + for layer in &mut doc.layers { + transform_unlinked_masks(Arc::make_mut(layer), a, document_scaling()); + } + scale_symbols(doc, k); + } + relayout_wrapped(doc); +} + +/// Draw the art of `doc`'s symbols `k` times as large ([`scale_symbol_art`]), with every instance +/// on the layers and in a pattern's tile (opacity masks included) placed by its transform times +/// `1/k`, so it draws where and as large as before, with the symbol's strokes and effects `k` +/// times as wide. +pub(crate) fn scale_symbols(doc: &mut Document, k: f64) { + if !(k.is_finite() && k > 0.0) || k == 1.0 || doc.symbols.is_empty() { + return; + } + scale_symbol_art(&mut doc.symbols, k); + let back = Affine::scale(1.0 / k); + for n in doc.layers.iter_mut().chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) { + place_instances_by(n, back); + } +} + +/// Draw the art of `symbols` `k` times as large, from each symbol's origin, with its strokes, +/// effects and pattern tiles, as [`scale_document`] draws the layers. An instance in the art keeps +/// its own scale and turn: it is placed by its transform times `1/k`. +pub(crate) fn scale_symbol_art(symbols: &mut [Symbol], k: f64) { + if !(k.is_finite() && k > 0.0) || k == 1.0 { + return; + } + let (a, back) = (Affine::scale(k), Affine::scale(1.0 / k)); + for s in symbols { + let art = Arc::make_mut(&mut s.art); + art.transform(a, document_scaling()); + transform_unlinked_masks(art, a, document_scaling()); + scale_wrap_offsets(art, k); + place_instances_by(&mut s.art, back); + } +} + +/// The art of the opacity masks in `n` that don't move with their object, which +/// [`Node::transform`] leaves where it is, transformed by `a` with `scaling` as `n` was: a change of +/// units moves them too. +fn transform_unlinked_masks(n: &mut Node, a: Affine, scaling: Scaling) { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + let linked = m.linked; + let art = Arc::make_mut(&mut m.art); + if !linked { + art.transform(a, scaling); + } + transform_unlinked_masks(art, a, scaling); + } + if let Some(children) = n.children_mut() { + for c in children { + transform_unlinked_masks(Arc::make_mut(c), a, scaling); + } + } +} + +/// Every symbol instance in `n` (opacity masks included) placed by its transform times `m`. Only +/// the objects on the way to an instance are copied. +fn place_instances_by(n: &mut Arc, m: Affine) { + let mut holds = false; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |c| holds |= matches!(c.kind, NodeKind::SymbolInstance { .. })); + if !holds { + return; + } + let n = Arc::make_mut(n); + if let NodeKind::SymbolInstance { xf, .. } = &mut n.kind { + *xf *= m; + } + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + place_instances_by(&mut mask.art, m); + } + if let Some(children) = n.children_mut() { + for c in children { + place_instances_by(c, m); + } + } +} + /// The text wrap offsets of `n` and what it holds, times `k` (within the -1000 to 1000 points /// they may be). fn scale_wrap_offsets(n: &mut Node, k: f64) { @@ -2256,6 +2349,85 @@ mod candidate_tests { assert_eq!(offsets, [wrap(60.0), wrap(1000.0), wrap(-1000.0)]); } + /// Symbols drawn ten times as large keep every instance where and as large as it was (on a layer, + /// in an opacity mask's art, linked or not, and in a pattern's tile), and an instance in a + /// symbol's art keeps its own scale and turn, moved with the art. + #[test] + fn scaling_the_symbols_keeps_their_instances_in_place() { + let mut doc = Document::new(100.0, 100.0); + let boxed = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(-1.0, -0.5, 1.0, 0.5)), Default::default()); + doc.symbols.push(Symbol { name: "Box".into(), art: Arc::new(boxed), dynamic: false }); + let turn = Affine::translate((3.0, 4.0)) * Affine::rotate(0.5) * Affine::scale(2.0); + let nested = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: turn }); + let mut pair = Node::group(doc.alloc_id(), vec![Arc::new(nested)]); + // An opacity mask that doesn't move with its object, holding an instance too. + let masking = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: turn }); + let mut unlinked = vectorcraft_doc::OpacityMask::new(masking, true); + unlinked.linked = false; + pair.extras_mut().mask = Some(Box::new(unlinked)); + doc.symbols.push(Symbol { name: "Pair".into(), art: Arc::new(pair), dynamic: false }); + let place = Affine::translate((40.0, 30.0)) * Affine::scale(3.0); + let mut on_layer = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Pair".into(), xf: place }); + let in_mask = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: place }); + on_layer.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(in_mask, true))); + doc.insert(Some(doc.layers[0].id), 0, on_layer).unwrap(); + let tiled = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: place }); + doc.patterns.push(vectorcraft_doc::PatternDef::new("Tiles", vec![Arc::new(tiled)])); + // Each instance's box on the page, by the art of Box it places (through Pair's instance). + let placed = |d: &Document| { + let xf = |n: &Node| match &n.kind { + NodeKind::SymbolInstance { xf, .. } => *xf, + other => panic!("an instance: {other:?}"), + }; + let art = d.symbols[0].art.geometric_bounds().unwrap(); + let layer = &d.layers[0].children().unwrap()[0]; + let mask = &layer.extras().mask.as_ref().unwrap().art; + let tile = &d.patterns[0].art[0]; + let nested = &d.symbols[1].art.children().unwrap()[0]; + let masking = &d.symbols[1].art.extras().mask.as_ref().unwrap().art; + [xf(layer) * xf(nested), xf(layer) * xf(masking), xf(mask), xf(tile)].map(|m| m.transform_rect_bbox(art)) + }; + let before = placed(&doc); + scale_symbols(&mut doc, 10.0); + let after = placed(&doc); + for (a, b) in after.iter().zip(&before) { + let far = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())); + assert!(far < 1e-9, "{after:?} {before:?}"); + } + let pair = &doc.symbols[1].art.children().unwrap()[0]; + let NodeKind::SymbolInstance { xf, .. } = &pair.kind else { panic!("an instance") }; + let want = Affine::translate((30.0, 40.0)) * Affine::rotate(0.5) * Affine::scale(2.0); + let far = xf.as_coeffs().iter().zip(want.as_coeffs()).fold(0.0f64, |m, (a, b)| m.max((a - b).abs())); + assert!(far < 1e-9, "{xf:?}"); + let art = doc.symbols[0].art.geometric_bounds().unwrap(); + assert_eq!(art, Rect::new(-10.0, -5.0, 10.0, 5.0)); + } + + /// Layers read in the units of a page scaled up to points take the art of their opacity masks + /// with them, a mask that doesn't move with its object included, except in data VectorCraft + /// wrote, where that mask's art is at its own size. + #[test] + fn scaling_a_read_document_scales_unlinked_opacity_masks() { + for own_size in [false, true] { + let mut doc = Document::new(100.0, 100.0); + let mut boxes = vec![]; + for linked in [true, false] { + let mask = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(1.0, 2.0, 3.0, 4.0)), Default::default()); + let mut mask = vectorcraft_doc::OpacityMask::new(mask, true); + mask.linked = linked; + let mut n = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Default::default()); + n.extras_mut().mask = Some(Box::new(mask)); + boxes.push((linked, doc.insert(Some(doc.layers[0].id), 0, n).unwrap())); + } + scale_read_document(&mut doc, 10.0, false, own_size); + for (linked, id) in boxes { + let mask = doc.node(id).and_then(|n| n.extras().mask.as_ref().and_then(|m| m.art.geometric_bounds())); + let want = if linked || !own_size { Rect::new(10.0, 20.0, 30.0, 40.0) } else { Rect::new(1.0, 2.0, 3.0, 4.0) }; + assert_eq!(mask, Some(want), "linked {linked}, own size {own_size}"); + } + } + } + /// Area type in `doc`'s first layer at `index`, in the frame `r`. fn insert_area(doc: &mut Document, index: usize, r: Rect) -> NodeId { let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Wrapped", Default::default()); diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index d1af3301df..afd8f2568c 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -1,4 +1,6 @@ -use super::build::{PAGE_MOVE, Page, alone_scale, build, build_from, build_on_page, build_weighing, build_without_page, reid_page_art}; +use super::build::{ + PAGE_MOVE, Page, alone_scale, build, build_from, build_on_page, build_weighing, build_without_page, reid_page_art, scale_symbol_art, +}; use super::slots::LAYERS_UNREAD; use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai}; @@ -45,7 +47,7 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illus let own_palette = use_palette(data, &mut v.document, &mut read); use_brushes(data, &mut v.document, have, &mut v.warnings, &mut read); use_styles(data, &mut v.document, true, 1.0, None, &mut read); - use_symbols(data, &mut v.document, own_palette, &mut v.warnings, &mut read); + use_symbols(data, &mut v.document, own_palette, None, &mut v.warnings, &mut read); use_variables(data, &mut v.document, &mut v.warnings, &mut read); add_notes(&mut v.warnings, read.notes()); } @@ -153,7 +155,7 @@ pub fn layered_ai_from( let own_palette = use_palette(&data, &mut visible, &mut read); use_brushes(&data, &mut visible, have, &mut warnings, &mut read); use_styles(&data, &mut visible, false, unit, Some(&boards), &mut read); - use_symbols(&data, &mut visible, own_palette, &mut warnings, &mut read); + use_symbols(&data, &mut visible, own_palette, Some((unit, boards.as_slice())), &mut warnings, &mut read); use_variables(&data, &mut visible, &mut warnings, &mut read); add_notes(&mut warnings, read.notes()); return (visible, warnings, false); @@ -172,7 +174,7 @@ pub fn layered_ai_from( use_brushes(&data, &mut visible, have, &mut warnings, &mut read); use_styles(&data, &mut visible, false, unit, Some(&boards), &mut read); let mut all = unread(warnings, why); - use_symbols(&data, &mut visible, own_palette, &mut all, &mut read); + use_symbols(&data, &mut visible, own_palette, Some((unit, boards.as_slice())), &mut all, &mut read); use_variables(&data, &mut visible, &mut all, &mut read); add_notes(&mut all, read.notes()); (visible, all, false) @@ -377,26 +379,34 @@ fn use_styles(data: &[u8], doc: &mut Document, large_canvas: bool, unit: f64, bo if styles.is_empty() { return; } - if !large_canvas && (unit != 1.0 || canvas != 1.0) { - let read; - let boards = match boards { - Some(b) => b, - None => { - read = ai::artboards(data, &mut ai::Names::default()).0; - &read - } - }; - let k = board_scale(boards, doc, unit).or_else(|| board_scale(boards, doc, canvas)); - if let Some(k) = k.filter(|k| *k != 1.0) { - for g in &mut styles { - g.appearance.scale_strokes(k); - g.appearance.scale_effects(k, vectorcraft_effects::scale_effect); - } + if !large_canvas && let Some(k) = data_scale(data, doc, unit, canvas, boards) { + for g in &mut styles { + g.appearance.scale_strokes(k); + g.appearance.scale_effects(k, vectorcraft_effects::scale_effect); } } doc.graphic_styles = styles.into(); } +/// How many times as large `doc` (a file read as its PDF part) draws the art of its editing data +/// `data`, whose declared large canvas scale is `canvas`, when it isn't 1: `unit` when the editing +/// data's artboards (`boards`, or read from `data`) are the size of `doc`'s pages in units of +/// `unit` points, else `canvas` when they are the pages' size at that scale ([`board_scale`]). +fn data_scale(data: &[u8], doc: &Document, unit: f64, canvas: f64, boards: Option<&[(String, Rect)]>) -> Option { + if unit == 1.0 && canvas == 1.0 { + return None; + } + let read; + let boards = match boards { + Some(b) => b, + None => { + read = ai::artboards(data, &mut ai::Names::default()).0; + &read + } + }; + board_scale(boards, doc, unit).or_else(|| board_scale(boards, doc, canvas)).filter(|k| *k != 1.0) +} + /// `doc` (a file read as its page or PDF part, in place of its layers) with the symbols of its /// editing data `data` ([`ai::symbols`]), their registration points, the images their art shows and /// the global and spot colors it is linked to that `doc` has no such swatch for (a color whose name @@ -405,15 +415,34 @@ fn use_styles(data: &[u8], doc: &mut Document, large_canvas: bool, unit: f64, bo /// of the name, which the art then names, as does one whose name a color group has), as [`build`] gives a document read from the layers, and /// the note about instances that can't be drawn added to `warnings`. An object of their art linked /// to a graphic style keeps the link when [`use_styles`] gave `doc` the editing data's styles. The -/// symbols' art keeps the editing data's units, as in a document read from the layers: when -/// [`use_styles`] draws the styles' strokes and effects larger (a file in larger units), linked art -/// isn't scaled with them. Left as it is when `doc` has symbols already or the editing data has -/// none this reads. `names` reads the symbols' names and keeps the notes on them. -fn use_symbols(data: &[u8], doc: &mut Document, own_palette: bool, warnings: &mut Vec, names: &mut ai::Names) { +/// symbols' art is drawn as large as a document read from the layers draws it +/// ([`scale_symbol_art`]): with `units` `None` (an EPS's page), as many times as large as the data's +/// declared large canvas scale; with `Some((unit, boards))`, as many times as large as +/// [`use_styles`] draws the styles' strokes and effects ([`data_scale`], `boards` the editing data's +/// artboards); at its own size in data VectorCraft wrote. Left as it is when `doc` has symbols +/// already or the editing data has none this reads. `names` reads the symbols' names and keeps the +/// notes on them. +fn use_symbols( + data: &[u8], + doc: &mut Document, + own_palette: bool, + units: Option<(f64, &[(String, Rect)])>, + warnings: &mut Vec, + names: &mut ai::Names, +) { if !doc.symbols.is_empty() { return; } - let Some(ai::Symbols { symbols, images, swatches }) = ai::symbols(data, names) else { return }; + let Some(ai::Symbols { symbols, images, swatches, canvas_scale, own_size }) = ai::symbols(data, names) else { return }; + let (mut symbols, registrations): (Vec<_>, Vec<_>) = symbols.into_iter().unzip(); + let k = match units { + _ if own_size => None, + None => Some(canvas_scale), + Some((unit, boards)) => data_scale(data, doc, unit, canvas_scale, Some(boards)), + }; + if let Some(k) = k { + scale_symbol_art(&mut symbols, k); + } for (key, blob) in images { doc.images.entry(key).or_insert(blob); } @@ -444,7 +473,7 @@ fn use_symbols(data: &[u8], doc: &mut Document, own_palette: bool, warnings: &mu *link = Some(new.clone()); true }; - for (mut symbol, registration) in symbols { + for (mut symbol, registration) in symbols.into_iter().zip(registrations) { let mut art = reid_page_art(doc, &symbol.art); if !renamed.is_empty() { vectorcraft_doc::swatches::map_node_solid_paints(&mut art, &mut follow); diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 1f634b3744..738f02d857 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -1809,6 +1809,44 @@ fn an_eps_without_layers_keeps_its_symbols() { assert_eq!(symbol_names(&r.document), ["Bar", "Masked", "Dot"], "{:?}", r.warnings); } +/// The box of the art of `d`'s first symbol (Bar, 12 × 2 about the origin in the editing data). +fn bar_bounds(d: &Document) -> Option { + d.symbols.first().and_then(|s| s.art.geometric_bounds()) +} + +/// A symbol library without layers whose editing data is drawn larger than its units comes in with +/// its symbols' art as large as a document read from its layers has it: an EPS of a Large Canvas +/// copy at its declared scale, and a `.ai` whose artboard is its PDF part's page at that scale or +/// in the units of its `UserUnit`. One whose artboard is the page in points keeps its art's size. +#[test] +fn a_symbol_library_in_larger_units_comes_in_at_the_pages_size() { + let board = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\ + %_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"; + let library = |setup: &str| symbol_library().replace("%%BeginSetup\n", &format!("%%BeginSetup\n{setup}")); + let ten = Some(vectorcraft_geom::Rect::new(-60.0, -10.0, 60.0, 10.0)); + let r = import(&eps(&page_square(50, 50), &library("%AI24_LargeCanvasScale: 10\n"))).unwrap(); + assert_eq!(bar_bounds(&r.document), ten, "{:?}", r.warnings); + let large = format!("%AI24_LargeCanvasScale: 10\n{board}"); + let (d, warnings, read) = crate::layered_ai(&private(&library(&large)), in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, true, 1.0); + assert!(!read, "{warnings:?}"); + assert_eq!(bar_bounds(&d), ten, "{warnings:?}"); + let (d, warnings, _) = crate::layered_ai(&private(&library(board)), in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, true, 10.0); + assert_eq!(bar_bounds(&d), ten, "{warnings:?}"); + let points = board.replace("0 100 /Real", "0 1000 /Real").replace("100 0 /Real", "1000 0 /Real"); + let (d, warnings, _) = crate::layered_ai(&private(&library(&points)), in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, true, 10.0); + assert_eq!(bar_bounds(&d), Some(vectorcraft_geom::Rect::new(-6.0, -1.0, 6.0, 1.0)), "{warnings:?}"); + // A library VectorCraft wrote (its `%%Creator`) has its symbols at their own size, whatever its + // scale. + let own = + |setup: &str| library(setup).replacen("%!PS-Adobe-3.0 \n", &format!("%!PS-Adobe-3.0 \n%%Creator: {}\n", crate::ai_editing::CREATOR_TEXT), 1); + let own_size = Some(vectorcraft_geom::Rect::new(-6.0, -1.0, 6.0, 1.0)); + let r = import(&eps(&page_square(50, 50), &own("%AI24_LargeCanvasScale: 10\n"))).unwrap(); + assert_eq!(bar_bounds(&r.document), own_size, "{:?}", r.warnings); + let (d, warnings, _) = crate::layered_ai(&private(&own(&large)), in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, true, 1.0); + assert_eq!(bar_bounds(&d), own_size, "{warnings:?}"); +} + /// A `.ai` that comes in as its PDF part, and an EPS that comes in as its page, because their /// layers can't be used (their editing data is for another page) get the symbols defined before /// their layers. @@ -2769,6 +2807,94 @@ fn a_large_canvas_eps_keeps_its_graphic_styles_at_the_pages_size() { assert_eq!((object.appearance.stroke_width(), object.extras().graphic_style), (2.0, Some(1)), "{object:#?}"); } +/// The definition of the symbol Box: a rectangle 2 × 1 about the origin, stroked 0.4 wide. +fn stroked_box_symbol() -> String { + let art = "0 0 0 1 K\n0.4 w\n-1 -0.5 m\n-1 0.5 L\n1 0.5 L\n1 -0.5 L\n-1 -0.5 L\nS\n"; + format!("%AI5_Begin_NonPrinting\n{}%AI5_End_NonPrinting--\n", symbol_def("Box", art, 5)) +} + +/// The art of `d`'s symbol Box ([`stroked_box_symbol`]) is ten times as large, its stroke 4 wide, +/// and its instance on `d`'s second layer, placed in the file at twice its size about (`x`, `y`), +/// keeps that scale and draws a box of 40 × 20 about `center`. +fn box_symbol_at_ten_times(d: &Document, center: (f64, f64), notes: &[String]) { + let art = &d.symbols.iter().find(|s| s.name == "Box").unwrap_or_else(|| panic!("{notes:?}")).art; + let b = art.geometric_bounds().unwrap(); + let close = |r: vectorcraft_geom::Rect, e: [f64; 4]| [r.x0, r.y0, r.x1, r.y1].iter().zip(e).all(|(a, b)| (a - b).abs() < 1e-6); + assert!(close(b, [-10.0, -5.0, 10.0, 5.0]), "the art at the page's size: {b:?} {notes:?}"); + let mut widths = vec![]; + art.walk(&mut |n| { + if matches!(n.kind, NodeKind::Path { .. }) { + widths.push(n.appearance.stroke_width()); + } + }); + assert_eq!(widths, [4.0], "{notes:?}"); + let instance = d.layers[1].children().unwrap().first().unwrap_or_else(|| panic!("{:#?} {notes:?}", d.layers)); + let NodeKind::SymbolInstance { xf, .. } = &instance.kind else { panic!("an instance: {:?}", instance.kind) }; + let [a, b0, c, d0, ..] = xf.as_coeffs(); + assert!((a.hypot(b0) - 2.0).abs() < 1e-9 && (c.hypot(d0) - 2.0).abs() < 1e-9, "the instance's own scale: {xf:?}"); + let (x, y) = center; + assert!(close(xf.transform_rect_bbox(b), [x - 20.0, y - 10.0, x + 20.0, y + 10.0]), "{:?}", xf.transform_rect_bbox(b)); +} + +/// The symbols of a Large Canvas editing copy come in at the page's size, as its art does: their +/// art and its strokes ten times as large, and each instance with the scale the file gives it, +/// drawn where and as large as before. +#[test] +fn a_large_canvas_eps_keeps_its_symbols_at_the_pages_size() { + let instance = "/SymbolInstance :\n(Box) /SymbolRef ,\n2 0 0 -2 5 5 /RTransformMatrix\n;\n9 () XW"; + let square = "0 0 1 0 k\n5 5 m\n5.4 5 L\n5.4 5.4 L\n5 5.4 L\nf"; + let art = native_large_canvas(10.0, 10.0, &format!("{}{}{}", stroked_box_symbol(), layer("Art", true, square), layer("Box", false, instance))); + let r = import(&eps(&page_square(50, 50), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art", "Box"], "{:?}", r.warnings); + box_symbol_at_ten_times(&r.document, (50.0, 50.0), &r.warnings); +} + +/// A large canvas `.ai` 100 units square at a scale of 10, with the symbol Box +/// ([`stroked_box_symbol`]) and an instance of it at twice its size about (50, 50), its header +/// carrying `creator` when given, read with its PDF part, on a page whose PDF part can't be read, +/// and without a page. +fn large_canvas_ai_box_reads(creator: Option<&str>) -> Vec<(&'static str, Document, Vec)> { + let instance = "/SymbolInstance :\n(Box) /SymbolRef ,\n2 0 0 -2 50 50 /RTransformMatrix\n;\n9 () XW"; + let layers = format!("{}{}{}", stroked_box_symbol(), layer("Art", true, &square(10, 10)), layer("Box", false, instance)); + let mut art = native_large_canvas(100.0, 10.0, &layers); + if let Some(creator) = creator { + art = art.replacen("%!PS-Adobe-3.0 \n", &format!("%!PS-Adobe-3.0 \n%%Creator: {creator}\n"), 1); + } + let data = private(&art); + let (d, notes, read) = crate::layered_ai(&data, in_points(pdf_part(10.0, 86.0), 10.0), vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + let on_page = crate::ai_alone_on_page(&data, (1000.0, 1000.0), 10.0, Default::default()).unwrap(); + let no_page = crate::ai_alone(&data).unwrap(); + vec![("PDF part", d, notes), ("on a page", on_page.0, on_page.1), ("no page", no_page.0, no_page.1)] +} + +/// A `.ai` whose editing data is read at its declared large canvas scale has its symbols scaled +/// with the art, as an EPS has, however the file opens. +#[test] +fn a_large_canvas_ai_keeps_its_symbols_at_the_pages_size() { + for (how, d, notes) in large_canvas_ai_box_reads(None) { + let notes: Vec = notes.into_iter().chain([how.to_string()]).collect(); + box_symbol_at_ten_times(&d, (500.0, 500.0), ¬es); + } +} + +/// A large canvas `.ai` that VectorCraft wrote (its `%%Creator`) has its symbols defined at their +/// own size, as its `.ai` save writes them: their art keeps that size, and the instance, scaled with +/// the layers, draws the same box, however the file opens. +#[test] +fn a_large_canvas_ai_vectorcraft_wrote_keeps_its_symbols_at_their_own_size() { + for (how, d, notes) in large_canvas_ai_box_reads(Some(crate::ai_editing::CREATOR_TEXT)) { + let art = &d.symbols.iter().find(|s| s.name == "Box").unwrap_or_else(|| panic!("{how}: {notes:?}")).art; + let b = art.geometric_bounds().unwrap(); + assert_eq!(b, vectorcraft_geom::Rect::new(-1.0, -0.5, 1.0, 0.5), "{how}: {notes:?}"); + let instance = d.layers[1].children().unwrap().first().unwrap_or_else(|| panic!("{how}: {:#?} {notes:?}", d.layers)); + let NodeKind::SymbolInstance { xf, .. } = &instance.kind else { panic!("{how}: an instance: {:?}", instance.kind) }; + let drawn = xf.transform_rect_bbox(b); + let close = [drawn.x0 - 480.0, drawn.y0 - 490.0, drawn.x1 - 520.0, drawn.y1 - 510.0].iter().all(|v| v.abs() < 1e-6); + assert!(close, "{how}: {drawn:?}"); + } +} + #[test] fn type_with_a_listed_art_style_is_linked_to_its_graphic_style() { let doc = text_document(&[("Bang\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..083dc9fb84 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1675,6 +1675,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `.ai` editing-data import restores the document's listed graphic styles in panel order and links objects that use a listed style; unsupported appearances keep their drawn look (`crates/eps/src/import/ai/tests.rs`). A `.ai` or EPS graphic style library, whose editing data holds the styles and their list but no layers, opens with those styles in list order. Other Library… loads another document's graphic styles ([Apply graphic styles](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/apply-graphic-styles.html)) and lists such a library's styles in a library panel. A `.ai` or EPS read as its PDF part or page because its layers can't be used also gets the styles of its list in place of the default ones (`crates/eps/src/import/tests_native.rs`, `cmd::fileio::tests_aiimport::a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles`). A `.ai` or EPS symbol library, whose editing data holds symbol definitions and their list but no layers, opens with those symbols in list order, each with its registration point. Other Library… in the Symbols panel's Symbol Libraries Menu imports the symbols of the file you pick ([Create or import symbol libraries](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)) and lists such a library's symbols in a library panel. A `.ai` or EPS read as its PDF part or page because its layers can't be used also gets the symbols defined before its layers (`crates/eps/src/import/tests_native.rs`, `crates/eps/src/import/ai/tests.rs`, `cmd::fileio::tests_aiimport::a_symbol_library_in_a_ai_file_without_layers_loads_its_symbols`). + In a large canvas `.ai` or EPS ([Large canvas FAQ](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/large-canvas-faq.html)), or a `.ai` whose page is in larger units, opened from its editing data, VectorCraft reads the symbol definitions in the same units as the layers. When the file opens at its full size, each symbol's art, strokes and effects are scaled with the layers, and each instance keeps its own scale and rotation and draws where and as large as before. A new instance comes in at the symbol's full size, the size of an instance at 100% in the file. A symbol library from such a file comes in at the same size, and opacity masks on the layers that don't move with their object are scaled with the layers too. Editing data that VectorCraft wrote keeps its symbols and those masks at their own size, as its `.ai` save writes them (`tests_native::a_large_canvas_eps_keeps_its_symbols_at_the_pages_size`, `a_large_canvas_ai_keeps_its_symbols_at_the_pages_size`, `a_large_canvas_ai_vectorcraft_wrote_keeps_its_symbols_at_their_own_size`, `a_symbol_library_in_larger_units_comes_in_at_the_pages_size`, `build::candidate_tests::scaling_the_symbols_keeps_their_instances_in_place`, `scaling_a_read_document_scales_unlinked_opacity_masks`). A `.ai` or EPS opened from its editing data whose text document makes the type of every text object that shows takes all its type from the text document: each text object stays in its own group, frame and layer, the page's type isn't matched with them, a file of more than 5,000 text objects still opens with its layers, and type the page draws differently (in another font) keeps the layers, with a warning. A line break inside a paragraph stays a line break in it (`crates/eps/src/import/tests_native.rs`, `crates/eps/src/import/ate.rs`). In SVG export without Fewer `` Elements, each character of live type that justification or Mojikumi moves (the letter gaps of a Justify All line without word spaces, the gaps between the characters of a justified Japanese line, closed-up punctuation, the space between Japanese and Latin letters) starts its own positioned ``, and type scaled horizontally on a justified line is stretched to its own width (`crates/svg/tests/it/text_layout.rs`). To find those characters, SVG export lays a type object out a second time only when the second layout can differ from the first: point type with Line-end Punctuation Half Width and no Japanese punctuation or letters, the setting new type gets, is laid out once. Exporting 5,000 such objects takes a median of 109.4 ms instead of 171.5 ms on Windows 11, with the same SVG (`crates/svg/src/export/text/tests.rs`).