diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index c5f64079c..2bd94479d 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -377,7 +377,9 @@ impl<'a> Reader<'a> { sub.global_names = std::mem::take(&mut self.global_names); sub.nodes = self.nodes; sub.template = self.template; + self.lend_budgets(&mut sub); let read = sub.open(Kind::Group, false).and_then(|()| sub.run()); + self.take_back_budgets(&mut sub); self.nodes = sub.nodes; self.gradients = std::mem::take(&mut sub.gradients); self.global_names = std::mem::take(&mut sub.global_names); @@ -402,6 +404,7 @@ impl<'a> Reader<'a> { sub.global_names = std::mem::take(&mut self.global_names); sub.nodes = self.nodes; sub.template = self.template; + self.lend_budgets(&mut sub); let mut read = sub.open(Kind::Group, false); for layer in &def.layers { if read.is_err() { @@ -409,6 +412,7 @@ impl<'a> Reader<'a> { } read = sub.pattern_layer(layer, def.tile); } + self.take_back_budgets(&mut sub); self.nodes = sub.nodes; self.gradients = std::mem::take(&mut sub.gradients); self.global_names = std::mem::take(&mut sub.global_names); @@ -543,7 +547,9 @@ impl<'a> Reader<'a> { sub.pattern_tiles = std::mem::take(&mut self.pattern_tiles); sub.v7_patterns.clone_from(&self.v7_patterns); sub.nodes = self.nodes; + self.lend_budgets(&mut sub); let read = sub.open(Kind::Group, false).and_then(|()| sub.run()); + self.take_back_budgets(&mut sub); self.nodes = sub.nodes; self.gradients = std::mem::take(&mut sub.gradients); self.global_names = std::mem::take(&mut sub.global_names); @@ -723,7 +729,9 @@ impl<'a> Reader<'a> { sub.global_names = std::mem::take(&mut self.global_names); sub.nodes = self.nodes; sub.template = self.template; + self.lend_budgets(&mut sub); let read = sub.open(Kind::Group, false).and_then(|()| sub.run()); + self.take_back_budgets(&mut sub); self.nodes = sub.nodes; self.gradients = std::mem::take(&mut sub.gradients); self.pattern_tiles = std::mem::take(&mut sub.pattern_tiles); @@ -749,6 +757,21 @@ impl<'a> Reader<'a> { self.brush_art.insert(name, node); } + /// Lend `sub`, a reader of a definition, what this import may still spend on laying out blends + /// and checking envelopes ([`Reader::take_back_budgets`] takes back what is left). + fn lend_budgets(&mut self, sub: &mut Reader<'_>) { + sub.blend_budget = self.blend_budget; + sub.envelope_checks = self.envelope_checks; + sub.envelope_spent = std::mem::take(&mut self.envelope_spent); + } + + /// Take back from `sub` what [`Self::lend_budgets`] lent it, less what it spent. + fn take_back_budgets(&mut self, sub: &mut Reader<'_>) { + self.blend_budget = sub.blend_budget; + self.envelope_checks = sub.envelope_checks; + self.envelope_spent = std::mem::take(&mut sub.envelope_spent); + } + /// In a pattern's definition, the operands of its first operator start with its head, /// `(name) llx lly urx ury`: taken off them. pub(super) fn take_head(&mut self, mut vals: Vec) -> Vec { @@ -1107,4 +1130,66 @@ mod tests { assert!(reader.brush_art.is_empty(), "{placed:?}"); } } + + /// A blend of two keys along a straight spine, with its drawn steps and its record. + fn blend() -> String { + use crate::import::ai::tests::{blend_file, blend_record}; + blend_file("%_10 50 m\n%_110 50 L\n", [Some("A"), Some("B")], &blend_record(&['A', 'B'])) + } + + /// A symbol's definition named `name` whose art is `art`. + fn symbol_section(name: &str, art: &str) -> String { + format!("({name})\n0 A\n0 Xw\n0 Ae\nu\n{art}U\n") + } + + /// Whether `n` or an object in it is a live blend. + fn holds_blend(n: &Node) -> bool { + matches!(n.kind, NodeKind::Blend { .. }) || n.children().is_some_and(|c| c.iter().any(|k| holds_blend(k))) + } + + /// Blends in symbols' definitions lay out their objects from the import's one budget: once a + /// definition has spent it, the next definition's blend keeps its drawn steps. + #[test] + fn blends_in_symbol_definitions_spend_the_imports_budget() { + let mut reader = Reader::new(b""); + reader.define_symbol(symbol_section("One", &blend()).as_bytes()); + let one = super::super::types::BLEND_BUDGET - reader.blend_budget; + assert!(one > 0, "the definition's blend spends from the reader's budget"); + let mut reader = Reader::new(b""); + reader.blend_budget = one; + reader.define_symbol(symbol_section("One", &blend()).as_bytes()); + reader.define_symbol(symbol_section("Two", &blend()).as_bytes()); + assert_eq!(reader.blend_budget, 0); + let live: Vec = reader.doc.symbols.iter().map(|s| holds_blend(&s.art)).collect(); + assert_eq!(live, [true, false]); + } + + /// Blends in patterns' and brushes' art spend from the same budget. + #[test] + fn blends_in_pattern_and_brush_art_spend_the_imports_budget() { + let mut reader = Reader::new(b""); + reader.define_pattern(format!("(P) 0 0 200 100\n0 A\n0 Xw\n0 Ae\n{}E\n", blend()).as_bytes()); + assert!(reader.blend_budget < super::super::types::BLEND_BUDGET, "a pattern's blend"); + let mut reader = Reader::new(b""); + reader.define_brush_art(format!("(A)\n0 A\n{}", blend()).as_bytes()); + assert!(reader.blend_budget < super::super::types::BLEND_BUDGET, "a brush's blend"); + } + + /// Envelopes in symbols' definitions are checked within the import's one count of checks. + #[test] + fn envelopes_in_symbol_definitions_spend_the_imports_checks() { + use crate::import::ai::tests::{drawn_content, envelope_content, envelope_of, straight_mesh, warp_keys}; + use vectorcraft_doc::live::EnvelopeKind; + let want = EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true }; + let envelope = + envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn_content(&want)); + let mut reader = Reader::new(b""); + reader.define_symbol(symbol_section("One", &envelope).as_bytes()); + assert_eq!(reader.envelope_checks, crate::import::ai::envelope::MAX_CHECKED - 1); + let mut reader = Reader::new(b""); + reader.envelope_checks = 1; + reader.define_symbol(symbol_section("One", &envelope).as_bytes()); + reader.define_symbol(symbol_section("Two", &envelope).as_bytes()); + assert_eq!(reader.envelope_checks, 0); + } } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 0a1938dff..839007232 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5495,7 +5495,7 @@ fn envelope_parts(before: &str, keys: &str, block: &str, kind: u32, drawn: &str) /// An envelope of `content` (in the plain group the editing data puts round it) shaped by the mesh /// `shape`. -fn envelope_of(shape: &str, keys: &str, content: &str, kind: u32, drawn: &str) -> String { +pub(super) fn envelope_of(shape: &str, keys: &str, content: &str, kind: u32, drawn: &str) -> String { envelope_parts(shape, keys, &format!("u\n{content}U\n9 () XW\n"), kind, drawn) } @@ -5527,7 +5527,7 @@ fn mesh_text(cols: usize, rows: usize, at: MeshAt<'_>, handle: MeshHandle<'_>, a } /// A grid in art space, its points `step` apart from (40, 20), handles a third of the way. -fn straight_mesh(cols: usize, rows: usize, step: (f64, f64)) -> String { +pub(super) fn straight_mesh(cols: usize, rows: usize, step: (f64, f64)) -> String { let at = move |(i, j): (usize, usize)| (40.0 + i as f64 * step.0, 20.0 + j as f64 * step.1); let third = move |p: (usize, usize), q: (usize, usize)| { let ((x, y), (u, v)) = (at(p), at(q)); @@ -5537,13 +5537,13 @@ fn straight_mesh(cols: usize, rows: usize, step: (f64, f64)) -> String { } /// The red rectangle 40 20 160 80 in art space, which is the same box in the document's. -fn envelope_content() -> String { +pub(super) fn envelope_content() -> String { format!("{RED}{}f\n", rect_path(40.0, 20.0, 160.0, 80.0)) } /// What envelope `kind` draws of [`envelope_content`]: the rectangle's edges, many points to a /// side, mapped as VectorCraft maps them, as a red path in art space. -fn drawn_content(kind: &vectorcraft_doc::live::EnvelopeKind) -> String { +pub(super) fn drawn_content(kind: &vectorcraft_doc::live::EnvelopeKind) -> String { let rect = Rect::new(40.0, 20.0, 160.0, 80.0); let map = vectorcraft_doc::live::envelope_mapper(kind, rect); let corners = [Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(160.0, 80.0), Point::new(40.0, 80.0)]; @@ -5571,7 +5571,7 @@ fn live_envelope(n: &Node) -> (&[Arc], &vectorcraft_doc::live::EnvelopeKin /// A warp's dictionary keys: style number `style`, bend, horizontal and vertical distortion as /// fractions, and the orientation (vertical with `rotate` 1). -fn warp_keys(style: u32, bend: f64, h: f64, v: f64, rotate: u32) -> String { +pub(super) fn warp_keys(style: u32, bend: f64, h: f64, v: f64, rotate: u32) -> String { format!( "{ENVELOPE_KEYS}{style} /Int (DeformStyle) ,\n{h} /Real (DeformHoriz) ,\n{bend} /Real (DeformValue) ,\n{v} /Real (DeformVert) ,\n{rotate} /Bool (Rotate) ,\n(Warp) /UnicodeString (DisplayString) ,\n" ) @@ -6209,7 +6209,7 @@ fn layer_options_of_nine_to_twelve_operands() { /// A blend's record as the editing data writes it after `(Adobe Path Blends) 1 0 n XP`: `DCBA`, /// Specified Steps, then (as far as the reader reads it) the number of keys at word 10 and a record /// of six words per key starting with its id. Hand-written; the other words are zero. -fn blend_record(ids: &[char]) -> String { +pub(super) fn blend_record(ids: &[char]) -> String { blend_record_spaced(ids, 1, 0.0, 0) } @@ -6241,7 +6241,7 @@ fn blend_part(kind: &str, id: Option<&str>) -> String { /// A blend of two 20 pt squares, red on the left (key `A`) and blue on the right (`B`), along /// `spine` (path operators, `%_` lines), with its drawn steps (three between the keys) and the /// record `record`. -fn blend_file(spine: &str, ids: [Option<&str>; 2], record: &str) -> String { +pub(super) fn blend_file(spine: &str, ids: [Option<&str>; 2], record: &str) -> String { blend_file_colored(spine, ids, record, [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]) } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7d79e94fc..a7ac88b16 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1214,6 +1214,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai` or EPS opened from its editing data keeps each artboard's center mark, cross hairs, video safe areas and video ruler pixel aspect ratio, and the file's Document Raster Effects Settings; the author comes from the header's `%%For` (the editing data's, else an EPS's own) when the file's File Info gives none, the header's custom colors that no swatch has come in as spot swatches, a named color the file defines takes the place of a default swatch of that name when the file has no palette, and an older file's `%%TemplateBox` places its artboard. A value of another type or out of range keeps its default (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` opened from its editing data opens its Objects on Path groups live, with their path, their objects in order along it, Pivot and Rotate, and their art where the file draws it; a group whose spacing has been dragged, or with options an Objects on Path group doesn't hold, opens as a plain group (`crates/eps/src/import/ai/tests.rs`). `.ai` symbol definitions and placed instances open as live symbols, in the Symbols panel's order; an instance of a symbol that isn't read, or whose appearance differs from its symbol's, comes in as the page draws it (`crates/eps/src/import/ai/tests.rs`). + Blends and envelopes in the definitions of a `.ai`'s symbols, patterns and brushes' art count against the same per-file limits on blend layout and envelope checks as the layers' art, so a file with many definitions does not get those limits again for each one (`crates/eps/src/import/ai/read/directives.rs`). A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art. A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. Pattern brushes and brushes that can't be read are left out with a note; brushed paths still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, an object with a Tweak that moves nothing or Pucker & Bloat at 0 opens as itself with the effect live. A path, compound path or group whose only effect is Transform without copies, or a Free Distort whose corners make a parallelogram, opens as itself with it live, and so does an unstroked path or compound path with straight segments whose only effect is Twist. A gradient or pattern, an image, type, a clipping group, an opacity mask or effects inside the object, or a stroke with a Transform that scales, rotates or reflects, keep the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/ai/read/ops.rs`).