.ai import: radial and skewed gradient matrices, focal points and stop order, gradient meshes and artboard names from the editing data

This commit is contained in:
EricKuck 2026-10-12 11:14:16 -04:00 committed by bflatastic
parent 92f117cf1d
commit 77120ada62
12 changed files with 488 additions and 57 deletions

View File

@ -6,7 +6,7 @@
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@ -127,6 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-12 | `.ai` import reads gradients with their matrix (radials and skew too), focal point and stop order, gradient meshes and the artboards' names from the editing data. |
| 2026-10-12 | The Rectangular Grid and Polar Grid tools take their options: a click opens the size, the divider counts with a skew slider each, Use Outside Rectangle As Frame (rectangular) or Create Compound Path From Ellipses (polar) and Fill Grid; the choices stay with the tool, so dragging draws with them, and scripts and agents pass them to `shape.rectangularGrid` and `shape.polarGrid` (#1377, in part). |
| 2026-10-12 | A `.ai` file with placed (linked) pictures opens from its editing data, with its guides, sublayers and hidden objects: each picture is a linked image in its place, showing the low-resolution preview the file keeps until its file is found or relinked (#1264). Scores unchanged. |
| 2026-10-12 | Desktop printing adopts the shared `craft-print` spooler with exact physical sheet sizes. Windows 7 retains its WMI/viewer transport and excludes the shared backend; compatibility CI checks the dependency tree and PE imports. Scores unchanged. |

View File

@ -2073,6 +2073,22 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
"`",
"~",
"\n%AI5_EndPlace\n",
"Xm",
"50 -50 -100 -100 5000 -2000 Xm",
"0 0 0 0 1e308 -1e308 Xm",
"X!",
"/Mesh X!",
"/End X!",
"X#",
"(DeviceRGB) /CS X#",
"[0 0] /P X#",
"[1 0 0 1 0 0 10 10 12 10 1 8 10 1] /N X#",
"[1e308 -1e308 0] /N X#",
"0 /E X#",
"[0.5 1e308 0 0 0 0 1 0 0 1] /N X#",
"(DeviceGray) /CS X#",
"\n%\n",
"\n%AI5_EndPlace\n",
"\n%%PageTrailer\n",
])
.prop_map(str::to_string),

View File

