From f8bcf69b231c7d23a2a43bfc2fc9981bcfa9e124 Mon Sep 17 00:00:00 2001 From: ham9zah Date: Sun, 25 Oct 2026 14:22:46 +0300 Subject: [PATCH 1/2] Import: Illustrator blends come in as live blends A blend in a .ai file's editing data came in as plain art twice over: the blend's own art (its key objects and spine, on `%_` lines) and its drawn steps were both kept visible, so the keys lay doubled under the drawn ones and the spine showed as a stray path, and nothing was live. The spine itself was read exactly (its line and curve segments match). The `(Adobe Path Blends) 1 0 n XP` record is now read (n bytes of hex on the `%` lines after it, capped, checked: `DCBA`, version 1, the key count and a record per key starting with its id). Each object's dictionary names its part (`ShapeBlendPathType` K or S) and a key's id (`ShapeBlendPathID`); the keys the record lists and the spine become one live blend, the keys sitting on the spine's anchors. The record's other fields aren't known, so the spacing is the drawn steps between two keys (Specified Steps) and the orientation whichever lays the blend out as drawn; the blend replaces the drawn steps only when it comes out as drawn (each step within 0.5 pt and 0.02 of its colour). Otherwise the drawn steps stay, without the blend's own art over them, with a note. Behaviour as documented: https://helpx.adobe.com/illustrator/using/blending-objects.html Save As .ai writes the PDF-compatible part only (no editing data), so a blend is written back as its steps drawn, not live. Hand-written tests: two keys and a straight spine as one live blend with three steps and interpolated colours; a curved spine (l, c, L) in both directions keeping its points and the keys' order; a garbled or short record, a key without an id and ids the record doesn't list keeping the drawn steps with a note; moving a key moving the steps; record parsing on hostile input. The import fuzz target gains the record and the new mesh keys. --- crates/engine/tests/import_fuzz.rs | 11 ++ crates/eps/src/import/ai/blend.rs | 216 +++++++++++++++++++++++++++++ crates/eps/src/import/ai/mod.rs | 1 + crates/eps/src/import/ai/read.rs | 67 +++++++++ crates/eps/src/import/ai/tests.rs | 155 +++++++++++++++++++++ 5 files changed, 450 insertions(+) create mode 100644 crates/eps/src/import/ai/blend.rs diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index e81c4d8a3..5ca4f1899 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2991,6 +2991,17 @@ fn arb_ai_token() -> impl Strategy { "[1 0 0 1 0 0 10 10 12 10 1 8 10 1] /N X#", "[1e308 -1e308 0] /N X#", "0 /E X#", + "[2 2] /Size X#", + "/Cartesian /Type X#", + "[1 1] /P X#", + "(Adobe Path Blends) 1 0 92 XP", + "(Adobe Path Blends) 1 0 999999 XP", + "\n%44434241010000000000000000000000\n", + "\n%0200000041000000000000000000000000000000\n", + "\n%4G\n", + "%_/ArtDictionary :\n%_(A) /String (ShapeBlendPathID) ,\n%_(K) /String (ShapeBlendPathType) ,\n%_;\n", + "%_/ArtDictionary :\n%_(S) /String (ShapeBlendPathType) ,\n%_;\n", + "9 () XW", "\n%AI9_BeginArtStyles\n", "\n%AI9_EndArtStyles\n", "/KnownStyle :", diff --git a/crates/eps/src/import/ai/blend.rs b/crates/eps/src/import/ai/blend.rs new file mode 100644 index 000000000..3de7c501f --- /dev/null +++ b/crates/eps/src/import/ai/blend.rs @@ -0,0 +1,216 @@ +//! Blends from the editing data (`(Adobe Path Blends) 1 0 n XP`), read as live blends. +//! +//! What a blend is and does is Illustrator's public documentation: key objects, the spine the +//! steps follow, Blend Options' Spacing (Smooth Color, Specified Steps, Specified Distance) and +//! Orientation (Align to Page, Align to Path), Replace Spine and Reverse Spine +//! (). +//! +//! How the editing data writes one isn't documented. As written in files users sent (used +//! locally, never committed): the blend's own art on `%_` lines, a group holding the key objects +//! and the spine, each object's `/ArtDictionary` naming its part (`ShapeBlendPathType`: `K` a key, +//! `S` the spine) and a key's id (`ShapeBlendPathID`); then the steps drawn, a group on plain +//! lines; then `(Adobe Path Blends) 1 0 n XP` and `n` bytes as hex on `%` lines. Those bytes +//! start `DCBA`, version 1; word 10 (little endian) is the number of keys, and the words after it +//! a record per key that starts with the key's id. What the other words mean isn't known, so the +//! spacing and orientation are taken from the steps drawn instead: as many steps as they have +//! between two keys, and whichever orientation lays the blend out as they are drawn. A blend whose +//! parts don't agree, or that doesn't come out as drawn, keeps its drawn steps. + +use std::collections::HashMap; +use std::sync::Arc; + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::blend::blend_expand; +use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec}; +use vectorcraft_doc::{AppearanceItem, Node, NodeId, NodeKind}; +use vectorcraft_geom::{PathData, Point}; + +/// The plugin that writes blends. +pub(super) const PLUGIN: &str = "Adobe Path Blends"; // brand-ok: the plugin name the files use +/// The most bytes of a blend's record read. +pub(super) const MAX_RECORD: usize = 4096; +/// The most keys read from one blend. +const MAX_KEYS: usize = 64; +/// The most steps between two keys (Blend Options' limit). +const MAX_STEPS: usize = 1000; +/// How far (pt) a step laid out here may be from where the file draws it. +const PLACE_TOLERANCE: f64 = 0.5; +/// How far a step's colour (each channel, 0 to 1) may be from the drawn one. +const COLOR_TOLERANCE: f32 = 0.02; +/// The note for a blend that keeps its drawn steps. +pub(super) const DRAWN: &str = "a blend VectorCraft couldn't read as a live blend comes in as its drawn steps"; + +/// A blend part an object's dictionary names: its type (`K`, `S`) and its id. +pub(super) type Roles = HashMap)>; + +/// The bytes of hex digits `hex`, or `None` when they aren't pairs of hex digits. +pub(super) fn hex_bytes(hex: &[u8]) -> Option> { + if !hex.len().is_multiple_of(2) { + return None; + } + let digit = |b: u8| (b as char).to_digit(16).map(|d| d as u8); + hex.as_chunks::<2>().0.iter().map(|&[a, b]| Some(digit(a)? << 4 | digit(b)?)).collect() +} + +/// The keys' ids in a blend's record, in its order; `None` for a record that isn't one. +pub(super) fn record_ids(bytes: &[u8]) -> Option> { + if bytes.get(..4)? != b"DCBA" || bytes.len() > MAX_RECORD { + return None; + } + let words: Vec = bytes.as_chunks::<4>().0.iter().map(|c| u32::from_le_bytes(*c)).collect(); + if words.get(1) != Some(&1) { + return None; + } + let count = usize::try_from(*words.get(10)?).ok().filter(|c| (2..=MAX_KEYS).contains(c))?; + let rest = words.len().checked_sub(11)?; + if rest == 0 || !rest.is_multiple_of(count) { + return None; + } + let size = rest / count; + (0..count) + .map(|i| { + let w = *words.get(11 + i * size)?; + let c = char::from_u32(w).filter(char::is_ascii_alphanumeric)?; + Some(c.to_string()) + }) + .collect() +} + +/// The blend `edit` (its key objects and spine, as their dictionaries name them in `roles`) and +/// `drawn` (its steps as the file draws them) make, with the keys of the record `ids`: one live +/// blend, or `None` when they don't agree or it doesn't come out as drawn. +pub(super) fn live_blend(edit: &Node, drawn: &Node, ids: &[String], roles: &Roles) -> Option { + let parts = edit.children()?; + let role = |n: &Arc| roles.get(&n.id); + let keys: Vec> = parts.iter().filter(|n| role(n).is_some_and(|(t, _)| t == "K")).cloned().collect(); + let spine = parts.iter().filter(|n| role(n).is_some_and(|(t, _)| t == "S")).find_map(|n| match &n.kind { + NodeKind::Path { path, .. } => Some(path.clone()), + _ => None, + }); + // Each key named once, and the record's keys the same. + let mut named: Vec<&str> = keys.iter().filter_map(|k| role(k).and_then(|(_, id)| id.as_deref())).collect(); + if keys.len() < 2 || named.len() != keys.len() || ids.len() != keys.len() { + return None; + } + named.sort_unstable(); + let mut listed: Vec<&str> = ids.iter().map(String::as_str).collect(); + listed.sort_unstable(); + if named != listed || named.windows(2).any(|w| w.first() == w.get(1)) { + return None; + } + // The steps between two keys: the drawn objects that aren't keys, shared out. + let drawn: Vec<&Arc> = drawn.children()?.iter().collect(); + let between = (drawn.len().checked_sub(keys.len())?).checked_div(keys.len() - 1)?; + if between == 0 || between > MAX_STEPS || drawn.len() != keys.len() + between * (keys.len() - 1) { + return None; + } + let keys: Vec> = keys + .into_iter() + .map(|k| { + let mut k = (*k).clone(); + k.visible = true; + Arc::new(k) + }) + .collect(); + let spine = spine?; + let key_anchors = on_spine(&keys, &spine)?; + let spacing = BlendSpacing::Steps(u32::try_from(between).ok()?); + // The orientation that lays the steps out as drawn, the closest when both do. + [BlendOrientation::AlignToPage, BlendOrientation::AlignToPath] + .into_iter() + .filter_map(|orientation| { + let spec = BlendSpec { spacing, orientation, spine: Some(spine.clone()), key_anchors: key_anchors.clone(), ..BlendSpec::default() }; + let laid = blend_expand(&keys, &spec); + let off = how_far(&laid, &drawn)?; + Some((off, spec)) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map(|(_, spec)| NodeKind::Blend { children: keys, spec: Box::new(spec) }) +} + +/// The anchor of `spine`'s first subpath each key's centre sits on (within a point), each a +/// different one. +fn on_spine(keys: &[Arc], spine: &PathData) -> Option> { + let anchors = &spine.subpaths.first()?.anchors; + let mut out: Vec = vec![]; + for k in keys { + let c = centre(k)?; + let (i, d) = anchors.iter().enumerate().map(|(i, a)| (i, (a.p - c).hypot())).min_by(|a, b| a.1.total_cmp(&b.1))?; + let i = u32::try_from(i).ok()?; + if d > 1.0 || out.contains(&i) { + return None; + } + out.push(i); + } + Some(out) +} + +fn centre(n: &Node) -> Option { + n.geometric_bounds().map(|b| b.center()).filter(|p| p.x.is_finite() && p.y.is_finite()) +} + +/// An object's first solid fill colour, as RGB. +fn fill(n: &Node) -> Option<[f32; 3]> { + n.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) => match &f.paint { + Paint::Solid { color, .. } => Some(Color::to_rgb(color)), + _ => None, + }, + _ => None, + }) +} + +/// How far the objects `laid` are from `drawn` (the farthest centre, each drawn object matched to +/// the nearest laid one), when they are as many and each matches in place and colour; else `None`. +fn how_far(laid: &[Node], drawn: &[&Arc]) -> Option { + if laid.len() != drawn.len() { + return None; + } + let mut worst: f64 = 0.0; + for d in drawn { + let c = centre(d)?; + let (near, off) = laid.iter().filter_map(|l| Some((l, (centre(l)? - c).hypot()))).min_by(|a, b| a.1.total_cmp(&b.1))?; + if off > PLACE_TOLERANCE { + return None; + } + if let (Some(a), Some(b)) = (fill(near), fill(d)) + && a.iter().zip(b).any(|(x, y)| (x - y).abs() > COLOR_TOLERANCE) + { + return None; + } + worst = worst.max(off); + } + Some(worst) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Records that aren't one: too short, another magic, another version, key counts out of + /// range or not dividing the rest, ids that aren't letters or digits, and hex digits that + /// don't pair up. None of them panics. + #[test] + fn records_are_checked() { + let words = |w: &[u32]| w.iter().flat_map(|x| x.to_le_bytes()).collect::>(); + let magic = u32::from_le_bytes(*b"DCBA"); + let good = words(&[magic, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2, 'A' as u32, 0, 0, 0, 0, 0, 'B' as u32, 0, 0, 0, 0, 0]); + assert_eq!(record_ids(&good), Some(vec!["A".to_string(), "B".to_string()])); + for bad in [ + vec![], + b"DCB".to_vec(), + words(&[0x41424344, 1]), + words(&[magic, 2, 0, 0, 0, 0, 0, 0, 0, 0, 2, 65, 0, 66, 0]), + words(&[magic, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 65]), + words(&[magic, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1000, 65]), + words(&[magic, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2, 65, 0, 66]), + words(&[magic, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0x20, 0, 66, 0]), + words(&[magic, 1, 0, 0, 0, 0, 0, 0, 0, 0, u32::MAX]), + ] { + assert_eq!(record_ids(&bad), None, "{bad:?}"); + } + assert_eq!(hex_bytes(b"4A4"), None); + assert_eq!(hex_bytes(b"4G"), None); + assert_eq!(hex_bytes(b"4a4B"), Some(vec![0x4a, 0x4b])); + } +} diff --git a/crates/eps/src/import/ai/mod.rs b/crates/eps/src/import/ai/mod.rs index d74fba703..e71fc7db4 100644 --- a/crates/eps/src/import/ai/mod.rs +++ b/crates/eps/src/import/ai/mod.rs @@ -17,6 +17,7 @@ //! objects and points. Anything it doesn't read is an error: the caller opens the file the way it //! did before (the printed page, or the PDF), with a warning saying why. +mod blend; mod frames; mod lex; mod mesh; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 45c3c89e0..ef6af8fe6 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -505,6 +505,10 @@ struct Reader<'a> { drawn_looks: usize, /// Nodes read from `%_` lines. hidden_ids: HashSet, + /// The blend part each object's dictionary names (`ShapeBlendPathType`, `ShapeBlendPathID`). + blend_roles: super::blend::Roles, + /// A blend's record being read from the `%` lines after its `XP`: its length and hex digits. + blend_record: Option<(usize, Vec)>, /// A group just ended, its state (`Xy`, `Xd`, `XW`) not yet read. group_ended: bool, /// A layer's own state (`Xy` after its art, before `LB`), until something says the art moves on. @@ -667,6 +671,8 @@ impl<'a> Reader<'a> { warnings: Vec::new(), drawn_looks: 0, hidden_ids: HashSet::new(), + blend_roles: HashMap::new(), + blend_record: None, clip_fills: HashSet::new(), looks: HashSet::new(), compound_groups: HashSet::new(), @@ -745,6 +751,16 @@ impl<'a> Reader<'a> { fn comment(&mut self, c: &[u8]) -> Result<(), String> { let line = String::from_utf8_lossy(c); let line = line.trim_end(); + if let Some((len, hex)) = self.blend_record.as_mut() { + if !line.is_empty() && line.bytes().all(|b| b.is_ascii_hexdigit()) { + hex.extend_from_slice(line.as_bytes()); + if hex.len() >= *len * 2 { + self.end_blend(); + } + return Ok(()); + } + self.end_blend(); + } if let Some(r) = self.raster.as_mut() && r.operands.is_some() && !line.is_empty() @@ -1129,6 +1145,9 @@ impl<'a> Reader<'a> { // ---- operators ------------------------------------------------------------------------- fn op(&mut self, w: &str, hidden: bool) -> Result<(), String> { + if self.blend_record.is_some() { + self.end_blend(); + } match w { // The writer's placeholder for a number the object doesn't have (a radial gradient's // highlight `-- -- angle length Bh`): a NaN keeps the other operands where they are. @@ -1398,6 +1417,14 @@ impl<'a> Reader<'a> { r.operands = Some(vals); } } + // A plugin group ends; a blend's record follows on `%` lines. + "XP" => { + let name = vals.iter().find_map(V::text).unwrap_or_default(); + let len = Self::nums(&vals).last().copied().unwrap_or(0.0); + if name == super::blend::PLUGIN && len.is_finite() && len > 0.0 && len <= super::blend::MAX_RECORD as f64 { + self.blend_record = Some((len as usize, vec![])); + } + } _ if IGNORED.contains(&w) => {} // Before the art, the setup runs procedures of the printing format. _ if self.frames.is_empty() => {} @@ -1646,9 +1673,49 @@ impl<'a> Reader<'a> { self.frames.iter().all(|f| !matches!(&f.kind, Kind::Layer(a) if !a.visible)) } + /// A blend's record read: the blend's own art (the group of its key objects and spine, on `%_` + /// lines) and its drawn steps (the group after it) become one live blend, or, where they can't, + /// the drawn steps stay, without the blend's own art drawn over them (see [`super::blend`]). + fn end_blend(&mut self) { + let Some((_, hex)) = self.blend_record.take() else { return }; + let ids = super::blend::hex_bytes(&hex).as_deref().and_then(super::blend::record_ids); + let roles = &self.blend_roles; + let Some(f) = self.frames.last_mut() else { return }; + let n = f.children.len(); + let (Some(at), Some(drawn)) = (n.checked_sub(2), n.checked_sub(1)) else { return }; + let part_of_blend = |node: &Node| node.children().is_some_and(|c| c.iter().any(|k| roles.contains_key(&k.id))); + let (Some(edit), Some(steps)) = (f.children.get(at), f.children.get(drawn)) else { return }; + if !part_of_blend(edit) || !matches!(steps.kind, NodeKind::Group { clip: false, .. }) { + self.warn(super::blend::DRAWN); + return; + } + let live = ids.and_then(|ids| super::blend::live_blend(edit, steps, &ids, roles)); + let Some(mut steps) = f.children.pop() else { return }; + let Some(_edit) = f.children.pop() else { return }; + match live { + Some(kind) => { + let mut blend = Node::new(self.doc.alloc_id(), kind); + (blend.name, blend.visible, blend.locked) = (steps.name.take(), steps.visible, steps.locked); + (blend.opacity, blend.blend, blend.isolate, blend.knockout) = (steps.opacity, steps.blend, steps.isolate, steps.knockout); + f.children.push(blend); + } + None => { + f.children.push(steps); + self.warn(super::blend::DRAWN); + } + } + } + /// An object's dictionary: its name (`AIArtName`, or the XML id `AI10_ArtUID`), and the /// opacity mask it may carry. fn art_dictionary(&mut self, o: &Obj) { + // A blend's key object or spine. + if let Some(kind) = o.text("ShapeBlendPathType") + && let Some(id) = self.last_object().map(|n| n.id) + && self.blend_roles.len() < 100_000 + { + self.blend_roles.insert(id, (kind, o.text("ShapeBlendPathID"))); + } // An envelope (a distortion, `AIDeformStyle` / `DeformValue`) is written on `%_` lines as // a group holding the mesh that shapes it; the distorted art the file's PDF part draws // follows on plain lines. That mesh isn't art, so it goes (#2405). diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 6a0d914c5..9cb7be5ea 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1569,3 +1569,158 @@ fn stroked_path_guides_from_the_star_operator() { assert_eq!(n.appearance.items.len(), 1); assert!(matches!(n.appearance.items[0], AppearanceItem::Stroke(_))); } + +/// A blend's record as the editing data writes it after `(Adobe Path Blends) 1 0 n XP`: `DCBA`, +/// version 1, 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 { + let mut words: Vec = vec![u32::from_le_bytes(*b"DCBA"), 1, 0, 0, 0, 0, 0, 0, 0, 0, ids.len() as u32]; + for id in ids { + words.extend([*id as u32, 0, 0, 0, 0, 0]); + } + let hex: String = words.iter().flat_map(|w| w.to_le_bytes()).map(|b| format!("{b:02X}")).collect(); + let lines: Vec = hex.as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect(); + format!("(Adobe Path Blends) 1 0 {} XP\n{}\n", hex.len() / 2, lines.join("\n")) +} + +/// A square `s` wide with its lower left at (x, y), filled with RGB `rgb`, as `%_` lines when +/// `hidden`. +fn square(x: f64, y: f64, s: f64, rgb: [f64; 3], hidden: bool) -> String { + let p = if hidden { "%_" } else { "" }; + format!("{p}0 0 0 0 {} {} {} Xa\n{p}{x} {y} m\n{p}{x} {} L\n{p}{} {} L\n{p}{} {y} L\n{p}f\n", rgb[0], rgb[1], rgb[2], y + s, x + s, y + s, x + s) +} + +/// The dictionary naming a blend part: type `kind` (`K`, `S`) and, for a key, `id`. +fn blend_part(kind: &str, id: Option<&str>) -> String { + let id = id.map(|i| format!("%_({i}) /String (ShapeBlendPathID) ,\n")).unwrap_or_default(); + format!("%_/ArtDictionary :\n{id}%_({kind}) /String (ShapeBlendPathType) ,\n%_;\n") +} + +/// 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 { + let (red, blue) = ([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]); + let mut art = String::from("%_0 Ae\n%_u\n"); + art += &square(0.0, 40.0, 20.0, red, true); + art += &blend_part("K", ids[0]); + art += &square(100.0, 40.0, 20.0, blue, true); + art += &blend_part("K", ids[1]); + art += spine; + art += "%_N\n"; + art += &blend_part("S", None); + art += "%_U\n%_9 () XW\nu\n"; + for i in 0..5 { + let t = i as f64 / 4.0; + art += &square(t * 100.0, 40.0, 20.0, [1.0 - t, 0.0, t], false); + } + art += "U\n9 () XW\n0 Ae\n"; + art += record; + art +} + +/// The blend read from `body`: the first object of the layer. +fn read_blend(body: &str) -> (Arc, Vec) { + let s = read_ok(&layer(body)); + let first = art(&s.doc).into_iter().next().expect("an object"); + (first, s.warnings) +} + +/// Two keys, a straight spine and a record: one live blend of the two keys, three steps between +/// them (as drawn), its colours running from red to blue, the drawn steps and the blend's own art +/// gone. +#[test] +fn a_blend_comes_in_as_a_live_blend() { + let body = blend_file("%_10 50 m\n%_110 50 L\n", [Some("A"), Some("B")], &blend_record(&['A', 'B'])); + let s = read_ok(&layer(&body)); + let objects = art(&s.doc); + assert_eq!(objects.len(), 1, "one object: {objects:?}"); + let NodeKind::Blend { children, spec } = &objects[0].kind else { panic!("not a blend: {:?}", objects[0].kind) }; + assert_eq!(children.len(), 2); + assert_eq!(spec.spacing, vectorcraft_doc::live::BlendSpacing::Steps(3)); + let laid = vectorcraft_doc::blend::blend_expand(children, spec); + assert_eq!(laid.len(), 5); + let middle = laid.iter().find(|n| n.geometric_bounds().is_some_and(|b| (b.center().x - 60.0).abs() < 0.5)).expect("the middle step"); + let Paint::Solid { color, .. } = fill(middle) else { panic!("solid") }; + let [r, _, b] = color.to_rgb(); + assert!((r - 0.5).abs() < 0.02 && (b - 0.5).abs() < 0.02, "{r} {b}"); + assert!(!s.warnings.iter().any(|w| w == super::blend::DRAWN), "{:?}", s.warnings); +} + +/// A curved spine (a line, a curve and a line) keeps its points exactly; written from the right +/// key to the left or the other way, the keys stay in their order on it. +#[test] +fn a_blends_spine_keeps_its_points_and_its_keys_order() { + for spine in ["%_110 50 m\n%_95 45 l\n%_80 20 40 20 25 45 c\n%_10 50 L\n", "%_10 50 m\n%_25 45 l\n%_40 20 80 20 95 45 c\n%_110 50 L\n"] { + // Drawn steps on a curve lie off the straight line: this blend comes out as drawn only + // where its steps follow the spine, so the spine and the key order are what's checked. + let (blend, _) = read_blend(&blend_file(spine, [Some("A"), Some("B")], &blend_record(&['A', 'B']))); + let NodeKind::Blend { children, spec } = &blend.kind else { + // Steps laid along this curve aren't where the straight drawing has them: the drawn + // steps stay, which is right too; the spine's own reading is checked below. + continue; + }; + let red = |n: &Node| matches!(fill(n), Paint::Solid { color, .. } if color.to_rgb()[0] > 0.9); + assert!(red(&children[0]), "the first key is the red one"); + let spine = spec.spine.as_ref().unwrap(); + assert_eq!(spine.subpaths[0].anchors.len(), 4); + } + // The spine's points as read, through the blend's own art. + let spine = "%_110 50 m\n%_95 45 l\n%_80 20 40 20 25 45 c\n%_10 50 L\n"; + let s = read_ok(&layer(&format!("%_u\n{spine}%_N\n%_U\n"))); + let path = s.doc.layers[0].children().unwrap().iter().find_map(|n| { + let mut found = None; + n.walk(&mut |c| { + if let NodeKind::Path { path, .. } = &c.kind { + found = Some(path.clone()); + } + }); + found + }); + if let Some(path) = path { + let a = &path.subpaths[0].anchors; + assert_eq!(a.len(), 4); + assert!((a[1].h_out.x - 80.0).abs() < 1e-9 && (a[2].h_in.x - 40.0).abs() < 1e-9 && (a[2].p.x - 25.0).abs() < 1e-9, "{a:?}"); + } +} + +/// A record that isn't one, a key with no id, or ids the record doesn't list: the drawn steps +/// stay (with a note), without the blend's own art drawn over them. +#[test] +fn a_blend_it_cant_read_keeps_its_drawn_steps() { + let straight = "%_10 50 m\n%_110 50 L\n"; + let garbled = "(Adobe Path Blends) 1 0 16 XP\n%00112233445566778899AABBCCDDEEFF\n"; + let short = "(Adobe Path Blends) 1 0 92 XP\n%44434241\n"; + for body in [ + blend_file(straight, [Some("A"), Some("B")], garbled), + blend_file(straight, [Some("A"), Some("B")], short), + blend_file(straight, [Some("A"), None], &blend_record(&['A', 'B'])), + blend_file(straight, [Some("A"), Some("B")], &blend_record(&['X', 'Y'])), + ] { + let s = read_ok(&layer(&body)); + let objects = art(&s.doc); + assert_eq!(objects.len(), 1, "only the drawn steps: {objects:?}"); + assert_eq!(objects[0].children().map(|c| c.len()), Some(5), "the five drawn squares"); + assert!(s.warnings.iter().any(|w| w == super::blend::DRAWN), "{:?}", s.warnings); + } +} + +/// The blend is live: moving a key moves the steps between it and the other key with it. +#[test] +fn moving_a_blends_key_moves_its_steps() { + let body = blend_file("%_10 50 m\n%_110 50 L\n", [Some("A"), Some("B")], &blend_record(&['A', 'B'])); + let (blend, _) = read_blend(&body); + let NodeKind::Blend { children, spec } = &blend.kind else { panic!("a blend") }; + let middle = |keys: &[Arc]| { + let laid = vectorcraft_doc::blend::blend_expand(keys, spec); + let xs: Vec = laid.iter().filter_map(|n| n.geometric_bounds()).map(|b| b.center().y).collect(); + xs.iter().sum::() / xs.len() as f64 + }; + let before = middle(children); + let mut keys = children.clone(); + let mut moved = (*keys[1]).clone(); + moved.transform(vectorcraft_geom::Affine::translate((0.0, 40.0)), true); + keys[1] = Arc::new(moved); + let after = middle(&keys); + assert!((after - before - 20.0).abs() < 1.0, "the steps follow: {before} -> {after}"); +} From 4214d4c9d3139e900f1e59ee4f40379aa074bcf4 Mon Sep 17 00:00:00 2001 From: ham9zah Date: Mon, 26 Oct 2026 10:05:16 +0300 Subject: [PATCH 2/2] Import: live blends are checked in bounded time and keep no stray art Review follow-ups for the live blend import: - The check of a blend against its drawn steps matched every drawn step against every laid one and measured centres inside the loop, so a crafted blend (64 keys, 1,000 steps between each) could stall the import. Centres and fills are now measured once, and each drawn step finds its neighbours through a grid of cells the tolerance wide. - A blend lays out at most 5,000 objects to be made live, and all the blends of one import at most 50,000 (each laid out twice, once per orientation); past either, the drawn steps stay, with the note. The live steps take the place of drawn steps already counted against the reader's object limit. - A fill the check can't compare (a gradient, a pattern) or a missing one no longer passes: the fills must both be none or solid colours within the tolerance. - When what follows the blend's own art isn't a group of drawn steps, that art (its keys and spine) goes too, with the note, instead of staying drawn. - The spine test can no longer pass without asserting: a spine written backwards keeps the keys' order (live), and a curved spine is read exactly while its straight-drawn steps stay drawn. New tests for steps with a gradient, for the blend's art when its steps aren't a group, and for the budget; import_fuzz gains a mutated-blend property and a large-blend test (998 steps live, 1,500 kept drawn, both quickly). --- crates/engine/tests/import_fuzz.rs | 74 +++++++++++++++++++ crates/eps/src/import/ai/blend.rs | 111 ++++++++++++++++++++++------- crates/eps/src/import/ai/read.rs | 20 +++++- crates/eps/src/import/ai/tests.rs | 78 +++++++++++++------- 4 files changed, 229 insertions(+), 54 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 5ca4f1899..8c6da8f73 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -4265,3 +4265,77 @@ fn a_long_thin_shape_with_a_shadow_renders_and_exports() { let doc = vectorcraft_engine::cmd::fileio::load("long.af", &bytes).unwrap().doc; survive("long shape", || Some(doc)).unwrap(); } + +/// A blend in `.ai` editing data, written by hand: two keys (10 pt squares, red on the left, blue on +/// the right) on a straight spine, `steps` steps drawn between them as the blend lays them out, and +/// the record (`DCBA`, version 1, two keys `A` and `B`, see `vectorcraft_eps`' AI blend reader). +fn blend_text(steps: usize) -> String { + use std::fmt::Write; + let square = |x: f64, hidden: bool, t: f64| { + let p = if hidden { "%_" } else { "" }; + format!("{p}0 0 0 0 {} 0 {} Xa\n{p}{x} 40 m\n{p}{x} 50 L\n{p}{} 50 L\n{p}{} 40 L\n{p}f\n", 1.0 - t, t, x + 10.0, x + 10.0) + }; + let part = |kind: &str, id: Option<&str>| { + let id = id.map(|i| format!("%_({i}) /String (ShapeBlendPathID) ,\n")).unwrap_or_default(); + format!("%_/ArtDictionary :\n{id}%_({kind}) /String (ShapeBlendPathType) ,\n%_;\n") + }; + let span = 10.0 * (steps as f64 + 1.0); + let mut s = String::from("%_0 Ae\n%_u\n"); + s += &square(0.0, true, 0.0); + s += &part("K", Some("A")); + s += &square(span, true, 1.0); + s += &part("K", Some("B")); + let _ = write!(s, "%_5 45 m\n%_{} 45 L\n%_N\n", span + 5.0); + s += &part("S", None); + s += "%_U\n%_9 () XW\nu\n"; + for i in 0..steps + 2 { + let t = i as f64 / (steps as f64 + 1.0); + s += &square(t * span, false, t); + } + s += "U\n9 () XW\n0 Ae\n"; + let mut words: Vec = vec![u32::from_le_bytes(*b"DCBA"), 1, 0, 0, 0, 0, 0, 0, 0, 0, 2]; + for id in ['A', 'B'] { + words.extend([id as u32, 0, 0, 0, 0, 0]); + } + let hex: String = words.iter().flat_map(|w| w.to_le_bytes()).map(|b| format!("{b:02X}")).collect(); + let _ = write!(s, "(Adobe Path Blends) 1 0 {} XP\n%{hex}\n", hex.len() / 2); + s +} + +/// `.ai` editing data holding `layer_art` on one layer, compressed as the private data is. +fn ai_private(layer_art: &str) -> Vec { + use vectorcraft_testkit::ai; + let data = ai::editing_data(4000.0, 200.0, &ai::layer("L", layer_art)); + [b"%AI24_ZStandard_Data".as_slice(), &zstd(&data)].concat() +} + +/// A blend of many steps doesn't stall the import: one within the limits (a thousand steps between +/// two keys) is checked and made live quickly, one past them keeps its drawn steps without being +/// laid out. +#[test] +fn large_ai_blends_import_in_bounded_time() { + for (steps, live) in [(3, true), (998, true), (1_500, false)] { + let private = ai_private(&blend_text(steps)); + let started = std::time::Instant::now(); + let (doc, notes, read) = vectorcraft_eps::layered_ai(&private, Document::new(4000.0, 200.0), vec![], false, true, 1.0); + assert!(read, "{steps} steps: read from the layers ({notes:?})"); + let took = started.elapsed(); + assert!(took < std::time::Duration::from_secs(30), "{steps} steps took {took:?}"); + let mut blends = 0; + doc.walk(|n| blends += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Blend { .. }))); + assert_eq!(blends == 1, live, "{steps} steps: live {live}"); + } +} + +proptest! { + #![proptest_config(config())] + + /// A `.ai` blend whose record, parts or drawn steps are damaged or hostile: read live, as its + /// drawn steps, or not at all, never panicking. + #[test] + fn ai_blend_never_panics(steps in 1usize..6, cut in 0usize..2_500, edits in prop::collection::vec((0usize..2_500, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '%', '_', 'K', 'S', 'A', 'B', 'u', 'U', 'F', 'X'])), 0..12)) { + let text = mutate_text(&blend_text(steps), cut, &edits); + let private = ai_private(&text); + survive("mutated .ai blend", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, true, 1.0).0))?; + } +} diff --git a/crates/eps/src/import/ai/blend.rs b/crates/eps/src/import/ai/blend.rs index 3de7c501f..9cee1d81b 100644 --- a/crates/eps/src/import/ai/blend.rs +++ b/crates/eps/src/import/ai/blend.rs @@ -33,6 +33,9 @@ pub(super) const MAX_RECORD: usize = 4096; const MAX_KEYS: usize = 64; /// The most steps between two keys (Blend Options' limit). const MAX_STEPS: usize = 1000; +/// The most objects (keys and steps) one blend may lay out to be read live; more keep their +/// drawn steps. +pub(super) const MAX_BLEND_OBJECTS: usize = 5_000; /// How far (pt) a step laid out here may be from where the file draws it. const PLACE_TOLERANCE: f64 = 0.5; /// How far a step's colour (each channel, 0 to 1) may be from the drawn one. @@ -79,7 +82,12 @@ pub(super) fn record_ids(bytes: &[u8]) -> Option> { /// The blend `edit` (its key objects and spine, as their dictionaries name them in `roles`) and /// `drawn` (its steps as the file draws them) make, with the keys of the record `ids`: one live /// blend, or `None` when they don't agree or it doesn't come out as drawn. -pub(super) fn live_blend(edit: &Node, drawn: &Node, ids: &[String], roles: &Roles) -> Option { +/// +/// `budget`: how many more objects blends may lay out in this import; a blend that would lay out +/// more isn't made live (each blend is laid out twice, once per orientation, and checked against +/// its drawn steps, so a file of huge blends mustn't stall the import). What it lays out is taken +/// from it. +pub(super) fn live_blend(edit: &Node, drawn: &Node, ids: &[String], roles: &Roles, budget: &mut usize) -> Option { let parts = edit.children()?; let role = |n: &Arc| roles.get(&n.id); let keys: Vec> = parts.iter().filter(|n| role(n).is_some_and(|(t, _)| t == "K")).cloned().collect(); @@ -101,9 +109,17 @@ pub(super) fn live_blend(edit: &Node, drawn: &Node, ids: &[String], roles: &Role // The steps between two keys: the drawn objects that aren't keys, shared out. let drawn: Vec<&Arc> = drawn.children()?.iter().collect(); let between = (drawn.len().checked_sub(keys.len())?).checked_div(keys.len() - 1)?; - if between == 0 || between > MAX_STEPS || drawn.len() != keys.len() + between * (keys.len() - 1) { + if between == 0 || between > MAX_STEPS || drawn.len() != keys.len() + between * (keys.len() - 1) || drawn.len() > MAX_BLEND_OBJECTS { return None; } + // Both orientations are laid out. + let cost = drawn.len().saturating_mul(2); + if cost > *budget { + return None; + } + *budget -= cost; + // The drawn steps' centres and paints, once. + let drawn: Vec<(Point, Fill)> = drawn.iter().map(|d| Some((centre(d)?, fill(d)))).collect::>()?; let keys: Vec> = keys .into_iter() .map(|k| { @@ -149,36 +165,68 @@ fn centre(n: &Node) -> Option { n.geometric_bounds().map(|b| b.center()).filter(|p| p.x.is_finite() && p.y.is_finite()) } -/// An object's first solid fill colour, as RGB. -fn fill(n: &Node) -> Option<[f32; 3]> { - n.appearance.items.iter().find_map(|i| match i { - AppearanceItem::Fill(f) => match &f.paint { - Paint::Solid { color, .. } => Some(Color::to_rgb(color)), - _ => None, - }, - _ => None, - }) +/// An object's fill, as far as the check compares it. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Fill { + /// No fill. + None, + /// A solid colour, as RGB. + Solid([f32; 3]), + /// A fill this can't compare (a gradient, a pattern): the blend isn't made live. + Other, } -/// How far the objects `laid` are from `drawn` (the farthest centre, each drawn object matched to -/// the nearest laid one), when they are as many and each matches in place and colour; else `None`. -fn how_far(laid: &[Node], drawn: &[&Arc]) -> Option { +/// An object's first fill. +fn fill(n: &Node) -> Fill { + n.appearance + .items + .iter() + .find_map(|i| match i { + AppearanceItem::Fill(f) => Some(match &f.paint { + Paint::None => Fill::None, + Paint::Solid { color, .. } => Fill::Solid(Color::to_rgb(color)), + _ => Fill::Other, + }), + _ => None, + }) + .unwrap_or(Fill::None) +} + +/// Do two fills match: both none, or solid colours within the tolerance? +fn same_fill(a: Fill, b: Fill) -> bool { + match (a, b) { + (Fill::None, Fill::None) => true, + (Fill::Solid(a), Fill::Solid(b)) => a.iter().zip(b).all(|(x, y)| (x - y).abs() <= COLOR_TOLERANCE), + _ => false, + } +} + +/// How far the objects `laid` are from the drawn ones (`drawn`: their centres and fills): the +/// farthest, each drawn object matched to a laid one within the tolerance (through a grid of +/// cells the tolerance wide, so each finds its neighbours without looking at them all), when they +/// are as many and each matches in place and fill; else `None`. +fn how_far(laid: &[Node], drawn: &[(Point, Fill)]) -> Option { if laid.len() != drawn.len() { return None; } + let cell = |p: Point| ((p.x / PLACE_TOLERANCE).floor() as i64, (p.y / PLACE_TOLERANCE).floor() as i64); + let laid: Vec<(Point, Fill)> = laid.iter().map(|l| Some((centre(l)?, fill(l)))).collect::>()?; + let mut grid: HashMap<(i64, i64), Vec> = HashMap::with_capacity(laid.len()); + for (i, (p, _)) in laid.iter().enumerate() { + grid.entry(cell(*p)).or_default().push(i); + } let mut worst: f64 = 0.0; - for d in drawn { - let c = centre(d)?; - let (near, off) = laid.iter().filter_map(|l| Some((l, (centre(l)? - c).hypot()))).min_by(|a, b| a.1.total_cmp(&b.1))?; - if off > PLACE_TOLERANCE { - return None; - } - if let (Some(a), Some(b)) = (fill(near), fill(d)) - && a.iter().zip(b).any(|(x, y)| (x - y).abs() > COLOR_TOLERANCE) - { - return None; - } - worst = worst.max(off); + for &(c, f) in drawn { + let (cx, cy) = cell(c); + let near = (-1..=1) + .flat_map(|dx| (-1..=1).map(move |dy| (cx + dx, cy + dy))) + .filter_map(|k| grid.get(&k)) + .flatten() + .filter_map(|&i| laid.get(i)) + .filter(|(p, lf)| (*p - c).hypot() <= PLACE_TOLERANCE && same_fill(*lf, f)) + .map(|(p, _)| (*p - c).hypot()) + .min_by(f64::total_cmp)?; + worst = worst.max(near); } Some(worst) } @@ -213,4 +261,15 @@ mod tests { assert_eq!(hex_bytes(b"4G"), None); assert_eq!(hex_bytes(b"4a4B"), Some(vec![0x4a, 0x4b])); } + + /// A blend that would lay out more than the import's budget has left isn't made live, and + /// takes nothing from it. + #[test] + fn a_blend_past_the_budget_isnt_made_live() { + let edit = Node::new(NodeId(1), NodeKind::Group { children: vec![], clip: false }); + let drawn = Node::new(NodeId(2), NodeKind::Group { children: vec![], clip: false }); + let mut budget = 1; + assert!(live_blend(&edit, &drawn, &["A".into(), "B".into()], &Roles::new(), &mut budget).is_none()); + assert_eq!(budget, 1); + } } diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index ef6af8fe6..1174356b0 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -75,6 +75,9 @@ const MAX_DEPTH: usize = 256; const MAX_STACK: usize = 1 << 20; /// Most objects read. const MAX_NODES: usize = 4_000_000; +/// The most objects all the blends of one import may lay out to be checked (each twice) and made +/// live; past it, blends keep their drawn steps. +const BLEND_BUDGET: usize = 50_000; /// Most points in one path. const MAX_POINTS: usize = 4_000_000; /// Largest image read (pixels). @@ -509,6 +512,8 @@ struct Reader<'a> { blend_roles: super::blend::Roles, /// A blend's record being read from the `%` lines after its `XP`: its length and hex digits. blend_record: Option<(usize, Vec)>, + /// How many more objects blends may lay out in this import ([`super::blend::live_blend`]). + blend_budget: usize, /// A group just ended, its state (`Xy`, `Xd`, `XW`) not yet read. group_ended: bool, /// A layer's own state (`Xy` after its art, before `LB`), until something says the art moves on. @@ -673,6 +678,7 @@ impl<'a> Reader<'a> { hidden_ids: HashSet::new(), blend_roles: HashMap::new(), blend_record: None, + blend_budget: BLEND_BUDGET, clip_fills: HashSet::new(), looks: HashSet::new(), compound_groups: HashSet::new(), @@ -1685,15 +1691,25 @@ impl<'a> Reader<'a> { let (Some(at), Some(drawn)) = (n.checked_sub(2), n.checked_sub(1)) else { return }; let part_of_blend = |node: &Node| node.children().is_some_and(|c| c.iter().any(|k| roles.contains_key(&k.id))); let (Some(edit), Some(steps)) = (f.children.get(at), f.children.get(drawn)) else { return }; - if !part_of_blend(edit) || !matches!(steps.kind, NodeKind::Group { clip: false, .. }) { + if !part_of_blend(edit) { + return; + } + if !matches!(steps.kind, NodeKind::Group { clip: false, .. }) { + // The steps aren't a group of drawn steps: the blend's own art goes all the same, so + // its keys and spine aren't drawn over what the file draws. + f.children.remove(at); self.warn(super::blend::DRAWN); return; } - let live = ids.and_then(|ids| super::blend::live_blend(edit, steps, &ids, roles)); + let mut budget = self.blend_budget; + let live = ids.and_then(|ids| super::blend::live_blend(edit, steps, &ids, roles, &mut budget)); + self.blend_budget = budget; let Some(mut steps) = f.children.pop() else { return }; let Some(_edit) = f.children.pop() else { return }; match live { Some(kind) => { + // The steps the blend lays out count as the objects they draw, as its drawn steps + // did (they were counted as read). let mut blend = Node::new(self.doc.alloc_id(), kind); (blend.name, blend.visible, blend.locked) = (steps.name.take(), steps.visible, steps.locked); (blend.opacity, blend.blend, blend.isolate, blend.knockout) = (steps.opacity, steps.blend, steps.isolate, steps.knockout); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 9cb7be5ea..304d2a234 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1647,41 +1647,67 @@ fn a_blend_comes_in_as_a_live_blend() { assert!(!s.warnings.iter().any(|w| w == super::blend::DRAWN), "{:?}", s.warnings); } -/// A curved spine (a line, a curve and a line) keeps its points exactly; written from the right -/// key to the left or the other way, the keys stay in their order on it. +/// A spine written from the right key to the left one keeps the keys in their order on it (the +/// red key first), sitting on its last and first anchors. #[test] -fn a_blends_spine_keeps_its_points_and_its_keys_order() { - for spine in ["%_110 50 m\n%_95 45 l\n%_80 20 40 20 25 45 c\n%_10 50 L\n", "%_10 50 m\n%_25 45 l\n%_40 20 80 20 95 45 c\n%_110 50 L\n"] { - // Drawn steps on a curve lie off the straight line: this blend comes out as drawn only - // where its steps follow the spine, so the spine and the key order are what's checked. - let (blend, _) = read_blend(&blend_file(spine, [Some("A"), Some("B")], &blend_record(&['A', 'B']))); - let NodeKind::Blend { children, spec } = &blend.kind else { - // Steps laid along this curve aren't where the straight drawing has them: the drawn - // steps stay, which is right too; the spine's own reading is checked below. - continue; - }; - let red = |n: &Node| matches!(fill(n), Paint::Solid { color, .. } if color.to_rgb()[0] > 0.9); - assert!(red(&children[0]), "the first key is the red one"); - let spine = spec.spine.as_ref().unwrap(); - assert_eq!(spine.subpaths[0].anchors.len(), 4); - } - // The spine's points as read, through the blend's own art. +fn a_blends_spine_written_backwards_keeps_its_keys_order() { + let (blend, warnings) = read_blend(&blend_file("%_110 50 m\n%_10 50 L\n", [Some("A"), Some("B")], &blend_record(&['A', 'B']))); + let NodeKind::Blend { children, spec } = &blend.kind else { panic!("a live blend: {:?} {warnings:?}", blend.kind) }; + assert!(matches!(fill(&children[0]), Paint::Solid { color, .. } if color.to_rgb()[0] > 0.9), "the first key is the red one"); + assert_eq!(spec.key_anchors, vec![1, 0], "the red key on the spine's end, the blue one on its start"); +} + +/// A curved spine (a line, a curve and a line) is read with its points exactly; a blend whose drawn +/// steps don't follow it (they are drawn on a straight line here) keeps its drawn steps. +#[test] +fn a_curved_spine_keeps_its_points() { let spine = "%_110 50 m\n%_95 45 l\n%_80 20 40 20 25 45 c\n%_10 50 L\n"; let s = read_ok(&layer(&format!("%_u\n{spine}%_N\n%_U\n"))); - let path = s.doc.layers[0].children().unwrap().iter().find_map(|n| { - let mut found = None; + let mut found = None; + for n in art(&s.doc) { n.walk(&mut |c| { if let NodeKind::Path { path, .. } = &c.kind { found = Some(path.clone()); } }); - found - }); - if let Some(path) = path { - let a = &path.subpaths[0].anchors; - assert_eq!(a.len(), 4); - assert!((a[1].h_out.x - 80.0).abs() < 1e-9 && (a[2].h_in.x - 40.0).abs() < 1e-9 && (a[2].p.x - 25.0).abs() < 1e-9, "{a:?}"); } + let path = found.expect("the spine is read"); + let a = &path.subpaths[0].anchors; + assert_eq!(a.len(), 4); + assert!((a[1].h_out.x - 80.0).abs() < 1e-9 && (a[2].h_in.x - 40.0).abs() < 1e-9 && (a[2].p.x - 25.0).abs() < 1e-9, "{a:?}"); + let (blend, warnings) = read_blend(&blend_file(spine, [Some("A"), Some("B")], &blend_record(&['A', 'B']))); + assert!(!matches!(blend.kind, NodeKind::Blend { .. }), "steps drawn off the spine aren't made live"); + assert_eq!(blend.children().map(|c| c.len()), Some(5)); + assert!(warnings.iter().any(|w| w == super::blend::DRAWN)); +} + +/// Steps with a gradient fill can't be compared: the blend keeps its drawn steps. +#[test] +fn a_blend_whose_steps_it_cant_compare_keeps_its_drawn_steps() { + let body = blend_file("%_10 50 m\n%_110 50 L\n", [Some("A"), Some("B")], &blend_record(&['A', 'B'])); + // The drawn squares' fill made a gradient's. + let gradient = "%AI5_BeginGradient: (g)\n(g) 0 2 Bd\n[\n1 0 0 0 1 %_Bs\n0 0 0 1 0 %_Bs\nBD\n%AI5_EndGradient\n"; + let drawn_from = body.find("\nu\n").expect("the drawn group") + 1; + let (head, tail) = body.split_at(drawn_from); + let tail = tail.replacen("Xa\n", "Xa\n0 1 0 0 1 0 0 1 0 0 Bb\n(g) 0 0 0 1 1 0 0 1 0 0 Bg\n", 5).replace("\nf\n", "\nf\n0 BB\n"); + let (blend, _) = read_blend(&format!("{gradient}{head}{tail}")); + assert!(!matches!(blend.kind, NodeKind::Blend { .. }), "{:?}", blend.kind); +} + +/// The blend's own art (its keys and spine) isn't left drawn when what follows it isn't a group of +/// drawn steps. +#[test] +fn a_blends_own_art_goes_when_its_steps_arent_a_group() { + let body = blend_file("%_10 50 m\n%_110 50 L\n", [Some("A"), Some("B")], &blend_record(&['A', 'B'])); + // The drawn steps as one square, not a group. + let at = body.find("\nu\n").expect("the drawn group") + 1; + let end = body.find("U\n9 () XW\n0 Ae\n").expect("its end"); + let body = format!("{}{}{}", &body[..at], square(0.0, 40.0, 20.0, [1.0, 0.0, 0.0], false), &body[end + 2..]); + let s = read_ok(&layer(&body)); + let objects = art(&s.doc); + assert_eq!(objects.len(), 1, "the drawn square alone: {objects:?}"); + assert!(matches!(objects[0].kind, NodeKind::Path { .. })); + assert!(s.warnings.iter().any(|w| w == super::blend::DRAWN)); } /// A record that isn't one, a key with no id, or ids the record doesn't list: the drawn steps