diff --git a/ROADMAP.md b/ROADMAP.md index 04c2c975..f404f12a 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). 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-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). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). | | 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/src/cmd/fileio/tests_epsimport.rs b/crates/engine/src/cmd/fileio/tests_epsimport.rs index 0e23dfed..1f7488cf 100644 --- a/crates/engine/src/cmd/fileio/tests_epsimport.rs +++ b/crates/engine/src/cmd/fileio/tests_epsimport.rs @@ -108,6 +108,15 @@ showpage let inner = outer.children().map(|c| (c.len(), c[1].children().map(Vec::len))).unwrap(); assert!(matches!(outer.kind, vectorcraft_doc::NodeKind::Group { clip: false, .. }), "{:?}", outer.kind); assert_eq!(inner, (2, Some(2))); + // One that names the prolog defining its operators without including it (as other apps write + // the format) opens with the layers its program has (#1027). + let named = b"%!PS-Adobe-3.0\n%%BoundingBox: 0 0 100 100\n%%DocumentNeededResources: procset Adobe_IllustratorA_AI3 1.0 0\n%AI3_TemplateBox: 50 50 50 50\n%%EndComments\n\ + %%BeginProlog\n%%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 0\n%%EndProlog\n%%BeginSetup\nAdobe_IllustratorA_AI3 /initialize get exec\n%%EndSetup\n\ + %AI5_BeginLayer\n1 1 1 1 0 0 2 0 0 0 Lb\n(PLAN::Walls) Ln\n0 0 0 1 K\n0 i 1 w\n10 10 m\n90 90 L\nS\nLB\n%AI5_EndLayer--\n%%PageTrailer\n%%Trailer\n%%EOF\n"; + let r = open(&mut s, "cad.ai", named).unwrap(); + assert_eq!(r["warnings"].as_array().map_or(0, Vec::len), 0, "{r}"); + let d = s.doc().unwrap().doc.clone(); + assert_eq!((d.layers.len(), d.layers[0].name.as_deref(), objects(&s)), (1, Some("PLAN::Walls"), 1)); // A .ait one opens as a new untitled document. let r = open(&mut s, "legacy.ait", ps).unwrap(); assert!(r["title"].as_str().unwrap().starts_with("Untitled"), "{r}"); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 0a6407b2..ba7f6fe1 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1280,11 +1280,17 @@ proptest! { /// Hostile PostScript programs: operators in any order with any operands, opened and placed; /// as files in the legacy Illustrator format too (the creator line is what turns on its - /// documented `u` … `U` groups), the groups unbalanced, deep and among clips. + /// documented `u` … `U` groups), the groups unbalanced, deep and among clips; in a layer, which + /// the editing data reader reads from the program too. #[test] - fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60), illustrator in any::()) { + fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60), illustrator in any::(), layers in any::()) { let head = if illustrator { "%%Creator: Adobe Illustrator(R) 8.0\n%%EndComments\n/u {} def /U {} def" } else { "%%EndComments" }; - let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 200\n{head}\n{}\nshowpage\n", tokens.join(" ")); + let (open, close) = if layers { + ("/Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def\n%AI5_BeginLayer\n1 1 1 1 0 0 -1 0 0 0 Lb\n(L) Ln\n", "\nLB\n%AI5_EndLayer--") + } else { + ("", "") + }; + let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 200\n{head}\n{open}{}{close}\nshowpage\n", tokens.join(" ")); survive("hostile PostScript", || vectorcraft_engine::cmd::fileio::load("x.eps", ps.as_bytes()).ok().map(|l| l.doc))?; let r = catch_quiet(|| { let mut s = rich_session(); @@ -1878,6 +1884,35 @@ proptest! { } } +/// A `.ai` in PostScript form as other apps write it: the procsets that define its operators named, +/// not included, so its program can't be run and its layers are read from it. +const NAMED_PROLOG_AI: &str = "%!PS-Adobe-3.0\n%%BoundingBox: 0 0 200 100\n%%DocumentNeededResources: procset Adobe_packedarray 2.0 0\n%AI3_TemplateBox: 288 384 288 384\n%%EndComments\n\ + %%BeginProlog\n%%IncludeResource: procset Adobe_packedarray 2.0 0\nAdobe_packedarray /initialize get exec\n%%EndProlog\n%%BeginSetup\n%AI5_Begin_NonPrinting\nNp\n%AI5_End_NonPrinting--\n%%EndSetup\n\ + %AI5_BeginLayer\n1 1 1 1 0 0 -1 191 63 255 Lb\n(A::B) Ln\n0 A\n0 R\n0 0 0 1 K\n0 i 1 J 1 j 0.8 w 4 M [2 1]0 d\n0 D\n10 10 m\n90 10 L\n90 90 L\n10 90 L\n10 10 L\nS\n\ + u\n0.5 g\n20 20 m\n40 20 L\n40 40 L\nf\nU\n%AI5_BeginLayer\n0 1 1 1 0 0 3 0 0 0 Lb\n(Sub) Ln\n110 10 m\n190 90 L\nS\nLB\n%AI5_EndLayer--\nLB\n%AI5_EndLayer--\n\ + %%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n"; + +/// The fixture, as it is, opens with its layer and sublayer. +#[test] +fn the_named_prolog_fixture_opens_with_its_layers() { + let l = vectorcraft_engine::cmd::fileio::load("x.ai", NAMED_PROLOG_AI.as_bytes()).unwrap(); + let names: Vec<_> = l.doc.layers.iter().map(|l| l.name.clone()).collect(); + assert_eq!(names, [Some("A::B".to_string())], "{:?}", l.warnings); + assert_eq!(l.doc.layers[0].children().unwrap().len(), 3); +} + +proptest! { + #![proptest_config(config())] + + /// A `.ai` in PostScript form whose prolog is named, not included, damaged: read from its + /// layers, or refused. + #[test] + fn ps_form_ai_never_panics(cut in 0usize..1_200, edits in prop::collection::vec((0usize..1_200, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '[', ']', '%', 'u', 'U', 'L', 'b', 'B', 'i', 'A', 'K'])), 0..12)) { + let text = mutate_text(NAMED_PROLOG_AI, cut, &edits); + survive("mutated PostScript-form .ai", || vectorcraft_engine::cmd::fileio::load("x.ai", text.as_bytes()).ok().map(|l| l.doc))?; + } +} + /// The operators, operands and section comments of hostile editing data. fn arb_ai_token() -> impl Strategy { prop_oneof![ diff --git a/crates/eps/src/import/ai/mod.rs b/crates/eps/src/import/ai/mod.rs index 489fb1ac..c753fc72 100644 --- a/crates/eps/src/import/ai/mod.rs +++ b/crates/eps/src/import/ai/mod.rs @@ -52,6 +52,11 @@ pub fn eps_data(ps: &[u8]) -> Option, String>> { Some(eps_section(section)) } +/// Does the EPS `ps` carry editing data ([`eps_data`] isn't `None`)? +pub fn has_eps_data(ps: &[u8]) -> bool { + find(ps, EPS_BEGIN).is_some() +} + /// The editing data between the EPS markers: header comments, then the data (encoded or not). fn eps_section(section: &[u8]) -> Result, String> { let mut header = Vec::new(); diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index cb45d9ac..6d4f2aa4 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -2,14 +2,17 @@ //! //! - Art space has y up; the first artboard's top left corner is the document's origin. //! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed … colorIndex r g b … -//! dimPercent … Lb`, `(name) Ln`, its art, `LB`. +//! dimPercent … Lb` (ten operands in the legacy format, without the one before the colour +//! index), `(name) Ln`, its art, `LB`. //! - Groups `u` … `U`, compound paths `*u` … `*U`, clipping groups `q` … `Q` (the path painted //! with `W` clips); a group's transparency and name follow its end. //! - Paths: `m`, `l`/`L`, `c`/`C`, `v`/`V`, `y`/`Y`, painted by `N n F f S s B b` (lower case //! closes), `(op) *` for a guide. Stroke `w J j M d`, fill rule `XR`, overprint `O`/`R`. //! - State: hidden `Xw`, locked `A`, transparency `mode opacity isolate knockout shape Xy`. //! - Names: an `/ArtDictionary` after an object, its `AI10_ArtUID` the name's XML id. -//! - Artboards: the `ArtboardArray` of the `/Document` dictionary, else `%AI3_Cropmarks`. +//! - Artboards: the `ArtboardArray` of the `/Document` dictionary, else `%AI3_Cropmarks`, else +//! the art size around the template box's centre, else (a file in the legacy format another +//! app wrote) the bounding box. //! - Images: `%AI5_BeginRaster`, the colour space `XN`, `[matrix] bounds w h bits type alpha …` //! and the samples after `XI`. //! - Art with an appearance the format can't write as plain art (several fills or strokes, @@ -304,6 +307,8 @@ const IGNORED: &[&str] = &[ "Xm", "XP", "Np", + // Flatness (`i`): how finely curves print. + "i", "TE", "TZ", "Xs", @@ -1098,7 +1103,8 @@ impl<'a> Reader<'a> { } } - /// `… Lb`: a layer's options. + /// `… Lb`: a layer's options. The legacy format's has ten operands, its colour index one place + /// earlier (`visible preview unlocked printing dimmed masks colorIndex r g b`). fn layer_attrs(&mut self, vals: &[V]) { let n = Self::nums(vals); let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() else { return }; @@ -1109,12 +1115,13 @@ impl<'a> Reader<'a> { a.printable = flag(3, true); let dim_pct = n.get(12).copied().filter(|v| v.is_finite()).unwrap_or(50.0).clamp(0.0, 100.0); a.dim = flag(4, false).then_some(dim_pct.round() as u8); - let index = n.get(7).copied().unwrap_or(0.0); - let rgb = |i: usize| n.get(i).map_or(0, |v| v.clamp(0.0, 255.0) as u8); + let at = if n.len() <= 10 { 6 } else { 7 }; + let index = n.get(at).copied().unwrap_or(0.0); + let rgb = |i: usize| n.get(at + i).map_or(0, |v| v.clamp(0.0, 255.0) as u8); a.color = Some(if (0.0..vectorcraft_doc::LAYER_COLORS.len() as f64).contains(&index) && index.fract() == 0.0 { LayerColor::Preset(index as u8) } else { - LayerColor::Custom([rgb(8), rgb(9), rgb(10)]) + LayerColor::Custom([rgb(1), rgb(2), rgb(3)]) }); } @@ -1438,12 +1445,24 @@ impl<'a> Reader<'a> { (sane(r) && w > 0.0 && h > 0.0).then_some(r) } + /// The first artboard: the `/Document`'s first, else the crop marks, else [`Self::sized_board`]. + fn first_board(&self) -> Option { + self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board()) + } + + /// The page of a file that says nothing of its artboard (the legacy format as other apps write + /// it): its art's bounding box. + fn bbox_board(&self) -> Option { + let [x0, y0, x1, y1] = self.hires.or(self.bbox)?; + Some(Rect::new(x0, y0, x1, y1)).filter(|r| r.width() > 0.0 && r.height() > 0.0) + } + /// Art space → the document (fixed when the art starts, from the artboards read by then). fn ensure_space(&mut self) -> Affine { if let Some(m) = self.to_doc { return m; } - let first = self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board()); + let first = self.first_board().or_else(|| self.bbox_board()); let top_left = first.map_or(Point::new(0.0, 792.0), |r| Point::new(r.x0, r.y1)); let m = Affine::new([1.0, 0.0, 0.0, -1.0, -top_left.x, top_left.y]); self.to_doc = Some(m); @@ -1475,11 +1494,11 @@ impl<'a> Reader<'a> { return Err("it has no layers".into()); } let to_doc = self.ensure_space(); - let artboard = self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board()); + let artboard = self.first_board(); drop_commented_copies(&mut self.doc.layers, &self.hidden_ids); let mut doc = std::mem::replace(&mut self.doc, Document::new(1.0, 1.0)); let boards: Vec<(String, Rect)> = if self.artboards.is_empty() { - self.cropmarks.or_else(|| self.sized_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default() + artboard.or_else(|| self.bbox_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default() } else { self.artboards.clone() }; diff --git a/crates/eps/src/import/mod.rs b/crates/eps/src/import/mod.rs index 8dac8a78..02906f66 100644 --- a/crates/eps/src/import/mod.rs +++ b/crates/eps/src/import/mod.rs @@ -16,14 +16,18 @@ //! 3–8, with the prolog that defines its operators), the group operators `u` … `U` (nested) come in //! as groups. The format's published specification documents its header comments (`%AI…`, //! `%%Creator`) and these operators: Adobe Illustrator File Format Specification, version 7.0 -//! (1998), listed by PRONOM at https://www.nationalarchives.gov.uk/PRONOM/fmt/423. +//! (1998), listed by PRONOM at https://www.nationalarchives.gov.uk/PRONOM/fmt/423. A file in that +//! format whose program has its layers (`%AI5_BeginLayer`) comes in through them, as `native` reads +//! an Illustrator EPS's editing copy (#1027). //! //! The page is the file's `%%BoundingBox` (`%%HiResBoundingBox` when it has one), the first page //! of a PostScript file without one. A file whose program can't be read (an operator the -//! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in as its -//! preview (a TIFF image, a Windows metafile or an EPSI bitmap), with a warning that names the -//! error, the operator and the procedures it ran in; without a preview, what was drawn before the -//! error is kept (with that warning), else the file is refused. +//! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in from its +//! layers when its program has them (a `.ai` in PostScript form whose prolog is named but not +//! included, as other apps write it), else as its preview (a TIFF image, a Windows metafile or an +//! EPSI bitmap), with a warning that names the error, the operator and the procedures it ran in; +//! without a preview, what was drawn before the error is kept (with that warning), else the file +//! is refused. mod ai; mod ate; @@ -201,6 +205,13 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result (Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(), (Err(e), _) => reason(e, fault.as_ref()), }; + // A file in the legacy Illustrator format whose program can't be run (a `.ai` that names the + // prolog defining its operators without including it): its layers, which the program has. + let why = match editing_data.then(|| native::program_alone(ps)).flatten() { + Some(Ok((document, warnings))) => return Ok(Imported { document, warnings, preview: false }), + Some(Err(e)) => format!("{why}; its layers couldn't be read either ({e})"), + None => why, + }; if let Some(p) = preview::preview(bytes, ps, frame) { let warnings = vec![format!("this file's PostScript couldn't be read ({why}): {}", p.what)]; return Ok(Imported { document: p.document, warnings, preview: true }); @@ -214,7 +225,7 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result out.warn("the file draws nothing"); Ok(finish(out)) } - Err(e) => Err(format!("this PostScript file can't be read ({}): save it as PDF or SVG in the app that made it", reason(&e, fault.as_ref()))), + Err(_) => Err(format!("this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it")), } } diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index d0aff0c0..36b082fe 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -1,11 +1,15 @@ //! The layers of an Illustrator EPS or `.ai`. //! //! Besides the page it prints, such a file carries the app's own copy of the art: an EPS after its -//! `%%EOF`, a `.ai` in its `AIPrivateData` streams (see `ai` for the containers). It has what the -//! page doesn't: the layers with their names and options, the groups, compound paths and clipping -//! groups, the objects' names, the hidden objects and layers, the art outside the artboards and -//! every artboard. `ai` reads that structure into a document, with the colours, gradients, -//! transparency and images of the art. +//! `%%EOF`, a `.ai` in its `AIPrivateData` streams (see `ai` for the containers). A file in the +//! legacy format (a `.ai` in PostScript form: versions 3 to 8, and the apps that still write it) +//! is that copy itself: its program has the layers, and the prolog it names defines the operators +//! for printing (other apps name it without including it, so their files can't be run). +//! +//! The copy has what the page doesn't: the layers with their names and options, the groups, +//! compound paths and clipping groups, the objects' names, the hidden objects and layers, the art +//! outside the artboards and every artboard. `ai` reads that structure into a document, with the +//! colours, gradients, transparency and images of the art. //! //! Text objects are slots in that document: their characters, fonts and places are in the file's //! text document (see `ate`). Type that shows comes from the page, which draws it, into the slot @@ -21,6 +25,7 @@ //! //! Where a file doesn't match what this module expects, it is left alone and its page is imported. +use std::borrow::Cow; use std::collections::BTreeSet; use std::sync::Arc; @@ -991,12 +996,13 @@ fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result })) } -/// The EPS `visible` (read as its page) read through its editing copy instead: its layers (the +/// The EPS `visible` (read as its page) read through its editing copy instead (its private data, or +/// its program in the legacy format, see [`editing_copy`]): its layers (the /// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the /// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it /// has one that can't be used, `visible` with a warning that says why. pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported { - let Some(data) = ai::eps_data(ps) else { return visible }; + let Some(data) = editing_copy(ps) else { return visible }; let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false)); match result { Ok((document, mut warnings)) => { @@ -1023,7 +1029,7 @@ pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported { /// its PDF part). pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outlined: bool) -> (Document, Vec) { let Ok(data) = ai::decode_private(private) else { return (visible, warnings) }; - if !data.windows(15).any(|w| w == b"%AI5_BeginLayer") { + if !has_layers(&data) { return (visible, warnings); } match build(&data, Some(&visible), Page::Artboard, outlined) { @@ -1047,6 +1053,27 @@ pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec), String> { build(&data, None, Page::Artboard, false) } +/// Does the editing data (or a program in the legacy format) have layers? +fn has_layers(data: &[u8]) -> bool { + data.windows(15).any(|w| w == b"%AI5_BeginLayer") +} + +/// The editing copy of the art in the PostScript `ps`: an EPS's private data, else the program +/// itself when it has layers (the legacy format). `None` without one. +fn editing_copy(ps: &[u8]) -> Option, String>> { + match ai::eps_data(ps) { + Some(data) => Some(data.map(Cow::Owned)), + None => has_layers(ps).then_some(Ok(Cow::Borrowed(ps))), + } +} + +/// A file in the legacy format whose page can't be read (a `.ai` in PostScript form that names its +/// prolog without including it) from its layers alone (`ps`: its program, which has them) → the +/// document and the import's notes. `None` when the layers aren't in the program. +pub(super) fn program_alone(ps: &[u8]) -> Option), String>> { + (!ai::has_eps_data(ps) && has_layers(ps)).then(|| build(ps, None, Page::Art, false)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/eps/src/import/tests_illustrator.rs b/crates/eps/src/import/tests_illustrator.rs index a3124af1..849917a7 100644 --- a/crates/eps/src/import/tests_illustrator.rs +++ b/crates/eps/src/import/tests_illustrator.rs @@ -4,7 +4,8 @@ use std::sync::Arc; use vectorcraft_doc::clipnest::MAX_NEST; -use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_doc::{LayerColor, LineCap, Node, NodeKind}; +use vectorcraft_geom::Rect; use crate::import::import; @@ -80,6 +81,80 @@ fn other_postscript_keeps_its_own_u() { assert_eq!(read("%%Creator: some app\n", "", &body).0, "p p"); } +/// A `.ai` in PostScript form as other apps write it (#1027): its header and prolog name the +/// procsets that define its operators (by the DSC resource comments) without including them, so +/// its program can't be run. Its layers come from the program itself: names as they are, the +/// legacy format's ten-operand `Lb`, flatness `i`, the bounding box as the page. +const NAMED_PROLOG: &str = "%!PS-Adobe-3.0\n%%Creator: a CAD app\n%%BoundingBox: 0 0 200 100\n\ + %%DocumentNeededResources: procset Adobe_packedarray 2.0 0\n%%+ procset Adobe_IllustratorA_AI3 1.0 0\n\ + %AI3_TemplateBox: 288 384 288 384\n%%EndComments\n%%BeginProlog\n%%IncludeResource: procset Adobe_packedarray 2.0 0\n\ + Adobe_packedarray /initialize get exec\n%%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 0\n%%EndProlog\n\ + %%BeginSetup\nAdobe_IllustratorA_AI3 /initialize get exec\n%%EndSetup\n\ + %AI5_BeginLayer\n1 1 1 1 0 0 -1 0 0 0 Lb\n(SECTION::Cut) Ln\n0 A\n0 R\n0 0 0 1 K\n0 i 1 J 1 j 0.8 w 4 M []0 d\n0 D\n\ + 10 10 m\n90 10 L\n90 90 L\n10 90 L\n10 10 L\nS\nLB\n%AI5_EndLayer--\n\ + %AI5_BeginLayer\n1 1 1 1 0 0 -1 191 63 255 Lb\n(SECTION::Hatch) Ln\n0 A\n0 R\n0 0 0 1 K\n0.1 w\n110 10 m\n190 90 L\nS\nLB\n%AI5_EndLayer--\n\ + %%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\nAdobe_IllustratorA_AI3 /terminate get exec\n%%EOF\n"; + +#[test] +fn a_postscript_ai_that_names_its_prolog_without_including_it_opens_with_its_layers() { + let r = import(NAMED_PROLOG.as_bytes()).unwrap(); + assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings); + let d = &r.document; + let names: Vec<_> = d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert_eq!(names, ["SECTION::Cut", "SECTION::Hatch"]); + let colors: Vec<_> = d.layers.iter().map(|l| if let NodeKind::Layer { color, .. } = l.kind { Some(color) } else { None }).collect(); + assert_eq!(colors, [Some(LayerColor::Custom([0, 0, 0])), Some(LayerColor::Custom([191, 63, 255]))]); + assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), "the page is the bounding box"); + let square = &d.layers[0].children().unwrap()[0]; + assert_eq!(square.geometric_bounds(), Some(Rect::new(10.0, 10.0, 90.0, 90.0))); + let stroke = square.appearance.stroke().unwrap(); + assert_eq!((stroke.width, stroke.cap), (0.8, LineCap::Round)); + assert_eq!(tree(d.layers[1].children().unwrap()), "p"); +} + +/// A `.ai` in PostScript form whose layers can't be read either says why, after what stopped its +/// program. +#[test] +fn a_postscript_ai_whose_layers_cant_be_read_says_why() { + let damaged = NAMED_PROLOG.replace("0 D\n", "0 D\n1 2 frobnicate\n"); + let e = import(damaged.as_bytes()).unwrap_err(); + assert!(e.contains("`Adobe_packedarray`") && e.contains("its layers couldn't be read either") && e.contains("`frobnicate`"), "{e}"); +} + +/// The editing text of a newer file saved as PostScript (no prolog at all, a non-printing setup): +/// it opens with its sublayers. +#[test] +fn newer_editing_text_as_postscript_opens_with_its_sublayers() { + let layer = + |name: &str, art: &str| format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 -1 79 128 255 0 50 0 Lb\n({name}) Ln\n0 A\n0 Xw\n{art}LB\n%AI5_EndLayer--\n"); + let ground = layer("Ground", &(layer("Lawn", "0 0 0 0.3 k\n10 20 m\n70 20 L\n70 80 L\n10 80 L\nf\n") + &layer("Paving", ""))); + let text = format!( + "%!PS-Adobe-3.0\n%%Creator: a script\n%%BoundingBox: 0 0 250 100\n%AI5_FileFormat 14.0\n%AI3_Cropmarks: 0 0 250 100\n%%EndComments\n\ + %%BeginProlog\n%%EndProlog\n%%BeginSetup\n%AI5_Begin_NonPrinting\nNp\n%AI5_End_NonPrinting--\n%%EndSetup\n{ground}%%PageTrailer\n%%Trailer\n%%EOF\n" + ); + let r = import(text.as_bytes()).unwrap(); + let d = &r.document; + assert_eq!(d.layers.len(), 1, "{:?}", r.warnings); + let subs: Vec<_> = d.layers[0].children().unwrap().iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert_eq!(subs, ["Lawn", "Paving"]); +} + +/// A file in the legacy format whose prolog is included (here one of our own) runs as its page, and +/// comes in through the layers its program has when they look like the page. +#[test] +fn a_legacy_file_with_its_prolog_comes_in_through_its_layers() { + let prolog = "/Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def /A {pop} def /k {setcmykcolor} def"; + let body = format!("%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Back) Ln\n0 A\n0 0 0 1 k\n{} LB\n%AI5_EndLayer--\n", tri(10)); + let file = String::from_utf8(ai(AI8, "", &body)).unwrap().replace("%%EndProlog", &format!("{prolog}\n%%EndProlog")); + let r = import(file.as_bytes()).unwrap(); + let names: Vec<_> = r.document.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert_eq!(names, ["Back"], "{:?}", r.warnings); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + // Without the editing data, it is its page. + let page = crate::import::import_with(file.as_bytes(), false).unwrap(); + assert_eq!(page.document.layers[0].name.as_deref(), Some("Layer 1")); +} + #[test] fn unbalanced_and_runaway_groups_are_read_safely() { // Ends without a beginning end nothing; groups left open hold the rest. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ba350b17..d4e5e6f6 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3686,7 +3686,7 @@ theme Appearance Erscheinungsbild @msg the {tool} tool has no options das Werkzeug „{tool}“ hat keine Optionen @msg there are no objects to release es gibt keine Objekte zum Zurückwandeln @msg there are no printer settings to open here hier lassen sich keine Druckereinstellungen öffnen -@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it diese PostScript-Datei kann nicht gelesen werden ({_1}): Speichern Sie sie in der App, mit der sie erstellt wurde, als PDF oder SVG +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it diese PostScript-Datei kann nicht gelesen werden ({why}): Speichern Sie sie in der App, mit der sie erstellt wurde, als PDF oder SVG @msg this file's PostScript couldn't be read ({why}): {what} das PostScript dieser Datei konnte nicht gelesen werden ({why}): {what} @msg this format holds one artboard: export several as PDF or with document.exportForScreens dieses Format fasst eine Zeichenfläche: Exportieren Sie mehrere als PDF oder mit document.exportForScreens @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that dies ist eine DWG-Zeichnung, die nicht gelesen werden kann: Speichern Sie sie in der CAD-App als ASCII-DXF und öffnen Sie diese Datei diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 07b19b0c..6bb2aa3f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3786,7 +3786,7 @@ axis Both Ambos @msg the {tool} tool has no options la herramienta {tool} no tiene opciones @msg there are no objects to release no hay objetos que soltar @msg there are no printer settings to open here aquí no hay ajustes de impresora que abrir -@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it no se puede leer este archivo PostScript ({_1}): guárdalo como PDF o SVG en la aplicación que lo creó +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it no se puede leer este archivo PostScript ({why}): guárdalo como PDF o SVG en la aplicación que lo creó @msg this file's PostScript couldn't be read ({why}): {what} no se pudo leer el PostScript de este archivo ({why}): {what} @msg this format holds one artboard: export several as PDF or with document.exportForScreens este formato admite una sola mesa de trabajo: exporta varias como PDF o con document.exportForScreens @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that este es un dibujo DWG, que no se puede leer: guárdalo desde la aplicación de CAD como DXF ASCII y abre ese archivo diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 72b97407..23092e6d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3783,7 +3783,7 @@ axis Both Les deux @msg the {tool} tool has no options l’outil {tool} n’a pas d’options @msg there are no objects to release il n’y a aucun objet à libérer @msg there are no printer settings to open here il n’y a pas de réglages d’imprimante à ouvrir ici -@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it impossible de lire ce fichier PostScript ({_1}) : enregistrez-le au format PDF ou SVG dans l’application qui l’a créé +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it impossible de lire ce fichier PostScript ({why}) : enregistrez-le au format PDF ou SVG dans l’application qui l’a créé @msg this file's PostScript couldn't be read ({why}): {what} impossible de lire le PostScript de ce fichier ({why}) : {what} @msg this format holds one artboard: export several as PDF or with document.exportForScreens ce format ne contient qu’un plan de travail : exportez-en plusieurs au format PDF ou avec document.exportForScreens @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that c’est un dessin DWG, qui ne peut pas être lu : enregistrez-le depuis l’application de CAO au format DXF ASCII et ouvrez ce fichier diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 695b75b4..c76bd87d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3786,7 +3786,7 @@ axis Both Entrambi @msg the {tool} tool has no options lo strumento {tool} non ha opzioni @msg there are no objects to release non ci sono oggetti da rilasciare @msg there are no printer settings to open here non ci sono impostazioni della stampante da aprire qui -@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it impossibile leggere questo file PostScript ({_1}): salvalo come PDF o SVG nell'app che lo ha creato +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it impossibile leggere questo file PostScript ({why}): salvalo come PDF o SVG nell'app che lo ha creato @msg this file's PostScript couldn't be read ({why}): {what} impossibile leggere il PostScript di questo file ({why}): {what} @msg this format holds one artboard: export several as PDF or with document.exportForScreens questo formato contiene una sola tavola da disegno: esportane più di una come PDF o con document.exportForScreens @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that questo è un disegno DWG, che non può essere letto: salvalo dall'app CAD come DXF ASCII e apri quel file diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b32c26d9..5835f335 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3636,7 +3636,7 @@ theme Appearance 外観 @msg shadings of an unknown type were left out 種類が不明なシェーディングは除外されました @msg shadings whose functions are of an unknown type are filled with their middle colour 関数の種類が不明なシェーディングは中間の色で塗りつぶされています @msg the file's preview header points outside the file ファイルのプレビューヘッダーがファイルの範囲外を指しています -@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it この PostScript ファイルは読み込めません ({_1}): 作成したアプリで PDF または SVG として保存してください +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it この PostScript ファイルは読み込めません ({why}): 作成したアプリで PDF または SVG として保存してください @msg this is not a PostScript file PostScript ファイルではありません @msg a PostScript error ({name} in `{op}`) PostScript エラー (`{op}` での {name}) @msg a PostScript error ({name} in `{op}`), in {procs} {procs} 内の PostScript エラー (`{op}` での {name}) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index be704cae..e324fd01 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3782,7 +3782,7 @@ axis Both Оба @msg the {tool} tool has no options инструмент {tool} не имеет параметров @msg there are no objects to release нет объектов для освобождения @msg there are no printer settings to open here здесь нет настроек принтера для открытия -@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it этот файл PostScript невозможно прочитать ({_1}): сохраните его как PDF или SVG в приложении, которое его создало +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it этот файл PostScript невозможно прочитать ({why}): сохраните его как PDF или SVG в приложении, которое его создало @msg this file's PostScript couldn't be read ({why}): {what} PostScript этого файла невозможно прочитать ({why}): {what} @msg this format holds one artboard: export several as PDF or with document.exportForScreens этот формат содержит одну монтажную область: экспортируйте несколько как PDF или с помощью document.exportForScreens @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that это чертёж DWG, который невозможно прочитать: сохраните его из приложения CAD как ASCII DXF и откройте его diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6ce17e59..c8410593 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3769,7 +3769,7 @@ axis Both Обидві @msg the {tool} tool has no options інструмент {tool} не має параметрів @msg there are no objects to release немає об’єктів для звільнення @msg there are no printer settings to open here тут немає налаштувань принтера для відкриття -@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it цей файл PostScript неможливо прочитати ({_1}): збережіть його як PDF або SVG у додатку, який його створив +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it цей файл PostScript неможливо прочитати ({why}): збережіть його як PDF або SVG у додатку, який його створив @msg this file's PostScript couldn't be read ({why}): {what} PostScript цього файлу неможливо прочитати ({why}): {what} @msg this format holds one artboard: export several as PDF or with document.exportForScreens цей формат містить одну монтажну область: експортуйте кілька як PDF або за допомогою document.exportForScreens @msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that це креслення DWG, яке неможливо прочитати: збережіть його з CAD як ASCII DXF і відкрийте його diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d9019c75..6830f7fe 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -29,14 +29,16 @@ private editing data. VectorCraft: - **Reads** the PDF page (vectors, text, images, colour, transparency, layers via optional content) and the *structure* of the editing data (below). Opens Illustrator EPS the same way through its own PostScript - Level 3 interpreter. + Level 3 interpreter. A `.ai` in PostScript form (versions 3 to 8, and what Rhino and other CAD apps + write) is its own editing data: it opens through the layers its program has, compared with its page + when the program runs, from the layers alone when it names its prolog without including it (#1027). - **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. - **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 not read. + 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). Closing the open-side issues: 10–20 h with users' files. @@ -78,5 +80,6 @@ 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` in PostScript form opens through its layers, also without its prolog (#1027) | | 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 8a81e01f..f27098dc 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 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 + 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). 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: legacy `.ai` without the prolog opens with its layers (#1027) | | 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 |