From eb320593dcc71f87229869e546838d0e0dc77411 Mon Sep 17 00:00:00 2001 From: Dan Green Date: Mon, 12 Oct 2026 19:08:16 -0700 Subject: [PATCH 01/10] Part of #868: legacy .ai writer to the published version 7 specification export_legacy_ai writes a document as a PostScript-form .ai of version 7 (one artboard per file), keeping every layer (name, order, Hidden, Locked) and its groups, compound paths and clip groups. Closed paths end on their start point, so they keep their corner. The .ai reader learns what the writer emits, and the tests read every file back. --- crates/eps/src/import/ai/lex.rs | 16 +- crates/eps/src/import/ai/read.rs | 65 +- crates/eps/src/legacy_ai.rs | 951 ++++++++++++++++++++++++++++++ crates/eps/src/lib.rs | 3 + crates/eps/src/tests_legacy_ai.rs | 287 +++++++++ 5 files changed, 1318 insertions(+), 4 deletions(-) create mode 100644 crates/eps/src/legacy_ai.rs create mode 100644 crates/eps/src/tests_legacy_ai.rs diff --git a/crates/eps/src/import/ai/lex.rs b/crates/eps/src/import/ai/lex.rs index 37e431797..071176bb4 100644 --- a/crates/eps/src/import/ai/lex.rs +++ b/crates/eps/src/import/ai/lex.rs @@ -3,7 +3,9 @@ //! - A line starting with `%_` holds tokens like any other line (they are marked [`Token::hidden`]: //! readers of the printed format skip them as comments). //! - Any other line starting with `%` is a [`Tok::Comment`] (the section markers). -//! - `%` later in a line starts a comment that is skipped. +//! - `%_` later in a line starts tokens too, hidden (a pseudo-comment operator after its +//! operands, as a gradient stop's `… 50 100 %_Bs`: AI7 spec §1.4.3); any other `%` later in a +//! line starts a comment that is skipped. //! - The binary image data after `%%BeginData:` is one [`Tok::Data`]; the binary preview of a placed //! file is one [`Tok::Preview`]. @@ -118,7 +120,8 @@ impl<'a> Lexer<'a> { } if b == b'%' { let start = self.at_line_start(); - if start && self.rest().starts_with(b"%_") { + // A pseudo-comment: `%_` at the line's start, or after operands. + if self.rest().starts_with(b"%_") && (start || self.rest().get(2).is_some_and(|b| b.is_ascii_alphabetic())) { self.pos += 2; self.hidden = true; continue; @@ -313,11 +316,20 @@ mod tests { t(Tok::Word(b":"), true), t(Tok::Word(b";"), true), t(Tok::Num(0.0), false), + // A pseudo-comment operator after its operands. + t(Tok::Word(b"BS"), true), t(Tok::Hex(b"AB".to_vec()), false), ] ); } + #[test] + fn a_percent_later_in_a_line_is_a_comment_unless_an_operator_follows_it() { + let toks = all(b"1 % a note (\n2 %_Bs\n3 %_ 4\n"); + let words: Vec<_> = toks.iter().map(|t| (t.tok.clone(), t.hidden)).collect(); + assert_eq!(words, [(Tok::Num(1.0), false), (Tok::Num(2.0), false), (Tok::Word(b"Bs"), true), (Tok::Num(3.0), false)]); + } + #[test] fn image_data_is_one_token() { // The count starts after the comment's `\r`: `\nXI\n` and 3 samples; an alpha channel diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index ca129690d..f507696a5 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -64,6 +64,8 @@ const MAX_NODES: usize = 4_000_000; const MAX_POINTS: usize = 4_000_000; /// Largest image read (pixels). const MAX_PIXELS: u64 = 1 << 25; +/// Most hex digits of an image's samples read from comment lines (CMYK and alpha at the largest size). +const MAX_HEX: usize = (MAX_PIXELS as usize) * 10; /// Largest side of the document and its artboards (points). const MAX_SIDE: f64 = 1e6; /// Art space (y up) → a pattern's space in the document's terms (y down). @@ -291,6 +293,11 @@ struct PatternHead { #[derive(Debug, Default)] struct Raster { space: Option, + /// `XI`'s operands when its samples follow as hex in comment lines (`%FF00…`) up to + /// `%AI5_EndRaster`, the form the version 7 specification documents (§8). + operands: Option>, + /// Those hex digits. + hex: Vec, } /// A placed file's operands, from `` ` `` and its preview, until `~`. @@ -566,6 +573,16 @@ impl<'a> Reader<'a> { fn comment(&mut self, c: &[u8]) -> Result<(), String> { let line = String::from_utf8_lossy(c); let line = line.trim_end(); + if let Some(r) = self.raster.as_mut() + && r.operands.is_some() + && !line.is_empty() + && line.bytes().all(|b| b.is_ascii_hexdigit()) + { + if r.hex.len() < MAX_HEX { + r.hex.extend_from_slice(line.as_bytes()); + } + return Ok(()); + } let m = marker(line); // A pattern's definition (one inside another's isn't read). if m == "BeginPattern" && self.pattern.is_none() { @@ -597,7 +614,9 @@ impl<'a> Reader<'a> { if line.starts_with("AI5_BeginRaster") { self.raster = Some(Raster::default()); } else if line.starts_with("AI5_EndRaster") { - self.raster = None; + if let Some(Raster { operands: Some(vals), hex, .. }) = self.raster.take() { + return self.image_from(vals, &hex); + } } else if line.starts_with("AI5_BeginPlace") { self.place = Some(Place::default()); } else if line.starts_with("AI5_EndPlace") { @@ -1067,6 +1086,10 @@ impl<'a> Reader<'a> { { self.set_instance(&i); } + // `1 BB` strokes the path the gradient just filled, `2 BB` closes it first (§3.5.3). + if matches!(Self::nums(&vals).last(), Some(f) if *f == 1.0 || *f == 2.0) { + self.stroke_last(); + } } // Images. "XN" => { @@ -1088,6 +1111,12 @@ impl<'a> Reader<'a> { "~" if self.place.is_some() => self.end_place(hidden)?, // Type in the legacy format. "To" | "TO" | "Tp" | "TP" | "Tx" | "TX" | "Tj" | "Tk" => self.unreadable("type in the legacy format"), + // An image whose samples follow in comment lines (read at `%AI5_EndRaster`). + "XI" if self.raster.is_some() => { + if let Some(r) = self.raster.as_mut() { + r.operands = Some(vals); + } + } _ if IGNORED.contains(&w) => {} // Before the art, the setup runs procedures of the printing format. _ if self.frames.is_empty() => {} @@ -1612,6 +1641,24 @@ impl<'a> Reader<'a> { self.add(node, hidden) } + /// Stroke the path just painted (a gradient-filled one, `1 BB` or `2 BB`) with the stroke's state. + fn stroke_last(&mut self) { + let gs = self.gs.clone(); + let Some(n) = self.last_object() else { return }; + if !matches!(n.kind, NodeKind::Path { clipping: false, .. }) || n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))) { + return; + } + let mut st = StrokeLayer::new(gs.stroke.clone(), gs.width); + st.cap = gs.cap; + st.join = gs.join; + st.miter_limit = gs.miter; + st.dash = gs.dash.clone(); + st.overprint = gs.overprint_stroke; + let mut a = (*n.appearance).clone(); + a.items.push(AppearanceItem::Stroke(st)); + n.appearance = a.into(); + } + fn set_instance(&mut self, i: &Instance) { let to_doc = self.space(); match i.paint(&self.gradients, to_doc) { @@ -1629,6 +1676,16 @@ impl<'a> Reader<'a> { /// The samples after `XI`, its operands on the stack. fn image(&mut self, data: &[u8]) -> Result<(), String> { let vals = self.operands(); + // Operands that came before an `XI` on their own line. + let vals = match self.raster.as_mut().and_then(|r| r.operands.take()) { + Some(before) if !vals.iter().any(|v| matches!(v, V::Arr(_))) => before, + _ => vals, + }; + self.image_from(vals, data) + } + + /// An image of operands `vals` and samples `data`. + fn image_from(&mut self, vals: Vec, data: &[u8]) -> Result<(), String> { let space = self.raster.as_ref().and_then(|r| r.space.clone()).unwrap_or_default(); let Some(m) = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }) else { return Ok(()) }; let n = Self::nums(&vals); @@ -1753,7 +1810,11 @@ impl<'a> Reader<'a> { /// box's centre (half a point off the grid). fn sized_board(&self) -> Option { let ((w, h), (cx, cy)) = (self.art_size?, self.template_center?); - let (x0, y1) = ((cx - w / 2.0).floor(), (cy + h / 2.0).ceil()); + // Rounded out, but not off a whole point the decimals in the file only just miss. + let near = |v: f64| (v - v.round()).abs() < 1e-6; + let (left, top) = (cx - w / 2.0, cy + h / 2.0); + let x0 = if near(left) { left.round() } else { left.floor() }; + let y1 = if near(top) { top.round() } else { top.ceil() }; let r = Rect::new(x0, y1 - h, x0 + w, y1); (sane(r) && w > 0.0 && h > 0.0).then_some(r) } diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs new file mode 100644 index 000000000..de2cc5b0a --- /dev/null +++ b/crates/eps/src/legacy_ai.rs @@ -0,0 +1,951 @@ +//! Legacy `.ai`, version 7: the PostScript-form Illustrator format, written so that apps that read +//! it get the document's layers, groups, compound paths and clipping groups as editable objects (a +//! PDF-compatible `.ai` without Illustrator's undocumented editing data opens in them as one +//! clipped group per page). +//! +//! The format is the one the published specification documents, and only that: Adobe Illustrator +//! File Format Specification, version 7.0 (23 February 1998), listed by PRONOM at +//! https://www.nationalarchives.gov.uk/PRONOM/fmt/423 (its specification link: +//! http://mercator.elte.hu/~saman/hu/okt/AI7FileFormat.pdf). Section numbers below are its. This +//! writer emits: +//! +//! - Header comments (§2.1): `%%BoundingBox` (the one it requires), `%%CreationDate: (date) +//! (time)`, custom (spot) colours, `%AI5_FileFormat 3` (version 7.0, Table 2), the artboard as +//! `%AI5_ArtSize` and an empty `%AI3_TemplateBox` at its centre, which puts the ruler origin at +//! its bottom-left corner (§2.2), `%AI5_NumLayers`. +//! - Setup (§3): `n Bn`, then each gradient: `%AI5_BeginGradient: (name)`, `(name) type n Bd`, +//! `[`, its stops last first (`colorSpec colorStyle midPoint rampPoint %_Bs`), `BD`, +//! `%AI5_EndGradient`. +//! - Layers (§9): `%AI5_BeginLayer`, `visible preview enabled printing dimmed 0 colorIndex r g b +//! Lb`, `(name) Ln`, the art, `LB`, `%AI5_EndLayer--`. +//! - Groups `u` … `U`, clipping groups `q` … `Q` with the clipping path painted `h W n` (§5.6), +//! compound paths `*u` … `*U` (§5.3.5), locked objects `1 A` (§5.1). +//! - Paths (§5.3): `m`, `l`/`L`, `c`/`C` (lower case: the new point is smooth, upper case: a +//! corner), painted `N n F f S s B b` (lower case closes first); guides `(N) *` before and after +//! the path (§6); fill rule `XR`; overprint `O`/`R`; stroke `w J j M d`. +//! - Colours (§5.4): `g`/`G` (grey), `k`/`K` (CMYK), `Xa`/`XA` (`r g b`), `x`/`X` (a spot colour +//! at a tint). Gradient fills (§3.5.3): the path, `Bb`, `1 (name) x y angle length 1 0 0 1 0 0 +//! Bg`, a radial's highlight `x y angle fraction Bh`, `f` (or `F`), and `0 BB`, or `2 BB` (`1 BB` +//! open) to stroke it too. +//! - Images (§8): `%AI5_BeginRaster`, `[matrix] 0 0 w h w h 8 3 0 0 0 0 XI`, the RGB samples in +//! hex on comment lines, `%AI5_EndRaster`. +//! +//! The format has one artboard, no transparency, sublayers, object names, live effects, symbols +//! or appearances with several fills and strokes. Callers write one file per artboard and expand +//! appearances first; what is left is written as the format can hold it, each loss a warning: +//! transparent objects are written opaque, sublayers become groups, type its outlines, live objects and symbols their +//! art, strokes a plain line can't draw their outlines, pattern fills their tiles clipped to the +//! shape, freeform gradients their average colour, elliptical radial gradients circular (the +//! gradient matrix is relative to a canvas the specification doesn't size), transparent image +//! pixels over white. Hidden +//! objects are left out (hidden layers stay, hidden). +//! +//! No prolog is written: the file names none, so PostScript printers can't print it, as the `.ai` +//! files other apps write in this format. + +use std::fmt::Write; +use std::sync::Arc; + +use vectorcraft_color::cms::Cms; +use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint}; +use vectorcraft_doc::{ + AppearanceItem, Document, FillLayer, ImageObject, LAYER_COLORS, LayerColor, LineCap, LineJoin, Node, NodeKind, StrokeLayer, TextObject, +}; +use vectorcraft_effects::stroke; +use vectorcraft_geom::{Affine, AnchorKind, BezPath, FillRule, PathData, Point, Rect, Shape, SubPath, Vec2}; + +use crate::ps; + +/// How deep symbols and live objects may nest in one another (deeper art is left out). +const MAX_NEST: u32 = 8; +/// Deepest nesting of groups and layers written (deeper art is left out). +const MAX_DEPTH: u32 = 256; +/// Largest image written (pixels). +const MAX_IMAGE_PIXELS: u64 = 1 << 26; +/// Hex digits on a line of image data. +const HEX_LINE: usize = 64; + +pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level layer (legacy .ai files have no sublayers)"; +pub const TYPE: &str = "type is written as outlines (legacy .ai type isn't written yet)"; +pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, hidden)"; +pub const TRANSPARENCY: &str = "transparency (opacity, blending modes, opacity masks) is written opaque: legacy .ai files have none"; +pub const NAMES: &str = "object names are left out (legacy .ai files name only layers)"; +const PATTERNS: &str = "pattern fills are written as their tiles, clipped to the shape"; +const FREEFORM: &str = "freeform gradients are written as their average colour"; +const STROKES: &str = + "strokes a plain line can't draw (inside or outside the path, variable width, gradients, brushes, arrowheads) are written as filled outlines"; +const IMAGE_ALPHA: &str = "transparent image pixels are written over white"; +const LIVE: &str = "symbols, blends, envelopes, meshes, repeats and placed documents are written as their art"; +const UNREADABLE_IMAGE: &str = "an image that can't be decoded or is too large was left out"; +const DEEP: &str = "art nested too deeply was left out"; +const ELLIPTICAL: &str = "elliptical radial gradients are written circular"; + +/// What to write in the header. +#[derive(Clone, Debug, Default)] +pub struct LegacyAiOptions { + /// `%%Title`. + pub title: String, + /// `%%CreationDate`, Unix seconds. + pub created: Option, +} + +/// The file and what it couldn't hold. +#[derive(Clone, Debug, Default)] +pub struct LegacyAiOutput { + pub bytes: Vec, + pub warnings: Vec, +} + +/// Write `doc`'s first artboard and every layer (all its art, on the artboard or not) as a +/// legacy `.ai` file of version 7. Art space is y up, its origin at the artboard's left edge and +/// just under its bottom edge: the artboard's top is on a whole point, where readers that place +/// the artboard by rounding the template box out to whole points put it too. +pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result { + let board = doc.artboards.first().ok_or("the document has no artboard")?.rect; + if !(board.is_finite() && board.width() > 0.0 && board.height() > 0.0) { + return Err("the document has no artboard".into()); + } + // The document is y down; art space y up from the artboard's left edge, its top on a whole point. + let (width, height) = (board.width(), board.height()); + let top = if (height - height.round()).abs() < 1e-6 { height.round() } else { height.ceil() }; + let below = top - height; + let to_art = Affine::new([1.0, 0.0, 0.0, -1.0, -board.x0, board.y1 + below]); + let mut w = Writer::new(doc, to_art); + let mut layers = 0usize; + for layer in &doc.layers { + if w.layer(layer) { + layers += 1; + } + } + let art = doc + .layers + .iter() + .filter(|l| l.visible) + .filter_map(|l| l.visual_bounds()) + .map(|b| to_art.transform_rect_bbox(b)) + .filter(|b| b.is_finite()) + .reduce(|a, b| a.union(b)) + .unwrap_or(Rect::new(0.0, below, width, top)); + let mut s = String::new(); + s.push_str(PS_HEADER); + s.push('\n'); + let _ = writeln!(s, "%%Creator: VectorCraft {}", env!("CARGO_PKG_VERSION")); + let _ = writeln!(s, "%%Title: {}", ps::string(&o.title)); + if let Some(t) = o.created { + let [y, mo, d, h, mi, _] = vectorcraft_doc::metadata::civil(t); + let _ = writeln!(s, "%%CreationDate: ({mo}/{d}/{y:04}) ({h:02}:{mi:02})"); + } + let _ = writeln!(s, "%%BoundingBox: {} {} {} {}", art.x0.floor() as i64, art.y0.floor() as i64, art.x1.ceil() as i64, art.y1.ceil() as i64); + s.push_str("%%HiResBoundingBox: "); + ps::push_nums(&mut s, &[art.x0, art.y0, art.x1, art.y1]); + s.pop(); + s.push('\n'); + s.push_str("%%DocumentProcessColors: Cyan Magenta Yellow Black\n"); + for (i, (name, _)) in w.spots.iter().enumerate() { + let _ = writeln!(s, "{} {}", if i == 0 { "%%DocumentCustomColors:" } else { "%%+" }, ps::string(name)); + } + for (i, (name, cmyk)) in w.spots.iter().enumerate() { + s.push_str(if i == 0 { "%%CMYKCustomColor: " } else { "%%+ " }); + ps::push_nums(&mut s, &cmyk.map(f64::from)); + let _ = writeln!(s, "{}", ps::string(name)); + } + s.push_str("%AI5_FileFormat 3\n%AI3_ColorUsage: Color\n"); + // An empty template box at the artboard's centre sets the ruler origin (§2.2). + let (cx, cy) = (ps::num(width / 2.0), ps::num(below + height / 2.0)); + let _ = writeln!(s, "%AI3_TemplateBox: {cx} {cy} {cx} {cy}"); + let _ = writeln!(s, "%AI3_TileBox: 0 {} {} {}", ps::num(below), ps::num(width), ps::num(top)); + s.push_str("%AI3_DocumentPreview: None\n"); + let _ = writeln!(s, "%AI5_ArtSize: {} {}", ps::num(width), ps::num(height)); + s.push_str("%AI5_RulerUnits: 2\n"); + let _ = writeln!(s, "%AI5_NumLayers: {layers}"); + s.push_str("%%EndComments\n%%BeginProlog\n%%EndProlog\n%%BeginSetup\n"); + if !w.gradients.is_empty() { + let _ = writeln!(s, "{} Bn", w.gradients.len()); + for (name, g) in &w.gradients { + gradient_def(&mut s, &w, name, g); + } + } + s.push_str("%%EndSetup\n"); + s.push_str(&w.body); + s.push_str("%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n"); + Ok(LegacyAiOutput { bytes: s.into_bytes(), warnings: w.warnings }) +} + +/// The first line of a PostScript file (the DSC version comment). +const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header comment + +/// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd`, `[`, the stops +/// last first (`colorSpec colorStyle midPoint rampPoint %_Bs`), `BD`, `%AI5_EndGradient`. +fn gradient_def(s: &mut String, w: &Writer, name: &str, g: &Gradient) { + let name = ps::string(name); + let kind = if g.kind == GradientKind::Radial { 1 } else { 0 }; + let _ = writeln!(s, "%AI5_BeginGradient: {name}\n{name} {kind} {} Bd\n[", g.stops.len()); + for stop in g.stops.iter().rev() { + let paint = Paint::Solid { color: stop.color, swatch: stop.swatch.clone(), tint: stop.tint }; + match w.ink(&paint) { + Ink::Gray(v) => { + ps::push_num(s, f64::from(v)); + s.push_str(" 0 "); + } + Ink::Cmyk(c) => { + ps::push_nums(s, &c.map(f64::from)); + s.push_str("1 "); + } + Ink::Rgb(c, rgb) => { + ps::push_nums(s, &c.map(f64::from)); + ps::push_nums(s, &rgb.map(f64::from)); + s.push_str("2 "); + } + Ink::Spot { cmyk, name, tint } => { + ps::push_nums(s, &cmyk.map(f64::from)); + let _ = write!(s, "{} ", ps::string(&name)); + ps::push_num(s, f64::from(1.0 - tint)); + s.push_str(" 3 "); + } + } + ps::push_nums(s, &[f64::from(stop.midpoint) * 100.0, f64::from(stop.offset) * 100.0]); + s.push_str("%_Bs\n"); + } + s.push_str("BD\n%AI5_EndGradient\n"); +} + +/// A colour as the format writes it. +#[derive(Clone, Debug, PartialEq)] +enum Ink { + /// Lightness (1 is white). + Gray(f32), + Cmyk([f32; 4]), + /// RGB, with its CMYK equivalent. + Rgb([f32; 4], [f32; 3]), + /// A spot colour's full CMYK equivalent, name and tint (1: full). + Spot { + cmyk: [f32; 4], + name: String, + tint: f32, + }, +} + +fn unit(v: f32) -> f32 { + if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } +} + +/// Can `a` be drawn through (a finite, non-flat map)? +fn invertible(a: Affine) -> bool { + let det = a.determinant(); + det.is_finite() && det.abs() > 1e-12 +} + +struct Writer<'a> { + doc: &'a Document, + /// The document → art space. + to_art: Affine, + cms: Arc, + body: String, + /// Gradient definitions, by the name objects use. + gradients: Vec<(String, Gradient)>, + /// Spot colours used: name and full CMYK equivalent. + spots: Vec<(String, [f32; 4])>, + warnings: Vec, + /// The lock state the last `A` set. + locked: bool, + /// Symbols and live objects being written inside one another. + nest: u32, + /// Groups and layers open. + depth: u32, +} + +impl<'a> Writer<'a> { + fn new(doc: &'a Document, to_art: Affine) -> Self { + Self { + doc, + to_art, + cms: vectorcraft_color::cms::active(), + body: String::new(), + gradients: vec![], + spots: vec![], + warnings: vec![], + locked: false, + nest: 0, + depth: 0, + } + } + + fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + fn line(&mut self, s: &str) { + self.body.push_str(s); + self.body.push('\n'); + } + + // ---------- layers and objects ---------- + + /// A top-level layer. False when it isn't a layer. + fn layer(&mut self, n: &Node) -> bool { + let NodeKind::Layer { color, template, printable, children, clip, preview, dim_images } = &n.kind else { return false }; + let [r, g, b] = match color { + LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1), + LayerColor::Custom(rgb) => *rgb, + }; + let index = match color { + LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i), + _ => -1, + }; + let flag = |b: bool| u8::from(b); + // A template layer doesn't print and shows dimmed. + let _ = writeln!( + self.body, + "%AI5_BeginLayer\n{} {} {} {} {} 0 {index} {r} {g} {b} Lb\n{} Ln", + flag(n.visible), + flag(*preview), + flag(!n.locked), + flag(*printable && !*template), + flag(dim_images.is_some() || *template), + ps::string(n.name.as_deref().unwrap_or("Layer")), + ); + self.locked = false; + self.line("0 A"); + self.contents(children, *clip, false); + self.line("LB\n%AI5_EndLayer--"); + true + } + + /// A container's members, the first clipping the others when `clip`; `locked`: the container is. + fn contents(&mut self, children: &[Arc], clip: bool, locked: bool) { + if clip { + self.clip_group(children, locked); + } else { + for c in children { + self.node(c, locked); + } + } + } + + /// Set the lock state of the objects that follow. + fn lock(&mut self, locked: bool) { + if locked != self.locked { + self.locked = locked; + self.line(if locked { "1 A" } else { "0 A" }); + } + } + + /// One object (inside a container locked when `locked`). + fn node(&mut self, n: &Node, locked: bool) { + if !n.visible { + self.warn(HIDDEN); + return; + } + if self.depth >= MAX_DEPTH { + self.warn(DEEP); + return; + } + if n.opacity < 1.0 || n.blend != BlendMode::Normal || n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { + self.warn(TRANSPARENCY); + } + if n.name.as_deref().is_some_and(|s| !s.is_empty()) && !n.is_layer() { + self.warn(NAMES); + } + let locked = locked || n.locked; + match &n.kind { + NodeKind::Layer { children, clip, .. } => { + self.warn(SUBLAYERS); + self.group(children, *clip, locked); + } + NodeKind::Group { children, clip } => self.group(children, *clip, locked), + NodeKind::Path { path, rule, guide, .. } => { + self.lock(locked); + if *guide { + self.guide(path); + } else { + self.shape(n, path, *rule); + } + } + NodeKind::Compound { children, rule } => { + self.lock(locked); + let mut subpaths = vec![]; + for p in children.iter().filter_map(|c| c.path_data()) { + subpaths.extend(p.subpaths.iter().cloned()); + } + self.shape(n, &PathData::new(subpaths), *rule); + } + NodeKind::Text(t) => { + self.lock(locked); + self.text(t); + } + NodeKind::Image(im) => { + self.lock(locked); + self.image(im); + } + NodeKind::SymbolInstance { symbol, xf } => { + self.warn(LIVE); + if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) + && self.nest < MAX_NEST + { + let mut art = (*sym.art).clone(); + art.transform(*xf, false); + art.visible = true; + self.nest += 1; + self.node(&art, locked); + self.nest -= 1; + } + } + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { + self.warn(LIVE); + if self.nest < MAX_NEST { + let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); + self.nest += 1; + if let Some(children) = g.children() { + self.group(children, false, locked); + } + self.nest -= 1; + } + } + } + } + + /// A group (`u` … `U`), or a clipping group (`q` … `Q`). + fn group(&mut self, children: &[Arc], clip: bool, locked: bool) { + if children.is_empty() { + return; + } + self.lock(locked); + self.depth += 1; + if clip { + self.clip_group(children, locked); + } else { + self.line("u"); + self.contents(children, false, locked); + self.lock(locked); + self.line("U"); + } + self.depth -= 1; + } + + /// A clipping group: its first member's outline clips the others (`h W n`). That member's fills + /// paint behind the art, inside the clip, and its strokes over it, outside. + fn clip_group(&mut self, children: &[Arc], locked: bool) { + let Some((clip, rest)) = children.split_first() else { return }; + // Nothing to clip by hides the clipped art. + let Some((outline, rule)) = vectorcraft_effects::clip_outline(clip) else { return }; + let paint = clip.clip_paint(); + self.lock(locked); + self.line("q"); + let path = PathData::from_bezpath(&outline); + self.fill_rule(rule); + let several = path.subpaths.len() > 1; + if several { + self.line("*u"); + } + for sp in path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()) { + self.mask(sp); + } + if several { + self.line("*U"); + } + if let Some(fill) = &paint.fill { + self.node(fill, locked); + } + for c in rest { + self.node(c, locked); + } + self.lock(locked); + self.line("Q"); + if let Some(stroke) = &paint.stroke { + self.node(stroke, locked); + } + } + + // ---------- paths ---------- + + /// A clipping path (§5.6.2): its points, `h` (closed) or `H`, `W`, then painted with nothing. + fn mask(&mut self, sp: &SubPath) { + self.subpath(sp); + self.line(if sp.closed { "h\nW\nn" } else { "H\nW\nN" }); + } + + /// A guide (§6): `(N) *`, its path, `(N) *`. + fn guide(&mut self, path: &PathData) { + for sp in path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()) { + self.line("(N) *"); + self.subpath(sp); + self.line("(N) *"); + } + } + + /// A path's fills and strokes: one object with one fill and a plain stroke over it where it + /// can be, else a group of an object for each (in paint order). + fn shape(&mut self, n: &Node, path: &PathData, rule: FillRule) { + if path.is_empty() { + return; + } + let items: Vec<&AppearanceItem> = n + .appearance + .items + .iter() + .filter(|i| match i { + AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(), + AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0 && s.width.is_finite(), + }) + .collect(); + for i in &items { + if i.opacity() < 1.0 || i.blend() != BlendMode::Normal { + self.warn(TRANSPARENCY); + } + } + match items.as_slice() { + [] => self.path_object(path, rule, None, None), + [AppearanceItem::Fill(f)] if direct_fill(&f.paint) => self.path_object(path, rule, Some(f), None), + [AppearanceItem::Stroke(s)] if direct_stroke(s) => self.path_object(path, rule, None, Some(s)), + [AppearanceItem::Fill(f), AppearanceItem::Stroke(s)] if direct_fill(&f.paint) && direct_stroke(s) => { + self.path_object(path, rule, Some(f), Some(s)) + } + [one] => self.item(path, rule, one), + items => { + self.depth += 1; + self.line("u"); + for i in items { + self.item(path, rule, i); + } + self.line("U"); + self.depth -= 1; + } + } + } + + /// One fill or stroke of a path as an object of its own. + fn item(&mut self, path: &PathData, rule: FillRule, item: &AppearanceItem) { + match item { + AppearanceItem::Fill(f) => self.fill_object(path, rule, f), + AppearanceItem::Stroke(s) if direct_stroke(s) => self.path_object(path, rule, None, Some(s)), + AppearanceItem::Stroke(s) => { + self.warn(STROKES); + let outline = stroke::outline_region(path, rule, s); + let fill = FillLayer { overprint: s.overprint, ..FillLayer::new(s.paint.clone()) }; + self.fill_object(&outline, FillRule::NonZero, &fill); + } + } + } + + /// A fill as an object of its own: patterns as their tiles clipped to the path. + fn fill_object(&mut self, path: &PathData, rule: FillRule, f: &FillLayer) { + if direct_fill(&f.paint) { + return self.path_object(path, rule, Some(f), None); + } + let Paint::Pattern { pattern, xf } = &f.paint else { + // A freeform gradient: its average colour. + self.warn(FREEFORM); + let color = match &f.paint { + Paint::Gradient(g) => average(&g.gradient), + _ => Color::gray(0.5), + }; + let flat = FillLayer { paint: Paint::solid(color), ..f.clone() }; + return self.path_object(path, rule, Some(&flat), None); + }; + self.warn(PATTERNS); + let doc = self.doc; + let Some(def) = doc.pattern(pattern) else { + let flat = FillLayer { paint: Paint::solid(Color::gray(0.5)), ..f.clone() }; + return self.path_object(path, rule, Some(&flat), None); + }; + let area = path.to_bezpath().bounding_box(); + let tiles = def.instances_in(*xf, area); + self.line("q"); + self.fill_rule(rule); + let several = path.subpaths.len() > 1; + if several { + self.line("*u"); + } + for sp in path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()) { + self.mask(sp); + } + if several { + self.line("*U"); + } + let locked = self.locked; + for t in &tiles { + self.node(t, locked); + } + self.lock(locked); + self.line("Q"); + } + + /// A path object painted with `fill` (a colour or a linear or radial gradient) and `stroke` + /// (a plain line of a colour) over it; several subpaths make a compound path. + fn path_object(&mut self, path: &PathData, rule: FillRule, fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>) { + let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect(); + if subpaths.is_empty() { + return; + } + let gradient = match fill.map(|f| &f.paint) { + Some(Paint::Gradient(g)) => Some(self.gradient(g, path.to_bezpath().bounding_box())), + _ => None, + }; + if let Some(f) = fill { + let paint = match &f.paint { + // The colour under the gradient: its first stop's. + Paint::Gradient(g) => g.gradient.stops.first().map_or(Paint::None, |s| Paint::solid(s.color)), + p => p.clone(), + }; + self.color(&paint, false); + self.line(if f.overprint { "1 O" } else { "0 O" }); + } + if let Some(s) = stroke { + self.color(&s.paint, true); + self.line(if s.overprint { "1 R" } else { "0 R" }); + self.line_style(s); + } + self.fill_rule(rule); + let op = match (fill.is_some(), stroke.is_some()) { + (true, true) => 'b', + (true, false) => 'f', + (false, true) => 's', + (false, false) => 'n', + }; + let several = subpaths.len() > 1; + if several { + self.line("*u"); + } + for sp in subpaths { + self.subpath(sp); + if let Some(g) = &gradient { + // The gradient fills between `Bb` and `BB`, whose flag strokes the path too (§3.5.3). + self.body.push_str(g); + self.line(if sp.closed { "f" } else { "F" }); + self.line(match (stroke.is_some(), sp.closed) { + (false, _) => "0 BB", + (true, false) => "1 BB", + (true, true) => "2 BB", + }); + } else { + self.line(&if sp.closed { op.to_string() } else { op.to_ascii_uppercase().to_string() }); + } + } + if several { + self.line("*U"); + } + } + + /// `rule` as `XR` (1: even-odd). + fn fill_rule(&mut self, rule: FillRule) { + self.line(if rule == FillRule::EvenOdd { "1 XR" } else { "0 XR" }); + } + + /// One subpath's points, in art space: `m`, then a line or curve to each point (lower case: the + /// point is smooth, upper case: a corner, §5.3.2), and for a closed one the segment back to the + /// start, which says whether the start is smooth or a corner (as the specification's examples + /// close paths: the start point again, then the painting operator). + fn subpath(&mut self, sp: &SubPath) { + let Some(first) = sp.anchors.first() else { return }; + let xf = self.to_art; + let s = &mut self.body; + let pt = |s: &mut String, p: Point| { + let p = xf * p; + ps::push_nums(s, &[p.x, p.y]); + }; + pt(s, first.p); + s.push_str("m\n"); + let segment = |s: &mut String, a: &vectorcraft_geom::Anchor, b: &vectorcraft_geom::Anchor| { + let straight = a.h_out == a.p && b.h_in == b.p; + let smooth = b.kind == AnchorKind::Smooth; + if straight { + pt(s, b.p); + s.push_str(if smooth { "l\n" } else { "L\n" }); + } else { + pt(s, a.h_out); + pt(s, b.h_in); + pt(s, b.p); + s.push_str(if smooth { "c\n" } else { "C\n" }); + } + }; + for pair in sp.anchors.windows(2) { + if let [a, b] = pair { + segment(s, a, b); + } + } + if sp.closed + && sp.anchors.len() > 1 + && let Some(last) = sp.anchors.last() + { + segment(s, last, first); + } + } + + /// The stroke's width, cap, join, miter limit and dashes: `w J j M d`. + fn line_style(&mut self, st: &StrokeLayer) { + let cap = match st.cap { + LineCap::Butt => 0, + LineCap::Round => 1, + LineCap::Square => 2, + }; + let join = match st.join { + LineJoin::Miter => 0, + LineJoin::Round => 1, + LineJoin::Bevel => 2, + }; + let s = &mut self.body; + ps::push_num(s, st.width); + let _ = write!(s, " w {cap} J {join} j "); + ps::push_num(s, if st.miter_limit.is_finite() { st.miter_limit.max(1.0) } else { 10.0 }); + s.push_str(" M ["); + let dash = st.dash.as_ref().filter(|d| d.is_dashed()); + if let Some(d) = dash { + ps::push_nums(s, &d.pattern); + s.pop(); + } + s.push_str("] "); + ps::push_num(s, dash.map_or(0.0, |d| d.offset)); + s.push_str(" d\n"); + } + + // ---------- colour ---------- + + /// How `p` (a solid paint) is written. + fn ink(&self, p: &Paint) -> Ink { + let Paint::Solid { color, swatch, tint } = p else { return Ink::Gray(0.0) }; + let doc = self.doc; + let intent = self.cms.settings().intent; + if let Some(name) = swatch + && let Some(sw) = doc.swatch(name).filter(|s| s.spot) + && let Some(full) = sw.paint.color() + { + let full = doc.linked_color(full, true); + return Ink::Spot { cmyk: self.cms.to_cmyk(&full, intent).map(unit), name: name.clone(), tint: unit(*tint) }; + } + match *color { + // Grey is ink coverage; the format's grey is lightness. + Color::Gray { k } => Ink::Gray(unit(1.0 - k)), + Color::Cmyk { c, m, y, k } => Ink::Cmyk([c, m, y, k].map(unit)), + Color::Rgb { r, g, b } => Ink::Rgb(self.cms.to_cmyk(color, intent).map(unit), [r, g, b].map(unit)), + Color::Lab { .. } => { + let rgb = color.to_rgb().map(unit); + Ink::Rgb(self.cms.to_cmyk(color, intent).map(unit), rgb) + } + } + } + + /// Set the fill (or with `stroke`, the stroke) colour to solid paint `p`. + fn color(&mut self, p: &Paint, stroke: bool) { + let ink = self.ink(p); + let op = |fill: &'static str, line: &'static str| if stroke { line } else { fill }; + let s = &mut self.body; + match ink { + Ink::Gray(v) => { + ps::push_num(s, f64::from(v)); + let _ = writeln!(s, " {}", op("g", "G")); + } + Ink::Cmyk(c) => { + ps::push_nums(s, &c.map(f64::from)); + let _ = writeln!(s, "{}", op("k", "K")); + } + Ink::Rgb(_, rgb) => { + ps::push_nums(s, &rgb.map(f64::from)); + let _ = writeln!(s, "{}", op("Xa", "XA")); + } + Ink::Spot { cmyk, name, tint } => { + ps::push_nums(s, &cmyk.map(f64::from)); + let _ = write!(s, "{} ", ps::string(&name)); + // The format's tint is how much lighter than the full colour (0: full). + ps::push_num(s, f64::from(1.0 - tint)); + let _ = writeln!(s, " {}", op("x", "X")); + if !self.spots.iter().any(|(n, _)| *n == name) { + self.spots.push((name, cmyk)); + } + } + } + } + + // ---------- gradients ---------- + + /// The definition gradient `g` uses (defined once for each distinct gradient), and its + /// instance on a path of bounds `bounds`: `Bb`, `1 (name) x y angle length 1 0 0 1 0 0 Bg`, a + /// radial's highlight `x y angle fraction Bh` (its distance a fraction of the radius, §3.5.5). + fn gradient(&mut self, g: &GradientPaint, bounds: Rect) -> String { + let name = self.define(&g.gradient, g.swatch.as_deref()); + for stop in &g.gradient.stops { + if stop.opacity < 1.0 { + self.warn(TRANSPARENCY); + } + if let Some(sw) = &stop.swatch { + // Spot colours in stops are custom colours too. + let p = Paint::Solid { color: stop.color, swatch: Some(sw.clone()), tint: stop.tint }; + if let Ink::Spot { cmyk, name, .. } = self.ink(&p) + && !self.spots.iter().any(|(n, _)| *n == name) + { + self.spots.push((name, cmyk)); + } + } + } + let geom = g.resolve(bounds); + let (start, end) = (self.to_art * geom.start, self.to_art * geom.end); + let d = end - start; + let (angle, length) = (d.y.atan2(d.x).to_degrees(), d.hypot()); + // The matrix is relative to the origin of a canvas whose size the specification doesn't + // give (§3.5.4), so it stays the identity: an elliptical radial is written circular. + if g.gradient.kind == GradientKind::Radial && (geom.aspect - 1.0).abs() > 1e-6 { + self.warn(ELLIPTICAL); + } + let mut s = String::from("Bb\n"); + if g.gradient.kind == GradientKind::Radial + && let Some(f) = geom.focal + { + let off: Vec2 = self.to_art * f - start; + if off.hypot() > 1e-9 && length > 1e-9 { + let fraction = (off.hypot() / length).min(1.0); + ps::push_nums(&mut s, &[off.x, off.y, off.y.atan2(off.x).to_degrees(), fraction]); + s.push_str("Bh\n"); + } + } + s.push_str("1 "); + s.push_str(&ps::string(&name)); + s.push(' '); + ps::push_nums(&mut s, &[start.x, start.y, angle, length]); + s.push_str("1 0 0 1 0 0 Bg\n"); + s + } + + /// The name of the definition of `g` (its swatch's name when it has one), defined the first + /// time. + fn define(&mut self, g: &Gradient, swatch: Option<&str>) -> String { + if let Some((name, _)) = self.gradients.iter().find(|(_, d)| d == g) { + return name.clone(); + } + let taken = |n: &str, defs: &[(String, Gradient)]| defs.iter().any(|(m, _)| m == n); + let base = swatch.filter(|s| !s.is_empty()).unwrap_or("Gradient").to_string(); + let mut name = base.clone(); + let mut i = 1; + while taken(&name, &self.gradients) { + i += 1; + name = format!("{base} {i}"); + } + self.gradients.push((name.clone(), g.clone())); + name + } + + // ---------- type ---------- + + /// Type as its glyphs' outlines: a group of a compound path for each run, with the run's fill + /// and stroke. + fn text(&mut self, t: &TextObject) { + self.warn(TYPE); + if !invertible(t.xf) { + return; + } + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let scale = t.xf.determinant().abs().sqrt(); + self.depth += 1; + self.line("u"); + for (i, run) in t.runs.iter().enumerate() { + let mut bp = BezPath::new(); + for g in layout.glyphs.iter().filter(|g| g.run == i) { + bp.extend(g.outline.iter()); + } + if bp.elements().is_empty() { + continue; + } + bp.apply_affine(t.xf); + let path = PathData::from_bezpath(&bp); + let fill = + (!run.style.fill.is_none()).then(|| FillLayer { overprint: run.style.overprint_fill, ..FillLayer::new(run.style.fill.clone()) }); + let stroke = run.style.has_stroke().then(|| { + let mut s = run.style.stroke_layer(); + s.width *= scale; + s + }); + let items: Vec = fill.map(AppearanceItem::Fill).into_iter().chain(stroke.map(AppearanceItem::Stroke)).collect(); + let mut n = Node::path(vectorcraft_doc::NodeId(0), path.clone(), vectorcraft_doc::Appearance::default()); + n.appearance = vectorcraft_doc::Appearance { items, ..vectorcraft_doc::Appearance::default() }.into(); + self.shape(&n, &path, FillRule::NonZero); + } + self.line("U"); + self.depth -= 1; + } + + // ---------- images ---------- + + /// An image (§8): `%AI5_BeginRaster`, its matrix (pixels, y down, → art space with y flipped) + /// and operands with `XI`, its RGB samples in hex on comment lines, `%AI5_EndRaster`. + fn image(&mut self, im: &ImageObject) { + let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); + let m = flip * self.to_art * im.xf; + if !invertible(m) { + return; + } + let Some(img) = self.doc.images.get(&im.key).and_then(|b| image::load_from_memory(&b.bytes).ok()) else { + self.warn(UNREADABLE_IMAGE); + return; + }; + let (w, h) = (img.width(), img.height()); + if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_IMAGE_PIXELS { + self.warn(UNREADABLE_IMAGE); + return; + } + // The object places the image's own pixel grid (its width × height) wherever the file's is. + let fit = Affine::scale_non_uniform(f64::from(im.width.max(1)) / f64::from(w), f64::from(im.height.max(1)) / f64::from(h)); + let m = flip * self.to_art * im.xf * fit; + let rgba = img.to_rgba8(); + let mut hex = String::with_capacity(rgba.len() / 4 * 7); + hex.push('%'); + let mut on_line = 0; + let mut see_through = false; + for px in rgba.pixels() { + let [r, g, b, a] = px.0; + if a < 255 { + see_through = true; + } + // Over white. + let over = |c: u8| ((u32::from(c) * u32::from(a) + 255 * (255 - u32::from(a)) + 127) / 255) as u8; + for c in [over(r), over(g), over(b)] { + let _ = write!(hex, "{c:02X}"); + on_line += 2; + if on_line >= HEX_LINE { + hex.push_str("\n%"); + on_line = 0; + } + } + } + if hex.ends_with('%') { + hex.pop(); + } else { + hex.push('\n'); + } + if see_through { + self.warn(IMAGE_ALPHA); + } + let [a, b, c, d, e, f] = m.as_coeffs(); + let s = &mut self.body; + s.push_str("%AI5_File:\n%AI5_BeginRaster\n["); + ps::push_nums(s, &[a, b, c, d, e, -f]); + s.pop(); + let _ = writeln!(s, "] 0 0 {w} {h} {w} {h} 8 3 0 0 0 0 XI"); + s.push_str(&hex); + s.push_str("%AI5_EndRaster\n"); + } +} + +/// Can `p` paint a path object's fill directly (a colour or a linear or radial gradient)? +fn direct_fill(p: &Paint) -> bool { + match p { + Paint::Solid { .. } => true, + Paint::Gradient(g) => g.gradient.kind != GradientKind::Freeform && !g.gradient.stops.is_empty(), + _ => false, + } +} + +/// Can stroke `s` be written as the stroke of a path object (a plain line of a colour)? +fn direct_stroke(s: &StrokeLayer) -> bool { + matches!(s.paint, Paint::Solid { .. }) && stroke::is_plain(s) +} + +/// The average colour of a gradient's stops (in RGB). +fn average(g: &Gradient) -> Color { + let n = g.stops.len().max(1) as f32; + let sum = g.stops.iter().map(|s| s.color.to_rgb()).fold([0.0f32; 3], |a, c| [a[0] + c[0], a[1] + c[1], a[2] + c[2]]); + Color::rgb(unit(sum[0] / n), unit(sum[1] / n), unit(sum[2] / n)) +} + +#[cfg(test)] +#[path = "tests_legacy_ai.rs"] +mod tests; diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 7959f960e..eb972210d 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -27,14 +27,17 @@ //! - `tiff`: the TIFF preview. //! - `import`: the PostScript interpreter. //! - `print`: print jobs as PostScript files ([`print()`]), their pages drawn by `scene`. +//! - `legacy_ai`: legacy `.ai` files of version 7 ([`export_legacy_ai`]), with their layers and groups. mod import; +mod legacy_ai; mod print; mod ps; mod scene; mod tiff; pub use import::{Imported, ai_alone, family_style, import, import_with, is_loss, layered_ai}; +pub use legacy_ai::{LegacyAiOptions, LegacyAiOutput, export_legacy_ai}; pub use print::{PrintJob, PrintPage, print}; use vectorcraft_doc::Document; diff --git a/crates/eps/src/tests_legacy_ai.rs b/crates/eps/src/tests_legacy_ai.rs new file mode 100644 index 000000000..ce8c0c206 --- /dev/null +++ b/crates/eps/src/tests_legacy_ai.rs @@ -0,0 +1,287 @@ +//! Legacy `.ai` files (version 7) written, then read back as File › Open reads them. + +use std::sync::Arc; + +use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, ImageBlob, ImageObject, Node, NodeKind}; +use vectorcraft_geom::{Affine, AnchorKind, FillRule, PathData, Rect, shapes}; + +use super::*; + +/// A node of `doc` filled `fill` (and stroked `stroke` 2 pt wide), path `path`. +fn shape(doc: &mut Document, path: PathData, fill: Paint, stroke: Paint) -> Node { + Node::path(doc.alloc_id(), path, Appearance::basic(fill, stroke, 2.0)) +} + +fn red() -> Paint { + Paint::solid(Color::rgb(1.0, 0.0, 0.0)) +} + +/// A 200 × 100 document: a layer `Back` holding a group of a red square (stroked blue) and a +/// locked one, a compound ring, a clipping group and a gradient square; a hidden layer `Front` +/// holding an ellipse and a 2 × 1 image. +fn sample() -> Document { + let mut doc = Document::new(200.0, 100.0); + doc.layers.clear(); + let back = doc.add_layer(Some("Back")); + let front = doc.add_layer(Some("Front")); + let a = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), red(), Paint::solid(Color::rgb(0.0, 0.0, 1.0))); + let mut b = shape(&mut doc, shapes::rectangle(Rect::new(50.0, 10.0, 80.0, 40.0)), Paint::solid(Color::gray(0.5)), Paint::None); + b.locked = true; + let group = Node::group(doc.alloc_id(), vec![Arc::new(a), Arc::new(b)]); + doc.insert(Some(back), usize::MAX, group).unwrap(); + let outer = shape(&mut doc, shapes::rectangle(Rect::new(100.0, 10.0, 140.0, 50.0)), Paint::None, Paint::None); + let inner = shape(&mut doc, shapes::rectangle(Rect::new(110.0, 20.0, 130.0, 40.0)), Paint::None, Paint::None); + let mut ring = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(inner)], rule: FillRule::EvenOdd }); + ring.appearance = Appearance::basic(Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)), Paint::None, 0.0).into(); + doc.insert(Some(back), usize::MAX, ring).unwrap(); + let clip = Node::new( + doc.alloc_id(), + NodeKind::Path { + path: shapes::rectangle(Rect::new(160.0, 20.0, 180.0, 40.0)), + rule: FillRule::NonZero, + live: None, + clipping: true, + guide: false, + }, + ); + let clipped = shape(&mut doc, shapes::rectangle(Rect::new(150.0, 10.0, 190.0, 50.0)), Paint::solid(Color::gray(0.25)), Paint::None); + let window = Node::new(doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(clipped)], clip: true }); + doc.insert(Some(back), usize::MAX, window).unwrap(); + let g = Gradient { + kind: GradientKind::Linear, + stops: vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 0.0))], + }; + let shaded = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 60.0, 50.0, 90.0)), Paint::Gradient(Box::new(GradientPaint::new(g))), Paint::None); + doc.insert(Some(back), usize::MAX, shaded).unwrap(); + let ellipse = shape(&mut doc, shapes::ellipse(Rect::new(100.0, 60.0, 160.0, 90.0)), red(), Paint::None); + doc.insert(Some(front), usize::MAX, ellipse).unwrap(); + let mut png = vec![]; + let img = image::RgbaImage::from_raw(2, 1, vec![255, 0, 0, 255, 0, 0, 255, 255]).unwrap(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let blob = ImageBlob::new("image/png", png); + let key = blob.content_key(); + doc.images.insert(key.clone(), blob); + let im = + ImageObject { key, width: 2, height: 1, xf: Affine::new([10.0, 0.0, 0.0, 10.0, 170.0, 70.0]), link: None, placement: Default::default() }; + let im = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im))); + doc.insert(Some(front), usize::MAX, im).unwrap(); + if let Some(l) = doc.layers.get_mut(1) { + Arc::make_mut(l).visible = false; + } + doc +} + +fn write(doc: &Document) -> LegacyAiOutput { + export_legacy_ai(doc, &LegacyAiOptions { title: "Sample".into(), created: Some(0) }).unwrap() +} + +fn children(n: &Node) -> &[Arc] { + n.children().map_or(&[][..], Vec::as_slice) +} + +#[test] +fn writes_the_documented_operators() { + let out = write(&sample()); + let text = String::from_utf8(out.bytes).unwrap(); + for needle in [ + "%!PS-Adobe-3.0\n", + "%AI5_FileFormat 3\n", + "%%CreationDate: (1/1/1970) (00:00)\n", + "%AI5_ArtSize: 200 100", + "%AI3_TemplateBox: 100 50 100 50", + "%AI5_NumLayers: 2", + "(Back) Ln", + "(Front) Ln", + "%AI5_BeginGradient: (Gradient)", + "\nu\n", + "\n*u\n", + "\nq\n", + "\n1 A\n", + "XI\n", + "%%EOF\n", + ] { + assert!(text.contains(needle), "missing {needle:?} in\n{text}"); + } + // The hidden layer is written hidden: visible 0. + assert!(text.contains("%AI5_BeginLayer\n0 1 1 1 0 0 1 "), "{text}"); + // Art space is y up from the artboard's bottom-left: the red square (10..40 down from the top) + // is 60..90 up. Its corners are corner points (upper case, AI7 spec §5.3.2); its fill RGB + // (`r g b Xa`, §5.4.1). + assert!(text.contains("1 0 0 Xa\n"), "{text}"); + assert!(text.contains("10 90 m\n40 90 L\n40 60 L\n10 60 L\n10 90 L\nb\n"), "{text}"); + // The ellipse's points are smooth (lower case). + assert!(text.contains(" c\n") && !text.contains(" C\n"), "{text}"); + // The clipping path: closed by `h`, then `W n` (§5.6.2). + assert!(text.contains(" L\nh\nW\nn\n"), "{text}"); + // Gradients: counted (`Bn`), each stop's operands before `%_Bs` (§3.5). + assert!(text.contains("%%BeginSetup\n1 Bn\n%AI5_BeginGradient: (Gradient)\n(Gradient) 0 2 Bd\n[\n"), "{text}"); + assert!(text.contains(" 2 50 100 %_Bs\n") && !text.contains("Np\n"), "{text}"); + assert!(text.contains("1 0 0 1 0 0 Bg\nf\n0 BB\n"), "{text}"); + // The image: operands and `XI` on a line, its samples on comment lines (§8). + assert!(text.contains("] 0 0 2 1 2 1 8 3 0 0 0 0 XI\n%FF00000000FF\n%AI5_EndRaster\n"), "{text}"); +} + +#[test] +fn a_gradient_fill_and_its_stroke_are_one_object() { + let mut doc = Document::new(100.0, 100.0); + let g = Gradient { + kind: GradientKind::Radial, + stops: vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 1.0))], + }; + let mut paint = GradientPaint::new(g); + paint.geom = Some(vectorcraft_color::GradientGeom { + start: vectorcraft_geom::Point::new(30.0, 30.0), + end: vectorcraft_geom::Point::new(50.0, 30.0), + aspect: 1.0, + focal: Some(vectorcraft_geom::Point::new(35.0, 30.0)), + }); + let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0)), Paint::Gradient(Box::new(paint)), red()); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let text = String::from_utf8(write(&doc).bytes).unwrap(); + // `2 BB` strokes the filled path (§3.5.3); the highlight is a quarter of the radius out (§3.5.5). + assert!(text.contains("Bb\n5 0 0 0.25 Bh\n1 (Gradient) 30 70 0 20 1 0 0 1 0 0 Bg\nf\n2 BB\n"), "{text}"); + let doc = crate::import(text.as_bytes()).unwrap().document; + let n = &children(&doc.layers[0])[0]; + let Paint::Gradient(g) = n.appearance.fill_paint() else { panic!("{:?}", n.appearance) }; + assert_eq!(g.gradient.kind, GradientKind::Radial); + assert_eq!(n.appearance.stroke_paint().color(), Some(Color::rgb(1.0, 0.0, 0.0))); +} + +#[test] +fn reads_back_with_layers_groups_and_paths() { + let out = write(&sample()); + let imported = crate::import(&out.bytes).unwrap(); + let doc = imported.document.clone(); + let names: Vec<_> = doc.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert_eq!(names, ["Back", "Front"], "{:?}", imported.warnings); + assert!(doc.layers[0].visible); + assert!(!doc.layers[1].visible); + let back = children(&doc.layers[0]); + assert_eq!(back.len(), 4, "{back:#?}"); + // The group of two squares, the second locked. + let NodeKind::Group { children: pair, clip: false } = &back[0].kind else { panic!("{:?}", back[0].kind) }; + assert_eq!(pair.len(), 2); + assert!(!pair[0].locked && pair[1].locked); + let a = &pair[0]; + assert_eq!(a.appearance.fill_paint().color(), Some(Color::rgb(1.0, 0.0, 0.0))); + assert_eq!(a.appearance.stroke_paint().color(), Some(Color::rgb(0.0, 0.0, 1.0))); + // Four corners: the start point written again to close it isn't a fifth. + let sq = a.path_data().unwrap(); + assert!(sq.subpaths[0].closed && sq.subpaths[0].anchors.len() == 4, "{sq:?}"); + assert!(sq.subpaths[0].anchors.iter().all(|p| p.kind == AnchorKind::Corner), "{sq:?}"); + let bounds = a.visual_bounds().unwrap(); + assert!((bounds.x0 - 9.0).abs() < 0.01 && (bounds.y0 - 9.0).abs() < 0.01, "{bounds:?}"); + // The ring: a compound path, even-odd. + assert!(matches!(&back[1].kind, NodeKind::Compound { children, rule: FillRule::EvenOdd } if children.len() == 2), "{:?}", back[1].kind); + // The clipping group: its clipping path first. + let NodeKind::Group { children: window, clip: true } = &back[2].kind else { panic!("{:?}", back[2].kind) }; + assert!(matches!(window[0].kind, NodeKind::Path { clipping: true, .. })); + // The gradient square. + let Paint::Gradient(g) = back[3].appearance.fill_paint() else { panic!("{:?}", back[3].appearance) }; + assert_eq!(g.gradient.kind, GradientKind::Linear); + assert_eq!(g.gradient.stops.len(), 2); + assert_eq!(g.gradient.stops[0].color, Color::rgb(1.0, 1.0, 1.0)); + // The hidden layer: the ellipse with smooth points, and the image. + let front = children(&doc.layers[1]); + assert_eq!(front.len(), 2, "{:?} {:?}", front.iter().map(|n| &n.kind).collect::>(), imported.warnings); + let path = front[0].path_data().unwrap(); + assert_eq!(path.subpaths.len(), 1); + assert!(path.subpaths[0].closed); + assert!(path.subpaths[0].anchors.iter().all(|a| a.kind == AnchorKind::Smooth), "{path:?}"); + assert_eq!(path.subpaths[0].anchors.len(), 4, "{path:?}"); + let NodeKind::Image(im) = &front[1].kind else { panic!("{:?}", front[1].kind) }; + assert_eq!((im.width, im.height), (2, 1)); + let b = front[1].visual_bounds().unwrap(); + assert!((b.x0 - 170.0).abs() < 0.01 && (b.y0 - 70.0).abs() < 0.01 && (b.x1 - 190.0).abs() < 0.01, "{b:?}"); +} + +#[test] +fn spot_colours_are_custom_colours() { + let mut doc = Document::new(100.0, 100.0); + doc.swatches.push(vectorcraft_color::Swatch { + name: "Spot Test".into(), + paint: Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0)), + global: true, + spot: true, + }); + let fill = Paint::Solid { color: Color::cmyk(0.0, 0.25, 0.5, 0.0), swatch: Some("Spot Test".into()), tint: 0.5 }; + let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0)), fill, Paint::None); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = write(&doc); + let text = String::from_utf8(out.bytes.clone()).unwrap(); + assert!(text.contains("%%DocumentCustomColors: (Spot Test)"), "{text}"); + assert!(text.contains("0 0.5 1 0 (Spot Test) 0.5 x"), "{text}"); + let doc = crate::import(&out.bytes).unwrap().document; + let n = &children(&doc.layers[0])[0]; + let Paint::Solid { swatch, tint, .. } = n.appearance.fill_paint() else { panic!() }; + assert_eq!(swatch.as_deref(), Some("Spot Test")); + assert!((tint - 0.5).abs() < 1e-6); +} + +#[test] +fn losses_are_warned() { + let mut doc = sample(); + // A sublayer, a hidden object, a named object, type and half opacity. + let back = doc.layers[0].id; + let sub = Node::layer(doc.alloc_id(), "Sub", vectorcraft_doc::LayerColor::Preset(2)); + let sub = doc.insert(Some(back), usize::MAX, sub).unwrap(); + let mut hidden = shape(&mut doc, shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), red(), Paint::None); + hidden.visible = false; + doc.insert(Some(sub), usize::MAX, hidden).unwrap(); + let mut named = shape(&mut doc, shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), red(), Paint::None); + named.name = Some("Named".into()); + named.opacity = 0.5; + doc.insert(Some(sub), usize::MAX, named).unwrap(); + let w = write(&doc).warnings; + for expected in [SUBLAYERS, HIDDEN, NAMES, TRANSPARENCY] { + assert!(w.iter().any(|x| x == expected), "{expected} not in {w:?}"); + } + // The sublayer is a group in its layer. + let doc = crate::import(&write(&doc).bytes).unwrap().document; + let last = children(&doc.layers[0]).last().unwrap(); + assert!(matches!(&last.kind, NodeKind::Group { children, .. } if children.len() == 1), "{:?}", last.kind); +} + +#[test] +fn several_fills_and_odd_strokes_become_objects() { + let mut doc = Document::new(100.0, 100.0); + let mut n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0)), red(), Paint::solid(Color::gray(1.0))); + let mut a = (*n.appearance).clone(); + if let Some(AppearanceItem::Stroke(s)) = a.items.iter_mut().find(|i| !i.is_fill()) { + s.align = vectorcraft_doc::StrokeAlign::Inside; + } + n.appearance = a.into(); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w.contains("filled outlines")), "{:?}", out.warnings); + let doc = crate::import(&out.bytes).unwrap().document; + let n = &children(&doc.layers[0])[0]; + // The fill, then the stroke's outline filled. + assert!(matches!(&n.kind, NodeKind::Group { children, .. } if children.len() == 2), "{:?}", n.kind); +} + +#[test] +fn art_stays_put_on_an_artboard_a_fraction_of_a_point_tall() { + let mut doc = Document::new(100.0, 364.252); + let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 20.0, 30.0, 40.0)), red(), Paint::None); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let text = String::from_utf8(write(&doc).bytes).unwrap(); + // The artboard's top is on a whole point (365); the template box's centre is the artboard's. + assert!(text.contains("%AI3_TemplateBox: 50 182.874 50 182.874\n"), "{text}"); + assert!(text.contains("10 345 m\n"), "{text}"); + let doc = crate::import(text.as_bytes()).unwrap().document; + let b = children(&doc.layers[0])[0].visual_bounds().unwrap(); + assert!((b.x0 - 10.0).abs() < 1e-3 && (b.y0 - 20.0).abs() < 1e-3 && (b.y1 - 40.0).abs() < 1e-3, "{b:?}"); +} + +#[test] +fn needs_an_artboard() { + let mut doc = Document::new(100.0, 100.0); + doc.artboards.clear(); + assert!(export_legacy_ai(&doc, &LegacyAiOptions::default()).is_err()); +} From bcae7ca4860d4ea569f89b4511ce4ec43fbbf0a2 Mon Sep 17 00:00:00 2001 From: Dan Green Date: Mon, 12 Oct 2026 19:08:16 -0700 Subject: [PATCH 02/10] Part of #868: Save As .ai offers Legacy (version 7), with Transparent Images and an artboard range aiVersion 7 saves through the legacy writer, one file per artboard; range picks the artboards and document.save writes them again. Appearances are expanded first. Transparent images (see-through pixels, opacity or a blending mode) are flattened with the art under them into opaque images clipped to where they change it (transparentImages "flatten", the default), or left out ("omit"); both warn. --- crates/engine/src/cmd/effectcmd.rs | 2 +- crates/engine/src/cmd/fileio/legacy_ai.rs | 278 ++++++++++++++++++++++ crates/engine/src/cmd/fileio/mod.rs | 1 + crates/engine/src/cmd/fileio/native.rs | 12 + crates/engine/src/cmd/fileio/save.rs | 21 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_legacy_ai.rs | 229 ++++++++++++++++++ 7 files changed, 539 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/legacy_ai.rs create mode 100644 crates/engine/src/tests_legacy_ai.rs diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 801491671..292a85b97 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -407,7 +407,7 @@ fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects: /// Expand Appearance of `id` (and, recursively, of a group's or layer's members): every fill and /// stroke becomes an object of its own (strokes outlined, brushes as their art), geometry effects /// are baked, raster effects become an embedded image, and the object keeps its transparency. -fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec) { +pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec) { let Some(mut n) = d.node(id).cloned() else { return }; if !expandable(&n) { return; diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs new file mode 100644 index 000000000..e0961b61c --- /dev/null +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -0,0 +1,278 @@ +//! Save As › `.ai` with `aiVersion: "7"`: legacy `.ai` files of version 7 (the PostScript-form `.ai` +//! that `vectorcraft_eps::export_legacy_ai` writes), which keep the layers, groups, compound +//! paths and clipping groups that a PDF-compatible `.ai` loses in apps reading only its PDF part +//! (#868). +//! +//! The format has one artboard: each chosen artboard (default all) is a file of its own, holding +//! the art that touches it, named `{stem}-{artboard}.ai` beside the path when there are several. +//! Appearances the format can't hold (several fills or strokes, effects, brushes) are expanded into +//! objects. Transparency isn't flattened: flattening merges the art that overlaps transparent +//! objects, across layers, into one flattened result, which empties the layers under them, and +//! editable layers are what this format is saved for. Transparent objects are written opaque (a +//! warning says so). +//! +//! Images are the exception (`transparentImages`): an image with see-through pixels, opacity or a +//! blending mode written opaque is a box hiding the art under it (version 7 images have no alpha +//! channel, §8.1). With `flatten` (the default) each becomes an opaque image of itself and the art +//! under it, rendered from the document as it is, clipped to the area where it changes what shows; +//! the art under it stays as it was. With `omit` they are left out. + +use std::sync::Arc; + +use serde_json::Value; +use vectorcraft_color::BlendMode; +use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; +use vectorcraft_geom::{Anchor, FillRule, PathData, Point, Rect, SubPath}; + +use super::super::*; +use super::{ArtboardPick, Encoded, Format}; + +/// Does `p` ask for a legacy `.ai` file of version 7 (`aiVersion: "7"`)? An unknown version is an error. +pub(super) fn wanted(cmd: &str, p: &Value) -> Result { + match p.get("aiVersion") { + None | Some(Value::Null) => Ok(false), + Some(Value::String(v)) if v.trim().is_empty() || v.eq_ignore_ascii_case("pdf") => Ok(false), + Some(Value::String(v)) if v.trim() == "7" => Ok(true), + Some(Value::Number(n)) if n.as_f64() == Some(7.0) => Ok(true), + Some(v) => Err(bad(cmd, format!("aiVersion {v}: \"pdf\" (PDF-compatible, the default) or \"7\" (legacy .ai, version 7)"))), + } +} + +/// What a legacy `.ai` does with the images it can't hold (`transparentImages`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SeeThrough { + Flatten, + Omit, +} + +fn see_through(cmd: &str, p: &Value) -> Result { + match p.get("transparentImages") { + None | Some(Value::Null) => Ok(SeeThrough::Flatten), + Some(Value::String(v)) if v.trim().is_empty() || v.trim().eq_ignore_ascii_case("flatten") => Ok(SeeThrough::Flatten), + Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("omit") => Ok(SeeThrough::Omit), + Some(v) => Err(bad(cmd, format!("transparentImages {v}: \"flatten\" (the default) or \"omit\""))), + } +} + +/// What the format loses against a VectorCraft document, reported with every save. +const FIDELITY: &str = "a legacy .ai (version 7) keeps layers, groups, compound paths, clipping groups, colours, gradients and images, not the rest: save as VectorCraft to keep everything editable"; +const FLATTENED: &str = + "transparent images (see-through pixels, opacity or a blending mode) are flattened with the art under them into opaque images"; +const OMITTED: &str = "transparent images (see-through pixels, opacity or a blending mode) are left out"; +const SEPARATE: &str = "legacy .ai files have one artboard: each artboard is saved to a file of its own"; + +/// Encode `doc` as legacy `.ai` files of version 7: one per chosen artboard (`artboard`, `artboards` or +/// `range`; default all). +pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result { + let n = doc.artboards.len(); + if n == 0 { + return Err(bad(cmd, "the document has no artboard")); + } + let mode = see_through(cmd, p)?; + let picked: ArtboardPick = super::encode::options(f, p)?; + let boards = picked.resolve(n).map_err(|e| bad(cmd, e))?.unwrap_or_else(|| (0..n).collect()); + let mut enc = Encoded { warnings: vec![FIDELITY.to_string()], ..Encoded::default() }; + if boards.len() > 1 { + enc.warnings.push(SEPARATE.to_string()); + } + let created = vectorcraft_doc::metadata::now_unix(); + for b in boards { + let one = super::save::artboard_doc(doc, b); + let (full, placed) = crate::cmd::place::document::full_documents(&one); + let mut d = full.into_owned(); + expand_appearances(&mut d); + let images = see_through_images(&d); + if !images.is_empty() { + note(&mut enc.warnings, if mode == SeeThrough::Flatten { FLATTENED } else { OMITTED }); + if mode == SeeThrough::Flatten { + flatten_images(&mut d, &images)?; + } else { + for (_, id) in images { + let _ = d.remove(id); // Inside an image left out before: gone already. + } + } + } + let o = vectorcraft_eps::LegacyAiOptions { title: doc.title.clone(), created }; + let out = vectorcraft_eps::export_legacy_ai(&d, &o).map_err(|e| bad(cmd, e))?; + for w in placed.iter().chain(&out.warnings) { + note(&mut enc.warnings, w); + } + enc.files.push((Some(b), out.bytes)); + } + Ok(enc) +} + +/// Expand Appearance of every layer's art whose appearance the format can't hold. +fn expand_appearances(d: &mut Document) { + let brushes = vectorcraft_brush::library(d); + let layers: Vec = d.layers.iter().map(|l| l.id).collect(); + let mut out = vec![]; + for id in layers { + super::super::effectcmd::expand_node(d, id, &brushes, &mut out); + } +} + +/// The images of `d` a legacy `.ai` can't hold, in paint order with their places in it (as +/// [`Document::walk`] counts): visible ones with see-through pixels, opacity or a blending mode. +fn see_through_images(d: &Document) -> Vec<(usize, NodeId)> { + let keys = super::super::flatten::see_through_images(d); + let mut out = vec![]; + let mut at = 0; + d.walk(|n| { + if let NodeKind::Image(im) = &n.kind + && (keys.contains(&im.key) || n.opacity < 1.0 || n.blend != BlendMode::Normal) + { + out.push((at, n.id)); + } + at += 1; + }); + out.retain(|(_, id)| d.is_visible(*id)); + out +} + +/// Replace each of `images` (from [`see_through_images`]) with an opaque image of what shows +/// where it is, clipped to where it changes that; one that changes nothing is left out. Each is +/// rendered from `d` as it was, so images over one another flatten as they look. +fn flatten_images(d: &mut Document, images: &[(usize, NodeId)]) -> Result<()> { + let src = d.clone(); + for &(at, id) in images { + let Some((parent, idx, _)) = d.position(id) else { continue }; + let flat = flattened(&src, at, id)?; + d.remove(id)?; + if let Some(flat) = flat { + let png = flat.png; + let key = super::super::menucmds::unique_key(d, "flattened"); + d.images.insert(key.clone(), ImageBlob::new("image/png", png)); + let image = ImageObject { key, width: flat.width, height: flat.height, xf: flat.xf, link: None, placement: Default::default() }; + let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); + let mut group = super::super::rasterfx::clip_group(d, flat.clip, FillRule::NonZero, image); + group.name = src.node(id).and_then(|n| n.name.clone()); + d.insert(parent, idx, group)?; + } + } + Ok(()) +} + +/// An image made by [`flattened`]: PNG pixels, their place, and the outline they are clipped to. +struct Flat { + png: Vec, + width: u32, + height: u32, + xf: Affine, + clip: PathData, +} + +/// Highest and lowest resolution of a flattened image (pixels per inch); it takes the image's own +/// in between. +const MAX_PPI: f64 = 600.0; +const MIN_PPI: f64 = 72.0; +/// A rendered pixel channel changes when it changes by more than this. +const CHANGED: u8 = 2; +/// The clip outline follows cells of about this many points. +const CELL: f64 = 2.0; + +/// The image `id` (the `at`th node in paint order of `src`) flattened: `src` drawn up to and +/// including it, over white, cropped and clipped to the cells where drawing it changes the +/// result. `None` when it changes nothing. +fn flattened(src: &Document, at: usize, id: NodeId) -> Result> { + let (Some(n), Some(bounds)) = (src.node(id), src.bounds_of(&[id], true)) else { return Ok(None) }; + let NodeKind::Image(im) = &n.kind else { return Ok(None) }; + // The image's own resolution (points per pixel from its placement). + let pt = im.xf.determinant().abs().sqrt(); + let ppi = if pt.is_finite() && pt > 0.0 { (72.0 / pt).clamp(MIN_PPI, MAX_PPI) } else { MIN_PPI }; + let area = (bounds.width() * bounds.height()).max(1.0); + let scale = (ppi / 72.0).min((super::super::menucmds::MAX_PIXELS / area).sqrt()); + if !(scale.is_finite() && scale > 0.0) { + return Ok(None); + } + let region = Rect::new( + bounds.x0, + bounds.y0, + bounds.x0 + (bounds.width() * scale).ceil().max(1.0) / scale, + bounds.y0 + (bounds.height() * scale).ceil().max(1.0) / scale, + ); + let mut with = src.clone(); + let mut i = 0; + with.layers = src.layers.iter().filter_map(|l| up_to(l, at, &mut i)).map(Arc::new).collect(); + let mut without = with.clone(); + without.remove(id)?; + let mut r = vectorcraft_render::Renderer::new(); + let shown = r.render_region(&with, region, scale, true); + let under = r.render_region(&without, region, scale, true); + let (w, h) = (shown.width as usize, shown.height as usize); + if w == 0 || h == 0 || shown.pixels.len() != w * h * 4 || under.pixels.len() != shown.pixels.len() { + return Ok(None); + } + // The cells where it changes anything. + let c = (CELL * scale).round().max(1.0) as usize; + let (cw, ch) = (w.div_ceil(c), h.div_ceil(c)); + let mut cells = vec![false; cw * ch]; + for (k, (a, b)) in shown.pixels.as_chunks::<4>().0.iter().zip(under.pixels.as_chunks::<4>().0).enumerate() { + if a.iter().zip(b).any(|(a, b)| a.abs_diff(*b) > CHANGED) { + let (x, y) = (k % w, k / w); + if let Some(cell) = cells.get_mut(y / c * cw + x / c) { + *cell = true; + } + } + } + let marked = || cells.iter().enumerate().filter(|(_, on)| **on).map(|(k, _)| (k % cw, k / cw)); + let (Some(x0), Some(x1), Some(y0), Some(y1)) = + (marked().map(|p| p.0).min(), marked().map(|p| p.0).max(), marked().map(|p| p.1).min(), marked().map(|p| p.1).max()) + else { + return Ok(None); + }; + // Crop to them. + let (px0, py0) = (x0 * c, y0 * c); + let (px1, py1) = (((x1 + 1) * c).min(w), ((y1 + 1) * c).min(h)); + let mut pixels = Vec::with_capacity((px1 - px0) * (py1 - py0) * 4); + for y in py0..py1 { + pixels.extend_from_slice(shown.pixels.get((y * w + px0) * 4..(y * w + px1) * 4).unwrap_or_default()); + } + let crop = vectorcraft_render::Rendered { width: (px1 - px0) as u32, height: (py1 - py0) as u32, pixels }; + let png = crop.to_png().map_err(EngineError::Other)?; + let at = |x: usize, y: usize| Point::new(region.x0 + (x.min(w)) as f64 / scale, region.y0 + (y.min(h)) as f64 / scale); + let mut clip = PathData::new(vec![]); + for comp in vectorcraft_trace::trace_mask(&cells, cw, ch) { + for lp in std::iter::once(&comp.outer).chain(&comp.holes) { + let pts: Vec<(i32, i32)> = corners(&lp.pts); + let anchors = pts.iter().map(|&(x, y)| Anchor::corner(at(x.max(0) as usize * c, y.max(0) as usize * c))).collect(); + clip.subpaths.push(SubPath::new(anchors, true)); + } + } + let xf = Affine::translate(at(px0, py0).to_vec2()) * Affine::scale(1.0 / scale); + Ok(Some(Flat { png, width: crop.width, height: crop.height, xf, clip })) +} + +/// `n` without the nodes after the `last`th in paint order (`i` counts them, as +/// [`Document::walk`] does); `None` when `n` itself comes after it. +fn up_to(n: &Node, last: usize, i: &mut usize) -> Option { + if *i > last { + return None; + } + *i += 1; + let mut n = n.clone(); + if let Some(ch) = n.children_mut() { + let kept: Vec> = ch.iter().filter_map(|c| up_to(c, last, i)).map(Arc::new).collect(); + *ch = kept; + } + Some(n) +} + +/// A loop's corners: its points without those in the middle of a straight run. +fn corners(pts: &[(i32, i32)]) -> Vec<(i32, i32)> { + let n = pts.len(); + let get = |k: usize| pts.get(k % n.max(1)).copied(); + (0..n) + .filter_map(|k| { + let (a, b, c) = (get(k + n - 1)?, get(k)?, get(k + 1)?); + let straight = (a.0 == b.0 && b.0 == c.0) || (a.1 == b.1 && b.1 == c.1); + (!straight).then_some(b) + }) + .collect() +} + +fn note(warnings: &mut Vec, w: &str) { + if !warnings.iter().any(|x| x == w) { + warnings.push(w.to_string()); + } +} diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 9c078146b..b84bc0dae 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -27,6 +27,7 @@ mod encode; pub mod eps; mod export; pub(crate) mod imagemap; +mod legacy_ai; mod load; pub(crate) use load::{MAX_RASTER_ALLOC, too_large_note}; mod metafile; diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index 83d9ab452..2874b95a7 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -99,6 +99,18 @@ pub const AI_OPTIONS: &[FormatOption] = &[ default: "true", description: "compress the PDF's content and the native document it carries (the PDF option compression.compressText)", }, + FormatOption { + name: "aiVersion", + ty: "string", + default: "\"pdf\"", + description: "pdf (a PDF-compatible .ai carrying the VectorCraft document) or 7 (the legacy, PostScript-form .ai of version 7, which keeps layers, groups, compound paths and clipping groups in apps that read it; one file per artboard, transparent objects opaque, type as outlines; the other .ai options don't apply)", + }, + FormatOption { + name: "transparentImages", + ty: "string", + default: "\"flatten\"", + description: "with aiVersion 7, the images version 7 can't hold (see-through pixels, opacity or a blending mode): flatten (the default: each becomes an opaque image of itself and the art under it, clipped to where it changes what shows; that art stays as it is) or omit (left out)", + }, ]; #[derive(Default, Deserialize)] diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index fdc8584c7..36925bf41 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -335,7 +335,8 @@ impl std::io::Write for Counted { /// The artboards a native or `.ai` save also writes to files of their own: with /// `separateArtboards`, those `range` names (default all). fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result> { - if !is_native(f) || !bool_or(p, "separateArtboards", false) { + // A legacy .ai file is a file per artboard already. + if !is_native(f) || !bool_or(p, "separateArtboards", false) || super::legacy_ai::wanted(cmd, p)? { return Ok(vec![]); } let range = str_param(p, "range").filter(|r| !r.trim().is_empty()).map(str::to_string); @@ -346,7 +347,7 @@ fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result Document { +pub(super) fn artboard_doc(doc: &Document, i: usize) -> Document { let mut d = doc.clone(); let Some(a) = doc.artboards.get(i) else { return d }; let r = a.rect; @@ -387,6 +388,9 @@ const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens i /// The shared Save and Export encoder for PDF-compatible `.ai` files. pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result { + if super::legacy_ai::wanted(cmd, p)? { + return super::legacy_ai::encode(cmd, f, doc, p); + } let mut params = if p.is_object() { p.clone() } else { json!({}) }; if let Some(o) = params.as_object_mut() { o.insert("preserveEditing".into(), json!(true)); @@ -463,9 +467,14 @@ fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option Result { let (cmd, f) = (self.plan.mode.command(), self.plan.format); let enc = if f.id == "ai" { encode_ai(cmd, f, doc, &self.params)? } else { encode_all(doc, f.id, &self.params)? }; - if enc.files.len() != 1 { + // A legacy .ai file per artboard. + let per_artboard = f.id == "ai" && super::legacy_ai::wanted(cmd, &self.params)?; + if enc.files.len() != 1 && !per_artboard { return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export")); } Ok(enc) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2d906d5a0..29fc89604 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1854,6 +1854,8 @@ mod tests_layerclip; #[cfg(test)] mod tests_layers; #[cfg(test)] +mod tests_legacy_ai; +#[cfg(test)] mod tests_library; #[cfg(test)] mod tests_linked_stops; diff --git a/crates/engine/src/tests_legacy_ai.rs b/crates/engine/src/tests_legacy_ai.rs new file mode 100644 index 000000000..79f41c822 --- /dev/null +++ b/crates/engine/src/tests_legacy_ai.rs @@ -0,0 +1,229 @@ +//! Save As › `.ai` with `aiVersion: "7"`: legacy `.ai` files of version 7, one per artboard, that open with +//! their layers and groups (#868). + +use serde_json::{Value, json}; + +use super::*; +use crate::tests_links::Folder; + +/// Three artboards (named A, B and C) with a rectangle on each, on two layers: the first +/// rectangle's group (with a second one) on `Layer 1`, the others on `Top`. +fn three_boards() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 0, "name": "A"})).unwrap(); + for (i, name) in ["B", "C"].iter().enumerate() { + let x = 200.0 * (i + 1) as f64; + s.execute("artboard.new", &json!({"x": x, "y": 0, "width": 100, "height": 100, "name": name})).unwrap(); + } + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 50, "y": 10, "width": 30, "height": 30})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.execute("layer.new", &json!({"name": "Top"})).unwrap(); + for x in [210, 410] { + s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 30, "height": 30})).unwrap(); + } + s +} + +fn layer_names(d: &vectorcraft_doc::Document) -> Vec { + d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect() +} + +fn warnings(r: &Value) -> Vec { + r["warnings"].as_array().map(|w| w.iter().filter_map(|w| w.as_str().map(str::to_string)).collect()).unwrap_or_default() +} + +#[test] +fn each_artboard_is_a_file_that_opens_with_its_layers() { + let dir = Folder::new("ai8"); + let mut s = three_boards(); + let path = dir.file("Poster.ai"); + let r = s.execute("file.saveAs", &json!({"path": path, "format": "ai", "aiVersion": "7"})).unwrap(); + let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect(); + assert_eq!(files, [dir.file("Poster-A.ai"), dir.file("Poster-B.ai"), dir.file("Poster-C.ai")], "{r}"); + assert!(warnings(&r).iter().any(|w| w.contains("file of its own")), "{r}"); + for (f, objects) in [(files[0], 1), (files[1], 1), (files[2], 1)] { + let bytes = std::fs::read(f).unwrap(); + assert!(bytes.starts_with(b"%!PS-Adobe-3.0\n"), "{f}"); + s.execute("document.open", &json!({"path": f})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.artboards.len(), 1, "{f}"); + assert_eq!(layer_names(d), ["Layer 1", "Top"], "{f}"); + let count: usize = d.layers.iter().map(|l| l.children().map_or(0, Vec::len)).sum(); + assert_eq!(count, objects, "{f}"); + } + // Artboard A's file: the group of two rectangles in Layer 1, nothing on Top. + s.execute("document.open", &json!({"path": files[0]})).unwrap(); + let d = &s.doc().unwrap().doc; + let group = &d.layers[0].children().unwrap()[0]; + assert!(matches!(&group.kind, vectorcraft_doc::NodeKind::Group { children, clip: false } if children.len() == 2), "{:?}", group.kind); + assert!(d.layers[1].children().unwrap().is_empty()); +} + +#[test] +fn one_artboard_is_the_file_itself_and_save_writes_it_again() { + let dir = Folder::new("ai8-one"); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); + let path = dir.file("One.ai"); + let r = s.execute("file.saveAs", &json!({"path": path, "format": "ai", "aiVersion": "7"})).unwrap(); + assert!(r["files"].is_null(), "{r}"); + assert!(std::fs::read(&path).unwrap().starts_with(b"%!PS")); + // Save remembers the version. + std::fs::remove_file(&path).unwrap(); + s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 30, "height": 30})).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + assert!(std::fs::read(&path).unwrap().starts_with(b"%!PS")); +} + +#[test] +fn the_default_stays_pdf_compatible_and_other_versions_are_refused() { + let mut s = three_boards(); + for p in [json!({"format": "ai"}), json!({"format": "ai", "aiVersion": "pdf"})] { + let r = s.execute("file.saveCopy", &p).unwrap(); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + assert!(bytes.starts_with(b"%PDF"), "{p}"); + } + assert!(s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "3"})).is_err()); + let v = s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap(); + assert_eq!(v["options"]["aiVersion"]["value"], "pdf", "{v}"); +} + +#[test] +fn transparency_type_and_appearances_are_written_as_plain_art() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 30, "y": 30, "width": 50, "height": 50})).unwrap(); + s.execute("transparency.set", &json!({"opacity": 50})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 120, "y": 10, "width": 30, "height": 30})).unwrap(); + s.execute("appearance.addFill", &json!({})).unwrap(); + s.execute("text.create", &json!({"x": 100, "y": 80, "text": "Hi", "size": 24})).unwrap(); + let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap(); + let w = warnings(&r); + assert!(w.iter().any(|w| w.contains("written opaque")), "{w:?}"); + assert!(w.iter().any(|w| w.contains("outlines")), "{w:?}"); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + let text = String::from_utf8(bytes.clone()).unwrap(); + // No transparency operators: version 7 has none. + assert!(!text.contains(" Xy\n"), "{text}"); + s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(d.layers.iter().all(|l| l.children().is_some_and(|c| !c.is_empty()))); + let mut opaque = true; + d.walk(|n| opaque &= n.opacity == 1.0); + assert!(opaque); +} + +#[test] +fn art_under_transparent_art_stays_in_its_own_layer() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap(); + s.execute("layer.new", &json!({"name": "Shade"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap(); + s.execute("transparency.set", &json!({"opacity": 50, "blend": "multiply"})).unwrap(); + let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap(); + s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(layer_names(d), ["Layer 1", "Shade"]); + assert!(d.layers.iter().all(|l| l.children().is_some_and(|c| c.len() == 1)), "{:?}", d.layers); +} + +/// A `w`×`h` PNG of one colour (`rgba`), 72 ppi. +fn png(w: u32, h: u32, rgba: [u8; 4]) -> Vec { + let mut out = vec![]; + image::RgbaImage::from_pixel(w, h, image::Rgba(rgba)).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// A red square (10–50 × 10–50) half under a half-transparent black image (30–70 × 10–50), and +/// an opaque blue image (140–160 × 40–60). +fn shadow_over_square() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + for (bytes, at) in [(png(40, 40, [0, 0, 0, 128]), [50, 30]), (png(20, 20, [0, 0, 255, 255]), [150, 50])] { + let p = json!({"name": "i.png", "dataBase64": vectorcraft_format::base64_encode(&bytes), "at": at}); + s.execute("file.place", &p).unwrap(); + } + s +} + +/// The colour `d` shows at (`x`, `y`), over white. +fn colour_at(d: &vectorcraft_doc::Document, x: f64, y: f64) -> [u8; 3] { + let px = vectorcraft_render::Renderer::new().render_region(d, vectorcraft_geom::Rect::new(x, y, x + 1.0, y + 1.0), 1.0, true).pixels; + [px[0], px[1], px[2]] +} + +fn images(d: &vectorcraft_doc::Document) -> (usize, usize) { + let (mut all, mut clipped) = (0, 0); + d.walk(|n| { + if let vectorcraft_doc::NodeKind::Group { children, clip: true } = &n.kind { + clipped += children.iter().filter(|c| matches!(c.kind, vectorcraft_doc::NodeKind::Image(_))).count(); + } + all += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))); + }); + (all, clipped) +} + +#[test] +fn a_transparent_image_is_flattened_with_the_art_under_it_which_stays() { + let mut s = shadow_over_square(); + let before = [colour_at(&s.doc().unwrap().doc, 40.0, 30.0), colour_at(&s.doc().unwrap().doc, 60.0, 30.0)]; + let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap(); + assert!(warnings(&r).iter().any(|w| w.contains("flattened with the art under them")), "{r}"); + assert!(!warnings(&r).iter().any(|w| w.contains("transparent image pixels are written over white")), "{r}"); + s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + let d = &s.doc().unwrap().doc; + // The square is still there, under an opaque image clipped to the transparent one's place; + // the opaque image stays as it was. + let mut squares = 0; + d.walk(|n| squares += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { clipping: false, .. }))); + assert_eq!(squares, 1); + assert_eq!(images(d), (2, 1)); + let after = [colour_at(d, 40.0, 30.0), colour_at(d, 60.0, 30.0)]; + for (a, b) in before.iter().zip(&after) { + assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(*b) <= 3), "{before:?} {after:?}"); + } + assert_eq!(colour_at(d, 20.0, 30.0), [255, 0, 0], "the square outside the image"); + assert_eq!(colour_at(d, 80.0, 30.0), [255, 255, 255], "nothing beyond the image"); +} + +#[test] +fn transparent_images_can_be_left_out_and_opacity_makes_one_transparent() { + let mut s = shadow_over_square(); + s.execute("transparency.set", &json!({"opacity": 50})).unwrap(); // the blue image + let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "transparentImages": "omit"})).unwrap(); + assert!(warnings(&r).iter().any(|w| w.contains("left out")), "{r}"); + s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(images(d), (0, 0)); + assert_eq!(colour_at(d, 40.0, 30.0), [255, 0, 0]); + assert!(s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "transparentImages": "blur"})).is_err()); + let v = s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap(); + assert_eq!(v["options"]["transparentImages"]["value"], "flatten", "{v}"); +} + +#[test] +fn a_range_saves_those_artboards_and_save_writes_them_again() { + let dir = Folder::new("ai7-range"); + let mut s = three_boards(); + let path = dir.file("Poster.ai"); + let r = s.execute("file.saveAs", &json!({"path": path, "format": "ai", "aiVersion": "7", "range": "1, 3"})).unwrap(); + let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect(); + assert_eq!(files, [dir.file("Poster-A.ai"), dir.file("Poster-C.ai")], "{r}"); + // One artboard: the file itself. An empty range: every artboard. + let r = s.execute("file.saveAs", &json!({"path": path, "format": "ai", "aiVersion": "7", "range": "2"})).unwrap(); + assert!(r["files"].is_null(), "{r}"); + let r = s.execute("file.saveAs", &json!({"path": path, "format": "ai", "aiVersion": "7", "range": ""})).unwrap(); + assert_eq!(r["files"].as_array().map(Vec::len), Some(3), "{r}"); + // Save keeps the range it was saved with. + s.execute("file.saveAs", &json!({"path": path, "format": "ai", "aiVersion": "7", "range": "1-2"})).unwrap(); + let r = s.execute("document.save", &json!({})).unwrap(); + assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{r}"); +} From 91ed4853a8c4a0606bc8437cd984d555a3d9a987 Mon Sep 17 00:00:00 2001 From: Dan Green Date: Mon, 12 Oct 2026 19:08:16 -0700 Subject: [PATCH 03/10] Part of #868: legacy .ai options dialog, Transparent Images and an artboard picker The .ai options dialog offers Legacy (version 7). In that mode it shows Transparent Images (Flatten, Omit) and, with more than one artboard, thumbnails to check with the file each is saved to (long names keep their end). OK is greyed out until an artboard is checked. A save panel's Enter no longer confirms the options dialog it opens. --- .../ui-egui/src/dialogs/export_for_screens.rs | 2 +- crates/ui-egui/src/dialogs/mod.rs | 18 +- crates/ui-egui/src/dialogs/save_options.rs | 317 +++++++++++++++++- crates/ui-egui/src/i18n/cs.tsv | 9 + crates/ui-egui/src/i18n/de.tsv | 9 + crates/ui-egui/src/i18n/es.tsv | 9 + crates/ui-egui/src/i18n/fr.tsv | 9 + crates/ui-egui/src/i18n/hu.tsv | 9 + crates/ui-egui/src/i18n/it.tsv | 9 + crates/ui-egui/src/i18n/ja.tsv | 9 + crates/ui-egui/src/i18n/ko.tsv | 9 + crates/ui-egui/src/i18n/pl.tsv | 9 + crates/ui-egui/src/i18n/pt-br.tsv | 9 + crates/ui-egui/src/i18n/ru.tsv | 9 + crates/ui-egui/src/i18n/uk.tsv | 9 + crates/ui-egui/src/i18n/vi.tsv | 9 + crates/ui-egui/src/i18n/zh-hans.tsv | 9 + crates/ui-egui/src/i18n/zh-hant.tsv | 9 + crates/ui-egui/src/io.rs | 6 +- crates/ui-egui/src/widgets.rs | 21 +- 20 files changed, 486 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index a2c5b1e84..5665da8c6 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -596,7 +596,7 @@ fn format_settings(ui: &mut egui::Ui, d: &mut Dialog, id: &str) { } /// A small cached rendering of artboard `i` (keyed by document revision). -fn artboard_thumb(app: &mut VectorcraftApp, ctx: &egui::Context, i: usize) -> Option { +pub(super) fn artboard_thumb(app: &mut VectorcraftApp, ctx: &egui::Context, i: usize) -> Option { let st = app.session.active()?; let key = egui::Id::new(("ab-thumb", i, st.revision)); if let Some(t) = ctx.data(|d| d.get_temp::(key)) { diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index d6a3c7b88..c54ec1465 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -149,6 +149,8 @@ pub(crate) struct DialogSpec { /// The OK button's label when it depends on the app (Export for Screens' "Download" on the /// web); `ok` when none. Only with an `ok`. pub ok_label: Option &'static str>, + /// Can OK (or Enter) apply the dialog as it is? None: always. The body says why not. + pub ok_enabled: Option bool>, } impl DialogSpec { @@ -170,6 +172,7 @@ impl DialogSpec { max_width: None, preview: false, ok_label: None, + ok_enabled: None, }; /// A dialog that draws its own window and confirms through its module. @@ -380,6 +383,10 @@ pub(crate) fn settle(app: &mut VectorcraftApp) { missing_fonts::open_next(app); } +/// The field of a dialog that a save panel opened (its format's options): Enter doesn't confirm it +/// until Enter has been let go. +pub(crate) const AFTER_PANEL: &str = "__afterPanel"; + pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { about::show(app, ctx); settle(app); @@ -418,8 +425,14 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { return window(app, ctx); } let mut ok = false; + let mut ok_enabled = true; let mut cancel = false; let mut discard = false; + // Opened as a save panel closed: the Enter that closed it isn't this dialog's, until it is let go. + let after_panel = d.bool(AFTER_PANEL); + if after_panel && !ctx.input(|i| i.key_down(egui::Key::Enter) || i.key_pressed(egui::Key::Enter)) { + d.fields.remove(AFTER_PANEL); + } let heading = (spec.heading)(&d); let revolves = revolve_gizmo::active(&d); if revolves { @@ -450,9 +463,10 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { // right-to-left layout would otherwise take all the room left in the window, so the // window could never shrink to its content. let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y); + ok_enabled = spec.ok_enabled.is_none_or(|f| f(app, &d)); ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| { if let Some(label) = spec.ok.map(|ok| spec.ok_label.map_or(ok, |f| f(app))) - && widgets::primary_button(ui, label).clicked() + && widgets::primary_button_enabled(ui, label, ok_enabled).clicked() { ok = true; } @@ -466,7 +480,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } }); }); - if spec.ok.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Enter)) { + if spec.ok.is_some() && ok_enabled && !after_panel && ctx.input(|i| i.key_pressed(egui::Key::Enter)) { ok = true; } if discard { diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs index 0d5b5a06c..859c1cbf9 100644 --- a/crates/ui-egui/src/dialogs/save_options.rs +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -7,7 +7,10 @@ //! editable), `path`, `format`, and one field per option (agents set them with `ui.dialog.set`). //! Native and `.ai` files show their save options (VectorCraft Options after Save As): each //! artboard to a separate file, with `all` (true: every artboard) or `range`, Include Linked Files, -//! Embed ICC Profiles, Create PDF-Compatible File and Use Compression. +//! Embed ICC Profiles, Create PDF-Compatible File and Use Compression. A `.ai` file's Version comes +//! first: PDF-compatible, or legacy version 7 (`aiVersion: "7"`, a file per artboard), which takes +//! only Transparent Images and, with several artboards, a picker of the artboards to save +//! (thumbnails to check, field `boards`, saved as `range`). use std::sync::OnceLock; @@ -22,7 +25,12 @@ use crate::{VectorcraftApp, io, widgets}; /// Dialog kind. pub const KIND: &str = "saveOptions"; -pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 380.0, ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 380.0, ok_enabled: Some(can_save), ..DialogSpec::FORM }; + +/// OK saves a legacy `.ai` file only with an artboard checked. +fn can_save(app: &VectorcraftApp, d: &Dialog) -> bool { + !legacy_ai(d) || picked_boards(d, app.session.active().map_or(0, |st| st.doc.artboards.len())).iter().any(|b| *b) +} fn heading(d: &Dialog) -> String { if d.bool("__pick") { @@ -33,6 +41,9 @@ fn heading(d: &Dialog) -> String { } .into(); } + if legacy_ai(d) { + return tl!("Legacy .ai Options").into(); + } crate::i18n::fmt(tl!("{format} Options"), &[("format", fileio::format(&d.str("format")).map_or("", |f| f.label))]) } @@ -133,21 +144,197 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.end_row(); } } - for o in shown(f) { + // Version first; the legacy version takes only its own options. + let legacy = legacy_ai(d); + let mut rows: Vec<&FormatOption> = shown(f).filter(|o| LEGACY_ONLY.contains(&o.name) == legacy || o.name == "aiVersion").collect(); + rows.sort_by_key(|o| o.name != "aiVersion"); + for o in rows { option_row(app, ui, d, o); } }); + if legacy_ai(d) && app.session.active().is_some_and(|st| st.doc.artboards.len() > 1) { + artboard_picker(app, ui, d); + } if let Some(id) = switch.filter(|id| *id != f.id) { switch_format(app, d, id); } false } -/// One option: a checkbox, an artboard menu, a number or a text field. +/// The `.ai` options only the legacy version takes (and the only ones it takes, besides the +/// version). +const LEGACY_ONLY: &[&str] = &["transparentImages"]; + +/// Is a `.ai` file to be saved in the legacy version 7 (`aiVersion: "7"`)? +fn legacy_ai(d: &Dialog) -> bool { + d.str("format") == "ai" && d.str("aiVersion").trim() == "7" +} + +/// The artboards a legacy `.ai` save writes (field `boards`: one bool per artboard), as last +/// saved (`range`), else all of them. +fn picked_boards(d: &Dialog, n: usize) -> Vec { + if let Some(a) = d.fields.get("boards").and_then(Value::as_array) { + let mut b: Vec = a.iter().map(|b| b.as_bool().unwrap_or(false)).collect(); + b.resize(n, false); + return b; + } + let range = d.str("range"); + match (ArtboardPick { range: Some(range.clone()), ..Default::default() }).resolve(n) { + Ok(Some(chosen)) if !range.trim().is_empty() => (0..n).map(|i| chosen.contains(&i)).collect(), + _ => vec![true; n], + } +} + +/// The file each checked artboard is saved to (`None` for the others): the file itself when one +/// is checked, else `{stem}-{artboard}.ai` beside it, as the save names them. +fn board_files(app: &VectorcraftApp, d: &Dialog, boards: &[bool]) -> Vec> { + let checked: Vec = boards.iter().enumerate().filter(|(_, on)| **on).map(|(i, _)| i).collect(); + let path = d.str("path"); + let file = std::path::Path::new(path.trim()); + let name = file.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + let stem = file.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + let mut names = match (app.session.active(), checked.len()) { + (_, 1) => vec![name], + (Some(st), _) => fileio::artboard_file_names(&st.doc, &checked).into_iter().map(|b| format!("{stem}-{b}.ai")).collect(), + (None, _) => vec![], + } + .into_iter(); + boards.iter().map(|on| if *on { names.next() } else { None }).collect() +} + +/// `text` as it fits in `width` with `font`: whole, else its start cut short before its last +/// `tail` characters ("desktop-i…-Artboard-1.ai"), else as much of its end as fits. +fn elide_before_tail(ui: &egui::Ui, text: &str, tail: usize, font: &egui::FontId, width: f32) -> String { + let fits = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), egui::Color32::WHITE).size().x <= width; + if fits(text) { + return text.to_string(); + } + let chars: Vec = text.chars().collect(); + let split = chars.len().saturating_sub(tail); + let (head, end) = chars.split_at(split); + let end: String = end.iter().collect(); + for p in (1..head.len()).rev() { + let s = format!("{}…{end}", head.iter().take(p).collect::()); + if fits(&s) { + return s; + } + } + for k in (1..=chars.len()).rev() { + let s = format!("…{}", chars.iter().skip(chars.len() - k).collect::()); + if fits(&s) { + return s; + } + } + "…".into() +} + +/// Legacy `.ai`: the artboards to save, as thumbnails to check; each is saved as a file of its own. +fn artboard_picker(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + const TILE: egui::Vec2 = egui::vec2(116.0, 84.0); + let t = Tokens::get(ui.ctx()); + let names: Vec = app.session.active().map(|st| st.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default(); + let n = names.len(); + let mut boards = picked_boards(d, n); + let before = boards.clone(); + ui.add_space(10.0); + ui.add( + egui::Label::new( + egui::RichText::new(tl!("A legacy .ai file holds one artboard: each artboard you check is saved as a separate file.")).color(t.text_dim), + ) + .wrap(), + ); + ui.add_space(4.0); + ui.horizontal(|ui| { + if ui.small_button(tl!("Select All")).clicked() { + boards.iter_mut().for_each(|b| *b = true); + } + if ui.small_button(tl!("Clear")).clicked() { + boards.iter_mut().for_each(|b| *b = false); + } + let checked = boards.iter().filter(|b| **b).count(); + let (count, total) = (checked.to_string(), n.to_string()); + let text = crate::i18n::fmt(tl!("{count} of {total} artboards"), &[("count", &count), ("total", &total)]); + ui.label(egui::RichText::new(text).color(if checked == 0 { t.warning } else { t.text_dim })); + if checked == 0 { + ui.label(egui::RichText::new(tl!("Check an artboard to save.")).color(t.warning)); + } + }); + ui.add_space(4.0); + let files = board_files(app, d, &boards); + let stem = std::path::Path::new(d.str("path").trim()).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + egui::ScrollArea::vertical().max_height(330.0).show(ui, |ui| { + ui.with_layout(egui::Layout::left_to_right(egui::Align::Min).with_main_wrap(true), |ui| { + ui.spacing_mut().item_spacing = egui::vec2(10.0, 10.0); + for (i, name) in names.iter().enumerate() { + let on = boards.get(i).copied().unwrap_or(false); + ui.allocate_ui(egui::vec2(TILE.x, TILE.y + 44.0), |ui| { + ui.vertical(|ui| { + ui.set_width(TILE.x); + let (r, resp) = ui.allocate_exact_size(TILE, egui::Sense::click()); + ui.painter().rect_filled(r, 3.0, t.pasteboard); + let inner = r.shrink(6.0); + match super::export_for_screens::artboard_thumb(app, ui.ctx(), i) { + Some(tex) => { + let sz = tex.size_vec2(); + let s = (inner.width() / sz.x).min(inner.height() / sz.y).min(1.0); + let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); + ui.painter().image(tex.id(), egui::Rect::from_center_size(inner.center(), sz * s), uv, egui::Color32::WHITE); + } + None => { + ui.painter().rect_filled(inner, 0.0, egui::Color32::WHITE); + } + } + let stroke = if on { egui::Stroke::new(2.0, t.accent) } else { egui::Stroke::new(1.0, t.border) }; + ui.painter().rect_stroke(r, 3.0, stroke, egui::StrokeKind::Inside); + let mut flip = resp.on_hover_text(name.as_str()).clicked(); + flip |= widgets::check_name(ui, name, on); + let file = files.get(i).cloned().flatten().unwrap_or_default(); + // The end tells the files apart: the start gives way first. + let font = egui::TextStyle::Small.resolve(ui.style()); + let tail = file.strip_prefix(stem.as_str()).map_or(0, |t| t.chars().count()); + let shown = elide_before_tail(ui, &file, tail, &font, TILE.x); + let label = ui.add(egui::Label::new(egui::RichText::new(&shown).small().color(t.text_dim))); + if shown != file { + label.on_hover_text(file); + } + if flip && let Some(b) = boards.get_mut(i) { + *b ^= true; + } + }); + }); + } + }); + }); + if boards != before || !d.fields.contains_key("boards") { + d.fields.insert("boards".into(), json!(boards)); + } +} + +/// One option: a checkbox, an artboard menu, a version menu, a number or a text field. fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &FormatOption) { let t = Tokens::get(ui.ctx()); let label = label(o); let value = d.fields.get(o.name).cloned().unwrap_or(Value::Null); + if o.name == "aiVersion" { + ui.label(egui::RichText::new(tl!("Version:")).color(t.text_dim)).on_hover_text(o.description); + let versions = [tl!("PDF-compatible"), tl!("Legacy (version 7)")]; + let at = usize::from(legacy_ai(d)); + if let Some(i) = widgets::dropdown(ui, ("save-option", o.name), versions.get(at).copied().unwrap_or_default(), &versions, 254.0) { + d.fields.insert(o.name.into(), json!(if i == 1 { "7" } else { "pdf" })); + } + ui.end_row(); + return; + } + if o.name == "transparentImages" { + ui.label(egui::RichText::new(tl!("Transparent Images:")).color(t.text_dim)).on_hover_text(o.description); + let choices = [tl!("Flatten"), tl!("Omit")]; + let at = usize::from(d.str("transparentImages").trim().eq_ignore_ascii_case("omit")); + if let Some(i) = widgets::dropdown(ui, ("save-option", o.name), choices.get(at).copied().unwrap_or_default(), &choices, 254.0) { + d.fields.insert(o.name.into(), json!(if i == 1 { "omit" } else { "flatten" })); + } + ui.end_row(); + return; + } if o.ty == "boolean" { ui.label(""); let on = value.as_bool().unwrap_or(false); @@ -216,7 +403,18 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult { .iter() .filter_map(|o| d.fields.get(o.name).filter(|v| !v.is_null() && v.as_str() != Some("")).map(|v| (o.name.to_string(), v.clone()))) .collect(); - if separates(f) { + if legacy_ai(d) { + // The artboards checked in the picker (all of them: no range). + options.remove("range"); + let n = app.session.active().map_or(0, |s| s.doc.artboards.len()); + let boards = picked_boards(d, n); + if !boards.iter().any(|b| *b) { + return Err(tl!("Check an artboard to save.").into()); + } + if !boards.iter().all(|b| *b) { + options.insert("range".into(), json!(super::export_for_screens::range_text(&boards))); + } + } else if separates(f) { if !d.bool("separateArtboards") || all_artboards(d) { options.remove("range"); } else { @@ -335,6 +533,115 @@ mod tests { assert!(written.borrow()[1].1.starts_with(b" = written.borrow().iter().map(|(p, _)| p.clone()).collect(); + assert_eq!(names, ["art-Artboard-1.ai", "art-Back.ai"]); + assert!(written.borrow().iter().all(|(_, b)| b.starts_with(b"%!PS"))); + let st = app.session.active().unwrap(); + assert_eq!((st.format, st.save_options["aiVersion"].clone()), ("ai", json!("7")), "Save remembers the version"); + assert_eq!(st.save_options["transparentImages"], json!("omit")); + } + + /// An Illustrator file opened, then saved as `.ai`: the options dialog stays open through the + /// Enter that closed the save panel, and only an Enter after it is let go confirms it. + #[test] + fn legacy_ai_saves_the_artboards_checked_in_its_picker() { + let (mut app, written, _) = desktop("art.ai"); + for (i, name) in ["Back", "Side", "Top"].iter().enumerate() { + let x = 200.0 * (i + 1) as f64; + app.run("artboard.new", json!({"x": x, "y": 0, "width": 100, "height": 80 + 20 * i, "name": name})).unwrap(); + app.run("shape.ellipse", json!({"x": x + 10.0, "y": 10, "width": 40, "height": 40})).unwrap(); + } + app.run("file.saveAs", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("aiVersion".into(), json!("7")); + frame(&mut app, false); + assert_eq!(dialog(&app).fields["boards"], json!([true, true, true, true]), "every artboard to start with"); + if let Some(dir) = std::env::var_os("CRAFT_UI_SAVE_OPTIONS_SHOT") { + let mut ready = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 760.0)).wgpu().build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !ready { + theme::install_fonts(ui.ctx()); + theme::apply(ui.ctx(), theme::Brightness::MediumDark); + ready = true; + return; + } + super::super::show(app, ui.ctx()); + }, + app, + ); + h.run_steps(6); + h.render().unwrap().save(std::path::Path::new(&dir).join("legacy-ai-picker.png")).unwrap(); + let fields = &mut h.state_mut().ui.dialog.as_mut().unwrap().fields; + fields.insert("path".into(), json!("desktop-ideas1-vc5.ai")); + fields.insert("boards".into(), json!([false, false, false, false])); + h.run_steps(2); + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("boards".into(), json!([true, true, false, true])); + h.run_steps(2); + h.render().unwrap().save(std::path::Path::new(&dir).join("legacy-ai-picker-long.png")).unwrap(); + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("boards".into(), json!([false, false, false, false])); + h.run_steps(2); + h.render().unwrap().save(std::path::Path::new(&dir).join("legacy-ai-picker-none.png")).unwrap(); + app = h.into_state(); + app.ui.dialog.as_mut().unwrap().fields.insert("path".into(), json!("art.ai")); + } + // Clearing them all keeps the dialog open. + app.ui.dialog.as_mut().unwrap().fields.insert("boards".into(), json!([false, false, false, false])); + let d = dialog(&app).clone(); + assert!(!can_save(&app, &d), "OK is greyed out"); + frame(&mut app, true); + assert!(app.ui.dialog.is_some(), "Enter does nothing"); + assert!(confirm(&mut app, &d).is_err()); + assert!(written.borrow().is_empty()); + app.ui.dialog.as_mut().unwrap().fields.insert("boards".into(), json!([true, false, true, false])); + frame(&mut app, true); + assert!(app.ui.dialog.is_none()); + let names: Vec = written.borrow().iter().map(|(p, _)| p.clone()).collect(); + assert_eq!(names, ["art-Artboard-1.ai", "art-Side.ai"]); + assert_eq!(board_files_of(&app, [true, false, true, false]), [Some("art-Artboard-1.ai".into()), None, Some("art-Side.ai".into()), None]); + assert_eq!(board_files_of(&app, [false, true, false, false]), [None, Some("art.ai".into()), None, None], "one: the file itself"); + // Save As again starts from the artboards saved. + assert_eq!(app.session.active().unwrap().save_options["range"], json!("1, 3")); + app.run("file.saveAs", json!({})).unwrap(); + frame(&mut app, false); + assert_eq!(dialog(&app).fields["boards"], json!([true, false, true, false])); + } + + fn board_files_of(app: &VectorcraftApp, boards: [bool; 4]) -> Vec> { + let d = Dialog { kind: KIND.into(), fields: [("path".to_string(), json!("art.ai"))].into_iter().collect() }; + board_files(app, &d, &boards) + } + + #[test] + fn the_enter_that_closed_the_save_panel_does_not_confirm_the_options() { + let (mut app, written, _) = desktop("again.ai"); + let dir = vectorcraft_testkit::temp_dir("ai-options-enter"); + let orig = dir.join("orig.ai"); + std::fs::write(&orig, vectorcraft_testkit::ai::ai(&vectorcraft_testkit::ai::sample_data(), vectorcraft_testkit::ai::page_pdf())).unwrap(); + app.run("document.open", json!({"path": orig.to_string_lossy()})).unwrap(); + assert_eq!(app.run("file.saveAs", json!({})).unwrap()["pending"], KIND); + frame(&mut app, true); + assert!(app.ui.dialog.is_some(), "the panel's Enter doesn't confirm it"); + assert!(written.borrow().is_empty()); + // Let go, then pressed again: OK. + frame(&mut app, false); + frame(&mut app, true); + assert!(app.ui.dialog.is_none()); + assert_eq!(written.borrow()[0].0, "again.ai"); + } + #[test] fn save_a_copy_and_template_suggest_their_names() { let (mut app, written, picks) = desktop("copy.vectorcraft"); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5a223e467..a2b2a541d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3178,6 +3178,15 @@ theme Appearance Motiv Large Velké Large Tabs Velké karty Legacy vector clipboard Starší vektorová schránka + Legacy (version 7) Starší (verze 7) + Transparent Images: Průhledné obrazy: + Flatten Sloučit + Omit Vynechat + Legacy .ai Options Starší soubor .ai – volby + A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Soubor .ai ve starší verzi obsahuje jedinou kreslicí plochu: každá zaškrtnutá kreslicí plocha se uloží jako samostatný soubor. + {count} of {total} artboards {count} z {total} kreslicích ploch + Check an artboard to save. Zaškrtněte kreslicí plochu, kterou chcete uložit. + PDF-compatible Kompatibilní s PDF Legacy vector clipboard (no transparency) Starší vektorová schránka (bez průhlednosti) Line Color Barva čar Lines Čáry diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 597e7056f..b7d5a81b2 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1461,6 +1461,15 @@ Left: Links: Legacy Macintosh RGB (Gamma 1.8) Älteres Macintosh-RGB (Gamma 1,8) Legacy vector clipboard Ältere Vektorzwischenablage + Legacy (version 7) Älter (Version 7) + Transparent Images: Transparente Bilder: + Flatten Reduzieren + Omit Weglassen + Legacy .ai Options Optionen für ältere .ai-Datei + A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Eine ältere .ai-Datei enthält eine Zeichenfläche: Jede aktivierte Zeichenfläche wird als eigene Datei gespeichert. + {count} of {total} artboards {count} von {total} Zeichenflächen + Check an artboard to save. Aktivieren Sie eine Zeichenfläche zum Speichern. + PDF-compatible PDF-kompatibel Legacy vector clipboard (no transparency) Ältere Vektorzwischenablage (ohne Transparenz) Less Weniger Letter Letter diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8538bbf99..c64b2f344 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1477,6 +1477,15 @@ Left: Izquierda: Legacy Macintosh RGB (Gamma 1.8) RGB de Macintosh heredado (Gamma 1.8) Legacy vector clipboard Portapapeles vectorial heredado + Legacy (version 7) Heredado (versión 7) + Transparent Images: Imágenes transparentes: + Flatten Acoplar + Omit Omitir + Legacy .ai Options Opciones de .ai heredado + A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Un archivo .ai heredado contiene una mesa de trabajo: cada mesa de trabajo marcada se guarda como un archivo independiente. + {count} of {total} artboards {count} de {total} mesas de trabajo + Check an artboard to save. Marque una mesa de trabajo para guardarla. + PDF-compatible Compatible con PDF Legacy vector clipboard (no transparency) Portapapeles vectorial heredado (sin transparencia) Less Menos Letter Carta diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2eb9d59ab..cc40e5330 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1476,6 +1476,15 @@ Left: Gauche : Legacy Macintosh RGB (Gamma 1.8) RVB Macintosh hérité (gamma 1,8) Legacy vector clipboard Presse-papiers vectoriel hérité + Legacy (version 7) Hérité (version 7) + Transparent Images: Images transparentes : + Flatten Aplatir + Omit Omettre + Legacy .ai Options Options .ai hérité + A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Un fichier .ai hérité contient un seul plan de travail : chaque plan de travail coché est enregistré dans un fichier distinct. + {count} of {total} artboards {count} plans de travail sur {total} + Check an artboard to save. Cochez un plan de travail à enregistrer. + PDF-compatible Compatible PDF Legacy vector clipboard (no transparency) Presse-papiers vectoriel hérité (sans transparence) Less Moins Letter Lettre US diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 26655c258..014e0cf7b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1880,6 +1880,15 @@ Left: Bal: Legacy Macintosh RGB (Gamma 1.8) Régi Macintosh RGB (gamma 1,8) Legacy vector clipboard Régi vektoros vágólap + Legacy (version 7) Régi (7-as verzió) + Transparent Images: Átlátszó képek: + Flatten Egyesítés + Omit Kihagyás + Legacy .ai Options Régi .ai beállításai + A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. A régi .ai fájl egy rajztáblát tartalmaz: minden bejelölt rajztábla külön fájlba kerül. + {count} of {total} artboards {count} / {total} rajztábla + Check an artboard to save. Jelöljön be egy mentendő rajztáblát. + PDF-compatible PDF-kompatibilis Legacy vector clipboard (no transparency) Régi vektoros vágólap (átlátszóság nélkül) Legal Legal Length: Hossz: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 674ebb6ae..7629befb1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1479,6 +1479,15 @@ Left: Sinistra: Legacy Macintosh RGB (Gamma 1.8) RGB Macintosh tradizionale (gamma 1.8) Legacy vector clipboard Appunti vettoriali tradizionali + Legacy (version 7) Tradizionale (versione 7) + Transparent Images: Immagini trasparenti: + Flatten Unisci + Omit Ometti + Legacy .ai Options Opzioni .ai tradizionale + A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Un file .ai tradizionale contiene una tavola da disegno: ogni tavola selezionata viene salvata come file separato. + {count} of {total} artboards {count} di {total} tavole da disegno + Check an artboard to save. Seleziona una tavola da disegno da salvare. + PDF-compatible Compatibile PDF Legacy vector clipboard (no transparency) Appunti vettoriali tradizionali (senza trasparenza) Less Meno Letter Lettera diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index aea1c8059..ba105523f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1666,6 +1666,15 @@ Left Plane 左面 Left: 左: Legacy vector clipboard 従来のベクタークリップボード + Legacy (version 7) 従来 (バージョン 7) + Transparent Images: 透明な画像: + Flatten 統合 + Omit 省略 + Legacy .ai Options 従来の .ai オプション + A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. 従来の .ai ファイルにはアートボードが 1 つしか入りません。チェックした各アートボードは別々のファイルとして保存されます。 + {count} of {total} artboards {total} 個中 {count} 個のアートボード + Check an artboard to save. 保存するアートボードをチェックしてください。 + PDF-compatible PDF 互換 Legacy vector clipboard (no transparency) 従来のベクタークリップボード (透明なし) Less 少なく Ligatures are off while the text is tracked. テキストにトラッキングが設定されている間、合字は無効です。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 0365abac3..2695baefe 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1661,6 +1661,15 @@ Left Plane 왼쪽 평면 Left: 왼쪽: Legacy vector clipboard 레거시 벡터 클립보드 + Legacy (version 7) 레거시 (버전 7) + Transparent Images: 투명 이미지: + Flatten 병합 + Omit 생략 + Legacy .ai Options 레거시 .ai 옵션 + A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. 레거시 .ai 파일에는 대지가 하나만 들어갑니다. 선택한 각 대지는 별도의 파일로 저장됩니다. + {count} of {total} artboards 대지 {total}개 중 {count}개 + Check an artboard to save. 저장할 대지를 선택하십시오. + PDF-compatible PDF 호환 Legacy vector clipboard (no transparency) 레거시 벡터 클립보드 (투명도 없음) Less 간단히 Ligatures are off while the text is tracked. 자간이 적용된 텍스트에서는 합자가 꺼집니다. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 98e0c3a15..922a3900b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4651,6 +4651,15 @@ mark-style Japanese Japońskie Softness Miękkość Ambient Light Światło otoczenia Light behind the object Światło za obiektem + Legacy (version 7) Starsza (wersja 7) + Transparent Images: Przezroczyste obrazy: + Flatten Spłaszcz + Omit Pomiń + Legacy .ai Options Opcje starszego pliku .ai + A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Starszy plik .ai zawiera jeden obszar kompozycji: każdy zaznaczony obszar kompozycji jest zapisywany jako osobny plik. + {count} of {total} artboards {count} z {total} obszarów kompozycji + Check an artboard to save. Zaznacz obszar kompozycji, aby zapisać. + PDF-compatible Zgodny z PDF # Dimension tool and Object › Dimensions (dimension groups, their dialog and messages). Dimension Wymiar Drawing : real size — 1:10 labels 1 unit on the page as 10. Rysunek : rozmiar rzeczywisty — 1:10 opisuje 1 jednostkę na stronie jako 10. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 4a60aed9f..df16b056e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1225,6 +1225,15 @@ Left: Esquerda: Legacy Macintosh RGB (Gamma 1.8) Macintosh RGB Legado (Gama 1.8) Legacy vector clipboard Área de transferência vetorial legada + Legacy (version 7) Legado (versão 7) + Transparent Images: Imagens transparentes: + Flatten Achatar + Omit Omitir + Legacy .ai Options Opções de .ai legado + A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Um arquivo .ai legado contém uma prancheta: cada prancheta marcada é salva como um arquivo separado. + {count} of {total} artboards {count} de {total} pranchetas + Check an artboard to save. Marque uma prancheta para salvar. + PDF-compatible Compatível com PDF Legacy vector clipboard (no transparency) Área de transferência vetorial legada (sem transparência) Less Menos Levels… Níveis… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0eb092e65..cc79c3664 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1477,6 +1477,15 @@ Left: Слева: Legacy Macintosh RGB (Gamma 1.8) Устаревший Macintosh RGB (гамма 1.8) Legacy vector clipboard Устаревший векторный буфер обмена + Legacy (version 7) Устаревший (версия 7) + Transparent Images: Прозрачные изображения: + Flatten Объединить + Omit Пропустить + Legacy .ai Options Параметры устаревшего .ai + A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Устаревший файл .ai содержит одну монтажную область: каждая отмеченная монтажная область сохраняется в отдельный файл. + {count} of {total} artboards {count} из {total} монтажных областей + Check an artboard to save. Отметьте монтажную область для сохранения. + PDF-compatible Совместимый с PDF Legacy vector clipboard (no transparency) Устаревший векторный буфер обмена (без прозрачности) Less Меньше Letter Letter diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5d5902e11..03edb3694 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1473,6 +1473,15 @@ Left: Зліва: Legacy Macintosh RGB (Gamma 1.8) Застарілий Macintosh RGB (гама 1.8) Legacy vector clipboard Застарілий векторний буфер обміну + Legacy (version 7) Застарілий (версія 7) + Transparent Images: Прозорі зображення: + Flatten Об’єднати + Omit Пропустити + Legacy .ai Options Параметри застарілого .ai + A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Застарілий файл .ai містить одну монтажну область: кожна позначена монтажна область зберігається в окремий файл. + {count} of {total} artboards {count} з {total} монтажних областей + Check an artboard to save. Позначте монтажну область для збереження. + PDF-compatible Сумісний із PDF Legacy vector clipboard (no transparency) Застарілий векторний буфер обміну (без прозорості) Less Менше Letter Letter diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 9d0dd9292..99ae5af8b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1694,6 +1694,15 @@ Left-Justified Tab Tab căn trái Left-to-Right Paragraph Direction Đoạn văn từ trái sang phải Left: Trái: + Legacy (version 7) Cũ (phiên bản 7) + Transparent Images: Ảnh trong suốt: + Flatten Làm phẳng + Omit Bỏ qua + Legacy .ai Options Tùy chọn .ai cũ + A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Một tệp .ai cũ chỉ chứa một bảng vẽ: mỗi bảng vẽ bạn chọn được lưu thành một tệp riêng. + {count} of {total} artboards {count} trên {total} bảng vẽ + Check an artboard to save. Hãy chọn một bảng vẽ để lưu. + PDF-compatible Tương thích PDF Legacy Macintosh RGB (Gamma 1.8) RGB Macintosh cũ (Gamma 1.8) Legacy vector clipboard Bảng tạm vector cũ Legacy vector clipboard (no transparency) Bảng tạm vector cũ (không có trong suốt) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9aa3f05da..a73072b1b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1381,6 +1381,15 @@ Left: 左: Legacy Macintosh RGB (Gamma 1.8) 旧版 Macintosh RGB (Gamma 1.8) Legacy vector clipboard 旧版矢量剪贴板 + Legacy (version 7) 旧版 (版本 7) + Transparent Images: 透明图像: + Flatten 拼合 + Omit 省略 + Legacy .ai Options 旧版 .ai 选项 + A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. 旧版 .ai 文件只包含一个画板:勾选的每个画板都会存储为单独的文件。 + {count} of {total} artboards {total} 个画板中的 {count} 个 + Check an artboard to save. 请勾选要存储的画板。 + PDF-compatible 与 PDF 兼容 Legacy vector clipboard (no transparency) 旧版矢量剪贴板 (无透明度) Less 较少 Letter 信纸 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 60a51adba..74d85b238 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1455,6 +1455,15 @@ Left: 左: Legacy Macintosh RGB (Gamma 1.8) 舊版 Macintosh RGB (Gamma 1.8) Legacy vector clipboard 舊版向量剪貼簿 + Legacy (version 7) 舊版 (版本 7) + Transparent Images: 透明影像: + Flatten 平面化 + Omit 略過 + Legacy .ai Options 舊版 .ai 選項 + A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. 舊版 .ai 檔案只包含一個工作區域:勾選的每個工作區域都會儲存為獨立的檔案。 + {count} of {total} artboards {total} 個工作區域中的 {count} 個 + Check an artboard to save. 請勾選要儲存的工作區域。 + PDF-compatible 與 PDF 相容 Legacy vector clipboard (no transparency) 舊版向量剪貼簿 (無透明度) Less 較少 Letter Letter diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 5b5a1d796..42cf8bec6 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -412,7 +412,11 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo } if ask && asks_options(mode, chosen.format) { let path = chosen.path.clone().unwrap_or_default(); - return ask_format_options(app, mode, chosen.format, &path); + let r = ask_format_options(app, mode, chosen.format, &path); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert(crate::dialogs::AFTER_PANEL.into(), json!(true)); + } + return r; } write_plan(app, chosen) } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c84ec2e81..8fd3291e1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -89,13 +89,26 @@ fn flat_fill(ui: &Ui, rect: Rect, resp: &Response) { /// Blue call-to-action pill (dialog OK/Create). pub fn primary_button(ui: &mut Ui, text: &str) -> Response { + primary_button_enabled(ui, text, true) +} + +/// [`primary_button`], greyed out and not clickable unless `enabled`. +pub fn primary_button_enabled(ui: &mut Ui, text: &str, enabled: bool) -> Response { let t = Tokens::get(ui.ctx()); - let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), theme::semibold(12.5), Color32::WHITE); + let fg = if enabled { Color32::WHITE } else { t.text_disabled }; + let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), theme::semibold(12.5), fg); let w = galley.size().x + 32.0; - let (rect, resp) = ui.allocate_exact_size(vec2(w.max(72.0), 28.0), Sense::click()); - let bg = if resp.hovered() { t.accent } else { t.accent_strong }; + let (rect, resp) = ui.allocate_exact_size(vec2(w.max(72.0), 28.0), if enabled { Sense::click() } else { Sense::hover() }); + let bg = match (enabled, resp.hovered()) { + (false, _) => t.input, + (true, true) => t.accent, + (true, false) => t.accent_strong, + }; ui.painter().rect_filled(rect, CornerRadius::same(14), bg); - ui.painter().galley(rect.center() - galley.size() / 2.0, galley, Color32::WHITE); + if !enabled { + ui.painter().rect_stroke(rect, CornerRadius::same(14), egui::Stroke::new(1.0, t.button_border), egui::StrokeKind::Inside); + } + ui.painter().galley(rect.center() - galley.size() / 2.0, galley, fg); resp } From 32df15a554ddecb6050d36ddf864802d16b17a19 Mon Sep 17 00:00:00 2001 From: Dan Green Date: Mon, 12 Oct 2026 19:08:16 -0700 Subject: [PATCH 04/10] Part of #868: docs for Save As .ai legacy version 7 (artboard picker, Transparent Images) --- README.md | 2 +- docs/control-protocol.md | 5 ++++- docs/file-format-parity.md | 18 +++++++++++++++--- docs/gaps.md | 8 ++++++-- docs/mcp.md | 5 ++++- docs/target-app-parity.md | 7 ++++--- 6 files changed, 34 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7b01b54f0..9d6e47626 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the fu all of Illustrator): everyday vector illustration is close to usable: drawing and path tools, Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and -files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package). +files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers and groups, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package). Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4bf97a936..88d1bb9b8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -621,7 +621,10 @@ Native save options: File → Save As to a native or `.ai` file opens the `saveO (heading "VectorCraft Options" or "PDF-compatible .ai Options"). Fields: `separateArtboards`, `all` (true: every artboard; false: `range`, such as "1-3, 5"), `includeLinked`, `embedProfiles`, `pdfCompatible`, `compress`, and for native files `version` and `preview`. A bad range keeps the dialog open; `ui.dialog.confirm` writes the file and the -artboards' files. +artboards' files. A `.ai` file's `aiVersion` comes first: `"pdf"` (the default) or `"7"` (legacy, heading "Legacy .ai +Options"), which takes only `transparentImages` (`"flatten"` or `"omit"`) and, with several artboards, the artboard +picker: `boards`, one bool per artboard (default: those last saved, else all), saved as `range`, one file per checked +artboard. OK is disabled with none checked. EMF and WMF: Export As… lists both formats (`file.exportAs {format: "emf"}` or `"wmf"`); they have no options dialog, so `ui.dialog.confirm` asks for the path and writes the file (one per artboard with Use Artboards). Open and diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8bd249de0..9e753a335 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ from the public specifications, and users' own `.ai` files are used locally only | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| | Formats opened or placed | ~30 | 22 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, JPEG 2000, PICT, PCX, Pixar, RTF/Word, PS | -| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written as PDF-compatible only (no Illustrator editing data) | +| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (no Illustrator editing data) or as legacy version 7 (layers and groups, one artboard per file) | | Formats exported | ~16 | 17 | missing: DWG, 3D (OBJ/USDA/glTF); extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | | **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting | @@ -40,12 +40,24 @@ private editing data. VectorCraft: A PDF saved with its editing data (`AIPDFPrivateData`) opens the same way. - **Writes** a PDF-compatible `.ai` (Save As › `ai`): Illustrator opens it as plain art. It never writes Illustrator's private editing data, which is undocumented. +- **Writes** a legacy `.ai` of version 7 (Save As › `ai` › Version: Legacy (version 7), `aiVersion: "7"`): the + PostScript form, from the published *Adobe Illustrator File Format Specification* version 7.0 + ([PRONOM fmt/423](https://www.nationalarchives.gov.uk/PRONOM/fmt/423)), which apps reading `.ai` open with its + structure (#868). It keeps layers (names, order, visibility, lock), groups, compound paths, clipping groups, + solid colours, linear and radial gradients and RGB images. The format has one artboard: each artboard chosen in + the options dialog's artboard picker (thumbnails to check, default all) is a file of its own, + `{name}-{artboard}.ai` when there are several. It has no transparency: objects are written opaque, and images + with see-through pixels, opacity or a blending mode are flattened (an opaque image of themselves and the art + under them, clipped to where they show; that art stays) or omitted (Transparent Images, + `transparentImages: "flatten"|"omit"`). Type is written as outlines, sublayers as groups; appearances, effects + and brushes are expanded; object names are left out. - **Known failures in users' files:** layer structure flattened or multiplied (#951: 109 layers instead of 14; #868: round trip with Illustrator 2018 loses layers and groups), guides and non-printing construction lines lost (#779), some files 4× slower to open since the editing data is read whole (#758), live effects, brushes and symbols come in as their drawn look (#637; pattern fills are read since #1025); legacy `.ai` without the prolog opens since #1027, its legacy type (`To` … `TO`) not read yet. -- Estimate: **~65%** for real exchange with Illustrator users (open: ~75%; save: PDF-compatible only). +- Estimate: **~65%** for real exchange with Illustrator users (open: ~75%; save: PDF-compatible, or legacy + version 7 with layers and groups; not yet tested by users who hand files back). Closing the open-side issues: 10–20 h with users' files. ### Illustrator's private editing data @@ -57,7 +69,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Format | Illustrator | Ours: open/place | Ours: save/export | Fidelity and tests | |---|---|---|---|---| | VectorCraft `.vectorcraft` / `.vctemplate` | — | yes | yes | lossless JSON, versioned, save down to v1; property-tested round trips (`format/tests/prop_format.rs`); documents with a lot of path data are written as compact JSON, about a fifth of the size (#1492); a save falls back to a plain fsync and to writing elsewhere and copying in when the folder refuses the usual steps (#1625, #1649, part of #1620; not confirmed on a real network share); the size limits on embedded data also apply to files read from memory (#1783), and saving over a private file on Unix leaves no readable temporary copy beside it (#1778) | -| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure) | PDF-compatible only | see above; no real-file corpus | +| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus | | Illustrator template `.ait` | open, save | yes | no (save as `.vctemplate`) | | | PDF | open, place, save | yes (layers, masks, editable text, security, overprint; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text placement issues in some files (#722); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781) | | EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type and CCITTFax images not read; #505; Type 42 (TrueType) fonts set type at its size (#1553); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | diff --git a/docs/gaps.md b/docs/gaps.md index 8100c524d..5dd7c777d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -81,8 +81,12 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall of 14), guides and non-printing construction lines (#779), open speed on large files (#758), legacy type in `.ai` files in PostScript form (those without their prolog open since #1027), live effects, brushes and symbols as live objects (#637; pattern fills and global colours are read since #1025, placed (linked) files since #1264). Writing Illustrator's own editing data is out of scope - (undocumented); Save As `.ai` stays PDF-compatible, and Illustrator 2018 opens such a file without its layers and - groups (#868). + (undocumented): a PDF-compatible `.ai` opens in Illustrator 2018 without its layers and groups (#868). Save As + `.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification + instead, keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and + images, one artboard per file (an artboard picker chooses which); transparency, type, live effects and object + names don't survive it (transparent images are flattened or omitted). It waits for testing by users who hand + files back. - **Evidence:** the issues above; 28 of the 350 issues are about `.ai`/EPS (about 22 people, the most common single format theme), 20 fixed, and the 8 open ones are about structure (layers, guides, speed, live objects), not missing art. No real-file `.ai` corpus (users' files may be used locally, never committed). diff --git a/docs/mcp.md b/docs/mcp.md index d5de15803..f47ad1465 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -594,7 +594,10 @@ imported instead and the first warning says why. Choosing a standard turns it of path (`document.save {path: "art.ai"}` or `{format: "ai"}`) writes a PDF-compatible file that always carries the document, takes the PDF options and keeps its path when reopened, so Save writes `.ai` again; a `.ait` opens untitled. `document.export` / `serialize` to `.ai` (and the CLI's `run --export x.ai` and `convert in.svg out.ai`) write the same file -without giving the document a path. +without giving the document a path. `options: {aiVersion: "7"}` writes legacy `.ai` files of version 7 instead (the +PostScript form, which keeps layers, groups, compound paths and clipping groups in apps that read it), one per artboard +of `range` (default all; `{name}-{artboard}.ai` when several, listed in the result's `files`), with +`transparentImages: "flatten"` (default) or `"omit"` for images with see-through pixels, opacity or a blending mode. Drive a tool like a mouse: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 71893fdb4..e5b6b1267 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -157,8 +157,9 @@ The discounts, with the evidence: about `.ai`/EPS, the most common single format theme. 20 of the 28 are fixed (often the same day). The 8 open ones are about structure, not missing art: layers flattened or multiplied (#951: 109 layers instead of 14; #868), guides lost (#779), slower opening (#758), live effects, brushes and symbols as their drawn look - (#637). VectorCraft can't write Illustrator's editing data (undocumented), so its `.ai` opens in Illustrator - as plain art. That blocks beta (the main-format rule) for people who hand files back to Illustrator users, + (#637). VectorCraft can't write Illustrator's editing data (undocumented), so its PDF-compatible `.ai` opens + in Illustrator as plain art; the legacy `.ai` of version 7 it also writes keeps layers and groups (one artboard + per file, no transparency or live type) but hasn't been tested by users yet (#868). That blocks beta (the main-format rule) for people who hand files back to Illustrator users, but it doesn't stop someone opening and reworking their own archive. - **Interaction fidelity (×0.9).** Pen, Direct Selection, smart guides and snapping are deep ([ui-parity.md](ui-parity.md)), and there has never been a side-by-side session. The issue tracker says the @@ -365,7 +366,7 @@ since 2026-10-01. | **Large missing subsystems** | 0–20% | 3D & Materials (~25%, initial 3D types without materials), Photoshop-style raster effects and the Effect Gallery (~19 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), scripting | | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | -| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | +| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers and groups), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | | **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | From 6ce1a622be9e7ef740262c292b47d609501f21e8 Mon Sep 17 00:00:00 2001 From: Dan Green Date: Mon, 12 Oct 2026 21:20:38 -0700 Subject: [PATCH 05/10] Part of #868: legacy .ai type as text objects and hidden objects kept; legacy type read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type is written as version 7 text objects (§15): point type, area type and type on a path, with fonts by PostScript name re-encoded to Windows-1252, and each run's paint, size, scaling, tracking, leading, alignment and indents; type 8-bit text can't hold (or all, with outlineText) is outlines. The format hides only layers, so hidden objects go to a hidden layer above their own (hiddenObjects "layer", the default), are left out ("omit") or show ("visible"). The .ai reader reads legacy type (To … TO, TE, %AI3_BeginEncoding) and ignores Ar; the import fuzzer gets legacy type tokens. --- crates/engine/src/cmd/fileio/legacy_ai.rs | 44 ++- crates/engine/src/cmd/fileio/native.rs | 14 +- crates/engine/src/tests_legacy_ai.rs | 62 +++- crates/engine/tests/import_fuzz.rs | 31 ++ crates/eps/src/import/ai/legacy_text.rs | 310 ++++++++++++++++++ crates/eps/src/import/ai/read.rs | 35 ++- crates/eps/src/latin.rs | 255 +++++++++++++++ crates/eps/src/legacy_ai.rs | 367 ++++++++++++++++++++-- crates/eps/src/lib.rs | 3 +- crates/eps/src/ps.rs | 24 ++ crates/eps/src/tests_legacy_ai.rs | 185 ++++++++++- crates/text/src/fontdb.rs | 19 ++ 12 files changed, 1312 insertions(+), 37 deletions(-) create mode 100644 crates/eps/src/import/ai/legacy_text.rs create mode 100644 crates/eps/src/latin.rs diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index e0961b61c..5d1df1493 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -16,6 +16,12 @@ //! channel, §8.1). With `flatten` (the default) each becomes an opaque image of itself and the art //! under it, rendered from the document as it is, clipped to the area where it changes what shows; //! the art under it stays as it was. With `omit` they are left out. +//! +//! The format can't hide objects, only layers (`hiddenObjects`): with `layer` (the default) each +//! layer's hidden objects go to a hidden layer of their own just above it, with `omit` they are +//! left out, with `visible` they show where they are. Type is written as text the format's reader +//! lays out again, in the fonts it names; `outlineText` writes its outlines instead (as type that +//! 8-bit text can't hold always is). use std::sync::Arc; @@ -45,6 +51,27 @@ enum SeeThrough { Omit, } +/// Where a legacy `.ai`'s hidden objects go (`hiddenObjects`): the format hides only layers. +fn hidden_objects(cmd: &str, p: &Value) -> Result { + use vectorcraft_eps::HiddenObjects; + match p.get("hiddenObjects") { + None | Some(Value::Null) => Ok(HiddenObjects::Layer), + Some(Value::String(v)) if v.trim().is_empty() || v.trim().eq_ignore_ascii_case("layer") => Ok(HiddenObjects::Layer), + Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("omit") => Ok(HiddenObjects::Omit), + Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("visible") => Ok(HiddenObjects::Visible), + Some(v) => Err(bad(cmd, format!("hiddenObjects {v}: \"layer\" (the default), \"omit\" or \"visible\""))), + } +} + +/// Type as outlines (`outlineText`), not text objects. +fn outline_text(cmd: &str, p: &Value) -> Result { + match p.get("outlineText") { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(b)) => Ok(*b), + Some(v) => Err(bad(cmd, format!("outlineText {v}: true or false"))), + } +} + fn see_through(cmd: &str, p: &Value) -> Result { match p.get("transparentImages") { None | Some(Value::Null) => Ok(SeeThrough::Flatten), @@ -55,7 +82,7 @@ fn see_through(cmd: &str, p: &Value) -> Result { } /// What the format loses against a VectorCraft document, reported with every save. -const FIDELITY: &str = "a legacy .ai (version 7) keeps layers, groups, compound paths, clipping groups, colours, gradients and images, not the rest: save as VectorCraft to keep everything editable"; +const FIDELITY: &str = "a legacy .ai (version 7) keeps layers, groups, compound paths, clipping groups, type, colours, gradients and images, not the rest: save as VectorCraft to keep everything editable"; const FLATTENED: &str = "transparent images (see-through pixels, opacity or a blending mode) are flattened with the art under them into opaque images"; const OMITTED: &str = "transparent images (see-through pixels, opacity or a blending mode) are left out"; @@ -69,6 +96,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result return Err(bad(cmd, "the document has no artboard")); } let mode = see_through(cmd, p)?; + let (hidden, outline_text) = (hidden_objects(cmd, p)?, outline_text(cmd, p)?); let picked: ArtboardPick = super::encode::options(f, p)?; let boards = picked.resolve(n).map_err(|e| bad(cmd, e))?.unwrap_or_else(|| (0..n).collect()); let mut enc = Encoded { warnings: vec![FIDELITY.to_string()], ..Encoded::default() }; @@ -81,7 +109,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result let (full, placed) = crate::cmd::place::document::full_documents(&one); let mut d = full.into_owned(); expand_appearances(&mut d); - let images = see_through_images(&d); + let images = see_through_images(&d, mode); if !images.is_empty() { note(&mut enc.warnings, if mode == SeeThrough::Flatten { FLATTENED } else { OMITTED }); if mode == SeeThrough::Flatten { @@ -92,7 +120,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result } } } - let o = vectorcraft_eps::LegacyAiOptions { title: doc.title.clone(), created }; + let o = vectorcraft_eps::LegacyAiOptions { title: doc.title.clone(), created, hidden, outline_text }; let out = vectorcraft_eps::export_legacy_ai(&d, &o).map_err(|e| bad(cmd, e))?; for w in placed.iter().chain(&out.warnings) { note(&mut enc.warnings, w); @@ -113,8 +141,10 @@ fn expand_appearances(d: &mut Document) { } /// The images of `d` a legacy `.ai` can't hold, in paint order with their places in it (as -/// [`Document::walk`] counts): visible ones with see-through pixels, opacity or a blending mode. -fn see_through_images(d: &Document) -> Vec<(usize, NodeId)> { +/// [`Document::walk`] counts): those with see-through pixels, opacity or a blending mode. Only +/// visible ones flatten (a hidden one, which the hidden objects option may write, is written over +/// white); all are left out with `omit`. +fn see_through_images(d: &Document, mode: SeeThrough) -> Vec<(usize, NodeId)> { let keys = super::super::flatten::see_through_images(d); let mut out = vec![]; let mut at = 0; @@ -126,7 +156,9 @@ fn see_through_images(d: &Document) -> Vec<(usize, NodeId)> { } at += 1; }); - out.retain(|(_, id)| d.is_visible(*id)); + if mode == SeeThrough::Flatten { + out.retain(|(_, id)| d.is_visible(*id)); + } out } diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index 2874b95a7..681b2fd5f 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -103,7 +103,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[ name: "aiVersion", ty: "string", default: "\"pdf\"", - description: "pdf (a PDF-compatible .ai carrying the VectorCraft document) or 7 (the legacy, PostScript-form .ai of version 7, which keeps layers, groups, compound paths and clipping groups in apps that read it; one file per artboard, transparent objects opaque, type as outlines; the other .ai options don't apply)", + description: "pdf (a PDF-compatible .ai carrying the VectorCraft document) or 7 (the legacy, PostScript-form .ai of version 7, which keeps layers, groups, compound paths and clipping groups in apps that read it; one file per artboard, transparent objects opaque; the other .ai options don't apply)", }, FormatOption { name: "transparentImages", @@ -111,6 +111,18 @@ pub const AI_OPTIONS: &[FormatOption] = &[ default: "\"flatten\"", description: "with aiVersion 7, the images version 7 can't hold (see-through pixels, opacity or a blending mode): flatten (the default: each becomes an opaque image of itself and the art under it, clipped to where it changes what shows; that art stays as it is) or omit (left out)", }, + FormatOption { + name: "hiddenObjects", + ty: "string", + default: "\"layer\"", + description: "with aiVersion 7, hidden objects, which version 7 can't hide (it hides only layers): layer (the default: each layer's hidden objects in a hidden layer of their own just above it, out of their groups), omit (left out) or visible (shown where they are)", + }, + FormatOption { + name: "outlineText", + ty: "boolean", + default: "false", + description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, inline graphics, gradient or pattern fills) is always outlines", + }, ]; #[derive(Default, Deserialize)] diff --git a/crates/engine/src/tests_legacy_ai.rs b/crates/engine/src/tests_legacy_ai.rs index 79f41c822..fef2b44cf 100644 --- a/crates/engine/src/tests_legacy_ai.rs +++ b/crates/engine/src/tests_legacy_ai.rs @@ -93,7 +93,7 @@ fn the_default_stays_pdf_compatible_and_other_versions_are_refused() { } #[test] -fn transparency_type_and_appearances_are_written_as_plain_art() { +fn transparency_and_appearances_are_written_as_plain_art_and_type_as_text() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); @@ -105,7 +105,7 @@ fn transparency_type_and_appearances_are_written_as_plain_art() { let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap(); let w = warnings(&r); assert!(w.iter().any(|w| w.contains("written opaque")), "{w:?}"); - assert!(w.iter().any(|w| w.contains("outlines")), "{w:?}"); + assert!(!w.iter().any(|w| w.contains("type") && w.contains("outlines")), "{w:?}"); let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); let text = String::from_utf8(bytes.clone()).unwrap(); // No transparency operators: version 7 has none. @@ -116,6 +116,64 @@ fn transparency_type_and_appearances_are_written_as_plain_art() { let mut opaque = true; d.walk(|n| opaque &= n.opacity == 1.0); assert!(opaque); + assert_eq!(texts(d), ["Hi"]); +} + +/// The plain text of every text object of `d`. +fn texts(d: &vectorcraft_doc::Document) -> Vec { + let mut out = vec![]; + d.walk(|n| { + if let vectorcraft_doc::NodeKind::Text(t) = &n.kind { + out.push(t.plain_text()); + } + }); + out +} + +#[test] +fn type_can_be_written_as_outlines() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("text.create", &json!({"x": 20, "y": 50, "text": "Café", "size": 24})).unwrap(); + let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "outlineText": true})).unwrap(); + s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(texts(d).is_empty()); + let mut paths = 0; + d.walk(|n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. } | vectorcraft_doc::NodeKind::Path { .. }))); + assert!(paths > 0); + assert!(s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "outlineText": "yes"})).is_err()); + let v = s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap(); + assert_eq!(v["options"]["outlineText"]["value"], false, "{v}"); +} + +#[test] +fn hidden_objects_go_to_a_hidden_layer_or_are_left_out_or_shown() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 60, "y": 10, "width": 30, "height": 30})).unwrap(); + s.execute("object.hide", &json!({})).unwrap(); + let saved = |s: &mut Session, hidden: Value| { + let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "hiddenObjects": hidden})).unwrap(); + s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + s.execute("file.close", &json!({})).unwrap(); + (d, warnings(&r)) + }; + let (d, w) = saved(&mut s, Value::Null); + assert_eq!(layer_names(&d), ["Layer 1", "Layer 1 (hidden)"], "{w:?}"); + assert!(d.layers[0].visible && !d.layers[1].visible); + assert!(w.iter().any(|w| w.contains("hidden layer")), "{w:?}"); + let (d, _) = saved(&mut s, json!("omit")); + assert_eq!(layer_names(&d), ["Layer 1"]); + assert_eq!(d.layers[0].children().map(Vec::len), Some(1)); + let (d, _) = saved(&mut s, json!("visible")); + assert_eq!(layer_names(&d), ["Layer 1"]); + assert_eq!(d.layers[0].children().map(|c| c.iter().filter(|n| n.visible).count()), Some(2)); + assert!(s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "hiddenObjects": "behind"})).is_err()); + let v = s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap(); + assert_eq!(v["options"]["hiddenObjects"]["value"], "layer", "{v}"); } #[test] diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 6c8737cb9..8da0b4e30 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2152,6 +2152,37 @@ fn arb_ai_token() -> impl Strategy { "\n%AI3_BeginPattern: (Dots)\n(Dots) 0 0 6 6\n", "\n%AI3_EndPattern\n", "To", + // Legacy type (version 7 §15): text objects, their paths, attributes and strings. + "0 To", + "1 To", + "2 To", + "1e308 To", + "TO", + "1 0 0 1 10 10 0 Tp", + "0 0 0 0 1e308 -1e308 1e308 Tp", + "-1 0 0 -1 5 5 99.5 Tp", + "TP", + "/_Helvetica 12 9 3 Tf", + "/_ 1e308 Tf", + "-5 Tf", + "(H\\351llo\\r) Tx", + "(\\r) TX", + "() Tj", + "(x) Tx", + "7 Tr", + "-1e308 Ts", + "1e308 0 Tz", + "1e308 Tt", + "0 TA", + "1e308 -1e308 Tl", + "9 Ta", + "1e308 -1e308 1e308 Ti", + "0 1 Tk", + "[39/quotesingle 128/Euro/eacute/nosuch 300/x TE", + "[1e308/a -1/b TE", + "\n%AI3_BeginEncoding: _Helvetica Helvetica\n[/_Helvetica/Helvetica 0 0 1 TZ\n%AI3_EndEncoding AdobeType\n", + "\n%AI3_BeginEncoding:\n", + "800 Ar", "frobnicate", "\n%AI5_BeginLayer\n", "\n%AI5_EndLayer--\n", diff --git a/crates/eps/src/import/ai/legacy_text.rs b/crates/eps/src/import/ai/legacy_text.rs new file mode 100644 index 000000000..126619c13 --- /dev/null +++ b/crates/eps/src/import/ai/legacy_text.rs @@ -0,0 +1,310 @@ +//! Type in the legacy format (version 7 specification §15): text objects `type To` … `TO`, their +//! text path (`a b c d tx ty startPt Tp`, an area's frame or the path type runs along, `TP`), the +//! modal character and paragraph attributes (`Tr Tf Ts Tz Tt TA Tl Ta Ti`), the paint style, and +//! the characters (`Tx`, `Tj`, overflow `TX`; `\r` ends a paragraph). Final-form operators (the +//! computed positions, §15.1) are skipped: the type is laid out again. Strings are decoded by the +//! file's `TE` encoding, else as Windows-1252; a re-encoded font `/_name` is the font its +//! `%AI3_BeginEncoding` names (else `name`). + +use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen}; +use vectorcraft_color::Paint; +use vectorcraft_doc::{CharStyle, Justify, NodeKind, ParaStyle, TextKind, TextObject, TextRun}; +use vectorcraft_geom::PathData; + +use super::super::obj::V; +use super::{FLIP, GState, Reader}; +use crate::latin; + +/// Most characters of type read in one file. +const MAX_CHARS: usize = 1 << 22; +/// Largest type size and indent read (points). +const MAX_SIZE: f64 = 1e4; + +/// A text object being read. +#[derive(Debug, Default)] +pub(super) struct LegacyText { + /// `To`'s type: 0 point type, 1 area type, 2 type on a path. + kind: i64, + /// `Tp`'s matrix: text space (y up) → art space. + matrix: Option, + /// Where type on a path starts: a segment's index plus how far along it. + start: f64, + /// Between `Tp` and `TP`: the path read is the text path. + in_path: bool, + /// The frame or path, in art space. + path: Option, + runs: Vec, + /// Each paragraph's attributes, as they were at its first character. + paras: Vec, + /// The current paragraph's attributes are in `paras`. + para_taken: bool, + /// An overflow paragraph break (`(\r) TX`) not yet followed by type: the story's end. + pending_break: bool, + /// The state when it began (lock, visibility), and whether it began on a `%_` line. + gs: GState, + hidden: bool, +} + +/// The modal type attributes of the legacy format, which last from one text object to the next. +#[derive(Debug)] +pub(super) struct TypeState { + style: CharStyle, + para: ParaStyle, + /// `Tr`. + render: i64, + /// The encoding strings are in, by code (`TE`); None where it says nothing. + encoding: Vec>, + /// Re-encoded fonts' original names (`%AI3_BeginEncoding: _new old`). + fonts: Vec<(String, String)>, + chars: usize, +} + +impl Default for TypeState { + fn default() -> Self { + Self { style: CharStyle::default(), para: ParaStyle::default(), render: 0, encoding: vec![None; 256], fonts: vec![], chars: 0 } + } +} + +impl TypeState { + /// `%AI3_BeginEncoding: _new old` (`line` the comment). + pub(super) fn encoding_comment(&mut self, line: &str) { + let Some((_, names)) = line.split_once(':') else { return }; + let mut words = names.split_whitespace(); + if let (Some(new), Some(old)) = (words.next(), words.next()) + && self.fonts.len() < 4096 + { + self.fonts.push((new.trim_start_matches('/').to_string(), old.trim_start_matches('/').to_string())); + } + } +} + +fn num(vals: &[V], i: usize) -> Option { + vals.iter().filter_map(V::num).nth(i).filter(|v| v.is_finite()) +} + +impl Reader<'_> { + /// `[encodingPairs TE`: codes, each followed by the glyph names from it on (§3.3.1). + pub(super) fn te(&mut self, vals: &[V]) { + let mut code: Option = None; + for v in vals { + match v { + V::Num(n) if n.is_finite() && (0.0..256.0).contains(n) => code = Some(*n as usize), + V::Name(name) => { + if let Some(c) = code { + if let (Some(slot), Ok(b)) = (self.type_state.encoding.get_mut(c), u8::try_from(c)) { + *slot = latin::glyph(b, name); + } + code = Some(c + 1); + } + } + _ => {} + } + } + } + + /// Is a text object's path being read (so the path painted is its frame or path)? + pub(super) fn in_text_path(&self) -> bool { + self.text.as_ref().is_some_and(|t| t.in_path) + } + + /// `type To`: a text object begins. + pub(super) fn begin_text(&mut self, vals: &[V], hidden: bool) { + self.end_path_quietly(); + let kind = num(vals, 0).map_or(0, |k| k as i64); + self.text = Some(LegacyText { kind, gs: self.gs.clone(), hidden, ..LegacyText::default() }); + } + + /// `a b c d tx ty startPt Tp`: the text path begins. + pub(super) fn begin_text_path(&mut self, vals: &[V]) { + self.end_path_quietly(); + let n: Vec = vals.iter().filter_map(V::num).collect(); + let Some(t) = self.text.as_mut() else { return }; + if let Some(m) = n.get(..6).and_then(|m| <[f64; 6]>::try_from(m).ok()) { + let m = Affine::new(m); + let det = m.determinant(); + if m.as_coeffs().iter().all(|v| v.is_finite()) && det.is_finite() && det.abs() > 1e-12 { + t.matrix = Some(m); + } + } + t.start = n.get(6).copied().filter(|s| s.is_finite() && *s >= 0.0).unwrap_or(0.0); + t.in_path = true; + } + + /// The path painted (or `TP`, ending the text path): the text object's frame or path. + pub(super) fn text_path(&mut self) { + let path = std::mem::take(&mut self.path); + self.end_path_quietly(); + if let Some(t) = self.text.as_mut() + && t.path.is_none() + && !path.elements().is_empty() + { + t.path = Some(path); + } + } + + /// `TP`. + pub(super) fn end_text_path(&mut self) { + self.text_path(); + if let Some(t) = self.text.as_mut() { + t.in_path = false; + } + } + + /// A character or paragraph attribute. + pub(super) fn text_attr(&mut self, w: &str, vals: &[V]) { + let st = &mut self.type_state; + let n0 = num(vals, 0); + match w { + "Tr" => st.render = n0.map_or(0, |v| v as i64), + "Tf" => { + if let Some(name) = vals.iter().find_map(|v| if let V::Name(n) = v { Some(n.clone()) } else { None }) { + let name = st + .fonts + .iter() + .find(|(new, _)| *new == name) + .map_or_else(|| name.trim_start_matches('_').to_string(), |(_, old)| old.clone()); + let (family, style) = super::super::super::text::family_style(&name); + st.style.font_family = family; + st.style.font_style = style; + st.style.font_version = None; + } + if let Some(size) = num(vals, 0).filter(|s| *s > 0.0) { + st.style.size = size.min(MAX_SIZE); + } + } + "Ts" => st.style.baseline_shift = n0.unwrap_or(0.0).clamp(-MAX_SIZE, MAX_SIZE), + "Tz" => { + let scale = |v: f64| v.clamp(1.0, 10_000.0); + st.style.h_scale = n0.map_or(100.0, scale); + st.style.v_scale = num(vals, 1).map_or(100.0, scale); + } + "Tt" => st.style.tracking = n0.unwrap_or(0.0).clamp(-10_000.0, 10_000.0), + "TA" => st.style.kerning = if n0 == Some(0.0) { Some(0.0) } else { None }, + "Tl" => { + if let Some(l) = n0 { + let l = l.clamp(0.0, MAX_SIZE); + // The automatic leading, 120% of the size, stays automatic. + st.style.leading = if (l - st.style.size * 1.2).abs() < 1e-3 { None } else { Some(l) }; + } + st.para.space_before = num(vals, 1).unwrap_or(0.0).clamp(-MAX_SIZE, MAX_SIZE); + } + "Ta" => { + st.para.justify = match n0.map(|v| v as i64) { + Some(1) => Justify::Center, + Some(2) => Justify::Right, + Some(3) => Justify::JustifyLeft, + Some(4) => Justify::JustifyAll, + _ => Justify::Left, + } + } + "Ti" => { + let indent = |i: usize| num(vals, i).unwrap_or(0.0).clamp(-MAX_SIZE, MAX_SIZE); + (st.para.first_line_indent, st.para.left_indent, st.para.right_indent) = (indent(0), indent(1), indent(2)); + } + _ => {} + } + } + + /// `(…) Tx` (`Tj`, overflow `TX`): characters in the current style and paint. + pub(super) fn text_body(&mut self, w: &str, vals: &[V]) { + let Some(bytes) = vals.iter().rev().find_map(|v| if let V::Str(s) = v { Some(s.clone()) } else { None }) else { return }; + let st = &mut self.type_state; + let Some(t) = self.text.as_mut() else { return }; + let decoded: String = bytes + .iter() + .map(|&b| if b == b'\r' { '\n' } else { st.encoding.get(usize::from(b)).copied().flatten().unwrap_or_else(|| latin::decode(b)) }) + .collect(); + if decoded.is_empty() { + return; + } + // Overflow text ending the story with a paragraph break (`(\r) TX`) adds no paragraph. + if w == "TX" && decoded == "\n" { + t.pending_break = true; + return; + } + if st.chars >= MAX_CHARS { + return; + } + st.chars += decoded.chars().count(); + let gs = &self.gs; + let mut style = st.style.clone(); + let (fill, stroke) = match st.render { + 0 | 4 => (true, false), + 1 | 5 => (false, true), + 2 | 6 => (true, true), + _ => (false, false), + }; + style.fill = if fill { gs.fill.clone() } else { Paint::None }; + style.stroke = if stroke { gs.stroke.clone() } else { Paint::None }; + style.stroke_width = if stroke { gs.width } else { 0.0 }; + (style.stroke_cap, style.stroke_join, style.stroke_miter_limit, style.stroke_dash) = (gs.cap, gs.join, gs.miter, gs.dash.clone()); + (style.overprint_fill, style.overprint_stroke) = (gs.overprint_fill, gs.overprint_stroke); + let mut text = String::new(); + if std::mem::take(&mut t.pending_break) { + text.push('\n'); + t.para_taken = false; + } + for c in decoded.chars() { + if c != '\n' && !t.para_taken { + t.paras.push(st.para.clone()); + t.para_taken = true; + } + text.push(c); + if c == '\n' { + if !t.para_taken { + t.paras.push(st.para.clone()); + } + t.para_taken = false; + } + } + match t.runs.last_mut() { + Some(last) if last.style == style => last.text.push_str(&text), + _ => t.runs.push(TextRun::new(text, style)), + } + } + + /// `TO`: the text object ends. + pub(super) fn end_text(&mut self, hidden: bool) -> Result<(), String> { + let Some(t) = self.text.take() else { return Ok(()) }; + if t.runs.is_empty() { + return Ok(()); + } + let m = t.matrix.unwrap_or(Affine::IDENTITY); + let to_doc = self.space(); + // Art space → text space (y down). + let to_text = FLIP * m.inverse(); + let kind = match (t.kind, &t.path) { + (1, Some(p)) => TextKind::Area { frame: PathData::from_bezpath(&(to_text * p.clone())) }, + (2, Some(p)) => TextKind::OnPath { path: PathData::from_bezpath(&(to_text * p.clone())), start: start_fraction(p, t.start), end: None }, + _ => TextKind::Point, + }; + let mut obj = TextObject::point(kurbo::Point::ZERO, "", CharStyle::default()); + obj.kind = kind; + obj.xf = to_doc * m * FLIP; + obj.runs = t.runs; + let mut paras = t.paras; + if paras.is_empty() { + paras.push(self.type_state.para.clone()); + } + obj.set_paragraph_styles(paras); + let node = self.new_node(NodeKind::Text(Box::new(obj)), &t.gs); + self.add(node, t.hidden || hidden) + } +} + +/// Where type on `path` starts (`index.fraction`: a segment and how far along it, §15.5.2), as a +/// fraction of its length. +fn start_fraction(path: &BezPath, start: f64) -> f64 { + const ACCURACY: f64 = 1e-3; + let segments: Vec = path.segments().collect(); + let lengths: Vec = segments.iter().map(|s| s.arclen(ACCURACY)).collect(); + let total: f64 = lengths.iter().sum(); + if !(total.is_finite() && total > 0.0 && start.is_finite()) { + return 0.0; + } + let index = start.floor().max(0.0) as usize; + let Some(seg) = segments.get(index) else { return 1.0 }; + let before: f64 = lengths.iter().take(index).sum(); + let along = seg.subsegment(0.0..(start - start.floor()).clamp(0.0, 1.0)).arclen(ACCURACY); + ((before + along) / total).clamp(0.0, 1.0) +} diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index f507696a5..6e65c9e3e 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -30,9 +30,12 @@ //! `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`), the matrix //! taking the pattern's space to art space (the other operands are what it is made of). //! +//! - Type in the legacy format (version 7 specification §15): `type To` … `TO`, read by +//! [`legacy_text`]. +//! //! Sections the reader doesn't need (brushes, symbols, styles, swatches, filters) are skipped; -//! type, symbols, fills with a pattern it can't read, placed files without a preview, legacy type -//! and operators it doesn't know are errors. +//! type, symbols, fills with a pattern it can't read, placed files without a preview and operators +//! it doesn't know are errors. use std::collections::{BTreeSet, HashMap, HashSet}; use std::rc::Rc; @@ -54,6 +57,9 @@ use super::obj::{self, Obj, V}; use super::paint::{self, GradientDef, Instance, Named}; use crate::ps; +#[path = "legacy_text.rs"] +mod legacy_text; + /// Deepest nesting of layers, groups and dictionaries. const MAX_DEPTH: usize = 256; /// Most operands on the stack. @@ -389,6 +395,9 @@ struct Reader<'a> { pattern: Option, /// The tile's top left (y down) of each pattern read, by name. pattern_tiles: HashMap, + /// The legacy text object being read (`To` … `TO`), and the modal type attributes. + text: Option, + type_state: legacy_text::TypeState, } /// Operators that change nothing the reader keeps. @@ -411,7 +420,8 @@ const IGNORED: &[&str] = &[ "Np", // Flatness (`i`): how finely curves print. "i", - "TE", + // Path resolution (`Ar`, §12): how finely curves print. + "Ar", "TZ", "Xs", "Xt", @@ -517,6 +527,8 @@ impl<'a> Reader<'a> { template_center: None, pattern: None, pattern_tiles: HashMap::new(), + text: None, + type_state: legacy_text::TypeState::default(), } } @@ -595,6 +607,9 @@ impl<'a> Reader<'a> { self.styles = super::style::art_styles(section); return Ok(()); } + if m == "BeginEncoding" { + self.type_state.encoding_comment(line); + } if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| *b == m) { return self.skip_to(end); } @@ -1003,6 +1018,8 @@ impl<'a> Reader<'a> { // Ends a path that stays open. "H" => {} "W" => self.clip_next = true, + // A text object's frame or path. + "N" | "n" | "F" | "f" | "S" | "s" | "B" | "b" if self.in_text_path() => self.text_path(), "N" | "n" | "F" | "f" | "S" | "s" | "B" | "b" => self.paint(w, hidden, false)?, "*" => { let op = vals.iter().rev().find_map(V::text).unwrap_or_else(|| "N".into()); @@ -1110,7 +1127,17 @@ impl<'a> Reader<'a> { } "~" if self.place.is_some() => self.end_place(hidden)?, // Type in the legacy format. - "To" | "TO" | "Tp" | "TP" | "Tx" | "TX" | "Tj" | "Tk" => self.unreadable("type in the legacy format"), + "To" => self.begin_text(&vals, hidden), + "Tp" => self.begin_text_path(&vals), + "TP" => self.end_text_path(), + "TO" => self.end_text(hidden)?, + "Tx" | "Tj" | "TX" => self.text_body(w, &vals), + "Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" => self.text_attr(w, &vals), + // Attributes the reader doesn't keep (kerning pairs, spacing, hanging punctuation, + // Far-Eastern type) and final-form positions (§15.3). + "Tk" | "TK" | "TC" | "TW" | "Tq" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TV" | "Tv" | "Ty" | "TY" | "TG" | "Tg" + | "Tu" | "Xu" => {} + "TE" => self.te(&vals), // An image whose samples follow in comment lines (read at `%AI5_EndRaster`). "XI" if self.raster.is_some() => { if let Some(r) = self.raster.as_mut() { diff --git a/crates/eps/src/latin.rs b/crates/eps/src/latin.rs new file mode 100644 index 000000000..be8c5e98b --- /dev/null +++ b/crates/eps/src/latin.rs @@ -0,0 +1,255 @@ +//! The 8-bit encoding of type in legacy `.ai` files: Windows-1252, its glyphs named as the +//! WinAnsiEncoding of the PDF specification names them (ISO 32000-1, Annex D.2). The writer +//! installs it with `TE` (version 7 specification §3.3.1) and encodes strings in it; the reader +//! decodes strings by the `TE` a file has (its glyph names, which may be another platform's). + +/// The glyph names of codes 128–255 (`.notdef` where Windows-1252 has no character). +const HIGH: [&str; 128] = [ + "Euro", + ".notdef", + "quotesinglbase", + "florin", + "quotedblbase", + "ellipsis", + "dagger", + "daggerdbl", + "circumflex", + "perthousand", + "Scaron", + "guilsinglleft", + "OE", + ".notdef", + "Zcaron", + ".notdef", + ".notdef", + "quoteleft", + "quoteright", + "quotedblleft", + "quotedblright", + "bullet", + "endash", + "emdash", + "tilde", + "trademark", + "scaron", + "guilsinglright", + "oe", + ".notdef", + "zcaron", + "Ydieresis", + "space", + "exclamdown", + "cent", + "sterling", + "currency", + "yen", + "brokenbar", + "section", + "dieresis", + "copyright", + "ordfeminine", + "guillemotleft", + "logicalnot", + "hyphen", + "registered", + "macron", + "degree", + "plusminus", + "twosuperior", + "threesuperior", + "acute", + "mu", + "paragraph", + "periodcentered", + "cedilla", + "onesuperior", + "ordmasculine", + "guillemotright", + "onequarter", + "onehalf", + "threequarters", + "questiondown", + "Agrave", + "Aacute", + "Acircumflex", + "Atilde", + "Adieresis", + "Aring", + "AE", + "Ccedilla", + "Egrave", + "Eacute", + "Ecircumflex", + "Edieresis", + "Igrave", + "Iacute", + "Icircumflex", + "Idieresis", + "Eth", + "Ntilde", + "Ograve", + "Oacute", + "Ocircumflex", + "Otilde", + "Odieresis", + "multiply", + "Oslash", + "Ugrave", + "Uacute", + "Ucircumflex", + "Udieresis", + "Yacute", + "Thorn", + "germandbls", + "agrave", + "aacute", + "acircumflex", + "atilde", + "adieresis", + "aring", + "ae", + "ccedilla", + "egrave", + "eacute", + "ecircumflex", + "edieresis", + "igrave", + "iacute", + "icircumflex", + "idieresis", + "eth", + "ntilde", + "ograve", + "oacute", + "ocircumflex", + "otilde", + "odieresis", + "divide", + "oslash", + "ugrave", + "uacute", + "ucircumflex", + "udieresis", + "yacute", + "thorn", + "ydieresis", +]; + +/// Codes 128–159 of Windows-1252 (the others are Latin-1's); its five gaps are C1 controls. +const C1: [char; 32] = [ + '€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž', '\u{8F}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—', '˜', + '™', 'š', '›', 'œ', '\u{9D}', 'ž', 'Ÿ', +]; + +/// Glyph names other platforms' encodings use that Windows-1252 has no code for (Mac Roman's). +const OTHER_NAMES: &[(&str, char)] = &[ + ("notequal", '≠'), + ("infinity", '∞'), + ("lessequal", '≤'), + ("greaterequal", '≥'), + ("partialdiff", '∂'), + ("summation", '∑'), + ("product", '∏'), + ("pi", 'π'), + ("integral", '∫'), + ("Omega", 'Ω'), + ("radical", '√'), + ("approxequal", '≈'), + ("Delta", '∆'), + ("lozenge", '◊'), + ("fraction", '⁄'), + ("fi", 'fi'), + ("fl", 'fl'), + ("dotlessi", 'ı'), + ("breve", '˘'), + ("dotaccent", '˙'), + ("ring", '˚'), + ("hungarumlaut", '˝'), + ("ogonek", '˛'), + ("caron", 'ˇ'), + ("nbspace", '\u{A0}'), + ("quotesingle", '\''), + ("grave", '`'), +]; + +/// Character `b` of Windows-1252. +pub(crate) fn decode(b: u8) -> char { + match b { + 0x80..=0x9F => C1.get(usize::from(b - 0x80)).copied().unwrap_or('?'), + _ => char::from(b), + } +} + +/// The code of `c` in Windows-1252: the printable characters, tab and the line breaks; None for +/// the others (and the C1 controls). +pub(crate) fn encode(c: char) -> Option { + match c { + '\t' | '\n' | '\r' | ' '..='~' | '\u{A0}'..='\u{FF}' => u8::try_from(u32::from(c)).ok(), + _ => C1.iter().position(|&x| x == c && !x.is_control()).and_then(|i| u8::try_from(0x80 + i).ok()), + } +} + +/// The `TE` that installs the encoding: its codes 39 and 96 as in ASCII (PostScript's standard +/// encoding has quotes there) and 128–255 by name. +pub(crate) fn te() -> String { + let mut s = String::from("[39/quotesingle 96/grave 128"); + for (i, name) in HIGH.iter().enumerate() { + s.push('/'); + s.push_str(name); + if i % 8 == 7 && i + 1 < HIGH.len() { + s.push('\n'); + } + } + s.push_str("\nTE\n"); + s +} + +/// The character glyph `name` stands for at code `code` of an encoding: that code's Windows-1252 +/// character when the name is the one this encoding gives it; else by the name. +pub(crate) fn glyph(code: u8, name: &str) -> Option { + if code >= 0x80 && HIGH.get(usize::from(code - 0x80)) == Some(&name) && name != ".notdef" { + return Some(decode(code)); + } + if let Some(i) = HIGH.iter().position(|n| *n == name && *n != ".notdef" && *n != "space") { + return u8::try_from(0x80 + i).ok().map(decode); + } + if let Some((_, c)) = OTHER_NAMES.iter().find(|(n, _)| *n == name) { + return Some(*c); + } + match name { + "space" => Some(' '), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_encodable_character_decodes_as_itself() { + for b in 0..=255u8 { + let c = decode(b); + if let Some(e) = encode(c) { + assert_eq!(e, b, "{c:?}"); + } + } + assert_eq!(encode('é'), Some(0xE9)); + assert_eq!(encode('€'), Some(0x80)); + assert_eq!(encode('—'), Some(0x97)); + assert_eq!(encode('Ω'), None); + assert_eq!(encode('\u{81}'), None); + } + + #[test] + fn glyph_names_decode_by_code_and_by_name() { + assert_eq!(glyph(0xE9, "eacute"), Some('é')); + assert_eq!(glyph(0xA0, "space"), Some('\u{A0}')); + // Mac Roman puts é at 0x8E. + assert_eq!(glyph(0x8E, "eacute"), Some('é')); + assert_eq!(glyph(0xAD, "notequal"), Some('≠')); + assert_eq!(glyph(0x41, "nosuchglyph"), None); + assert!(te().starts_with("[39/quotesingle 96/grave 128/Euro/.notdef/")); + assert!(te().ends_with("/ydieresis\nTE\n")); + } +} diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index de2cc5b0a..6904fb0d5 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -29,16 +29,27 @@ //! open) to stroke it too. //! - Images (§8): `%AI5_BeginRaster`, `[matrix] 0 0 w h w h 8 3 0 0 0 0 XI`, the RGB samples in //! hex on comment lines, `%AI5_EndRaster`. +//! - Type (§15): point type, area type and type on a path as text objects, `type To`, `a b c d tx +//! ty startPt Tp` (the text matrix; the frame or path in art space between it and `TP`), each +//! run's paint style, `Tr` (fill, stroke, both or neither), `/_font size ascent descent Tf`, +//! `Ts Tz Tt TA Tl Ta Ti` and its characters `(…) Tx` (`\r` ends a paragraph), `TO`. Only the +//! revisable form is written: readers lay the type out again (§15.1). Fonts are named by their +//! PostScript names in `%%DocumentFonts` and `%%IncludeFont` (§3.1) and re-encoded to +//! Windows-1252 (`TE`, then `%AI3_BeginEncoding: _name name`, `TZ`, §3.3), the 8-bit encoding +//! strings are in. //! -//! The format has one artboard, no transparency, sublayers, object names, live effects, symbols -//! or appearances with several fills and strokes. Callers write one file per artboard and expand -//! appearances first; what is left is written as the format can hold it, each loss a warning: -//! transparent objects are written opaque, sublayers become groups, type its outlines, live objects and symbols their -//! art, strokes a plain line can't draw their outlines, pattern fills their tiles clipped to the -//! shape, freeform gradients their average colour, elliptical radial gradients circular (the -//! gradient matrix is relative to a canvas the specification doesn't size), transparent image -//! pixels over white. Hidden -//! objects are left out (hidden layers stay, hidden). +//! The format has one artboard, no transparency, sublayers, object names, hidden objects (only +//! layers hide), live effects, symbols or appearances with several fills and strokes. Callers write +//! one file per artboard and expand appearances first; what is left is written as the format can +//! hold it, each loss a warning: transparent objects are written opaque, sublayers become groups, +//! live objects and symbols their art, strokes a plain line can't draw their outlines, pattern +//! fills their tiles clipped to the shape, freeform gradients their average colour, elliptical +//! radial gradients circular (the gradient matrix is relative to a canvas the specification +//! doesn't size), transparent image pixels over white. Hidden objects go to a hidden layer of +//! their own above their layer, are left out or show, as [`HiddenObjects`] says (in a hidden +//! layer they stay where they are). Type that 8-bit text can't hold (characters outside +//! Windows-1252, vertical type, inline graphics, gradient or pattern fills) is written as its +//! outlines, and all type with [`LegacyAiOptions::outline_text`]. //! //! No prolog is written: the file names none, so PostScript printers can't print it, as the `.ai` //! files other apps write in this format. @@ -49,12 +60,13 @@ use std::sync::Arc; use vectorcraft_color::cms::Cms; use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{ - AppearanceItem, Document, FillLayer, ImageObject, LAYER_COLORS, LayerColor, LineCap, LineJoin, Node, NodeKind, StrokeLayer, TextObject, + AppearanceItem, AreaOptions, CharPosition, CharStyle, Document, FillLayer, ImageObject, Justify, LAYER_COLORS, LayerColor, LineCap, LineJoin, + Node, NodeKind, ParaDirection, ParaStyle, PathEffect, StrokeLayer, TextKind, TextObject, }; use vectorcraft_effects::stroke; use vectorcraft_geom::{Affine, AnchorKind, BezPath, FillRule, PathData, Point, Rect, Shape, SubPath, Vec2}; -use crate::ps; +use crate::{latin, ps}; /// How deep symbols and live objects may nest in one another (deeper art is left out). const MAX_NEST: u32 = 8; @@ -66,8 +78,11 @@ const MAX_IMAGE_PIXELS: u64 = 1 << 26; const HEX_LINE: usize = 64; pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level layer (legacy .ai files have no sublayers)"; -pub const TYPE: &str = "type is written as outlines (legacy .ai type isn't written yet)"; +pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, inline graphics, gradient or pattern fills) is written as outlines"; +pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, small caps, superscript and subscript, character rotation, OpenType features, area type options, type on a path effects, space after paragraphs, tabs) are left out"; pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, hidden)"; +pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above their own (legacy .ai files can hide only layers)"; +pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)"; pub const TRANSPARENCY: &str = "transparency (opacity, blending modes, opacity masks) is written opaque: legacy .ai files have none"; pub const NAMES: &str = "object names are left out (legacy .ai files name only layers)"; const PATTERNS: &str = "pattern fills are written as their tiles, clipped to the shape"; @@ -80,13 +95,30 @@ const UNREADABLE_IMAGE: &str = "an image that can't be decoded or is too large w const DEEP: &str = "art nested too deeply was left out"; const ELLIPTICAL: &str = "elliptical radial gradients are written circular"; -/// What to write in the header. +/// What to write in the header, and how to write what the format can't hold. #[derive(Clone, Debug, Default)] pub struct LegacyAiOptions { /// `%%Title`. pub title: String, /// `%%CreationDate`, Unix seconds. pub created: Option, + /// Where hidden objects go (the format hides only layers). + pub hidden: HiddenObjects, + /// Type as its glyphs' outlines, not text objects. + pub outline_text: bool, +} + +/// What a legacy `.ai` does with hidden objects, which the format can't mark hidden (only layers). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum HiddenObjects { + /// Each layer's hidden objects in a hidden layer of their own, just above it (they leave + /// their groups). + #[default] + Layer, + /// Left out. + Omit, + /// Written where they are, visible. + Visible, } /// The file and what it couldn't hold. @@ -110,12 +142,15 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result Result Result Result { nest: u32, /// Groups and layers open. depth: u32, + /// What to do with hidden objects. + hidden: HiddenObjects, + /// Type as outlines. + outline_text: bool, + /// The layer being written's hidden objects for its hidden layer, and whether each is locked. + hidden_art: Vec<(Node, bool)>, + /// Writing a layer that is hidden (or the hidden layer of one's hidden objects): hidden objects + /// are written where they are. + in_hidden: bool, + /// The fonts type is set in: PostScript name, and whether it's TrueType. + fonts: Vec<(String, bool)>, } impl<'a> Writer<'a> { - fn new(doc: &'a Document, to_art: Affine) -> Self { + fn new(doc: &'a Document, to_art: Affine, o: &LegacyAiOptions) -> Self { Self { doc, to_art, @@ -267,6 +341,11 @@ impl<'a> Writer<'a> { locked: false, nest: 0, depth: 0, + hidden: o.hidden, + outline_text: o.outline_text, + hidden_art: vec![], + in_hidden: false, + fonts: vec![], } } @@ -308,7 +387,45 @@ impl<'a> Writer<'a> { ); self.locked = false; self.line("0 A"); + self.in_hidden = !n.visible; self.contents(children, *clip, false); + self.in_hidden = false; + self.line("LB\n%AI5_EndLayer--"); + true + } + + /// The hidden layer of top-level layer `n`'s hidden objects, just above it (named after it, + /// with its colour, lock and printing). False when it has none. + fn hidden_layer(&mut self, n: &Node) -> bool { + if self.hidden_art.is_empty() { + return false; + } + let art = std::mem::take(&mut self.hidden_art); + let NodeKind::Layer { color, template, printable, .. } = &n.kind else { return false }; + let [r, g, b] = match color { + LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1), + LayerColor::Custom(rgb) => *rgb, + }; + let index = match color { + LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i), + _ => -1, + }; + let name = format!("{} (hidden)", n.name.as_deref().unwrap_or("Layer")); + let _ = writeln!( + self.body, + "%AI5_BeginLayer\n0 1 {} {} {} 0 {index} {r} {g} {b} Lb\n{} Ln", + u8::from(!n.locked), + u8::from(*printable && !*template), + u8::from(*template), + ps::string(&name), + ); + self.locked = false; + self.line("0 A"); + self.in_hidden = true; + for (node, locked) in &art { + self.node(node, *locked); + } + self.in_hidden = false; self.line("LB\n%AI5_EndLayer--"); true } @@ -334,9 +451,19 @@ impl<'a> Writer<'a> { /// One object (inside a container locked when `locked`). fn node(&mut self, n: &Node, locked: bool) { - if !n.visible { - self.warn(HIDDEN); - return; + if !n.visible && !self.in_hidden { + match self.hidden { + HiddenObjects::Omit => { + self.warn(HIDDEN); + return; + } + HiddenObjects::Layer => { + self.warn(HIDDEN_LAYER); + self.hidden_art.push((n.clone(), locked || n.locked)); + return; + } + HiddenObjects::Visible => self.warn(HIDDEN_SHOWN), + } } if self.depth >= MAX_DEPTH { self.warn(DEEP); @@ -826,13 +953,137 @@ impl<'a> Writer<'a> { // ---------- type ---------- - /// Type as its glyphs' outlines: a group of a compound path for each run, with the run's fill - /// and stroke. + /// Type: a text object when the format can hold it as text, else its outlines. fn text(&mut self, t: &TextObject) { - self.warn(TYPE); if !invertible(t.xf) { return; } + if !self.outline_text { + if writable(t) { + return self.text_object(t); + } + self.warn(TYPE); + } + self.outlines(t); + } + + /// A text object (§15): `type To`, the text path (`a b c d tx ty startPt Tp`, an area's frame + /// or the path type runs along, `TP`), then each run's style and characters, `TO`. The matrix + /// takes text space (y up, the first baseline's start for point type) to art space; the frame + /// and path are in art space, as the specification's examples have them (§15.5). + fn text_object(&mut self, t: &TextObject) { + if lost_attributes(t) { + self.warn(TYPE_ATTRIBUTES); + } + let m = self.to_art * t.xf * Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); + let (kind, path, start) = match &t.kind { + TextKind::Point => (0, None, 0.0), + TextKind::Area { frame } => (1, frame.subpaths.iter().find(|sp| !sp.anchors.is_empty()).cloned(), 0.0), + TextKind::OnPath { path, .. } => { + let sp = path.subpaths.iter().find(|sp| !sp.anchors.is_empty()).cloned(); + let fraction = t.kind.path_span().map_or(0.0, |(s, _)| s); + let start = sp + .as_ref() + .map_or(0.0, |sp| segment_point(&PathData::new(vec![sp.clone()]).transformed(self.to_art * t.xf).to_bezpath(), fraction)); + (2, sp, start) + } + }; + let _ = writeln!(self.body, "{kind} To"); + let [a, b, c, d, e, f] = m.as_coeffs(); + ps::push_nums(&mut self.body, &[a, b, c, d, e, f, start]); + self.line("Tp"); + if let Some(sp) = path { + let placed = SubPath::new(sp.anchors.iter().map(|a| a.transform(t.xf)).collect(), sp.closed); + self.subpath(&placed); + self.line(if sp.closed { "n" } else { "N" }); + } + self.line("TP"); + let mut para = 0usize; + let mut para_start = true; + for run in &t.runs { + let text = if run.style.all_caps { run.text.to_uppercase() } else { run.text.clone() }; + let mut pieces = text.split('\n').peekable(); + while let Some(piece) = pieces.next() { + let ends_paragraph = pieces.peek().is_some(); + if piece.is_empty() && !ends_paragraph { + continue; + } + if para_start { + self.paragraph(t.para_at(para)); + para_start = false; + } + self.char_style(&run.style, t.para_at(para)); + let mut bytes: Vec = piece.chars().filter_map(latin::encode).collect(); + if ends_paragraph { + bytes.push(b'\r'); + para += 1; + para_start = true; + } + self.body.push_str(&ps::bytes(&bytes)); + self.line(" Tx"); + } + } + self.line("TO"); + } + + /// A paragraph's attributes: alignment `Ta` and indents `Ti` (first line's, left, right). + fn paragraph(&mut self, p: &ParaStyle) { + let align = match p.justify { + Justify::Auto if p.direction == Some(ParaDirection::RightToLeft) => 2, + Justify::Auto | Justify::Left => 0, + Justify::Center => 1, + Justify::Right => 2, + Justify::JustifyLeft | Justify::JustifyCenter | Justify::JustifyRight => 3, + Justify::JustifyAll => 4, + }; + let _ = writeln!(self.body, "{align} Ta"); + ps::push_nums(&mut self.body, &[p.first_line_indent, p.left_indent, p.right_indent]); + self.line("Ti"); + } + + /// A run's character attributes and paint style: `Tr`, its fill and stroke, `Tf`, `Ts`, `Tz`, + /// `Tt`, `TA`, and the leading with the space before its paragraph, `Tl`. + fn char_style(&mut self, st: &CharStyle, p: &ParaStyle) { + let fill = !st.fill.is_none(); + let stroke = st.has_stroke(); + let render = match (fill, stroke) { + (true, false) => 0, + (false, true) => 1, + (true, true) => 2, + (false, false) => 3, + }; + let _ = writeln!(self.body, "{render} Tr"); + if fill { + self.color(&st.fill, false); + self.line(if st.overprint_fill { "1 O" } else { "0 O" }); + } + if stroke { + self.color(&st.stroke, true); + self.line(if st.overprint_stroke { "1 R" } else { "0 R" }); + self.line_style(&st.stroke_layer()); + } + let (name, truetype, ascent, descent) = font_of(st); + if !self.fonts.iter().any(|(n, _)| *n == name) { + self.fonts.push((name.clone(), truetype)); + } + let size = finite(st.size, 12.0); + let _ = write!(self.body, "/_{name} "); + ps::push_nums(&mut self.body, &[size, ascent * size, descent * size]); + self.line("Tf"); + ps::push_num(&mut self.body, finite(st.baseline_shift, 0.0)); + self.line(" Ts"); + ps::push_nums(&mut self.body, &[finite(st.h_scale, 100.0), finite(st.v_scale, 100.0)]); + self.line("Tz"); + ps::push_num(&mut self.body, finite(st.tracking, 0.0)); + self.line(" Tt"); + self.line(if st.kerning.is_none() { "1 TA" } else { "0 TA" }); + ps::push_nums(&mut self.body, &[finite(st.effective_leading(), size * 1.2), finite(p.space_before, 0.0)]); + self.line("Tl"); + } + + /// Type as its glyphs' outlines: a group of a compound path for each run, with the run's fill + /// and stroke. + fn outlines(&mut self, t: &TextObject) { let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); let scale = t.xf.determinant().abs().sqrt(); self.depth += 1; @@ -925,6 +1176,80 @@ impl<'a> Writer<'a> { } } +/// Can type `t` be written as text: horizontal, its runs plain characters (no inline graphics) +/// that 8-bit text can hold, filled and stroked with colours? +fn writable(t: &TextObject) -> bool { + let solid = |p: &Paint| matches!(p, Paint::Solid { .. } | Paint::None); + !t.vertical + && t.runs.iter().any(|r| !r.text.is_empty()) + && t.runs.iter().all(|r| { + r.inline.is_none() + && solid(&r.style.fill) + && solid(&r.style.stroke) + && (if r.style.all_caps { r.text.to_uppercase() } else { r.text.clone() }).chars().all(|c| latin::encode(c).is_some()) + }) +} + +/// Has type `t` attributes a legacy `.ai` text object can't hold (lost when it's written as text)? +fn lost_attributes(t: &TextObject) -> bool { + let run_lost = |s: &CharStyle| { + s.underline || s.strikethrough || s.small_caps.is_some() || s.position != CharPosition::Normal || s.rotation != 0.0 || !s.features.is_empty() + }; + let para_lost = |p: &ParaStyle| p.space_after != 0.0 || !p.tabs.is_empty(); + t.runs.iter().any(|r| run_lost(&r.style)) + || t.paragraph_styles().iter().any(para_lost) + || (matches!(t.kind, TextKind::Area { .. }) && t.area != AreaOptions::default()) + || (matches!(t.kind, TextKind::OnPath { .. }) && (t.path_effect != PathEffect::default() || t.path_spacing != 0.0)) +} + +/// The font type in style `st` is set in: its PostScript name, whether it's TrueType, and its +/// ascent and descent (ems). A font that isn't installed is named after its family and style. +fn font_of(st: &CharStyle) -> (String, bool, f64, f64) { + let db = vectorcraft_text::FontDb::global(); + match db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) { + Some(face) => { + let (ascent, descent) = face.vertical_metrics(); + let em = face.units_per_em().max(1.0); + let name = face.postscript_name(); + (name, face.is_truetype(), ascent / em, descent / em) + } + None => { + let squeeze = |s: &str| -> String { s.chars().filter(|c| c.is_ascii_alphanumeric()).collect() }; + let style = squeeze(&st.font_style); + let name = + if style.is_empty() || style == "Regular" { squeeze(&st.font_family) } else { format!("{}-{style}", squeeze(&st.font_family)) }; + (if name.is_empty() { "Helvetica".into() } else { name }, false, 0.75, 0.25) + } + } +} + +/// `v`, or `default` when it isn't finite. +fn finite(v: f64, default: f64) -> f64 { + if v.is_finite() { v } else { default } +} + +/// Where a fraction of `path`'s length is, as the start of type on a path gives it (§15.5.2): the +/// index of the segment it's on, plus how far along that segment (its curve parameter). +fn segment_point(path: &BezPath, fraction: f64) -> f64 { + use kurbo::ParamCurveArclen; + const ACCURACY: f64 = 1e-3; + let segments: Vec = path.segments().collect(); + let lengths: Vec = segments.iter().map(|s| s.arclen(ACCURACY)).collect(); + let total: f64 = lengths.iter().sum(); + if !(total.is_finite() && total > 0.0) { + return 0.0; + } + let mut left = fraction.clamp(0.0, 1.0) * total; + for (i, (seg, len)) in segments.iter().zip(&lengths).enumerate() { + if left <= *len || i + 1 == segments.len() { + let t = if *len > 0.0 { seg.inv_arclen(left.min(*len), ACCURACY) } else { 0.0 }; + return i as f64 + t.clamp(0.0, 0.9999); + } + left -= len; + } + 0.0 +} + /// Can `p` paint a path object's fill directly (a colour or a linear or radial gradient)? fn direct_fill(p: &Paint) -> bool { match p { diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index eb972210d..6328964c2 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -30,6 +30,7 @@ //! - `legacy_ai`: legacy `.ai` files of version 7 ([`export_legacy_ai`]), with their layers and groups. mod import; +mod latin; mod legacy_ai; mod print; mod ps; @@ -37,7 +38,7 @@ mod scene; mod tiff; pub use import::{Imported, ai_alone, family_style, import, import_with, is_loss, layered_ai}; -pub use legacy_ai::{LegacyAiOptions, LegacyAiOutput, export_legacy_ai}; +pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai}; pub use print::{PrintJob, PrintPage, print}; use vectorcraft_doc::Document; diff --git a/crates/eps/src/ps.rs b/crates/eps/src/ps.rs index fb27425ed..b00c1fbab 100644 --- a/crates/eps/src/ps.rs +++ b/crates/eps/src/ps.rs @@ -73,6 +73,30 @@ pub(crate) fn string(text: &str) -> String { s } +/// `bytes` as a PostScript string: `(` `)` `\` escaped, carriage returns, line feeds and tabs as +/// `\r` `\n` `\t`, other bytes outside printable ASCII as octal escapes. +pub(crate) fn bytes(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() + 2); + s.push('('); + for &b in bytes { + match b { + b'(' | b')' | b'\\' => { + s.push('\\'); + s.push(char::from(b)); + } + b'\r' => s.push_str("\\r"), + b'\n' => s.push_str("\\n"), + b'\t' => s.push_str("\\t"), + 0x20..=0x7e => s.push(char::from(b)), + _ => { + let _ = write!(s, "\\{b:03o}"); + } + } + } + s.push(')'); + s +} + /// A matrix operand `[a b c d e f]`. pub(crate) fn matrix(a: Affine) -> String { let mut s = String::from("["); diff --git a/crates/eps/src/tests_legacy_ai.rs b/crates/eps/src/tests_legacy_ai.rs index ce8c0c206..5930aa125 100644 --- a/crates/eps/src/tests_legacy_ai.rs +++ b/crates/eps/src/tests_legacy_ai.rs @@ -73,7 +73,11 @@ fn sample() -> Document { } fn write(doc: &Document) -> LegacyAiOutput { - export_legacy_ai(doc, &LegacyAiOptions { title: "Sample".into(), created: Some(0) }).unwrap() + write_with(doc, LegacyAiOptions::default()) +} + +fn write_with(doc: &Document, o: LegacyAiOptions) -> LegacyAiOutput { + export_legacy_ai(doc, &LegacyAiOptions { title: "Sample".into(), created: Some(0), ..o }).unwrap() } fn children(n: &Node) -> &[Arc] { @@ -236,7 +240,7 @@ fn losses_are_warned() { named.opacity = 0.5; doc.insert(Some(sub), usize::MAX, named).unwrap(); let w = write(&doc).warnings; - for expected in [SUBLAYERS, HIDDEN, NAMES, TRANSPARENCY] { + for expected in [SUBLAYERS, HIDDEN_LAYER, NAMES, TRANSPARENCY] { assert!(w.iter().any(|x| x == expected), "{expected} not in {w:?}"); } // The sublayer is a group in its layer. @@ -285,3 +289,180 @@ fn needs_an_artboard() { doc.artboards.clear(); assert!(export_legacy_ai(&doc, &LegacyAiOptions::default()).is_err()); } + +/// Every text object of `doc`'s layers, in paint order. +fn texts(doc: &Document) -> Vec<(Node, vectorcraft_doc::TextObject)> { + let mut out = vec![]; + doc.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + out.push((n.clone(), (**t).clone())); + } + }); + out +} + +fn style(size: f64, fill: Paint) -> vectorcraft_doc::CharStyle { + vectorcraft_doc::CharStyle { size, fill, ..vectorcraft_doc::CharStyle::default() } +} + +/// A document of point type (two paragraphs, the second centred, accented letters and a euro +/// sign), area type in a 100 × 50 frame and type on a path starting a quarter of the way along. +fn typed() -> Document { + use vectorcraft_doc::{Justify, ParaStyle, TextKind, TextObject, TextRun}; + let mut doc = Document::new(300.0, 200.0); + let layer = doc.layers[0].id; + let mut point = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "Héllo €5\nWorld", style(18.0, red())); + point.runs.push(TextRun::new(" again", style(12.0, Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0))))); + point.set_paragraph_styles(vec![ParaStyle::default(), ParaStyle { justify: Justify::Center, ..ParaStyle::default() }]); + let mut area = TextObject::point(vectorcraft_geom::Point::ZERO, "Area type flows in its frame", style(10.0, Paint::solid(Color::BLACK))); + area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)) }; + area.xf = Affine::translate((20.0, 80.0)); + let mut on_path = TextObject::point(vectorcraft_geom::Point::ZERO, "Along a path", style(12.0, Paint::solid(Color::gray(0.5)))); + let mut line = PathData::new(vec![]); + line.subpaths.push(vectorcraft_geom::SubPath::polyline(&[(150.0, 150.0).into(), (250.0, 150.0).into(), (250.0, 190.0).into()], false)); + on_path.kind = TextKind::OnPath { path: line, start: 0.25, end: None }; + on_path.xf = Affine::IDENTITY; + for t in [point, area, on_path] { + let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + doc +} + +#[test] +fn type_is_written_as_text_objects() { + let out = write(&typed()); + let text = String::from_utf8(out.bytes).unwrap(); + for needle in [ + "%%DocumentFonts: ", + "%%IncludeFont: ", + "\nTE\n", + "%AI3_BeginEncoding: _", + "0 To\n1 0 0 1 20 160 0 Tp\nTP\n", + "1 To\n", + "2 To\n", + " Tf\n", + "TO\n", + ] { + assert!(text.contains(needle), "missing {needle:?} in\n{text}"); + } + // Windows-1252: é is 351 (octal), the euro sign 200, the paragraph ends with `\r`. + assert!(text.contains("(H\\351llo \\2005\\r) Tx\n"), "{text}"); + // The second paragraph centred. + assert!(text.contains("1 Ta\n"), "{text}"); + // The area's frame in art space, painted with nothing. + assert!(text.contains("20 120 m\n"), "{text}"); + assert!(!out.warnings.iter().any(|w| w == TYPE), "{:?}", out.warnings); +} + +#[test] +fn type_reads_back_as_text() { + use vectorcraft_doc::{Justify, TextKind}; + let imported = crate::import(&write(&typed()).bytes).unwrap(); + let found = texts(&imported.document); + assert_eq!(found.len(), 3, "{:?}", imported.warnings); + let squeeze = |s: &str| s.replace(' ', ""); + // Point type: the characters, paragraphs, sizes, colours and place. + let (_, point) = &found[0]; + assert!(matches!(point.kind, TextKind::Point)); + assert_eq!(point.plain_text(), "Héllo €5\nWorld again"); + assert_eq!(point.runs.len(), 2, "{:?}", point.runs); + let first = &point.runs[0].style; + assert_eq!(squeeze(&first.font_family), "SourceSans3", "{first:?}"); + assert!((first.size - 18.0).abs() < 1e-6 && first.leading.is_none(), "{first:?}"); + assert_eq!(first.fill.color(), Some(Color::rgb(1.0, 0.0, 0.0))); + assert_eq!(point.runs[1].style.fill.color(), Some(Color::cmyk(1.0, 0.0, 0.0, 0.0))); + assert!((point.runs[1].style.size - 12.0).abs() < 1e-6); + assert_eq!(point.para_at(0).justify, Justify::Left); + assert_eq!(point.para_at(1).justify, Justify::Center); + let origin = point.xf * vectorcraft_geom::Point::ZERO; + assert!((origin.x - 20.0).abs() < 1e-3 && (origin.y - 40.0).abs() < 1e-3, "{:?}", point.xf); + // Area type: its frame where it was. + let (_, area) = &found[1]; + let TextKind::Area { frame } = &area.kind else { panic!("{:?}", area.kind) }; + let b = frame.transformed(area.xf).to_bezpath().bounding_box(); + assert!((b.x0 - 20.0).abs() < 1e-3 && (b.y0 - 80.0).abs() < 1e-3 && (b.x1 - 120.0).abs() < 1e-3 && (b.y1 - 130.0).abs() < 1e-3, "{b:?}"); + assert_eq!(area.plain_text(), "Area type flows in its frame"); + // Type on a path: its path and where along it the type starts. + let (_, along) = &found[2]; + let TextKind::OnPath { path, start, .. } = &along.kind else { panic!("{:?}", along.kind) }; + assert!((start - 0.25).abs() < 1e-2, "{start}"); + let b = path.transformed(along.xf).to_bezpath().bounding_box(); + assert!((b.x0 - 150.0).abs() < 1e-3 && (b.y1 - 190.0).abs() < 1e-3, "{b:?}"); +} + +#[test] +fn type_8_bit_text_cant_hold_and_type_asked_for_as_outlines_are_outlines() { + use vectorcraft_doc::TextObject; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let t = TextObject::point(vectorcraft_geom::Point::new(10.0, 50.0), "Ωmega 日本", style(12.0, red())); + let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == TYPE), "{:?}", out.warnings); + assert!(!String::from_utf8_lossy(&out.bytes).contains(" To\n")); + // Type that could be text, asked for as outlines. + let out = write_with(&typed(), LegacyAiOptions { outline_text: true, ..LegacyAiOptions::default() }); + let text = String::from_utf8(out.bytes).unwrap(); + assert!(!text.contains(" To\n") && !text.contains("%%DocumentFonts"), "{text}"); + assert!(!out.warnings.iter().any(|w| w == TYPE), "{:?}", out.warnings); +} + +/// A layer `Art` holding a group of a shown square and a hidden one; a hidden layer `Off` holding +/// a hidden square. +fn with_hidden() -> Document { + let mut doc = Document::new(200.0, 100.0); + doc.layers.clear(); + let art = doc.add_layer(Some("Art")); + let off = doc.add_layer(Some("Off")); + let shown = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), red(), Paint::None); + let mut hidden = shape(&mut doc, shapes::rectangle(Rect::new(50.0, 10.0, 80.0, 40.0)), red(), Paint::None); + hidden.visible = false; + hidden.locked = true; + let group = Node::group(doc.alloc_id(), vec![Arc::new(shown), Arc::new(hidden)]); + doc.insert(Some(art), usize::MAX, group).unwrap(); + let mut also = shape(&mut doc, shapes::rectangle(Rect::new(100.0, 10.0, 140.0, 40.0)), red(), Paint::None); + also.visible = false; + doc.insert(Some(off), usize::MAX, also).unwrap(); + if let Some(l) = doc.layers.get_mut(1) { + Arc::make_mut(l).visible = false; + } + doc +} + +#[test] +fn hidden_objects_go_to_a_hidden_layer_above_their_own() { + let out = write(&with_hidden()); + assert!(out.warnings.iter().any(|w| w == HIDDEN_LAYER), "{:?}", out.warnings); + let doc = crate::import(&out.bytes).unwrap().document; + let names: Vec<_> = doc.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert_eq!(names, ["Art", "Art (hidden)", "Off"]); + assert!(doc.layers[0].visible && !doc.layers[1].visible && !doc.layers[2].visible); + // The group keeps the shown square; the hidden one, still locked, is in the hidden layer. + let group = &children(&doc.layers[0])[0]; + assert_eq!(children(group).len(), 1); + let moved = children(&doc.layers[1]); + assert_eq!(moved.len(), 1); + assert!(moved[0].locked); + let b = moved[0].visual_bounds().unwrap(); + assert!((b.x0 - 50.0).abs() < 0.01, "{b:?}"); + // In a hidden layer, hidden objects stay where they are. + assert_eq!(children(&doc.layers[2]).len(), 1); +} + +#[test] +fn hidden_objects_can_be_left_out_or_shown() { + let out = write_with(&with_hidden(), LegacyAiOptions { hidden: HiddenObjects::Omit, ..LegacyAiOptions::default() }); + assert!(out.warnings.iter().any(|w| w == HIDDEN), "{:?}", out.warnings); + let doc = crate::import(&out.bytes).unwrap().document; + assert_eq!(doc.layers.len(), 2); + assert_eq!(children(&children(&doc.layers[0])[0]).len(), 1); + let out = write_with(&with_hidden(), LegacyAiOptions { hidden: HiddenObjects::Visible, ..LegacyAiOptions::default() }); + assert!(out.warnings.iter().any(|w| w == HIDDEN_SHOWN), "{:?}", out.warnings); + let doc = crate::import(&out.bytes).unwrap().document; + assert_eq!(doc.layers.len(), 2); + let pair = children(&children(&doc.layers[0])[0]); + assert_eq!(pair.len(), 2); + assert!(pair.iter().all(|n| n.visible)); +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 9b1c89a65..4466d0a12 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -289,6 +289,25 @@ impl FontFace { pub fn face_index(&self) -> u32 { self.index } + /// The face's PostScript name, as PostScript files name fonts (`SourceSans3-Bold`): name ID 6; + /// for a named instance of a variable font (whose name ID 6 is the default instance's), its + /// prefix and style (`SourceSans3-Semibold`). Without one, its family and style. + pub fn postscript_name(&self) -> String { + let squeeze = |s: &str| -> String { s.chars().filter(|c| c.is_ascii_graphic() && !"[](){}<>/%".contains(*c)).collect() }; + let own = self.skrifa().and_then(|f| localized(&f, StringId::POSTSCRIPT_NAME).into_iter().map(|(_, s)| s).next()).map(|s| squeeze(&s)); + match own.filter(|s| !s.is_empty()) { + Some(name) if self.variations.is_empty() => name, + own => { + let prefix = own.as_deref().and_then(|n| n.split('-').next()).map_or_else(|| squeeze(&self.family), str::to_string); + format!("{prefix}-{}", squeeze(&self.style)) + } + } + } + /// Are the face's outlines TrueType (`glyf`), not PostScript (`CFF `)? + pub fn is_truetype(&self) -> bool { + use skrifa::raw::TableProvider; + self.skrifa().is_some_and(|f| f.glyf().is_ok()) + } } /// One installed family found by the system font scan: its name and the file of each style. From 67e17c1b669bcdeb737b5d33fd70cb8661f1cfdb Mon Sep 17 00:00:00 2001 From: Dan Green Date: Mon, 12 Oct 2026 21:20:38 -0700 Subject: [PATCH 06/10] Part of #868: legacy .ai options Hidden Objects and Text, and docs The Legacy .ai Options dialog gains Hidden Objects (Hidden Layer, Omit, Make Visible) and Text (Editable, Outlines), translated in every catalog. Docs: type and hidden objects in the legacy .ai paragraphs, the restored revision rows and roadmap row. --- README.md | 2 +- crates/ui-egui/src/dialogs/save_options.rs | 37 ++++++++++++++++++++-- crates/ui-egui/src/i18n/cs.tsv | 5 +++ crates/ui-egui/src/i18n/de.tsv | 5 +++ crates/ui-egui/src/i18n/es.tsv | 5 +++ crates/ui-egui/src/i18n/fr.tsv | 5 +++ crates/ui-egui/src/i18n/hu.tsv | 5 +++ crates/ui-egui/src/i18n/it.tsv | 5 +++ crates/ui-egui/src/i18n/ja.tsv | 5 +++ crates/ui-egui/src/i18n/ko.tsv | 5 +++ crates/ui-egui/src/i18n/pl.tsv | 5 +++ crates/ui-egui/src/i18n/pt-br.tsv | 5 +++ crates/ui-egui/src/i18n/ru.tsv | 5 +++ crates/ui-egui/src/i18n/uk.tsv | 5 +++ crates/ui-egui/src/i18n/vi.tsv | 5 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 5 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 5 +++ docs/control-protocol.md | 3 +- docs/file-format-parity.md | 14 +++++--- docs/gaps.md | 14 ++++---- docs/mcp.md | 4 ++- docs/roadmap.md | 5 +-- docs/target-app-parity.md | 9 +++--- 23 files changed, 141 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 9d6e47626..6c7b50058 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the fu all of Illustrator): everyday vector illustration is close to usable: drawing and path tools, Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and -files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers and groups, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package). +files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package). Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs index 859c1cbf9..fdb05c770 100644 --- a/crates/ui-egui/src/dialogs/save_options.rs +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -9,8 +9,10 @@ //! artboard to a separate file, with `all` (true: every artboard) or `range`, Include Linked Files, //! Embed ICC Profiles, Create PDF-Compatible File and Use Compression. A `.ai` file's Version comes //! first: PDF-compatible, or legacy version 7 (`aiVersion: "7"`, a file per artboard), which takes -//! only Transparent Images and, with several artboards, a picker of the artboards to save -//! (thumbnails to check, field `boards`, saved as `range`). +//! only Transparent Images (`transparentImages`), Hidden Objects (`hiddenObjects`: a hidden layer, +//! omitted or made visible), Text (`outlineText`: editable or outlines) and, with several +//! artboards, a picker of the artboards to save (thumbnails to check, field `boards`, saved as +//! `range`). use std::sync::OnceLock; @@ -163,7 +165,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { /// The `.ai` options only the legacy version takes (and the only ones it takes, besides the /// version). -const LEGACY_ONLY: &[&str] = &["transparentImages"]; +const LEGACY_ONLY: &[&str] = &["transparentImages", "hiddenObjects", "outlineText"]; /// Is a `.ai` file to be saved in the legacy version 7 (`aiVersion: "7"`)? fn legacy_ai(d: &Dialog) -> bool { @@ -335,6 +337,30 @@ fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &Forma ui.end_row(); return; } + if o.name == "hiddenObjects" { + ui.label(egui::RichText::new(tl!("Hidden Objects:")).color(t.text_dim)).on_hover_text(o.description); + let choices = [tl!("Hidden Layer"), tl!("Omit"), tl!("Make Visible")]; + let at = match d.str("hiddenObjects").trim() { + "omit" => 1, + "visible" => 2, + _ => 0, + }; + if let Some(i) = widgets::dropdown(ui, ("save-option", o.name), choices.get(at).copied().unwrap_or_default(), &choices, 254.0) { + d.fields.insert(o.name.into(), json!(["layer", "omit", "visible"].get(i).copied().unwrap_or("layer"))); + } + ui.end_row(); + return; + } + if o.name == "outlineText" && legacy_ai(d) { + ui.label(egui::RichText::new(tl!("Text:")).color(t.text_dim)).on_hover_text(o.description); + let choices = [tl!("Editable"), tl!("Outlines")]; + let at = usize::from(value.as_bool().unwrap_or(false)); + if let Some(i) = widgets::dropdown(ui, ("save-option", o.name), choices.get(at).copied().unwrap_or_default(), &choices, 254.0) { + d.fields.insert(o.name.into(), json!(i == 1)); + } + ui.end_row(); + return; + } if o.ty == "boolean" { ui.label(""); let on = value.as_bool().unwrap_or(false); @@ -540,9 +566,13 @@ mod tests { assert_eq!(app.run("file.saveAs", json!({})).unwrap()["pending"], KIND); assert_eq!(dialog(&app).str("aiVersion"), "pdf", "PDF-compatible unless asked"); assert_eq!(dialog(&app).str("transparentImages"), "flatten"); + assert_eq!(dialog(&app).str("hiddenObjects"), "layer"); + assert!(!dialog(&app).bool("outlineText")); frame(&mut app, false); app.ui.dialog.as_mut().unwrap().fields.insert("aiVersion".into(), json!("7")); app.ui.dialog.as_mut().unwrap().fields.insert("transparentImages".into(), json!("omit")); + app.ui.dialog.as_mut().unwrap().fields.insert("hiddenObjects".into(), json!("visible")); + app.ui.dialog.as_mut().unwrap().fields.insert("outlineText".into(), json!(true)); frame(&mut app, false); frame(&mut app, true); assert!(app.ui.dialog.is_none()); @@ -552,6 +582,7 @@ mod tests { let st = app.session.active().unwrap(); assert_eq!((st.format, st.save_options["aiVersion"].clone()), ("ai", json!("7")), "Save remembers the version"); assert_eq!(st.save_options["transparentImages"], json!("omit")); + assert_eq!((st.save_options["hiddenObjects"].clone(), st.save_options["outlineText"].clone()), (json!("visible"), json!(true))); } /// An Illustrator file opened, then saved as `.ai`: the options dialog stays open through the diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index a2b2a541d..8d16b4fab 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3186,6 +3186,11 @@ theme Appearance Motiv A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Soubor .ai ve starší verzi obsahuje jedinou kreslicí plochu: každá zaškrtnutá kreslicí plocha se uloží jako samostatný soubor. {count} of {total} artboards {count} z {total} kreslicích ploch Check an artboard to save. Zaškrtněte kreslicí plochu, kterou chcete uložit. + Hidden Objects: Skryté objekty: + Hidden Layer Skrytá vrstva + Make Visible Zviditelnit + Text: Text: + Editable Upravitelný PDF-compatible Kompatibilní s PDF Legacy vector clipboard (no transparency) Starší vektorová schránka (bez průhlednosti) Line Color Barva čar diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b7d5a81b2..5ac139ea2 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1469,6 +1469,11 @@ A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Eine ältere .ai-Datei enthält eine Zeichenfläche: Jede aktivierte Zeichenfläche wird als eigene Datei gespeichert. {count} of {total} artboards {count} von {total} Zeichenflächen Check an artboard to save. Aktivieren Sie eine Zeichenfläche zum Speichern. + Hidden Objects: Ausgeblendete Objekte: + Hidden Layer Ausgeblendete Ebene + Make Visible Einblenden + Text: Text: + Editable Bearbeitbar PDF-compatible PDF-kompatibel Legacy vector clipboard (no transparency) Ältere Vektorzwischenablage (ohne Transparenz) Less Weniger diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c64b2f344..3d9d8c0c8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1485,6 +1485,11 @@ A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Un archivo .ai heredado contiene una mesa de trabajo: cada mesa de trabajo marcada se guarda como un archivo independiente. {count} of {total} artboards {count} de {total} mesas de trabajo Check an artboard to save. Marque una mesa de trabajo para guardarla. + Hidden Objects: Objetos ocultos: + Hidden Layer Capa oculta + Make Visible Hacer visibles + Text: Texto: + Editable Editable PDF-compatible Compatible con PDF Legacy vector clipboard (no transparency) Portapapeles vectorial heredado (sin transparencia) Less Menos diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index cc40e5330..a529e8d75 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1484,6 +1484,11 @@ A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Un fichier .ai hérité contient un seul plan de travail : chaque plan de travail coché est enregistré dans un fichier distinct. {count} of {total} artboards {count} plans de travail sur {total} Check an artboard to save. Cochez un plan de travail à enregistrer. + Hidden Objects: Objets masqués : + Hidden Layer Calque masqué + Make Visible Rendre visibles + Text: Texte : + Editable Modifiable PDF-compatible Compatible PDF Legacy vector clipboard (no transparency) Presse-papiers vectoriel hérité (sans transparence) Less Moins diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 014e0cf7b..90767644c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1888,6 +1888,11 @@ A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. A régi .ai fájl egy rajztáblát tartalmaz: minden bejelölt rajztábla külön fájlba kerül. {count} of {total} artboards {count} / {total} rajztábla Check an artboard to save. Jelöljön be egy mentendő rajztáblát. + Hidden Objects: Rejtett objektumok: + Hidden Layer Rejtett réteg + Make Visible Láthatóvá tétel + Text: Szöveg: + Editable Szerkeszthető PDF-compatible PDF-kompatibilis Legacy vector clipboard (no transparency) Régi vektoros vágólap (átlátszóság nélkül) Legal Legal diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7629befb1..82dbf0194 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1487,6 +1487,11 @@ A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Un file .ai tradizionale contiene una tavola da disegno: ogni tavola selezionata viene salvata come file separato. {count} of {total} artboards {count} di {total} tavole da disegno Check an artboard to save. Seleziona una tavola da disegno da salvare. + Hidden Objects: Oggetti nascosti: + Hidden Layer Livello nascosto + Make Visible Rendi visibili + Text: Testo: + Editable Modificabile PDF-compatible Compatibile PDF Legacy vector clipboard (no transparency) Appunti vettoriali tradizionali (senza trasparenza) Less Meno diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ba105523f..ffd8570f5 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1674,6 +1674,11 @@ A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. 従来の .ai ファイルにはアートボードが 1 つしか入りません。チェックした各アートボードは別々のファイルとして保存されます。 {count} of {total} artboards {total} 個中 {count} 個のアートボード Check an artboard to save. 保存するアートボードをチェックしてください。 + Hidden Objects: 非表示のオブジェクト: + Hidden Layer 非表示レイヤー + Make Visible 表示する + Text: テキスト: + Editable 編集可能 PDF-compatible PDF 互換 Legacy vector clipboard (no transparency) 従来のベクタークリップボード (透明なし) Less 少なく diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2695baefe..8f7f63a00 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1669,6 +1669,11 @@ A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. 레거시 .ai 파일에는 대지가 하나만 들어갑니다. 선택한 각 대지는 별도의 파일로 저장됩니다. {count} of {total} artboards 대지 {total}개 중 {count}개 Check an artboard to save. 저장할 대지를 선택하십시오. + Hidden Objects: 숨겨진 개체: + Hidden Layer 숨겨진 레이어 + Make Visible 표시 + Text: 텍스트: + Editable 편집 가능 PDF-compatible PDF 호환 Legacy vector clipboard (no transparency) 레거시 벡터 클립보드 (투명도 없음) Less 간단히 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 922a3900b..5761e8dcb 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4659,6 +4659,11 @@ mark-style Japanese Japońskie A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Starszy plik .ai zawiera jeden obszar kompozycji: każdy zaznaczony obszar kompozycji jest zapisywany jako osobny plik. {count} of {total} artboards {count} z {total} obszarów kompozycji Check an artboard to save. Zaznacz obszar kompozycji, aby zapisać. + Hidden Objects: Ukryte obiekty: + Hidden Layer Ukryta warstwa + Make Visible Uwidocznij + Text: Tekst: + Editable Edytowalny PDF-compatible Zgodny z PDF # Dimension tool and Object › Dimensions (dimension groups, their dialog and messages). Dimension Wymiar diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index df16b056e..4ea720d4b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1233,6 +1233,11 @@ A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Um arquivo .ai legado contém uma prancheta: cada prancheta marcada é salva como um arquivo separado. {count} of {total} artboards {count} de {total} pranchetas Check an artboard to save. Marque uma prancheta para salvar. + Hidden Objects: Objetos ocultos: + Hidden Layer Camada oculta + Make Visible Tornar visíveis + Text: Texto: + Editable Editável PDF-compatible Compatível com PDF Legacy vector clipboard (no transparency) Área de transferência vetorial legada (sem transparência) Less Menos diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index cc79c3664..c34ef3eac 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1485,6 +1485,11 @@ A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Устаревший файл .ai содержит одну монтажную область: каждая отмеченная монтажная область сохраняется в отдельный файл. {count} of {total} artboards {count} из {total} монтажных областей Check an artboard to save. Отметьте монтажную область для сохранения. + Hidden Objects: Скрытые объекты: + Hidden Layer Скрытый слой + Make Visible Сделать видимыми + Text: Текст: + Editable Редактируемый PDF-compatible Совместимый с PDF Legacy vector clipboard (no transparency) Устаревший векторный буфер обмена (без прозрачности) Less Меньше diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 03edb3694..3692b8b15 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1481,6 +1481,11 @@ A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Застарілий файл .ai містить одну монтажну область: кожна позначена монтажна область зберігається в окремий файл. {count} of {total} artboards {count} з {total} монтажних областей Check an artboard to save. Позначте монтажну область для збереження. + Hidden Objects: Приховані об’єкти: + Hidden Layer Прихований шар + Make Visible Зробити видимими + Text: Текст: + Editable Редагований PDF-compatible Сумісний із PDF Legacy vector clipboard (no transparency) Застарілий векторний буфер обміну (без прозорості) Less Менше diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 99ae5af8b..ca4e1df18 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1702,6 +1702,11 @@ A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Một tệp .ai cũ chỉ chứa một bảng vẽ: mỗi bảng vẽ bạn chọn được lưu thành một tệp riêng. {count} of {total} artboards {count} trên {total} bảng vẽ Check an artboard to save. Hãy chọn một bảng vẽ để lưu. + Hidden Objects: Đối tượng ẩn: + Hidden Layer Lớp ẩn + Make Visible Hiện ra + Text: Văn bản: + Editable Có thể chỉnh sửa PDF-compatible Tương thích PDF Legacy Macintosh RGB (Gamma 1.8) RGB Macintosh cũ (Gamma 1.8) Legacy vector clipboard Bảng tạm vector cũ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a73072b1b..9ba28c6e2 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1389,6 +1389,11 @@ A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. 旧版 .ai 文件只包含一个画板:勾选的每个画板都会存储为单独的文件。 {count} of {total} artboards {total} 个画板中的 {count} 个 Check an artboard to save. 请勾选要存储的画板。 + Hidden Objects: 隐藏的对象: + Hidden Layer 隐藏图层 + Make Visible 设为可见 + Text: 文本: + Editable 可编辑 PDF-compatible 与 PDF 兼容 Legacy vector clipboard (no transparency) 旧版矢量剪贴板 (无透明度) Less 较少 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 74d85b238..0e8468271 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1463,6 +1463,11 @@ A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. 舊版 .ai 檔案只包含一個工作區域:勾選的每個工作區域都會儲存為獨立的檔案。 {count} of {total} artboards {total} 個工作區域中的 {count} 個 Check an artboard to save. 請勾選要儲存的工作區域。 + Hidden Objects: 隱藏的物件: + Hidden Layer 隱藏圖層 + Make Visible 設為可見 + Text: 文字: + Editable 可編輯 PDF-compatible 與 PDF 相容 Legacy vector clipboard (no transparency) 舊版向量剪貼簿 (無透明度) Less 較少 diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 88d1bb9b8..391b802b3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -622,7 +622,8 @@ Native save options: File → Save As to a native or `.ai` file opens the `saveO artboard; false: `range`, such as "1-3, 5"), `includeLinked`, `embedProfiles`, `pdfCompatible`, `compress`, and for native files `version` and `preview`. A bad range keeps the dialog open; `ui.dialog.confirm` writes the file and the artboards' files. A `.ai` file's `aiVersion` comes first: `"pdf"` (the default) or `"7"` (legacy, heading "Legacy .ai -Options"), which takes only `transparentImages` (`"flatten"` or `"omit"`) and, with several artboards, the artboard +Options"), which takes only `transparentImages` (`"flatten"` or `"omit"`), `hiddenObjects` (`"layer"`, `"omit"` or +`"visible"`, the Hidden Objects menu), `outlineText` (the Text menu: false Editable, true Outlines) and, with several artboards, the artboard picker: `boards`, one bool per artboard (default: those last saved, else all), saved as `range`, one file per checked artboard. OK is disabled with none checked. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9e753a335..5cf073425 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-14 · **Change:** minor (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-14 · **Change:** minor (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 @@ -49,13 +49,18 @@ private editing data. VectorCraft: `{name}-{artboard}.ai` when there are several. It has no transparency: objects are written opaque, and images with see-through pixels, opacity or a blending mode are flattened (an opaque image of themselves and the art under them, clipped to where they show; that art stays) or omitted (Transparent Images, - `transparentImages: "flatten"|"omit"`). Type is written as outlines, sublayers as groups; appearances, effects - and brushes are expanded; object names are left out. + `transparentImages: "flatten"|"omit"`). Type is written as editable text objects (point type, area type and type + on a path, with their fonts' PostScript names, sizes, colours, alignment, indents, leading, tracking and + scaling), which the reading app sets in those fonts; characters outside Windows-1252, vertical type, inline + graphics and gradient or pattern fills make that type outlines, and Text: Outlines (`outlineText: true`) writes + all type as outlines. The format can hide only layers: hidden objects go to a hidden layer of their own above + their layer, are omitted, or are made visible (Hidden Objects, `hiddenObjects: "layer"|"omit"|"visible"`). + Sublayers become groups; appearances, effects and brushes are expanded; object names are left out. - **Known failures in users' files:** layer structure flattened or multiplied (#951: 109 layers instead of 14; #868: round trip with Illustrator 2018 loses layers and groups), guides and non-printing construction lines lost (#779), some files 4× slower to open since the editing data is read whole (#758), live effects, brushes and symbols come in as their drawn look (#637; pattern fills are read since #1025); legacy `.ai` - without the prolog opens since #1027, its legacy type (`To` … `TO`) not read yet. + without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868. - Estimate: **~65%** for real exchange with Illustrator users (open: ~75%; save: PDF-compatible, or legacy version 7 with layers and groups; not yet tested by users who hand files back). Closing the open-side issues: 10–20 h with users' files. @@ -110,6 +115,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | 2026-10-13 | minor | PDF.W1: PDF export and print composition use the shared guarded writer; drawing, colour policy and editable import stay app-owned. Metadata regression coverage and immutable dependency checks added; scores and hours unchanged. | | 2026-10-13 | minor | Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492); Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499); Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479); a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542) (the `.ai` row for #1528 was written by that PR); scores unchanged (estimated) | | 2026-10-13 | minor | `.ai` read as its PDF part: a page whose art reaches past its artboard to the left (notes beside a design) moves right in the row until that art clears the page before it, so it is no longer taken for that page's art; a multi-artboard `.ai` with such notes opened as its PDF part ("the art on them differs from the page's"), without the art outside its artboards, and now opens from its editing data | +| 2026-10-12 | minor | Legacy `.ai` of version 7 (Save As › `ai` › Version: Legacy (version 7), #868): written from the published specification, keeping layers, groups, compound paths, clipping groups, gradients and images, one artboard per file chosen in an artboard picker; type as editable text objects (point, area and on a path, fonts by PostScript name, Windows-1252), or outlines (Text: Outlines); hidden objects, which the format can't hide, in a hidden layer of their own, omitted or made visible (Hidden Objects); images with see-through pixels flattened with the art under them or omitted (Transparent Images). The `.ai` reader reads legacy type (`To` … `TO`) and `Ar`; scores unchanged (estimated) | | 2026-10-13 | minor | TIFF: grayscale files with an alpha channel or other extra channels place, relink and open, read as CMYK files with alpha already were (8 and 16 bits, either polarity; #1489, part of #1466); the rows for #1434 (strokes kept apart from a faded or masked fill, a `.ai`'s RGB numbers) and #1435 (SVG knockout groups without masks, `--decimals`) were written by those PRs; scores unchanged (estimated) | | 2026-10-12 | minor | TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged | | 2026-10-12 | minor | Affinity import: a text frame too short for its first line (a 64 pt title in a 54 pt frame) is made taller from its top, with a warning in the open result, instead of showing nothing (#1423), as `.ai` area type is (#1392); the spacing Affinity itself fits the line with isn't read; scores unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index 5dd7c777d..5dc0624bd 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates 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; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates 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; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make @@ -79,14 +79,15 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead of 14), guides and non-printing construction - lines (#779), open speed on large files (#758), legacy type in `.ai` files in PostScript form (those without - their prolog open since #1027), live effects, brushes and symbols as live objects (#637; pattern fills and global colours are read since #1025, placed (linked) files since #1264). Writing Illustrator's own editing data is out of scope + lines (#779), open speed on large files (#758), live effects, brushes and symbols as live objects (#637; pattern fills and global colours are read since #1025, placed (linked) files since #1264). Writing Illustrator's own editing data is out of scope (undocumented): a PDF-compatible `.ai` opens in Illustrator 2018 without its layers and groups (#868). Save As `.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification instead, keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and - images, one artboard per file (an artboard picker chooses which); transparency, type, live effects and object - names don't survive it (transparent images are flattened or omitted). It waits for testing by users who hand - files back. + images, and type as editable text objects (or outlines), one artboard per file (an artboard picker chooses + which); transparency, live effects and object names don't survive it (transparent images are flattened or + omitted, hidden objects go to a hidden layer, are omitted or made visible), and type is set again in the + reader's fonts. Legacy type in `.ai` files in PostScript form is read on open. It waits for testing by users + who hand files back. - **Evidence:** the issues above; 28 of the 350 issues are about `.ai`/EPS (about 22 people, the most common single format theme), 20 fixed, and the 8 open ones are about structure (layers, guides, speed, live objects), not missing art. No real-file `.ai` corpus (users' files may be used locally, never committed). @@ -302,6 +303,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | 2026-10-13 | minor | G1: Average and Join in one step with a corner or smooth choice is listed as missing (the ui-parity row said done; the command does not exist); Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534); Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539). G11: Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537); a round corner next to a curved side curves smoothly instead of sticking out of the shape and the Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512); Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). G9: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542); Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499); Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492); the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); Polish catalog (#1498); estimates unchanged, no gap entry closed | | 2026-10-13 | minor | Interaction: Direct Selection takes the selected object's anchor where anchors of overlapping objects coincide (#1508), sliders in dialogs show their rail (#1483), a template layer stays in Preview in Outline view (#1486), a tab stop in the Tabs panel drags from where the press landed (#1458); type: commands and MCP `add_text` report area type that does not fit its frame (#1471); 3D and Materials on outlined type and other shapes of many small curves (#1481), Twist 0° to 360° (#1524), twisted walls keep their faces (#1525); print-size images draw, export and crop (#1517); `vectorcraft-cli perf` reports load and memory on Windows (#1502; the idle-machine run is still missing); French catalog complete again (#1511); `graph.setData`'s CSV keeps each value exactly (#1485); estimates unchanged, no gap entry closed | | 2026-10-12 | minor | Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged | +| 2026-10-12 | minor | G9: Save As `.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification (#868): layers, groups, compound paths, clipping groups, type as text objects (or outlines), gradients and images, one artboard per file; hidden objects in a hidden layer, omitted or shown; transparent images flattened or omitted; legacy type read on open; it waits for users' testing, so the estimate is unchanged | | 2026-10-13 | minor | G1: Split Into Grid has its documented options (#1462), Transform Objects in the Transform dialogs and Transform Each and Reflect across an angle (#1448, #1460), the Pathfinder panel works on a group selected alone (#1450; one plain path alone is not settled), a focused field follows its value (#1457), selected text in value fields is readable (#1465 in part); G9: grayscale TIFFs with an alpha channel or extra channels place (#1466 in part), strokes kept apart from a faded or masked fill on PDF and `.ai` import (#1434); G4: Fit Headline in a frame one line tall (#1444), frames beyond a billion points hold no text (#1451); G6: commands refuse a parameter key they don't take (#1442); G10: a window surface past 8192 px no longer closes the app (#1459 in part); G15: edits and Alt-drags of thousands of objects (#1429 in part); G16: 128 more Czech strings (#1461); estimates unchanged | | 2026-10-12 | minor | TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged | | 2026-10-12 | minor | Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index f47ad1465..a137567e6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -597,7 +597,9 @@ document, takes the PDF options and keeps its path when reopened, so Save writes without giving the document a path. `options: {aiVersion: "7"}` writes legacy `.ai` files of version 7 instead (the PostScript form, which keeps layers, groups, compound paths and clipping groups in apps that read it), one per artboard of `range` (default all; `{name}-{artboard}.ai` when several, listed in the result's `files`), with -`transparentImages: "flatten"` (default) or `"omit"` for images with see-through pixels, opacity or a blending mode. +`transparentImages: "flatten"` (default) or `"omit"` for images with see-through pixels, opacity or a blending mode, +`hiddenObjects: "layer"` (default: a hidden layer of each layer's hidden objects), `"omit"` or `"visible"`, and +`outlineText: true` to write type as outlines instead of text objects. Drive a tool like a mouse: diff --git a/docs/roadmap.md b/docs/roadmap.md index e095dfb16..653713675 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (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: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; 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; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates 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; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, 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; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (legacy `.ai` of version 7 saved, #868; estimates 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: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; 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; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates 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; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, 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; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and [target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5 @@ -23,7 +23,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | Set type: point and area type, Character and Paragraph styles, threading, outlines | yes | type core 80%; styles, threading, Create Outlines and font matching tested ([type-parity.md](type-parity.md)); hyphenation and justification options missing (not blocking) | 0 | | Organise layers and artboards and export assets (Export for Screens, SVG, PNG, PDF per artboard) | yes | Layers panel rework, Artboards panel and tool, Export for Screens and Asset Export done; ghost borders in some Asset Export output (#983) | 0 | | Print production: CMYK and spot colour, bleed and marks, PDF/X out, separations preview | yes | PDF/X-1a/3/4, presets, marks and bleed, ICC output intent, Separations Preview, Print with PostScript output | 0 | -| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951) and lose guides (#779); Save As `.ai` is PDF-compatible, which Illustrator opens without its own editing data (#868). This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | +| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951) and lose guides (#779); Save As `.ai` is PDF-compatible, which Illustrator opens without its own editing data (#868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | ## Current focus @@ -99,6 +99,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14 | 2026-10-13 | minor | The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498); Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492); Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499); Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479); a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542); Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537); a round corner next to a curved side curves smoothly instead of sticking out of the shape and the Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512); Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534); Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539); Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536); the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); estimates unchanged | | 2026-10-13 | minor | Direct Selection takes the selected object's anchor where anchors of overlapping objects coincide (#1508); commands and MCP `add_text` report area type that does not fit its frame (#1471), a tab stop in the Tabs panel drags from where the press landed (#1458); M9: 3D and Materials on outlined type and other shapes of many small curves (#1481), Twist 0° to 360° (#1524), twisted walls keep their faces (#1525); print-size images draw, export and crop (#1517), `vectorcraft-cli perf` reports load and memory on Windows (#1502); sliders in dialogs show their rail (#1483), a template layer stays in Preview in Outline view (#1486), French catalog complete again (#1511), a Hungarian message in the informal address (#1474); `graph.setData`'s CSV keeps each value exactly (#1485); estimates unchanged | | 2026-10-12 | minor | Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged | +| 2026-10-12 | minor | Exchange with Illustrator users: Save As `.ai` › Version: Legacy (version 7) keeps layers, groups and type as text, one artboard per file (#868); estimate unchanged until users test it | | 2026-10-13 | minor | M6: Split Into Grid has its documented options (#1462), the Pathfinder panel works on a group selected alone (#1450); M2: Transform Objects in the Transform dialogs and Transform Each, Reflect across an angle (#1448, #1460); M4: grayscale TIFFs with an alpha channel or extra channels place (#1466 in part), strokes kept apart from a faded or masked fill on PDF and `.ai` import (#1434), SVG knockout groups without masks (#1435); M7: Fit Headline in a frame one line tall (#1444), frames beyond a billion points hold no text (#1451); M13: commands refuse a parameter key they don't take (#1442); M5: edits and Alt-drags of thousands of objects (#1429 in part); M14: a focused field follows its value (#1457), selected text in value fields is readable (#1465 in part), a window surface past 8192 px no longer closes the app (#1459 in part); estimates unchanged | | 2026-10-12 | minor | TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged | | 2026-10-12 | minor | Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e5b6b1267..8533a288d 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-14 · **Change:** minor (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 29/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 29/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-14 · **Change:** minor (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 29/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 29/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), @@ -158,8 +158,8 @@ The discounts, with the evidence: ones are about structure, not missing art: layers flattened or multiplied (#951: 109 layers instead of 14; #868), guides lost (#779), slower opening (#758), live effects, brushes and symbols as their drawn look (#637). VectorCraft can't write Illustrator's editing data (undocumented), so its PDF-compatible `.ai` opens - in Illustrator as plain art; the legacy `.ai` of version 7 it also writes keeps layers and groups (one artboard - per file, no transparency or live type) but hasn't been tested by users yet (#868). That blocks beta (the main-format rule) for people who hand files back to Illustrator users, + in Illustrator as plain art; the legacy `.ai` of version 7 it also writes keeps layers, groups and type as text (one + artboard per file, no transparency) but hasn't been tested by users yet (#868). That blocks beta (the main-format rule) for people who hand files back to Illustrator users, but it doesn't stop someone opening and reworking their own archive. - **Interaction fidelity (×0.9).** Pen, Direct Selection, smart guides and snapping are deep ([ui-parity.md](ui-parity.md)), and there has never been a side-by-side session. The issue tracker says the @@ -366,7 +366,7 @@ since 2026-10-01. | **Large missing subsystems** | 0–20% | 3D & Materials (~25%, initial 3D types without materials), Photoshop-style raster effects and the Effect Gallery (~19 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), scripting | | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | -| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers and groups), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | +| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers, groups and type as text), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | | **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | @@ -607,6 +607,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur | 2026-10-13 | minor | Tools and UI: Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537); a round corner next to a curved side curves smoothly instead of sticking out of the shape and the Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512); Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534); Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539). Correction: the combined Average and Join with its corner or smooth choice was listed as done in [ui-parity.md](ui-parity.md) and does not exist in the app (`path.join` and `path.average` are separate commands, and Join has no such choice); it is now listed as missing there and in [gap 1](gaps.md); scores and hours unchanged (estimated) | | 2026-10-13 | minor | UI and tools: sliders in Save for Web, the raster export options, Preferences and the opacity popup show their rail, filled up to the handle (#1506, fixes #1483); in Outline view a template layer stays in Preview, dimmed (#1505, fixes #1486); Direct Selection takes the selected object's anchor where anchors of overlapping objects coincide (#1509, fixes #1508); a tab stop in the Tabs panel drags from where the press landed (#1487, fixes #1458); type: commands and MCP `add_text` report area type that does not fit its frame (#1500, fixes #1471); graphs: `graph.setData`'s CSV keeps each value exactly (#1485); scores and hours unchanged (estimated) | | 2026-10-13 | minor | 3D and Materials: outlined words and other shapes of many small curves take Rotate, Extrude & Bevel and Inflate (#1481), Twist runs 0° to 360° (#1524) and twisted walls keep their faces (#1525), all described in [three-d.md](three-d.md); rendering: print-size images draw, export and crop (#1517); `vectorcraft-cli perf` on Windows reports load and memory (#1502; no budget re-run); localization: French catalog complete again with 423 strings added and 188 reworded (#1511), a Hungarian message in the informal address (#1474); scores and hours unchanged (estimated) | +| 2026-10-12 | minor | File interop: Save As `.ai` also writes the legacy version 7 form (#868), keeping layers, groups, compound paths, clipping groups and type as text objects, one artboard per file, with Transparent Images, Hidden Objects and Text options; `.ai` legacy type is read on open; not yet tested by users who hand files back, so scores and hours are unchanged (estimated) | | 2026-10-13 | minor | Image Trace, measured in `image-trace-quality.md`: the palette is made of the picture's own colours (#1504): 16 Colors on flat art 70 → 4.2 paths and 5.1 → 0.13 px off, paths in a colour the art lacks 5.9 → 0; photos are smoothed within their tones first and stacked layers go by lightness (#1516): High Fidelity Photo 3,653 → 121 paths with its colour error 2.30 → 1.75, 16 megapixels traced where 4 were refused. Score and hours unchanged (estimated) | | 2026-10-13 | minor | 3D and Materials: Inflate takes Twist, Taper and Cap ([three-d.md](three-d.md)); scores unchanged (3D ~25%) | | 2026-10-12 | minor | Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged | From 37cebc47ee29d864855c839ac4e5378b89de230d Mon Sep 17 00:00:00 2001 From: Dan Green Date: Mon, 12 Oct 2026 22:15:40 -0700 Subject: [PATCH 07/10] Part of #868: vectorcraft-cli convert writes legacy .ai (--ai-version 7, --hidden-objects, --transparent-images) --- apps/vectorcraft-cli/src/main.rs | 18 ++++++++-- apps/vectorcraft-cli/tests/fileio.rs | 54 ++++++++++++++++++++++++++++ docs/file-format-parity.md | 2 +- docs/mcp.md | 4 ++- 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 63df4511f..b54059f60 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -4,7 +4,7 @@ //! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR] [--automation-write-root DIR] //! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2] //! vectorcraft-cli commands -//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] +//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] //! vectorcraft-cli info FILE //! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5] //! vectorcraft-cli perf [--paths 50000] [--deep] @@ -84,7 +84,7 @@ USAGE: vectorcraft-cli commands Print the command catalogue as JSON. - vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] + vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] Open IN (any readable format) and export OUT in the format its extension picks (see Writable formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard unless one of them is given, EPS the bounds of the art, the other formats the first artboard. @@ -95,6 +95,11 @@ USAGE: --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 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 + --hidden-objects (default layer: a hidden layer above each layer's hidden objects; omit; + visible), --transparent-images (default flatten, with the art under them; omit) and + --outline-text (type as outlines instead of text). vectorcraft-cli info FILE Print a JSON summary: the import warnings (what didn't come in as it was, such as an EPS @@ -223,6 +228,9 @@ fn convert(args: &[String]) -> Result<(), String> { let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy, mut editing_data) = (1.0f64, None::, None::, false, false, false); let mut decimals = None::; + // Legacy `.ai` options, passed on as the export's own (`aiVersion`, `hiddenObjects`, + // `transparentImages`); the engine checks their values. + let mut legacy: Vec<(&str, String)> = vec![]; let mut it = args.iter(); while let Some(a) = it.next() { match a.as_str() { @@ -233,6 +241,9 @@ fn convert(args: &[String]) -> Result<(), String> { "--decimals" => decimals = Some(it.next().and_then(|v| v.parse::().ok()).ok_or("--decimals needs a number")?), "--replace-lossy" => replace_lossy = true, "--editing-data" => editing_data = true, + "--ai-version" => legacy.push(("aiVersion", it.next().cloned().ok_or("--ai-version needs 7 or pdf")?)), + "--hidden-objects" => legacy.push(("hiddenObjects", it.next().cloned().ok_or("--hidden-objects needs layer, omit or visible")?)), + "--transparent-images" => legacy.push(("transparentImages", it.next().cloned().ok_or("--transparent-images needs flatten or omit")?)), f => files.push(f.to_string()), } } @@ -250,6 +261,9 @@ fn convert(args: &[String]) -> Result<(), String> { if let Some(decimals) = decimals { params["decimals"] = json!(decimals); } + for (key, value) in legacy { + params[key] = json!(value); + } let mut r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?; // What reading the input left out (a missing linked file, an unsupported filter…) matters as // much as what writing the output did. diff --git a/apps/vectorcraft-cli/tests/fileio.rs b/apps/vectorcraft-cli/tests/fileio.rs index 7c40493e2..1619048e9 100644 --- a/apps/vectorcraft-cli/tests/fileio.rs +++ b/apps/vectorcraft-cli/tests/fileio.rs @@ -115,3 +115,57 @@ fn convert_reports_import_warnings() { let w = out["importWarnings"].as_array().unwrap_or_else(|| panic!("{out}")); assert!(w.iter().any(|w| w.as_str().unwrap().contains("nowhere-to-be-found.png")), "{out}"); } + +#[test] +fn convert_writes_legacy_ai_with_its_options() { + // Two artboards, a shown rectangle and a hidden one. + let src = tmp("legacy.vectorcraft"); + ok(&[ + "run", + "--cmd", + "file.new", + "--params", + r#"{"width":120,"height":80,"artboards":2}"#, + "--cmd", + "shape.rectangle", + "--params", + r#"{"x":10,"y":10,"width":20,"height":10}"#, + "--cmd", + "shape.rectangle", + "--params", + r#"{"x":40,"y":10,"width":20,"height":10}"#, + "--cmd", + "object.hide", + "--export", + &src, + ]); + // One file per artboard, named after it; hidden objects in a hidden layer by default. + let out = tmp("legacy.ai"); + let r: Value = serde_json::from_str(&ok(&["convert", &src, &out, "--ai-version", "7"])).unwrap(); + let files: Vec<&str> = r["files"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert_eq!(files.len(), 2, "{r}"); + assert!(files.iter().all(|f| std::fs::read(f).unwrap().starts_with(b"%!PS-Adobe-3.0")), "{r}"); + assert!(r["warnings"].to_string().contains("hidden layer"), "{r}"); + // One artboard is the file itself; the hidden rectangle left out. + let one = tmp("legacy-one.ai"); + let r: Value = serde_json::from_str(&ok(&[ + "convert", + &src, + &one, + "--ai-version", + "7", + "--range", + "1", + "--hidden-objects", + "omit", + "--transparent-images", + "omit", + ])) + .unwrap(); + assert!(r["warnings"].to_string().contains("hidden objects are left out"), "{r}"); + assert!(std::fs::read(&one).unwrap().starts_with(b"%!PS-Adobe-3.0")); + // A value the engine doesn't take is an error. + let bad = Command::new(BIN).args(["convert", &src, &tmp("bad.ai"), "--ai-version", "7", "--hidden-objects", "behind"]).output().unwrap(); + assert!(!bad.status.success()); + assert!(String::from_utf8_lossy(&bad.stderr).contains("hiddenObjects"), "{}", String::from_utf8_lossy(&bad.stderr)); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5cf073425..77c988ce3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -115,7 +115,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | 2026-10-13 | minor | PDF.W1: PDF export and print composition use the shared guarded writer; drawing, colour policy and editable import stay app-owned. Metadata regression coverage and immutable dependency checks added; scores and hours unchanged. | | 2026-10-13 | minor | Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492); Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499); Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479); a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542) (the `.ai` row for #1528 was written by that PR); scores unchanged (estimated) | | 2026-10-13 | minor | `.ai` read as its PDF part: a page whose art reaches past its artboard to the left (notes beside a design) moves right in the row until that art clears the page before it, so it is no longer taken for that page's art; a multi-artboard `.ai` with such notes opened as its PDF part ("the art on them differs from the page's"), without the art outside its artboards, and now opens from its editing data | -| 2026-10-12 | minor | Legacy `.ai` of version 7 (Save As › `ai` › Version: Legacy (version 7), #868): written from the published specification, keeping layers, groups, compound paths, clipping groups, gradients and images, one artboard per file chosen in an artboard picker; type as editable text objects (point, area and on a path, fonts by PostScript name, Windows-1252), or outlines (Text: Outlines); hidden objects, which the format can't hide, in a hidden layer of their own, omitted or made visible (Hidden Objects); images with see-through pixels flattened with the art under them or omitted (Transparent Images). The `.ai` reader reads legacy type (`To` … `TO`) and `Ar`; scores unchanged (estimated) | +| 2026-10-12 | minor | Legacy `.ai` of version 7 (Save As › `ai` › Version: Legacy (version 7), #868): written from the published specification, keeping layers, groups, compound paths, clipping groups, gradients and images, one artboard per file chosen in an artboard picker; type as editable text objects (point, area and on a path, fonts by PostScript name, Windows-1252), or outlines (Text: Outlines); hidden objects, which the format can't hide, in a hidden layer of their own, omitted or made visible (Hidden Objects); images with see-through pixels flattened with the art under them or omitted (Transparent Images). The `.ai` reader reads legacy type (`To` … `TO`) and `Ar`. `vectorcraft-cli convert` takes `--ai-version 7`, `--hidden-objects` and `--transparent-images`; scores unchanged (estimated) | | 2026-10-13 | minor | TIFF: grayscale files with an alpha channel or other extra channels place, relink and open, read as CMYK files with alpha already were (8 and 16 bits, either polarity; #1489, part of #1466); the rows for #1434 (strokes kept apart from a faded or masked fill, a `.ai`'s RGB numbers) and #1435 (SVG knockout groups without masks, `--decimals`) were written by those PRs; scores unchanged (estimated) | | 2026-10-12 | minor | TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged | | 2026-10-12 | minor | Affinity import: a text frame too short for its first line (a 64 pt title in a 54 pt frame) is made taller from its top, with a warning in the open result, instead of showing nothing (#1423), as `.ai` area type is (#1392); the spacing Affinity itself fits the line with isn't read; scores unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index a137567e6..102d8d2e1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -599,7 +599,9 @@ PostScript form, which keeps layers, groups, compound paths and clipping groups of `range` (default all; `{name}-{artboard}.ai` when several, listed in the result's `files`), with `transparentImages: "flatten"` (default) or `"omit"` for images with see-through pixels, opacity or a blending mode, `hiddenObjects: "layer"` (default: a hidden layer of each layer's hidden objects), `"omit"` or `"visible"`, and -`outlineText: true` to write type as outlines instead of text objects. +`outlineText: true` to write type as outlines instead of text objects. From the CLI: `vectorcraft-cli convert in.svg +out.ai --ai-version 7 [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] [--outline-text] +[--range R]`. Drive a tool like a mouse: From 06221726fbdae0be39d425add74e50b31e49e9b1 Mon Sep 17 00:00:00 2001 From: Dan Green Date: Tue, 13 Oct 2026 18:25:07 -0700 Subject: [PATCH 08/10] Part of #868: legacy .ai defines annotatepage in a prolog of its own The page trailer the version 7 specification gives (section 3, Script Setup) is `gsave annotatepage grestore showpage`, and annotatepage is a procedure of the app's own prolog, which the file didn't have. The file now carries a procedure set of the writer's own (VectorCraft_Legacy 1 0, listed in %%DocumentSuppliedResources) in a dictionary the setup begins and the trailer ends, which defines annotatepage as marking nothing. --- crates/eps/src/legacy_ai.rs | 29 +++++++++++++++++++++++++---- crates/eps/src/tests_legacy_ai.rs | 8 ++++++-- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 6904fb0d5..4b774c2f7 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -51,8 +51,10 @@ //! Windows-1252, vertical type, inline graphics, gradient or pattern fills) is written as its //! outlines, and all type with [`LegacyAiOptions::outline_text`]. //! -//! No prolog is written: the file names none, so PostScript printers can't print it, as the `.ai` -//! files other apps write in this format. +//! The prolog defines the one procedure the script calls that isn't an operator of the format, +//! the page trailer's `annotatepage` ([`PROLOG`]); the format's operators are left to the app that +//! reads the file, so PostScript printers can't print it, as the `.ai` files other apps write in +//! this format. use std::fmt::Write; use std::sync::Arc; @@ -196,7 +198,11 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result Result