From 95ac8d7cc7e168b24f9123fabfa20989dc0ee3af Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:40:43 -0500 Subject: [PATCH] EPS/.ai editing data: blends with no spine written come in live A blend whose spine was never edited has only its keys in the editing data, no spine part. Its spine is the straight lines between the keys' centres in the record's order; read as such, the blend comes in live instead of as its drawn steps. --- crates/eps/src/import/ai/blend.rs | 25 ++++++++++++++++++++++--- crates/eps/src/import/ai/tests.rs | 20 ++++++++++++++++++++ docs/file-format-parity.md | 3 ++- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/crates/eps/src/import/ai/blend.rs b/crates/eps/src/import/ai/blend.rs index 494a11cdc..6124c9eee 100644 --- a/crates/eps/src/import/ai/blend.rs +++ b/crates/eps/src/import/ai/blend.rs @@ -11,7 +11,7 @@ //! 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`. +//! start `DCBA`. A blend whose spine was never edited has no spine part: only its keys. //! //! 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 @@ -28,7 +28,7 @@ use vectorcraft_color::{Color, Paint}; 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}; +use vectorcraft_geom::{PathData, Point, SubPath}; /// The plugin that writes blends. pub(super) const PLUGIN: &str = "Adobe Path Blends"; // brand-ok: the plugin name the files use @@ -149,7 +149,12 @@ pub(super) fn live_blend(edit: &Node, drawn: &Node, record: &Record, roles: &Rol Arc::new(k) }) .collect(); - let spine = spine?; + // A blend whose spine was never edited has none written: its spine is the straight lines + // between the keys' centres, in the record's order. + let spine = match spine { + Some(spine) => spine, + None => straight_spine(&keys, ids, roles)?, + }; let key_anchors = on_spine(&keys, &spine)?; let spacing = match record.spacing { // Specified Steps: the steps between two keys are the drawn objects that aren't keys, @@ -204,6 +209,20 @@ fn on_spine(keys: &[Arc], spine: &PathData) -> Option> { Some(out) } +/// The straight lines between the centres of `keys`, taken in the order of the record's `ids`: a +/// blend's spine until it is edited or replaced, as [`vectorcraft_doc::blend::blend_spine`] lays +/// out a blend with no spine of its own. +fn straight_spine(keys: &[Arc], ids: &[String], roles: &Roles) -> Option { + let centres = ids + .iter() + .map(|id| { + let key = keys.iter().find(|k| roles.get(&k.id).and_then(|(_, named)| named.as_ref()) == Some(id))?; + centre(key) + }) + .collect::>>()?; + Some(PathData::single(SubPath::polyline(¢res, false))) +} + fn centre(n: &Node) -> Option { n.geometric_bounds().map(|b| b.center()).filter(|p| p.x.is_finite() && p.y.is_finite()) } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 9768d53e7..84329e395 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -6878,6 +6878,26 @@ fn a_blend_comes_in_as_a_live_blend() { assert!(!s.warnings.iter().any(|w| w == super::blend::DRAWN), "{:?}", s.warnings); } +/// A blend whose spine was never edited has no spine written, only its keys: its spine is the +/// straight line between the keys' centres, in the record's order (here the left key, named `B`, +/// first), and the blend comes in live. +#[test] +fn a_blend_with_no_spine_written_runs_straight_between_its_keys() { + let spine_part = format!("%_N\n{}", blend_part("S", None)); + let body = blend_file("", [Some("B"), Some("A")], &blend_record(&['B', 'A'])); + assert!(body.contains(&spine_part)); + let (blend, warnings) = read_blend(&body.replace(&spine_part, "")); + let NodeKind::Blend { children, spec } = &blend.kind else { panic!("a live blend: {:?} {warnings:?}", blend.kind) }; + assert!(!warnings.iter().any(|w| w == super::blend::DRAWN), "{warnings:?}"); + assert_eq!(spec.spacing, vectorcraft_doc::live::BlendSpacing::Steps(3)); + let spine = spec.spine.as_ref().and_then(|p| p.subpaths.first()).expect("a spine"); + let points: Vec<(f64, f64)> = spine.anchors.iter().map(|a| (a.p.x, a.p.y)).collect(); + assert_eq!(points.len(), 2, "{points:?}"); + assert!((points[0].0 - 10.0).abs() < 0.01 && (points[1].0 - 110.0).abs() < 0.01, "from the left key to the right one: {points:?}"); + assert_eq!(spec.key_anchors, vec![0, 1]); + assert_eq!(vectorcraft_doc::blend::blend_expand(children, spec).len(), 5); +} + /// The tags after a blend's drawn steps are the live blend's. #[test] fn a_live_blend_keeps_the_tags_of_its_drawn_steps() { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 720664e21..f21ec6b07 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (EPS and `.ai` editing data: symbols defined before the art styles take them; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (EPS and `.ai` editing data: blends with no spine written come in live; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -204,6 +204,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | EPS and `.ai` editing data: a blend whose spine was never edited, written with its keys and no spine part, comes in as a live blend along the straight lines between its keys' centres (in its record's order) instead of as its drawn steps. Checked on six users' Illustrator EPS files (used locally, not committed): every blend in four of them now comes in live, their renders unchanged; two still have blends the reader can't match to their drawn steps. Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: the art styles are found before the art is read, so objects in symbols defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes it) come in as themselves with their style instead of their drawn look. Checked on two users' EPS files (used locally, not committed): one's Symbols panel gains live Feather and Round Corners, its drawn-look count drops from 5 to 2, and both render unchanged; own-fixture test in `import::ai::tests`; scores and hours unchanged | | 2026-11-06 | minor | Legacy `.ai` (version 7) export: the clipping path around a flattened transparent image followed 2 pt cells, a staircase that was visibly jagged at the image's resolution. It now follows the image's own pixels (in blocks only past 4 million pixels), grown by one pixel so the whole anti-aliased edge is inside, and runs through the middle of each pixel edge with Douglas–Peucker simplification, so slopes and curves are sloped lines instead of steps (#5264). Version 7 images have no alpha channel (§8.1 of the published format specification), so the image itself is still opaque. Test `legacy_ai::a_flattened_images_clip_follows_its_edge_smoothly` (crate `vectorcraft-engine`); scores unchanged | | 2026-11-06 | minor | Paste: cells copied from Excel, Google Sheets, LibreOffice Calc or Numbers (tab-separated text, quoted cells too) paste as a group of point type, one object per cell in columns and rows, over 0.5 pt gridlines; other text pastes as before; scores and estimates unchanged (#5296) |