@ -179,8 +179,12 @@ impl<'a> Lexer<'a> {
// The count starts after the comment's line end character (a following `\n` counts).
let from = self.pos + 1;
let region = self.src.get(from..)?;
let skip = region.iter().take_while(|b| is_space(**b)).count();
if !region.get(skip..)?.starts_with(b"XI") {
let mut skip = region.iter().take_while(|b| is_space(**b)).count();
// A hidden object's section is on `%_` lines up to its samples: `%_XI`, then `%_%%EndData`.
let hidden = region.get(skip..)?.starts_with(b"%_XI");
if hidden {
skip += 2;
} else if !region.get(skip..)?.starts_with(b"XI") {
return None;
}
let mut data = from + skip + 2;
@ -198,7 +202,9 @@ impl<'a> Lexer<'a> {
None => declared,
};
self.pos = end;
Some(Token { tok: Tok::Data(self.src.get(data..end).unwrap_or_default()), hidden: false })
let samples = self.src.get(data..end).unwrap_or_default();
let samples = if hidden { samples.strip_suffix(b"%_").unwrap_or(samples) } else { samples };
Some(Token { tok: Tok::Data(samples), hidden: false })
}
/// A regular token's characters.
@ -327,6 +333,10 @@ mod tests {
// A section of hex lines is comments.
let toks = all(b"%%BeginData: 9 Hex Bytes\r\n%00FF\r\n%%EndData\r\n1\r\n");
assert_eq!(toks.last(), Some(&t(Tok::Num(1.0), false)));
// On `%_` lines (an opacity mask's image, say) too: a `(` in the samples starts no string.
let toks = all(b"%_XI\r%_%%BeginData: 4\r%_XI\r(\x00\x00\x00%_%%EndData\r%_XH\r1\r");
assert!(toks.contains(&t(Tok::Data(b"(\x00\x00\x00"), true)), "{toks:?}");
assert_eq!(toks.last(), Some(&t(Tok::Num(1.0), false)));
}
#[test]

View File

@ -0,0 +1,136 @@
//! Gradient meshes: `/Mesh X!`, then on `%_` lines `value /key X#` entries — the colour space
//! (`/CS`), and per patch its place (`/P [row col]`) and four corners (`/N`, from the corner nearest
//! the origin round to the one beside it), ended by `/E` — and `/End X!`.
//!
//! A corner is `[colour… opacity x y` then the handle towards the next corner, a weight, the handle
//! towards the previous one and a weight `]`, in art space. Each patch becomes a 1×1 mesh.
//!
//! `X!`/`X#` aren't publicly documented (the AI7 specification predates meshes).
use kurbo::{Affine, Point, Vec2};
use vectorcraft_color::Color;
use vectorcraft_doc::live::{GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, MeshPoint};
use super::obj::V;
/// The most patches read from one mesh.
const MAX_PATCHES: usize = 4096;
#[derive(Debug, Default)]
pub(super) struct MeshReader {
space: String,
corners: Vec<Vec<f64>>,
pub patches: Vec<GradientMesh>,
/// Something in it this doesn't read.
pub unread: bool,
}
impl MeshReader {
/// `value /key X#`; `to_doc`: art space → the document.
pub fn entry(&mut self, vals: &[V], to_doc: Affine) {
let Some(at) = vals.iter().rposition(|v| matches!(v, V::Name(_))) else { return };
let key = vals.get(at).and_then(V::text).unwrap_or_default();
let value = at.checked_sub(1).and_then(|i| vals.get(i));
match key.as_str() {
"CS" => self.space = value.and_then(V::text).unwrap_or_default(),
"N" => {
if let Some(V::Arr(a)) = value {
if self.corners.len() < 4 {
self.corners.push(a.iter().filter_map(V::num).take(64).collect());
} else {
self.unread = true;
}
}
}
"E" => {
let corners = std::mem::take(&mut self.corners);
match self.patch(&corners, to_doc) {
Some(m) if self.patches.len() < MAX_PATCHES => self.patches.push(m),
_ => self.unread = true,
}
}
_ => {}
}
}
fn color(&self, c: &[f64]) -> Option<Color> {
let u = |i: usize| c.get(i).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0) as f32);
Some(match (self.space.as_str(), c.len()) {
("DeviceRGB", 3) => Color::rgb(u(0)?, u(1)?, u(2)?),
("DeviceCMYK", 4) => Color::cmyk(u(0)?, u(1)?, u(2)?, u(3)?),
("DeviceGray", 1) => Color::gray(1.0 - u(0)?),
_ => return None,
})
}
fn patch(&self, corners: &[Vec<f64>], to_doc: Affine) -> Option<GradientMesh> {
let [n0, n1, n2, n3] = corners else { return None };
// (anchor, towards the next corner, towards the previous one, colour, opacity)
let read = |n: &Vec<f64>| -> Option<(Point, Vec2, Vec2, Color, f32)> {
let comps = n.len().checked_sub(9)?;
let &[opacity, x, y, nx, ny, _, px, py, _] = n.get(comps..)? else { return None };
if ![opacity, x, y, nx, ny, px, py].iter().all(|v| v.is_finite()) {
return None;
}
let at = to_doc * Point::new(x, y);
let color = self.color(n.get(..comps)?)?;
Some((at, to_doc * Point::new(nx, ny) - at, to_doc * Point::new(px, py) - at, color, opacity.clamp(0.0, 1.0) as f32))
};
let (a, b, c, d) = (read(n0)?, read(n1)?, read(n2)?, read(n3)?);
// Rows run from the first corner to the second, columns from the first to the fourth.
let point = |(p, _, _, color, opacity): (Point, Vec2, Vec2, Color, f32), handles: [(usize, Vec2); 2]| {
let mut h = [Vec2::ZERO; 4];
for (i, v) in handles {
if let Some(slot) = h.get_mut(i) {
*slot = v;
}
}
MeshPoint { p, color, opacity, handles: h }
};
let points = vec![
point(a, [(H_DOWN, a.1), (H_RIGHT, a.2)]),
point(d, [(H_LEFT, d.1), (H_DOWN, d.2)]),
point(b, [(H_RIGHT, b.1), (H_UP, b.2)]),
point(c, [(H_UP, c.1), (H_LEFT, c.2)]),
];
Some(GradientMesh { rows: 1, cols: 1, points })
}
}
#[cfg(test)]
mod tests {
use super::*;
fn corner(values: &[f64]) -> Vec<V> {
vec![V::Arr(values.iter().map(|v| V::Num(*v)).collect()), V::Name("N".into())]
}
fn patch(reader: &mut MeshReader, first: &[f64]) {
reader.entry(&corner(first), Affine::IDENTITY);
for (x, y) in [(0.0, 1.0), (1.0, 1.0), (1.0, 0.0)] {
reader.entry(&corner(&[0.5, 1.0, x, y, x, y, 1.0, x, y, 1.0]), Affine::IDENTITY);
}
reader.entry(&[V::Num(0.0), V::Name("E".into())], Affine::IDENTITY);
}
#[test]
fn hostile_meshes_are_capped_and_checked() {
let mut reader = MeshReader { space: "DeviceGray".into(), ..MeshReader::default() };
for _ in 0..=MAX_PATCHES {
patch(&mut reader, &[0.5, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0]);
}
assert!(reader.unread && reader.patches.len() == MAX_PATCHES);
// NaN passes `clamp`: a non-finite opacity or colour leaves the patch unread.
for bad in [[0.5, f64::NAN], [f64::INFINITY, 1.0]] {
let mut reader = MeshReader { space: "DeviceGray".into(), ..MeshReader::default() };
patch(&mut reader, &[bad[0], bad[1], 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0]);
assert!(reader.unread && reader.patches.is_empty(), "{bad:?}");
}
// More corners than a patch has.
let mut reader = MeshReader { space: "DeviceGray".into(), ..MeshReader::default() };
for _ in 0..1000 {
reader.entry(&corner(&[0.5, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0]), Affine::IDENTITY);
}
assert!(reader.unread && reader.corners.len() == 4);
}
}

View File

@ -19,6 +19,7 @@
mod frames;
mod lex;
mod mesh;
mod obj;
mod paint;
mod read;

View File

