diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 96cc2d0fb..906df8522 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -96,8 +96,9 @@ USAGE: SVG's data-* attributes; --outline-text writes SVG text as paths, --decimals sets SVG's coordinate precision (1–7, default 3). OUT may not be IN when reading IN left things out (hidden text, art or layers it could not read; the notes say which) unless - --replace-lossy is given. --editing-data opens a .ai from its editing copy even where that - draws differently from its PDF part (which can be older), as long as it can be read. The + --replace-lossy is given. --editing-data opens a .ai or Illustrator EPS from its editing copy + even where that draws differently from its PDF part or page (which can be older), as long as + it can be read. The result has the export's warnings, and importWarnings for what reading IN left out. --ai-version 7 writes a .ai as the legacy version 7 (layers, groups and type kept; one file per artboard, OUT-.ai when there are several; default pdf, PDF-compatible), with diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 7dc335fc8..b7bb78098 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -244,7 +244,8 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result _ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => { let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes))); restore_or_import(editing, || { - let r = vectorcraft_eps::import_with(bytes, opts.editing_data).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?; + let r = vectorcraft_eps::import_trusting(bytes, opts.editing_data, opts.trust_editing_data) + .map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?; Ok((r.document, r.warnings)) })? } diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index ce7e09084..f292cc124 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -116,6 +116,9 @@ pub struct Structure { pub hidden_unread: BTreeSet, /// The names of text objects, by their slot's id. pub slot_names: HashMap, + /// Where the drawn looks lay that objects read with their art style took the place of (the + /// document's coordinates): the page draws Illustrator's rendering of those styles there. + pub restyled: Vec, pub warnings: Vec, } @@ -359,7 +362,12 @@ const BLENDS: [BlendMode; 16] = [ /// 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)) + format!("{:?}", blend_mode(n)) +} + +/// Blend mode `n` of `Xy` (Normal when it is out of range). +pub(super) fn blend_mode(n: usize) -> BlendMode { + BLENDS.get(n).copied().unwrap_or(BlendMode::Normal) } #[derive(Debug, Default)] @@ -507,6 +515,8 @@ struct Reader<'a> { mesh: Option<(super::mesh::MeshReader, bool)>, /// The art styles (`%AI9_BeginArtStyles`), by name. styles: HashMap, + /// See [`Structure::restyled`]. + restyled: Vec, /// 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. @@ -655,6 +665,7 @@ impl<'a> Reader<'a> { group_ended: false, layer_state: None, styles: HashMap::new(), + restyled: vec![], mesh: None, cmyk_cache: HashMap::new(), done: false, @@ -1698,14 +1709,10 @@ impl<'a> Reader<'a> { return; }; let matrix = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { paint::affine(&Self::nums(a)) } else { None }); - // The tile's art (y down from the tile's top left) → the pattern's space → art space. The - // tile's lower left is the pattern's origin wherever the definition drew it, as the file's - // PDF part tiles it. - let xf = self.space() * matrix.unwrap_or(Affine::IDENTITY) * FLIP * Affine::translate((0.0, -tile.height())); - if xf.determinant().abs() < 1e-12 || !xf.as_coeffs().iter().all(|v| v.is_finite()) { + let Some(xf) = pattern_xf(self.space(), matrix, tile) else { self.unreadable("pattern fills"); return; - } + }; let p = Paint::Pattern { pattern: name.unwrap_or_default(), xf }; if w == "p" { self.gs.fill = p; @@ -1797,6 +1804,7 @@ impl<'a> Reader<'a> { if code != 1.0 || style.is_empty() || !after { return; } + let space = self.space(); let Some(f) = self.frames.last_mut() else { return }; let n = f.children.len(); 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 { @@ -1811,9 +1819,16 @@ impl<'a> Reader<'a> { 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)) { + if let Some(appearance) = self + .styles + .get(&style) + .and_then(|st| styled(object, look, st, &StylePaints { gradients: &self.gradients, patterns: &self.pattern_tiles, space })) + { let Some(mut object) = f.children.pop() else { return }; let Some(look) = f.children.pop() else { return }; + if let Some(b) = look.visual_bounds().filter(|_| !hidden && object.visible && look.visible) { + self.restyled.push(b); + } object.appearance = appearance.into(); // The object's transparency is written on its look, after the look's group ends. (object.opacity, object.blend, object.isolate, object.knockout) = (look.opacity, look.blend, look.isolate, look.knockout); @@ -2356,6 +2371,7 @@ impl<'a> Reader<'a> { template: self.template, hidden_unread: self.hidden_unread, slot_names: self.slot_names, + restyled: self.restyled, warnings, }) } @@ -2454,26 +2470,116 @@ 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 { +/// The tile's art (y down from the tile's top left) → the pattern's space (`matrix`) → art space +/// → the document (`space`). The tile's lower left is the pattern's origin wherever the +/// definition drew it, as the file's PDF part tiles it. `None` when it can't be inverted. +fn pattern_xf(space: Affine, matrix: Option, tile: Rect) -> Option { + let xf = space * matrix.unwrap_or(Affine::IDENTITY) * FLIP * Affine::translate((0.0, -tile.height())); + (xf.determinant().abs() >= 1e-12 && xf.as_coeffs().iter().all(|v| v.is_finite())).then_some(xf) +} + +/// What a style's fills paint with: the file's gradients and pattern tiles, and art space → the document. +struct StylePaints<'a> { + gradients: &'a HashMap, + patterns: &'a HashMap, + space: Affine, +} + +/// The gradients and patterns `look` paints with, placed as it draws them, by their swatch's name +/// (the first of each; a pattern's name after `PATTERN_KEY`). +fn placed_paints(look: &Node, out: &mut HashMap, depth: usize) { + if depth > MAX_DEPTH { + return; + } + for item in &look.appearance.items { + let key = match item.paint() { + Paint::Gradient(g) => g.swatch.clone(), + Paint::Pattern { pattern, .. } => Some(format!("{PATTERN_KEY}{pattern}")), + _ => None, + }; + if let Some(key) = key { + out.entry(key).or_insert_with(|| item.paint().clone()); + } + } + for c in look.children().into_iter().flatten() { + placed_paints(c, out, depth + 1); + } +} + +/// What [`placed_paints`] keys a pattern's name with, apart from the gradients' names. +const PATTERN_KEY: &str = "\u{1}pattern "; + +/// `object`'s appearance with style `st`, its drawn look `look`: `None` when the style isn't one +/// this reads, or asks a group for fills or strokes. +fn styled(object: &Node, look: &Node, st: &super::style::ArtStyle, paints: &StylePaints) -> Option { use super::style::StyleItem; - if !st.plain || (object.is_container() && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty()) { + // A text object's slot (an empty group standing for it) takes the style as the type will. + let slot = slot_of(object.name.as_deref()).is_some(); + if !st.plain + || (!slot && !st.simple) + || (object.is_container() && !slot && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty()) + { return None; } + // A style with one gradient fill: the object's own gradient, placed as the object's fill is. + let gradients = st.items.iter().filter(|i| matches!(i, StyleItem::Gradient { .. })).count(); + let own_gradient = object.appearance.fill().filter(|f| matches!(f.paint, Paint::Gradient(_))); + // A style's gradients are written in a space of the object's bounds that isn't documented; its + // drawn look has each where the app placed it. + let mut placed = HashMap::new(); + placed_paints(look, &mut placed, 0); let mut items = vec![]; - for item in &st.items { - items.push(match item { + for (n, item) in st.items.iter().enumerate() { + let mut layer = 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::Gradient { name, angle } => { + let paint = match (name.as_ref().and_then(|n| placed.get(n)), gradients, own_gradient) { + // Other objects than type: the object's own, as they always were. + (_, 1, Some(own)) if !slot => own.paint.clone(), + (Some(p), ..) => p.clone(), + (None, 1, Some(own)) => own.paint.clone(), + _ => { + let gradient = name.as_ref().and_then(|n| paints.gradients.get(n))?; + let mut g = vectorcraft_color::gradient::GradientPaint::new(gradient.clone()); + // Fitted to the object's bounds at its angle (art space turns counter-clockwise, as the document's angles do). + g.angle = *angle; + Paint::Gradient(Box::new(g)) + } + }; + AppearanceItem::Fill(FillLayer::new(paint)) + } + StyleItem::Pattern { name, matrix } => match placed.get(&format!("{PATTERN_KEY}{name}")) { + Some(p) => AppearanceItem::Fill(FillLayer::new(p.clone())), + None => { + let tile = paints.patterns.get(name).copied()?; + let xf = pattern_xf(paints.space, matrix.and_then(|m| paint::affine(&m)), tile)?; + AppearanceItem::Fill(FillLayer::new(Paint::Pattern { pattern: name.clone(), xf })) + } + }, 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) } - }); + }; + if let Some(look) = st.looks.get(n) { + match &mut layer { + AppearanceItem::Fill(f) => { + (f.visible, f.opacity, f.blend, f.effects) = (look.visible, look.opacity, look.blend, look.effects.clone()) + } + AppearanceItem::Stroke(s) => { + (s.visible, s.opacity, s.blend, s.effects) = (look.visible, look.opacity, look.blend, look.effects.clone()) + } + } + } + items.push(layer); } - Some(Appearance { items, effects: st.effects.clone(), ..(*object.appearance).clone() }) + let contents_index = if object.is_container() || matches!(object.kind, NodeKind::Text(_) | NodeKind::Image(_)) { + st.contents_index + } else { + object.appearance.contents_index + }; + Some(Appearance { items, effects: st.effects.clone(), contents_index }) } /// Remove from `n` the children read from `%_` lines (`hidden`: the object itself, not its look). diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 07ffb0728..e915e5935 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -1,63 +1,121 @@ //! 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`): +//! `1 (style) XW` names, a tree of filters. A compound filter (`/CompoundFilter :`) holds the +//! filters after it until the `;` that closes it; a basic one (`/BasicFilter :`) ends at its +//! `/Part ,`: //! //! ```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 , +//! (Chain Style Filter) 0 0 /Filter , the object: its effects, its stack, its transparency //! 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) , +//! /BasicFilter : +//! (Adobe Deform) 1 0 /Filter , an effect on the whole object +//! … /Part , +//! /CompoundFilter : +//! (Stack Style Filter) 0 0 /Filter , its fills and strokes, the top one first +//! … (Stroke Style Filter) … /Part , a stroke with no effects of its own +//! /CompoundFilter : +//! (Chain Style Filter) 0 0 /Filter , a fill with effects and a transparency of its own +//! … (Adobe Offset Path) … (Fill Style Filter) … (Adobe Transform) … (Blend Style Filter) … +//! ; +//! /Part , +//! … (Conduit Filter) … the object's contents (type's characters, a group's members) +//! ; +//! /Part , +//! (Blend Style Filter) … the object's own transparency (written on its look too) +//! ; //! ``` //! -//! 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, Feather) 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`. +//! A fill's style paints with a colour (`c m y k r g b Xa`), a pattern (`(name) … [matrix] p`) or a +//! gradient (`Bb` … `flag (name) x y angle length … Bg` … `BB`); `stroke fill Xd` says whether it +//! paints at all. A blend filter's `mode opacity isolate knockout shape Xy` is its transparency. +//! +//! A style is *plain* when every filter in it is one read here: fills and strokes (in a colour; fills +//! with a pattern or gradient too), each with its own transparency and effects, and the effects of +//! [`effect`]. Such objects are read as themselves with an appearance of those fills, strokes and +//! effects; others keep their drawn look. use std::collections::HashMap; use serde_json::json; -use vectorcraft_color::Paint; +use vectorcraft_color::{BlendMode, 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; 8] = - ["Chain Style Filter", "Stack Style Filter", "Stroke Style Filter", "Fill Style Filter", "Conduit Filter", "Blend Style Filter", GLOW, FEATHER]; const GLOW: &str = "Adobe Outer Glow"; // brand-ok: the filter's name in the files const FEATHER: &str = "Adobe Feather"; // brand-ok: the filter's name in the files const SHADOW: &str = "Adobe Drop Shadow"; // brand-ok: the filter's name in the files +const DEFORM: &str = "Adobe Deform"; // brand-ok: the filter's name in the files +const OFFSET: &str = "Adobe Offset Path"; // brand-ok: the filter's name in the files +const TRANSFORM: &str = "Adobe Transform"; // brand-ok: the filter's name in the files +const GAUSSIAN: &str = "Adobe PSL Gaussian Blur"; // brand-ok: the filter's name in the files +const BAS_RELIEF: &str = "PSAdapter_plugin_BsRl"; + +const CHAIN: &str = "Chain Style Filter"; +const STACK: &str = "Stack Style Filter"; +const FILL: &str = "Fill Style Filter"; +const STROKE: &str = "Stroke Style Filter"; +const CONDUIT: &str = "Conduit Filter"; +const BLEND: &str = "Blend Style Filter"; + +/// The warp styles by their number in `DeformStyle` (Effect › Warp's list, from 1). +const WARPS: [&str; 15] = [ + "warp.arc", + "warp.arcLower", + "warp.arcUpper", + "warp.arch", + "warp.bulge", + "warp.shellLower", + "warp.shellUpper", + "warp.flag", + "warp.wave", + "warp.fish", + "warp.rise", + "warp.fisheye", + "warp.inflate", + "warp.squeeze", + "warp.twist", +]; /// 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. + /// It is one fill and one stroke at most, in solid colours or the object's own gradient, with + /// no transparency or effects of their own, and only Outer Glow, Feather and Drop Shadow on the + /// object: what any object is read with. Type takes the other plain styles too (its drawn look + /// can't be retyped); other objects keep their drawn look, which is the app's own rendering. + pub simple: bool, + /// Its fills and strokes, bottom first. pub items: Vec, - /// Its live effects, in the order they apply. + /// Each item's own visibility, transparency and effects, in the order of [`Self::items`]. + pub looks: Vec, + /// The object's live effects, in the order they apply. pub effects: Vec, + /// How many of [`Self::items`] paint below the object's contents (`None`: none do). + pub contents_index: Option, } #[derive(Clone, Debug, PartialEq)] pub(super) enum StyleItem { Fill(Paint), - /// A fill with a gradient: the object's own. - ObjectFill, + /// A fill with a gradient: the swatch `name` at `angle` degrees, fitted to the object (the + /// object's own gradient when it is the style's only one). + Gradient { + name: Option, + angle: f64, + }, + /// A fill with pattern swatch `name`, its space taken to art space by `matrix`. + Pattern { + name: String, + matrix: Option<[f64; 6]>, + }, Stroke { paint: Paint, width: f64, @@ -69,10 +127,28 @@ pub(super) enum StyleItem { }, } +/// A fill's or stroke's own state in the stack. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct ItemLook { + pub visible: bool, + pub opacity: f32, + pub blend: BlendMode, + pub effects: Vec, +} + +impl Default for ItemLook { + fn default() -> Self { + Self { visible: true, opacity: 1.0, blend: BlendMode::Normal, effects: vec![] } + } +} + #[derive(Debug)] struct Filter { name: String, visible: bool, + /// The compound filter it is in (`None`: the style's first). + parent: Option, + compound: bool, params: HashMap, /// Its dictionary's arrays (`/Array : v /Real , … ; (key) ,`), by key. arrays: HashMap>, @@ -86,6 +162,12 @@ struct Filter { /// `stroke fill Xd`. paints: (bool, bool), gradient: bool, + /// The gradient's swatch and angle (`Bg`). + gradient_of: Option<(Option, f64)>, + /// `(name) … [matrix] p`. + pattern: Option<(String, Option<[f64; 6]>)>, + /// `mode opacity … Xy`. + xy: Option<(usize, f32)>, } impl Default for Filter { @@ -93,6 +175,8 @@ impl Default for Filter { Self { name: String::new(), visible: true, + parent: None, + compound: false, params: HashMap::new(), arrays: HashMap::new(), fill: None, @@ -104,16 +188,27 @@ impl Default for Filter { dash: None, paints: (false, false), gradient: false, + gradient_of: None, + pattern: None, + xy: None, } } } +/// Most filters read in one style. +const MAX_FILTERS: usize = 4096; + /// The styles of the section `data` (between its markers), by name. pub(super) fn art_styles(data: &[u8]) -> HashMap { let mut styles = HashMap::new(); let mut name: Option = None; let mut filters: Vec = vec![]; let mut operands: Vec = vec![]; + // The compound filters not yet closed, the kind the next `/Filter` is, and whether a basic + // filter's dictionaries are being read (their `;` close those, not a compound filter). + let mut open: Vec = vec![]; + let mut pending: Option = None; + let mut in_basic = false; let mut finish = |name: Option, filters: &mut Vec| { if let Some(name) = name { styles.insert(name, style(filters)); @@ -135,9 +230,28 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { b"KnownStyle" => { finish(name.take(), &mut filters); operands.clear(); + open.clear(); + (pending, in_basic) = (None, false); } 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"CompoundFilter" => pending = Some(true), + b"BasicFilter" => pending = Some(false), + b"Part" => in_basic = false, + b"Filter" => { + let compound = pending.take().unwrap_or(false); + if filters.len() < MAX_FILTERS { + filters.push(Filter { + name: operands.iter().rev().find_map(V::text).unwrap_or_default(), + parent: open.last().copied(), + compound, + ..Filter::default() + }); + if compound { + open.push(filters.len() - 1); + } + } + in_basic = !compound; + } b"Visible" => { if let Some(f) = filters.last_mut() { f.visible = operands.iter().rev().find_map(V::num) != Some(0.0); @@ -181,6 +295,9 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { operands.clear(); continue; } + if op == ";" && !in_basic { + open.pop(); + } if let Some(f) = filters.last_mut() { paint_op(f, op, &operands); } @@ -245,7 +362,37 @@ fn paint_op(f: &mut Filter, op: &str, vals: &[V]) { f.paints = (*stroke != 0.0, *fill != 0.0); } } - "Bg" => f.gradient = true, + "Xy" => { + let n: Vec = vals.iter().filter_map(V::num).collect(); + if let [mode, opacity, ..] = n.as_slice() + && mode.is_finite() + && opacity.is_finite() + { + f.xy = Some((mode.max(0.0) as usize, opacity.clamp(0.0, 1.0) as f32)); + } + } + // `flag (name) x y angle length a b c d tx ty Bg`. + "Bg" => { + f.gradient = true; + let at = vals.iter().rposition(|v| matches!(v, V::Str(_))); + let name = at.and_then(|i| vals.get(i)).and_then(V::text); + let n: Vec = at.map_or(&[][..], |i| vals.get(i + 1..).unwrap_or_default()).iter().filter_map(V::num).collect(); + let angle = n.get(2).copied().filter(|a| a.is_finite()).unwrap_or(0.0); + f.gradient_of = Some((name, angle)); + } + "p" => { + let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None }); + let matrix = vals.iter().rev().find_map(|v| match v { + V::Arr(a) => { + let n: Vec = a.iter().filter_map(V::num).collect(); + <[f64; 6]>::try_from(n.as_slice()).ok().filter(|m| m.iter().all(|v| v.is_finite())) + } + _ => None, + }); + if let Some(name) = name { + f.pattern = Some((name, matrix)); + } + } _ => {} } } @@ -275,91 +422,237 @@ fn width_profile(arrays: &HashMap>) -> Option { (!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 == FEATHER || 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, - }), - // LiveEffect XML uses `radius`; art styles abbreviate it `rad` (public scripting posts on applyEffect). - FEATHER => effects.push(Effect { - id: "stylize.feather".into(), - params: json!({"radius": param("rad").or_else(|| param("radius")).filter(|v| v.is_finite()).unwrap_or(5.0)}), - visible: true, - }), - SHADOW => { - let mut 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, - }); - // A `csrc` of 1 is Darkness, the object's own colors with black added; any value other than 0 is read as - // Darkness. `dark` is the percent of black (0 to 100, where `opac` is a fraction), and 100 when it is missing or not finite. - if param("csrc").unwrap_or(0.0) != 0.0 { - params["shade"] = json!("darkness"); - params["darkness"] = json!(param("dark").filter(|v| v.is_finite()).map_or(100.0, |v| v.clamp(0.0, 100.0))); - } - effects.push(Effect { id: "stylize.dropShadow".into(), params, visible: true }); +/// The live effect filter `f` is, when it is one read here. +fn effect(f: &Filter) -> Option { + let param = |k: &str| f.params.get(k).copied().filter(|v| v.is_finite()); + let flag = |k: &str| param(k).is_some_and(|v| v != 0.0); + 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); + let (id, params) = match f.name.as_str() { + // `blur`'s scale isn't publicly documented: read as the Gaussian's σ, an effect's blur being 2σ. + GLOW => ( + "stylize.outerGlow", + json!({"mode": mode(2), "opacity": param("opac").unwrap_or(0.75) * 100.0, "blur": 2.0 * param("blur").unwrap_or(5.0), "color": color}), + ), + // LiveEffect XML uses `radius`; art styles abbreviate it `rad` (public scripting posts on applyEffect). + FEATHER => ("stylize.feather", json!({"radius": param("rad").or_else(|| param("radius")).unwrap_or(5.0)})), + SHADOW => { + let mut 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, + }); + // A `csrc` of 1 is Darkness, the object's own colors with black added; any value other than 0 is read as + // Darkness. `dark` is the percent of black (0 to 100, where `opac` is a fraction), and 100 when it is missing or not finite. + if param("csrc").unwrap_or(0.0) != 0.0 { + params["shade"] = json!("darkness"); + params["darkness"] = json!(param("dark").map_or(100.0, |v| v.clamp(0.0, 100.0))); } - _ => {} + ("stylize.dropShadow", params) + } + // Effect › Warp: `DeformStyle` numbers the styles from 1 as the dialog lists them, `Rotate` + // is Vertical, and the bend and distortions are fractions. + DEFORM => { + let style = param("DeformStyle").map_or(1, |v| v as i64); + let id = WARPS.get(usize::try_from(style - 1).ok()?)?; + let pct = |k: &str| (param(k).unwrap_or(0.0) * 100.0).clamp(-100.0, 100.0); + ( + *id, + json!({ + "bend": pct("DeformValue"), + "horizontal": pct("DeformHoriz"), + "vertical": pct("DeformVert"), + "orientation": if flag("Rotate") { "vertical" } else { "horizontal" }, + }), + ) + } + // `jntp`: 0 round, 1 bevel, 2 miter (the joins LiveEffect XML writes in public scripts). + OFFSET => ( + "path.offsetPath", + json!({ + "offset": param("ofst").unwrap_or(10.0).clamp(-1e5, 1e5), + "joins": match param("jntp").map(|v| v as i64) { Some(0) => "round", Some(1) => "bevel", _ => "miter" }, + "miterLimit": param("mlim").unwrap_or(4.0).clamp(1.0, 500.0), + }), + ), + // Art space has y up: a move up is a negative `moveV` in the document. + TRANSFORM => ( + "distort.transform", + json!({ + "scaleH": param("scaleH_Percent").unwrap_or(100.0), + "scaleV": param("scaleV_Percent").unwrap_or(100.0), + "moveH": param("moveH_Pts").unwrap_or(0.0), + "moveV": -param("moveV_Pts").unwrap_or(0.0), + "rotate": param("rotate_Degrees").unwrap_or(0.0), + "copies": param("numCopies").unwrap_or(0.0).clamp(0.0, 1000.0) as i64, + "reflectX": flag("reflectX"), + "reflectY": flag("reflectY"), + "reference": param("pinPoint").unwrap_or(4.0).clamp(0.0, 8.0) as i64, + "random": flag("randomize"), + }), + ), + GAUSSIAN => ("blur.gaussian", json!({"radius": param("blur").unwrap_or(5.0).clamp(0.0, 1000.0)})), + // Its settings are in a Photoshop plug-in's binary block: the effect's defaults stand in. + BAS_RELIEF => ("sketch.basRelief", json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"})), + _ => return None, + }; + Some(Effect { id: id.into(), params, visible: f.visible }) +} + +/// The fill or stroke basic filter `f` paints, `Ok(None)` when it paints nothing, `Err` when it +/// paints in a way this doesn't read. +fn paint_item(f: &Filter) -> Result, ()> { + match f.name.as_str() { + FILL if !f.paints.1 => Ok(None), + FILL => match (&f.pattern, &f.gradient_of, &f.fill) { + (Some((name, matrix)), ..) => Ok(Some(StyleItem::Pattern { name: name.clone(), matrix: *matrix })), + (None, Some((name, angle)), _) => Ok(Some(StyleItem::Gradient { name: name.clone(), angle: *angle })), + (None, None, _) if f.gradient => Ok(Some(StyleItem::Gradient { name: None, angle: 0.0 })), + (None, None, Some(p)) => Ok(Some(StyleItem::Fill(p.clone()))), + (None, None, None) => Err(()), + }, + STROKE if !f.paints.0 => Ok(None), + // 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 if f.params.get("BSDashAdj").is_some_and(|v| *v != 0.0) => Err(()), + STROKE => match &f.stroke { + Some(p) if !f.gradient && f.pattern.is_none() => Ok(Some(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), + })), + _ => Err(()), + }, + _ => Err(()), + } +} + +/// Builds an [`ArtStyle`] from a style's filter tree. +struct Builder<'a> { + filters: &'a [Filter], + plain: bool, +} + +impl Builder<'_> { + fn children(&self, parent: usize) -> Vec { + self.filters.iter().enumerate().filter(|(_, f)| f.parent == Some(parent)).map(|(i, _)| i).collect() + } + + /// The fill or stroke a stack holds as `id`: a basic one, or a chain of one with its effects + /// and transparency. + fn item(&mut self, id: usize) -> Option<(StyleItem, ItemLook)> { + let filters = self.filters; + let f = &filters[id]; + if !f.compound { + return match paint_item(f) { + Ok(item) => item.map(|i| (i, ItemLook { visible: f.visible, ..ItemLook::default() })), + Err(()) => { + self.plain = false; + None + } + }; + } + if f.name != CHAIN { + self.plain = false; + return None; + } + let mut look = ItemLook { visible: f.visible, ..ItemLook::default() }; + let mut painted: Option = None; + let mut paints = 0; + for k in self.children(id) { + let c = &filters[k]; + match c.name.as_str() { + _ if c.compound => self.plain = false, + FILL | STROKE => { + paints += 1; + look.visible &= c.visible; + match paint_item(c) { + Ok(item) => painted = item, + Err(()) => self.plain = false, + } + } + BLEND => { + if let Some((mode, opacity)) = c.xy { + (look.blend, look.opacity) = (super::read::blend_mode(mode), opacity); + } + } + _ => match effect(c) { + Some(e) => look.effects.push(e), + None => self.plain = false, + }, + } + } + if paints != 1 { + self.plain = false; + } + painted.map(|p| (p, look)) + } + + /// A stack's fills and strokes, bottom first, and how many of them paint below its contents. + fn stack(&mut self, id: usize, st: &mut ArtStyle) { + // Listed top first. + let mut top_first = vec![]; + let mut contents_at = None; + for k in self.children(id) { + if self.filters[k].name == CONDUIT { + contents_at = Some(top_first.len()); + continue; + } + if let Some(item) = self.item(k) { + top_first.push(item); + } + } + let below = contents_at.map(|at| top_first.len() - at); + for (item, look) in top_first.into_iter().rev() { + st.items.push(item); + st.looks.push(look); + } + st.contents_index = below.filter(|n| *n > 0); + } +} + +fn style(filters: &[Filter]) -> ArtStyle { + let mut st = ArtStyle { plain: true, ..ArtStyle::default() }; + let Some(root) = filters.iter().position(|f| f.parent.is_none()) else { return st }; + let mut b = Builder { filters, plain: true }; + let top = if filters[root].compound { b.children(root) } else { vec![root] }; + for id in top { + let f = &filters[id]; + match f.name.as_str() { + STACK if f.compound => b.stack(id, &mut st), + FILL | STROKE | CHAIN => { + if let Some((item, look)) = b.item(id) { + st.items.push(item); + st.looks.push(look); + } + } + CONDUIT => {} + // The object's own transparency, which its look carries too. + BLEND => {} + _ => match effect(f) { + Some(e) => st.effects.push(e), + None => b.plain = false, + }, } } - ArtStyle { plain, items, effects } + st.plain = b.plain; + let count = |stroke: bool| st.items.iter().filter(|i| matches!(i, StyleItem::Stroke { .. }) == stroke).count(); + st.simple = st.plain + && count(true) <= 1 + && count(false) <= 1 + && st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Stroke { .. } | StyleItem::Gradient { .. })) + && st.looks.iter().all(|l| l.visible && l.opacity >= 1.0 && l.blend == BlendMode::Normal && l.effects.is_empty()) + && st.effects.iter().all(|e| matches!(e.id.as_str(), "stylize.outerGlow" | "stylize.feather" | "stylize.dropShadow")); + st } #[cfg(test)] @@ -483,4 +776,120 @@ mod tests { ]); 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)])); } + /// A basic filter `name` whose dictionary holds `body`. + fn basic(name: &str, body: &str) -> String { + format!("/BasicFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{body}; /Dict ;\n /Part ,\n") + } + + /// A compound filter `name` holding `kids`. + fn compound(name: &str, kids: &[String]) -> String { + format!("/CompoundFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n{};\n /Part ,\n", kids.concat()) + } + + fn fill(paint: &str) -> String { + basic( + "Fill Style Filter", + &format!("0 /Bool (UseEvenOdd) ,\n/FillStyle : 0 O\n{paint}\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n0 1 Xd\n/Def ;\n (FillStyle) ,\n"), + ) + } + + fn blend(mode: u8, opacity: f64) -> String { + basic("Blend Style Filter", &format!("/BlendStyle : {mode} {opacity} 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n/Def ;\n (BlendStyle) ,\n")) + } + + /// A comic-lettering style: a warp over a stack of a textured fill, a gradient, a white rim + /// repeated by Transform, and the characters at the bottom. + fn lettering() -> String { + let stack = compound( + "Stack Style Filter", + &[ + basic("Stroke Style Filter", "/StrokeStyle : 0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n0 0 Xd\n/Def ;\n (StrokeStyle) ,\n"), + compound( + "Chain Style Filter", + &[fill("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p"), basic("PSAdapter_plugin_BsRl", "300 /Int (PrevDres) ,\n"), blend(5, 0.48)], + ), + fill("Bb\n0 0 0 0 Bh\n2 (Red ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB"), + compound( + "Chain Style Filter", + &[ + basic("Adobe Offset Path", "3.5 /Real (ofst) ,\n0 /Int (jntp) ,\n4 /Real (mlim) ,\n"), + fill("0 0 0 0 1 1 1 Xa"), + basic( + "Adobe Transform", + "6 /Int (numCopies) ,\n0.75 /Real (moveH_Pts) ,\n-0.25 /Real (moveV_Pts) ,\n100 /Real (scaleH_Percent) ,\n100 /Real (scaleV_Percent) ,\n4 /Int (pinPoint) ,\n", + ), + ], + ), + basic("Conduit Filter", ""), + ], + ); + let root = compound( + "Chain Style Filter", + &[ + basic( + "Adobe Deform", + "0 /Bool (Rotate) ,\n0.02 /Real (DeformVert) ,\n4 /Int (DeformStyle) ,\n0.05 /Real (DeformValue) ,\n0 /Real (DeformHoriz) ,\n", + ), + stack, + blend(0, 1.0), + ], + ); + format!("/KnownStyle :\n(Lettering) /Name ,\n/ActiveStyle :\n{root}\n/Execution ;\n /Def ;\n") + } + + #[test] + fn a_stack_of_fills_with_effects_of_their_own_is_read_as_itself() { + // GUARDS: more than one fill, or an effect on a fill, made the style not plain, and type + // with it came in as its drawn look (outlines) instead of type. + let styles = art_styles(lettering().as_bytes()); + let st = &styles["Lettering"]; + assert!(st.plain, "{st:?}"); + // Bottom first: the rim, the gradient, the textured fill; the stroke paints nothing. + let [StyleItem::Fill(rim), StyleItem::Gradient { name, angle }, StyleItem::Pattern { name: pattern, .. }] = st.items.as_slice() else { + panic!("{:?}", st.items) + }; + assert!(matches!(rim, Paint::Solid { .. }), "{rim:?}"); + assert_eq!((name.as_deref(), *angle, pattern.as_str()), (Some("Red ramp"), 90.0, "Dots")); + let ids = |l: &ItemLook| l.effects.iter().map(|e| e.id.clone()).collect::>(); + assert_eq!(ids(&st.looks[0]), ["path.offsetPath", "distort.transform"]); + assert!(ids(&st.looks[1]).is_empty()); + assert_eq!(ids(&st.looks[2]), ["sketch.basRelief"]); + assert_eq!((st.looks[2].blend, st.looks[2].opacity), (BlendMode::HardLight, 0.48)); + let transform = &st.looks[0].effects[1].params; + assert_eq!((&transform["copies"], &transform["moveH"], &transform["moveV"]), (&json!(6), &json!(0.75), &json!(0.25))); + assert_eq!(st.looks[0].effects[0].params["joins"], json!("round")); + // The warp is the whole object's; the characters paint below every item. + let [warp] = st.effects.as_slice() else { panic!("{:?}", st.effects) }; + assert_eq!((warp.id.as_str(), &warp.params["bend"], &warp.params["orientation"]), ("warp.arch", &json!(5.0), &json!("horizontal"))); + assert_eq!(st.contents_index, None); + } + + #[test] + fn contents_above_some_items_count_them() { + let stack = compound("Stack Style Filter", &[fill("0 0 0 0 1 1 1 Xa"), basic("Conduit Filter", ""), fill("0 0 0 1 0 0 0 Xa")]); + let data = format!("/KnownStyle :\n(Over) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[stack])); + let st = &art_styles(data.as_bytes())["Over"]; + assert!(st.plain, "{st:?}"); + assert_eq!((st.items.len(), st.contents_index), (2, Some(1))); + } + + #[test] + fn an_effect_not_read_here_keeps_the_drawn_look() { + let item = compound("Chain Style Filter", &[fill("0 0 0 1 0 0 0 Xa"), basic("Adobe Scribble Fill", "")]); + let data = + format!("/KnownStyle :\n(Odd) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[compound("Stack Style Filter", &[item])])); + assert!(!art_styles(data.as_bytes())["Odd"].plain); + } + + #[test] + fn a_hidden_fill_comes_in_hidden() { + let hidden = fill("0 0 0 1 0 0 0 Xa").replacen("1 /Visible", "0 /Visible", 1); + let data = format!( + "/KnownStyle :\n(Hidden) /Name ,\n/ActiveStyle :\n{}", + compound("Chain Style Filter", &[compound("Stack Style Filter", &[fill("0 0 0 0 1 1 1 Xa"), hidden])]) + ); + let st = &art_styles(data.as_bytes())["Hidden"]; + assert!(st.plain, "{st:?}"); + assert_eq!(st.looks.iter().map(|l| l.visible).collect::>(), [false, true]); + } } diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 0a1b674ad..03a588aa7 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -362,7 +362,12 @@ impl Texts { (rest.is_empty() && !chunks.is_empty() && chunks.len() <= MAX_SEGMENTS).then(|| chunks.to_vec()) }; match carries.get("0").and_then(Val::num).unwrap_or(0.0) as i64 { - 0 => Some(FrameKind::Point(carries.get("2")?.nums()?.try_into().ok()?)), + // Point type that is neither turned nor scaled has no matrix (Illustrator 24 leaves it out): + // its layout is already where it goes on the canvas. + 0 => Some(FrameKind::Point(match carries.get("2") { + None => [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + Some(m) => m.nums()?.try_into().ok()?, + })), 1 => Some(FrameKind::Area(segments()?)), 2 => { let range = carries.get("6").and_then(Val::nums).unwrap_or_default(); diff --git a/crates/eps/src/import/mod.rs b/crates/eps/src/import/mod.rs index dd7ffd05c..5a7a9b491 100644 --- a/crates/eps/src/import/mod.rs +++ b/crates/eps/src/import/mod.rs @@ -233,6 +233,12 @@ pub fn page(bytes: &[u8]) -> Result { /// [`import`], an Illustrator EPS through the editing copy of its art only when `editing_data` /// (else what its page prints, as any EPS). pub fn import_with(bytes: &[u8], editing_data: bool) -> Result { + import_trusting(bytes, editing_data, false) +} + +/// [`import_with`]; `trust`: an Illustrator EPS's layers are used even where they draw differently +/// from its page, with a note (`editingData: "always"`, as for a `.ai`). +pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result { let ps = program(bytes)?; let dsc = Dsc::read(ps); // A legacy `.ai` VectorCraft wrote: its prolog prints the art plainly (gradients flat, no @@ -260,7 +266,7 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result } let drew = !out.drawn.is_empty(); let why = match (&result, drew) { - (Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out))), + (Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out), trust)), (Ok(()), true) => return Ok(finish(out)), (Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(), (Err(e), _) => reason(e, fault.as_ref()), diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 0d2b9e111..a5626e463 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -234,6 +234,8 @@ fn fill_slots( let frame = ai::slot_frame(n.name.as_deref()); let state = (n.opacity, n.blend, n.isolate, n.knockout); let transparent = see_through(n); + // An art style read onto the slot (fills, strokes and effects round the characters) is the type's. + let styled = (!n.appearance.items.is_empty() || !n.appearance.effects.is_empty()).then(|| n.appearance.clone()); let replacement = match content.next() { Some(Content::Page(parts)) if !parts.is_empty() => { // The page's type (and the strokes round it) take the slot's place as they are. @@ -286,6 +288,11 @@ fn fill_slots( made.push((story, frame, text.id)); } text.visible = visible; + // Type with an art style is what the page draws as the style's look: it is seen. + let seen = seen || styled.is_some(); + if let Some(appearance) = styled { + text.appearance = appearance; + } // Type the page shows is kept; other made type is weighed against the page. text.name = if seen { name.cloned() } else { Some(format!("{MADE}{}", name.map_or("", String::as_str))) }; let extra: Vec> = extra.iter().map(|p| reid(doc, p)).collect(); @@ -833,6 +840,10 @@ struct PageView { page: vectorcraft_render::Rendered, /// Where the page's own type is, in its pixels. page_type: Vec, + /// Where objects read with their art style lie, in its pixels: the page has Illustrator's + /// rendering of the style there, the layers VectorCraft's live effects, and the two aren't + /// held to look alike. + restyled: Vec, layers: vectorcraft_render::Renderer, scale: f64, samples: u32, @@ -848,7 +859,18 @@ impl PageView { let page = comparison_render(&mut vectorcraft_render::Renderer::new(), frame.page, ra, scale, samples); let mut page_type = vec![]; type_areas(&frame.page.layers, ra, scale, &mut page_type); - Self { page, page_type, layers: vectorcraft_render::Renderer::new(), scale, samples } + Self { page, page_type, restyled: vec![], layers: vectorcraft_render::Renderer::new(), scale, samples } + } + + /// Leave the drawn looks in `looks` (on `region`, the page's area in the layers' document) out + /// of the comparisons ([`Structure::restyled`](ai::Structure)). + fn restyled(&mut self, looks: &[Rect], region: Rect) { + let s = self.scale; + self.restyled = looks + .iter() + .map(|r| r.inflate(OUTLINE_REACH, OUTLINE_REACH)) + .map(|r| Rect::new((r.x0 - region.x0) * s, (r.y0 - region.y0) * s, (r.x1 - region.x0) * s, (r.y1 - region.y0) * s)) + .collect(); } /// How `b` on `rb` (a rectangle the page's size) draws differently from the page ([`compared`]). @@ -859,7 +881,7 @@ impl PageView { let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples); let mut areas = self.page_type.clone(); type_areas(&b.layers, rb, self.scale, &mut areas); - compared(&self.page, &y, &areas) + compared(&self.page, &y, &areas, &self.restyled) } /// The share (0 to 1) of the pixels where `b` on `rb` draws another colour than the page @@ -890,19 +912,22 @@ fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> Difference { let mut areas = vec![]; type_areas(&a.layers, ra, scale, &mut areas); type_areas(&b.layers, rb, scale, &mut areas); - compared(&x, &y, &areas) + compared(&x, &y, &areas, &[]) } /// How two drawings of the same size differ, each drawn on nothing and seen on white, away from -/// the type in `areas` (pixels). Pixels are compared by lightness, and by whether they are inked +/// the type in `areas` (pixels), pixels in `skip` counting as alike. Pixels are compared by lightness, and by whether they are inked /// at all ([`differ`]): the page of an RGB document is written in the colours' CMYK equivalents, /// and what the comparison looks for is art that is missing or out of place. -fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect]) -> Difference { +fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect], skip: &[Rect]) -> Difference { if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height { return Difference { any: 1.0, art: 1.0, solid: 1.0 }; } - let differs: Vec = x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).map(|(p, q)| differ(p, q)).collect(); let (w, h) = (x.width as usize, x.height as usize); + // Pixels in `skip` count as alike. + let skipped = in_areas(skip, w, h); + let differs: Vec = + x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).zip(&skipped).map(|((p, q), s)| !*s && differ(p, q)).collect(); let at = |i: usize, j: usize| j.checked_mul(w).and_then(|row| row.checked_add(i)).and_then(|k| differs.get(k)).copied().unwrap_or(false); let typed = in_areas(areas, w, h); let art = differs.iter().zip(&typed).filter(|(d, t)| **d && !**t).count(); @@ -1002,7 +1027,8 @@ struct Frame<'a> { fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool, trust: bool, unit: f64) -> Result<(Document, Vec), String> { // An EPS (its page is its art) draws a Large Canvas page at full size; a `.ai` keeps its art // space, as its PDF part may (#2031). - let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, warnings } = ai::read(data, matches!(page, Page::Art))?; + let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, restyled, warnings } = + ai::read(data, matches!(page, Page::Art))?; // Editing data in the units of the page's user space ([`in_units`]) is compared with the page // scaled down to those units, and the document read from it is scaled up by `unit`. let board = artboard.map(|a| to_doc.transform_rect_bbox(a)); @@ -1153,6 +1179,7 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool, tr match frame { Some(frame) => { let mut view = PageView::new(&frame); + view.restyled(&restyled, frame.rect); let unseen = prune_unseen(&frame, &mut view, &mut doc); if unseen > 0 { notes.push(format!("{unseen} {UNSEEN_TEXT}")); @@ -1327,9 +1354,9 @@ fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result /// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the /// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it /// has one that can't be used, `visible` with a warning that says why. -pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported { +pub(super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Imported { let Some(data) = editing_copy(ps) else { return visible }; - let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false, false, 1.0)); + let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false, trust, 1.0)); match result { Ok((document, mut warnings)) => { for w in &visible.warnings { @@ -1615,7 +1642,7 @@ mod tests { let mut renderer = vectorcraft_render::Renderer::new(); let a = comparison_render(&mut renderer, &page, rect, 0.4, 1); let b = comparison_render(&mut vectorcraft_render::Renderer::new(), &vector, rect, 0.4, 1); - assert!(compared(&a, &b, &[]).solid > MAX_SOLID_DIFFERENCE, "the old point sampling falsely refused this page"); + assert!(compared(&a, &b, &[], &[]).solid > MAX_SOLID_DIFFERENCE, "the old point sampling falsely refused this page"); let frame = Frame { page: &page, page_rect: rect, rect, whole: false }; let mut view = PageView::new(&frame); assert!(compare(&frame, &mut view, &vector).is_ok()); diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 0e74cffa7..3fe8bc9c9 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -1352,3 +1352,81 @@ fn envelope_options_keep_the_source_text_hidden_under_its_outlined_appearance() assert_eq!(texts, [("CHONKYCATS".to_string(), false)], "{:?}", r.warnings); assert!(r.warnings.is_empty(), "{:?}", r.warnings); } + +/// An art style section with style `Comic`: a white fill offset and repeated by Transform under a +/// red fill with its own blend mode, the characters at the bottom. +fn comic_style() -> String { + let basic = |name: &str, body: &str| { + format!("/BasicFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{body}; /Dict ;\n /Part ,\n") + }; + let fill = |paint: &str| basic("Fill Style Filter", &format!("/FillStyle : 0 O\n{paint}\n0 1 0 0 0 Xy\n0 1 Xd\n/Def ;\n (FillStyle) ,\n")); + let compound = |name: &str, kids: &[String]| format!("/CompoundFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n{};\n /Part ,\n", kids.concat()); + let rim = compound( + "Chain Style Filter", + &[ + basic("Adobe Offset Path", "3 /Real (ofst) ,\n0 /Int (jntp) ,\n"), + fill("0 0 0 0 1 1 1 Xa"), + basic("Adobe Transform", "6 /Int (numCopies) ,\n1 /Real (moveH_Pts) ,\n"), + ], + ); + let red = compound( + "Chain Style Filter", + &[fill("0 1 1 0 1 0 0 Xa"), basic("Blend Style Filter", "/BlendStyle : 1 0.5 0 0 0 Xy\n/Def ;\n (BlendStyle) ,\n")], + ); + let stack = compound("Stack Style Filter", &[red, rim, basic("Conduit Filter", "")]); + format!("%AI9_BeginArtStyles\n/KnownStyle :\n(Comic) /Name ,\n/ActiveStyle :\n{}%AI9_EndArtStyles\n", compound("Chain Style Filter", &[stack])) +} + +#[test] +fn type_with_a_stacked_art_style_comes_in_as_type_with_that_appearance() { + // GUARDS: a style of more than one fill (or with effects on a fill) left the type as its drawn look, + // outlines that can't be retyped (comic lettering made with Illustrator's Appearance panel). + use vectorcraft_color::BlendMode; + use vectorcraft_doc::AppearanceItem; + let doc = text_document(&[("Bang\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + // The look: the style's drawing round where the type lies (its anchor is at (58.5, 61.5)). + let drawing = "0 0 1 0 k\n54 56 m\n100 56 L\n100 80 L\n54 80 L\nf"; + let look = format!("u\n/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n{drawing}\nU\n9 () XW\n"); + let own: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect(); + let art = native_with_text(&(comic_style() + &layer("Art", true, &format!("{look}{own}1 (Comic) XW\n"))), &doc); + let check = |d: &Document, notes: &[String]| { + let mut texts = vec![]; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + texts.push((t.plain_text(), n.appearance.clone())); + } + }); + let [(text, a)] = texts.as_slice() else { panic!("{texts:?} {notes:?}") }; + assert_eq!(text, "Bang"); + // Bottom first: the white rim with its effects, then the red fill at half opacity, multiplied. + let [AppearanceItem::Fill(rim), AppearanceItem::Fill(red)] = a.items.as_slice() else { panic!("{a:?}") }; + assert_eq!(rim.effects.iter().map(|e| e.id.as_str()).collect::>(), ["path.offsetPath", "distort.transform"]); + assert_eq!((red.blend, red.opacity), (BlendMode::Multiply, 0.5)); + assert!(!notes.iter().any(|w| w.contains("drawn look")), "{notes:?}"); + }; + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + check(&d, ¬es); + // From an EPS whose page draws Illustrator's rendering of the style (here, its look's rectangle): + // the layers are used all the same, the style's area not being held to look like the page. + let page = "0 0 1 0 setcmykcolor 54 56 moveto 100 56 lineto 100 80 lineto 54 80 lineto closepath fill"; + let r = import(&eps(page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + check(&r.document, &r.warnings); +} + +#[test] +fn point_type_without_a_matrix_is_placed_by_its_layout() { + // GUARDS: Illustrator 24 leaves an unturned, unscaled point type's matrix out of its frame; such + // stories weren't read, and the type was left out. + let with = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let without = with.replace("/2 << /2 [ 1 0 0 1 0 0 ] >>", "/2 << >>"); + assert_ne!(with, without); + let place = |doc: &str| { + let art = native_with_text(&layer("Spare", false, &text_object(0)), doc); + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + let texts = text_of(&d.layers[0]); + assert_eq!(texts.len(), 1, "{notes:?}"); + (texts[0].plain_text(), texts[0].xf) + }; + assert_eq!(place(&without), place(&with)); +} diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index ee71b17dd..59926fe18 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -37,7 +37,7 @@ mod ps; mod scene; mod tiff; -pub use import::{Imported, POSTSCRIPT_ERRORS, ai_alone, family_style, import, import_with, is_loss, layered_ai, page}; +pub use import::{Imported, POSTSCRIPT_ERRORS, ai_alone, family_style, import, import_trusting, import_with, is_loss, layered_ai, page}; pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai}; pub use print::{PrintJob, PrintPage, print}; diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index 45c233e7e..b6458de60 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -117,6 +117,32 @@ impl Look { } } +/// A gap between adjacent glyphs on a line. +#[derive(Clone, Copy, Debug)] +struct Gap { + /// Byte offset in the line's text of the glyph after the gap. + at: usize, + /// From the end of the glyph before to the start of the next (points). + width: f64, + /// The larger of the two glyphs' sizes (points). + em: f64, + /// Along the line, not turned along a curve. + on_line: bool, +} + +/// The spacing a line's letters share (letter-spaced type), in ems: the gap a third of the way up +/// from the narrowest, of three or more along the line. Never below zero, and at most half an em: +/// glyphs set wider apart than that are words or columns, not tracked letters. +fn common_gap(gaps: &[Gap]) -> f64 { + let mut widths: Vec = gaps.iter().filter(|g| g.on_line).map(|g| g.width / g.em).filter(|w| w.is_finite()).collect(); + if widths.len() < 3 { + return 0.0; + } + let pivot = widths.len() / 3; + let (_, common, _) = widths.select_nth_unstable_by(pivot, f64::total_cmp); + common.clamp(0.0, 0.5) +} + /// A line of type being gathered: runs of glyphs on one baseline. pub(crate) struct TextLine { /// The first glyph's placement. @@ -140,10 +166,13 @@ pub(crate) struct TextLine { /// Each glyph's ink box as drawn (document space), in drawing order; glyphs without ink are /// left out. pub ink: Vec, - /// The spaces read from gaps between glyphs, and the spaces the file draws between two glyphs - /// that aren't spaces: their byte offset in the text, and the gap from the end of the glyph - /// before to the start of the next (points). - gaps: Vec<(usize, f64)>, + /// The gaps between adjacent glyphs that may read as spaces ([`finish`](Self::finish) + /// decides). + gaps: Vec, + /// The spaces the file draws between two glyphs that aren't spaces: their byte offset in the + /// text before the spaces read from gaps go in, and the gaps on both sides of it plus its + /// advance (points). + drawn_spaces: Vec<(usize, f64)>, /// A space the file drew after a glyph that isn't one, until the next glyph comes: its byte /// offset, and the gap before it plus its advance (points). drawn_space: Option<(usize, f64)>, @@ -184,6 +213,7 @@ impl TextLine { column: false, ink: vec![], gaps: vec![], + drawn_spaces: vec![], drawn_space: None, advances: vec![], held: vec![], @@ -266,12 +296,8 @@ impl TextLine { if !on_line && !on_curve { return false; } - // A gap wider than a fifth of an em reads as a space. - if gap > size * 0.2 && !run.ends_with(' ') && !text.starts_with(' ') { - run.push(' '); - if on_line { - self.gaps.push((bytes, gap)); - } + if !run.ends_with(' ') && !text.starts_with(' ') { + self.gaps.push(Gap { at: bytes, width: gap, em: size, on_line }); } // A space the file draws between two glyphs spans the gaps on both sides of it. let drawn = self.drawn_space.take(); @@ -279,7 +305,7 @@ impl TextLine { if text == " " && !run.ends_with(' ') { self.drawn_space = Some((bytes, gap + advance)); } else if let Some((b, before)) = drawn.filter(|_| !text.starts_with(' ')) { - self.gaps.push((b, before + gap)); + self.drawn_spaces.push((b, before + gap)); } } if last.takes(look) { @@ -358,6 +384,35 @@ impl TextLine { let first = runs.next()?; let mut t = TextObject::point(Point::ORIGIN, &first.text, first.style); t.runs.extend(runs); + // A gap wider than a fifth of an em, past the spacing the line's letters share, reads as a + // space. Letter spacing is read only in text left in the file's order. + let common = if t.para.direction.is_none() { common_gap(&self.gaps) } else { 0.0 }; + let mut spaces = vec![]; + for gap in &self.gaps { + // A word space set at exactly a fifth of an em still counts, whatever the rounding. + if gap.width / gap.em - common <= 0.199 { + continue; + } + // After the earlier spaces, at the end of the run before it (the run it follows). + let at = gap.at + spaces.len(); + let mut offset = 0; + let Some(run) = t.runs.iter_mut().find(|run| { + offset += run.text.len(); + at <= offset + }) else { + break; + }; + let at = at - (offset - run.text.len()); + if !run.text.is_char_boundary(at) { + continue; + } + run.text.insert(at, ' '); + spaces.push((gap.at, gap.width, gap.on_line)); + } + // Each by its offset in the text with the spaces in. + let mut gaps: Vec<(usize, f64)> = spaces.iter().enumerate().filter(|(_, s)| s.2).map(|(i, s)| (s.0 + i, s.1)).collect(); + gaps.extend(self.drawn_spaces.iter().map(|&(b, width)| (b + spaces.iter().filter(|s| s.0 < b).count(), width))); + gaps.sort_by_key(|&(b, _)| b); if self.upright.is_none() { to_logical(&mut t); } @@ -420,8 +475,7 @@ impl TextLine { // The spaces read from gaps or drawn between glyphs keep their own place only in text // left in the file's order. let total: usize = t.runs.iter().map(|r| r.text.len()).sum(); - let gaps: Vec<(usize, f64)> = - if t.para.direction.is_none() { self.gaps.into_iter().filter(|(b, _)| *b < total).collect() } else { vec![] }; + let gaps: Vec<(usize, f64)> = if t.para.direction.is_none() { gaps.into_iter().filter(|(b, _)| *b < total).collect() } else { vec![] }; let (tracking, spaces) = spacing(&laid, &gaps, length - natural, chars, size); t.runs = tracked(std::mem::take(&mut t.runs), tracking, &spaces); // Tracked: laid out again for its bounds. diff --git a/crates/pdf/src/tests_import_fidelity.rs b/crates/pdf/src/tests_import_fidelity.rs index 446e84ee8..248f8c78a 100644 --- a/crates/pdf/src/tests_import_fidelity.rs +++ b/crates/pdf/src/tests_import_fidelity.rs @@ -309,6 +309,19 @@ fn text_becomes_point_type() { assert!(all(&r.document).iter().any(|n| n.name.as_deref() == Some(""))); } +#[test] +fn character_spacing_does_not_become_spaces() { + for size in [12.0, 13.0, 14.0, 15.0] { + for tracking in [150.0, 180.0, 200.0, 220.0, 250.0] { + let spacing = size * tracking / 1000.0; + let content = format!("BT /F1 {size} Tf {spacing} Tc 10 40 Td (OPEN DAILY) Tj ET"); + let t = texts(&open(&one_page(&content, HELVETICA, &[]))); + assert_eq!(t.len(), 1, "size {size}, tracking {tracking}: {t:?}"); + assert_eq!(t[0].plain_text(), "OPEN DAILY", "size {size}, tracking {tracking}"); + } + } +} + /// Invisible text (rendering mode 3: a scanned page's recognized text, laid over the scan) is /// type with neither fill nor stroke, and the visible text keeps its paint. On a line with /// stroked text it is a run of its own, not stroked. As outlines it would draw nothing: it is left diff --git a/crates/pdf/src/tests_live_text.rs b/crates/pdf/src/tests_live_text.rs index b61dcb847..ba48de415 100644 --- a/crates/pdf/src/tests_live_text.rs +++ b/crates/pdf/src/tests_live_text.rs @@ -101,6 +101,43 @@ fn letter_spacing_in_the_pdf_becomes_tracking() { assert!((ink_width(&t[0]) - expected).abs() < 0.3, "{} vs {expected}", ink_width(&t[0])); } +#[test] +fn common_tracking_does_not_turn_gaps_into_spaces() { + let font = vectorcraft_text::FontDb::global().find_postscript("SourceSans3-Regular").unwrap(); + for size in [12.0, 13.0, 14.0, 15.0] { + for tracking in [150.0, 180.0, 200.0, 220.0, 250.0] { + let mut x = 20.0; + let mut pieces = vec![]; + for (word_index, word) in ["OPEN", "DAILY", "TYPE"].iter().enumerate() { + for character in word.chars() { + pieces.push((x, character.to_string())); + x += font.advance(font.glyph_for(character)) as f32 / font.units_per_em() as f32 * size + size * tracking / 1000.0; + } + if word_index < 2 { + x += font.advance(font.glyph_for(' ')) as f32 / font.units_per_em() as f32 * size + size * tracking / 1000.0; + } + } + let lines: Vec<_> = pieces.iter().map(|(x, text)| (*x, 60.0, size, text.as_str())).collect(); + let d = import_with_report(&text_pdf(&lines, false), &ImportOptions::default()).unwrap().document; + let t = texts(&d); + assert_eq!(t.len(), 1, "size {size}, tracking {tracking}: {t:?}"); + assert_eq!(t[0].plain_text(), "OPEN DAILY TYPE", "size {size}, tracking {tracking}"); + assert!((t[0].first_style().tracking - f64::from(tracking)).abs() <= 10.0, "size {size}, tracking {tracking}: {:?}", t[0].runs); + } + } +} + +/// Glyphs set an em or more apart, each on its own, are words or columns: every gap stays a space, +/// however evenly they are spaced. +#[test] +fn evenly_wide_gaps_stay_spaces() { + let lines: Vec<_> = ["1", "2", "3", "4"].iter().enumerate().map(|(i, text)| (20.0 + i as f32 * 18.0, 60.0, 12.0, *text)).collect(); + let d = import_with_report(&text_pdf(&lines, false), &ImportOptions::default()).unwrap().document; + let t = texts(&d); + assert_eq!(t.len(), 1, "{t:?}"); + assert_eq!(t[0].plain_text(), "1 2 3 4"); +} + /// Embedded font data is untrusted: anything that isn't a CID-keyed CFF is no table, never a panic. #[test] fn cid_tables_ignore_fonts_that_are_not_cid_keyed_cff() { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a52f8909e..56635cdae 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (PDF invisible text, as a scanned page's recognized text is, opens as type with no fill or stroke and is written back as invisible text, #2675; DXF circular cubic paths export as native CIRCLE/ARC where stroke semantics permit (R13–2018 for weighted strokes; R12 weighted strokes remain POLYLINE) (#1299); non-circular curves stay SPLINE; scores and hours unchanged; an Illustrator 3 file without layers, as image editors export their paths, opens with its art on one layer, storytold/photocraft#6036; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; batch exporting, #1935; legacy `.ai` runs as PostScript with a prolog of its own, #868; legacy `.ai` of version 7 saved, with type as text, hidden objects and an artboard picker, and its type read, #868; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: SVG `:not()` selectors, #1557, stacked selectors, #1703, linked files, #1870; DXF VPORT, #1789; nesting caps, #1852; hostile PDF, `.ai`, EPS, Affinity and native data, #1862, #1863, #1871–#1873; failed saves, #1685, #1693, #1694; type in symbols after reopening, #1738; scores unchanged; verified PngSuite downloads, #1729; EPS stitched shadings, #1653; oversized images in WMF/EMF refused from the header, #1776; PDF name trees with shared nodes, #1781; native size limits for files read from memory, #1783; private temporary files on Unix, #1778; ruzstd 0.9.1, #1807; scores unchanged; SVG import: CSS transforms and the `font` shorthand, #1565, percentage lengths in symbols, #1596, Inkscape flowed text and pages, #1568, #1601, filter warnings, #1598; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment in JPEG, TIFF and PSD, #1584; saves to folders that refuse the temporary file, #1620 in part; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; compact native saves for documents with a lot of path data, #1492; PDF and EPS paste as vector art, #1499; `.ai` gradient stops listed upwards, #1530; SVG text on basic shapes, #1527; PDF soft masks drawn as images, #1551; Type 42 fonts in EPS, #1553; clips that only seem to cover the page, #1542; images too large to decode are reported, #1540; a `.ai` page's art past its artboard no longer lies over the page before it when read as its PDF part; grayscale TIFFs with an alpha channel or extra channels place and open, #1466 in part; a faded or masked fill keeps its stroke apart on PDF and `.ai` import, #1434; SVG knockout groups without masks, #1435; Affinity import: a text frame too short for its first line is made taller, #1423; `.ai` editing data: a compound path takes its last path's paint and keeps paths grouped inside it, #1410; 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; `editingData: "always"` opens a `.ai` from its 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; `.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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (EPS and `.ai` editing data: type keeps stacked art styles as a live appearance; point type frames without a matrix read; `editingData: "always"` for EPS; PDF invisible text, as a scanned page's recognized text is, opens as type with no fill or stroke and is written back as invisible text, #2675; DXF circular cubic paths export as native CIRCLE/ARC where stroke semantics permit (R13–2018 for weighted strokes; R12 weighted strokes remain POLYLINE) (#1299); non-circular curves stay SPLINE; scores and hours unchanged; an Illustrator 3 file without layers, as image editors export their paths, opens with its art on one layer, storytold/photocraft#6036; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; batch exporting, #1935; legacy `.ai` runs as PostScript with a prolog of its own, #868; legacy `.ai` of version 7 saved, with type as text, hidden objects and an artboard picker, and its type read, #868; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: SVG `:not()` selectors, #1557, stacked selectors, #1703, linked files, #1870; DXF VPORT, #1789; nesting caps, #1852; hostile PDF, `.ai`, EPS, Affinity and native data, #1862, #1863, #1871–#1873; failed saves, #1685, #1693, #1694; type in symbols after reopening, #1738; scores unchanged; verified PngSuite downloads, #1729; EPS stitched shadings, #1653; oversized images in WMF/EMF refused from the header, #1776; PDF name trees with shared nodes, #1781; native size limits for files read from memory, #1783; private temporary files on Unix, #1778; ruzstd 0.9.1, #1807; scores unchanged; SVG import: CSS transforms and the `font` shorthand, #1565, percentage lengths in symbols, #1596, Inkscape flowed text and pages, #1568, #1601, filter warnings, #1598; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment in JPEG, TIFF and PSD, #1584; saves to folders that refuse the temporary file, #1620 in part; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; compact native saves for documents with a lot of path data, #1492; PDF and EPS paste as vector art, #1499; `.ai` gradient stops listed upwards, #1530; SVG text on basic shapes, #1527; PDF soft masks drawn as images, #1551; Type 42 fonts in EPS, #1553; clips that only seem to cover the page, #1542; images too large to decode are reported, #1540; a `.ai` page's art past its artboard no longer lies over the page before it when read as its PDF part; grayscale TIFFs with an alpha channel or extra channels place and open, #1466 in part; a faded or masked fill keeps its stroke apart on PDF and `.ai` import, #1434; SVG knockout groups without masks, #1435; Affinity import: a text frame too short for its first line is made taller, #1423; `.ai` editing data: a compound path takes its last path's paint and keeps paths grouped inside it, #1410; 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; `editingData: "always"` opens a `.ai` from its 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; `.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 @@ -84,7 +84,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 (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), 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 where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), 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. `editingData: "always"` (`convert --editing-data`) opens a `.ai` from its editing data even where it draws differently from its page, which can be older, with a note. 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). Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. 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 draw opens on the pasteboard. +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 (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), 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 where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), 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). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. 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. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), 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). Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. 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 draw opens on the pasteboard. ## Format by format @@ -130,6 +130,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | EPS and `.ai` editing data: type with an art style of several fills or strokes, effects on a fill (Offset Path, Transform with copies, Drop Shadow, Gaussian Blur, Bas Relief), pattern and gradient fills, per-fill blend modes and a Warp comes in as type with that live appearance instead of its drawn look (outlines), the style read as a tree of filters; other objects keep the rule for plain styles. Point type whose frame has no matrix (written so by newer files) is read; such stories were left out. Where a styled object's drawn look lay, the page isn't held to look like VectorCraft's drawing of the style. `editingData: "always"` also opens an Illustrator EPS from its editing data. Own-fixture tests; a user's file used locally only; scores and hours unchanged | | 2026-10-18 | minor | PDF import: invisible text (rendering mode 3, which carries a scanned page's recognized text) opens as type with no fill or stroke instead of being dropped without a word; when it can't be type (text opened as outlines, Type 3 fonts) it is left out with a warning. PDF export writes characters with no fill or stroke as invisible text in rendering mode 3, so a scanned PDF opened and saved keeps its searchable text, in PDF/X-1a and PDF/A too (#2675); own-fixture tests; scores and hours unchanged | | 2026-10-18 | minor | DXF circular cubic paths export as native CIRCLE/ARC where stroke semantics permit (R13–2018 for weighted strokes; R12 weighted strokes remain POLYLINE); R12 stroked circles/arcs retain their POLYLINE width, and ClosePath geometry with an implicit closing chord is not promoted to CIRCLE (#1299); scores and hours unchanged | | 2026-10-17 | minor | PDF Place and clipboard paste preserve live text; explicit outlines remain supported on Place. Synthetic regressions; scores and hours unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 15ac02c99..3f39bab54 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace Flat Logo traces line art on white paper instead of a blank result; score and hours unchanged; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; 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; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; 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; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; 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; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; 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; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (imported PDF type that is letter-spaced keeps its word spaces and no others; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace Flat Logo traces line art on white paper instead of a blank result; score and hours unchanged; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; 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; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; 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; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; 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; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; 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; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md), @@ -11,6 +11,7 @@ the work list made from it. Deep areas have their own checklists: [UI and intera | Number | Value | Kind | |---|---|---| +| 2026-10-21 | minor | PDF import: letter-spaced type keeps its word spaces and gains none between letters; synthetic Tc and glyph-by-glyph regressions. Scores and hours unchanged. | | 2026-10-15 | minor | Image Trace retains nonzero positioned boundaries when a fixed-end returning piece has fewer than two fitted cubics. Source-generated tests check survival, winding, shared-edge reversal and Noise controls; see `image-trace-quality.md`. Scores and hours unchanged. | | **Feature breadth** (does each Illustrator menu item, tool, effect and format exist?) | **~88%** | measured in part: menu tree 353 of 375 items wired (94.1%), tools 89 of 92 (97%), vector effects 44 of 50 (88%), Photoshop-style effects 47 of 57 (82%), 3D 4 of 4 (initial: Extrude & Bevel, Revolve, Inflate, Rotate); blended by the area weights below | | **Feature depth** (each area scored by behaviour, weighted) | **~75%** | estimated, area table below | @@ -771,6 +772,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). Overprint is kept from PDF import (opened, the PDF part of an `.ai`, placed) to PDF export: the graphics state's `/OP` and `/op` become the overprint of fills, strokes and type, so a spot colour over a process colour still overprints; a page whose overprint can't be read for certain knocks out with a note, as does `/OPM 0` being read as nonzero overprint (#1388; hand-written fixtures, `pdf/src/tests_import_overprint.rs`). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs and the images in an `.ai` or EPS file's editing data, an alpha channel coming in as a grayscale opacity mask) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). Overprint is kept from PDF import (opened, the PDF part of an `.ai`, placed) to PDF export: the graphics state's `/OP` and `/op` become the overprint of fills, strokes and type, so a spot colour over a process colour still overprints; a page whose overprint can't be read for certain knocks out with a note, as does `/OPM 0` being read as nonzero overprint (#1388; hand-written fixtures, `pdf/src/tests_import_overprint.rs`). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. In imported PDF type, a space glyph the file draws between two words also keeps the next word where the file has it when the gap beside the space is wide, as VectorCraft's own export draws a tracked space: the space gets tracking of its own, and the wrapped lines of a justified paragraph still come back as one area type object when the word gaps on each line are alike (part of #2384). + Letter-spaced PDF type, whether set with character spacing (`Tc`) or glyph by glyph, keeps its word spaces and gains none between its letters: the spacing a line's letters share is taken off each gap before a gap reads as a space. Glyphs set more than half an em apart, such as figures in columns, keep their spaces. SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `