From cee73d03a378ae37850bfb973256e68d60bdabb9 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 04:19:04 +0700 Subject: [PATCH] #1025: .ai editing data keeps sublayers with global colours and pattern fills The editing-data reader stopped on `Xk`/`XK` (global process colours) and on `p`/`P` (pattern fills), so the file fell back to its PDF part, which has only the top-level layers. - `Xk`/`XK` read like `Xx`: a global swatch (not a spot ink), linked at its tint. - `%AI3_BeginPattern` sections are read by a reader of their own into pattern swatches (tile from `(name) llx lly urx ury`, art in pattern space); `p`/`P` fill or stroke with them, placed by the operator's matrix. A pattern it can't read whole still makes a fill with it unreadable, as before. - Tests on synthetic editing data; fuzz tokens and fixture extended. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/tests/import_fuzz.rs | 18 ++- crates/eps/src/import/ai/lex.rs | 10 ++ crates/eps/src/import/ai/paint.rs | 25 +++- crates/eps/src/import/ai/read.rs | 167 +++++++++++++++++++++++--- crates/eps/src/import/ai/tests.rs | 86 +++++++++++++ crates/eps/src/import/tests_native.rs | 27 +++++ docs/file-format-parity.md | 7 +- docs/gaps.md | 5 +- 9 files changed, 321 insertions(+), 26 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 28a47705..04c2c975 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| -| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). | +| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). | | 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) | | 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day | | 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections | diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index bbab8fa3..0a6407b2 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1702,6 +1702,9 @@ fn ascii85(data: &[u8]) -> String { out } +/// A pattern's definition in editing data: its name and tile, its art on `%_` lines, its end. +const PATTERN: &str = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 6 6\n%_0 A\n%_0 Ae\n%_u\n%_0 0 m\n%_0 6 L\n%_6 6 L\n%_6 0 L\n%_n\n%_0 0 0 1 K\n%_1 J 1 w\n%_3 3 m\n%_3 3 L\n%_S\n%_U\n%_9 () XW\nE\n%AI3_EndPattern\n"; + /// The editing data of a file with every sort of object the layers reader knows, and some it doesn't. fn editing_text() -> String { let square = |x: u32| format!("0 0 1 0 k\n{x} 10 m\n{} 10 L\n{} 14 L\n{x} 14 L\nf", x + 4, x + 4); @@ -1724,13 +1727,16 @@ fn editing_text() -> String { "0 Xw".into(), "1 0 0 0 1 0 Bg".into(), "0 1 w 2 J 0 j 4 M [3 2]0 d 1 D".into(), + "0 0 0 1 (Black global) 0.4 0 Xk".into(), + "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p".into(), + square(60), ] .join("\n"); let layer = |name: &str, visible: u8, body: &str| { format!("%AI5_BeginLayer\n{visible} 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{body}\nLB\n%AI5_EndLayer--\n") }; format!( - "%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{}{}%%Trailer\n", + "%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{PATTERN}{}{}%%Trailer\n", layer("One", 1, &format!("{body}\n{}", layer("Sub", 1, &square(70)))), layer("Two", 0, &square(80)) ) @@ -1980,6 +1986,16 @@ fn arb_ai_token() -> impl Strategy { "/Binary : /ASCII85Decode ,", "~>", "p", + "P", + "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p", + "(Dots) 0 0 1 1 0 0 0 0 0 [0 0 0 0 1e308 0] P", + "Xk", + "XK", + "0 0 0 1 (Global) 0.4 0 Xk", + "1 0.5 0 (Global) 0 1 XK", + "E", + "\n%AI3_BeginPattern: (Dots)\n(Dots) 0 0 6 6\n", + "\n%AI3_EndPattern\n", "To", "frobnicate", "\n%AI5_BeginLayer\n", diff --git a/crates/eps/src/import/ai/lex.rs b/crates/eps/src/import/ai/lex.rs index 25f757b7..b30481b5 100644 --- a/crates/eps/src/import/ai/lex.rs +++ b/crates/eps/src/import/ai/lex.rs @@ -81,6 +81,16 @@ impl<'a> Lexer<'a> { rest.get(..n).unwrap_or_default() } + /// Where the next token is looked for. + pub fn offset(&self) -> usize { + self.pos + } + + /// The data from `from` to `to` (empty where that isn't in it). + pub fn slice(&self, from: usize, to: usize) -> &'a [u8] { + self.src.get(from..to).unwrap_or_default() + } + /// Skip past the next `needle` (the end of data that isn't tokens) → what was skipped. pub fn skip_past(&mut self, needle: &[u8]) -> &'a [u8] { let rest = self.rest(); diff --git a/crates/eps/src/import/ai/paint.rs b/crates/eps/src/import/ai/paint.rs index 2358b65f..af6f86e2 100644 --- a/crates/eps/src/import/ai/paint.rs +++ b/crates/eps/src/import/ai/paint.rs @@ -3,7 +3,9 @@ //! - Colours: `g`/`G` grey (1 is white), `k`/`K` CMYK, `x`/`X` a named CMYK colour at a tint //! (`c m y k (name) tint`; tint 0 is the full colour), `Xa`/`XA` RGB (`c m y k r g b`, the CMYK //! values its equivalent), `Xx`/`XX` a named colour of either model -//! (`c m y k [r g b] (name) tint type`). Lower case paints fills, upper case strokes. +//! (`c m y k [r g b] (name) tint type`), `Xk`/`XK` a global process colour (its operands as +//! `Xx`'s: a global swatch that isn't an ink of its own). Lower case paints fills, upper case +//! strokes. //! - Gradient definitions: `(name) type stops Bd`, its stops as `colour style [opacity 6] //! midpoint ramp Bs` (style 0 grey, 1 CMYK, 2 RGB with its CMYK, 3 and 4 named colours), `BD`. //! - A gradient on an object: `[flag] Bb` (1: the stroke), `flag (name) x y angle length a b c d @@ -28,6 +30,8 @@ pub(super) struct Named { /// The full colour. pub color: Color, pub tint: f32, + /// An ink of its own (a spot colour), not a global process colour. + pub spot: bool, } /// The paint of a colour operator's operands (`op`: its name, lower case or not), with the named @@ -48,6 +52,7 @@ pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option)> { } "x" => named(vals, Some(false)), "xx" => named(vals, None), + "xk" => named(vals, None).map(|(p, n)| (p, n.map(|n| Named { spot: false, ..n }))), _ => None, } } @@ -72,7 +77,7 @@ fn named(vals: &[V], rgb: Option) -> Option<(Paint, Option)> { Color::cmyk(unit(*c), unit(*m), unit(*y), unit(*k)) }; let paint = Paint::Solid { color: color.tinted(tint), swatch: Some(name.clone()), tint }; - Some((paint, Some(Named { name, color, tint }))) + Some((paint, Some(Named { name, color, tint, spot: true }))) } /// The colour of a gradient stop's colour values and style. @@ -162,7 +167,7 @@ pub(super) struct Instance { hilight: Vec2, } -fn affine(n: &[f64]) -> Option { +pub(super) fn affine(n: &[f64]) -> Option { let [a, b, c, d, e, f] = n else { return None }; let m = Affine::new([*a, *b, *c, *d, *e, *f]); (m.as_coeffs().iter().all(|v| v.is_finite())).then_some(m) @@ -247,6 +252,20 @@ mod tests { assert!((named.tint - 0.6).abs() < 1e-6); assert!(matches!(p, Paint::Solid { swatch: Some(_), .. })); assert!(color_op("k", &nums(&[1.0])).is_none()); + // A global process colour: CMYK (type 0) or RGB (type 1), not an ink. + let mut v = nums(&[0.0, 0.0, 0.0, 1.0]); + v.extend([V::Str(b"K100".to_vec()), V::Num(0.4), V::Num(0.0)]); + let (p, named) = color_op("XK", &v).unwrap(); + let named = named.unwrap(); + assert_eq!((named.name.as_str(), named.color, named.spot), ("K100", Color::cmyk(0.0, 0.0, 0.0, 1.0), false)); + assert!((named.tint - 0.6).abs() < 1e-6); + assert!( + matches!(p, Paint::Solid { swatch: Some(s), tint, color: Color::Cmyk { k, .. } } if s == "K100" && (tint - 0.6).abs() < 1e-6 && (k - 0.6).abs() < 1e-6) + ); + let mut v = nums(&[0.0, 0.0, 0.0, 0.0, 1.0, 0.5, 0.0]); + v.extend([V::Str(b"Orange".to_vec()), V::Num(0.0), V::Num(1.0)]); + let (p, _) = color_op("Xk", &v).unwrap(); + assert_eq!(p.color(), Some(Color::rgb(1.0, 0.5, 0.0))); } #[test] diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index b018f68a..cb45d9ac 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -15,10 +15,15 @@ //! - Art with an appearance the format can't write as plain art (several fills or strokes, //! effects, brushes) comes first as that look, drawn (a group), then the object itself on `%_` //! lines and `1 (style) XW`: the drawn look is kept, named after the object. +//! - Patterns: `%AI3_BeginPattern: (name)`, `(name) llx lly urx ury` (the tile, in the pattern's +//! space, y up), the tile's art (on `%_` lines in newer files), `E`, `%AI3_EndPattern`; each is +//! read by a reader of its own and becomes a pattern swatch. A fill or stroke with one: +//! `(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). //! -//! Sections the reader doesn't need (patterns, brushes, symbols, styles, swatches, filters) are -//! skipped; type, symbols, pattern fills, placed files, legacy type and operators it doesn't know -//! are errors. +//! 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, legacy type and operators it +//! doesn't know are errors. use std::collections::{BTreeSet, HashMap, HashSet}; use std::rc::Rc; @@ -30,7 +35,7 @@ use vectorcraft_color::swatch::Swatch; use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::{ Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, Knockout, LayerColor, LineCap, LineJoin, - Node, NodeId, NodeKind, StrokeLayer, + Node, NodeId, NodeKind, PatternDef, StrokeLayer, }; use vectorcraft_geom::{FillRule, PathData}; @@ -51,6 +56,10 @@ const MAX_POINTS: usize = 4_000_000; const MAX_PIXELS: u64 = 1 << 25; /// 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). +const FLIP: Affine = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); +/// Most patterns read (others can't fill art). +const MAX_PATTERNS: usize = 4096; /// What the editing data holds: the document of its art, and where its art space is. #[derive(Debug)] @@ -197,6 +206,16 @@ struct Frame { clips: Vec, } +/// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands +/// before its first operator. +#[derive(Debug, Default)] +struct PatternHead { + taken: bool, + name: String, + /// The tile in the pattern's space (y up). + tile: Option, +} + /// An image's operands, from `XN` and before `XI`. #[derive(Debug, Default)] struct Raster { @@ -260,6 +279,10 @@ struct Reader<'a> { /// The artboard's size and centre in files without artboards or crop marks. art_size: Option<(f64, f64)>, template_center: Option<(f64, f64)>, + /// This reader reads a pattern's definition (see [`Reader::define_pattern`]). + pattern: Option, + /// The tile's top left (y down) of each pattern read, by name. + pattern_tiles: HashMap, } /// Operators that change nothing the reader keeps. @@ -377,6 +400,8 @@ impl<'a> Reader<'a> { slot_names: HashMap::new(), art_size: None, template_center: None, + pattern: None, + pattern_tiles: HashMap::new(), } } @@ -429,6 +454,12 @@ impl<'a> Reader<'a> { let line = String::from_utf8_lossy(c); let line = line.trim_end(); let m = marker(line); + // A pattern's definition (one inside another's isn't read). + if m == "BeginPattern" && self.pattern.is_none() { + let section = self.section("EndPattern")?; + self.define_pattern(section); + return Ok(()); + } if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| *b == m) { return self.skip_to(end); } @@ -481,10 +512,18 @@ impl<'a> Reader<'a> { /// Skip to the comment with marker `end` (sections of the same kind nested in it count, and /// versioned content in an alternate). fn skip_to(&mut self, end: &str) -> Result<(), String> { + self.section(end).map(|_| ()) + } + + /// [`Self::skip_to`] → what was skipped, up to the comment that ends it. + fn section(&mut self, end: &str) -> Result<&'a [u8], String> { let begin = SKIPPED.iter().find(|(_, e)| *e == end).map_or("", |(b, _)| *b); let begin = if end == "End_Versioned_Content" { "Begin_Content_if_version_gt" } else { begin }; + let start = self.lex.offset(); let mut depth = 0usize; - while let Some(t) = self.lex.next_token() { + loop { + let before = self.lex.offset(); + let Some(t) = self.lex.next_token() else { break }; if let Tok::Comment(c) = t.tok { let line = String::from_utf8_lossy(c); let m = marker(&line); @@ -492,7 +531,7 @@ impl<'a> Reader<'a> { depth += 1; } else if m == end { if depth == 0 { - return Ok(()); + return Ok(self.lex.slice(start, before)); } depth -= 1; } @@ -501,6 +540,58 @@ impl<'a> Reader<'a> { Err(format!("its section ending with `%{end}` doesn't end")) } + /// A pattern's definition (`section`: what follows `%AI3_BeginPattern`), read by a reader of its + /// own into a pattern swatch, its art in the pattern's space (y down). A definition it can't + /// read whole (an operator it doesn't know, type, unbalanced groups, no tile) isn't kept: art + /// filled with it can't be read. + fn define_pattern(&mut self, section: &[u8]) { + if self.pattern_tiles.len() >= MAX_PATTERNS { + return; + } + let mut sub = Reader::new(section); + sub.pattern = Some(PatternHead::default()); + // The gradients defined so far, lent (and those it defines kept). + sub.gradients = std::mem::take(&mut self.gradients); + sub.nodes = self.nodes; + let read = sub.open(Kind::Group, false).and_then(|()| sub.run()); + self.nodes = sub.nodes; + self.gradients = std::mem::take(&mut sub.gradients); + let whole = read.is_ok() && sub.unsupported.is_empty() && sub.unknown.is_empty() && sub.frames.len() == 1; + let (Some(head), Some(frame)) = (sub.pattern.take(), sub.frames.pop()) else { return }; + let Some(tile) = head.tile.filter(|t| whole && sane(*t) && t.width() > 0.0 && t.height() > 0.0 && !frame.children.is_empty()) else { + return; + }; + let art: Vec> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect(); + let mut def = PatternDef::new(&head.name, art); + def.tile = FLIP.transform_rect_bbox(tile); + if self.pattern_tiles.insert(def.name.clone(), def.tile.origin()).is_some() { + self.doc.patterns.retain(|p| p.name != def.name); + } + self.doc.patterns.push(def); + for (key, blob) in sub.doc.images { + self.doc.images.entry(key).or_insert(blob); + } + self.spots.extend(sub.spots); + self.cmyk_colors += sub.cmyk_colors; + self.rgb_colors += sub.rgb_colors; + for w in &sub.warnings { + self.warn(w); + } + } + + /// In a pattern's definition, the operands of its first operator start with its head, + /// `(name) llx lly urx ury`: taken off them. + fn take_head(&mut self, mut vals: Vec) -> Vec { + let Some(head) = self.pattern.as_mut().filter(|h| !h.taken) else { return vals }; + head.taken = true; + let Some(at) = vals.iter().position(|v| matches!(v, V::Str(_))) else { return vals }; + let tile = Self::nums(vals.get(at + 1..at + 5).unwrap_or_default()); + let ([x0, y0, x1, y1], Some(name)) = (tile.as_slice(), vals.get(at).and_then(V::text)) else { return vals }; + head.name = name; + head.tile = Some(Rect::new(*x0, *y0, *x1, *y1)); + vals.split_off(at + 5) + } + // ---- frames ---------------------------------------------------------------------------- fn open(&mut self, kind: Kind, hidden: bool) -> Result<(), String> { @@ -682,6 +773,7 @@ impl<'a> Reader<'a> { self.after_object = false; } let vals = self.operands(); + let vals = self.take_head(vals); if let Some(d) = self.gradient_def.as_mut() { match w { "Bs" => d.stop(&vals, &mut self.spots), @@ -728,8 +820,10 @@ impl<'a> Reader<'a> { self.paint(&op, hidden, true)?; } // Colours. - "g" | "G" | "k" | "K" | "x" | "X" | "Xa" | "XA" | "Xx" | "XX" => self.color(w, &vals), - "p" | "P" => self.unreadable("pattern fills"), + "g" | "G" | "k" | "K" | "x" | "X" | "Xa" | "XA" | "Xx" | "XX" | "Xk" | "XK" => self.color(w, &vals), + "p" | "P" => self.pattern_paint(w, &vals), + // A pattern's definition ends. + "E" if self.pattern.is_some() => self.done = true, "O" => self.gs.overprint_fill = Self::nums(&vals).last() == Some(&1.0), "R" => self.gs.overprint_stroke = Self::nums(&vals).last() == Some(&1.0), "XR" => self.gs.rule = if Self::nums(&vals).last() == Some(&1.0) { FillRule::EvenOdd } else { FillRule::NonZero }, @@ -856,6 +950,9 @@ impl<'a> Reader<'a> { /// A text object (`/AI11Text`): an empty group standing for it, named after its story. fn text_slot(&mut self, o: &Obj) -> Result<(), String> { + if self.pattern.is_some() { + self.unreadable("type in a pattern"); + } let index = |key: &str| o.nums(key).first().copied().filter(|v| (0.0..f64::from(u32::MAX)).contains(v)).map(|v| v as u32); let (story, frame) = (index("StoryIndex"), index("FrameIndex").unwrap_or(0)); let gs = self.gs.clone(); @@ -893,7 +990,7 @@ impl<'a> Reader<'a> { self.warn(NON_NATIVE_ART_UNREAD); return Ok(()); } - let target = self.ensure_space().transform_rect_bbox(art_box); + let target = self.space().transform_rect_bbox(art_box); let opts = vectorcraft_pdf::ImportOptions { layers: false, ..Default::default() }; let read = match vectorcraft_pdf::import_with_report(&pdf, &opts) { Ok(r) => r, @@ -1021,6 +1118,34 @@ impl<'a> Reader<'a> { }); } + /// `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`: the stroke's): + /// the pattern swatch `name`, its space taken to art space by the matrix. A pattern this hasn't + /// read can't be drawn. + fn pattern_paint(&mut self, w: &str, vals: &[V]) { + // In the setup it paints nothing (and the art's space isn't fixed yet). + if self.frames.is_empty() { + return; + } + let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None }); + let Some(origin) = name.as_deref().and_then(|n| self.pattern_tiles.get(n)).map(|p| p.to_vec2()) else { + self.unreadable("pattern fills"); + return; + }; + let matrix = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { paint::affine(&Self::nums(a)) } else { None }); + // The tile's art (y down from the tile's top left) → the pattern's space → art space. + let xf = self.space() * matrix.unwrap_or(Affine::IDENTITY) * FLIP * Affine::translate(origin); + if xf.determinant().abs() < 1e-12 || !xf.as_coeffs().iter().all(|v| v.is_finite()) { + self.unreadable("pattern fills"); + return; + } + let p = Paint::Pattern { pattern: name.unwrap_or_default(), xf }; + if w == "p" { + self.gs.fill = p; + } else { + self.gs.stroke = p; + } + } + fn color(&mut self, w: &str, vals: &[V]) { let Some((p, named)) = paint::color_op(w, vals) else { return }; match p.color() { @@ -1031,7 +1156,7 @@ impl<'a> Reader<'a> { if let Some(n) = named { self.spots.push(n); } - if w.chars().all(|c| c.is_ascii_lowercase()) || w == "Xa" || w == "Xx" { + if w.ends_with(|c: char| c.is_ascii_lowercase()) { self.gs.fill = p; } else { self.gs.stroke = p; @@ -1165,7 +1290,7 @@ impl<'a> Reader<'a> { if op.chars().next().is_some_and(|c| c.is_ascii_lowercase()) { close(&mut bp); } - let to_doc = self.ensure_space(); + let to_doc = self.space(); let path = PathData::from_bezpath(&(to_doc * bp)); let gs = self.gs.clone(); let (fill, stroke) = match op.to_ascii_lowercase().as_str() { @@ -1200,7 +1325,7 @@ impl<'a> Reader<'a> { } fn set_instance(&mut self, i: &Instance) { - let to_doc = self.ensure_space(); + let to_doc = self.space(); match i.paint(&self.gradients, to_doc) { Some(p) if i.stroke => self.pending_stroke = Some(p), Some(p) => self.pending_fill = Some(p), @@ -1286,9 +1411,8 @@ impl<'a> Reader<'a> { } // The matrix maps pixels (y down) into art space with its y flipped. let [a, b, c, d, tx, ty] = m.as_slice() else { return Ok(()) }; - let to_doc = self.ensure_space(); - let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); - let xf = to_doc * flip * Affine::new([*a, *b, *c, *d, *tx, -*ty]); + let to_doc = self.space(); + let xf = to_doc * FLIP * Affine::new([*a, *b, *c, *d, *tx, -*ty]); if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { return Ok(()); } @@ -1326,6 +1450,12 @@ impl<'a> Reader<'a> { m } + /// Art space → where the art being read goes: the document, or while a pattern's definition is + /// read, the pattern's space (y down). + fn space(&mut self) -> Affine { + if self.pattern.is_some() { FLIP } else { self.ensure_space() } + } + fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) && self.warnings.len() < 64 { self.warnings.push(w.to_string()); @@ -1374,7 +1504,12 @@ impl<'a> Reader<'a> { } for n in &self.spots { if doc.swatch(&n.name).is_none() { - doc.swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot: true }); + doc.swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot: n.spot }); + } + } + for name in doc.patterns.iter().map(|p| p.name.clone()).collect::>() { + if doc.swatch(&name).is_none() { + doc.swatches.push(Swatch { paint: Paint::Pattern { pattern: name.clone(), xf: Affine::IDENTITY }, name, global: false, spot: false }); } } let mut warnings = self.warnings; diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 25ded7b3..70c07b4c 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -132,6 +132,92 @@ fn paths_colours_and_strokes() { assert!(a[4].appearance.fill().is_none() && a[4].appearance.stroke().is_some()); } +/// `Xk` and `XK` are global process colours: a name, a tint and whether the colour is RGB, as +/// `Xx` has them (#1025). They come in as global swatches that aren't inks, linked at their tint. +#[test] +fn global_process_colours_are_global_swatches_at_their_tint() { + let art_ = format!("0 0 0 1 (Black global) 0.4 0 Xk\n0 0 0 0 1 0.5 0 (Brand orange) 0 1 XK\n{}b\n", rect_path(0.0, 0.0, 10.0, 10.0)); + let doc = read_ok(&layer(&art_)).doc; + let a = art(&doc); + let Paint::Solid { swatch, tint, color } = fill(&a[0]) else { panic!("{:?}", fill(&a[0])) }; + assert_eq!(swatch.as_deref(), Some("Black global")); + assert!((tint - 0.6).abs() < 1e-6 && matches!(color, Color::Cmyk { k, .. } if (k - 0.6).abs() < 1e-6), "{tint} {color:?}"); + let stroke = a[0].appearance.stroke().unwrap().paint.clone(); + assert!( + matches!(&stroke, Paint::Solid { swatch: Some(s), tint, color } if s == "Brand orange" && *tint == 1.0 && *color == Color::rgb(1.0, 0.5, 0.0)), + "{stroke:?}" + ); + for name in ["Black global", "Brand orange"] { + let sw = doc.swatch(name).unwrap(); + assert!(sw.global && !sw.spot, "{sw:?}"); + } + assert_eq!(doc.swatch("Black global").unwrap().paint.color(), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0)), "the swatch is the full colour"); +} + +/// A pattern's definition (`%AI3_BeginPattern`): its name and tile (8 points square here, y up), +/// then its art on `%_` lines (a group of the tile's unpainted box and a dot, a round-capped +/// stroke of no length), `E`. +const DOTS: &str = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 8 8\n%_0 A\n%_0 Xw\n%_0 Ae\n%_u\n%_0 O\n%_0 8 m\n%_8 8 L\n%_8 0 L\n%_0 0 L\n%_0 8 L\n%_n\n\ + %_0 R\n%_0 0 0 1 K\n%_1 J 0 j 2 w 4 M []0 d\n%_4 4 m\n%_4 4 L\n%_S\n%_U\n%_/ArtDictionary :\n%_;\n%_\n%_9 () XW\nE\n%AI3_EndPattern\n"; + +/// Patterns are read into pattern swatches, and `p` fills with one (#1025): the art keeps its +/// layers, the fill places the tiles by its matrix. +#[test] +fn patterns_are_pattern_swatches_and_fill_with_them() { + let sub = |name: &str, art: &str| format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{art}LB\n%AI5_EndLayer--\n"); + let body = format!( + "{DOTS}%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Ground) Ln\n{}{}LB\n%AI5_EndLayer--\n", + sub("Lawn", &format!("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(10.0, 20.0, 70.0, 80.0))), + sub("Paving", &format!("(Dots) 10 20 2 2 0 0 0 0 0 [2 0 0 2 10 20] P\n{}S\n", rect_path(90.0, 20.0, 150.0, 80.0))), + ); + let s = read_ok(&body); + let doc = &s.doc; + let ground = &doc.layers[0]; + let subs: Vec<_> = ground.children().unwrap().iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert_eq!(subs, ["Lawn", "Paving"]); + let def = doc.pattern("Dots").unwrap(); + assert_eq!(def.tile, Rect::new(0.0, -8.0, 8.0, 0.0), "the tile, y down"); + assert_eq!(def.art.len(), 1, "the tile's group"); + let tile_box = &def.art[0].children().unwrap()[0]; + assert_eq!(tile_box.geometric_bounds(), Some(def.tile), "the tile's box is where the tile is"); + assert_eq!(def.art[0].children().unwrap().len(), 2); + assert!(matches!(&doc.swatch("Dots").unwrap().paint, Paint::Pattern { pattern, .. } if pattern == "Dots")); + let lawn = &ground.children().unwrap()[0].children().unwrap()[0]; + let Paint::Pattern { pattern, xf } = fill(lawn) else { panic!("{:?}", fill(lawn)) }; + assert_eq!(pattern, "Dots"); + // The tile's top left (0, 8) in art space is (0, 92) on the page (crop marks 100 high); a dot + // centre (4, 4) is at (4, 96). + assert_eq!(xf * Point::ZERO, Point::new(0.0, 92.0)); + assert_eq!(xf * Point::new(4.0, 4.0), Point::new(4.0, 96.0)); + // A stroke with it, scaled twice and moved by (10, 20): the tile's top left is at (10, 36) in + // art space. + let paving = &ground.children().unwrap()[1].children().unwrap()[0]; + let Some(Paint::Pattern { xf, .. }) = paving.appearance.stroke().map(|st| st.paint.clone()) else { panic!() }; + assert_eq!(xf * Point::ZERO, Point::new(10.0, 64.0)); + assert!(s.warnings.is_empty(), "{:?}", s.warnings); +} + +/// A pattern whose definition it can't read whole isn't kept: a fill with it is something the file +/// has that isn't read, where it shows. Its definition costs nothing else. +#[test] +fn a_pattern_it_cant_read_is_an_error_only_where_it_fills() { + let unread = "%AI3_BeginPattern: (Old)\n(Old) 0 0 8 8 [\n(0 O 0 R 0 g) @\n(\n0 0 m 8 8 L S\n) &\n] E\n%AI3_EndPattern\n\ + %AI3_BeginPattern: (Odd)\n(Odd) 0 0 8 8\n%_U\nE\n%AI3_EndPattern\n%AI3_BeginPattern: (Flat)\n(Flat) 0 0 8 0\n%_0 0 m\n%_8 0 L\n%_S\nE\n%AI3_EndPattern\n"; + let s = read_ok(&format!("{unread}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")))); + assert!(s.doc.patterns.is_empty() && s.doc.swatches.iter().all(|w| !matches!(w.paint, Paint::Pattern { .. }))); + for name in ["Old", "Odd", "Flat"] { + let e = read( + stream(&format!("{unread}{}", layer(&format!("({name}) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0))))) + .as_bytes(), + ) + .unwrap_err(); + assert!(e.contains("pattern fills"), "{name}: {e}"); + } + // Type in a pattern isn't read either. + let typed = "%AI3_BeginPattern: (Words)\n(Words) 0 0 8 8\n%_/AI11Text :\n%_0 /StoryIndex ,\n%_;\nE\n%AI3_EndPattern\n"; + assert!(read_ok(&format!("{typed}{}", layer(""))).doc.patterns.is_empty()); +} + #[test] fn compound_paths_and_clipping() { let art_ = format!( diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 312f1ddb..bd8e4c32 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -543,6 +543,33 @@ fn an_ai_file_without_its_pdf_part_reads_its_layers_and_makes_its_type() { assert!(crate::ai_alone(b"%AI24_ZStandard_Data nothing").is_err()); } +/// A global process colour (`Xk`) or a pattern fill on a sublayer no longer sends a `.ai` to its +/// PDF part, which has only the top-level layers (#1025). +#[test] +fn a_global_colour_or_a_pattern_fill_keeps_the_sublayers_of_an_ai_file() { + // The PDF part's yellow square, in a global yellow at full tint, on a sublayer; a hidden + // sublayer with a pattern fill (the page doesn't have it). + let yellow = "0 0 1 0 (Yellow global) 0 0 Xk\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf"; + let dots = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n%_0 0 0 1 k\n%_1 1 m\n%_3 1 L\n%_3 3 L\n%_1 3 L\n%_f\nE\n%AI3_EndPattern\n"; + let patterned = "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n50 50 m\n70 50 L\n70 70 L\n50 70 L\nf"; + let ground = layer("Ground", true, &(layer("Lawn", true, yellow) + &layer("Paving", false, patterned))); + let art = native(&format!("{dots}{ground}")); + let (d, warnings) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false); + assert_eq!(names(&d), ["Ground"], "{warnings:?}"); + let subs: Vec<_> = d.layers[0].children().unwrap().iter().map(|l| (l.name.clone().unwrap_or_default(), kinds(l))).collect(); + assert_eq!(subs, [("Lawn".to_string(), vec!["path"]), ("Paving".to_string(), vec!["path"])]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(d.swatch("Yellow global").is_some_and(|s| s.global && !s.spot)); + assert!(d.pattern("Dots").is_some() && d.swatch("Dots").is_some()); + // Without a PDF part, the pattern fill shows. + let shown = native(&format!("{dots}{}", layer("Ground", true, &layer("Lawn", true, patterned)))); + let (d, notes) = crate::ai_alone(&private(&shown)).unwrap(); + let lawn = &d.layers[0].children().unwrap()[0]; + assert_eq!((lawn.name.as_deref(), kinds(lawn)), (Some("Lawn"), vec!["path"]), "{notes:?}"); + let fill = lawn.children().unwrap()[0].appearance.fill().map(|f| f.paint.clone()); + assert!(matches!(&fill, Some(vectorcraft_color::Paint::Pattern { pattern, .. }) if pattern == "Dots"), "{fill:?}"); +} + #[test] fn an_object_the_page_doesnt_draw_makes_the_file_come_in_as_its_page() { // A 20-point square that the page doesn't draw: 4% of the page, and an object all the same. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index de29c467..d9019c75 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -35,14 +35,14 @@ private editing data. VectorCraft: - **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, symbols and pattern fills come in as their drawn look (#637), legacy `.ai` without the prolog not - read. + brushes and symbols come in as their drawn look (#637; pattern fills are read since #1025), legacy `.ai` + without the prolog not read. - Estimate: **~65%** for real exchange with Illustrator users (open: ~75%; save: PDF-compatible only). Closing the open-side issues: 10–20 h with users' files. ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours), linear and radial gradients, opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills whose pattern it can't read, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format @@ -78,4 +78,5 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | `.ai` editing data: global process colours and pattern fills read, so they no longer drop sublayers (#1025) | | 2026-10-10 | major | First checklist; moved the `.ai` editing-data scope from the ROADMAP's "Out of scope" here; added the user-reported `.ai` failures | diff --git a/docs/gaps.md b/docs/gaps.md index 07b16be3..8a81e01f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -55,8 +55,8 @@ 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; #868: round trip with Illustrator 2018 loses layers and groups), guides and non-printing construction - lines (#779), open speed on large files (#758), legacy `.ai` without the prolog, live effects, brushes, symbols - and pattern fills as live objects (#637). Writing Illustrator's own editing data is out of scope + lines (#779), open speed on large files (#758), legacy `.ai` without the prolog, live effects, brushes and + symbols as live objects (#637; pattern fills and global colours are read since #1025). Writing Illustrator's own editing data is out of scope (undocumented); Save As `.ai` stays PDF-compatible. - **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 @@ -200,6 +200,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | G9: `.ai` pattern fills and global colours read from the editing data (#1025) | | 2026-10-10 | minor | G3: initial Revolve landed (#846), 45–75 h left | | 2026-10-10 | minor | G9 evidence counted from the tracker (28 `.ai`/EPS issues, 20 fixed); impact narrowed to handing files back | | 2026-10-10 | minor | Alpha blockers checked against the core-workflow gate: none |