mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
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.
This commit is contained in:
@@ -2991,6 +2991,17 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
|
||||
"[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 :",
|
||||
|
||||
@@ -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
|
||||
//! (<https://helpx.adobe.com/illustrator/using/blending-objects.html>).
|
||||
//!
|
||||
//! 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<NodeId, (String, Option<String>)>;
|
||||
|
||||
/// The bytes of hex digits `hex`, or `None` when they aren't pairs of hex digits.
|
||||
pub(super) fn hex_bytes(hex: &[u8]) -> Option<Vec<u8>> {
|
||||
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<Vec<String>> {
|
||||
if bytes.get(..4)? != b"DCBA" || bytes.len() > MAX_RECORD {
|
||||
return None;
|
||||
}
|
||||
let words: Vec<u32> = 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<NodeKind> {
|
||||
let parts = edit.children()?;
|
||||
let role = |n: &Arc<Node>| roles.get(&n.id);
|
||||
let keys: Vec<Arc<Node>> = 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<Node>> = 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<Arc<Node>> = 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<Node>], spine: &PathData) -> Option<Vec<u32>> {
|
||||
let anchors = &spine.subpaths.first()?.anchors;
|
||||
let mut out: Vec<u32> = 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<Point> {
|
||||
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<Node>]) -> Option<f64> {
|
||||
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::<Vec<u8>>();
|
||||
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]));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -505,6 +505,10 @@ struct Reader<'a> {
|
||||
drawn_looks: usize,
|
||||
/// Nodes read from `%_` lines.
|
||||
hidden_ids: HashSet<NodeId>,
|
||||
/// 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<u8>)>,
|
||||
/// 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).
|
||||
|
||||
@@ -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<u32> = 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<String> = 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<Node>, Vec<String>) {
|
||||
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<Node>]| {
|
||||
let laid = vectorcraft_doc::blend::blend_expand(keys, spec);
|
||||
let xs: Vec<f64> = laid.iter().filter_map(|n| n.geometric_bounds()).map(|b| b.center().y).collect();
|
||||
xs.iter().sum::<f64>() / 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}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user