.ai import: art styles, a clipping path's own fill, compound paths of looks and a group's own transparency from the editing data

This commit is contained in:
EricKuck authored and bflatastic committed 2026-10-12 23:07:05 +07:00
1 parent 77120ada62
commit 939bc02f6b
11 files changed
+709 -19

No files matched your search

Generated
+1
View File
@@ -4669,6 +4669,7 @@ dependencies = [
"image",
"kurbo 0.13.1",
"ruzstd",
"serde_json",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
+2 -1
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%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged) · **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; `.ai` art styles and clipping fills 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 an object's art style (a fill and a stroke, variable widths too, with an Outer Glow or Drop Shadow) as its own appearance, paints a clipping path's own fill under what it clips and keeps a group's transparency as its PDF part draws them. |
| 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. |
+34
View File
@@ -2076,6 +2076,11 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
"Xm",
"50 -50 -100 -100 5000 -2000 Xm",
"0 0 0 0 1e308 -1e308 Xm",
"Xd",
"0 1 Xd",
"1 0 Xd",
"XD",
"(00800080) Xt 0 XD",
"X!",
"/Mesh X!",
"/End X!",
@@ -2085,6 +2090,35 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
"[1 0 0 1 0 0 10 10 12 10 1 8 10 1] /N X#",
"[1e308 -1e308 0] /N X#",
"0 /E X#",
"\n%AI9_BeginArtStyles\n",
"\n%AI9_EndArtStyles\n",
"/KnownStyle :",
"(style) /Name ,",
"/ActiveStyle :",
"/CompoundFilter :",
"/BasicFilter :",
"(Stroke Style Filter) 0 0 /Filter ,",
"(Fill Style Filter) 0 0 /Filter ,",
"(Adobe Outer Glow) 1 0 /Filter ,",
"(Adobe Drop Shadow) 1 0 /Filter ,",
"1 /Visible ,",
"2 /FillOrStroke ,",
"1 /Bool (BSDashAdj) ,",
"0.75 /Real (opac) ,",
"1e308 /Real (blur) ,",
"0 /Real ,",
"; (BSVariableWidthTValuesArray) ,",
"; (BSCenteredWidthsArray) ,",
"/StrokeStyle : 0 R",
"/FillStyle : 0 O",
"/Def ;",
"/Execution ;",
"[5 5] 1e308 d",
"-0.5 /Real ,",
"1.5 /Real ,",
"-2 /Real ,",
"; (BSLeftWidthsArray) ,",
"; (BSRightWidthsArray) ,",
"[0.5 1e308 0 0 0 0 1 0 0 1] /N X#",
"(DeviceGray) /CS X#",
"\n%\n",
+2
View File
@@ -25,6 +25,8 @@ kurbo = { workspace = true }
image = { workspace = true }
flate2 = { workspace = true }
ruzstd = { workspace = true }
# Live effects' params, as the art styles that name them give them.
serde_json = { workspace = true }
[dev-dependencies]
vectorcraft-testkit = { workspace = true }
+1
View File
@@ -23,6 +23,7 @@ mod mesh;
mod obj;
mod paint;
mod read;
mod style;
use std::io::Read as _;
+131 -11
View File
@@ -235,6 +235,11 @@ const BLENDS: [BlendMode; 16] = [
BlendMode::Luminosity,
];
/// The name effects take for blend mode `n` of `Xy` (and of effects' `blnd`).
pub(super) fn blend_name(n: usize) -> String {
format!("{:?}", BLENDS.get(n).copied().unwrap_or(BlendMode::Normal))
}
#[derive(Debug, Default)]
struct LayerAttrs {
name: String,
@@ -350,8 +355,16 @@ struct Reader<'a> {
drawn_looks: usize,
/// Nodes read from `%_` lines.
hidden_ids: HashSet<NodeId>,
/// A group just ended, its state (`Xy`, `Xd`, `XW`) not yet read.
group_ended: bool,
/// Painted copies of clipping paths that have a fill (drawn under what they clip).
clip_fills: HashSet<NodeId>,
/// Groups closed as an object's drawn look (`9 () XW`).
looks: HashSet<NodeId>,
/// The gradient mesh being read (`/Mesh X!` … `/End X!`), and whether it started on a `%_` line.
mesh: Option<(super::mesh::MeshReader, bool)>,
/// The art styles (`%AI9_BeginArtStyles`), by name.
styles: HashMap<String, super::style::ArtStyle>,
/// 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.
@@ -395,6 +408,8 @@ const IGNORED: &[&str] = &[
"TZ",
"Xs",
"Xt",
// A brushed path's width at each anchor (`(…) Xt n XD`), on its hidden source path.
"XD",
"Xi",
"XI",
"`",
@@ -482,6 +497,10 @@ impl<'a> Reader<'a> {
warnings: Vec::new(),
drawn_looks: 0,
hidden_ids: HashSet::new(),
clip_fills: HashSet::new(),
looks: HashSet::new(),
group_ended: false,
styles: HashMap::new(),
mesh: None,
cmyk_cache: HashMap::new(),
done: false,
@@ -554,6 +573,11 @@ impl<'a> Reader<'a> {
self.define_pattern(section);
return Ok(());
}
if m == "BeginArtStyles" {
let section = self.section("EndArtStyles")?;
self.styles = super::style::art_styles(section);
return Ok(());
}
if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| *b == m) {
return self.skip_to(end);
}
@@ -755,10 +779,20 @@ impl<'a> Reader<'a> {
let Frame { kind, children: kids, gs, clips, hidden: opened_hidden, .. } = f;
// A compound path of clipping paths clips its clipping group.
let clipping = matches!(kind, Kind::Compound) && !clips.is_empty();
self.group_ended = !matches!(kind, Kind::Compound);
let mut painted = None;
let node = match kind {
// A compound path holding a group (its drawn look) is that look: its paths paint apart.
Kind::Compound if kids.iter().any(|k| self.looks.contains(&k.id)) => {
let (children, clip) = self.clip_first(kids, &clips);
self.new_node(NodeKind::Group { children, clip }, &gs)
}
Kind::Compound => {
// A group in a compound path (`*u u … U *U`) only holds its paths.
let Some(n) = self.compound(ungrouped(kids), &gs) else { return Ok(()) };
// A group in a compound path (`*u u … U *U`) only holds its paths. Clipping subpaths'
// painted copies make a compound of their own, under what it clips.
let (fills, kids): (Vec<Node>, Vec<Node>) = ungrouped(kids).into_iter().partition(|n| self.clip_fills.contains(&n.id));
painted = self.compound(fills, &gs);
let Some(n) = self.compound(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.
@@ -770,7 +804,12 @@ impl<'a> Reader<'a> {
if clipping && let Some(f) = self.frames.last_mut() {
f.clips.push(f.children.len());
}
self.add(node, opened_hidden || hidden)
self.add(node, opened_hidden || hidden)?;
if let Some(p) = painted {
self.clip_fills.insert(p.id);
self.add(p, opened_hidden || hidden)?;
}
Ok(())
}
/// The compound path of `kids`, painted and made transparent as its subpaths say.
@@ -806,7 +845,9 @@ impl<'a> Reader<'a> {
}
};
let clips = clip_node.is_some();
let children = clip_node.into_iter().chain(rest.into_iter().map(|(_, n)| n)).map(Arc::new).collect();
// A clipping path's own fill paints under the art it clips.
let (fills, rest): (Vec<Indexed>, Vec<Indexed>) = rest.into_iter().partition(|(_, n)| self.clip_fills.contains(&n.id));
let children = clip_node.into_iter().chain(fills.into_iter().chain(rest).map(|(_, n)| n)).map(Arc::new).collect();
(children, clips)
}
@@ -880,6 +921,7 @@ impl<'a> Reader<'a> {
if !matches!(w, "Xy" | "Xd" | "XW" | "BB") {
self.after_object = false;
}
let group_ended = std::mem::take(&mut self.group_ended);
let vals = self.operands();
let vals = self.take_head(vals);
if let Some(d) = self.gradient_def.as_mut() {
@@ -982,6 +1024,14 @@ impl<'a> Reader<'a> {
"d" => self.dash(&vals),
// Transparency and state.
"Xy" => self.transparency(&vals),
// A group's own state (`Xd`, `6 () XW`) without an `Xy` keeps the current transparency, as
// the file's PDF part draws it. A look's group (`9 () XW`) has no state of its own.
"Xd" if group_ended => {
let gs = self.gs.clone();
if let Some(n) = self.last_object().filter(|n| matches!(n.kind, NodeKind::Group { .. })) {
(n.opacity, n.blend, n.isolate, n.knockout) = (gs.opacity, gs.blend, gs.isolate, gs.knockout);
}
}
"Xw" => self.gs.hidden = Self::nums(&vals).last() == Some(&1.0),
"A" => self.gs.locked = Self::nums(&vals).last() == Some(&1.0),
"XW" => self.style_marker(&vals, hidden),
@@ -1361,12 +1411,18 @@ impl<'a> Reader<'a> {
}
/// `n (style) XW` after an object: `1 (style)` after the object written on `%_` lines, its
/// drawn look before it. Other markers end what follows an object.
/// drawn look before it (all on `%_` lines inside another such object). Other markers end what
/// follows an object.
fn style_marker(&mut self, vals: &[V], hidden: bool) {
let code = Self::nums(vals).last().copied().unwrap_or(0.0);
let style = vals.iter().rev().find_map(V::text).unwrap_or_default();
let after = std::mem::take(&mut self.after_object);
if hidden || code != 1.0 || style.is_empty() || !after {
if code == 9.0
&& let Some(look) = self.frames.last().and_then(|f| f.children.last()).filter(|n| matches!(n.kind, NodeKind::Group { .. }))
{
self.looks.insert(look.id);
}
if code != 1.0 || style.is_empty() || !after {
return;
}
let Some(f) = self.frames.last_mut() else { return };
@@ -1374,13 +1430,29 @@ impl<'a> Reader<'a> {
let (Some(object), Some(look)) = (n.checked_sub(1).and_then(|i| f.children.get(i)), n.checked_sub(2).and_then(|i| f.children.get(i))) else {
return;
};
if !self.hidden_ids.contains(&object.id) || self.hidden_ids.contains(&look.id) || !matches!(look.kind, NodeKind::Group { clip: false, .. }) {
if !self.hidden_ids.contains(&object.id)
|| self.hidden_ids.contains(&look.id) != hidden
|| !matches!(look.kind, NodeKind::Group { clip: false, .. })
{
return;
}
// A style read here: the object, painted as it says.
if let Some(appearance) = self.styles.get(&style).and_then(|st| styled(object, st)) {
let Some(mut object) = f.children.pop() else { return };
f.children.pop();
object.appearance = appearance.into();
// Now the object itself, not a copy an enclosing look's `strip` would remove.
self.hidden_ids.remove(&object.id);
f.children.push(object);
return;
}
let is_text = slot_of(object.name.as_deref()).is_some();
let Some(object) = f.children.pop() else { return };
let Some(look) = f.children.last_mut() else { return };
strip(look, &self.hidden_ids);
// Inside a copy on `%_` lines everything is, so nothing tells the look's own copies apart.
if !hidden {
strip(look, &self.hidden_ids);
}
if is_text {
// A text object stays the type it is: over what else its look draws, or in place of a
// look that was only its type.
@@ -1394,7 +1466,9 @@ impl<'a> Reader<'a> {
look.name = object.name.clone().or(look.name.take());
look.visible = object.visible;
look.locked = object.locked;
self.drawn_looks += 1;
if !hidden {
self.drawn_looks += 1;
}
}
// ---- paths -----------------------------------------------------------------------------
@@ -1473,7 +1547,10 @@ impl<'a> Reader<'a> {
};
let mut items = Vec::new();
if fill && !clip {
items.push(AppearanceItem::Fill(FillLayer { overprint: gs.overprint_fill, ..FillLayer::new(fill_paint.unwrap_or(gs.fill.clone())) }));
items.push(AppearanceItem::Fill(FillLayer {
overprint: gs.overprint_fill,
..FillLayer::new(fill_paint.clone().unwrap_or(gs.fill.clone()))
}));
}
if stroke && !clip {
let mut st = StrokeLayer::new(stroke_paint.unwrap_or(gs.stroke.clone()), gs.width);
@@ -1493,7 +1570,28 @@ impl<'a> Reader<'a> {
if clip && let Some(f) = self.frames.last_mut() {
f.clips.push(f.children.len());
}
self.add(node, hidden)
// A clipping path's fill is painted too, under the art it clips, as the file's PDF part draws it.
let painted = clip && fill && !matches!(gs.fill, Paint::None);
let copy = painted.then(|| {
let mut copy = node.clone();
copy.id = self.doc.alloc_id();
if let NodeKind::Path { clipping, .. } = &mut copy.kind {
*clipping = false;
}
let paint = fill_paint.clone().unwrap_or_else(|| gs.fill.clone());
copy.appearance = Appearance {
items: vec![AppearanceItem::Fill(FillLayer { overprint: gs.overprint_fill, ..FillLayer::new(paint) })],
..Appearance::default()
}
.into();
copy
});
self.add(node, hidden)?;
if let Some(copy) = copy {
self.clip_fills.insert(copy.id);
self.add(copy, hidden)?;
}
Ok(())
}
/// `/End X!`: the gradient mesh read, as one object.
@@ -1836,6 +1934,28 @@ fn path_rule(n: &Node) -> FillRule {
}
}
/// `object`'s appearance with style `st`: `None` when the style isn't one this reads, or asks a
/// group for fills or strokes.
fn styled(object: &Node, st: &super::style::ArtStyle) -> Option<Appearance> {
use super::style::StyleItem;
if !st.plain || (object.is_container() && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty()) {
return None;
}
let mut items = vec![];
for item in &st.items {
items.push(match item {
StyleItem::Fill(paint) => AppearanceItem::Fill(FillLayer::new(paint.clone())),
StyleItem::ObjectFill => AppearanceItem::Fill(object.appearance.fill().filter(|f| matches!(f.paint, Paint::Gradient(_)))?.clone()),
StyleItem::Stroke { paint, width, cap, join, miter, dash, profile } => {
let mut layer = StrokeLayer::new(paint.clone(), *width);
(layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile) = (*cap, *join, *miter, dash.clone(), profile.clone());
AppearanceItem::Stroke(layer)
}
});
}
Some(Appearance { items, effects: st.effects.clone(), ..(*object.appearance).clone() })
}
/// Remove from `n` the children read from `%_` lines (`hidden`: the object itself, not its look).
fn strip(n: &mut Node, hidden: &HashSet<NodeId>) {
if let Some(children) = n.children_mut() {
+408
View File
@@ -0,0 +1,408 @@
//! Art styles (`%AI9_BeginArtStyles`; not publicly documented): the appearance an object's
//! `1 (style) XW` names, a chain of filters — a stack of fills and strokes round the object's
//! contents (`Conduit Filter`), then live effects, then its transparency (`Blend Style Filter`):
//!
//! ```text
//! /KnownStyle :
//! (name) /Name ,
//! /ActiveStyle :
//! /CompoundFilter :
//! (Chain Style Filter) 0 0 /Filter ,
//! … (Stack Style Filter) … (Stroke Style Filter) … (Conduit Filter) … (Fill Style Filter) …
//! (Adobe Outer Glow) 1 0 /Filter ,
//! 1 /Visible ,
//! 2 /Int (blnd) ,
//! 0.75 /Real (opac) ,
//! 5 /Real (blur) ,
//! /FillStyle : 0 O
//! c m y k r g b Xa
//! /Def ;
//! (sclr) ,
//! ```
//!
//! A style is *plain* when it is one fill and one stroke at most, in solid colours (a fill's gradient
//! is the object's own), and only effects read here (Outer Glow, Drop Shadow) after them. Such
//! objects are read as themselves with that fill, stroke and those effects; others keep their
//! drawn look. A fill's or stroke's style says whether it paints with `stroke fill Xd`.
use std::collections::HashMap;
use serde_json::json;
use vectorcraft_color::Paint;
use vectorcraft_doc::{Dash, Effect, LineCap, LineJoin, WidthProfile};
use super::lex::{Lexer, Tok};
use super::obj::V;
use super::paint;
/// The filters a plain style may chain.
const PLAIN: [&str; 7] =
["Chain Style Filter", "Stack Style Filter", "Stroke Style Filter", "Fill Style Filter", "Conduit Filter", "Blend Style Filter", GLOW];
const GLOW: &str = "Adobe Outer Glow"; // brand-ok: the filter's name in the files
const SHADOW: &str = "Adobe Drop Shadow"; // brand-ok: the filter's name in the files
/// A plain style's paint, as the object comes in.
#[derive(Clone, Debug, Default, PartialEq)]
pub(super) struct ArtStyle {
/// The style reads as [`Self::items`] and [`Self::effects`].
pub plain: bool,
/// Its fill and stroke, bottom first.
pub items: Vec<StyleItem>,
/// Its live effects, in the order they apply.
pub effects: Vec<Effect>,
}
#[derive(Clone, Debug, PartialEq)]
pub(super) enum StyleItem {
Fill(Paint),
/// A fill with a gradient: the object's own.
ObjectFill,
Stroke {
paint: Paint,
width: f64,
cap: LineCap,
join: LineJoin,
miter: f64,
dash: Option<Dash>,
profile: Option<WidthProfile>,
},
}
#[derive(Debug)]
struct Filter {
name: String,
visible: bool,
params: HashMap<String, f64>,
/// Its dictionary's arrays (`/Array : v /Real , … ; (key) ,`), by key.
arrays: HashMap<String, Vec<f64>>,
fill: Option<Paint>,
stroke: Option<Paint>,
width: f64,
cap: LineCap,
join: LineJoin,
miter: f64,
dash: Option<Dash>,
/// `stroke fill Xd`.
paints: (bool, bool),
gradient: bool,
}
impl Default for Filter {
fn default() -> Self {
Self {
name: String::new(),
visible: true,
params: HashMap::new(),
arrays: HashMap::new(),
fill: None,
stroke: None,
width: 1.0,
cap: LineCap::Butt,
join: LineJoin::Miter,
miter: 10.0,
dash: None,
paints: (false, false),
gradient: false,
}
}
}
/// The styles of the section `data` (between its markers), by name.
pub(super) fn art_styles(data: &[u8]) -> HashMap<String, ArtStyle> {
let mut styles = HashMap::new();
let mut name: Option<String> = None;
let mut filters: Vec<Filter> = vec![];
let mut operands: Vec<V> = vec![];
let mut finish = |name: Option<String>, filters: &mut Vec<Filter>| {
if let Some(name) = name {
styles.insert(name, style(filters));
}
filters.clear();
};
let mut array: Option<Vec<V>> = None;
let mut dict_array: Option<Vec<f64>> = None;
let mut lex = Lexer::new(data);
while let Some(t) = lex.next_token() {
if let (Some(a), Tok::Num(v)) = (array.as_mut(), &t.tok) {
a.push(V::Num(*v));
continue;
}
match t.tok {
Tok::Num(v) => operands.push(V::Num(v)),
Tok::Str(s) | Tok::Hex(s) => operands.push(V::Str(s)),
Tok::Name(n) => match n {
b"KnownStyle" => {
finish(name.take(), &mut filters);
operands.clear();
}
b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(V::text),
b"Filter" => filters.push(Filter { name: operands.iter().rev().find_map(V::text).unwrap_or_default(), ..Filter::default() }),
b"Visible" => {
if let Some(f) = filters.last_mut() {
f.visible = operands.iter().rev().find_map(V::num) != Some(0.0);
}
}
b"Array" => dict_array = Some(vec![]),
b"Int" | b"Real" | b"Bool" => {
let value = operands.iter().rev().find_map(V::num);
operands.clear();
if let Some(a) = dict_array.as_mut() {
a.extend(value);
continue;
}
// The key follows: `value /Real (key) ,`.
if let (Some(value), Some(Tok::Str(key))) = (value, lex.next_token().map(|t| t.tok))
&& let Some(f) = filters.last_mut()
{
f.params.insert(String::from_utf8_lossy(&key).into_owned(), value);
}
continue;
}
_ => {}
},
Tok::Word(w) => {
let op = std::str::from_utf8(w).unwrap_or_default();
if op == "[" {
array = Some(vec![]);
continue;
}
if op == "]" {
let items = array.take().unwrap_or_default();
operands.push(V::Arr(items));
continue;
}
if op == ";"
&& let Some(values) = dict_array.take()
{
if let (Some(Tok::Str(key)), Some(f)) = (lex.next_token().map(|t| t.tok), filters.last_mut()) {
f.arrays.insert(String::from_utf8_lossy(&key).into_owned(), values);
}
operands.clear();
continue;
}
if let Some(f) = filters.last_mut() {
paint_op(f, op, &operands);
}
if op == "," || op == ";" || op.starts_with('X') || op.starts_with('B') || op.len() <= 2 {
operands.clear();
}
continue;
}
Tok::Comment(_) | Tok::Data(_) | Tok::Preview(_) => {}
}
if operands.len() > 64 {
operands.clear();
}
}
finish(name, &mut filters);
styles
}
/// A paint operator of a fill's or stroke's style.
fn paint_op(f: &mut Filter, op: &str, vals: &[V]) {
let last = vals.iter().rev().find_map(V::num);
match op {
"Xa" | "k" | "g" | "x" | "Xk" | "XA" | "K" | "G" | "X" | "XK" => {
let Some((p, _)) = paint::color_op(op, vals) else { return };
if op.ends_with(|c: char| c.is_ascii_lowercase()) {
f.fill = Some(p);
} else {
f.stroke = Some(p);
}
}
"w" => f.width = last.filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(f.width),
"J" => {
f.cap = match last.map(|v| v as i64) {
Some(1) => LineCap::Round,
Some(2) => LineCap::Square,
_ => LineCap::Butt,
}
}
"j" => {
f.join = match last.map(|v| v as i64) {
Some(1) => LineJoin::Round,
Some(2) => LineJoin::Bevel,
_ => LineJoin::Miter,
}
}
"M" => f.miter = last.filter(|v| v.is_finite() && *v >= 1.0).unwrap_or(f.miter).min(500.0),
"d" => {
let pattern: Vec<f64> = vals
.iter()
.rev()
.find_map(|v| if let V::Arr(a) = v { Some(a.iter().filter_map(V::num).collect()) } else { None })
.unwrap_or_default();
f.dash = (!pattern.is_empty() && pattern.len() <= 64 && pattern.iter().all(|v| v.is_finite() && *v >= 0.0)).then(|| Dash {
pattern,
offset: last.filter(|v| v.is_finite()).unwrap_or(0.0),
..Dash::default()
});
}
"Xd" => {
let n: Vec<f64> = vals.iter().filter_map(V::num).collect();
if let [.., stroke, fill] = n.as_slice() {
f.paints = (*stroke != 0.0, *fill != 0.0);
}
}
"Bg" => f.gradient = true,
_ => {}
}
}
/// A variable-width stroke's profile: its widths (fractions of the stroke's) at positions along the
/// path, the same both sides unless it gives each side's.
fn width_profile(arrays: &HashMap<String, Vec<f64>>) -> Option<WidthProfile> {
let at = arrays.get("BSVariableWidthTValuesArray")?;
let side = |key: &str| arrays.get(key).filter(|w| w.len() == at.len());
let (left, right) = match (side("BSLeftWidthsArray"), side("BSRightWidthsArray")) {
(Some(left), Some(right)) => (left, right),
_ => (side("BSCenteredWidthsArray")?, side("BSCenteredWidthsArray")?),
};
if at.len() > 1024 {
return None;
}
// The stroker expects the positions sorted over 0..=1 and no negative widths.
let mut points: Vec<(f64, f64, f64)> = at
.iter()
.zip(left)
.zip(right)
.filter(|((t, l), r)| t.is_finite() && l.is_finite() && r.is_finite())
.map(|((t, l), r)| (t.clamp(0.0, 1.0), l.max(0.0), r.max(0.0)))
.collect();
points.sort_by(|a, b| a.0.total_cmp(&b.0));
let uniform = points.iter().all(|(_, l, r)| (l - 1.0).abs() < 1e-9 && (r - 1.0).abs() < 1e-9);
(!uniform).then_some(WidthProfile { points })
}
fn style(filters: &[Filter]) -> ArtStyle {
let count = |name: &str| filters.iter().filter(|f| f.name == name).count();
let painted = filters.iter().rposition(|f| matches!(f.name.as_str(), "Stroke Style Filter" | "Fill Style Filter" | "Conduit Filter"));
let mut plain = count("Stroke Style Filter") <= 1 && count("Fill Style Filter") <= 1;
let mut effects = vec![];
let mut items = vec![];
// The stack lists its fills and strokes top first.
for f in filters.iter().rev().filter(|f| f.visible) {
match f.name.as_str() {
"Fill Style Filter" if f.paints.1 && f.gradient => items.push(StyleItem::ObjectFill),
"Fill Style Filter" if f.paints.1 => match &f.fill {
Some(p) => items.push(StyleItem::Fill(p.clone())),
None => plain = false,
},
// Dashes aligned to corners and path ends draw as the look's pieces: a solid end at each end
// and the pattern, fitted, between them.
"Stroke Style Filter" if f.paints.0 && f.params.get("BSDashAdj").is_some_and(|v| *v != 0.0) => plain = false,
"Stroke Style Filter" if f.paints.0 => match &f.stroke {
Some(p) if !f.gradient => items.push(StyleItem::Stroke {
paint: p.clone(),
width: f.width,
cap: f.cap,
join: f.join,
miter: f.miter,
dash: f.dash.clone(),
profile: width_profile(&f.arrays),
}),
_ => plain = false,
},
_ => {}
}
}
for (i, f) in filters.iter().enumerate() {
let effect = f.name == GLOW || f.name == SHADOW;
if !PLAIN.contains(&f.name.as_str()) && f.name != SHADOW {
plain = false;
}
// An effect inside the stack is a fill's or a stroke's, not the object's.
if effect && painted.is_some_and(|p| i < p) {
plain = false;
}
if !f.visible {
// A hidden fill or stroke leaves the object painting differently; a hidden effect, nothing.
plain &= effect;
continue;
}
let param = |k: &str| f.params.get(k).copied();
let mode = |default: usize| super::read::blend_name(param("blnd").map_or(default, |v| v.max(0.0) as usize));
let color = f.fill.as_ref().and_then(Paint::color).unwrap_or(vectorcraft_color::Color::BLACK);
match f.name.as_str() {
// `blur`'s scale isn't publicly documented: read as the Gaussian's σ, an effect's blur being 2σ.
GLOW => effects.push(Effect {
id: "stylize.outerGlow".into(),
params: json!({"mode": mode(2), "opacity": param("opac").unwrap_or(0.75) * 100.0, "blur": 2.0 * param("blur").unwrap_or(5.0), "color": color}),
visible: true,
}),
SHADOW if param("csrc").unwrap_or(0.0) == 0.0 => effects.push(Effect {
id: "stylize.dropShadow".into(),
params: json!({
"mode": mode(1),
"opacity": param("opac").unwrap_or(0.75) * 100.0,
"blur": 2.0 * param("blur").unwrap_or(5.0),
// Undocumented; read as the dialog's offsets, not flipped like art-space y: its default 7/7 pt is
// right and down (https://helpx.adobe.com/illustrator/using/drop-shadows-glows-feathering.html).
"x": param("horz").unwrap_or(7.0),
"y": param("vert").unwrap_or(7.0),
"color": color,
}),
visible: true,
}),
// A shadow darkening the object's own colours isn't one effects describe.
SHADOW => plain = false,
_ => {}
}
}
ArtStyle { plain, items, effects }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_variable_width_stroke_keeps_its_profile() {
let data = b"/KnownStyle :\n(Tapered) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n/StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n0 J 0 j 30 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n\
/Dictionary : /NotRecorded ,\n2 /Int (BSWidthSource) ,\n/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\
/Array :\n1 /Real ,\n0.1 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n";
let styles = art_styles(data);
let style = &styles["Tapered"];
assert!(style.plain);
let [StyleItem::Stroke { width, profile, .. }] = style.items.as_slice() else { panic!("{:?}", style.items) };
assert_eq!(*width, 30.0);
assert_eq!(profile.as_ref().map(|p| p.points.clone()), Some(vec![(0.0, 1.0, 1.0), (1.0, 0.1, 0.1)]));
}
#[test]
fn dashes_aligned_to_corners_keep_the_drawn_look() {
// GUARDS: the look holds the fitted pattern and the solid ends the style's dash array doesn't.
let data = b"/KnownStyle :\n(Stitches) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\
/Dictionary : /NotRecorded ,\n1 /Bool (BSDashAdj) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n\
/StrokeStyle : 0 R\n0 0 0 0 0 0 1 XA\n1 J 0 j 2 w 10 M [5 10 ]0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n; /Dict ;\n /Part ,\n;\n";
assert!(!art_styles(data)["Stitches"].plain);
}
#[test]
fn a_drop_shadow_offset_down_points_down() {
// GUARDS: the document has y down; flipping `vert` like an art-space y would turn the dialog's
// default shadow (7 pt right, 7 pt down) upwards.
let data = b"/KnownStyle :\n(Shade) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n7 /Real (horz) ,\n7 /Real (vert) ,\n; /Dict ;\n /Part ,\n;\n";
let styles = art_styles(data);
let [shadow] = styles["Shade"].effects.as_slice() else { panic!("{:?}", styles["Shade"]) };
assert_eq!((shadow.params["x"].as_f64(), shadow.params["y"].as_f64()), (Some(7.0), Some(7.0)));
}
#[test]
fn hostile_dash_offsets_and_width_profiles_are_tamed() {
let mut f = Filter::default();
paint_op(&mut f, "d", &[V::Arr(vec![V::Num(5.0)]), V::Num(f64::NAN)]);
assert_eq!(f.dash.map(|d| d.offset), Some(0.0));
// Positions outside 0..=1 and out of order, a negative width, a non-finite one.
let arrays = HashMap::from([
("BSVariableWidthTValuesArray".to_string(), vec![1.5, -0.2, 0.5, 0.7]),
("BSCenteredWidthsArray".to_string(), vec![-1.0, 2.0, 0.5, f64::INFINITY]),
]);
assert_eq!(width_profile(&arrays).map(|p| p.points), Some(vec![(0.0, 2.0, 2.0), (0.5, 0.5, 0.5), (1.0, 0.0, 0.0)]));
}
}
+120
View File
@@ -238,6 +238,12 @@ fn compound_paths_and_clipping() {
// The clipping path goes first.
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
assert!(matches!(children[1].kind, NodeKind::Path { clipping: false, .. }));
// A clipping path with a fill paints it too, under the art it clips; without one, nothing.
let filled = format!("0 Ae\nq\n{RED}{}f\n0 0 1 0 0 0 1 Xa\n{}W\nf\nQ\n", rect_path(0.0, 0.0, 30.0, 30.0), rect_path(5.0, 5.0, 25.0, 25.0));
let doc = read_ok(&layer(&filled)).doc;
let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") };
let kinds: Vec<_> = children.iter().map(|c| (matches!(c.kind, NodeKind::Path { clipping: true, .. }), fill(c).color())).collect();
assert_eq!(kinds, [(true, None), (false, Some(Color::rgb(0.0, 0.0, 1.0))), (false, Some(Color::rgb(1.0, 0.0, 0.0)))]);
// A clipping path in a layer clips the layer.
let doc = read_ok(&layer(&format!("{}f\n{}W\nn\n", rect_path(0.0, 0.0, 9.0, 9.0), rect_path(0.0, 0.0, 5.0, 5.0)))).doc;
let NodeKind::Layer { clip, children, .. } = &doc.layers[0].kind else { panic!() };
@@ -263,6 +269,20 @@ fn compound_path_takes_its_last_paths_paint() {
}
}
#[test]
fn a_group_whose_state_has_no_transparency_takes_the_current_one() {
// GUARDS: a group's own state block keeps the last `Xy`, as the PDF part draws it; a drawn look's group stays opaque.
let art_ = format!(
"{RED}0 Ae\nu\n0 0.5 0 0 0 Xy\n{}f\nU\n0 0 Xd\n6 () XW\n0 1 0 0 0 Xy\n0 Ae\nu\n0 0.5 0 0 0 Xy\n{}f\nU\n9 () XW\n",
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(20.0, 0.0, 30.0, 10.0)
);
let doc = read_ok(&layer(&art_)).doc;
let a = art(&doc);
assert_eq!((a[0].opacity, a[0].children().unwrap()[0].opacity), (0.5, 0.5));
assert_eq!((a[1].opacity, a[1].children().unwrap()[0].opacity), (1.0, 0.5));
}
#[test]
fn transparency_of_objects_and_groups() {
let art_ = format!(
@@ -332,6 +352,27 @@ 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 a_compound_path_holding_its_drawn_look_paints_its_paths_apart() {
// GUARDS: made one compound path, the look's paths take a single fill and the inner one punches a hole.
let art_ = format!(
"0 Ae\n*u\n0 Ae\nu\n{RED}{}f\n0 0 0 0 0 0 1 Xa\n{}f\nU\n9 () XW\n*U\n",
rect_path(0.0, 0.0, 20.0, 20.0),
rect_path(5.0, 5.0, 15.0, 15.0)
);
let a = art(&read_ok(&layer(&art_)).doc);
fn leaves(n: &Node, out: &mut Vec<Paint>) {
match n.children() {
Some(children) if !matches!(n.kind, NodeKind::Compound { .. }) => children.iter().for_each(|c| leaves(c, out)),
_ => out.push(fill(n)),
}
}
let mut painted = vec![];
a.iter().for_each(|n| leaves(n, &mut painted));
let fills: Vec<_> = painted.iter().map(Paint::color).collect();
assert_eq!(fills, [Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0))], "{a:#?}");
}
#[test]
fn gradients_through_a_skewed_matrix() {
// GUARDS: a sheared `Xm` leaves the matrix's columns oblique. A linear ramp runs along the
@@ -369,6 +410,12 @@ fn stops_sharing_a_ramp_point_keep_their_order() {
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 per_anchor_data_reads() {
let art_ = format!("{RED}{}f\n(00800080) Xt 0 XD\n%_(00004000) Xt 1 XD\n", rect_path(0.0, 0.0, 10.0, 10.0));
assert_eq!(art(&read_ok(&layer(&art_)).doc).len(), 1);
}
#[test]
fn artboards() {
let data = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\
@@ -804,6 +851,28 @@ fn artboards_after_what_stops_the_reading() {
assert_eq!(boards, [("one".to_string(), 0.0), ("two".to_string(), 200.0)]);
}
#[test]
fn a_compound_clipping_path_with_a_fill_paints_once_under_its_clip() {
// GUARDS: the painted copies of a compound's clipping subpaths went into the compound itself,
// doubling each subpath, so under even-odd the clip cancelled to nothing.
let art_ = format!(
"0 Ae\nq\n{RED}{}f\n1 XR\n*u\n0 0 0 0 0 0 1 Xa\n{}W\nf\n{}W\nf\n*U\nQ\n",
rect_path(0.0, 0.0, 100.0, 100.0),
rect_path(10.0, 10.0, 40.0, 40.0),
rect_path(50.0, 10.0, 80.0, 40.0),
);
let doc = read_ok(&layer(&art_)).doc;
let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") };
assert_eq!(children.len(), 3, "{children:#?}");
let NodeKind::Compound { children: clip, rule } = &children[0].kind else { panic!("{:?}", children[0].kind) };
assert_eq!(*rule, vectorcraft_geom::FillRule::EvenOdd);
assert_eq!(clip.len(), 2);
assert!(clip.iter().all(|c| matches!(c.kind, NodeKind::Path { clipping: true, .. })));
let NodeKind::Compound { children: painted, .. } = &children[1].kind else { panic!("{:?}", children[1].kind) };
assert_eq!((painted.len(), fill(&children[1]).color()), (2, Some(Color::rgb(0.0, 0.0, 1.0))));
assert_eq!(fill(&children[2]).color(), Some(Color::rgb(1.0, 0.0, 0.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.
@@ -821,6 +890,38 @@ fn a_radial_gradient_without_xm_is_its_bm_circle_else_its_bg_circle() {
}
/// Two art styles: `Blue`, a blue fill and a drop shadow; `Lit`, the object's gradient and an outer glow.
const ART_STYLES: &str = "%AI9_BeginArtStyles\n\
/KnownStyle :\n(Blue) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n\
/BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n3 /Real (horz) ,\n4 /Real (vert) ,\n; /Dict ;\n /Part ,\n;\n\
/KnownStyle :\n(Lit) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n1 (G) 0 0 0 1 1 0 0 1 0 0 Bg\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n\
/BasicFilter :\n(Adobe Outer Glow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n0.5 /Real (opac) ,\n2 /Real (blur) ,\n; /Dict ;\n /Part ,\n;\n\
%AI9_EndArtStyles\n";
#[test]
fn an_object_with_a_style_this_reads_comes_in_as_itself() {
let defs =
"%AI5_BeginGradient: (G)\n(G) 0 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 hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
let art_ = format!(
"0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_1 (G) 0 0 0 1 1 0 0 1 0 0 Bg\n%_10 0 0 10 5 5 Xm\n%_{hidden_rect}f\n%_0 BB\n1 (Lit) XW\n",
rect_path(0.0, 0.0, 10.0, 10.0)
);
let r = read_ok(&format!("{ART_STYLES}{defs}{}", layer(&art_)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look");
assert!(matches!(fill(&a[0]), Paint::Gradient(g) if g.swatch.as_deref() == Some("G")));
let effects: Vec<_> = a[0].appearance.effects.iter().map(|e| (e.id.as_str(), e.params["opacity"].as_f64())).collect();
assert_eq!(effects, [("stylize.outerGlow", Some(50.0))]);
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
}
#[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.
@@ -828,6 +929,25 @@ fn a_bare_comment_line_in_a_prolog_leaves_its_end_alone() {
assert_eq!(art(&r.doc).len(), 1);
}
#[test]
fn a_styled_object_inside_a_drawn_look_survives_it() {
// GUARDS: an adopted styled object was read on `%_` lines; while its id stayed among those, the
// enclosing look's strip removed it.
let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
let art_ = format!(
"0 Ae\nu\n0 Ae\nu\n{RED}{}f\nU\n%_{hidden_rect}f\n1 (Blue) XW\nU\n%_0 Ae\n%_u\n%_{hidden_rect}f\n%_U\n1 (Outer) XW\n",
rect_path(0.0, 0.0, 10.0, 10.0)
);
let r = read_ok(&format!("{ART_STYLES}{}", layer(&art_)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
let inner = a[0].children().unwrap();
assert_eq!(inner.len(), 1, "{inner:#?}");
assert_eq!(fill(&inner[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)));
let shadow = &inner[0].appearance.effects[0];
assert_eq!((shadow.id.as_str(), shadow.params["x"].as_f64(), shadow.params["y"].as_f64()), ("stylize.dropShadow", Some(3.0), Some(4.0)));
}
#[test]
fn a_gradient_mesh_reads_as_a_patch() {
let corner = |rgb: &str, x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| {
+5 -4
View File
@@ -113,7 +113,7 @@ fn sublayers_groups_compounds_clip_groups_and_hidden_objects_come_in() {
square(20, 10),
square(30, 10),
"*U".into(),
// A clipping group: its objects, then its clip path.
// A clipping group: its objects, then its clip path (filled, so it paints too).
"q".into(),
square(40, 10),
"50 10 m 60 10 L 60 20 L 50 20 L h W f".into(),
@@ -128,11 +128,12 @@ fn sublayers_groups_compounds_clip_groups_and_hidden_objects_come_in() {
.join("\n");
let sub = layer("Inner", true, &square(80, 10));
let art = native(&layer("Outer", true, &format!("{body}\n{sub}")));
// The page has what shows: the clip group's square is outside its clip path.
let page = [10, 20, 30, 80].map(|x| page_square(x, 10)).join("\n");
// The page has what shows: the clip group's square is outside its clip path, which paints.
let clip_fill = "0 0 1 0 setcmykcolor 50 10 moveto 60 10 lineto 60 20 lineto 50 20 lineto closepath fill";
let page = [10, 20, 30, 80].map(|x| page_square(x, 10)).join("\n") + "\n" + clip_fill;
let r = import(&eps(&page, &art)).unwrap();
let outer = &r.document.layers[0];
assert_eq!(kinds(outer), ["group[1]", "compound[2]", "clip[2]", "group[1]", "other"]);
assert_eq!(kinds(outer), ["group[1]", "compound[2]", "clip[3]", "group[1]", "other"]);
let children = outer.children().unwrap();
assert!(children[0].visible && !children[3].visible, "the hidden object is hidden, not left out");
// The clip path comes first in its group.
+3 -2
View File
@@ -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; `.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)
> **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; `.ai` art styles, clipping fills and compound looks 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; `.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). 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
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 whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners 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, a clipping path's own fill 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 Line 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: an art style of one fill and one stroke (variable widths too) with an Outer Glow or Drop Shadow comes in as the object's own appearance; a clipping path's own fill paints under what it clips; a compound path holding its drawn look paints that look's paths apart; a group's own state keeps the current transparency |
| 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 |
+2 -1
View File
@@ -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; 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
> **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` art styles 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; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data 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` art styles, clipping fills and compound looks read from the editing data; estimate unchanged |
| 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 |