Merge pull request #4127 from ham9zah/fix/ai-live-blends

Import: Illustrator blends come in as live blends
This commit is contained in:
BFlat
2026-10-26 14:23:53 +07:00
committed by GitHub
5 changed files with 625 additions and 0 deletions
+85
View File
@@ -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 :",
@@ -4254,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<u32> = 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<u8> {
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))?;
}
}
+275
View File
@@ -0,0 +1,275 @@
//! 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;
/// 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.
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.
///
/// `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<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) || 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::<Option<_>>()?;
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 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,
}
/// 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<f64> {
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::<Option<_>>()?;
let mut grid: HashMap<(i64, i64), Vec<usize>> = 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 &(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)
}
#[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]));
}
/// 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);
}
}
+1
View File
@@ -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;
+83
View File
@@ -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).
@@ -505,6 +508,12 @@ 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>)>,
/// 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.
@@ -667,6 +676,9 @@ impl<'a> Reader<'a> {
warnings: Vec::new(),
drawn_looks: 0,
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(),
@@ -745,6 +757,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 +1151,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 +1423,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 +1679,59 @@ 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) {
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 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);
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).
+181
View File
@@ -1569,3 +1569,184 @@ 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 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_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 mut found = None;
for n in art(&s.doc) {
n.walk(&mut |c| {
if let NodeKind::Path { path, .. } = &c.kind {
found = Some(path.clone());
}
});
}
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
/// 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}");
}