@ -11,10 +11,13 @@
//! - A gradient on an object: `[flag] Bb` (1: the stroke), `flag (name) x y angle length a b c d
//! tx ty Bg`, the focal point `x y angle length Bh`, `flag BB`. Newer files write the gradient's
//! place as matrices after `Bg` (whose own numbers are then not in art space): `a b c d tx ty Xm`
//! takes the unit vector onto a linear gradient's whole ramp (0 to 100, its angle and length in
//! it), and `a b c d tx ty Bm` takes it onto the part between two stops next to each other, one
//! takes the unit vector (a radial's unit circle) onto the gradient's whole ramp (0 to 100, its
//! angle and length in it), and `a b c d tx ty Bm` takes it onto the part between two stops next to each other, one
//! `Bm` for each pair in the order of the ramp (`Bc`, around them, are the caps past the first and
//! last stops). A radial gradient has one `Bm`, which takes the unit circle onto it.
//! last stops). Without `Xm`, a radial gradient's one `Bm` takes the unit circle onto it.
//!
//! "AI7 spec": Adobe Illustrator File Format Specification, version 7.0 (1998), listed by PRONOM at
//! https://www.nationalarchives.gov.uk/PRONOM/fmt/423; §3.5 documents the gradient operators.
use std::collections::HashMap;
@ -155,6 +158,8 @@ impl GradientDef {
if self.gradient.stops.is_empty() {
return None;
}
// Stops come last first: reversed, stops sharing a ramp point keep their order.
self.gradient.stops.reverse();
self.gradient.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
Some((self.name, self.gradient))
}
@ -170,9 +175,10 @@ pub(super) struct Instance {
length: f64,
/// `Bg`'s own matrix.
bg: Affine,
/// The matrix of a linear gradient's whole ramp (`Xm`), when there is one.
/// `Xm`: "the overall matrix applied to the gradient" (AI7 spec §3.5.4), when there is one.
xm: Option<Affine>,
/// The first and the last gradient matrix (`Bm`), when there are any.
/// The first and the last gradient matrix (`Bm`), when there are any. After `Xm` they are
/// imaging bands (AI7 spec §3.5.6), not the gradient's matrix.
bm: Option<(Affine, Affine)>,
/// The focal point's offset from the origin (`Bh`).
hilight: Vec2,
@ -216,7 +222,7 @@ impl Instance {
}
}
/// `a b c d tx ty Xm`.
/// `a b c d tx ty Xm`: "the overall matrix applied to the gradient" (AI7 spec §3.5.4).
pub fn xm(&mut self, vals: &[V]) {
self.xm = Self::matrix(vals);
}
@ -234,12 +240,29 @@ impl Instance {
let name = self.name.as_ref()?;
let gradient = defs.get(name)?.clone();
let radial = gradient.kind == GradientKind::Radial;
// The unit vector through `m`, the point across it (for an elliptical radial gradient) and
// the focal point.
let unit = |m: Affine| (m * Point::ORIGIN, m * Point::new(1.0, 0.0), m * Point::new(0.0, 1.0), m * self.hilight.to_point());
let whole = if radial { self.bm.map(|(m, _)| m).or(self.xm) } else { self.xm };
let (start, end, across, focal) = match (whole, self.bm) {
(Some(m), _) => unit(to_doc * m),
// `Xm` maps the unit circle (or vector) to the art, `Bg`'s origin, angle and length already
// in it, and so does a radial's `Bm`: files whose PDF part draws the radial draw it through
// that `Bm` alone, whatever `Bg` holds. The highlight is an offset from the centre in art space.
let unit = self.xm.or(self.bm.filter(|_| radial).map(|(first, _)| first));
// A skewed matrix leaves `along` and `across` oblique: a linear ramp runs along the normal to
// its isolines (`across`), and a radial's ellipse is set by the matrix's principal axes.
let through = |m: Affine, origin: Point, dir: Vec2| {
let start = m * origin;
let along = m * (origin + dir) - start;
let across = m * (origin + Vec2::new(-dir.y, dir.x)) - start;
if radial {
let (major, minor) = principal_axes(along, across);
(start, start + major, minor.hypot() / major.hypot())
} else {
let normal = Vec2::new(-across.y, across.x).normalize();
(start, start + normal * along.dot(normal), 1.0)
}
};
let (start, end, aspect, focal) = match (unit, self.bm) {
(Some(unit), _) => {
let (start, end, aspect) = through(to_doc * unit, Point::ORIGIN, Vec2::new(1.0, 0.0));
(start, end, aspect, to_doc * (unit * Point::ORIGIN + self.hilight))
}
// A linear gradient's `Bm`s alone: they run from its first stop to its last.
(None, Some((first, last))) => {
let (a, b) = (to_doc * first * Point::ORIGIN, to_doc * last * Point::new(1.0, 0.0));
@ -247,27 +270,38 @@ impl Instance {
let to = gradient.stops.last().map_or(1.0, |s| f64::from(s.offset));
let whole = (b - a) / (to - from).max(1e-6);
let start = a - whole * from;
(start, start + whole, start, start)
(start, start + whole, 1.0, start)
}
// `Bg`'s own numbers, in art space through its matrix.
(None, None) => {
let m = to_doc * self.bg;
let a = self.angle.to_radians();
let dir = Vec2::new(a.cos(), a.sin()) * self.length;
(m * self.origin, m * (self.origin + dir), m * (self.origin + Vec2::new(-dir.y, dir.x)), m * (self.origin + self.hilight))
let (start, end, aspect) = through(m, self.origin, Vec2::new(a.cos(), a.sin()) * self.length);
(start, end, aspect, m * (self.origin + self.hilight))
}
};
if !(start.is_finite() && end.is_finite()) || start.distance(end) < 1e-9 {
return None;
}
let aspect = (across.distance(start) / start.distance(end)).clamp(0.01, 100.0);
let aspect = aspect.clamp(0.01, 100.0);
let focal = (self.hilight.hypot() > 1e-9).then_some(focal).filter(|p| p.is_finite());
let geom = GradientGeom { start, end, aspect: if radial { aspect } else { 1.0 }, focal: if radial { focal } else { None } };
let geom = GradientGeom { start, end, aspect, focal: if radial { focal } else { None } };
let angle = -(end - start).atan2().to_degrees();
Some(Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), angle, swatch: Some(name.clone()), ..GradientPaint::new(gradient) })))
}
}
/// The semi-axes of the ellipse the unit circle maps to under the linear map with columns `u` and `v`,
/// the major one first.
fn principal_axes(u: Vec2, v: Vec2) -> (Vec2, Vec2) {
let (xx, yy, xy) = (u.x * u.x + v.x * v.x, u.y * u.y + v.y * v.y, u.x * u.y + v.x * v.y);
let angle = 0.5 * (2.0 * xy).atan2(xx - yy);
let spread = (0.25 * (xx - yy).powi(2) + xy * xy).sqrt();
let (major, minor) = ((0.5 * (xx + yy) + spread).sqrt(), (0.5 * (xx + yy) - spread).max(0.0).sqrt());
let axis = Vec2::new(angle.cos(), angle.sin());
(axis * major, Vec2::new(-axis.y, axis.x) * minor)
}
#[cfg(test)]
mod tests {
use super::*;
@ -332,7 +366,7 @@ mod tests {
let defs = HashMap::from([(name, g)]);
let mut i = Instance::begin(&[]);
let mut bg = vec![V::Num(1.0), V::Str(b"G".to_vec())];
bg.extend(nums(&[0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
bg.extend(nums(&[100.0, 100.0, 0.0, 50.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
i.bg(&bg);
i.bm(&nums(&[50.0, 0.0, 0.0, -50.0, 100.0, 100.0]));
let Some(Paint::Gradient(gp)) = i.paint(&defs, Affine::IDENTITY) else { panic!() };
@ -341,6 +375,14 @@ mod tests {
assert_eq!(geom.end, Point::new(150.0, 100.0));
assert!((geom.aspect - 1.0).abs() < 1e-9);
assert!(i.paint(&HashMap::new(), Affine::IDENTITY).is_none());
// Newer files give the matrix with `Xm`; the `Bm`s after it are bands of a cached ramp.
let mut i = Instance::begin(&[]);
i.bg(&bg);
i.xm(&nums(&[40.0, 0.0, 0.0, -40.0, 10.0, 10.0]));
i.bm(&nums(&[5.0, 0.0, 0.0, -40.0, 30.0, 10.0]));
let Some(Paint::Gradient(gp)) = i.paint(&defs, Affine::IDENTITY) else { panic!() };
let geom = gp.geom.unwrap();
assert_eq!((geom.start, geom.end), (Point::new(10.0, 10.0), Point::new(50.0, 10.0)));
}
/// A linear gradient of three stops as newer files place it: `Bg` with numbers that aren't in

View File

@ -113,13 +113,60 @@ pub fn read(data: &[u8]) -> Result<Structure, String> {
r.finish()
}
/// The artboards (art space) of the editing data `data` that [`read`] can't read as a whole, as far
/// as it gets: where to put the pages of its PDF part instead (#1068).
pub fn artboards(data: &[u8]) -> Vec<Rect> {
/// The artboards (names, and rects in art space) of the editing data `data` that [`read`] can't
/// read as a whole, as far as it gets: where to put the pages of its PDF part instead, and what to
/// call them (#1068).
pub fn artboards(data: &[u8]) -> Vec<(String, Rect)> {
let found = artboards_read(data);
if !found.is_empty() {
return found;
}
// Reading can stop before the artboards (art kept in the document's data comes first): read
// their array on its own.
artboard_array(data).map_or(found, |array| {
let mut alone = b"%_/Document :\n%_/Dictionary :\n".to_vec();
alone.extend_from_slice(array);
alone.extend_from_slice(b"\n%_; /NotRecorded ,\n%_;\n");
artboards_read(&alone)
})
}
/// The `ArtboardArray` entry of the editing data `data`, from its `/Array :` to its key: found
/// from the key back, counting the dictionaries its artboards open and close.
fn artboard_array(data: &[u8]) -> Option<&[u8]> {
const KEY: &[u8] = b"%_; (ArtboardArray) ,";
let end = data.windows(KEY.len()).rposition(|w| w == KEY)? + KEY.len();
let head = data.get(..end)?;
let mut depth = 0usize;
let mut line_end = head.len();
loop {
let line_start = head.get(..line_end)?.iter().rposition(|&b| b == b'\n' || b == b'\r').map_or(0, |at| at + 1);
if let Some(text) = head.get(line_start..line_end)?.strip_prefix(b"%_") {
if text.starts_with(b";") || text.starts_with(b"/Def ;") {
depth += 1;
} else if text.starts_with(b"/") && text.windows(2).any(|w| w == b" :") {
depth = depth.checked_sub(1)?;
if depth == 0 {
return head.get(line_start..);
}
}
}
line_end = line_start.checked_sub(1)?;
}
}
/// [`artboards`] as far as reading `data` from its start gets: those read before an error, and
/// those of a `/Document` still open when it stopped (its art can be what stopped it).
fn artboards_read(data: &[u8]) -> Vec<(String, Rect)> {
let mut r = Reader::new(data);
// Artboards read before an error still say where they are.
let _ = r.run();
r.artboards.into_iter().map(|(_, rect)| rect).collect()
let open: Vec<Obj> = r.frames.iter().filter_map(|f| if let Kind::Obj(o) = &f.kind { Some((**o).clone()) } else { None }).collect();
for o in &open {
if r.artboards.is_empty() {
r.document(o);
}
}
r.artboards
}
#[derive(Clone, Debug)]
@ -303,6 +350,8 @@ struct Reader<'a> {
drawn_looks: usize,
/// Nodes read from `%_` lines.
hidden_ids: HashSet<NodeId>,
/// The gradient mesh being read (`/Mesh X!` … `/End X!`), and whether it started on a `%_` line.
mesh: Option<(super::mesh::MeshReader, bool)>,
/// CMYK samples converted, by value.
cmyk_cache: HashMap<[u8; 4], [u8; 3]>,
/// A `/ForeignObject`'s `/Data`: its PDF as ASCII85 in comment lines, until the object closes.
@ -433,6 +482,7 @@ impl<'a> Reader<'a> {
warnings: Vec::new(),
drawn_looks: 0,
hidden_ids: HashSet::new(),
mesh: None,
cmyk_cache: HashMap::new(),
done: false,
obj_hidden: false,
@ -576,7 +626,7 @@ impl<'a> Reader<'a> {
if let Tok::Comment(c) = t.tok {
let line = String::from_utf8_lossy(c);
let m = marker(&line);
if m == begin {
if !begin.is_empty() && m == begin {
depth += 1;
} else if m == end {
if depth == 0 {
@ -708,21 +758,7 @@ impl<'a> Reader<'a> {
let node = match kind {
Kind::Compound => {
// A group in a compound path (`*u u … U *U`) only holds its paths.
let mut kids = ungrouped(kids);
// The compound path's paint is its last path's (a gradient's instance wraps only
// that one); the last painted one, should the last have none.
let Some(shown) = kids.iter().rev().find(|k| !k.appearance.items.is_empty()).or(kids.last()) else { return Ok(()) };
let (appearance, opacity, blend, rule) = (shown.appearance.clone(), shown.opacity, shown.blend, path_rule(shown));
for k in &mut kids {
k.opacity = 1.0;
k.blend = BlendMode::Normal;
k.visible = true;
k.locked = false;
}
let mut n = self.new_node(NodeKind::Compound { children: kids.into_iter().map(Arc::new).collect(), rule }, &gs);
n.appearance = appearance;
n.opacity = opacity;
n.blend = blend;
let Some(n) = self.compound(ungrouped(kids), &gs) else { return Ok(()) };
n
}
// A group with a clipping path (`q` … `Q`, or a `W` path in a group) is a clipping group.
@ -737,6 +773,25 @@ impl<'a> Reader<'a> {
self.add(node, opened_hidden || hidden)
}
/// The compound path of `kids`, painted and made transparent as its subpaths say.
fn compound(&mut self, mut kids: Vec<Node>, gs: &GState) -> Option<Node> {
// The compound path's paint is its last path's (a gradient's instance wraps only
// that one); the last painted one, should the last have none.
let shown = kids.iter().rev().find(|k| !k.appearance.items.is_empty()).or(kids.last())?;
let (appearance, opacity, blend, rule) = (shown.appearance.clone(), shown.opacity, shown.blend, path_rule(shown));
for k in &mut kids {
k.opacity = 1.0;
k.blend = BlendMode::Normal;
k.visible = true;
k.locked = false;
}
let mut n = self.new_node(NodeKind::Compound { children: kids.into_iter().map(Arc::new).collect(), rule }, gs);
n.appearance = appearance;
n.opacity = opacity;
n.blend = blend;
Some(n)
}
/// `kids` with the clipping paths among them (indexes `clips`) first, several as one compound
/// path → the children and whether they clip.
fn clip_first(&mut self, kids: Vec<Node>, clips: &[usize]) -> (Vec<Arc<Node>>, bool) {
@ -839,6 +894,26 @@ impl<'a> Reader<'a> {
}
return Ok(());
}
if w == "X!" || self.mesh.is_some() {
let name = vals.iter().rev().find_map(|v| if let V::Name(n) = v { Some(n.as_str()) } else { None });
match (w, name) {
("X!", Some("Mesh")) => {
self.end_path_quietly();
self.mesh = Some((super::mesh::MeshReader::default(), hidden));
return Ok(());
}
("X!", Some("End")) if self.mesh.is_some() => return self.end_mesh(),
("X#", _) => {
let to_doc = self.space();
if let Some((m, _)) = self.mesh.as_mut() {
m.entry(&vals, to_doc);
}
return Ok(());
}
_ if self.mesh.is_some() => return Ok(()),
_ => {}
}
}
match w {
// Containers.
"u" => {
@ -1238,10 +1313,14 @@ impl<'a> Reader<'a> {
if let Some(n) = named {
self.spots.push(n);
}
// A colour set after a gradient instance that painted nothing (`Bb` … `0 BB` with no
// path) is the next object's paint, not the gradient.
if w.ends_with(|c: char| c.is_ascii_lowercase()) {
self.gs.fill = p;
self.pending_fill = None;
} else {
self.gs.stroke = p;
self.pending_stroke = None;
}
}
@ -1417,6 +1496,24 @@ impl<'a> Reader<'a> {
self.add(node, hidden)
}
/// `/End X!`: the gradient mesh read, as one object.
fn end_mesh(&mut self) -> Result<(), String> {
let Some((m, hidden)) = self.mesh.take() else { return Ok(()) };
if m.unread || m.patches.is_empty() {
self.unreadable("gradient meshes it can't read");
return Ok(());
}
let gs = self.gs.clone();
let mut patches: Vec<Node> = m.patches.into_iter().map(|p| self.new_node(NodeKind::Mesh(p), &gs)).collect();
let mut node = match patches.len() {
1 => patches.pop().unwrap_or_else(|| self.new_node(NodeKind::Group { children: vec![], clip: false }, &gs)),
_ => self.new_node(NodeKind::Group { children: patches.into_iter().map(Arc::new).collect(), clip: false }, &gs),
};
node.opacity = gs.opacity;
node.blend = gs.blend;
self.add(node, hidden)
}
fn set_instance(&mut self, i: &Instance) {
let to_doc = self.space();
match i.paint(&self.gradients, to_doc) {

View File

@ -285,9 +285,13 @@ fn gradients() {
let defs = "%AI5_BeginGradient: (G)\n(G) 1 2 Bd\n[\n<00FF>\n0\n0 0 0 0 1 0 0 2 1 6 %_BS\n\
%_0 0 0 0 1 0 0 2 1 6 40 0 Bs\n%_0 0 0 0 0 0 1 2 0.5 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
let art_ = format!(
"{RED}{}Bb\n0 0 0 0 Bh\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n50 0 0 -50 100 50 Bm\nf\n0 BB\n{}f\n",
"{RED}{}Bb\n0 0 0 0 Bh\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n50 0 0 -50 100 50 Bm\nf\n0 BB\n{}f\n\
Bb\n2 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\n0 0 0 0 0 0 1 Xa\n{}f\n\
{}Bb\n-20 -10 144 0.77 Bh\n1 (G) -0.1 0.4 0 1.35 1 0 0 -1 0 16384 1 Bg\n50 0 0 -50 100 50 Bm\nf\n0 BB\n",
rect_path(50.0, 0.0, 150.0, 100.0),
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(20.0, 0.0, 30.0, 10.0),
rect_path(50.0, 0.0, 150.0, 100.0),
);
let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc;
let a = art(&doc);
@ -300,6 +304,15 @@ fn gradients() {
assert_eq!((geom.start, geom.end), (Point::new(100.0, 50.0), Point::new(150.0, 50.0)));
// The gradient was the one object's.
assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
// A gradient instance that paints nothing doesn't stick to the next object, which sets its colour.
assert_eq!(fill(&a[2]).color(), Some(Color::rgb(0.0, 0.0, 1.0)));
// A radial gradient is its matrix's unit circle whatever `Bg`'s origin and length, and its
// highlight an offset from the centre in art space.
let Paint::Gradient(g) = fill(&a[3]) else { panic!("{:?}", fill(&a[3])) };
let geom = g.geom.unwrap();
assert_eq!((geom.start, geom.end), (Point::new(100.0, 50.0), Point::new(150.0, 50.0)));
let focal = geom.focal.unwrap();
assert!((focal - Point::new(80.0, 60.0)).hypot() < 1e-6, "{focal:?}");
// A linear gradient with a stop in the middle: `Xm` places its whole ramp, a `Bm` each of its
// two parts. The object's own note of its gradient after it paints nothing.
let defs = "(L) 0 3 Bd\n%_1 0 50 100 Bs\n%_0.5 0 50 40 Bs\n%_0 0 50 0 Bs\nBD\n";
@ -319,6 +332,43 @@ fn gradients() {
assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)), "the note after the object isn't the next path's paint");
}
#[test]
fn gradients_through_a_skewed_matrix() {
// GUARDS: a sheared `Xm` leaves the matrix's columns oblique. A linear ramp runs along the
// normal to its isolines, not along its first column; a radial's ellipse is the one the unit
// circle maps to, set by the principal axes.
let defs = |name: &str, kind: u8| {
format!(
"%AI5_BeginGradient: ({name})\n({name}) {kind} 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 0 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n"
)
};
let instance = |name: &str| format!("Bb\n1 ({name}) 0 0 0 1 1 0 0 1 0 0 1 Bg\n10 0 10 10 100 50 Xm\nf\n0 BB\n");
let art_ = format!("{}{}{}{}", rect_path(0.0, 0.0, 200.0, 100.0), instance("Lin"), rect_path(0.0, 0.0, 200.0, 100.0), instance("Rad"));
let doc = read_ok(&format!("{}{}{}", defs("Lin", 0), defs("Rad", 1), layer(&art_))).doc;
let a = art(&doc);
let Paint::Gradient(g) = fill(&a[0]) else { panic!("{:?}", fill(&a[0])) };
let geom = g.geom.unwrap();
assert!((geom.start - Point::new(100.0, 50.0)).hypot() < 1e-9 && (geom.end - Point::new(105.0, 55.0)).hypot() < 1e-9, "{geom:?}");
let Paint::Gradient(g) = fill(&a[1]) else { panic!("{:?}", fill(&a[1])) };
let geom = g.geom.unwrap();
let golden = (1.0 + 5f64.sqrt()) / 2.0;
assert!((geom.start.distance(geom.end) - 10.0 * golden).abs() < 1e-9, "{geom:?}");
assert!((geom.aspect - 1.0 / golden.powi(2)).abs() < 1e-9, "{geom:?}");
}
#[test]
fn stops_sharing_a_ramp_point_keep_their_order() {
// GUARDS: stops come last first, so a sort alone puts the two at 100 the wrong way round and
// the ramp blends towards the colour meant only for past its end.
let defs = "%AI5_BeginGradient: (S)\n(S) 0 3 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 100 Bs\n\
%_0 0 0 0 0 1 0 2 1 6 50 100 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 0 Bs\nBD\n%AI5_EndGradient\n";
let art_ = format!("{}Bb\n1 (S) 0 0 0 1 1 0 0 1 0 0 1 Bg\n10 0 0 10 100 50 Xm\nf\n0 BB\n", rect_path(0.0, 0.0, 200.0, 100.0));
let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc;
let Paint::Gradient(g) = fill(&art(&doc)[0]) else { panic!() };
let colors: Vec<_> = g.gradient.stops.iter().map(|s| s.color).collect();
assert_eq!(colors, [Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0), Color::rgb(1.0, 0.0, 0.0)]);
}
#[test]
fn artboards() {
let data = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\
@ -738,3 +788,70 @@ fn closing_brackets_without_their_opening_dont_rescan_the_stack() {
let _ = read(stream(&layer(&body)).as_bytes());
assert!(started.elapsed() < std::time::Duration::from_secs(20), "{:?}", started.elapsed());
}
#[test]
fn artboards_after_what_stops_the_reading() {
let board = |name: &str, x: i32| {
format!(
"%_/Dictionary :\n%_/FillStyle : 0 1 0 0 0 Xy\n%_/Def ; \n%_ (EachArtboardColor) ,\n%_{x} 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_({name}) /UnicodeString (Name) ,\n%_{} 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n",
x + 100
)
};
let array = format!("%_/Array :\n{}{}%_; (ArtboardArray) ,\n", board("one", 0), board("two", 200));
// Art in the document's own data that the reader can't follow comes before the artboards.
let data = format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n%_/Dictionary :\nLB\n{array}%_; /NotRecorded ,\n%_;\n");
let boards: Vec<_> = super::artboards(data.as_bytes()).into_iter().map(|(n, r)| (n, r.x0)).collect();
assert_eq!(boards, [("one".to_string(), 0.0), ("two".to_string(), 200.0)]);
}
#[test]
fn a_radial_gradient_without_xm_is_its_bm_circle_else_its_bg_circle() {
// GUARDS: files write `Bg` as the unit circle and place the radial with `Bm`; reading `Bg` alone collapses it.
let defs =
"%AI5_BeginGradient: (R)\n(R) 1 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 0 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
let geom = |instance: &str| {
let doc = read_ok(&format!("{defs}{}", layer(&format!("{}Bb\n10 0 0 0.5 Bh\n{instance}f\n0 BB\n", rect_path(0.0, 0.0, 200.0, 100.0))))).doc;
let Paint::Gradient(g) = fill(&art(&doc)[0]) else { panic!() };
g.geom.unwrap()
};
let placed = geom("1 (R) 0 0 0 1 1 0 0 1 0 0 Bg\n50 0 0 -50 100 50 Bm\n");
assert_eq!((placed.start, placed.end, placed.focal), (Point::new(100.0, 50.0), Point::new(150.0, 50.0), Some(Point::new(110.0, 50.0))));
let own = geom("1 (R) 60 50 0 20 1 0 0 1 0 0 Bg\n");
assert_eq!((own.start, own.end, own.focal), (Point::new(60.0, 50.0), Point::new(80.0, 50.0), Some(Point::new(70.0, 50.0))));
}
/// Two art styles: `Blue`, a blue fill and a drop shadow; `Lit`, the object's gradient and an outer glow.
#[test]
fn a_bare_comment_line_in_a_prolog_leaves_its_end_alone() {
// GUARDS: a bare `%` line's empty marker matched the empty begin marker of a section that has none.
let r = read_ok(&format!("%%BeginProlog\n%\n%%EndProlog\n{}", layer(&format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0)))));
assert_eq!(art(&r.doc).len(), 1);
}
#[test]
fn a_gradient_mesh_reads_as_a_patch() {
let corner = |rgb: &str, x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| {
format!("%_[{rgb} 1 {x} {y} {} {} 1 {} {} 1] /N X#\n", next.0, next.1, prev.0, prev.1)
};
let mesh = |space: &str| {
format!(
"/Mesh X!\n%_({space}) /CS X#\n%_[0 0] /P X#\n{}{}{}{}%_0 /E X#\n/End X!\n",
corner("1 0 0", 0, 0, (0, 10), (10, 0)),
corner("0 1 0", 0, 40, (10, 40), (0, 30)),
corner("0 0 1", 40, 40, (40, 30), (30, 40)),
corner("1 1 1", 40, 0, (30, 0), (40, 10)),
)
};
let doc = read_ok(&layer(&mesh("DeviceRGB"))).doc;
let NodeKind::Mesh(m) = &art(&doc)[0].kind else { panic!("{:?}", art(&doc)[0].kind) };
let colors: Vec<_> = m.points.iter().map(|p| p.color).collect();
assert_eq!(colors, [Color::rgb(1.0, 0.0, 0.0), Color::rgb(1.0, 1.0, 1.0), Color::rgb(0.0, 1.0, 0.0), Color::rgb(0.0, 0.0, 1.0)]);
use vectorcraft_doc::live::{H_DOWN, H_RIGHT};
let first = &m.points[0];
assert_eq!(
(first.p, first.handles[H_DOWN], first.handles[H_RIGHT]),
(Point::new(0.0, 100.0), kurbo::Vec2::new(0.0, -10.0), kurbo::Vec2::new(10.0, 0.0))
);
let e = read(stream(&layer(&mesh("Separation"))).as_bytes()).unwrap_err();
assert!(e.contains("gradient meshes"), "{e}");
}

View File

@ -1160,19 +1160,25 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outl
}
}
/// The pages of `doc` (a `.ai` read as its PDF part: a page per artboard, in a row) moved, with
/// the art on each, to where `boards` (the artboards of its editing data, in art space) are, so
/// the artboards keep their layout when the layers can't be read (#1068). Left as they are unless
/// there are as many artboards as pages, of the same sizes.
fn place_pages(boards: &[Rect], doc: &mut Document) {
let (Some(first), true) = (boards.first(), boards.len() == doc.artboards.len() && boards.len() > 1) else { return };
if boards.iter().zip(&doc.artboards).any(|(b, a)| (b.width() - a.rect.width()).abs() > 0.5 || (b.height() - a.rect.height()).abs() > 0.5) {
/// The pages of `doc` (a `.ai` read as its PDF part: a page per artboard, in a row) named as
/// `boards` (the artboards of its editing data, in art space) name them, and moved, with the art on
/// each, to where they are, so the artboards keep their names and layout when the layers can't be
/// read (#1068). Left as they are unless there are as many artboards as pages, of the same sizes.
fn place_pages(boards: &[(String, Rect)], doc: &mut Document) {
if boards.is_empty() || boards.len() != doc.artboards.len() {
return;
}
if boards.iter().zip(&doc.artboards).any(|((_, b), a)| (b.width() - a.rect.width()).abs() > 0.5 || (b.height() - a.rect.height()).abs() > 0.5) {
return;
}
for ((name, _), a) in boards.iter().zip(&mut doc.artboards) {
a.name.clone_from(name);
}
let [(_, first), _, ..] = boards else { return };
// Art space (y up) → the document, the first artboard's top left at the origin, as `read` has it.
let to_doc = Affine::new([1.0, 0.0, 0.0, -1.0, -first.x0, first.y1]);
let pages: Vec<Rect> = doc.artboards.iter().map(|a| a.rect).collect();
let moves: Vec<Vec2> = boards.iter().zip(&pages).map(|(b, page)| to_doc.transform_rect_bbox(*b).origin() - page.origin()).collect();
let moves: Vec<Vec2> = boards.iter().zip(&pages).map(|((_, b), page)| to_doc.transform_rect_bbox(*b).origin() - page.origin()).collect();
for layer in &mut doc.layers {
place_art(Arc::make_mut(layer), &pages, &moves);
}

View File

@ -239,13 +239,13 @@ fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() {
}
/// A `.ai` whose layers can't be read comes in as its PDF part, a page per artboard in a row; its
/// pages still go where its editing data puts its artboards, with their art (#1068).
/// pages still go where its editing data puts its artboards, with their art, and keep their names (#1068).
#[test]
fn an_ai_file_read_as_its_pdf_part_keeps_its_artboard_layout() {
// Two 100-point artboards, the second below the first (art space, y up).
let board = |x0: i32, y0: i32| {
format!(
"%_/Dictionary :\n%_{x0} {} /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} {y0} /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n",
"%_/Dictionary :\n%_{x0} {} /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} {y0} /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_(at {x0} {y0}) /UnicodeString (Name) ,\n%_; ,\n",
y0 + 100,
x0 + 100
)
@ -271,6 +271,8 @@ fn an_ai_file_read_as_its_pdf_part_keeps_its_artboard_layout() {
assert!(!read && w.iter().any(|w| w.contains("layers weren't read")), "{w:?}");
let rects: Vec<_> = d.artboards.iter().map(|a| a.rect).collect();
assert_eq!(rects, [vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), vectorcraft_geom::Rect::new(0.0, 150.0, 100.0, 250.0)]);
let board_names: Vec<_> = d.artboards.iter().map(|a| a.name.as_str()).collect();
assert_eq!(board_names, ["at 0 0", "at 0 -150"]);
let moved = squares(&d);
assert!((moved[0] - squares(&pdf)[0]).hypot() < 1e-6, "the first page's art stays: {moved:?}");
assert!((moved[1] - vectorcraft_geom::Point::new(10.0, 160.0)).hypot() < 1e-6, "the second's goes with its page: {moved:?}");
@ -278,6 +280,7 @@ fn an_ai_file_read_as_its_pdf_part_keeps_its_artboard_layout() {
for list in [&[(0, 0)][..], &[(0, 0), (0, -150), (200, 0)]] {
let (d, _, _) = crate::layered_ai(&private(&unread(list)), pdf.clone(), vec![], false);
assert_eq!(d.artboards[1].rect, pdf.artboards[1].rect, "{list:?}");
assert_eq!(d.artboards[1].name, pdf.artboards[1].name, "{list:?}");
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long