diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 095d0ec14..fda4ad890 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -5715,8 +5715,15 @@ fn a_long_thin_shape_with_a_shadow_renders_and_exports() { /// 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). +/// the record (`DCBA`, Specified Steps, two keys `A` and `B`, see `vectorcraft_eps`' AI blend +/// reader). fn blend_text(steps: usize) -> String { + blend_text_spaced(steps, [1, 0, 0]) +} + +/// [`blend_text`] with the record's spacing words `spacing`: the spacing (word 1), the bits of the +/// distance (word 2, a 32-bit float) and word 4. +fn blend_text_spaced(steps: usize, spacing: [u32; 3]) -> String { use std::fmt::Write; let square = |x: f64, hidden: bool, t: f64| { let p = if hidden { "%_" } else { "" }; @@ -5740,7 +5747,8 @@ fn blend_text(steps: usize) -> String { 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]; + let [kind, distance, count] = spacing; + let mut words: Vec = vec![u32::from_le_bytes(*b"DCBA"), kind, distance, 0, count, 0, 0, 0, 0, 0, 2]; for id in ['A', 'B'] { words.extend([id as u32, 0, 0, 0, 0, 0]); } @@ -5788,6 +5796,14 @@ proptest! { survive("a .ai blend far out", || Some(vectorcraft_eps::layered_ai(&far, Document::new(100.0, 100.0), vec![], false, true, 1.0).0))?; survive("mutated .ai blend", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, true, 1.0).0))?; } + + /// A `.ai` blend whose record holds any spacing, distance bits (zero, negative, tiny, NaN, + /// infinite) and step count: read live, as its drawn steps, or not at all, never panicking. + #[test] + fn ai_blend_spacing_never_panics(steps in 1usize..6, kind in prop_oneof![0u32..4, any::()], distance in prop_oneof![any::(), prop::sample::select(vec![0u32, 0x8000_0000, 1, 0x7f80_0000, 0xff80_0000, 0x7fc0_0000, 0x7f7f_ffff, 0x41c8_0000])], count in prop_oneof![0u32..1_100, any::()]) { + let private = ai_private(&blend_text_spaced(steps, [kind, distance, count])); + survive("a .ai blend's spacing", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, true, 1.0).0))?; + } } /// A graph in `.ai` editing data, written by hand from the version 7 specification's chapter 17: diff --git a/crates/eps/src/import/ai/blend.rs b/crates/eps/src/import/ai/blend.rs index 553ab64b8..494a11cdc 100644 --- a/crates/eps/src/import/ai/blend.rs +++ b/crates/eps/src/import/ai/blend.rs @@ -3,24 +3,29 @@ //! 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. +//! start `DCBA`. +//! +//! The reader reads the words after `DCBA` (little endian) this way. Word 1 is the spacing: 0 is +//! Smooth Color, 1 is Specified Steps and 2 is Specified Distance. For Specified Distance, word 2 +//! is the distance in points, as a 32-bit float. Word 10 is the number of keys, and the words after +//! it are a record of six words per key that starts with the key's id. For Specified Steps, the +//! steps between two keys are counted from the steps drawn. The orientation is taken from the steps +//! drawn: whichever one lays the blend out as they are drawn. A blend whose parts don't agree, +//! whose spacing isn't one of these, 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::blend::{blend_expand, blend_steps}; use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec}; use vectorcraft_doc::{AppearanceItem, Node, NodeId, NodeKind}; use vectorcraft_geom::{PathData, Point}; @@ -55,15 +60,40 @@ pub(super) fn hex_bytes(hex: &[u8]) -> Option> { 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> { +/// What a blend's record says: its keys' ids, in its order, and its spacing. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct Record { + pub(super) ids: Vec, + /// `None`: Specified Steps, whose steps the reader counts from the drawn ones. + pub(super) spacing: Option, +} + +/// A blend's record, or `None` for a record that isn't one or whose spacing isn't read: an +/// unknown spacing, or a distance that isn't a positive number. +pub(super) fn record(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 spacing = match *words.get(1)? { + 0 => Some(BlendSpacing::SmoothColor), + 1 => None, + 2 => Some(BlendSpacing::Distance(distance(*words.get(2)?)?)), + _ => return None, + }; + let ids = record_ids(&words)?; + Some(Record { ids, spacing }) +} + +/// The distance in a record's word `bits` (a 32-bit float), as the shortest decimal that reads +/// back as that float (2.3 for the float nearest 2.3), or `None` when it isn't a positive number. +fn distance(bits: u32) -> Option { + let d: f64 = f32::from_bits(bits).to_string().parse().ok()?; + (d.is_finite() && d > 0.0).then_some(d) +} + +/// The keys' ids in a blend's record `words`, in its order; `None` when they aren't there. +fn record_ids(words: &[u32]) -> Option> { 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) { @@ -80,14 +110,15 @@ 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. +/// `drawn` (its steps as the file draws them) make, with the keys and spacing of `record`: one +/// live blend, or `None` when they don't agree or it doesn't come out as drawn. /// /// `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 { +pub(super) fn live_blend(edit: &Node, drawn: &Node, record: &Record, roles: &Roles, budget: &mut usize) -> Option { + let ids = &record.ids; 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(); @@ -106,20 +137,10 @@ pub(super) fn live_blend(edit: &Node, drawn: &Node, ids: &[String], roles: &Role 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) || drawn.len() > MAX_BLEND_OBJECTS { + if drawn.len() < keys.len() || 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| { @@ -130,12 +151,34 @@ pub(super) fn live_blend(edit: &Node, drawn: &Node, ids: &[String], roles: &Role .collect(); let spine = spine?; let key_anchors = on_spine(&keys, &spine)?; - let spacing = BlendSpacing::Steps(u32::try_from(between).ok()?); + let spacing = match record.spacing { + // Specified Steps: the steps between two keys are the drawn objects that aren't keys, + // shared out. + None => { + let between = (drawn.len() - keys.len()).checked_div(keys.len() - 1)?; + BlendSpacing::Steps(u32::try_from(between).ok().filter(|n| (1..=MAX_STEPS as u32).contains(n))?) + } + Some(spacing) => spacing, + }; + // Both orientations are laid out, as many objects as are drawn each. + let cost = drawn.len().saturating_mul(2); + if cost > *budget { + return None; + } + let spec = BlendSpec { spacing, spine: Some(spine), key_anchors, ..BlendSpec::default() }; + // As many objects as the spacing lays out between each pair of keys (the orientation doesn't + // change how many), counted before anything is laid out: as many as are drawn. + if blend_steps(&keys, &spec).into_iter().fold(keys.len(), usize::saturating_add) != drawn.len() { + 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::>()?; // 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 spec = BlendSpec { orientation, ..spec.clone() }; let laid = blend_expand(&keys, &spec); let off = how_far(&laid, &drawn)?; Some((off, spec)) @@ -242,7 +285,7 @@ fn how_far(laid: &[Node], drawn: &[(Point, Fill)]) -> Option { mod tests { use super::*; - /// Records that aren't one: too short, another magic, another version, key counts out of + /// Records that aren't one: too short, another magic, an unknown spacing, 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] @@ -250,7 +293,7 @@ mod tests { 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()])); + assert_eq!(record(&good).map(|r| r.ids), Some(vec!["A".to_string(), "B".to_string()])); for bad in [ vec![], b"DCB".to_vec(), @@ -262,7 +305,33 @@ mod tests { 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!(record(&bad), None, "{bad:?}"); + } + } + + /// The spacing at word 1 (0 Smooth Color, 1 Specified Steps, 2 Specified Distance) and, for + /// Specified Distance, the distance at word 2 as a 32-bit float, read as the decimal it was + /// typed as. Specified Steps leaves the steps to be counted from the drawn ones, whatever word + /// 4 holds. An unknown spacing, or a distance that isn't a positive number, isn't read. + #[test] + fn records_say_their_spacing() { + let words = |spacing: u32, distance: f32, steps: u32| { + let magic = u32::from_le_bytes(*b"DCBA"); + [magic, spacing, distance.to_bits(), 0, steps, 0, 0, 0, 0, 0, 2, 'A' as u32, 0, 0, 0, 0, 0, 'B' as u32, 0, 0, 0, 0, 0] + .iter() + .flat_map(|x| x.to_le_bytes()) + .collect::>() + }; + let spacing = |b: Vec| record(&b).map(|r| r.spacing); + assert_eq!(spacing(words(0, 4.0, 254)), Some(Some(BlendSpacing::SmoothColor))); + assert_eq!(spacing(words(1, 50.0, 3)), Some(None)); + assert_eq!(spacing(words(1, 0.0, 0)), Some(None)); + assert_eq!(spacing(words(2, 12.5, 7)), Some(Some(BlendSpacing::Distance(12.5)))); + assert_eq!(spacing(words(2, 2.3, 0)), Some(Some(BlendSpacing::Distance(2.3)))); + assert_eq!(spacing(words(2, 0.1, 0)), Some(Some(BlendSpacing::Distance(0.1)))); + assert_eq!(spacing(words(2, 12345.67, 0)), Some(Some(BlendSpacing::Distance(12345.67)))); + for (kind, distance) in [(2, 0.0), (2, -10.0), (2, f32::NAN), (2, f32::INFINITY), (3, 10.0), (u32::MAX, 10.0)] { + assert_eq!(spacing(words(kind, distance, 7)), None, "spacing {kind}, distance {distance}"); } assert_eq!(hex_bytes(b"4A4"), None); assert_eq!(hex_bytes(b"4G"), None); @@ -276,10 +345,69 @@ mod tests { 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()); + let record = Record { ids: vec!["A".into(), "B".into()], spacing: None }; + assert!(live_blend(&edit, &drawn, &record, &Roles::new(), &mut budget).is_none()); assert_eq!(budget, 1); } + /// Two 20 pt keys (`A` at x 0 and `B` at x 100) on a straight spine through their centers, + /// with three 20 pt steps drawn evenly between them: the blend's edit group, its drawn steps + /// and the roles naming its parts. + fn two_keys() -> (Node, Node, Roles) { + let square = |id: u64, x: f64| { + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 40.0, x + 20.0, 60.0)); + Arc::new(Node::path(NodeId(id), path, Default::default())) + }; + let spine = vectorcraft_geom::shapes::line(Point::new(10.0, 50.0), Point::new(110.0, 50.0)); + let spine = Arc::new(Node::path(NodeId(3), spine, Default::default())); + let edit = Node::new(NodeId(10), NodeKind::Group { children: vec![square(1, 0.0), square(2, 100.0), spine], clip: false }); + let steps = (0..5u32).map(|i| square(20 + u64::from(i), f64::from(i) * 25.0)).collect(); + let drawn = Node::new(NodeId(11), NodeKind::Group { children: steps, clip: false }); + let roles = Roles::from([ + (NodeId(1), ("K".to_string(), Some("A".to_string()))), + (NodeId(2), ("K".to_string(), Some("B".to_string()))), + (NodeId(3), ("S".to_string(), None)), + ]); + (edit, drawn, roles) + } + + /// The budget: a blend whose two layouts (one per orientation, five objects each) fit in what + /// is left is made live and takes ten; one that doesn't fit, or whose spacing lays out another + /// number of steps than are drawn, isn't made live and takes nothing. + #[test] + fn a_blend_takes_from_the_budget_only_when_it_is_laid_out() { + let (edit, drawn, roles) = two_keys(); + let steps = Record { ids: vec!["A".into(), "B".into()], spacing: None }; + let mut budget = 10; + assert!(matches!(live_blend(&edit, &drawn, &steps, &roles, &mut budget), Some(NodeKind::Blend { .. }))); + assert_eq!(budget, 0); + let mut budget = 9; + assert!(live_blend(&edit, &drawn, &steps, &roles, &mut budget).is_none()); + assert_eq!(budget, 9, "a blend past the budget takes nothing"); + let nine = Record { spacing: Some(BlendSpacing::Distance(10.0)), ..steps }; + let mut budget = 10; + assert!(live_blend(&edit, &drawn, &nine, &roles, &mut budget).is_none()); + assert_eq!(budget, 10, "a spacing that lays out nine steps where three are drawn takes nothing"); + } + + /// A distance longer than the keys are apart lays out no steps: a blend drawn as its two keys + /// alone comes in live with that distance, and one of Specified Steps drawn that way doesn't. + #[test] + fn a_distance_past_the_keys_lays_out_no_steps() { + let (edit, drawn, roles) = two_keys(); + let NodeKind::Group { children, .. } = &drawn.kind else { panic!("a group") }; + let keys_alone = [children[0].clone(), children[4].clone()]; + let drawn = Node::new(NodeId(11), NodeKind::Group { children: keys_alone.to_vec(), clip: false }); + let far = Record { ids: vec!["A".into(), "B".into()], spacing: Some(BlendSpacing::Distance(150.0)) }; + let mut budget = 10; + let Some(NodeKind::Blend { spec, .. }) = live_blend(&edit, &drawn, &far, &roles, &mut budget) else { panic!("a live blend") }; + assert_eq!(spec.spacing, BlendSpacing::Distance(150.0)); + assert_eq!(budget, 6); + let steps = Record { spacing: None, ..far }; + assert!(live_blend(&edit, &drawn, &steps, &roles, &mut budget).is_none()); + assert_eq!(budget, 6); + } + /// Two drawn steps on one laid object don't both match it: each laid object matches one drawn /// one, so a drawing the blend doesn't lay out isn't taken for it. #[test] diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index a37471ba6..09d056a4e 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -540,7 +540,7 @@ impl<'a> Reader<'a> { /// the drawn steps stay, without the blend's own art drawn over them (see [`super::blend`]). pub(super) fn end_blend(&mut self) { let Some((_, hex)) = self.blend_record.take() else { return }; - let ids = crate::import::ai::blend::hex_bytes(&hex).as_deref().and_then(crate::import::ai::blend::record_ids); + let record = crate::import::ai::blend::hex_bytes(&hex).as_deref().and_then(crate::import::ai::blend::record); let roles = &self.blend_roles; let Some(f) = self.frames.last_mut() else { return }; let n = f.children.len(); @@ -558,7 +558,7 @@ impl<'a> Reader<'a> { return; } let mut budget = self.blend_budget; - let live = ids.and_then(|ids| crate::import::ai::blend::live_blend(edit, steps, &ids, roles, &mut budget)); + let live = record.and_then(|record| crate::import::ai::blend::live_blend(edit, steps, &record, roles, &mut budget)); self.blend_budget = budget; let Some(mut steps) = f.children.pop() else { return }; let Some(_edit) = f.children.pop() else { return }; diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index c1061901f..152f6afa8 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -4303,10 +4303,16 @@ 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`, -/// 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. +/// 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 { - let mut words: Vec = vec![u32::from_le_bytes(*b"DCBA"), 1, 0, 0, 0, 0, 0, 0, 0, 0, ids.len() as u32]; + blend_record_spaced(ids, 1, 0.0, 0) +} + +/// [`blend_record`] with the spacing `spacing` at word 1 (0 Smooth Color, 1 Specified Steps, 2 +/// Specified Distance), the distance `distance` at word 2 and `steps` at word 4. +fn blend_record_spaced(ids: &[char], spacing: u32, distance: f32, steps: u32) -> String { + let mut words: Vec = vec![u32::from_le_bytes(*b"DCBA"), spacing, distance.to_bits(), 0, steps, 0, 0, 0, 0, 0, ids.len() as u32]; for id in ids { words.extend([*id as u32, 0, 0, 0, 0, 0]); } @@ -4332,11 +4338,16 @@ fn blend_part(kind: &str, id: Option<&str>) -> String { /// `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]); + blend_file_colored(spine, ids, record, [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]) +} + +/// [`blend_file`] with the left key in RGB `from`, the right one in `to`, and the steps drawn +/// between their colors. +fn blend_file_colored(spine: &str, ids: [Option<&str>; 2], record: &str, from: [f64; 3], to: [f64; 3]) -> String { let mut art = String::from("%_0 Ae\n%_u\n"); - art += &square(0.0, 40.0, 20.0, red, true); + art += &square(0.0, 40.0, 20.0, from, true); art += &blend_part("K", ids[0]); - art += &square(100.0, 40.0, 20.0, blue, true); + art += &square(100.0, 40.0, 20.0, to, true); art += &blend_part("K", ids[1]); art += spine; art += "%_N\n"; @@ -4344,7 +4355,8 @@ fn blend_file(spine: &str, ids: [Option<&str>; 2], record: &str) -> String { 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); + let rgb = [0, 1, 2].map(|c| from[c] + (to[c] - from[c]) * t); + art += &square(t * 100.0, 40.0, 20.0, rgb, false); } art += "U\n9 () XW\n0 Ae\n"; art += record; @@ -4389,6 +4401,106 @@ fn a_blends_spine_written_backwards_keeps_its_keys_order() { assert_eq!(spec.key_anchors, vec![1, 0], "the red key on the spine's end, the blue one on its start"); } +/// A blend's spacing comes from its record: Specified Distance with its distance (25 pt from one +/// step's edge to the corresponding edge of the next lays three out between keys 100 pt apart), +/// Smooth Color (keys 5/255 apart in blue take three steps), and Specified Steps with the steps +/// counted from the drawn ones, whatever its record's word 4 holds. The distance counts along the +/// spine between the keys, also where the spine runs on past the last key. +#[test] +fn a_blend_keeps_its_spacing() { + use vectorcraft_doc::live::BlendSpacing; + let straight = "%_10 50 m\n%_110 50 L\n"; + let longer = "%_10 50 m\n%_110 50 L\n%_1010 50 L\n"; + let (red, nearly_red, blue) = ([1.0, 0.0, 0.0], [1.0, 0.0, 5.0 / 255.0], [0.0, 0.0, 1.0]); + for (spine, record, (from, to), spacing) in [ + (straight, blend_record_spaced(&['A', 'B'], 2, 25.0, 3), (red, blue), BlendSpacing::Distance(25.0)), + (longer, blend_record_spaced(&['A', 'B'], 2, 25.0, 3), (red, blue), BlendSpacing::Distance(25.0)), + (straight, blend_record_spaced(&['A', 'B'], 0, 4.0, 3), (red, nearly_red), BlendSpacing::SmoothColor), + (straight, blend_record_spaced(&['A', 'B'], 1, 25.0, 3), (red, blue), BlendSpacing::Steps(3)), + (straight, blend_record_spaced(&['A', 'B'], 1, 12.5, 7), (red, blue), BlendSpacing::Steps(3)), + ] { + let (blend, warnings) = read_blend(&blend_file_colored(spine, [Some("A"), Some("B")], &record, from, to)); + let NodeKind::Blend { children, spec } = &blend.kind else { panic!("a live blend for {spacing:?}: {:?} {warnings:?}", blend.kind) }; + assert_eq!(spec.spacing, spacing); + assert_eq!(vectorcraft_doc::blend::blend_expand(children, spec).len(), 5, "{spacing:?}"); + assert!(!warnings.iter().any(|w| w == super::blend::DRAWN), "{spacing:?}: {warnings:?}"); + } +} + +/// A blend whose spacing doesn't lay out its drawn steps keeps them: a distance that lays out +/// nine, and Smooth Color between red and blue (more steps than the three drawn); and so does a +/// spacing the reader doesn't know or a distance that isn't a positive number. +#[test] +fn a_blend_whose_spacing_doesnt_lay_out_its_steps_keeps_them() { + let straight = "%_10 50 m\n%_110 50 L\n"; + for record in [ + blend_record_spaced(&['A', 'B'], 2, 10.0, 9), + blend_record_spaced(&['A', 'B'], 0, 4.0, 254), + blend_record_spaced(&['A', 'B'], 3, 25.0, 3), + blend_record_spaced(&['A', 'B'], 2, 0.0, 3), + blend_record_spaced(&['A', 'B'], 2, f32::NAN, 3), + blend_record_spaced(&['A', 'B'], 2, 1e-30, 3), + ] { + let (blend, warnings) = read_blend(&blend_file(straight, [Some("A"), Some("B")], &record)); + assert!(!matches!(blend.kind, NodeKind::Blend { .. }), "{record}: {:?}", blend.kind); + assert_eq!(blend.children().map(|c| c.len()), Some(5), "the five drawn squares"); + assert!(warnings.iter().any(|w| w == super::blend::DRAWN), "{warnings:?}"); + } +} + +/// Three keys with Specified Distance, unevenly spaced on a straight spine (red at x 0, blue at +/// 100, green at 150, 20 pt squares): 25 pt lays three steps out between the first two keys and +/// one between the last two, and a blend drawn that way comes in live. The same seven objects +/// drawn two and two between the keys keep their drawn steps. +#[test] +fn a_blend_of_three_keys_keeps_its_distance_between_each_pair() { + use vectorcraft_doc::live::BlendSpacing; + let (red, blue, green) = ([1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]); + let mix = |a: [f64; 3], b: [f64; 3], t: f64| [0, 1, 2].map(|c| a[c] + (b[c] - a[c]) * t); + let file = |drawn: &[(f64, [f64; 3])]| { + let mut art = String::from("%_0 Ae\n%_u\n"); + for (x, rgb, id) in [(0.0, red, "A"), (100.0, blue, "B"), (150.0, green, "C")] { + art += &square(x, 40.0, 20.0, rgb, true); + art += &blend_part("K", Some(id)); + } + art += "%_10 50 m\n%_110 50 L\n%_160 50 L\n%_N\n"; + art += &blend_part("S", None); + art += "%_U\n%_9 () XW\nu\n"; + for &(x, rgb) in drawn { + art += &square(x, 40.0, 20.0, rgb, false); + } + art += "U\n9 () XW\n0 Ae\n"; + art += &blend_record_spaced(&['A', 'B', 'C'], 2, 25.0, 0); + art + }; + let three_and_one = [ + (0.0, red), + (25.0, mix(red, blue, 0.25)), + (50.0, mix(red, blue, 0.5)), + (75.0, mix(red, blue, 0.75)), + (100.0, blue), + (125.0, mix(blue, green, 0.5)), + (150.0, green), + ]; + let (blend, warnings) = read_blend(&file(&three_and_one)); + let NodeKind::Blend { children, spec } = &blend.kind else { panic!("a live blend: {:?} {warnings:?}", blend.kind) }; + assert_eq!(spec.spacing, BlendSpacing::Distance(25.0)); + assert_eq!(vectorcraft_doc::blend::blend_steps(children, spec), vec![3, 1]); + let two_and_two = [ + (0.0, red), + (100.0 / 3.0, mix(red, blue, 1.0 / 3.0)), + (200.0 / 3.0, mix(red, blue, 2.0 / 3.0)), + (100.0, blue), + (350.0 / 3.0, mix(blue, green, 1.0 / 3.0)), + (400.0 / 3.0, mix(blue, green, 2.0 / 3.0)), + (150.0, green), + ]; + let (blend, warnings) = read_blend(&file(&two_and_two)); + assert!(!matches!(blend.kind, NodeKind::Blend { .. }), "{:?}", blend.kind); + assert_eq!(blend.children().map(|c| c.len()), Some(7), "the seven drawn squares"); + assert!(warnings.iter().any(|w| w == super::blend::DRAWN), "{warnings:?}"); +} + /// 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] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b2b2767ea..bf40ee19a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -995,6 +995,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Envelope Options › Anti-Alias off draws an envelope's distorted images without smoothing, and Preserve Shape Using › Transparency gives each piece of a distorted image an opacity mask in its shape instead of a clipping mask ([Envelope panel options](http://web.archive.org/web/20260513013456/https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/envelope-panel-options.html)). The four polynomial distributions are original approximations of the public description, not measured numeric parity. See [Blend panel overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html). + A blend in a `.ai` or EPS opened from its editing data keeps a Specified Distance spacing with its distance ([Blend Options](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-options-overview.html)) where VectorCraft lays out as many steps between each pair of keys as the file draws, in the same places and colors. A Smooth Color blend opens live only where VectorCraft's own Smooth Color step count equals the drawn steps, and VectorCraft often counts a different number. A blend that VectorCraft doesn't lay out as drawn keeps its drawn steps with a note (`import::ai::tests::a_blend_keeps_its_spacing`, `a_blend_of_three_keys_keeps_its_distance_between_each_pair`, `a_blend_whose_spacing_doesnt_lay_out_its_steps_keeps_them`). - Create Strokes uses exact squared Euclidean distances and Guo–Hall thinning in bounded per-blob crops. Maximal-disc pruning, split-crossing merges and degree-two joins simplify its skeleton graph. The whole-blob width rule and ink fitting remain; 46 code-drawn inputs measure ends and junctions in [`image-trace-quality.md`](image-trace-quality.md#centre-lines). Junction degrees measure 44/45 correct in each input variant; remaining spurs are recorded. This is a measured skeleton improvement; the Image Trace score, missing per-feature selection and width controls, and estimates remain unchanged. Object › Live Paint › Merge adds the other selected paths and Live Paint groups to the bottom selected Live Paint group ([Live Paint groups](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/live-paint-groups.html)), which keeps its id, the paint of its faces and edges, and its name, lock, visibility, transparency, appearance, opacity mask and graphic style link. Object › Live Paint › Make with a Live Paint group selected, and a Live Paint Bucket click on a selected path beside a selected Live Paint group, add the paths to that group the same way. Make, Merge and the Live Paint Bucket take paths, compound paths and groups of them; with type, an image or another object among the selection they report that it has to be converted to paths first and change nothing, as type and raster images can't go into a Live Paint group directly ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)); before, such an object went among the group's hidden sources and vanished from the canvas. Evidence: `tests_build::live_paint_refuses_type_and_images_and_changes_nothing`.