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}"); +}