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/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. |