mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
.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:
1 parent
92f117cf1d
commit
77120ada62
12 files changed
+488
-57
No files matched your search
+2
-1
@@ -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. |
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
mod frames;
|
||||
mod lex;
|
||||
mod mesh;
|
||||
mod obj;
|
||||
mod paint;
|
||||
mod read;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; SVG knockout groups of opaque art without masks; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` keeps its RGB numbers; PDF import keeps a stroke apart from a fill masked or faded by its group) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; SVG knockout groups of opaque art without masks; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` keeps its RGB numbers; PDF import keeps a stroke apart from a fill masked or faded by its group) · **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/mod.rs`). Illustrator's list comes from its public
|
||||
@@ -50,7 +50,7 @@ private editing data. VectorCraft:
|
||||
|
||||
### Illustrator's private editing data
|
||||
|
||||
the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does Line truncated
|
||||
the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes and the artboards' names are read too. Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). That part also has no paragraphs: area type is written line by line, and comLine truncated
|
||||
|
||||
## Format by format
|
||||
|
||||
@@ -86,6 +86,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | `.ai` editing data: gradients take their matrix (`Xm`, radials and skew too), focal point and the order of stops sharing a ramp point; gradient meshes come in as mesh objects; artboards keep their names |
|
||||
| 2026-10-12 | minor | SVG export: knockout groups of opaque art are written plainly, without masks, so shapes meeting edge to edge leave no seam; translucent, blended, masked or brushed art still knocks out through masks |
|
||||
| 2026-10-12 | minor | `.ai` read as its PDF part keeps the RGB numbers of its `ICCBased` spaces (`uncalibrated_rgb`, on for `.ai`, off for PDFs): a user-visible colour change for `.ai` files tagged with a non-sRGB RGB profile |
|
||||
| 2026-10-12 | minor | PDF import: a fill that took its opacity or soft mask from a transparency group no longer absorbs the same-outline stroke drawn after the group, so the stroke keeps neither |
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; Line truncated
|
||||
|
||||
Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from
|
||||
switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make
|
||||
@@ -234,6 +234,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | G9: `.ai` gradient matrices and meshes read from the editing data; estimate unchanged |
|
||||
| 2026-10-12 | minor | No gap: SVG export writes knockout groups of opaque art without masks (no edge seams) |
|
||||
| 2026-10-12 | minor | G9: `.ai` keeps the RGB numbers of its `ICCBased` spaces (`uncalibrated_rgb`; colours change for non-sRGB-profiled `.ai` files); estimate unchanged |
|
||||
| 2026-10-12 | minor | G9: PDF import no longer merges a stroke into a fill that got its opacity or soft mask from a transparency group; estimate unchanged |
|
||||
|
||||
Reference in new issue
Block a user