From ec43ac5a1edc0df39f5be433d00a1206f837b3ae Mon Sep 17 00:00:00 2001 From: Matthew Farrow Date: Thu, 8 Oct 2026 20:15:28 -0400 Subject: [PATCH 01/14] Read Illustrator EPS and .ai layers from their editing data An Illustrator EPS is a flattened page plus, after %%EOF, an editing copy of the document; a PDF-compatible .ai carries the same in its private data. Importing only the page lost layer names, order, visibility, hidden layers and hidden objects and hidden text. - eps: read the editing copy (Zstandard %AI24_DataStream, older zlib %AI9_DataStream) into layers, sublayers, groups, compound paths, clip groups and hidden objects, run by the existing PostScript interpreter. Falls back to the page, with a warning, when a shown layer uses an operator that isn't read or the art differs from the page by over 5%. - eps: read the text document (ate.rs) to place type on hidden layers and objects; the page's type is matched to the file's text objects by position (pieces of a line included) and kept with the nearest one when none claims it. Area type and type on a path are left out with a note. - pdf/engine: .ai files go through the same reader. - engine/cli: a file whose import left things out isn't overwritten unless asked (acknowledgeLoss, --replace-lossy). - eps: font names re-encoded with a * suffix resolve to their family. - i18n: es/it/ru for the new import messages; ROADMAP updated. Container and text-document formats were inferred from user-owned files; the Adobe file format specification was not read. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.toml | 2 + ROADMAP.md | 4 +- apps/vectorcraft-cli/src/main.rs | 13 +- crates/doc/src/clipnest.rs | 6 +- crates/engine/Cargo.toml | 2 + crates/engine/src/cmd/fileio/export.rs | 3 + crates/engine/src/cmd/fileio/load.rs | 60 +- crates/engine/src/cmd/fileio/mod.rs | 1 + crates/engine/src/cmd/fileio/save.rs | 3 + crates/engine/src/lib.rs | 9 + crates/engine/tests/import_fuzz.rs | 125 ++- crates/engine/tests/lossy_overwrite.rs | 86 ++ crates/eps/Cargo.toml | 1 + crates/eps/src/import/ate.rs | 541 +++++++++++++ crates/eps/src/import/graphics.rs | 98 ++- crates/eps/src/import/interp.rs | 38 +- crates/eps/src/import/mod.rs | 24 +- crates/eps/src/import/native.rs | 1011 ++++++++++++++++++++++++ crates/eps/src/import/tests_native.rs | 438 ++++++++++ crates/eps/src/lib.rs | 2 +- crates/metafile/src/import.rs | 2 +- crates/pdf/src/import.rs | 2 +- crates/pdf/src/lib.rs | 4 +- crates/pdf/src/pages.rs | 26 +- crates/pdf/src/tests_import_layers.rs | 20 + crates/ui-egui/src/i18n/es.tsv | 10 + crates/ui-egui/src/i18n/it.tsv | 10 + crates/ui-egui/src/i18n/ru.tsv | 10 + 28 files changed, 2526 insertions(+), 25 deletions(-) create mode 100644 crates/engine/tests/lossy_overwrite.rs create mode 100644 crates/eps/src/import/ate.rs create mode 100644 crates/eps/src/import/native.rs create mode 100644 crates/eps/src/import/tests_native.rs diff --git a/Cargo.toml b/Cargo.toml index 9dd725eb..f604adf5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,8 @@ skrifa = "0.47" harfrust = "0.13" linesweeper = "0.4" flate2 = { version = "1", default-features = false, features = ["rust_backend"] } +# Zstandard decoder (pure Rust, builds for wasm32 too): the editing copy an EPS carries of its art. +ruzstd = { version = "0.9", default-features = false, features = ["std"] } jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] } gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] } # CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG diff --git a/ROADMAP.md b/ROADMAP.md index 810e07a9..cabe0326 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -38,7 +38,7 @@ since 2026-10-01. | **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~4 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), Variables (data merge), 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, Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against Affinity's own thumbnails of 22 pinned public 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. Native `.ai` private data is out of scope by design; DWG has no open spec | +| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against Affinity's own thumbnails of 22 pinned public 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 layers, hidden objects and hidden type (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 | @@ -249,7 +249,7 @@ lossless SVG/PDF export of live effects with SVG filters, a documented JSON form command, gesture and dialog drivable by agents (MCP, CLI, control channel). ## Out of scope (by design or by law) -- **Native `.ai` private data:** it's undocumented. We read the 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. +- **Native `.ai` private data, beyond layer structure:** the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams) is not documented as a whole. We read only the part that is the legacy Illustrator file format: layers (names, order, colour, hidden), nested layers, groups, compound paths, clipping groups, hidden objects, and plain path/colour operators. It was worked out from files the contributors have the right to use; Adobe's published specification of that format was not used. Its Zstandard wrapper is the public [RFC 8878](https://www.rfc-editor.org/rfc/rfc8878) and the ASCII85 wrapper is PostScript Level 2's. Everything else in it (the app's private `%_` comments, its binary text document, live effects, brushes, gradients, images) is not read: such a file comes in as its page, with a warning, as before. Type that shows comes from the page, which draws it; type that doesn't (hidden layers and objects, art off the page) is made from the file's text document (characters, font, size, colour, alignment and where it is, for point type: the keys were checked against the page's own type in the owner's files and are used only where they matched), and area type and type on a path in it are left out with a note. 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. Each check is a render comparison against the page, which rejects the layers if they differ by more than 5%. The importer was built from public documentation and from files users own, used locally and never committed; no Adobe software was run. 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. - **Adobe cloud services** (Libraries sync, Adobe Fonts, Firefly/generative): these are pluggable provider APIs, not built in. - **Adobe's bundled assets** (swatch/brush/symbol libraries, presets, icons): ours are original. diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 3e3e99ca..9e327c68 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] //! 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] +//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--replace-lossy] //! vectorcraft-cli info FILE //! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5] //! vectorcraft-cli perf [--paths 50000] @@ -71,12 +71,14 @@ USAGE: vectorcraft-cli commands Print the command catalogue as JSON. - vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] + vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--replace-lossy] 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. Live effects are kept, and hidden layers and objects (written hidden in SVG and PSD), with - SVG's data-* attributes; --outline-text writes SVG text as paths. + SVG's data-* attributes; --outline-text writes SVG text as paths. OUT may not be IN when reading + IN left things out (hidden text, art or layers it could not read; the notes say which) unless + --replace-lossy is given. vectorcraft-cli info FILE Print a JSON summary: the import warnings (what didn't come in as it was, such as an EPS @@ -173,7 +175,7 @@ fn commands() -> Result<(), String> { fn convert(args: &[String]) -> Result<(), String> { let mut files = vec![]; - let (mut scale, mut artboard, mut range, mut outline_text) = (1.0f64, None::, None::, false); + let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy) = (1.0f64, None::, None::, false, false); let mut it = args.iter(); while let Some(a) = it.next() { match a.as_str() { @@ -181,6 +183,7 @@ fn convert(args: &[String]) -> Result<(), String> { "--artboard" | "-a" => artboard = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--artboard needs an index")?), "--range" | "-r" => range = Some(it.next().cloned().ok_or("--range needs artboards such as 1-3,5")?), "--outline-text" => outline_text = true, + "--replace-lossy" => replace_lossy = true, f => files.push(f.to_string()), } } @@ -189,7 +192,7 @@ fn convert(args: &[String]) -> Result<(), String> { h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?; // A conversion keeps hidden layers and objects, written hidden, where the format can (SVG, // PSD). - let params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true}); + let params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy}); let r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?; outln!("{r}"); Ok(()) diff --git a/crates/doc/src/clipnest.rs b/crates/doc/src/clipnest.rs index 64d5c3f4..2b6c6980 100644 --- a/crates/doc/src/clipnest.rs +++ b/crates/doc/src/clipnest.rs @@ -21,6 +21,8 @@ pub struct Clip { pub id: u32, /// The region and its rule; `None` for a plain group. pub region: Option<(BezPath, FillRule)>, + /// The group is hidden (its art is in it, not shown). + pub hidden: bool, } /// Deepest nesting of clips, and of groups, an importer records ([`nest`] goes one level deeper @@ -78,5 +80,7 @@ fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize) -> Arc Result { pub(super) fn export(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path"); + if let Some(path) = path { + super::check_not_lossy_overwrite(s.doc()?, path, p, "document.export")?; + } let f = writable("document.export", str_param(p, "format"), path)?; let expanded = expand_presets(s, "document.export", p)?; let p = &*expanded; diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 810e8ebb..788d3420 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -133,6 +133,23 @@ pub fn load(name: &str, bytes: &[u8]) -> Result { load_with(name, bytes, &LoadOptions::default()) } +/// A PDF-compatible `.ai` read through the editor's own copy of its art, which it carries: its +/// layers and hidden objects as they were, and the art outside its artboard (its PDF part has +/// only what is on the artboards), as an EPS of the editor is read. Anything else as it was. +fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec) -> (Document, Vec) { + if opts.pages.is_some() || !opts.layers { + return (doc, warnings); + } + let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings) }; + let (doc, mut warnings) = vectorcraft_eps::layered_ai(&private, doc, warnings); + // The note that the art outside the artboard is lost is the reason the layers weren't read. + if warnings.iter().any(|w| w.starts_with("the file's layers weren't read from its editing data")) { + return (doc, warnings); + } + warnings.retain(|w| w != vectorcraft_pdf::OFF_ARTBOARD_NOTE); + (doc, warnings) +} + /// [`load`] with `document.open` options (the PDF pages, box and password; the DXF options). pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result { let format = detect(name, bytes).ok_or_else(|| match super::unsupported(&super::extension(name)) { @@ -171,7 +188,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result .then(|| vectorcraft_pdf::editing_with(bytes, opts.password.as_deref())) .flatten() .map(|e| (e.intact, move || Some(e.data))); - restore_or_import(editing, || super::pdfimport::import(bytes, opts))? + restore_or_import(editing, || { + let (doc, warnings) = super::pdfimport::import(bytes, opts)?; + Ok(through_editing_data(bytes, opts, doc, warnings)) + })? } "dxf" => { let (doc, warnings) = super::dxfimport::import(bytes, &opts.dxf)?; @@ -244,6 +264,9 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st /// Make a loaded file the new active document; `opts` are the options it was read with. fn open_loaded(s: &mut Session, loaded: Loaded, path: Option, opts: &LoadOptions, as_template: bool) -> Result { let Loaded { mut doc, format, warnings, restored, converted, .. } = loaded; + // What reading the file left out of the document, if anything. + let losses: Vec = warnings.iter().filter(|w| is_loss(w)).cloned().collect(); + let source_path = path.clone().filter(|_| !losses.is_empty()); let links = crate::cmd::links::resolve(&mut doc, path.as_deref(), s.prefs.update_links == "automatically"); // A template (saved by Save as Template, or an .ait/.vctemplate file) opens as a new untitled // document. @@ -266,6 +289,8 @@ fn open_loaded(s: &mut Session, loaded: Loaded, path: Option, opts: &Loa st.format = format.id; } st.converted = converted; + st.imported_from = source_path; + st.import_losses = losses; let title = s.documents()[index].title(); Ok(super::merge(json!({ "index": index, "title": title, "format": format.id, "warnings": warnings, "restored": restored }), links.to_json())) } @@ -360,3 +385,36 @@ pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result { d.insert(Some(layer), 0, n).map_err(err)?; Ok(d) } + +/// Does this import note say that the file has something the document doesn't? +fn is_loss(note: &str) -> bool { + note == vectorcraft_pdf::OFF_ARTBOARD_NOTE || vectorcraft_eps::is_loss(note) +} + +/// Writing the file `target` for document `st`: not over the file `st` was read from when reading +/// it left something out (hidden text, art or layers), unless `p` has `acknowledgeLoss: true`. +/// Another name keeps the original file, with all it has. +pub(crate) fn check_not_lossy_overwrite(st: &crate::DocState, target: &str, p: &Value, cmd: &str) -> Result<()> { + let Some(source) = st.imported_from.as_deref().filter(|_| !st.import_losses.is_empty()) else { return Ok(()) }; + if bool_or(p, "acknowledgeLoss", false) || !same_file(source, target) { + return Ok(()); + } + let mut what = st.import_losses.iter().take(3).map(|n| format!("“{n}”")).collect::>().join("; "); + if st.import_losses.len() > 3 { + what.push_str(&format!("; {} more", st.import_losses.len() - 3)); + } + Err(bad( + cmd, + format!( + "{target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway" + ), + )) +} + +/// Do `a` and `b` name one file (spelled differently, or through a link)? +fn same_file(a: &str, b: &str) -> bool { + match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => a == b, + } +} diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 66b35567..dad634b6 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -47,6 +47,7 @@ pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, enc pub(crate) use encode::{anti_alias, background, with_single_artboard}; pub use export::export_source; pub(crate) use export::isolated; +pub(crate) use load::check_not_lossy_overwrite; use load::err; pub(crate) use load::import_svg; pub(crate) use load::native_file; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 70fad078..94b6571a 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -232,6 +232,9 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { _ => st.path.as_deref().and_then(parent_folder), }; let modified = date_param(p, "modified", cmd)?; + if let Some(path) = &path { + super::check_not_lossy_overwrite(st, path, p, cmd)?; + } Ok(SavePlan { mode, path, format, options, name, folder, modified }) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index de2b512f..f211ab09 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -155,6 +155,11 @@ pub struct DocState { pub print_tiling: bool, /// The rows open in the Layers panel (view state: not undoable; native files keep it). pub layers_open: OpenRows, + /// The file this document was read from, and what reading it left out (hidden text, art or + /// layers it could not read): writing the document over that file would lose those for good, + /// so an export or save to it asks first ([`cmd::fileio::check_not_lossy_overwrite`]). Not saved. + pub imported_from: Option, + pub import_losses: Vec, /// Transform Again after a perspective move or scale (Perspective Selection tool): the /// `perspective.transform` params it repeats. `None` once an ordinary transform follows. pub last_perspective: Option, @@ -243,6 +248,8 @@ impl DocState { recovery: None, print_tiling: false, layers_open, + imported_from: None, + import_losses: vec![], last_perspective: None, } } @@ -1016,6 +1023,8 @@ impl Session { st.format = old.format; st.save_options = old.save_options.clone(); st.converted = old.converted; + st.imported_from = old.imported_from.clone(); + st.import_losses = old.import_losses.clone(); st.view = old.view.clone(); st.layers_open = old.layers_open.clone(); let old = std::mem::replace(&mut self.docs[index], st); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 8b639600..9cffd214 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -4,7 +4,8 @@ //! must load as an error or as a document that then renders and exports, without a panic; nor may //! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records //! of every kind with random contents) opened, placed or pasted, nor EPS and PostScript files -//! (damaged ones, hostile programs) read by the PostScript interpreter, nor Affinity documents +//! (damaged ones, hostile programs) read by the PostScript interpreter, nor the editing data of +//! Illustrator EPS and `.ai` files (the layers they carry, damaged or hostile), nor Affinity documents //! (mutated object streams, archives and indexed PNG previews, hostile image dimensions). //! //! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test import_fuzz` runs a deeper search. @@ -1584,3 +1585,125 @@ proptest! { perspective_presets("mutated perspective presets", &mutate_text(&text, cut, &edits))?; } } + +// ---------- the editing data of Illustrator files ---------- + +/// `data` as ASCII85, up to the `~>`. +fn ascii85(data: &[u8]) -> String { + let mut out = String::new(); + for chunk in data.chunks(4) { + let mut x = chunk.iter().enumerate().fold(0u32, |v, (i, b)| v | u32::from(*b) << (24 - 8 * i)); + let mut d = [0u8; 5]; + for c in d.iter_mut().rev() { + *c = (x % 85) as u8 + b'!'; + x /= 85; + } + out.extend(d.iter().take(chunk.len() + 1).map(|c| char::from(*c))); + } + out.push_str("~>"); + out +} + +/// 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); + let body = [ + "u".to_string(), + square(10), + "U".into(), + "*u".into(), + square(20), + square(30), + "*U".into(), + "q".into(), + square(40), + "50 10 m 60 10 L 60 20 L 50 20 L h W f".into(), + "Q".into(), + "1 Xw".into(), + "u".into(), + "/AI11Text :\n0 /FreeUndo ,\n;".into(), + "U".into(), + "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(), + ] + .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", + layer("One", 1, &format!("{body}\n{}", layer("Sub", 1, &square(70)))), + layer("Two", 0, &square(80)) + ) +} + +fn zstd(text: &str) -> Vec { + ruzstd::encoding::compress_to_vec(text.as_bytes(), ruzstd::encoding::CompressionLevel::Fastest) +} + +/// An EPS drawing a square whose private data holds `editing`. +fn editing_eps(editing: &str) -> Vec { + let lines: Vec = ascii85(&zstd(editing)).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect(); + format!( + "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n0 0 1 0 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto closepath fill\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI24_DataStream\n{}\n%AI9_PrivateDataEnd\n", + lines.join("\n") + ) + .into_bytes() +} + +/// The text document of a story of centred type with two styles, as an editing copy keeps it. +fn text_document_text() -> String { + "/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ << /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 3 4 ] >> >> >> ] >> >>\n\ + /1 << /1 [ << /0 << /0 (Hi\\rthere\\r) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 2 >> /6 0 >> >> /1 9 >> ] >> \ + /6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /1 9 >> ] >> >> \ + /1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ 8200.0 8180.0 ] >> /6 [ << /99 /R /6 [ << /99 /R /6 [ << /99 /L /6 [ \ + << /99 /S /0 << /0 [ -5.0 0.0 ] >> /15 << /0 3 >> >> ] >> << /99 /L /0 << /0 [ 0.0 16.8 ] >> /6 [ << /99 /S /0 << /0 [ -9.0 0.0 ] >> /15 << /0 6 >> >> ] >> ] >> ] >> ] >> ] >> ] >> >> ] /2 << /1 12.0 >> >>\n" + .to_string() +} + +/// An EPS with a hidden text object whose story is in `document`. +fn text_eps(document: &str) -> Vec { + let lines: Vec = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect(); + let editing = 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%AI3_TemplateBox: 50 50 50 50\n\ + %AI5_BeginLayer\n0 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Spare) Ln\n/AI11Text :\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\nLB\n%AI5_EndLayer--\n\ + %AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n%%Trailer\n", + lines.join("\n") + ); + editing_eps(&editing) +} + +proptest! { + #![proptest_config(config())] + + /// An Illustrator EPS whose text document is damaged or hostile: read as its type, or without it. + #[test] + fn eps_text_document_never_panics(cut in 0usize..1_500, edits in prop::collection::vec((0usize..1_500, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '[', ']', '<', '>', '\\', 'e', '1'])), 0..12)) { + let text = mutate_text(&text_document_text(), cut, &edits); + let bytes = text_eps(&text); + survive("mutated EPS text document", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?; + } + + /// An Illustrator EPS whose editing data is damaged or hostile: read as its layers, or as its page. + #[test] + fn eps_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'k', 'x'])), 0..12)) { + let text = mutate_text(&editing_text(), cut, &edits); + let bytes = editing_eps(&text); + survive("mutated EPS editing data", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?; + } + + /// A `.ai` whose editing data is damaged or hostile. + #[test] + fn ai_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'k', 'x'])), 0..12), damage in prop::collection::vec((0usize..4_000, any::()), 0..4)) { + let text = mutate_text(&editing_text(), cut, &edits); + let mut private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + for (at, b) in damage { + let n = private.len(); + if let Some(x) = private.get_mut(at % n) { + *x = b; + } + } + survive("mutated .ai editing data", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![]).0))?; + } +} diff --git a/crates/engine/tests/lossy_overwrite.rs b/crates/engine/tests/lossy_overwrite.rs new file mode 100644 index 00000000..45820902 --- /dev/null +++ b/crates/engine/tests/lossy_overwrite.rs @@ -0,0 +1,86 @@ +//! A file whose import left something out isn't written over silently: its hidden text, art or +//! layers would be gone for good. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::json; +use vectorcraft_engine::Session; + +fn ascii85(data: &[u8]) -> String { + let mut out = String::new(); + for chunk in data.chunks(4) { + let mut x = chunk.iter().enumerate().fold(0u32, |v, (i, b)| v | u32::from(*b) << (24 - 8 * i)); + let mut d = [0u8; 5]; + for c in d.iter_mut().rev() { + *c = (x % 85) as u8 + b'!'; + x /= 85; + } + out.extend(d.iter().take(chunk.len() + 1).map(|c| char::from(*c))); + } + out.push_str("~>"); + out +} + +/// A square that is gradient-filled in the layers (which can't be read) and flat on the page: the +/// file comes in as its page, and its hidden layer is left out. +fn lossy_eps() -> Vec { + 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") + }; + let editing = 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", + layer("Art", 1, "1 0 0 0 1 0 Bg\n0 0 1 0 k\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf"), + layer("Hidden", 0, "0 0 1 0 k\n50 50 m\n54 50 L\n54 54 L\n50 54 L\nf"), + ); + let packed = ruzstd::encoding::compress_to_vec(editing.as_bytes(), ruzstd::encoding::CompressionLevel::Fastest); + let lines: Vec = ascii85(&packed).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect(); + format!( + "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n0 0 1 0 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto 10 14 lineto closepath fill\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI24_DataStream\n{}\n%AI9_PrivateDataEnd\n", + lines.join("\n") + ) + .into_bytes() +} + +fn dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("vc-lossy-{name}-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d +} + +#[test] +fn a_file_the_import_left_things_out_of_is_not_written_over_unasked() { + let d = dir("a"); + let src = d.join("art.eps"); + let original = lossy_eps(); + std::fs::write(&src, &original).unwrap(); + let mut s = Session::new(); + let opened = s.execute("document.open", &json!({ "path": src.to_string_lossy() })).unwrap(); + assert!(opened["warnings"].to_string().contains("layers weren't read"), "{opened}"); + + // Over the same file: refused, and the file is as it was. + let e = s.execute("document.export", &json!({ "path": src.to_string_lossy(), "format": "eps" })).unwrap_err().to_string(); + assert!(e.contains("left things out") && e.contains("acknowledgeLoss"), "{e}"); + assert_eq!(std::fs::read(&src).unwrap(), original); + // However the path is spelled. + let spelled = d.join(".").join("art.eps"); + assert!(s.execute("document.export", &json!({ "path": spelled.to_string_lossy(), "format": "eps" })).is_err()); + + // Another name is fine, and so is replacing the file when asked to. + let other = d.join("art copy.eps"); + s.execute("document.export", &json!({ "path": other.to_string_lossy(), "format": "eps" })).unwrap(); + assert!(other.exists()); + s.execute("document.export", &json!({ "path": src.to_string_lossy(), "format": "eps", "acknowledgeLoss": true })).unwrap(); + assert_ne!(std::fs::read(&src).unwrap(), original); + std::fs::remove_dir_all(d).ok(); +} + +#[test] +fn a_file_the_import_read_whole_is_written_over_as_before() { + let d = dir("b"); + let src = d.join("plain.eps"); + std::fs::write(&src, "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n0 0 1 0 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto closepath fill\nshowpage\n%%EOF\n").unwrap(); + let mut s = Session::new(); + s.execute("document.open", &json!({ "path": src.to_string_lossy() })).unwrap(); + s.execute("document.export", &json!({ "path": src.to_string_lossy(), "format": "eps" })).unwrap(); + std::fs::remove_dir_all(d).ok(); +} diff --git a/crates/eps/Cargo.toml b/crates/eps/Cargo.toml index 00132e66..8ce8fbbe 100644 --- a/crates/eps/Cargo.toml +++ b/crates/eps/Cargo.toml @@ -22,6 +22,7 @@ vectorcraft-render = { workspace = true } kurbo = { workspace = true } image = { workspace = true } flate2 = { workspace = true } +ruzstd = { workspace = true } [dev-dependencies] vectorcraft-testkit = { workspace = true } diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs new file mode 100644 index 00000000..1e1a66dd --- /dev/null +++ b/crates/eps/src/import/ate.rs @@ -0,0 +1,541 @@ +//! The text of an Illustrator editing copy. +//! +//! The editing copy of a file keeps the characters of its text objects, with their fonts, sizes, +//! colours and where they sit, in one text document of its own: between `%AI11_BeginTextDocument` +//! and `%AI11_EndTextDocument`, ASCII85 of nested dictionaries with numbers for keys. A text object +//! in a layer is only a stub naming its story in it (`/StoryIndex`). +//! +//! The format isn't documented. What is read here was worked out from the files of the project's +//! users, used locally and never committed, by comparing it with the type their pages draw: the +//! meaning of each key below was checked against the page's own type in more than a thousand text +//! objects, and it is only used for the keys that matched. A story the reader doesn't recognise (area +//! type, type on a path, anything with a frame that has more than a point and a matrix) is left +//! alone: it has no text, and the importer says so. +//! +//! Where type sits: a story lays its lines out round a point `F` (the anchor: the start of a line of +//! left-aligned type, the middle of a centred one, the end of a right-aligned one), each line with an +//! offset from it. The frame of the story gives a matrix that carries that layout onto the canvas of +//! the app, which is 16383 points a side; the canvas's centre, 8191.5, is the centre of the file's +//! `%AI3_TemplateBox`. See [`Story::place`]. + +use std::collections::BTreeMap; + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaStyle, TextObject, TextRun}; +use vectorcraft_geom::{Affine, Point}; + +/// Biggest text document read (bytes, decoded). +const MAX_BYTES: usize = 64 << 20; +/// Deepest nesting read. +const MAX_DEPTH: usize = 64; +/// Most values read. +const MAX_VALUES: usize = 4_000_000; +/// The centre of the app's canvas, in the units of its text document. +const CANVAS_CENTRE: f64 = 8191.5; + +/// A value of the text document. +#[derive(Debug, Clone, PartialEq)] +pub(super) enum Val { + Num(f64), + Str(String), + Name(String), + Bool(bool), + List(Vec), + Dict(BTreeMap), +} + +impl Val { + fn get(&self, key: &str) -> Option<&Val> { + match self { + Val::Dict(d) => d.get(key), + _ => None, + } + } + + fn at(&self, i: usize) -> Option<&Val> { + match self { + Val::List(l) => l.get(i), + _ => None, + } + } + + fn num(&self) -> Option { + match self { + Val::Num(n) if n.is_finite() => Some(*n), + _ => None, + } + } + + fn str(&self) -> Option<&str> { + match self { + Val::Str(s) => Some(s), + _ => None, + } + } + + fn list(&self) -> &[Val] { + match self { + Val::List(l) => l, + _ => &[], + } + } + + fn nums(&self) -> Option> { + self.list().iter().map(Val::num).collect() + } + + /// The `name` entry of a dictionary that is a typed node (`/99 /name`). + fn is_node(&self, name: &str) -> bool { + matches!(self.get("99"), Some(Val::Name(n)) if n == name) + } +} + +struct Reader<'a> { + b: &'a [u8], + at: usize, + budget: usize, +} + +impl Reader<'_> { + fn skip_space(&mut self) { + while self.b.get(self.at).is_some_and(u8::is_ascii_whitespace) { + self.at += 1; + } + } + + fn value(&mut self, depth: usize) -> Option { + self.skip_space(); + self.budget = self.budget.checked_sub(1)?; + let c = *self.b.get(self.at)?; + match c { + b'<' if self.b.get(self.at + 1) == Some(&b'<') => { + if depth > MAX_DEPTH { + return None; + } + self.at += 2; + let mut d = BTreeMap::new(); + loop { + self.skip_space(); + if self.b.get(self.at..self.at + 2) == Some(b">>") { + self.at += 2; + return Some(Val::Dict(d)); + } + let key = self.key()?; + let v = self.value(depth + 1)?; + d.insert(key, v); + } + } + b'[' => { + if depth > MAX_DEPTH { + return None; + } + self.at += 1; + let mut l = vec![]; + loop { + self.skip_space(); + if self.b.get(self.at) == Some(&b']') { + self.at += 1; + return Some(Val::List(l)); + } + l.push(self.value(depth + 1)?); + } + } + b'(' => self.string(), + b'/' => Some(Val::Name(self.key()?)), + _ => { + let start = self.at; + while self.b.get(self.at).is_some_and(|c| !c.is_ascii_whitespace() && !matches!(c, b'<' | b'>' | b'[' | b']' | b'(' | b')' | b'/')) { + self.at += 1; + } + let word = std::str::from_utf8(self.b.get(start..self.at)?).ok()?; + match word { + "true" => Some(Val::Bool(true)), + "false" => Some(Val::Bool(false)), + "" => None, + w => Some(Val::Num(w.parse().ok()?)), + } + } + } + } + + /// A `/name`, without its slash. + fn key(&mut self) -> Option { + self.skip_space(); + if self.b.get(self.at) != Some(&b'/') { + return None; + } + self.at += 1; + let start = self.at; + while self.b.get(self.at).is_some_and(|c| !c.is_ascii_whitespace() && !matches!(c, b'<' | b'>' | b'[' | b']' | b'(' | b')' | b'/')) { + self.at += 1; + } + Some(String::from_utf8_lossy(self.b.get(start..self.at)?).into_owned()) + } + + /// A string: up to the first `)` that isn't escaped; UTF-16 when it starts with a byte order mark. + fn string(&mut self) -> Option { + self.at += 1; + let mut bytes = vec![]; + loop { + let c = *self.b.get(self.at)?; + self.at += 1; + match c { + b')' => break, + b'\\' => { + let e = *self.b.get(self.at)?; + self.at += 1; + bytes.push(match e { + b'n' => b'\n', + b'r' => b'\r', + b't' => b'\t', + b'b' => 8, + b'f' => 12, + b'0'..=b'7' => { + let mut v = u32::from(e - b'0'); + for _ in 0..2 { + match self.b.get(self.at) { + Some(d @ b'0'..=b'7') => { + v = v * 8 + u32::from(d - b'0'); + self.at += 1; + } + _ => break, + } + } + (v & 0xff) as u8 + } + other => other, + }); + } + c => bytes.push(c), + } + if bytes.len() > MAX_BYTES { + return None; + } + } + Some(Val::Str(match bytes.strip_prefix(&[0xfe, 0xff]) { + Some(rest) => { + let units: Vec = rest.as_chunks::<2>().0.iter().map(|p| u16::from_be_bytes(*p)).collect(); + String::from_utf16_lossy(&units) + } + None => bytes.iter().map(|b| char::from(*b)).collect(), + })) + } +} + +/// The values of a text document: its top-level `/key value` pairs. +fn read(bytes: &[u8]) -> Option { + let mut r = Reader { b: bytes, at: 0, budget: MAX_VALUES }; + let mut d = BTreeMap::new(); + loop { + r.skip_space(); + if r.at >= bytes.len() { + return Some(Val::Dict(d)); + } + let key = r.key()?; + let v = r.value(0)?; + d.insert(key, v); + } +} + +/// How a paragraph's lines sit against its anchor. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Align { + Left, + Center, + Right, +} + +/// The text of one story, in the units of the text document. +#[derive(Debug)] +pub(super) struct Story { + /// The characters, with `\r` ending paragraphs and `\x03` ending lines. + text: String, + /// The anchor of the layout. + anchor: (f64, f64), + /// Where each line starts, from the anchor. + lines: Vec<(f64, f64)>, + /// Carries the layout onto the canvas. + matrix: [f64; 6], + align: Align, + /// `(characters, style)` in order. + runs: Vec<(usize, Style)>, +} + +#[derive(Clone, Debug)] +struct Style { + font: Option, + size: f64, + fill: Option<[f64; 4]>, + stroke: Option<[f64; 4]>, + stroke_width: f64, +} + +/// The text document of an editing copy. +pub(super) struct Texts { + stories: Vec, + fonts: Vec, + /// The character style a story starts from. + defaults: Val, + frames: Vec, +} + +impl Texts { + /// The text document in the editing copy `data`, if it has one that can be read. + pub(super) fn read(data: &[u8]) -> Option { + fn find(h: &[u8], n: &[u8], from: usize) -> Option { + h.get(from..)?.windows(n.len()).position(|w| w == n).map(|p| p + from) + } + let start = find(data, b"%AI11_BeginTextDocument", 0)?; + let from = find(data, b"/ASCII85Decode", start)? + b"/ASCII85Decode".len(); + let end = find(data, b"~>", from)? + 2; + // Each line after the first starts with a `%`. + let packed: String = String::from_utf8_lossy(data.get(from..end)?) + .split(['\r', '\n']) + .enumerate() + .map(|(i, l)| if i > 0 { l.strip_prefix('%').unwrap_or(l) } else { l }) + .collect::() + .trim_start_matches([' ', ',']) + .to_string(); + let bytes = crate::ps::ascii85_decode(&packed)?; + if bytes.len() > MAX_BYTES { + return None; + } + let doc = read(&bytes)?; + let fonts = doc + .get("0")? + .get("1")? + .get("0")? + .list() + .iter() + .map(|f| f.get("0").and_then(|f| f.get("0")).and_then(|f| f.get("0")).and_then(Val::str).unwrap_or_default().to_string()) + .collect(); + let main = doc.get("1")?; + Some(Texts { + stories: main.get("1")?.list().to_vec(), + fonts, + defaults: main.get("2")?.clone(), + frames: doc.get("0")?.get("8")?.get("0")?.list().to_vec(), + }) + } + + /// Story `i`, if it is one of point type this reads. + pub(super) fn story(&self, i: usize) -> Option { + let s = self.stories.get(i)?; + let text = s.get("0")?.get("0")?.str()?.to_string(); + // The frame the story is in: a matrix and nothing else but its place. + let frame = self.frames.get(s.get("1")?.get("0")?.at(0)?.get("0")?.num()? as usize)?.get("0")?; + let carries = frame.get("2")?; + if carries.get("2").is_none() || carries.as_dict().is_some_and(|d| d.keys().any(|k| k != "2")) { + return None; + } + let matrix: [f64; 6] = carries.get("2")?.nums()?.try_into().ok()?; + let f = find_node(s.get("1")?.get("2")?, "F")?; + let anchor = f.get("0")?.get("0")?.nums()?; + let anchor = (*anchor.first()?, *anchor.get(1)?); + let mut lines = vec![]; + let mut found = vec![]; + find_nodes(f, "L", &mut found); + for l in found { + let off = l.get("0").and_then(|o| o.get("0")).and_then(Val::nums).unwrap_or_else(|| vec![0.0, 0.0]); + let seg = l.get("6")?.list().iter().find(|c| c.is_node("S"))?; + let so = seg.get("0").and_then(|o| o.get("0")).and_then(Val::nums).unwrap_or_else(|| vec![0.0, 0.0]); + lines.push((off.first()? + so.first()?, *off.get(1)?)); + } + if lines.is_empty() { + return None; + } + let align = match s + .get("0") + .and_then(|t| t.get("5")) + .and_then(|p| p.get("0")) + .and_then(|p| p.at(0)) + .and_then(|p| p.get("0")) + .and_then(|p| p.get("0")) + .and_then(|p| p.get("5")) + .and_then(|p| p.get("0")) + .and_then(Val::num) + { + Some(1.0) => Align::Right, + Some(2.0) => Align::Center, + _ => Align::Left, + }; + let mut runs = vec![]; + for run in s.get("0")?.get("6")?.get("0")?.list() { + let n = run.get("1")?.num()? as usize; + let style = run.get("0")?.get("0")?.get("6")?; + runs.push((n, self.style(style))); + } + Some(Story { text, anchor, lines, matrix, align, runs }) + } + + /// A character style: its own keys, else the document's. + fn style(&self, s: &Val) -> Style { + let pick = |k: &str| s.get(k).or_else(|| self.defaults.get(k)); + let paint = |k: &str| -> Option<[f64; 4]> { + let p = pick(k)?.get("0")?; + // Kind 2 is CMYK: an opacity, then the four inks. + (p.get("0")?.num()? == 2.0).then(|| p.get("1")?.nums()).flatten().and_then(|v| Some([*v.get(1)?, *v.get(2)?, *v.get(3)?, *v.get(4)?])) + }; + let on = |k: &str| !matches!(pick(k), Some(Val::Bool(false))); + let font = pick("0").and_then(Val::num).and_then(|i| self.fonts.get(i as usize)).filter(|f| !f.is_empty()).cloned(); + Style { + font, + size: pick("1").and_then(Val::num).filter(|v| *v > 0.0).unwrap_or(12.0), + fill: if on("56") { Some(paint("53").unwrap_or([0.0, 0.0, 0.0, 1.0])) } else { None }, + stroke: if matches!(pick("57"), Some(Val::Bool(true))) { paint("54") } else { None }, + stroke_width: pick("63").and_then(Val::num).filter(|v| *v >= 0.0).unwrap_or(1.0), + } + } +} + +impl Val { + fn as_dict(&self) -> Option<&BTreeMap> { + match self { + Val::Dict(d) => Some(d), + _ => None, + } + } +} + +fn find_node<'a>(v: &'a Val, name: &str) -> Option<&'a Val> { + let mut out = vec![]; + find_nodes(v, name, &mut out); + out.into_iter().next() +} + +/// The typed nodes called `name` in `v`, in order, outermost first. +fn find_nodes<'a>(v: &'a Val, name: &str, out: &mut Vec<&'a Val>) { + match v { + Val::Dict(d) => { + if v.is_node(name) { + out.push(v); + } + for x in d.values() { + find_nodes(x, name, out); + } + } + Val::List(l) => { + for x in l { + find_nodes(x, name, out); + } + } + _ => {} + } +} + +/// Where a story's first line starts: on the canvas of the app (what [`Story::place`] gives). +impl Story { + /// The anchor of the story on the file's canvas: `(x, y)` in the units of the art, y up, given + /// the centre `(tx, ty)` of the file's template box. + pub(super) fn place(&self, template: (f64, f64)) -> (f64, f64) { + let [a, b, c, d, e, f] = self.matrix; + let (x, y) = (a * self.anchor.0 + c * self.anchor.1 + e, b * self.anchor.0 + d * self.anchor.1 + f); + (x - CANVAS_CENTRE + template.0, CANVAS_CENTRE + template.1 - y) + } + + /// Each line's characters and where it starts (in the units of the art, y up): where the page + /// draws the line when the type is shown. + pub(super) fn line_starts(&self, template: (f64, f64)) -> Vec<(String, (f64, f64))> { + let [a, b, c, d, e, f] = self.matrix; + let text: Vec<&str> = self.text.split(['\r', '\x03']).collect(); + self.lines + .iter() + .enumerate() + .map(|(i, (lx, ly))| { + let (x, y) = (self.anchor.0 + lx, self.anchor.1 + ly); + let (x, y) = (a * x + c * y + e, b * x + d * y + f); + (text.get(i).copied().unwrap_or_default().to_string(), (x - CANVAS_CENTRE + template.0, CANVAS_CENTRE + template.1 - y)) + }) + .collect() + } + + /// The story as a text object whose anchor is at `at` on the document (`to_doc` takes the art's + /// units onto it), or `None` for type that is mirrored or has no size. + pub(super) fn node(&self, id: NodeId, template: (f64, f64), to_doc: Affine) -> Option { + let [a, b, c, d, ..] = self.matrix; + let scale = (a * d - b * c).abs().sqrt(); + if !(scale.is_finite() && scale > 1e-6 && a * d - b * c > 0.0) { + return None; + } + let (x, y) = self.place(template); + let at = to_doc * Point::new(x, y); + let text: String = self.text.trim_end_matches('\r').chars().map(|c| if matches!(c, '\r' | '\x03') { '\n' } else { c }).collect(); + if text.trim().is_empty() { + return None; + } + let leading = self.lines.windows(2).map(|w| (w[1].1 - w[0].1) * scale).find(|l| l.is_finite() && *l > 0.0); + let chars: Vec = text.chars().collect(); + let mut runs = vec![]; + let mut from = 0; + let styles: Vec<&(usize, Style)> = self.runs.iter().collect(); + for (n, st) in styles { + let to = (from + n).min(chars.len()); + let piece: String = chars.get(from..to).unwrap_or_default().iter().collect(); + from = to; + if piece.is_empty() { + continue; + } + runs.push(TextRun { text: piece, style: char_style(st, scale, leading) }); + } + if from < chars.len() { + let last = self.runs.last().map(|r| &r.1)?; + runs.push(TextRun { text: chars.get(from..).unwrap_or_default().iter().collect(), style: char_style(last, scale, leading) }); + } + if runs.is_empty() { + return None; + } + let mut t = TextObject::point(Point::ZERO, "", runs.first()?.style.clone()); + t.runs = runs; + t.para = ParaStyle { + justify: match self.align { + Align::Left => Justify::Left, + Align::Center => Justify::Center, + Align::Right => Justify::Right, + }, + ..ParaStyle::default() + }; + t.xf = Affine::new([a / scale, b / scale, c / scale, d / scale, at.x, at.y]); + Some(Node::new(id, NodeKind::Text(Box::new(t)))) + } +} + +fn char_style(s: &Style, scale: f64, leading: Option) -> CharStyle { + let (family, style) = s.font.as_deref().map_or_else(|| (CharStyle::default().font_family, "Regular".to_string()), crate::family_style); + let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); + let ink = |c: [f64; 4]| Paint::solid(Color::cmyk(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32)); + CharStyle { + font_family: family, + font_style: style, + size: s.size * scale, + leading, + fill: s.fill.map_or(Paint::None, ink), + stroke: s.stroke.map_or(Paint::None, ink), + stroke_width: if s.stroke.is_some() { s.stroke_width * scale } else { 0.0 }, + ..CharStyle::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn values_read_nested_dictionaries_lists_and_utf16_strings() { + let v = read(b" /1 << /0 [ 1 2.5 -3 ] /2 (\xfe\xff\x00h\x00i) /3 true /99 /Name >> /0 (a\\)b\\n)").unwrap(); + let one = v.get("1").unwrap(); + assert_eq!(one.get("0").unwrap().nums(), Some(vec![1.0, 2.5, -3.0])); + assert_eq!(one.get("2").unwrap().str(), Some("hi")); + assert_eq!(one.get("3"), Some(&Val::Bool(true))); + assert!(one.is_node("Name")); + assert_eq!(v.get("0").unwrap().str(), Some("a)b\n")); + } + + #[test] + fn damaged_documents_are_not_read() { + for bad in [&b"/0 << /1"[..], b"/0 [ 1 2", b"/0 (abc", b"/0 nope", b"0 1"] { + assert!(read(bad).is_none(), "{}", String::from_utf8_lossy(bad)); + } + let deep = format!("/0 {}", "[".repeat(200)); + assert!(read(deep.as_bytes()).is_none()); + } +} diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs index 2a882dfc..1fd8100c 100644 --- a/crates/eps/src/import/graphics.rs +++ b/crates/eps/src/import/graphics.rs @@ -153,6 +153,18 @@ impl Default for GState { } } +/// The name of the empty group that stands for a text object (see [`Out::text_slot`]), followed by +/// the number of its story in the text document when the file says. +pub(crate) const TEXT_SLOT: &str = "\u{0}text"; + +/// Is `name` that of a text slot, and which story is it of? +pub(crate) fn slot_of(name: Option<&str>) -> Option> { + name?.strip_prefix(TEXT_SLOT).map(|rest| rest.parse().ok()) +} + +/// An object drawn, with its chain of clips and groups. +type DrawnItem = (Vec>, Node); + /// What the program draws. pub(crate) struct Out { /// The document being made: ids, images and swatches. @@ -169,6 +181,14 @@ pub(crate) struct Out { /// goes in the innermost group kept). groups: Vec>, too_deep: usize, + /// Illustrator clipping groups open (`q` … `Q`): the group's id (none when it opened past + /// [`MAX_NEST`]) and how many objects were drawn before it. + open_clips: Vec<(Option, usize)>, + /// Illustrator compound paths open (`*u` … `*U`): how many objects were drawn before each. + compounds: Vec<(usize, bool)>, + /// What the file says of the objects it draws now: hidden (Illustrator's `Xw`, a state it + /// changes only when the next object differs). + pub hidden: bool, /// Path elements (and the clips and groups of each object) drawn so far (see [`MAX_DRAWN`]). elements: usize, /// The path the last object was filled with: a stroke of the same path right after joins it. @@ -204,6 +224,9 @@ impl Out { next_clip: 0, groups: vec![], too_deep: 0, + open_clips: vec![], + compounds: vec![], + hidden: false, elements: 0, merge: None, cmyk: 0, @@ -229,7 +252,7 @@ impl Out { return; } self.next_clip += 1; - self.groups.push(Arc::new(Clip { id: self.next_clip, region: None })); + self.groups.push(Arc::new(Clip { id: self.next_clip, region: None, hidden: self.hidden })); } /// End the innermost group open (an end without a beginning ends nothing). @@ -241,6 +264,76 @@ impl Out { } } + /// Open an Illustrator clipping group (`q`): a group that [`Self::end_clip_group`] clips with + /// the clip path the file gives at its end. + pub fn begin_clip_group(&mut self) { + let deep = self.too_deep; + self.begin_group(); + let id = if self.too_deep == deep { self.groups.last().map(|c| c.id) } else { None }; + self.open_clips.push((id, self.drawn.len())); + } + + /// End the innermost clipping group open (`Q`), clipped to `region` when the file gave one + /// (without, it stays a plain group). An end without a beginning ends nothing. + pub fn end_clip_group(&mut self, region: Option<(BezPath, FillRule)>) { + let Some((id, start)) = self.open_clips.pop() else { return }; + let hidden = self.groups.last().is_some_and(|c| Some(c.id) == id && c.hidden); + self.end_group(); + let (Some(id), Some(region)) = (id, region) else { return }; + // The objects drawn in the group hold the group's clip as it was when they were drawn, + // without its region. + let clip = Arc::new(Clip { id, region: Some(region), hidden }); + for (chain, _) in self.drawn.iter_mut().skip(start) { + for c in chain.iter_mut().filter(|c| c.id == id) { + *c = clip.clone(); + } + } + } + + /// Open an Illustrator compound path (`*u`): the paths drawn until [`Self::end_compound`] are + /// its parts. + pub fn begin_compound(&mut self) { + self.compounds.push((self.drawn.len(), self.hidden)); + } + + /// End the innermost compound path open (`*U`): its parts become one compound path painted + /// with the first part's appearance (what Illustrator does). One part stays a plain path. + pub fn end_compound(&mut self) { + let Some((start, hidden)) = self.compounds.pop() else { return }; + let parts = self.drawn.get(start..).unwrap_or_default(); + let same_chain = |a: &DrawnItem, b: &DrawnItem| a.0.iter().map(|c| c.id).eq(b.0.iter().map(|c| c.id)); + let Some(first) = parts.first() else { return }; + if parts.len() < 2 || !parts.iter().all(|p| matches!(p.1.kind, NodeKind::Path { .. }) && same_chain(first, p)) { + return; + } + let parts = self.drawn.split_off(start); + let chain = parts.first().map(|p| p.0.clone()).unwrap_or_default(); + let (mut rule, mut appearance) = (FillRule::NonZero, Appearance::default()); + let mut children = Vec::with_capacity(parts.len()); + for (i, (_, mut part)) in parts.into_iter().enumerate() { + if i == 0 { + appearance = std::mem::take(&mut part.appearance); + if let NodeKind::Path { rule: r, .. } = &part.kind { + rule = *r; + } + } + part.appearance = Appearance::default(); + children.push(Arc::new(part)); + } + let mut compound = Node::new(self.doc.alloc_id(), NodeKind::Compound { children, rule }); + compound.appearance = appearance; + compound.visible = !hidden; + self.drawn.push((chain, compound)); + } + + /// Where a text object of the file's editing data goes: an empty group named [`TEXT_SLOT`], + /// which the importer replaces with the text it read from the file's page. + pub fn text_slot(&mut self, story: Option) { + let mut n = Node::group(NodeId(0), vec![]); + n.name = Some(story.map_or_else(|| TEXT_SLOT.to_string(), |s| format!("{TEXT_SLOT}{s}"))); + self.push(n, &[]); + } + /// The chain an object drawn under `clips` is drawn in: the clips and the groups open, in the /// order they opened. pub fn chain(&self, clips: &[Arc]) -> Vec> { @@ -272,6 +365,7 @@ impl Out { return None; } node.id = self.doc.alloc_id(); + node.visible = !self.hidden; let id = node.id; self.drawn.push((chain, node)); Some(id) @@ -326,7 +420,7 @@ impl Out { return None; } self.next_clip += 1; - Some(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)) })) + Some(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)), hidden: false })) } } diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 6104d1b5..4640ddd5 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -7,7 +7,7 @@ use std::cell::RefCell; use std::rc::Rc; -use vectorcraft_geom::Affine; +use vectorcraft_geom::{Affine, BezPath, FillRule}; use super::graphics::{GState, Out}; use super::lex::{IMMEDIATE, Lexer}; @@ -65,6 +65,11 @@ pub(crate) struct Interp<'a> { /// The program is in the legacy Illustrator format: its group operators `u` … `U` (at the top /// level, whatever its prolog defines them as) are groups (see the module docs of `import`). pub illustrator: bool, + /// The program is the editing copy of an Illustrator file's art (see `native`): its clipping + /// groups, compound paths and text objects have operators of their own too. + pub native: bool, + /// The clip path `W` set for the clipping group about to end. + clip_region: Option<(BezPath, FillRule)>, } /// Where a PostScript error was raised: the operator that raised it (none for an unknown name) @@ -164,6 +169,8 @@ impl<'a> Interp<'a> { saved: vec![], out, illustrator: false, + native: false, + clip_region: None, } } @@ -172,6 +179,9 @@ impl<'a> Interp<'a> { loop { let Some(o) = self.lex.next()? else { return Ok(()) }; let o = self.scanned(o, self.lex.immediate)?; + if self.native && self.native_op(&o)? { + continue; + } // A group begins before its `u` runs (`Some(true)`) and ends after its `U` // has (`Some(false)`). let group = match &o { @@ -195,6 +205,32 @@ impl<'a> Interp<'a> { } } + /// The operators only the editing copy of an Illustrator file's art has (`native`), run + /// instead of defined; `false` for any other token. + fn native_op(&mut self, o: &Obj) -> Res { + let Obj::Exec(name) = o else { return Ok(false) }; + match &**name { + "q" => self.out.begin_clip_group(), + "Q" => { + let region = self.clip_region.take(); + self.out.end_clip_group(region); + } + "W" => { + let path = self.take_path(); + self.clip_region = (!path.elements().is_empty()).then_some((path, FillRule::NonZero)); + } + "*u" => self.out.begin_compound(), + "*U" => self.out.end_compound(), + "Xw" => self.out.hidden = self.pop_num()? != 0.0, + "__txt" => { + let story = self.pop_num()?; + self.out.text_slot((story >= 0.0 && story < f64::from(u32::MAX)).then_some(story as u32)); + } + _ => return Ok(false), + } + Ok(true) + } + /// Empty the standard dictionaries, so the memory they hold is freed with the interpreter: /// systemdict holds itself, and programs link dictionaries into each other. pub fn release(&mut self) { diff --git a/crates/eps/src/import/mod.rs b/crates/eps/src/import/mod.rs index dbcc84ca..cac10c33 100644 --- a/crates/eps/src/import/mod.rs +++ b/crates/eps/src/import/mod.rs @@ -10,6 +10,7 @@ //! - `data`: data the program reads from itself through decoding filters, and images. //! - `text`: fonts by name and type as point type; Type 3 fonts' glyphs drawn by their procedures. //! - `preview`: the previews a file carries, for when its PostScript can't be read. +//! - `native`: the layers of an Illustrator file, from the editing copy of its art that it carries. //! //! Clipped art comes in as clipping groups. In a file in the legacy Illustrator format (versions //! 3–8, with the prolog that defines its operators), the group operators `u` … `U` (nested) come in @@ -24,10 +25,12 @@ //! 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 ate; mod data; mod graphics; mod interp; mod lex; +mod native; mod obj; mod preview; mod shading; @@ -43,6 +46,8 @@ mod tests_generators; mod tests_illustrator; #[cfg(test)] mod tests_images; +#[cfg(test)] +mod tests_native; use std::sync::Arc; @@ -55,6 +60,7 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; +pub use native::{is_loss, layered_ai}; pub use text::family_style; /// The page of a PostScript file without a bounding box (US Letter). @@ -182,7 +188,7 @@ pub fn import(bytes: &[u8]) -> Result { } let drew = !out.drawn.is_empty(); let why = match (&result, drew) { - (Ok(()), true) => return Ok(finish(out)), + (Ok(()), true) => return Ok(native::layered(ps, finish(out))), (Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(), (Err(e), _) => reason(e, fault.as_ref()), }; @@ -211,6 +217,16 @@ fn finish(mut out: Out) -> Imported { if !out.shadings.is_empty() { graphics::collapse(&mut children, &out.shadings); } + let mut layer = Node::layer(out.doc.alloc_id(), "Layer 1", LayerColor::Preset(0)); + if let Some(c) = layer.children_mut() { + *c = children; + } + finish_with(out, vec![Arc::new(layer)]) +} + +/// The document of what `out` drew, as `layers`: the spot inks as spot swatches, in CMYK when +/// most process colours were. +fn finish_with(out: Out, layers: Vec>) -> Imported { let mut doc = out.doc; if out.cmyk > out.rgb { doc.color_mode = ColorMode::Cmyk; @@ -227,10 +243,6 @@ fn finish(mut out: Out) -> Imported { doc.swatches.push(Swatch { paint: Paint::Pattern { pattern: name.clone(), xf: Affine::IDENTITY }, name, global: false, spot: false }); } } - let mut layer = Node::layer(doc.alloc_id(), "Layer 1", LayerColor::Preset(0)); - if let Some(c) = layer.children_mut() { - *c = children; - } - doc.layers = vec![Arc::new(layer)]; + doc.layers = layers; Imported { document: doc, warnings: out.warnings, preview: false } } diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs new file mode 100644 index 00000000..c2c8da57 --- /dev/null +++ b/crates/eps/src/import/native.rs @@ -0,0 +1,1011 @@ +//! 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`, between `%AI9_PrivateDataBegin` and `%AI9_PrivateDataEnd` (ASCII85 text of a Zstandard +//! stream, `%AI24_DataStream`; older files: zlib, `%AI9_DataStream`); a PDF-compatible `.ai` in its `AIPrivateData` streams (the Zstandard +//! stream after `%AI24_ZStandard_Data`). It is the document as the legacy Illustrator format writes +//! it (`%AI5_BeginLayer`, `Lb`, `Ln`, `u` … `U`, `*u` … `*U`, `q` … `Q`, `m` `l` `c` `f` `S` …). It +//! has what the page doesn't: the layers with their names, which of them are hidden (and what is on +//! them), the hidden objects, the art outside the artboards, the groups, the compound paths and the +//! clipping groups. +//! +//! The page stays the source of what the art looks like; this copy only gives it its structure. Only +//! the legacy format's own operators and comments are read, never what the app keeps in its private +//! comments (`%_`) and dictionaries. So: +//! +//! - Text objects are stubs here: 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 whose text is +//! where the page has it. Type that doesn't show (a hidden layer or object, or off the page) is made +//! from the text document; where that can't be read (area type, type on a path) it's left out, with +//! a warning. +//! - A line with an operator that isn't read (gradients, patterns, images, blends, …) isn't read. If +//! one is on a layer that shows, the art of this copy would differ from the page's, so the layers +//! aren't used: the file comes in as its page, as it did before. +//! +//! Sources (public documentation only; no Adobe software was run and no Adobe file is in this repo): +//! +//! - The legacy format: *Adobe Illustrator File Format Specification*, version 7.0, published by its +//! maker for third-party developers; PRONOM lists it at +//! (the record links the document itself). +//! The layer, group, compound-path and clipping vocabulary is that format's; PRONOM's record for +//! versions 5.0/5.5 () shows the layer-era comments +//! date from then. +//! - The wrappers: ASCII85 is PostScript Level 2 (*PostScript Language Reference*, 3rd ed.); the +//! compressed stream is Zstandard, RFC 8878 (). +//! - The container (`%AI9_PrivateDataBegin`, `%AI24_DataStream`, `%AI24_ZStandard_Data`, the +//! `AIPrivateData` streams), the meaning of the `Xw` hidden flag and the text document are NOT in +//! those documents: they +//! were worked out from `.eps` and `.ai` files their users own, used locally. Where a file doesn't +//! match what this module expects, it is left alone and its page is imported. + +use std::collections::BTreeSet; +use std::io::Read; +use std::sync::Arc; + +use vectorcraft_doc::clipnest::nest; +use vectorcraft_doc::{AppearanceItem, Document, LayerColor, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Affine, Point, Rect}; + +use super::ate::{Story, Texts}; +use super::graphics::{GState, Out, slot_of}; +use super::interp::Interp; +use super::{Imported, box_of, finish_with, reason}; + +const BEGIN: &[u8] = b"%AI9_PrivateDataBegin"; +const END: &[u8] = b"%AI9_PrivateDataEnd"; +const STREAM: &[u8] = b"%AI24_DataStream"; +/// The same, in files from before Zstandard. +const STREAM_ZLIB: &[u8] = b"%AI9_DataStream"; +/// What a `.ai`'s private data starts with, before its Zstandard stream. +const ZSTD_MARK: &[u8] = b"%AI24_ZStandard_Data"; +/// Most bytes the editing copy may have once decompressed. +const MAX_DECODED: u64 = 256 << 20; +/// The notes that say the import left out something the file has (see [`is_loss`]). +const TEXT_LEFT_OUT: &str = "text objects on hidden layers or hidden objects couldn't be read, so they are left out"; +const ART_LEFT_OUT: &str = "hidden layers have art this can't read"; +const PART_LEFT_OUT: &str = "part of a layer was left out"; +const LAYERS_UNREAD: &str = "the file's layers weren't read"; + +/// Does this import note say that the file has something the document doesn't (hidden text, art or +/// layers that could not be read)? Writing the document over the file would lose it for good. +pub fn is_loss(note: &str) -> bool { + note.ends_with(TEXT_LEFT_OUT) || [ART_LEFT_OUT, PART_LEFT_OUT, LAYERS_UNREAD].iter().any(|n| note.starts_with(n)) +} + +/// Deepest nesting of layers read (deeper ones are left out). +const MAX_DEPTH: usize = 32; +/// Most layers read. +const MAX_LAYERS: usize = 4096; + +/// What the operators of the format do, as PostScript: paths are the path operators, a fill or a +/// stroke uses the colour the last `k` `K` `g` `G` set (a file's objects each set their own). +const PROLOGUE: &str = "\ +/m {moveto} def /l {lineto} def /L {lineto} def /c {curveto} def /C {curveto} def +/v {currentpoint 6 2 roll curveto} def /V {currentpoint 6 2 roll curveto} def +/y {2 copy curveto} def /Y {2 copy curveto} def +/h {closepath} def /N {newpath} def /n {closepath newpath} def +/fc [0 0 0 0] def /sc [0 0 0 1] def /eo false def +/setc {dup length 1 eq {0 get setgray} {dup length 3 eq {aload pop setrgbcolor} {aload pop setcmykcolor} ifelse} ifelse} def +/k {4 array astore /fc exch def} def /K {4 array astore /sc exch def} def +/g {1 exch sub 0 0 0 4 -1 roll 4 array astore /fc exch def} def /G {1 exch sub 0 0 0 4 -1 roll 4 array astore /sc exch def} def +/Xk {pop pop pop 4 array astore /fc exch def} def /XK {pop pop pop 4 array astore /sc exch def} def +/x {pop pop 4 array astore /fc exch def} def /X {pop pop 4 array astore /sc exch def} def +/Xx {6 {pop} repeat 4 array astore /fc exch def} def /XX {6 {pop} repeat 4 array astore /sc exch def} def +/H {} def +/D {1 eq /eo exch def} def +/doF {fc setc eo {eofill} {fill} ifelse} def /doS {sc setc stroke} def +/F {doF} def /f {closepath doF} def /S {doS} def /s {closepath doS} def +/B {gsave doF grestore doS} def /b {closepath B} def +/w {setlinewidth} def /J {setlinecap} def /j {setlinejoin} def /M {setmiterlimit} def /d {setdash} def +/u {} def /U {} def +"; + +/// Operators that draw, set the state they draw with, or group (`q` `Q` `W` `*u` `*U` are the +/// interpreter's own, see `Interp::native_op`). +const KEEP: &[&str] = &[ + "m", "l", "L", "c", "C", "v", "V", "y", "Y", "h", "n", "N", "f", "F", "s", "S", "b", "B", "k", "K", "g", "G", "Xk", "XK", "x", "X", "Xx", "XX", + "H", "w", "J", "j", "M", "d", "D", "u", "U", "*u", "*U", "q", "Q", "W", "Xw", +]; +/// Operators that only describe an object to the app (its lock, its overprint, its name's id, …): +/// the line is left out. +const IGNORE: &[&str] = &["A", "Ae", "AE", "As", "Ap", "O", "R", "i", "Xd", "Xy", "XR", "XW", "XP"]; + +/// One token of a line. +enum Tok<'a> { + Num(f64), + Str(Vec), + Name(&'a str), +} + +/// The tokens of `line`: numbers, strings, names (the brackets of an array are left out); `None` +/// for a string that doesn't end. +fn tokens(line: &str) -> Option>> { + let b = line.as_bytes(); + let mut out = vec![]; + let mut i = 0; + while let Some(&c) = b.get(i) { + match c { + c if c.is_ascii_whitespace() || c == b'[' || c == b']' => i += 1, + b'(' => { + let (mut depth, mut s) = (1, vec![]); + i += 1; + loop { + let c = *b.get(i)?; + i += 1; + match c { + b'\\' => { + let e = *b.get(i)?; + i += 1; + s.push(match e { + b'n' => b'\n', + b'r' => b'\r', + b't' => b'\t', + b'0'..=b'7' => { + let mut v = u32::from(e - b'0'); + for _ in 0..2 { + match b.get(i) { + Some(d @ b'0'..=b'7') => { + v = v * 8 + u32::from(d - b'0'); + i += 1; + } + _ => break, + } + } + (v & 0xff) as u8 + } + other => other, + }); + } + b'(' => { + depth += 1; + s.push(c); + } + b')' => { + depth -= 1; + if depth == 0 { + break; + } + s.push(c); + } + _ => s.push(c), + } + } + out.push(Tok::Str(s)); + } + _ => { + let start = i; + while b.get(i).is_some_and(|c| !c.is_ascii_whitespace() && !matches!(c, b'[' | b']' | b'(')) { + i += 1; + } + let word = line.get(start..i)?; + out.push(word.parse::().map_or(Tok::Name(word), Tok::Num)); + } + } + } + Some(out) +} + +/// What the editing copy holds of a layer. +#[derive(Default)] +struct Layer { + name: String, + visible: bool, + color: Option<[u8; 3]>, + items: Vec, + /// The code read since the last sublayer. + code: String, + /// Operators on this layer that aren't read. + unsupported: BTreeSet, +} + +enum Item { + Code(String), + Sub(Layer), +} + +impl Layer { + fn flush(&mut self) { + if !self.code.is_empty() { + self.items.push(Item::Code(std::mem::take(&mut self.code))); + } + } +} + +struct Parsed { + /// The art's bounding box `llx lly urx ury`. + bbox: [f64; 4], + /// The first artboard (`%AI3_Cropmarks`), in the same space. + artboard: Option<[f64; 4]>, + /// The template box (`%AI3_TemplateBox`): its centre is the centre of the app's canvas. + template: Option<[f64; 4]>, + layers: Vec, +} + +/// The editing copy of the art, decompressed. +fn decode(ps: &[u8]) -> Option> { + fn find(h: &[u8], n: &[u8], from: usize) -> Option { + h.get(from..)?.windows(n.len()).position(|w| w == n).map(|p| p + from) + } + let begin = find(ps, BEGIN, 0)?; + let end = find(ps, END, begin).unwrap_or(ps.len()); + let block = ps.get(begin..end)?; + // Newer files compress with Zstandard, older ones (CS-era) with zlib. + let (at, zstd) = match find(block, STREAM, 0) { + Some(at) => (at + STREAM.len(), true), + None => (find(block, STREAM_ZLIB, 0)? + STREAM_ZLIB.len(), false), + }; + // The stream's lines each start with a `%`. + let text: String = block + .get(at..)? + .split(|b| matches!(b, b'\r' | b'\n')) + .filter(|l| !l.is_empty()) + .flat_map(|l| l.strip_prefix(b"%").unwrap_or(l).iter()) + .map(|b| char::from(*b)) + .collect(); + let packed = crate::ps::ascii85_decode(&text)?; + if zstd { unzstd(&packed) } else { unzlib(&packed) } +} + +/// The editing copy of a PDF-compatible `.ai`'s `AIPrivateData` streams, joined, decompressed. +fn decode_ai(private: &[u8]) -> Option> { + unzstd(private.strip_prefix(ZSTD_MARK)?) +} + +/// `packed` as a zlib stream decompressed (up to [`MAX_DECODED`] bytes). +fn unzlib(packed: &[u8]) -> Option> { + let mut data = vec![]; + flate2::read::ZlibDecoder::new(packed).take(MAX_DECODED).read_to_end(&mut data).ok()?; + (data.len() as u64 != MAX_DECODED).then_some(data) +} + +/// `packed` as a Zstandard stream decompressed (up to [`MAX_DECODED`] bytes). +fn unzstd(packed: &[u8]) -> Option> { + let mut zstd = ruzstd::decoding::StreamingDecoder::new(packed).ok()?; + let mut data = vec![]; + (&mut zstd).take(MAX_DECODED).read_to_end(&mut data).ok()?; + (data.len() as u64 != MAX_DECODED).then_some(data) +} + +/// The layers of the editing copy `data`. +fn parse(data: &[u8]) -> Option { + let (mut bbox, mut hires, mut artboard, mut template) = (None, None, None, None); + let mut stack: Vec = vec![]; + let mut done: Vec = vec![]; + let mut dict_depth = 0usize; + // In a text object's dictionary: the story it names, once the dictionary says. + let mut text_story: Option> = None; + let mut count = 0usize; + for raw in data.split(|b| matches!(b, b'\r' | b'\n')) { + let line = String::from_utf8_lossy(raw); + let line = line.trim(); + if line.is_empty() { + continue; + } + // A text object's dictionary may be written as comments (`%_`), which a reader that doesn't + // know text objects skips: what that reader skips is read here, for the text object only. + let line = match line.strip_prefix("%_") { + Some(rest) if dict_depth > 0 || rest.starts_with("/AI11Text") && rest.ends_with(':') => rest.trim(), + _ => line, + }; + if line.starts_with('%') { + match line { + "%AI5_BeginLayer" => { + count += 1; + if count > MAX_LAYERS { + return None; + } + if let Some(parent) = stack.last_mut() { + parent.flush(); + } + stack.push(Layer { visible: true, ..Layer::default() }); + } + "%AI5_EndLayer--" => { + let mut l = stack.pop()?; + l.flush(); + match stack.last_mut() { + Some(parent) => parent.items.push(Item::Sub(l)), + None => done.push(l), + } + } + _ if stack.is_empty() => { + if let Some(v) = line.strip_prefix("%%HiResBoundingBox:") { + hires = box_of(v.trim()).or(hires); + } else if let Some(v) = line.strip_prefix("%%BoundingBox:") { + bbox = box_of(v.trim()).or(bbox); + } else if let Some(v) = line.strip_prefix("%AI3_Cropmarks:") { + artboard = box_of(v.trim()).or(artboard); + } else if let Some(v) = line.strip_prefix("%AI3_TemplateBox:") { + template = four_numbers(v).or(template); + } + } + _ => {} + } + continue; + } + let Some(layer) = stack.last_mut() else { continue }; + // The app's dictionaries (`/Name :` … `;`): a text object's is a slot for its text. + if dict_depth > 0 { + // A dictionary ends with `;`, which may have its key after it (`; /ConfiningPath ,`). + if line == ";" || line.starts_with("; /") { + dict_depth -= 1; + if dict_depth == 0 + && let Some(story) = text_story.take() + { + layer.code.push_str(&format!("{} __txt\n", story.map_or(-1, i64::from))); + } + } else if line.ends_with(':') { + dict_depth += 1; + } else if text_story.is_some() + && let Some(n) = line.strip_suffix("/StoryIndex ,") + { + text_story = Some(n.trim().parse().ok()); + } + continue; + } + if line.starts_with('/') { + if line.ends_with(':') { + dict_depth = 1; + if line.starts_with("/AI11Text") { + text_story = Some(None); + } else { + layer.unsupported.insert("an object with its own dictionary".into()); + } + } else { + layer.unsupported.insert("an unknown object".into()); + } + continue; + } + let Some(toks) = tokens(line) else { + layer.unsupported.insert("an unreadable line".into()); + continue; + }; + let last = toks.last().and_then(|t| if let Tok::Name(n) = t { Some(*n) } else { None }); + match last { + Some("Lb") => { + let n: Vec = toks.iter().filter_map(|t| if let Tok::Num(v) = t { Some(*v) } else { None }).collect(); + layer.visible = n.first().is_none_or(|v| *v != 0.0); + if let [r, g, b] = n.get(8..11).unwrap_or_default() { + let byte = |v: &f64| (v.is_finite() && (0.0..=255.0).contains(v)).then_some(*v as u8); + layer.color = byte(r).zip(byte(g)).zip(byte(b)).map(|((r, g), b)| [r, g, b]); + } + continue; + } + Some("Ln") => { + if let Some(Tok::Str(s)) = toks.first() { + layer.name = String::from_utf8_lossy(s).into_owned(); + } + continue; + } + Some("LB") => continue, + _ => {} + } + let mut ignored = false; + let mut missing: Option<&str> = None; + for t in &toks { + if let Tok::Name(n) = t { + if IGNORE.contains(n) { + ignored = true; + } else if !KEEP.contains(n) { + missing = missing.or(Some(n)); + } + } + } + if let Some(n) = missing { + let shown = n.len() <= 8 && n.chars().all(|c| c.is_ascii_graphic()); + layer.unsupported.insert(if shown { format!("`{n}`") } else { "binary data".to_string() }); + } else if !ignored { + layer.code.push_str(line); + layer.code.push('\n'); + } + } + while let Some(mut l) = stack.pop() { + l.flush(); + match stack.last_mut() { + Some(parent) => parent.items.push(Item::Sub(l)), + None => done.push(l), + } + } + Some(Parsed { bbox: hires.or(bbox)?, artboard, template, layers: done }) +} + +/// Four numbers (a box that may be a point). +fn four_numbers(v: &str) -> Option<[f64; 4]> { + let n: Vec = v.split_whitespace().map_while(|w| w.parse::().ok()).collect(); + let [a, b, c, d] = n.as_slice() else { return None }; + [a, b, c, d].iter().all(|v| v.is_finite() && v.abs() <= 1e7).then_some([*a, *b, *c, *d]) +} + +/// `names` for a note: the first few, and how many more. +fn few(names: BTreeSet) -> String { + const SHOWN: usize = 5; + let more = names.len().saturating_sub(SHOWN); + let mut list: Vec = names.into_iter().take(SHOWN).collect(); + if more > 0 { + list.push(format!("{more} more")); + } + list.join(", ") +} + +/// What the operators not read are, on layers that show and on layers that don't. +fn unread(layers: &[Layer], shown: bool, on: &mut BTreeSet, off: &mut BTreeSet) { + for l in layers { + let shown = shown && l.visible; + let set = if shown { &mut *on } else { &mut *off }; + set.extend(l.unsupported.iter().cloned()); + let subs: Vec<&Layer> = l.items.iter().filter_map(|i| if let Item::Sub(s) = i { Some(s) } else { None }).collect(); + for s in subs { + unread(std::slice::from_ref(s), shown, on, off); + } + } +} + +/// A text slot of the layers. +struct SlotInfo { + story: Option, + /// It, its layer and the objects round it show. + shown: bool, +} + +/// The text slots of the layers and objects, in order. +fn collect_slots(nodes: &[Arc], shown: bool, out: &mut Vec) { + for n in nodes { + let shown = shown && n.visible; + if let Some(story) = slot_of(n.name.as_deref()) { + out.push(SlotInfo { story, shown }); + } else if let Some(c) = n.children().filter(|_| !matches!(n.kind, NodeKind::Compound { .. })) { + collect_slots(c, shown, out); + } + } +} + +/// Builds the layers' nodes by running each stretch of their code. +struct Builder { + out: Option, +} + +impl Builder { + /// What `code` draws, as the nodes of a layer. + fn run(&mut self, code: &str) -> Vec> { + let Some(out) = self.out.take() else { return vec![] }; + let src = format!("{PROLOGUE}{code}"); + let mut out = out; + out.hidden = false; + let mut it = Interp::new(src.as_bytes(), GState::default(), out); + it.illustrator = true; + it.native = true; + let result = it.run(); + it.release(); + let fault = it.fault.take(); + let mut out = it.out; + if let Err(e) = result { + out.warn(&format!("{PART_LEFT_OUT}: {}", reason(&e, fault.as_ref()))); + } + let drawn = std::mem::take(&mut out.drawn); + let nodes = nest(&mut out.doc, drawn); + self.out = Some(out); + nodes + } + + fn layer(&mut self, l: &Layer, depth: usize) -> Option> { + let mut children = vec![]; + for item in &l.items { + match item { + Item::Code(code) => children.extend(self.run(code)), + Item::Sub(sub) if depth < MAX_DEPTH => children.extend(self.layer(sub, depth + 1)), + Item::Sub(_) => {} + } + } + let out = self.out.as_mut()?; + let name = if l.name.is_empty() { "Layer" } else { &l.name }; + let mut node = Node::layer(out.doc.alloc_id(), name, l.color.map_or(LayerColor::Preset(0), LayerColor::Custom)); + node.visible = l.visible; + if let Some(c) = node.children_mut() { + *c = children; + } + Some(Arc::new(node)) + } +} + +/// How far from a text object's own extent the strokes drawn around it reach (points). +const OUTLINE_REACH: f64 = 6.0; + +/// Does `n` look like part of a text object's outline: a path with a stroke, inside `around`? +fn outlines(n: &Node, around: Rect) -> bool { + matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + && n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.visible && s.width > 0.0)) + && n.visual_bounds().is_some_and(|b| { + let a = around.inflate(OUTLINE_REACH, OUTLINE_REACH); + b.x0 >= a.x0 && b.y0 >= a.y0 && b.x1 <= a.x1 && b.y1 <= a.y1 + }) +} + +/// How far apart (points) the baselines of two text nodes may be for them to be on one line. +const SAME_LINE: f64 = 0.5; + +/// Where `n`'s baseline lies across the direction it runs in (points), if it is text. +fn baseline(n: &Node) -> Option { + let NodeKind::Text(t) = &n.kind else { return None }; + let [a, b, _, _, e, f] = t.xf.as_coeffs(); + let len = a.hypot(b); + (len > 1e-9).then(|| (a * f - b * e) / len) +} + +/// The text objects the page paints, each as the run of nodes that make it up (its text, once for +/// each fill and stroke it has, with the paths of its strokes), in painting order: those that +/// show in `shown`, those of hidden layers and objects (a PDF has them) in `hidden`. +fn text_objects(nodes: &[Arc], on: bool, shown: &mut Vec>>, hidden: &mut Vec>>) { + let is_text = |n: &Node| matches!(n.kind, NodeKind::Text(_)); + let (mut i, mut free) = (0, 0); + while let Some(n) = nodes.get(i) { + if !is_text(n) { + if let Some(c) = n.children().filter(|_| !matches!(n.kind, NodeKind::Compound { .. })) { + text_objects(c, on && n.visible, shown, hidden); + free = i + 1; + } + i += 1; + continue; + } + let mut bounds = n.visual_bounds().unwrap_or_default(); + let (mut start, mut end) = (i, i); + while start > free + && let Some(p) = nodes.get(start - 1) + && outlines(p, bounds) + { + start -= 1; + bounds = bounds.union(p.visual_bounds().unwrap_or_default()); + } + while let Some(next) = nodes.get(end + 1) + && let Some(b) = next.visual_bounds() + && (is_text(next) + && !b.intersect(bounds.inflate(OUTLINE_REACH, OUTLINE_REACH)).is_zero_area() + && baseline(next) + .zip(nodes.get(start..=end).unwrap_or_default().iter().find_map(|m| baseline(m))) + .is_some_and(|(x, y)| (x - y).abs() <= SAME_LINE) + || outlines(next, bounds)) + { + end += 1; + bounds = bounds.union(b); + } + let object = nodes.get(start..=end).unwrap_or_default().to_vec(); + let into = if on && n.visible { &mut *shown } else { &mut *hidden }; + into.push(object); + i = end + 1; + free = i; + } +} + +/// `n` and what it holds with ids of `doc`. +fn reid(doc: &mut Document, n: &Arc) -> Arc { + let mut n = n.as_ref().clone(); + n.id = doc.alloc_id(); + if let Some(children) = n.children_mut() { + for c in children.iter_mut() { + *c = reid(doc, c); + } + } + Arc::new(n) +} + +/// The page's text objects given to each of `slots` text slots: in painting order, and when there +/// are more of one than of the other (a label may be a few text objects, or one with several +/// strokes), each goes where the share of the order it is at falls. +fn assign(objects: &[Vec>], slots: usize) -> Vec]>> { + let mut given: Vec]>> = vec![vec![]; slots]; + for (j, o) in objects.iter().enumerate() { + if let Some(at) = given.get_mut(((2 * j + 1) * slots / (2 * objects.len())).min(slots.saturating_sub(1))) { + at.push(o); + } + } + given +} + +/// What goes into a text slot. +enum Content { + /// Text objects (with the strokes round them) of the page. + Page(Vec>), + /// A text object made from the file's text document. + Made(Box), + /// Nothing the file lets this read. + Empty, + /// Its type is on the page with another text object's; there is nothing to put here. + Merged, +} + +/// Replace the text slots in `nodes` with what `content` has for each, in order (a slot given none is +/// left out). `empty` counts those. +fn fill_slots(doc: &mut Document, nodes: &mut Vec>, content: &mut std::vec::IntoIter, empty: &mut usize) { + let mut i = 0; + while i < nodes.len() { + let Some(n) = nodes.get(i) else { break }; + if slot_of(n.name.as_deref()).is_some() { + let visible = n.visible; + let replacement = match content.next() { + Some(Content::Page(parts)) if !parts.is_empty() => { + let children = parts.iter().map(|p| reid(doc, p)).collect(); + let mut group = Node::group(doc.alloc_id(), children); + group.visible = visible; + Some(group) + } + Some(Content::Made(mut text)) => { + text.id = doc.alloc_id(); + text.visible = visible; + Some(*text) + } + Some(Content::Merged) => { + nodes.remove(i); + continue; + } + _ => None, + }; + match replacement { + Some(node) => { + if let Some(slot) = nodes.get_mut(i) { + *slot = Arc::new(node); + } + i += 1; + } + None => { + *empty += 1; + nodes.remove(i); + } + } + } else { + if n.children().is_some() + && !matches!(n.kind, NodeKind::Compound { .. }) + && let Some(c) = nodes.get_mut(i).map(Arc::make_mut).and_then(Node::children_mut) + { + fill_slots(doc, c, content, empty); + } + i += 1; + } + } +} + +/// How far, in points, the page's type may be from where the file's text document puts it for the +/// two to be the same type. +const SAME_PLACE: f64 = 0.75; + +/// Its letters and digits, which is what is compared of type (the page may spell a mark another way). +fn letters(s: &str) -> String { + s.chars().filter(char::is_ascii_alphanumeric).collect() +} + +/// Does `n` hold type that is where `story` lays one of its lines out: the line, or a piece of it +/// (the page splits a line where its kerning or its styles change), on that line's baseline and +/// after its start? How far from where the line starts it is, if so. +fn same_type(n: &Node, lines: &[(String, Point)]) -> Option { + let NodeKind::Text(t) = &n.kind else { return None }; + let have = letters(&t.plain_text()); + let [a, b, _, _, e, f] = t.xf.as_coeffs(); + let len = a.hypot(b); + if have.is_empty() || len < 1e-9 { + return None; + } + let (ux, uy) = (a / len, b / len); + // About how far a character of it advances, to say how far along a line a piece may be. + let advance = n.visual_bounds().map_or(0.0, |r| r.width().max(r.height())) / have.len() as f64; + lines + .iter() + .filter_map(|(text, at)| { + let line = letters(text); + let skipped = line.find(&have)?; + if skipped == 0 { + return Some(((at.x - e).powi(2) + (at.y - f).powi(2)).sqrt()).filter(|d| *d <= SAME_PLACE); + } + let (dx, dy) = (e - at.x, f - at.y); + let (along, across) = (dx * ux + dy * uy, (dx * uy - dy * ux).abs()); + ((have.len() > 1 || skipped + 1 == line.len()) + && across <= SAME_PLACE + && along >= -SAME_PLACE + && along <= line.len() as f64 * advance * 2.0 + 4.0) + .then_some(across + 0.001 * along) + }) + .min_by(f64::total_cmp) +} + +/// The box round `parts`. +fn extent(parts: &[Arc]) -> Option { + parts.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)) +} + +/// What goes into each of `infos`' slots: the page's type that the file's text document puts where the +/// page has it, else type made from the text document, else (for a file whose text document can't say) +/// the page's type in painting order. +fn plan( + infos: &[SlotInfo], + stories: &[Option], + template: Option<(f64, f64)>, + to_doc: Affine, + pages: [&[Vec>]; 2], + warn: &mut Vec, +) -> Vec { + let [shown, hidden] = pages; + let lines: Vec>> = stories + .iter() + .map(|s| { + let (s, t) = (s.as_ref()?, template?); + Some(s.line_starts(t).into_iter().map(|(text, (x, y))| (text, to_doc * Point::new(x, y))).collect()) + }) + .collect(); + // The page's objects that are where a shown slot's lines are, nearest first. + let mut pairs: Vec<(f64, usize, usize)> = vec![]; + for (k, info) in infos.iter().enumerate() { + let Some(l) = lines.get(k).and_then(Option::as_ref).filter(|_| info.shown) else { continue }; + for (j, object) in shown.iter().enumerate() { + if let Some(d) = object.iter().filter_map(|n| same_type(n, l)).min_by(f64::total_cmp) { + pairs.push((d, k, j)); + } + } + } + pairs.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut owner: Vec> = vec![None; shown.len()]; + for (_, k, j) in pairs { + if let Some(o) = owner.get_mut(j).filter(|o| o.is_none()) { + *o = Some(k); + } + } + // The file's text document is trusted where it agrees with the page about something, or where the + // page has no type to disagree with. + let trusted = owner.iter().any(Option::is_some) || shown.is_empty(); + let readable = |k: usize| trusted && lines.get(k).is_some_and(Option::is_some); + let mut content: Vec = infos.iter().map(|_| Content::Empty).collect(); + let mut spare: Vec>> = vec![]; + for (j, object) in shown.iter().enumerate() { + match owner.get(j).copied().flatten().filter(|_| trusted) { + Some(k) => match content.get_mut(k) { + Some(Content::Page(p)) => p.extend(object.iter().cloned()), + Some(c) => *c = Content::Page(object.clone()), + None => {} + }, + None => spare.push(object.clone()), + } + } + // Type of the text document where the page has none: hidden type, and shown type off the page. + for (k, c) in content.iter_mut().enumerate() { + if matches!(c, Content::Empty) + && readable(k) + && let (Some(Some(story)), Some(t)) = (stories.get(k), template) + && let Some(node) = story.node(NodeId(0), t, to_doc) + { + *c = Content::Made(Box::new(node)); + } + } + // What is left of the page goes by painting order into the shown slots the file can't say. + let loose: Vec = infos + .iter() + .enumerate() + .filter(|(k, i)| i.shown && matches!(content.get(*k), Some(Content::Empty)) && !readable(*k)) + .map(|(k, _)| k) + .collect(); + if loose.is_empty() { + // Type of the page that no text object says it owns goes with the nearest text object that has + // type of the page; with none, it is left out. + let mut left_out = false; + for object in &spare { + let (centre, near) = (extent(object).map(|r| r.center()), |c: &Content| match c { + Content::Page(parts) => extent(parts).map(|r| r.center()), + _ => None, + }); + let nearest = content + .iter_mut() + .filter_map(|c| near(c).zip(centre).map(|(p, q)| ((p.x - q.x).hypot(p.y - q.y), c))) + .min_by(|a, b| a.0.total_cmp(&b.0)); + match nearest { + Some((_, Content::Page(parts))) => parts.extend(object.iter().cloned()), + _ => left_out = true, + } + } + if !spare.is_empty() { + warn.push( + if left_out { + "some of the page's type has no text object in the layers, so it is left out" + } else { + "some of the page's type has no text object of its own in the layers, so it is with another's" + } + .into(), + ); + } + } else { + for (slot, parts) in loose.iter().zip(assign(&spare, loose.len())) { + if let Some(c) = content.get_mut(*slot) { + // A text object the page shows as part of another's has nothing to hold of its own. + *c = if parts.is_empty() && !spare.is_empty() { + Content::Merged + } else { + Content::Page(parts.into_iter().flat_map(|p| p.iter().cloned()).collect()) + }; + } + } + if spare.len() != loose.len() { + warn.push("the text is on the layers in the order the page paints it, so a text object may be in another group than it was".into()); + } + } + // The same for the slots that don't show. + let unread: Vec = + infos.iter().enumerate().filter(|(k, i)| !i.shown && matches!(content.get(*k), Some(Content::Empty))).map(|(k, _)| k).collect(); + for (slot, parts) in unread.iter().zip(assign(hidden, unread.len())) { + if let Some(c) = content.get_mut(*slot) { + *c = Content::Page(parts.into_iter().flat_map(|p| p.iter().cloned()).collect()); + } + } + content +} + +/// How much of `page` two documents draw differently (0 to 1), each seen on white. +fn difference(a: &Document, b: &Document, page: Rect) -> f64 { + let scale = (400.0 / page.width().max(page.height()).max(1.0)).min(4.0); + let mut r = vectorcraft_render::Renderer::new(); + let (x, y) = (r.render_region(a, page, scale, true), r.render_region(b, page, scale, true)); + if x.width != y.width || x.height != y.height { + return 1.0; + } + let differing = x + .pixels + .as_chunks::<4>() + .0 + .iter() + .zip(y.pixels.as_chunks::<4>().0) + .filter(|(p, q)| p.iter().zip(q.iter()).any(|(a, b)| a.abs_diff(*b) > 48)) + .count(); + differing as f64 / (f64::from(x.width) * f64::from(x.height)).max(1.0) +} + +/// More of the page than this differing between the layers' art and the page's own, and the +/// layers aren't used. +const MAX_DIFFERENCE: f64 = 0.05; +/// More than this and the import says so. +const NOTABLE_DIFFERENCE: f64 = 0.002; + +/// Which box of the editing copy is the page. +#[derive(Clone, Copy)] +enum Page { + /// An EPS's page is its art's bounding box. + Art, + /// A `.ai` is read on its (first and only) artboard. + Artboard, +} + +/// What [`build`] made: the layers, and the drawing state they were made in (swatches, warnings). +struct Built { + out: Out, + layers: Vec>, +} + +/// The layers of the editing copy `data`, with the text (and strokes round it) of `visible`, the +/// file read as its page, put on them. `base` is the document they go in. +fn build(data: &[u8], visible: &Document, base: Document, page: Page) -> Result { + let parsed = parse(data).ok_or("its editing data is damaged")?; + if parsed.layers.is_empty() { + return Err("its editing data has no layers".into()); + } + let [llx, lly, urx, ury] = match page { + Page::Art => parsed.bbox, + Page::Artboard => parsed.artboard.ok_or("its editing data doesn't say where its artboard is")?, + }; + let artboard = visible.artboards.first().map(|a| a.rect).ok_or("it has no page")?; + if matches!(page, Page::Artboard) && visible.artboards.len() != 1 { + return Err("it has more than one artboard".into()); + } + if ((urx - llx) - artboard.width()).abs() > 0.5 || ((ury - lly) - artboard.height()).abs() > 0.5 { + return Err("its editing data is for a different page".into()); + } + let (mut on, mut off) = (BTreeSet::new(), BTreeSet::new()); + unread(&parsed.layers, true, &mut on, &mut off); + if !on.is_empty() { + return Err(format!("its layers use {}, which aren't read yet", few(on))); + } + let (mut shown, mut hidden) = (vec![], vec![]); + for l in &visible.layers { + if let Some(c) = l.children() { + text_objects(c, l.visible, &mut shown, &mut hidden); + } + } + let mut doc = base; + doc.layers.clear(); + let to_doc = Affine::translate((artboard.x0, artboard.y0)) * Affine::new([1.0, 0.0, 0.0, -1.0, -llx, ury]); + let mut b = Builder { out: Some(Out::new(doc, to_doc, artboard)) }; + let mut layers: Vec> = parsed.layers.iter().filter_map(|l| b.layer(l, 0)).collect(); + let mut out = b.out.ok_or("a layer couldn't be read")?; + let mut infos = vec![]; + collect_slots(&layers, true, &mut infos); + if infos.iter().all(|i| !i.shown) && !shown.is_empty() { + return Err("its page has text, and its layers have no text objects to put it in".into()); + } + let texts = Texts::read(data); + let stories: Vec> = infos.iter().map(|i| texts.as_ref().and_then(|t| t.story(i.story? as usize))).collect(); + let template = parsed.template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0)); + let mut notes = vec![]; + let content = plan(&infos, &stories, template, to_doc, [&shown, &hidden], &mut notes); + let mut empty = 0; + fill_slots(&mut out.doc, &mut layers, &mut content.into_iter(), &mut empty); + for n in notes { + out.warn(&n); + } + if empty > 0 { + out.warn(&format!("{empty} {TEXT_LEFT_OUT}")); + } + if !off.is_empty() { + out.warn(&format!("{ART_LEFT_OUT} ({}), left out of them", few(off))); + } + Ok(Built { out, layers }) +} + +/// Do the layers' art and the page's own look alike? An error says they don't; `Ok` has the note +/// to give when they differ a little. +fn compare(page: &Document, layered: &Document) -> Result, String> { + let Some(rect) = page.artboards.first().map(|a| a.rect) else { return Ok(None) }; + let diff = difference(page, layered, rect); + if diff > MAX_DIFFERENCE { + return Err(format!("the art on them differs from the page's in {:.0}% of it", diff * 100.0)); + } + Ok((diff > NOTABLE_DIFFERENCE).then(|| { + format!( + "the art on the layers differs from the file's own page in about {:.1}% of it (brush strokes, live effects and the like that the layers' data doesn't describe)", + diff * 100.0 + ) + })) +} + +/// The EPS `visible` (read as its page) read through its editing copy instead: its layers (the +/// hidden ones too), groups, compound paths and clipping groups, with the text and the strokes +/// around it of the page. When the file has no editing copy this reads, `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) = decode(ps) else { return visible }; + let Some(page) = visible.document.artboards.first().map(|a| a.rect) else { return visible }; + let base = Document::new(page.width(), page.height()); + let result = build(&data, &visible.document, base, Page::Art).and_then(|built| { + let mut done = finish_with(built.out, built.layers); + let note = compare(&visible.document, &done.document)?; + done.warnings.extend(note); + Ok(done) + }); + match result { + Ok(mut done) => { + for w in &visible.warnings { + if !done.warnings.contains(w) { + done.warnings.push(w.clone()); + } + } + done + } + Err(why) => { + let mut v = visible; + v.warnings.push(format!("{LAYERS_UNREAD} ({why}): it comes in as its page, in one layer")); + v + } + } +} + +/// The PDF-compatible `.ai` `visible` (read as its PDF part) read through its editing copy +/// (`private`, the joined `AIPrivateData` streams) instead, as [`layered`] reads an EPS: with the +/// layers and objects of the file, and the art that lies outside its artboard, which its PDF part +/// doesn't have. `warnings` are the notes of reading the PDF part; the notes returned are those, +/// and the layers'. +pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec) -> (Document, Vec) { + let Some(data) = decode_ai(private) else { return (visible, warnings) }; + let result = build(&data, &visible, visible.clone(), Page::Artboard).and_then(|built| { + let mut notes = built.out.warnings.clone(); + let done = finish_layers(built.out, built.layers); + notes.extend(compare(&visible, &done)?); + Ok((done, notes)) + }); + match result { + Ok((done, notes)) => { + let mut all = warnings; + all.extend(notes.into_iter().filter(|n| !all.contains(n)).collect::>()); + (done, all) + } + Err(why) => { + let mut all = warnings; + all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part")); + (visible, all) + } + } +} + +fn finish_layers(out: Out, layers: Vec>) -> Document { + let mut doc = out.doc; + doc.layers = layers; + doc +} diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs new file mode 100644 index 00000000..414d51ae --- /dev/null +++ b/crates/eps/src/import/tests_native.rs @@ -0,0 +1,438 @@ +//! The layers of an Illustrator EPS and `.ai`: read from the editing copy of the art the file +//! carries (see `native`). The files here are made from the public specification's operators. + +use vectorcraft_doc::{Document, LayerColor, Node, NodeKind}; + +use crate::import::import; + +/// `data` as ASCII85, up to the `~>`. +fn ascii85(data: &[u8]) -> String { + let mut out = String::new(); + for chunk in data.chunks(4) { + let v = chunk.iter().enumerate().fold(0u32, |v, (i, b)| v | u32::from(*b) << (24 - 8 * i)); + let mut d = [0u8; 5]; + let mut x = v; + for c in d.iter_mut().rev() { + *c = (x % 85) as u8 + b'!'; + x /= 85; + } + out.extend(d.iter().take(chunk.len() + 1).map(|c| char::from(*c))); + } + out.push_str("~>"); + out +} + +fn zstd(data: &str) -> Vec { + ruzstd::encoding::compress_to_vec(data.as_bytes(), ruzstd::encoding::CompressionLevel::Fastest) +} + +/// The editing copy of a file with `layers` (made by [`layer`]), 100 × 100 points. +fn native(layers: &str) -> String { + 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%AI5_NumLayers: 2\n{layers}%%Trailer\n" + ) +} + +/// A layer as the format writes it. +fn layer(name: &str, visible: bool, body: &str) -> String { + format!("%AI5_BeginLayer\n{} 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{body}\nLB\n%AI5_EndLayer--\n", u8::from(visible)) +} + +/// A yellow square of 4 points at (`x`, `y`) in the format's operators. +fn square(x: u32, y: u32) -> String { + format!("0 0 1 0 k\n{x} {y} m\n{} {y} L\n{} {} L\n{x} {} L\nf", x + 4, x + 4, y + 4, y + 4) +} + +/// An EPS whose page draws `page` and whose private data is `native`. +fn eps(page: &str, native: &str) -> Vec { + let stream = ascii85(&zstd(native)); + let lines: Vec = stream.as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect(); + format!( + "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{page}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI24_DataStream\n{}\n%AI9_PrivateDataEnd\n", + lines.join("\n") + ) + .into_bytes() +} + +/// The page's drawing of the square at (`x`, `y`). +fn page_square(x: u32, y: u32) -> String { + format!("0 0 1 0 setcmykcolor {x} {y} moveto {} {y} lineto {} {} lineto {x} {} lineto closepath fill", x + 4, x + 4, y + 4, y + 4) +} + +fn names(d: &Document) -> Vec { + d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect() +} + +fn kinds(l: &Node) -> Vec<&'static str> { + l.children() + .into_iter() + .flatten() + .map(|n| match &n.kind { + NodeKind::Group { clip: true, children } => { + ["clip[0]", "clip[1]", "clip[2]", "clip[3]"].get(children.len()).copied().unwrap_or("clip[many]") + } + NodeKind::Group { children, .. } => { + ["group[0]", "group[1]", "group[2]", "group[3]"].get(children.len()).copied().unwrap_or("group[many]") + } + NodeKind::Compound { children, .. } => { + ["compound[0]", "compound[1]", "compound[2]", "compound[3]"].get(children.len()).copied().unwrap_or("compound[many]") + } + NodeKind::Path { clipping: true, .. } => "clippath", + NodeKind::Path { .. } => "path", + NodeKind::Text(_) => "text", + _ => "other", + }) + .collect() +} + +#[test] +fn layers_come_in_with_their_names_colours_and_hidden_state() { + let art = native(&(layer("Back", true, &square(10, 10)) + &layer("Hidden", false, &square(50, 50)))); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let d = &r.document; + assert_eq!(names(d), ["Back", "Hidden"], "{:?}", r.warnings); + assert_eq!([d.layers[0].visible, d.layers[1].visible], [true, false]); + // What is on a hidden layer stays: the page doesn't have it. + assert_eq!(kinds(&d.layers[1]), ["path"]); + assert!(matches!(&d.layers[0].kind, NodeKind::Layer { color: LayerColor::Custom([79, 128, 255]), .. })); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + // The page is where the page's art was. + let b = d.layers[0].children().unwrap()[0].visual_bounds().unwrap(); + assert!((b.x0 - 10.0).abs() < 1e-6 && (b.y0 - 86.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn sublayers_groups_compounds_clip_groups_and_hidden_objects_come_in() { + let body = [ + "u".to_string(), + square(10, 10), + "U".into(), + "*u".into(), + square(20, 10), + square(30, 10), + "*U".into(), + // A clipping group: its objects, then its clip path. + "q".into(), + square(40, 10), + "50 10 m 60 10 L 60 20 L 50 20 L h W f".into(), + "Q".into(), + // Hidden until the next `Xw`. + "1 Xw".into(), + "u".into(), + square(70, 10), + "U".into(), + "0 Xw".into(), + ] + .join("\n"); + let sub = layer("Inner", true, &square(80, 10)); + let art = native(&layer("Outer", true, &format!("{body}\n{sub}"))); + // The page has what shows: the clip group's square is outside its clip path. + let page = [10, 20, 30, 80].map(|x| page_square(x, 10)).join("\n"); + let r = import(&eps(&page, &art)).unwrap(); + let outer = &r.document.layers[0]; + assert_eq!(kinds(outer), ["group[1]", "compound[2]", "clip[2]", "group[1]", "other"]); + let children = outer.children().unwrap(); + assert!(children[0].visible && !children[3].visible, "the hidden object is hidden, not left out"); + // The clip path comes first in its group. + assert!(matches!(children[2].children().unwrap()[0].kind, NodeKind::Path { clipping: true, .. })); + // The sublayer is a layer of the layer. + assert!(matches!(&children[4].kind, NodeKind::Layer { .. })); + assert_eq!(children[4].name.as_deref(), Some("Inner")); +} + +#[test] +fn what_the_layers_cant_say_keeps_the_page_when_it_shows_and_not_when_it_doesnt() { + // A gradient (`Bg`) on a layer that shows: the layers would draw it as a flat colour. + let shown = native(&layer("Art", true, &format!("{}\n0 0 0 1 0 0 Bg", square(10, 10)))); + let r = import(&eps(&page_square(10, 10), &shown)).unwrap(); + assert_eq!(names(&r.document), ["Layer 1"]); + assert!(r.warnings.iter().any(|w| w.contains("layers weren't read") && w.contains("`Bg`")), "{:?}", r.warnings); + // On a hidden layer it only costs that layer's look. + let hidden = native(&(layer("Art", true, &square(10, 10)) + &layer("Old", false, "0 0 0 1 0 0 Bg"))); + let r = import(&eps(&page_square(10, 10), &hidden)).unwrap(); + assert_eq!(names(&r.document), ["Art", "Old"]); + assert!(r.warnings.iter().any(|w| w.contains("hidden layers have art this can't read")), "{:?}", r.warnings); +} + +#[test] +fn text_and_its_strokes_come_from_the_page_into_the_layer_of_its_text_object() { + let page = format!( + "{}\n/Helvetica findfont 12 scalefont setfont 10 50 moveto (Hi) show\n10 50 moveto (Hi) false charpath 2 setlinewidth stroke", + page_square(10, 10) + ); + let words = layer("Words", true, "/AI11Text :\n0 /FreeUndo ,\n;\n\n0 0 Xd"); + let art = native(&(layer("Art", true, &square(10, 10)) + &words)); + let r = import(&eps(&page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings); + assert_eq!(kinds(&r.document.layers[0]), ["path"]); + // The text and the path of its stroke, together where the text object is. + let group = &r.document.layers[1].children().unwrap()[0]; + assert_eq!(kinds(group), ["text", "path"], "{:?}", r.warnings); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); +} + +#[test] +fn a_damaged_editing_copy_leaves_the_page() { + // Not ASCII85, ASCII85 of something that isn't Zstandard, Zstandard of something that isn't art. + let plain = |data: &str| { + let lines = format!("%{}", data); + format!( + "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI24_DataStream\n{lines}\n%AI9_PrivateDataEnd\n", + page_square(10, 10) + ) + .into_bytes() + }; + for bytes in [plain("this is not ascii85 {{{~>"), plain(&ascii85(b"not zstandard at all")), eps(&page_square(10, 10), "nothing of the art here")] + { + let r = import(&bytes).unwrap(); + assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings); + } +} + +/// The `.ai`'s editing data: its marker, then the Zstandard stream. +fn private(native: &str) -> Vec { + [b"%AI24_ZStandard_Data".as_slice(), &zstd(native)].concat() +} + +/// A page of 100 × 100 with a square at (`x`, `y`) in one layer, as a PDF part would give. +fn pdf_part(x: f64, y: f64) -> Document { + let mut d = Document::new(100.0, 100.0); + let id = d.alloc_id(); + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, y, x + 4.0, y + 4.0)); + let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 0.0, 1.0, 0.0)); + let item = vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(paint)); + let n = Node::path(id, path, vectorcraft_doc::Appearance { items: vec![item], ..Default::default() }); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, n).unwrap(); + d +} + +#[test] +fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() { + // A square on the artboard, and one above it (the artboard is y 0 to 100 here). + let art = native(&(layer("Jig", true, &square(10, 10)) + &layer("Parts", true, &square(30, 110)) + &layer("Old", false, &square(60, 60)))); + let (d, warnings) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec!["a note".into()]); + assert_eq!(names(&d), ["Jig", "Parts", "Old"], "{warnings:?}"); + assert_eq!(warnings, ["a note"], "nothing else to say"); + let above = d.layers[1].children().unwrap()[0].visual_bounds().unwrap(); + assert!(above.y1 <= 0.0 + 1e-6, "the art above the artboard is kept, outside it: {above:?}"); + assert!(!d.layers[2].visible); +} + +#[test] +fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() { + let pdf = pdf_part(10.0, 86.0); + // No editing data this reads. + let (d, w) = crate::layered_ai(b"nothing", pdf.clone(), vec![]); + assert_eq!(d.layers.len(), pdf.layers.len()); + assert!(w.is_empty()); + // Editing data that isn't for this page. + let other = native(&layer("Jig", true, &square(10, 10))).replace("%AI3_Cropmarks: 0 0 100 100", "%AI3_Cropmarks: 0 0 50 50"); + let (d, w) = crate::layered_ai(&private(&other), pdf.clone(), vec![]); + assert_eq!(names(&d), names(&pdf)); + assert!(w.iter().any(|w| w.contains("different page")), "{w:?}"); +} + +#[test] +fn a_gray_is_the_black_ink_as_in_a_cmyk_document() { + // `g` is a gray: 0.25 is 75 % of the black ink, as the page's own CMYK black says. + let art = native(&layer("Art", true, "0.25 g\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf")); + let page = "0 0 0 0.75 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto 10 14 lineto closepath fill"; + let r = import(&eps(page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let fill = r.document.layers[0].children().unwrap()[0].appearance.fill().and_then(|f| f.paint.color()); + assert_eq!(fill, Some(vectorcraft_color::Color::cmyk(0.0, 0.0, 0.0, 0.75))); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); +} + +/// `native` as a CS-era file keeps it: zlib, under `%AI9_DataStream`. +fn older_eps(page: &str, native: &str) -> Vec { + use std::io::Write; + let mut z = flate2::write::ZlibEncoder::new(vec![], flate2::Compression::default()); + z.write_all(native.as_bytes()).unwrap(); + let stream = ascii85(&z.finish().unwrap()); + let lines: Vec = stream.as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect(); + format!( + "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{page}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI9_DataStream\n{}\n%AI9_PrivateDataEnd\n", + lines.join("\n") + ) + .into_bytes() +} + +#[test] +fn files_from_before_zstandard_give_their_layers_too() { + let art = native(&(layer("Back", true, &square(10, 10)) + &layer("Hidden", false, &square(50, 50)))); + let r = import(&older_eps(&page_square(10, 10), &art)).unwrap(); + assert_eq!(names(&r.document), ["Back", "Hidden"], "{:?}", r.warnings); + assert_eq!([r.document.layers[0].visible, r.document.layers[1].visible], [true, false]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); +} + +#[test] +fn custom_colours_open_paths_and_planar_group_marks_are_read() { + // `x` and `X` are the custom colours of a fill and a stroke (four inks, a name, a tint), `Xx` one + // with its Lab values; `H` ends an open path, `XP` marks a group the app made from a planar map. + let body = "0.1 0.2 0.3 0.4 (Spot) 0 x\n0 0 0 1 (Edge) 0 X\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf\n\ + 0.8 0.1 0 0 62.7 -28 -42 (Blue) 0 2 Xx\n(Adobe Planar Group) 1 0 0 XP\n20 20 m\n24 20 L\n24 24 L\nH\nF"; + let r = import(&older_eps( + "0 0 0 0 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto 10 14 lineto closepath fill", + &native(&layer("Art", true, body)), + )) + .unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + assert_eq!(kinds(&r.document.layers[0]), ["path", "path"]); +} + +/// A text document of `stories` (each as the text, its alignment code, its anchor and the frame's +/// translation, and the start of its first line from the anchor), one font and a 14 point default. +/// A story: its text, alignment code, anchor, frame translation and first line's start. +type Story<'a> = (&'a str, u8, (f64, f64), (f64, f64), f64); + +fn text_document(stories: &[Story<'_>]) -> String { + let story = |(text, align, anchor, _t, start): &Story<'_>| { + let n = text.chars().count(); + format!( + "<< /0 << /0 ({text}) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 {align} >> /6 0 >> >> /1 {n} >> ] >> \ + /6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /1 {n} >> ] >> >> \ + /1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ {} {} ] >> /6 [ << /99 /R /6 [ << /99 /R /6 [ << /99 /L /6 [ \ + << /99 /S /0 << /0 [ {start} 0.0 ] >> /15 << /0 {n} >> >> ] >> ] >> ] >> ] >> ] >> ] >> >>", + anchor.0, anchor.1 + ) + }; + let frame = + |(_, _, _, t, _): &(&str, u8, (f64, f64), (f64, f64), f64)| format!("<< /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 {} {} ] >> >> >>", t.0, t.1); + format!( + "/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ {} ] >> >>\n/1 << /1 [ {} ] /2 << /1 12.0 >> >>\n", + stories.iter().map(frame).collect::>().join(" "), + stories.iter().map(story).collect::>().join(" ") + ) +} + +/// The editing copy of a 100 × 100 file with `layers` and the text `document`; its template box is +/// centred at (50, 50). +fn native_with_text(layers: &str, document: &str) -> String { + let lines: Vec = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect(); + 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%AI3_TemplateBox: 50 50 50 50\n\ + %AI5_NumLayers: 2\n{layers}%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n%%Trailer\n", + lines.join("\n") + ) +} + +/// A text object of story `story`, as the layers hold it. +fn text_object(story: u32) -> String { + format!("/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n{story} /StoryIndex ,\n2 /TextAntialiasing ,\n;\n") +} + +fn text_of(l: &Node) -> Vec<&vectorcraft_doc::TextObject> { + l.children().into_iter().flatten().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(&**t) } else { None }).collect() +} + +#[test] +fn type_on_a_hidden_layer_comes_from_the_text_document() { + // Canvas centre 8191.5 is the template box's centre (50, 50): the anchor is 8.5 right of it and + // 11.5 above it, so at (58.5, 61.5) in the art, which is 38.5 down the page. + let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Mid\r", 2, (8191.5, 8191.5), (0.0, 0.0), -10.0)]); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &(text_object(0) + &text_object(1)))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + assert_eq!(names(&r.document), ["Back", "Spare"], "{:?}", r.warnings); + let spare = &r.document.layers[1]; + assert!(!spare.visible); + let texts = text_of(spare); + assert_eq!(texts.len(), 2, "{:?}", r.warnings); + let [a, b] = [texts[0], texts[1]]; + assert_eq!(a.plain_text(), "Hi there"); + let [.., e, f] = a.xf.as_coeffs(); + assert!((e - 58.5).abs() < 1e-6 && (f - 38.5).abs() < 1e-6, "{:?}", a.xf); + assert_eq!(a.runs[0].style.size, 14.0); + assert_eq!(a.para.justify, vectorcraft_doc::Justify::Left); + // Centred type is anchored at its middle. + assert_eq!(b.para.justify, vectorcraft_doc::Justify::Center); + let [.., e, f] = b.xf.as_coeffs(); + assert!((e - 50.0).abs() < 1e-6 && (f - 50.0).abs() < 1e-6, "{:?}", b.xf); + assert!(!r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings); +} + +#[test] +fn type_with_a_frame_it_cant_read_is_left_out_with_a_note() { + // A story whose frame says more than a matrix (a text area, say) isn't guessed at. + let mut doc = text_document(&[("Area\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + doc = doc.replace("/2 << /2 [ 1 0 0 1 0 0 ] >>", "/2 << /2 [ 1 0 0 1 0 0 ] /7 1 >>"); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + assert!(text_of(&r.document.layers[1]).is_empty()); + assert!(r.warnings.iter().any(|w| w.contains("1 text objects on hidden layers")), "{:?}", r.warnings); + assert!(crate::is_loss(r.warnings.iter().find(|w| w.contains("couldn't be read")).unwrap())); +} + +#[test] +fn shown_type_keeps_the_pages_and_type_off_the_page_comes_from_the_text_document() { + // Story 0 is where the page draws "Hi there"; story 1 is off the page, so the page has nothing of it. + let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Away\r", 0, (8300.0, 8180.0), (0.0, 0.0), 0.0)]); + let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &doc); + let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show"; + let r = import(&eps(page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let texts = text_of(&r.document.layers[0]); + // The page's own object is a group (its type and strokes), and the other is made from the file. + let all: Vec = { + let mut v = vec![]; + r.document.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + v.push(t.plain_text()); + } + }); + v + }; + assert_eq!(all.len(), 2, "{all:?} {:?}", r.warnings); + assert!(all.contains(&"Hi there".to_string()) && all.contains(&"Away".to_string()), "{all:?}"); + assert_eq!(texts.len(), 1, "the type of the page sits in a group of its own"); + assert!(!r.warnings.iter().any(|w| w.contains("order the page paints")), "{:?}", r.warnings); +} + +#[test] +fn a_text_object_written_as_comments_is_read_like_any_other() { + let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let commented: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect(); + let art = native_with_text(&layer("Spare", false, &(square(10, 10) + "\n" + &commented)), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[0]); + assert_eq!(texts.len(), 1, "{:?}", r.warnings); + assert_eq!(texts[0].plain_text(), "Hi"); +} + +#[test] +fn an_area_type_object_with_its_own_nested_art_doesnt_swallow_the_art_after_it() { + let area = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n3 /StoryIndex ,\n/Art :\n1 Ap\n0 0 m\n9 0 L\n9 9 L\nn\n; /ConfiningPath ,\n2 /TextAntialiasing ,\n;\n"; + let art = native(&layer("Art", true, &(square(10, 10) + "\n" + area + &square(30, 30)))); + let r = import(&eps(&(page_square(10, 10) + "\n" + &page_square(30, 30)), &art)).unwrap(); + let paths = r.document.layers[0].children().map_or(0, Vec::len); + assert!(paths >= 2, "{paths} {:?}", r.warnings); +} + +#[test] +fn a_line_the_page_draws_in_pieces_goes_to_its_text_object_whole() { + // The page sets "Hi there" as "Hi" and " there" (as when kerning changes); the story is one line. + let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let art = native_with_text(&layer("Art", true, &text_object(0)), &doc); + let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi) show 70 61.5 moveto (there) show"; + let r = import(&eps(page, &art)).unwrap(); + let group = &r.document.layers[0].children().unwrap()[0]; + assert_eq!(kinds(group), ["text", "text"], "{:?}", r.warnings); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); +} + +#[test] +fn type_of_the_page_that_no_story_places_stays_with_the_nearest_text_object() { + let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let art = native_with_text(&layer("Art", true, &text_object(0)), &doc); + let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show 58.5 30 moveto (Extra) show"; + let r = import(&eps(page, &art)).unwrap(); + let mut all = vec![]; + r.document.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + all.push(t.plain_text()); + } + }); + assert!(all.contains(&"Extra".to_string()), "{all:?} {:?}", r.warnings); +} diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 7ccbab46..0877de80 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -34,7 +34,7 @@ mod ps; mod scene; mod tiff; -pub use import::{Imported, family_style, import}; +pub use import::{Imported, family_style, import, is_loss, layered_ai}; pub use print::{PrintJob, PrintPage, print}; use vectorcraft_doc::Document; diff --git a/crates/metafile/src/import.rs b/crates/metafile/src/import.rs index bee9d0a0..a5b5b04c 100644 --- a/crates/metafile/src/import.rs +++ b/crates/metafile/src/import.rs @@ -700,7 +700,7 @@ impl Player { return self.warn(&deep_clips()); } self.next_clip += 1; - self.dc.clips.push(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)) })); + self.dc.clips.push(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)), hidden: false })); } /// SelectClipPath (`mode`: 1 and, 5 copy; the others cut out, which isn't read). diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 86b7c685..92202a24 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -132,7 +132,7 @@ fn group_layer( /// The note on a file carrying an editor's private data (#472): its PDF part, the one read, holds /// only the art on its artboards (art on the pasteboard is in the private data alone). -pub(crate) const OFF_ARTBOARD_NOTE: &str = +pub const OFF_ARTBOARD_NOTE: &str = "only the PDF-compatible part of this file was read: art outside its artboards is kept in the editor's private data alone, so it doesn't open"; /// Import a PDF, returning the document plus warnings about content that was approximated or skipped. diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 26174e8f..ac73761d 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -69,8 +69,8 @@ mod syntax; pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing, editing_with}; pub use encrypt::Encryption; pub use export::{export, export_with_report, page_areas}; -pub use import::{import, import_with_report}; -pub use pages::{PageInfo, PdfInfo, info, is_postscript}; +pub use import::{OFF_ARTBOARD_NOTE, import, import_with_report}; +pub use pages::{PageInfo, PdfInfo, illustrator_data, info, is_postscript}; pub use post::{THUMBNAIL_SIZE, Thumbnail}; pub use presets::*; pub use print::*; diff --git a/crates/pdf/src/pages.rs b/crates/pdf/src/pages.rs index 701fcbb4..11764026 100644 --- a/crates/pdf/src/pages.rs +++ b/crates/pdf/src/pages.rs @@ -1,7 +1,7 @@ //! The pages of a PDF to import: opening with a password, the pages picked, the box each page //! is cropped to ([`CropTo`]) and [`info`]. -use hayro_syntax::object::Dict; +use hayro_syntax::object::{Dict, Stream}; use hayro_syntax::page::Page; use hayro_syntax::{DecryptionError, LoadPdfError, Pdf}; use kurbo::{Affine, Rect}; @@ -128,6 +128,30 @@ pub(crate) fn frame(page: &Page<'_>, which: CropTo) -> (Affine, Rect) { (init, if b.is_finite() && b.area() > 0.0 { b } else { Rect::new(0.0, 0.0, w as f64, h as f64) }) } +/// The `PieceInfo` key such files keep their private data under. +const PRIVATE_DATA_OWNER: &[u8] = b"Illustrator"; // brand-ok: the key the files use + +/// Most bytes of an editor's private data read. +const MAX_PRIVATE: usize = 512 << 20; + +/// The private data of an Illustrator `.ai` (its first page's `PieceInfo`): the `AIPrivateData` +/// streams in order, joined. `None` for a PDF without them (or with a stream that can't be read). +/// What it holds is the editor's own copy of the art (see `vectorcraft_eps::layered_ai`). +pub fn illustrator_data(bytes: &[u8], password: Option<&str>) -> Option> { + let pdf = open(bytes, password).ok()?; + let page = pdf.pages().first()?; + let private = page.raw().get::>(b"PieceInfo")?.get::>(PRIVATE_DATA_OWNER)?.get::>(b"Private")?; + let mut data = vec![]; + for n in 1..=100_000u32 { + let Some(stream) = private.get::>(format!("AIPrivateData{n}").as_bytes()) else { break }; + data.extend_from_slice(&stream.decoded().ok()?); + if data.len() > MAX_PRIVATE { + return None; + } + } + (!data.is_empty()).then_some(data) +} + /// Does `page` carry an editor's private data (`PieceInfo` keys starting with `AIPrivateData`)? /// A file saved without PDF compatibility keeps its art there and shows a placeholder page. pub(crate) fn has_private_data(page: &Page<'_>) -> bool { diff --git a/crates/pdf/src/tests_import_layers.rs b/crates/pdf/src/tests_import_layers.rs index bc496312..6a1e4b3f 100644 --- a/crates/pdf/src/tests_import_layers.rs +++ b/crates/pdf/src/tests_import_layers.rs @@ -366,3 +366,23 @@ fn art_past_the_scan_limit_of_shown_layers_stays_shown() { assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0)]); assert!(r.warnings.iter().any(|w| w.contains("went to its page's layer")), "{:?}", r.warnings); } + +/// The editor's private data of a `.ai` is its `AIPrivateData` streams, joined in order. +#[test] +fn an_ai_files_private_data_is_its_streams_joined() { + let (a, b) = (first_extra(1), first_extra(1) + 1); + let page = |ai: bool| PdfPage { + entries: if ai { + format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {a} 0 R /AIPrivateData2 {b} 0 R /NumBlock 2 >> >> >>") + } else { + String::new() + }, + ..PdfPage::new(100.0, 100.0, "1 0 0 rg 10 10 30 30 re f") + }; + let (one, two) = (stream("", "%AI24_ZStandard_Data first "), stream("", "second")); + let ai = pdf_with_catalog(&[page(true)], &[&one, &two], "", None); + assert_eq!(illustrator_data(&ai, None).unwrap(), b"%AI24_ZStandard_Data first second"); + // A PDF without them has none; neither has bytes that aren't a PDF. + assert!(illustrator_data(&pdf_with_catalog(&[page(false)], &[&one, &two], "", None), None).is_none()); + assert!(illustrator_data(b"not a pdf", None).is_none()); +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 290ea0bf..d6e1cc43 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3118,6 +3118,16 @@ axis Both Ambos # stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order # (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. +@msg a layer couldn't be read no se pudo leer una capa +@msg it has more than one artboard tiene más de una mesa de trabajo +@msg it has no page no tiene página +@msg its editing data doesn't say where its artboard is sus datos de edición no indican dónde está su mesa de trabajo +@msg its editing data has no layers sus datos de edición no tienen capas +@msg its editing data is damaged sus datos de edición están dañados +@msg its editing data is for a different page sus datos de edición son de otra página +@msg its layers use {_1}, which aren't read yet sus capas usan {_1}, que aún no se leen +@msg its page has text, and its layers have no text objects to put it in su página tiene texto y sus capas no tienen objetos de texto donde ponerlo +@msg the art on them differs from the page's in {_1}% of it el arte de las capas difiere del de la página en el {_1} % de su superficie @msg `to` must be "corner" or "smooth" `to` debe ser "corner" o "smooth" @msg BMP files are RGB or grayscale: colorModel rgb or gray los archivos BMP son RGB o en escala de grises: colorModel rgb o gray @msg Blend colors: select at least three objects with filled colours Fusionar colores: selecciona al menos tres objetos con relleno de color diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f833c0c5..ffc74232 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3118,6 +3118,16 @@ axis Both Entrambi # stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order # (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. +@msg a layer couldn't be read non è stato possibile leggere un livello +@msg it has more than one artboard ha più di una tavola da disegno +@msg it has no page non ha pagine +@msg its editing data doesn't say where its artboard is i suoi dati di modifica non indicano dove si trova la tavola da disegno +@msg its editing data has no layers i suoi dati di modifica non hanno livelli +@msg its editing data is damaged i suoi dati di modifica sono danneggiati +@msg its editing data is for a different page i suoi dati di modifica sono di un'altra pagina +@msg its layers use {_1}, which aren't read yet i suoi livelli usano {_1}, che non vengono ancora letti +@msg its page has text, and its layers have no text objects to put it in la sua pagina ha del testo e i suoi livelli non hanno oggetti di testo in cui metterlo +@msg the art on them differs from the page's in {_1}% of it l'illustrazione dei livelli differisce da quella della pagina per il {_1}% della sua superficie @msg `to` must be "corner" or "smooth" `to` deve essere "corner" o "smooth" @msg BMP files are RGB or grayscale: colorModel rgb or gray I file BMP sono RGB o in scala di grigi: colorModel rgb o gray @msg Blend colors: select at least three objects with filled colours Fondi colori: seleziona almeno tre oggetti con riempimento colore diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4929c927..8496a894 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3114,6 +3114,16 @@ axis Both Оба # stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order # (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. +@msg a layer couldn't be read не удалось прочитать слой +@msg it has more than one artboard в нём несколько монтажных областей +@msg it has no page в нём нет страницы +@msg its editing data doesn't say where its artboard is его данные для редактирования не указывают, где находится монтажная область +@msg its editing data has no layers в его данных для редактирования нет слоёв +@msg its editing data is damaged его данные для редактирования повреждены +@msg its editing data is for a different page его данные для редактирования относятся к другой странице +@msg its layers use {_1}, which aren't read yet его слои используют {_1}, что пока не читается +@msg its page has text, and its layers have no text objects to put it in на его странице есть текст, а на слоях нет текстовых объектов, куда его поместить +@msg the art on them differs from the page's in {_1}% of it изображение на слоях отличается от изображения на странице на {_1} % площади @msg `to` must be "corner" or "smooth" `to` должно быть "corner" или "smooth" @msg BMP files are RGB or grayscale: colorModel rgb or gray файлы BMP бывают RGB или в оттенках серого: colorModel rgb или gray @msg Blend colors: select at least three objects with filled colours Смешивание цветов: выберите не менее трёх объектов с цветными заливками From 618b1cf94f8b118322c02588e5156305bb67c489 Mon Sep 17 00:00:00 2001 From: Matthew Farrow Date: Thu, 8 Oct 2026 21:38:33 -0400 Subject: [PATCH 02/14] EPS import: a commented copy of a text object that has a plain one isn't another object Some files write a text object twice, once plainly and once as comments (and others only as comments). Reading both made a second copy of the type, built from the text document, over the page's own (wrong size and place). The commented copy is now skipped when its story has a plain text object. Co-Authored-By: Claude Sonnet 5.5 --- crates/eps/src/import/native.rs | 33 ++++++++++++++++++++++++--- crates/eps/src/import/tests_native.rs | 12 ++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index c2c8da57..863ea989 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -266,6 +266,16 @@ fn unzstd(packed: &[u8]) -> Option> { (data.len() as u64 != MAX_DECODED).then_some(data) } +/// The stories that text objects written plainly (not as comments) name. +fn plain_stories(data: &[u8]) -> BTreeSet { + data.split(|b| matches!(b, b'\r' | b'\n')) + .filter_map(|raw| { + let line = String::from_utf8_lossy(raw); + line.trim().strip_suffix("/StoryIndex ,").and_then(|n| n.trim().parse().ok()) + }) + .collect() +} + /// The layers of the editing copy `data`. fn parse(data: &[u8]) -> Option { let (mut bbox, mut hires, mut artboard, mut template) = (None, None, None, None); @@ -274,6 +284,10 @@ fn parse(data: &[u8]) -> Option { let mut dict_depth = 0usize; // In a text object's dictionary: the story it names, once the dictionary says. let mut text_story: Option> = None; + // Whether that text object is written as comments, which the file does for some of its stories + // twice (once plain, once commented) and for others only commented. + let mut text_commented = false; + let plain = plain_stories(data); let mut count = 0usize; for raw in data.split(|b| matches!(b, b'\r' | b'\n')) { let line = String::from_utf8_lossy(raw); @@ -284,8 +298,18 @@ fn parse(data: &[u8]) -> Option { // A text object's dictionary may be written as comments (`%_`), which a reader that doesn't // know text objects skips: what that reader skips is read here, for the text object only. let line = match line.strip_prefix("%_") { - Some(rest) if dict_depth > 0 || rest.starts_with("/AI11Text") && rest.ends_with(':') => rest.trim(), - _ => line, + Some(rest) if dict_depth > 0 || rest.starts_with("/AI11Text") && rest.ends_with(':') => { + if dict_depth == 0 { + text_commented = true; + } + rest.trim() + } + _ => { + if dict_depth == 0 { + text_commented = false; + } + line + } }; if line.starts_with('%') { match line { @@ -331,7 +355,10 @@ fn parse(data: &[u8]) -> Option { if dict_depth == 0 && let Some(story) = text_story.take() { - layer.code.push_str(&format!("{} __txt\n", story.map_or(-1, i64::from))); + // A commented copy of a story that has a plain text object is not another object. + if !(text_commented && story.is_some_and(|n| plain.contains(&n))) { + layer.code.push_str(&format!("{} __txt\n", story.map_or(-1, i64::from))); + } } } else if line.ends_with(':') { dict_depth += 1; diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 414d51ae..3eb6f571 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -436,3 +436,15 @@ fn type_of_the_page_that_no_story_places_stays_with_the_nearest_text_object() { }); assert!(all.contains(&"Extra".to_string()), "{all:?} {:?}", r.warnings); } + +#[test] +fn a_commented_copy_of_a_story_that_has_a_plain_text_object_is_not_another_object() { + // The file writes some stories twice (plain, then as comments) and others only as comments. + let doc = text_document(&[("One\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Two\r", 0, (8300.0, 8180.0), (0.0, 0.0), 0.0)]); + let commented = |story| -> String { text_object(story).lines().map(|l| format!("%_{l}\n")).collect() }; + let body = text_object(0) + &commented(0) + &commented(1); + let art = native_with_text(&layer("Spare", false, &body), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts: Vec = text_of(&r.document.layers[0]).iter().map(|t| t.plain_text()).collect(); + assert_eq!(texts, ["One", "Two"], "{:?}", r.warnings); +} From 2dc738b8169ce64807f67393e765b5e353fef1fc Mon Sep 17 00:00:00 2001 From: Matthew Farrow Date: Thu, 8 Oct 2026 21:59:45 -0400 Subject: [PATCH 03/14] EPS import: spell out abbreviated styles, and keep a line the page draws in pieces as one text - A PostScript font name's shortened style words (Md, Lt, Bd, XBd, Cn, It, ...) weren't matched to installed faces, so AkzidenzGroteskPro-Md came in as the condensed face. They are spelled out before the face is looked up. - A line the page draws in pieces (around kerning) came in as several text objects ("swe", "ep"); the pieces that add up to one line of the file's text object now join into one, with the line's spaces between them. Co-Authored-By: Claude Sonnet 5.5 --- crates/eps/src/import/native.rs | 84 ++++++++++++++++++++++++ crates/eps/src/import/tests_fontnames.rs | 10 +++ crates/eps/src/import/tests_native.rs | 7 +- crates/eps/src/import/text.rs | 23 ++++++- 4 files changed, 121 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 863ea989..100fede0 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -730,6 +730,85 @@ fn same_type(n: &Node, lines: &[(String, Point)]) -> Option { .min_by(f64::total_cmp) } +/// The page draws a line in pieces where its kerning or styles change ("swe", "ep"), and the file +/// has it as one line of one text object: the pieces that on one baseline add up to a line of +/// `lines` become one text (each keeping its own style, the line's spaces between them). +fn join_pieces(parts: &mut Vec>, lines: &[(String, Point)]) { + let text_of = |n: &Arc| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }; + let texts: Vec = parts.iter().enumerate().filter(|(_, n)| matches!(n.kind, NodeKind::Text(_))).map(|(i, _)| i).collect(); + let mut taken: Vec = vec![]; + // The first piece of each line to join, the others, and the spaces to put after each piece. + let mut joins: Vec<(usize, Vec, Vec)> = vec![]; + for &i in &texts { + if taken.contains(&i) { + continue; + } + let Some(line) = parts.get(i).and_then(|n| baseline(n)) else { continue }; + let group: Vec = texts + .iter() + .copied() + .filter(|j| !taken.contains(j) && parts.get(*j).and_then(|n| baseline(n)).is_some_and(|b| (b - line).abs() <= SAME_LINE)) + .collect(); + taken.extend(&group); + let pieces: Vec = group.iter().filter_map(|j| parts.get(*j).and_then(text_of)).collect(); + let places: Vec<(f64, f64)> = group + .iter() + .filter_map(|j| parts.get(*j)) + .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some((t.xf.as_coeffs()[4], t.xf.as_coeffs()[5])) } else { None }) + .collect(); + let distinct = places.iter().enumerate().all(|(a, p)| places.iter().skip(a + 1).all(|q| (p.0 - q.0).hypot(p.1 - q.1) > 0.01)); + let joined: String = pieces.iter().map(|p| letters(p)).collect(); + if group.len() < 2 || !distinct || joined.is_empty() { + continue; + } + let Some((whole, _)) = lines.iter().find(|(text, _)| letters(text) == joined) else { continue }; + // Where the line has spaces after a piece's last character that the page doesn't draw. + let line_chars: Vec = whole.chars().collect(); + let mut at = 0; + let mut gaps = vec![]; + for (k, piece) in pieces.iter().enumerate() { + for _ in piece.chars().filter(|c| !c.is_whitespace()) { + while line_chars.get(at).is_some_and(|c| c.is_whitespace()) { + at += 1; + } + at += 1; + } + let space: String = line_chars.iter().skip(at).take_while(|c| c.is_whitespace()).collect(); + let next = pieces.get(k + 1); + let drawn = piece.ends_with(char::is_whitespace) || next.is_none_or(|n| n.starts_with(char::is_whitespace)); + gaps.push(if drawn { String::new() } else { space }); + } + if at <= line_chars.len() { + joins.push((group[0], group[1..].to_vec(), gaps)); + } + } + for (first, rest, gaps) in joins.iter().rev() { + let mut runs: Vec> = [first] + .into_iter() + .chain(rest) + .filter_map(|j| parts.get(*j)) + .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.runs.clone()) } else { None }) + .collect(); + for (r, gap) in runs.iter_mut().zip(gaps) { + if let Some(last) = r.last_mut() { + last.text.push_str(gap); + } + } + if let Some(node) = parts.get_mut(*first).map(Arc::make_mut) + && let NodeKind::Text(t) = &mut node.kind + { + t.runs = runs.into_iter().flatten().collect(); + } + let mut gone: Vec = rest.clone(); + gone.sort_unstable_by(|a, b| b.cmp(a)); + for j in gone { + if j < parts.len() { + parts.remove(j); + } + } + } +} + /// The box round `parts`. fn extent(parts: &[Arc]) -> Option { parts.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)) @@ -787,6 +866,11 @@ fn plan( None => spare.push(object.clone()), } } + for (k, c) in content.iter_mut().enumerate() { + if let (Content::Page(parts), Some(Some(l))) = (c, lines.get(k)) { + join_pieces(parts, l); + } + } // Type of the text document where the page has none: hidden type, and shown type off the page. for (k, c) in content.iter_mut().enumerate() { if matches!(c, Content::Empty) diff --git a/crates/eps/src/import/tests_fontnames.rs b/crates/eps/src/import/tests_fontnames.rs index 8aa4c0ed..f0dd9c6c 100644 --- a/crates/eps/src/import/tests_fontnames.rs +++ b/crates/eps/src/import/tests_fontnames.rs @@ -46,3 +46,13 @@ fn reencoded_copies_of_a_font_name_the_font() { assert_eq!(crate::family_style("Odd*Name").0, "Odd*Name"); assert_eq!(crate::family_style("*1").0, "*1"); } + +#[test] +fn abbreviated_styles_in_postscript_names_are_spelled_out() { + // A style word PostScript names shorten is the style the installed font names in full. + assert_eq!(crate::family_style("AkzidenzGroteskPro-Md"), ("Akzidenz Grotesk Pro".to_string(), "Medium".to_string())); + assert_eq!(crate::family_style("AkzidenzGroteskPro-XBdCnIt").1, "Extra Bold Condensed Italic"); + assert_eq!(crate::family_style("AkzidenzGroteskPro-LightCn").1, "Light Condensed"); + // Whole words stay as they were. + assert_eq!(crate::family_style("Helvetica-BoldOblique").1, "Bold Oblique"); +} diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 3eb6f571..6fddba75 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -417,9 +417,12 @@ fn a_line_the_page_draws_in_pieces_goes_to_its_text_object_whole() { let art = native_with_text(&layer("Art", true, &text_object(0)), &doc); let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi) show 70 61.5 moveto (there) show"; let r = import(&eps(page, &art)).unwrap(); + // One text, with the line's space between the pieces. let group = &r.document.layers[0].children().unwrap()[0]; - assert_eq!(kinds(group), ["text", "text"], "{:?}", r.warnings); - assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert_eq!(kinds(group), ["text"], "{:?}", r.warnings); + let texts: Vec = + group.children().unwrap().iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect(); + assert_eq!(texts, ["Hi there"]); } #[test] diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 42de4d0b..702cc9f1 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -18,6 +18,27 @@ use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, ps_err}; /// The font a program uses before it sets one. const DEFAULT_FONT: &str = "Helvetica"; +/// A word of a style as PostScript names abbreviate it (`AkzidenzGroteskPro-BoldCnIt`), in full. +fn style_word(w: &str) -> String { + match w { + "Lt" => "Light", + "Md" | "Med" => "Medium", + "Bd" => "Bold", + "XBd" => "Extra Bold", + "Blk" => "Black", + "Hv" => "Heavy", + "Th" => "Thin", + "Rg" => "Regular", + "Sb" | "Smbd" => "Semibold", + "Cn" | "Cond" => "Condensed", + "Ext" | "Ex" => "Extended", + "It" => "Italic", + "Obl" => "Oblique", + w => w, + } + .to_string() +} + /// A font's family and style from its PostScript name: `Helvetica-BoldOblique` → (`Helvetica`, /// `Bold Oblique`), `TimesNewRomanPS-BoldMT` → (`Times New Roman`, `Bold`), without a subset /// prefix (`ABCDEF+`) or the `*1` of a re-encoded copy (`ABCDEF+RollerBabyBV*1`). @@ -47,7 +68,7 @@ pub fn family_style(name: &str) -> (String, String) { }; let style = match trim(style).as_str() { "" | "Roman" | "Regular" | "Book" | "Normal" => "Regular".to_string(), - s => s.to_string(), + s => s.split(' ').map(style_word).collect::>().join(" "), }; (trim(family), style) } From 94dfa20fa0be3c3ff784ac17dea0c410a659eeb0 Mon Sep 17 00:00:00 2001 From: Matthew Farrow Date: Thu, 8 Oct 2026 22:06:22 -0400 Subject: [PATCH 04/14] EPS import: joined text lays out its bounds afresh The pieces joined into one text kept the layout cached for the first piece, so the selection box covered only that piece. Co-Authored-By: Claude Sonnet 5.5 --- crates/eps/src/import/native.rs | 3 +++ crates/eps/src/import/tests_native.rs | 3 +++ 2 files changed, 6 insertions(+) diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 100fede0..e6b8edec 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -798,6 +798,9 @@ fn join_pieces(parts: &mut Vec>, lines: &[(String, Point)]) { && let NodeKind::Text(t) = &mut node.kind { t.runs = runs.into_iter().flatten().collect(); + // The layout cached for the first piece is not this text's. + t.cached_bounds = None; + t.cached_baselines.clear(); } let mut gone: Vec = rest.clone(); gone.sort_unstable_by(|a, b| b.cmp(a)); diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 6fddba75..93a00a5a 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -423,6 +423,9 @@ fn a_line_the_page_draws_in_pieces_goes_to_its_text_object_whole() { let texts: Vec = group.children().unwrap().iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect(); assert_eq!(texts, ["Hi there"]); + // Its bounds are laid out afresh for the whole text, not those of the first piece. + let NodeKind::Text(t) = &group.children().unwrap()[0].kind else { panic!("text") }; + assert!(t.cached_bounds.is_none() && t.cached_baselines.is_empty()); } #[test] From 2f0ede9c448f6d8b822641d4d091d813292f691c Mon Sep 17 00:00:00 2001 From: Matthew Farrow Date: Thu, 8 Oct 2026 22:25:15 -0400 Subject: [PATCH 05/14] EPS import: leave out text objects that the page doesn't draw Type turned to outlines leaves its text object in the file, which the app that wrote it doesn't draw (it paints every shown text object on the page). A text object made from the file's text document that shows on the page's area where the page has no type, and without which the layers look no worse against the page, is left out, with a note. Co-Authored-By: Claude Sonnet 5.5 --- crates/eps/src/import/native.rs | 84 ++++++++++++++++++++++++++- crates/eps/src/import/tests_native.rs | 18 ++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index e6b8edec..7045404b 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -656,6 +656,7 @@ fn fill_slots(doc: &mut Document, nodes: &mut Vec>, content: &mut std: Some(Content::Made(mut text)) => { text.id = doc.alloc_id(); text.visible = visible; + text.name = Some(MADE.into()); Some(*text) } Some(Content::Merged) => { @@ -1044,6 +1045,79 @@ fn build(data: &[u8], visible: &Document, base: Document, page: Page) -> Result< Ok(Built { out, layers }) } +/// Marks (in a node's name, until [`prune_unseen`] has looked) the text objects made from the file's +/// text document. +const MADE: &str = "\u{1}made"; +/// How many such objects are weighed against the page (each takes a render of the page's size). +const MAX_WEIGHED: usize = 12; +/// The text objects the file keeps that the page doesn't draw. +const UNSEEN_TEXT: &str = "text objects that the file keeps but its page doesn't draw (what is left of type turned to outlines) are left out"; +/// How much more of the page (0 to 1) the layers may differ without a text object and still look no worse. +const UNSEEN_MARGIN: f64 = 5e-5; + +/// Clear the marks in `nodes` and say which marked objects show, by id. +fn unmark(nodes: &mut [Arc], shown: bool, found: &mut Vec<(NodeId, Option)>) { + for n in nodes { + let shown = shown && n.visible; + if n.name.as_deref() == Some(MADE) { + if shown { + found.push((n.id, n.visual_bounds())); + } + Arc::make_mut(n).name = None; + } else if !matches!(n.kind, NodeKind::Compound { .. }) + && let Some(c) = Arc::make_mut(n).children_mut() + { + unmark(c, shown, found); + } + } +} + +/// Take object `id` out of `nodes` (`hide`: leave it, hidden); whether it was there. +fn take_out(nodes: &mut Vec>, id: NodeId, hide: bool) -> bool { + if let Some(i) = nodes.iter().position(|n| n.id == id) { + if hide { + if let Some(n) = nodes.get_mut(i) { + Arc::make_mut(n).visible = false; + } + } else { + nodes.remove(i); + } + return true; + } + nodes.iter_mut().any(|n| !matches!(n.kind, NodeKind::Compound { .. }) && Arc::make_mut(n).children_mut().is_some_and(|c| take_out(c, id, hide))) +} + +/// The text objects made from the file's text document that show on the page's area, where the page +/// has no type to match them, and that the layers look no worse without, are not drawn by the app +/// that wrote the file (which paints every shown text object; it keeps the type of what was turned +/// to outlines): they are taken out. How many. +fn prune_unseen(page: &Document, doc: &mut Document) -> usize { + let mut found = vec![]; + unmark(&mut doc.layers, true, &mut found); + let Some(rect) = page.artboards.first().map(|a| a.rect) else { return 0 }; + if found.is_empty() || found.len() > MAX_WEIGHED { + return 0; + } + let mut with = difference(page, doc, rect); + let mut removed = 0; + for (id, bounds) in found { + if !bounds.is_some_and(|b| !b.intersect(rect).is_zero_area()) { + continue; + } + let mut without = doc.clone(); + if !take_out(&mut without.layers, id, true) { + continue; + } + let d = difference(page, &without, rect); + if d - with <= UNSEEN_MARGIN { + take_out(&mut doc.layers, id, false); + with = d; + removed += 1; + } + } + removed +} + /// Do the layers' art and the page's own look alike? An error says they don't; `Ok` has the note /// to give when they differ a little. fn compare(page: &Document, layered: &Document) -> Result, String> { @@ -1070,6 +1144,10 @@ pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported { let base = Document::new(page.width(), page.height()); let result = build(&data, &visible.document, base, Page::Art).and_then(|built| { let mut done = finish_with(built.out, built.layers); + let unseen = prune_unseen(&visible.document, &mut done.document); + if unseen > 0 { + done.warnings.push(format!("{unseen} {UNSEEN_TEXT}")); + } let note = compare(&visible.document, &done.document)?; done.warnings.extend(note); Ok(done) @@ -1100,7 +1178,11 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec) -> ( let Some(data) = decode_ai(private) else { return (visible, warnings) }; let result = build(&data, &visible, visible.clone(), Page::Artboard).and_then(|built| { let mut notes = built.out.warnings.clone(); - let done = finish_layers(built.out, built.layers); + let mut done = finish_layers(built.out, built.layers); + let unseen = prune_unseen(&visible, &mut done); + if unseen > 0 { + notes.push(format!("{unseen} {UNSEEN_TEXT}")); + } notes.extend(compare(&visible, &done)?); Ok((done, notes)) }); diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 93a00a5a..e1d2708b 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -454,3 +454,21 @@ fn a_commented_copy_of_a_story_that_has_a_plain_text_object_is_not_another_objec let texts: Vec = text_of(&r.document.layers[0]).iter().map(|t| t.plain_text()).collect(); assert_eq!(texts, ["One", "Two"], "{:?}", r.warnings); } + +#[test] +fn a_shown_text_object_on_the_page_that_the_page_doesnt_draw_is_left_out() { + // Story 1 is at (58.5, 61.5) on the 100 × 100 page, where the page paints no type: the file + // keeps it but the app that wrote it doesn't draw it (type turned to outlines leaves one). + let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Ghost\r", 0, (8200.0, 8200.0), (0.0, 0.0), 0.0)]); + let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &doc); + let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show"; + let r = import(&eps(page, &art)).unwrap(); + let mut all = vec![]; + r.document.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + all.push(t.plain_text()); + } + }); + assert_eq!(all, ["Hi there"], "{:?}", r.warnings); + assert!(r.warnings.iter().any(|w| w.contains("doesn't draw")), "{:?}", r.warnings); +} From 319e60125f3c6b8bf9ab7eacc2584ca8eea00293 Mon Sep 17 00:00:00 2001 From: Matthew Farrow Date: Fri, 9 Oct 2026 03:05:50 -0400 Subject: [PATCH 06/14] EPS import: say plainly that no Adobe specification was used Co-Authored-By: Claude Sonnet 5.5 --- crates/eps/src/import/native.rs | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 7045404b..2240c937 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -22,21 +22,20 @@ //! one is on a layer that shows, the art of this copy would differ from the page's, so the layers //! aren't used: the file comes in as its page, as it did before. //! -//! Sources (public documentation only; no Adobe software was run and no Adobe file is in this repo): +//! Sources (no Adobe software was run, no Adobe file is in this repo, and no Adobe specification, +//! SDK or installation was used): //! -//! - The legacy format: *Adobe Illustrator File Format Specification*, version 7.0, published by its -//! maker for third-party developers; PRONOM lists it at -//! (the record links the document itself). -//! The layer, group, compound-path and clipping vocabulary is that format's; PRONOM's record for -//! versions 5.0/5.5 () shows the layer-era comments -//! date from then. +//! - The layer, group, compound-path and clipping vocabulary of the legacy format, the container +//! (`%AI9_PrivateDataBegin`, `%AI24_DataStream`, `%AI24_ZStandard_Data`, the `AIPrivateData` +//! streams), the meaning of the `Xw` hidden flag and the text document were worked out from `.eps` +//! and `.ai` files their users own, used locally and never committed. (Adobe published a +//! specification of the legacy format, listed by PRONOM at +//! ; it was not used here.) //! - The wrappers: ASCII85 is PostScript Level 2 (*PostScript Language Reference*, 3rd ed.); the -//! compressed stream is Zstandard, RFC 8878 (). -//! - The container (`%AI9_PrivateDataBegin`, `%AI24_DataStream`, `%AI24_ZStandard_Data`, the -//! `AIPrivateData` streams), the meaning of the `Xw` hidden flag and the text document are NOT in -//! those documents: they -//! were worked out from `.eps` and `.ai` files their users own, used locally. Where a file doesn't -//! match what this module expects, it is left alone and its page is imported. +//! compressed stream is Zstandard, RFC 8878 (), read with +//! the permissively licensed `ruzstd` crate. +//! +//! Where a file doesn't match what this module expects, it is left alone and its page is imported. use std::collections::BTreeSet; use std::io::Read; From 964985b68c502251c03794c551fe3f49c2a70e5c Mon Sep 17 00:00:00 2001 From: Matthew Farrow Date: Fri, 9 Oct 2026 03:09:52 -0400 Subject: [PATCH 07/14] EPS import: adapt text runs to inline art, refresh Cargo.lock Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 2 ++ crates/eps/src/import/ate.rs | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b7bc08f4..2da7e754 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4445,6 +4445,7 @@ dependencies = [ "kurbo 0.13.1", "log", "proptest", + "ruzstd", "serde", "serde_json", "thiserror 2.0.21", @@ -4476,6 +4477,7 @@ dependencies = [ "flate2", "image", "kurbo 0.13.1", + "ruzstd", "vectorcraft-brush", "vectorcraft-color", "vectorcraft-doc", diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 1e1a66dd..c5d7d8ab 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -475,11 +475,11 @@ impl Story { if piece.is_empty() { continue; } - runs.push(TextRun { text: piece, style: char_style(st, scale, leading) }); + runs.push(TextRun::new(piece, char_style(st, scale, leading))); } if from < chars.len() { let last = self.runs.last().map(|r| &r.1)?; - runs.push(TextRun { text: chars.get(from..).unwrap_or_default().iter().collect(), style: char_style(last, scale, leading) }); + runs.push(TextRun::new(chars.get(from..).unwrap_or_default().iter().collect::(), char_style(last, scale, leading))); } if runs.is_empty() { return None; From 365d6a70234d85ba0b5d9defa8ee188941ab2732 Mon Sep 17 00:00:00 2001 From: Matthew Farrow Date: Fri, 9 Oct 2026 03:14:56 -0400 Subject: [PATCH 08/14] i18n: French for the new import messages Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/i18n/fr.tsv | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b249163c..ebdd3e0a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3115,6 +3115,16 @@ axis Both Les deux # stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order # (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. +@msg a layer couldn't be read une couche n’a pas pu être lue +@msg it has more than one artboard il a plus d'un plan de travail +@msg it has no page il n’a pas de page +@msg its editing data doesn't say where its artboard is ses données de modification n’indiquent pas où se trouve son plan de travail +@msg its editing data has no layers ses données de modification n’ont pas de couches +@msg its editing data is damaged ses données de modification sont endommagées +@msg its editing data is for a different page ses données de modification concernent une autre page +@msg its layers use {_1}, which aren't read yet ses couches utilisent {_1}, qui ne sont pas encore lus +@msg its page has text, and its layers have no text objects to put it in sa page contient du texte, et ses couches n’ont aucun objet texte où le mettre +@msg the art on them differs from the page's in {_1}% of it le dessin des couches diffère de celui de la page sur {_1} % de sa surface @msg `to` must be "corner" or "smooth" `to` doit être "corner" ou "smooth" @msg BMP files are RGB or grayscale: colorModel rgb or gray Les fichiers BMP sont en RVB ou en niveaux de gris : colorModel rgb ou gray @msg Blend colors: select at least three objects with filled colours Fusion des couleurs : sélectionnez au moins trois objets dotés d’un fond de couleur From e622b87ca2ffb0ae074cd4f810325b16612db67b Mon Sep 17 00:00:00 2001 From: Matthew Farrow Date: Fri, 9 Oct 2026 03:20:52 -0400 Subject: [PATCH 09/14] EPS import: don't claim a specification as the source of the tests or the wrappers Co-Authored-By: Claude Sonnet 5.5 --- crates/eps/src/import/native.rs | 7 ++++--- crates/eps/src/import/tests_native.rs | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 2240c937..1f25a2a8 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -31,9 +31,10 @@ //! and `.ai` files their users own, used locally and never committed. (Adobe published a //! specification of the legacy format, listed by PRONOM at //! ; it was not used here.) -//! - The wrappers: ASCII85 is PostScript Level 2 (*PostScript Language Reference*, 3rd ed.); the -//! compressed stream is Zstandard, RFC 8878 (), read with -//! the permissively licensed `ruzstd` crate. +//! - The wrappers: the text is the standard ASCII85 encoding; the compressed stream is Zstandard, +//! RFC 8878 (), read with the permissively licensed +//! `ruzstd` crate (MIT); the older one is zlib, read with `flate2`. The PostScript the layers are +//! translated into is run by this project's own interpreter (`interp`). //! //! Where a file doesn't match what this module expects, it is left alone and its page is imported. diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index e1d2708b..75642387 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -1,5 +1,6 @@ //! The layers of an Illustrator EPS and `.ai`: read from the editing copy of the art the file -//! carries (see `native`). The files here are made from the public specification's operators. +//! carries (see `native`). The files here are small ones written by hand for the tests, with the +//! operators seen in files their users own. use vectorcraft_doc::{Document, LayerColor, Node, NodeKind}; From 8fe0fd8394dec0a6edf0c33d2564fd7d88cc7139 Mon Sep 17 00:00:00 2001 From: Matthew Farrow Date: Fri, 9 Oct 2026 03:49:45 -0400 Subject: [PATCH 10/14] EPS import: bound what hostile editing data can cost, and say when it can't be read - a story is read once however many text objects name it; the text objects, their pairs with the page's type, the pieces of a line and a story's size are capped, and over a cap the layers aren't used - the ASCII85 of the text document is bounded before it is decoded - joining the pieces of lines that interleave no longer removes the wrong pieces - a run length too large for the text saturates instead of overflowing - a dictionary left open at the end of a layer, and layers nested deeper than are read, make the file one that comes in as its page (they were silently dropped) - a file whose editing data can't be decoded says so, and the notes about art that was too much or too far away count as losses, so writing over the source asks first - the private data of a .ai is inflated under a cap of its own (the PDF reader's decoder has none) - the other writers (PDF, selection, office, print) ask before replacing a source that lost things, and a recovered copy remembers that Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/fileio/export.rs | 6 ++ crates/engine/src/cmd/fileio/pdf.rs | 3 + crates/engine/src/cmd/print.rs | 3 + crates/engine/src/cmd/recovery.rs | 15 +++- crates/engine/src/tests_recovery.rs | 16 ++++ crates/engine/tests/lossy_overwrite.rs | 24 ++++++ crates/eps/src/import/ate.rs | 17 +++- crates/eps/src/import/graphics.rs | 4 +- crates/eps/src/import/native.rs | 109 ++++++++++++++++++++----- crates/eps/src/import/tests_native.rs | 48 +++++++++++ crates/pdf/src/pages.rs | 29 +++++-- crates/pdf/src/tests_import_layers.rs | 3 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + 16 files changed, 255 insertions(+), 30 deletions(-) diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index ea0838d0..c88b4d10 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -169,6 +169,9 @@ pub(crate) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Do /// template layers are guides, not artwork, and are left out. pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result { const C: &str = "document.exportSelection"; + if let Some(path) = str_param(p, "path") { + super::check_not_lossy_overwrite(s.doc()?, path, p, C)?; + } let path = str_param(p, "path"); let f = writable(C, str_param(p, "format"), path)?; let expanded = expand_presets(s, C, p)?; @@ -184,6 +187,9 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result { /// `transparent`. pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result { const C: &str = "document.exportForOffice"; + if let Some(path) = str_param(p, "path") { + super::check_not_lossy_overwrite(s.doc()?, path, p, C)?; + } let path = str_param(p, "path"); let ppi = f64_or(p, "ppi", 150.0); let doc = &s.doc()?.doc; diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index e02ad9ac..9068c696 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -356,6 +356,9 @@ fn without_non_printing(layers: &[Arc]) -> Option>> { } fn export_pdf(s: &mut Session, p: &Value) -> Result { + if let Some(path) = str_param(p, "path") { + super::check_not_lossy_overwrite(s.doc()?, path, p, C)?; + } let expanded = expand_preset(s, C, p)?; let p = &*expanded; let (bytes, warnings) = encode(C, &s.doc()?.doc, p)?; diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index a5ecac06..2561797a 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -109,6 +109,9 @@ fn to_postscript(p: &Value) -> Result { } fn print(s: &mut Session, p: &Value) -> Result { + if let Some(path) = str_param(p, "path") { + super::fileio::check_not_lossy_overwrite(s.doc()?, path, p, PRINT)?; + } let postscript = to_postscript(p)?; let doc = &s.doc()?.doc; let mut set = settings(PRINT, doc, p)?; diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 033d541e..83623a50 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -523,6 +523,10 @@ struct Meta { format: String, /// When the copy was written (Unix seconds; none without a clock). saved: Option, + /// The file it was read from when that import left things out of the document, and what + /// (see [`crate::DocState::import_losses`]): a restored copy still refuses to replace it. + imported_from: Option, + import_losses: Vec, } fn read_meta(store: &dyn RecoveryStore, file: &str) -> Meta { @@ -651,7 +655,14 @@ pub fn jobs(s: &mut Session, store: &Arc) -> Result<(Vec, file: &str) -> R let index = s.add_document(doc, meta.path.clone()); let st = s.doc_mut()?; st.recovered = true; + st.imported_from = meta.imported_from.clone(); + st.import_losses = meta.import_losses.clone(); st.format = SAVE_FORMATS.iter().copied().find(|f| *f == meta.format).unwrap_or("vectorcraft"); st.mark_unsaved(); // The copy stays until the document is saved or closed. diff --git a/crates/engine/src/tests_recovery.rs b/crates/engine/src/tests_recovery.rs index 3637624e..80922774 100644 --- a/crates/engine/src/tests_recovery.rs +++ b/crates/engine/src/tests_recovery.rs @@ -122,6 +122,22 @@ fn a_crash_leaves_copies_that_restore_modified_and_titled() { assert!(s.execute("file.recovery.restore", &json!({"file": format!("{mine}/Poster-1")})).is_err(), "an open document's copy"); } +#[test] +fn a_restored_copy_still_refuses_to_replace_the_file_an_import_left_things_out_of() { + let (mut s, store) = session(); + s.doc_mut().unwrap().path = Some("/art/Poster.eps".into()); + s.doc_mut().unwrap().imported_from = Some("/art/Poster.eps".into()); + s.doc_mut().unwrap().import_losses = vec!["hidden layers have art this can't read".into()]; + s.execute("file.recovery.save", &json!({})).unwrap(); + drop(s); + + let mut s = relaunch(&store); + s.execute("file.recovery.restore", &json!({})).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(st.imported_from.as_deref(), Some("/art/Poster.eps")); + assert_eq!(st.import_losses, ["hidden layers have art this can't read"]); +} + #[test] fn another_running_apps_copies_are_never_offered() { let store = Arc::new(MemoryStore::default()); diff --git a/crates/engine/tests/lossy_overwrite.rs b/crates/engine/tests/lossy_overwrite.rs index 45820902..5e1b06e3 100644 --- a/crates/engine/tests/lossy_overwrite.rs +++ b/crates/engine/tests/lossy_overwrite.rs @@ -84,3 +84,27 @@ fn a_file_the_import_read_whole_is_written_over_as_before() { s.execute("document.export", &json!({ "path": src.to_string_lossy(), "format": "eps" })).unwrap(); std::fs::remove_dir_all(d).ok(); } + +#[test] +fn the_other_ways_of_writing_a_file_ask_too() { + let d = dir("c"); + let src = d.join("art.eps"); + let original = lossy_eps(); + std::fs::write(&src, &original).unwrap(); + let mut s = Session::new(); + s.execute("document.open", &json!({ "path": src.to_string_lossy() })).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + for (cmd, params) in [ + ("document.exportPdf", json!({})), + ("document.exportSelection", json!({ "format": "eps" })), + ("document.exportForOffice", json!({})), + ("file.print", json!({ "format": "pdf" })), + ] { + let mut p = params; + p["path"] = json!(src.to_string_lossy()); + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains("left things out"), "{cmd}: {e}"); + assert_eq!(std::fs::read(&src).unwrap(), original, "{cmd}"); + } + std::fs::remove_dir_all(d).ok(); +} diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index c5d7d8ab..dedfb280 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -24,6 +24,8 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaStyle, TextObject, TextRun}; use vectorcraft_geom::{Affine, Point}; +/// Longest story read (bytes). +const MAX_STORY: usize = 1 << 20; /// Biggest text document read (bytes, decoded). const MAX_BYTES: usize = 64 << 20; /// Deepest nesting read. @@ -289,6 +291,10 @@ impl Texts { let from = find(data, b"/ASCII85Decode", start)? + b"/ASCII85Decode".len(); let end = find(data, b"~>", from)? + 2; // Each line after the first starts with a `%`. + // ASCII85 makes at most four bytes of a character (`z`): more than this would be over `MAX_BYTES`. + if end - from > MAX_BYTES / 4 { + return None; + } let packed: String = String::from_utf8_lossy(data.get(from..end)?) .split(['\r', '\n']) .enumerate() @@ -321,7 +327,7 @@ impl Texts { /// Story `i`, if it is one of point type this reads. pub(super) fn story(&self, i: usize) -> Option { let s = self.stories.get(i)?; - let text = s.get("0")?.get("0")?.str()?.to_string(); + let text = s.get("0")?.get("0")?.str().filter(|t| t.len() <= MAX_STORY)?.to_string(); // The frame the story is in: a matrix and nothing else but its place. let frame = self.frames.get(s.get("1")?.get("0")?.at(0)?.get("0")?.num()? as usize)?.get("0")?; let carries = frame.get("2")?; @@ -425,6 +431,11 @@ fn find_nodes<'a>(v: &'a Val, name: &str, out: &mut Vec<&'a Val>) { /// Where a story's first line starts: on the canvas of the app (what [`Story::place`] gives). impl Story { + /// How many bytes of text the story has. + pub(super) fn len(&self) -> usize { + self.text.len() + } + /// The anchor of the story on the file's canvas: `(x, y)` in the units of the art, y up, given /// the centre `(tx, ty)` of the file's template box. pub(super) fn place(&self, template: (f64, f64)) -> (f64, f64) { @@ -466,10 +477,10 @@ impl Story { let leading = self.lines.windows(2).map(|w| (w[1].1 - w[0].1) * scale).find(|l| l.is_finite() && *l > 0.0); let chars: Vec = text.chars().collect(); let mut runs = vec![]; - let mut from = 0; + let mut from = 0usize; let styles: Vec<&(usize, Style)> = self.runs.iter().collect(); for (n, st) in styles { - let to = (from + n).min(chars.len()); + let to = from.saturating_add(*n).min(chars.len()); let piece: String = chars.get(from..to).unwrap_or_default().iter().collect(); from = to; if piece.is_empty() { diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs index 1fd8100c..1d63c1d6 100644 --- a/crates/eps/src/import/graphics.rs +++ b/crates/eps/src/import/graphics.rs @@ -35,8 +35,8 @@ const MAX_DRAWN: usize = 1 << 24; /// Samples taken of a shading function that isn't a plain interpolation. const SHADING_SAMPLES: usize = 32; -const FAR_AWAY: &str = "objects far outside the page were left out"; -const TOO_MUCH: &str = "the file draws more than VectorCraft reads: the rest was left out"; +pub(super) const FAR_AWAY: &str = "objects far outside the page were left out"; +pub(super) const TOO_MUCH: &str = "the file draws more than VectorCraft reads: the rest was left out"; const NESTED_PATTERNS: &str = "patterns nested too deeply are filled with mid-grey"; /// Deepest patterns and glyph procedures drawn inside each other's art. pub(crate) const MAX_APART: u32 = 8; diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 1f25a2a8..a322c480 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -69,13 +69,22 @@ const LAYERS_UNREAD: &str = "the file's layers weren't read"; /// Does this import note say that the file has something the document doesn't (hidden text, art or /// layers that could not be read)? Writing the document over the file would lose it for good. pub fn is_loss(note: &str) -> bool { - note.ends_with(TEXT_LEFT_OUT) || [ART_LEFT_OUT, PART_LEFT_OUT, LAYERS_UNREAD].iter().any(|n| note.starts_with(n)) + note.ends_with(TEXT_LEFT_OUT) + || [ART_LEFT_OUT, PART_LEFT_OUT, LAYERS_UNREAD, super::graphics::TOO_MUCH, super::graphics::FAR_AWAY].iter().any(|n| note.starts_with(n)) } -/// Deepest nesting of layers read (deeper ones are left out). +/// Deepest nesting of layers read (a file with deeper ones comes in as its page). const MAX_DEPTH: usize = 32; /// Most layers read. const MAX_LAYERS: usize = 4096; +/// Most text objects in the layers, and most pieces of type in one place on the page, that are matched +/// to each other (more than this and the layers aren't used). +const MAX_SLOTS: usize = 5000; +const MAX_PIECES: usize = 2000; +/// Most bytes of text in the text objects of the layers (a story named twice counts twice). +const MAX_TEXT: usize = 16 << 20; +/// Most pairs of a text object and a piece of the page's type compared. +const MAX_PAIRS: usize = 4_000_000; /// What the operators of the format do, as PostScript: paths are the path operators, a fill or a /// stroke uses the colour the last `k` `K` `g` `G` set (a file's objects each set their own). @@ -315,7 +324,8 @@ fn parse(data: &[u8]) -> Option { match line { "%AI5_BeginLayer" => { count += 1; - if count > MAX_LAYERS { + // Too many, too deep, or in the middle of a dictionary: not a file this reads. + if count > MAX_LAYERS || stack.len() > MAX_DEPTH || dict_depth > 0 { return None; } if let Some(parent) = stack.last_mut() { @@ -324,6 +334,9 @@ fn parse(data: &[u8]) -> Option { stack.push(Layer { visible: true, ..Layer::default() }); } "%AI5_EndLayer--" => { + if dict_depth > 0 { + return None; + } let mut l = stack.pop()?; l.flush(); match stack.last_mut() { @@ -425,6 +438,9 @@ fn parse(data: &[u8]) -> Option { layer.code.push('\n'); } } + if dict_depth > 0 { + return None; + } while let Some(mut l) = stack.pop() { l.flush(); match stack.last_mut() { @@ -737,20 +753,30 @@ fn same_type(n: &Node, lines: &[(String, Point)]) -> Option { fn join_pieces(parts: &mut Vec>, lines: &[(String, Point)]) { let text_of = |n: &Arc| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }; let texts: Vec = parts.iter().enumerate().filter(|(_, n)| matches!(n.kind, NodeKind::Text(_))).map(|(i, _)| i).collect(); - let mut taken: Vec = vec![]; + // A page with this many pieces of type in one place isn't one of lines of a story. + if texts.len() > MAX_PIECES { + return; + } + let mut taken = vec![false; parts.len()]; // The first piece of each line to join, the others, and the spaces to put after each piece. let mut joins: Vec<(usize, Vec, Vec)> = vec![]; for &i in &texts { - if taken.contains(&i) { + if taken.get(i).copied().unwrap_or(true) { continue; } let Some(line) = parts.get(i).and_then(|n| baseline(n)) else { continue }; let group: Vec = texts .iter() .copied() - .filter(|j| !taken.contains(j) && parts.get(*j).and_then(|n| baseline(n)).is_some_and(|b| (b - line).abs() <= SAME_LINE)) + .filter(|j| { + !taken.get(*j).copied().unwrap_or(true) && parts.get(*j).and_then(|n| baseline(n)).is_some_and(|b| (b - line).abs() <= SAME_LINE) + }) .collect(); - taken.extend(&group); + for j in &group { + if let Some(t) = taken.get_mut(*j) { + *t = true; + } + } let pieces: Vec = group.iter().filter_map(|j| parts.get(*j).and_then(text_of)).collect(); let places: Vec<(f64, f64)> = group .iter() @@ -783,7 +809,8 @@ fn join_pieces(parts: &mut Vec>, lines: &[(String, Point)]) { joins.push((group[0], group[1..].to_vec(), gaps)); } } - for (first, rest, gaps) in joins.iter().rev() { + let mut gone: BTreeSet = BTreeSet::new(); + for (first, rest, gaps) in &joins { let mut runs: Vec> = [first] .into_iter() .chain(rest) @@ -803,14 +830,15 @@ fn join_pieces(parts: &mut Vec>, lines: &[(String, Point)]) { t.cached_bounds = None; t.cached_baselines.clear(); } - let mut gone: Vec = rest.clone(); - gone.sort_unstable_by(|a, b| b.cmp(a)); - for j in gone { - if j < parts.len() { - parts.remove(j); - } - } + gone.extend(rest); } + // The pieces that went into a text, removed once the joins are done: removing as each is made + // would move the places of the pieces that the others still name. + let mut keep = 0; + parts.retain(|_| { + keep += 1; + !gone.contains(&(keep - 1)) + }); } /// The box round `parts`. @@ -823,7 +851,7 @@ fn extent(parts: &[Arc]) -> Option { /// the page's type in painting order. fn plan( infos: &[SlotInfo], - stories: &[Option], + stories: &[Option>], template: Option<(f64, f64)>, to_doc: Affine, pages: [&[Vec>]; 2], @@ -1026,8 +1054,23 @@ fn build(data: &[u8], visible: &Document, base: Document, page: Page) -> Result< if infos.iter().all(|i| !i.shown) && !shown.is_empty() { return Err("its page has text, and its layers have no text objects to put it in".into()); } + // The page's type is compared with every text object of the layers: that has its limits. + if infos.len() > MAX_SLOTS || infos.len().saturating_mul(shown.len()) > MAX_PAIRS { + return Err("it has too many text objects".into()); + } let texts = Texts::read(data); - let stories: Vec> = infos.iter().map(|i| texts.as_ref().and_then(|t| t.story(i.story? as usize))).collect(); + // Each story is read once, however many text objects name it. + let mut read: std::collections::BTreeMap>> = Default::default(); + let stories: Vec>> = infos + .iter() + .map(|i| { + let n = i.story?; + read.entry(n).or_insert_with(|| texts.as_ref().and_then(|t| t.story(n as usize)).map(Arc::new)).clone() + }) + .collect(); + if stories.iter().flatten().map(|s| s.len()).sum::() > MAX_TEXT { + return Err("it has too much text".into()); + } let template = parsed.template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0)); let mut notes = vec![]; let content = plan(&infos, &stories, template, to_doc, [&shown, &hidden], &mut notes); @@ -1138,8 +1181,15 @@ fn compare(page: &Document, layered: &Document) -> Result, String /// hidden ones too), groups, compound paths and clipping groups, with the text and the strokes /// around it of the page. When the file has no editing copy this reads, `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) = decode(ps) else { return visible }; +pub(super) fn layered(ps: &[u8], mut visible: Imported) -> Imported { + let Some(data) = decode(ps) else { + // A file that says it has the editing copy but has none that can be read is a loss too. + let has = |n: &[u8]| ps.windows(n.len()).any(|w| w == n); + if has(BEGIN) && (has(STREAM) || has(STREAM_ZLIB)) { + visible.warnings.push(format!("{LAYERS_UNREAD} (its editing data is damaged or too large): it comes in as its page, in one layer")); + } + return visible; + }; let Some(page) = visible.document.artboards.first().map(|a| a.rect) else { return visible }; let base = Document::new(page.width(), page.height()); let result = build(&data, &visible.document, base, Page::Art).and_then(|built| { @@ -1205,3 +1255,24 @@ fn finish_layers(out: Out, layers: Vec>) -> Document { doc.layers = layers; doc } + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_doc::{CharStyle, TextObject}; + + fn piece(text: &str, x: f64, y: f64) -> Arc { + Arc::new(Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(x, y), text, CharStyle::default()))))) + } + + /// Lines that the page draws in pieces, the pieces of one between those of the other, are each + /// joined, and only their own pieces go. + #[test] + fn interleaved_lines_are_joined_without_taking_each_others_pieces() { + let mut parts = vec![piece("He", 0.0, 0.0), piece("ab", 0.0, -20.0), piece("cd", 16.0, -20.0), piece("llo", 20.0, 0.0)]; + let lines = [("Hello".to_string(), Point::new(0.0, 0.0)), ("abcd".to_string(), Point::new(0.0, -20.0))]; + join_pieces(&mut parts, &lines); + let texts: Vec = parts.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect(); + assert_eq!(texts, ["Hello", "abcd"]); + } +} diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 75642387..20a3ff61 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -188,6 +188,9 @@ fn a_damaged_editing_copy_leaves_the_page() { let r = import(&bytes).unwrap(); assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings); } + // A file that has the data but not in a form that can be read says so (and a write over it asks). + let r = import(&plain("this is not ascii85 {{{~>")).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("damaged") && crate::is_loss(w)), "{:?}", r.warnings); } /// The `.ai`'s editing data: its marker, then the Zstandard stream. @@ -473,3 +476,48 @@ fn a_shown_text_object_on_the_page_that_the_page_doesnt_draw_is_left_out() { assert_eq!(all, ["Hi there"], "{:?}", r.warnings); assert!(r.warnings.iter().any(|w| w.contains("doesn't draw")), "{:?}", r.warnings); } + +#[test] +fn pieces_of_two_lines_the_page_draws_alternately_go_each_to_its_own_text() { + // "Hello" is drawn as "He" and "llo", with "abcd" ("ab", "cd") drawn between them. + let doc = text_document(&[("Hello\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("abcd\r", 0, (8200.0, 8160.0), (0.0, 0.0), 0.0)]); + let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &doc); + let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (He) show 58.5 81.5 moveto (ab) show \ + 74.1 81.5 moveto (cd) show 76.4 61.5 moveto (llo) show"; + let r = import(&eps(page, &art)).unwrap(); + let mut all = vec![]; + r.document.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + all.push(t.plain_text()); + } + }); + all.sort(); + assert_eq!(all, ["Hello", "abcd"], "{:?}", r.warnings); +} + +#[test] +fn a_run_longer_than_any_text_doesnt_overflow() { + let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]).replace("/1 9 >>", "/1 1e30 >>"); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + assert_eq!(names(&r.document), ["Back", "Spare"], "{:?}", r.warnings); +} + +#[test] +fn more_text_objects_than_are_matched_leave_the_page() { + let many = text_object(0).repeat(5001); + let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &many)), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings); + assert!(r.warnings.iter().any(|w| w.contains("too many text objects") && crate::is_loss(w)), "{:?}", r.warnings); +} + +#[test] +fn a_dictionary_left_open_at_the_end_of_a_layer_is_a_damaged_file() { + let open = "/AI11Text :\n0 /FreeUndo ,\n"; + let art = native(&(layer("A", true, &(square(10, 10) + open)) + &layer("B", true, &square(20, 20)))); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings); + assert!(r.warnings.iter().any(|w| crate::is_loss(w)), "{:?}", r.warnings); +} diff --git a/crates/pdf/src/pages.rs b/crates/pdf/src/pages.rs index 11764026..88359e91 100644 --- a/crates/pdf/src/pages.rs +++ b/crates/pdf/src/pages.rs @@ -132,7 +132,7 @@ pub(crate) fn frame(page: &Page<'_>, which: CropTo) -> (Affine, Rect) { const PRIVATE_DATA_OWNER: &[u8] = b"Illustrator"; // brand-ok: the key the files use /// Most bytes of an editor's private data read. -const MAX_PRIVATE: usize = 512 << 20; +const MAX_PRIVATE: usize = 256 << 20; /// The private data of an Illustrator `.ai` (its first page's `PieceInfo`): the `AIPrivateData` /// streams in order, joined. `None` for a PDF without them (or with a stream that can't be read). @@ -144,14 +144,33 @@ pub fn illustrator_data(bytes: &[u8], password: Option<&str>) -> Option> let mut data = vec![]; for n in 1..=100_000u32 { let Some(stream) = private.get::>(format!("AIPrivateData{n}").as_bytes()) else { break }; - data.extend_from_slice(&stream.decoded().ok()?); - if data.len() > MAX_PRIVATE { - return None; - } + data.extend_from_slice(&inflated(&stream, MAX_PRIVATE - data.len())?); } (!data.is_empty()).then_some(data) } +/// `stream` decoded, up to `most` bytes: the decoder is run here rather than by the PDF reader's +/// `decoded()`, which has no limit, so a stream of zeros can't grow to the size of the memory. Only a +/// stream that is plain or only deflated is read (the private data is no other); else, or when it +/// is larger once decoded, `None`. +fn inflated(stream: &Stream<'_>, most: usize) -> Option> { + use std::io::Read; + if stream.dict().get::>(b"DecodeParms").is_some() { + return None; + } + let raw = stream.raw_data(); + let mut out = vec![]; + let limit = u64::try_from(most).ok()?.checked_add(1)?; + match stream.filters().len() { + 0 => out.extend_from_slice(&raw), + 1 => { + flate2::read::ZlibDecoder::new(&*raw).take(limit).read_to_end(&mut out).ok()?; + } + _ => return None, + } + (out.len() <= most).then_some(out) +} + /// Does `page` carry an editor's private data (`PieceInfo` keys starting with `AIPrivateData`)? /// A file saved without PDF compatibility keeps its art there and shows a placeholder page. pub(crate) fn has_private_data(page: &Page<'_>) -> bool { diff --git a/crates/pdf/src/tests_import_layers.rs b/crates/pdf/src/tests_import_layers.rs index 6a1e4b3f..937a65d9 100644 --- a/crates/pdf/src/tests_import_layers.rs +++ b/crates/pdf/src/tests_import_layers.rs @@ -385,4 +385,7 @@ fn an_ai_files_private_data_is_its_streams_joined() { // A PDF without them has none; neither has bytes that aren't a PDF. assert!(illustrator_data(&pdf_with_catalog(&[page(false)], &[&one, &two], "", None), None).is_none()); assert!(illustrator_data(b"not a pdf", None).is_none()); + // Only a plain or a deflated stream is read: a chain of filters is refused, not run unbounded. + let chained = stream("/Filter [/ASCIIHexDecode /ASCIIHexDecode]", "61>"); + assert!(illustrator_data(&pdf_with_catalog(&[page(true)], &[&chained, &two], "", None), None).is_none()); } diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d6e1cc43..068cd35f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3121,6 +3121,8 @@ axis Both Ambos @msg a layer couldn't be read no se pudo leer una capa @msg it has more than one artboard tiene más de una mesa de trabajo @msg it has no page no tiene página +@msg it has too many text objects tiene demasiados objetos de texto +@msg it has too much text tiene demasiado texto @msg its editing data doesn't say where its artboard is sus datos de edición no indican dónde está su mesa de trabajo @msg its editing data has no layers sus datos de edición no tienen capas @msg its editing data is damaged sus datos de edición están dañados diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ebdd3e0a..2add225c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3118,6 +3118,8 @@ axis Both Les deux @msg a layer couldn't be read une couche n’a pas pu être lue @msg it has more than one artboard il a plus d'un plan de travail @msg it has no page il n’a pas de page +@msg it has too many text objects il a trop d’objets texte +@msg it has too much text il a trop de texte @msg its editing data doesn't say where its artboard is ses données de modification n’indiquent pas où se trouve son plan de travail @msg its editing data has no layers ses données de modification n’ont pas de couches @msg its editing data is damaged ses données de modification sont endommagées diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ffc74232..ec309b0e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3121,6 +3121,8 @@ axis Both Entrambi @msg a layer couldn't be read non è stato possibile leggere un livello @msg it has more than one artboard ha più di una tavola da disegno @msg it has no page non ha pagine +@msg it has too many text objects ha troppi oggetti di testo +@msg it has too much text ha troppo testo @msg its editing data doesn't say where its artboard is i suoi dati di modifica non indicano dove si trova la tavola da disegno @msg its editing data has no layers i suoi dati di modifica non hanno livelli @msg its editing data is damaged i suoi dati di modifica sono danneggiati diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8496a894..c13dbad0 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3117,6 +3117,8 @@ axis Both Оба @msg a layer couldn't be read не удалось прочитать слой @msg it has more than one artboard в нём несколько монтажных областей @msg it has no page в нём нет страницы +@msg it has too many text objects в нём слишком много текстовых объектов +@msg it has too much text в нём слишком много текста @msg its editing data doesn't say where its artboard is его данные для редактирования не указывают, где находится монтажная область @msg its editing data has no layers в его данных для редактирования нет слоёв @msg its editing data is damaged его данные для редактирования повреждены From 5c040e5095cb787f5a7250c386c700554a72a70d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 15:05:11 +0700 Subject: [PATCH 11/14] EPS import: cap the Zstandard window a hostile frame can ask for Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/native.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index a322c480..a40e2d9d 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -60,6 +60,8 @@ const STREAM_ZLIB: &[u8] = b"%AI9_DataStream"; const ZSTD_MARK: &[u8] = b"%AI24_ZStandard_Data"; /// Most bytes the editing copy may have once decompressed. const MAX_DECODED: u64 = 256 << 20; +/// The largest Zstandard window a frame may ask for: the decoder allocates it before reading. +const MAX_ZSTD_WINDOW: u64 = 64 << 20; /// The notes that say the import left out something the file has (see [`is_loss`]). const TEXT_LEFT_OUT: &str = "text objects on hidden layers or hidden objects couldn't be read, so they are left out"; const ART_LEFT_OUT: &str = "hidden layers have art this can't read"; @@ -269,7 +271,7 @@ fn unzlib(packed: &[u8]) -> Option> { /// `packed` as a Zstandard stream decompressed (up to [`MAX_DECODED`] bytes). fn unzstd(packed: &[u8]) -> Option> { - let mut zstd = ruzstd::decoding::StreamingDecoder::new(packed).ok()?; + let mut zstd = ruzstd::decoding::StreamingDecoder::new_with_max_window_size(packed, MAX_ZSTD_WINDOW).ok()?; let mut data = vec![]; (&mut zstd).take(MAX_DECODED).read_to_end(&mut data).ok()?; (data.len() as u64 != MAX_DECODED).then_some(data) From 5f4f82d8856609936ef82ae1736d6054f618756c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 15:05:11 +0700 Subject: [PATCH 12/14] i18n: French says calque for a layer in the new import messages Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/fr.tsv | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2add225c..55c44afa 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2144,6 +2144,8 @@ Reverse Spine Inverser la colonne vertébrale Revert Version précédente Revert to the saved version of “{name}”? Revenir à la version enregistrée de « {name} » ? + Replace “{name}”, the file this document was opened from? Remplacer « {name} », le fichier à partir duquel ce document a été ouvert ? + Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. Son ouverture a laissé de côté ce que VectorCraft ne sait pas encore lire ({what}) : le remplacer le perd définitivement. Enregistrez sous un autre nom pour le conserver. Revolve… Révolution… RGB Color Couleur RVB Right Droite @@ -3115,18 +3117,18 @@ axis Both Les deux # stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order # (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. -@msg a layer couldn't be read une couche n’a pas pu être lue +@msg a layer couldn't be read un calque n’a pas pu être lu @msg it has more than one artboard il a plus d'un plan de travail @msg it has no page il n’a pas de page @msg it has too many text objects il a trop d’objets texte @msg it has too much text il a trop de texte @msg its editing data doesn't say where its artboard is ses données de modification n’indiquent pas où se trouve son plan de travail -@msg its editing data has no layers ses données de modification n’ont pas de couches +@msg its editing data has no layers ses données de modification n’ont pas de calques @msg its editing data is damaged ses données de modification sont endommagées @msg its editing data is for a different page ses données de modification concernent une autre page -@msg its layers use {_1}, which aren't read yet ses couches utilisent {_1}, qui ne sont pas encore lus -@msg its page has text, and its layers have no text objects to put it in sa page contient du texte, et ses couches n’ont aucun objet texte où le mettre -@msg the art on them differs from the page's in {_1}% of it le dessin des couches diffère de celui de la page sur {_1} % de sa surface +@msg its layers use {_1}, which aren't read yet ses calques utilisent {_1}, qui ne sont pas encore lus +@msg its page has text, and its layers have no text objects to put it in sa page contient du texte, et ses calques n’ont aucun objet texte où le mettre +@msg the art on them differs from the page's in {_1}% of it le dessin des calques diffère de celui de la page sur {_1} % de sa surface @msg `to` must be "corner" or "smooth" `to` doit être "corner" ou "smooth" @msg BMP files are RGB or grayscale: colorModel rgb or gray Les fichiers BMP sont en RVB ou en niveaux de gris : colorModel rgb ou gray @msg Blend colors: select at least three objects with filled colours Fusion des couleurs : sélectionnez au moins trois objets dotés d’un fond de couleur From 0559aac53e70c679c14361178ca522b9107cf164 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 15:05:12 +0700 Subject: [PATCH 13/14] Save and Export ask before replacing a file whose import left things out The engine refuses to write over the file a document was read from when reading it left something out, unless acknowledgeLoss is passed. In the app that surfaced as an error naming a parameter. File > Save, Save As, Save a Copy, Save as Template and Export As now ask instead ("Replace 'a.eps', the file this document was opened from?", with what would be lost), and OK repeats the command with acknowledgeLoss. Closing a document whose save asks keeps it open, and so does Package. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fileio/load.rs | 27 ++++++++++---- crates/engine/src/cmd/fileio/mod.rs | 1 + crates/ui-egui/src/dialogs/package.rs | 6 ++- crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/io.rs | 54 ++++++++++++++++++++++++++- crates/ui-egui/src/tests_save.rs | 39 +++++++++++++++++++ crates/ui-egui/src/unsaved.rs | 5 +++ 13 files changed, 135 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 788d3420..73aa81c2 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -395,14 +395,8 @@ fn is_loss(note: &str) -> bool { /// it left something out (hidden text, art or layers), unless `p` has `acknowledgeLoss: true`. /// Another name keeps the original file, with all it has. pub(crate) fn check_not_lossy_overwrite(st: &crate::DocState, target: &str, p: &Value, cmd: &str) -> Result<()> { - let Some(source) = st.imported_from.as_deref().filter(|_| !st.import_losses.is_empty()) else { return Ok(()) }; - if bool_or(p, "acknowledgeLoss", false) || !same_file(source, target) { - return Ok(()); - } - let mut what = st.import_losses.iter().take(3).map(|n| format!("“{n}”")).collect::>().join("; "); - if st.import_losses.len() > 3 { - what.push_str(&format!("; {} more", st.import_losses.len() - 3)); - } + let Some(losses) = overwrite_losses(st, target, p) else { return Ok(()) }; + let what = losses_summary(losses); Err(bad( cmd, format!( @@ -411,6 +405,23 @@ pub(crate) fn check_not_lossy_overwrite(st: &crate::DocState, target: &str, p: & )) } +/// What writing the file `target` for document `st` would lose for good: the notes of what reading +/// it left out when `target` is the file `st` was read from, unless `p` has `acknowledgeLoss: true`. +/// The UI asks before such a write and repeats it with `acknowledgeLoss` ([`check_not_lossy_overwrite`]). +pub fn overwrite_losses<'a>(st: &'a crate::DocState, target: &str, p: &Value) -> Option<&'a [String]> { + let source = st.imported_from.as_deref().filter(|_| !st.import_losses.is_empty())?; + (!bool_or(p, "acknowledgeLoss", false) && same_file(source, target)).then_some(st.import_losses.as_slice()) +} + +/// Up to three of `losses`, quoted, and how many more. +pub fn losses_summary(losses: &[String]) -> String { + let mut what = losses.iter().take(3).map(|n| format!("“{n}”")).collect::>().join("; "); + if losses.len() > 3 { + what.push_str(&format!("; {} more", losses.len() - 3)); + } + what +} + /// Do `a` and `b` name one file (spelled differently, or through a link)? fn same_file(a: &str, b: &str) -> bool { match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index dad634b6..4a71c4db 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -53,6 +53,7 @@ pub(crate) use load::import_svg; pub(crate) use load::native_file; pub(crate) use load::source; pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image}; +pub use load::{losses_summary, overwrite_losses}; pub use native::with_compression_pref; pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; diff --git a/crates/ui-egui/src/dialogs/package.rs b/crates/ui-egui/src/dialogs/package.rs index 234141c9..ca49854f 100644 --- a/crates/ui-egui/src/dialogs/package.rs +++ b/crates/ui-egui/src/dialogs/package.rs @@ -104,7 +104,11 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } // The package holds the document as saved. if app.session.active().is_some_and(|st| st.is_dirty()) { - io::save(app, vectorcraft_engine::cmd::fileio::SaveMode::Save, &json!({}), false)?; + let r = io::save(app, vectorcraft_engine::cmd::fileio::SaveMode::Save, &json!({}), false)?; + // The save asks first (it would replace a file that loses what opening it left out). + if r.get("pending").is_some() { + return Ok(r); + } } let r = app.run("file.package", params)?; app.ui.dialog = None; diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 068cd35f..ae9cfbfe 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2147,6 +2147,8 @@ Reverse Spine Invertir eje Revert Revertir Revert to the saved version of “{name}”? ¿Revertir a la versión guardada de “{name}”? + Replace “{name}”, the file this document was opened from? ¿Reemplazar “{name}”, el archivo desde el que se abrió este documento? + Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. Al abrirlo quedó fuera lo que VectorCraft aún no puede leer ({what}), así que reemplazarlo lo pierde para siempre. Guárdelo con otro nombre para conservarlo. Revolve… Revolución… RGB Color Color RGB Right Derecha diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ec309b0e..6bcba24b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2147,6 +2147,8 @@ Reverse Spine Inverti dorsale Revert Ripristina Revert to the saved version of “{name}”? Ripristinare la versione salvata di “{name}”? + Replace “{name}”, the file this document was opened from? Sostituire “{name}”, il file da cui è stato aperto questo documento? + Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. Aprendolo è rimasto fuori ciò che VectorCraft non sa ancora leggere ({what}), quindi sostituirlo lo perde per sempre. Salva con un altro nome per conservarlo. Revolve… Rivoluzione… RGB Color Colore RGB Right Destra diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1540e48d..3d7b6c6b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2115,6 +2115,8 @@ Reverse Path Direction Off パスの方向を反転: オフ Reverse Path Direction On パスの方向を反転: オン Revert to the saved version of “{name}”? 「{name}」を保存済みの状態に戻しますか? + Replace “{name}”, the file this document was opened from? このドキュメントの開き元のファイル「{name}」を置き換えますか? + Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. 開いたときに VectorCraft がまだ読み込めない内容({what})が除外されたため、置き換えるとそれは完全に失われます。保持するには別の名前で保存してください。 Right Grid: 右グリッド: Right Indent 右インデント Right Plane 右面 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1d168bca..bf3679f9 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1904,6 +1904,8 @@ Reverse Spine Inverter Coluna Revert Reverter Revert to the saved version of “{name}”? Reverter para a versão salva de “{name}”? + Replace “{name}”, the file this document was opened from? Substituir “{name}”, o arquivo do qual este documento foi aberto? + Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. Ao abri-lo, ficou de fora o que o VectorCraft ainda não consegue ler ({what}), então substituí-lo perde isso para sempre. Salve com outro nome para mantê-lo. Revolve… Revolver… Right Grid: Grade Direita: Right Indent Recuo Direito diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c13dbad0..b4efe1df 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2143,6 +2143,8 @@ Reverse Spine Обратить остов Revert Вернуть Revert to the saved version of “{name}”? Вернуться к сохранённой версии «{name}»? + Replace “{name}”, the file this document was opened from? Заменить «{name}» — файл, из которого открыт этот документ? + Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. При открытии было пропущено то, что VectorCraft пока не умеет читать ({what}), поэтому замена файла потеряет это навсегда. Чтобы сохранить это, сохраните документ под другим именем. Revolve… Вращение… RGB Color Цвет RGB Right Справа diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 15939595..0a2acad8 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2102,6 +2102,8 @@ Reverse Spine 反转路径 Revert 恢复 Revert to the saved version of “{name}”? 要恢复为“{name}”已存储的版本吗? + Replace “{name}”, the file this document was opened from? 要替换“{name}”(打开此文档的源文件)吗? + Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. 打开时略去了 VectorCraft 尚无法读取的内容({what}),替换该文件会永久丢失这些内容。要保留它们,请以其他名称存储。 Revolve… 回转… RGB Color RGB 颜色 Right 右 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d44527e2..3226c128 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2150,6 +2150,8 @@ Reverse Spine 反轉軸線 Revert 回復 Revert to the saved version of “{name}”? 要回復為「{name}」已儲存的版本嗎? + Replace “{name}”, the file this document was opened from? 要取代「{name}」(開啟此文件的來源檔案)嗎? + Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. 開啟時略過了 VectorCraft 尚無法讀取的內容({what}),取代該檔案會永久遺失這些內容。若要保留,請以其他名稱儲存。 Revolve… 迴轉… RGB Color RGB 色彩 Right 右 diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 96913fc0..635697f7 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -239,9 +239,13 @@ fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result Result { remember_view(app); - let first = plan(app, mode, p)?; + // Writing over the file the document was read from is asked about here, not refused. + let first = plan(app, mode, &acknowledged(p))?; let ask = ask_options && !has_options(p); if let Some(path) = first.path.clone() { + if let Some(r) = ask_before_losing(app, &path, save_command(mode), p) { + return Ok(r); + } if ask && matches!(mode, SaveMode::SaveAs | SaveMode::Copy) && matches!(first.format.id, "svg" | "svgz") { return ask_format_options(app, mode, first.format, &path); } @@ -261,7 +265,10 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo o.remove("format"); o.insert("path".into(), json!(with_save_extension(&picked, first.format))); } - let chosen = plan(app, mode, &q)?; + let chosen = plan(app, mode, &acknowledged(&q))?; + if let Some(r) = chosen.path.as_deref().and_then(|path| ask_before_losing(app, path, save_command(mode), &q)) { + return Ok(r); + } if ask && asks_options(mode, chosen.format) { let path = chosen.path.clone().unwrap_or_default(); return ask_format_options(app, mode, chosen.format, &path); @@ -269,6 +276,42 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo write_plan(app, chosen) } +/// The UI command that runs a save in `mode`. +fn save_command(mode: SaveMode) -> &'static str { + match mode { + SaveMode::Save => "file.save", + SaveMode::SaveAs => "file.saveAs", + SaveMode::Copy => "file.saveCopy", + SaveMode::Template => "file.saveAsTemplate", + } +} + +/// `p` with `acknowledgeLoss: true`. +fn acknowledged(p: &Value) -> Value { + let mut p = if p.is_object() { p.clone() } else { json!({}) }; + p["acknowledgeLoss"] = json!(true); + p +} + +/// Writing `path` over the file the active document was read from, when reading it left things +/// out (hidden text, art or layers VectorCraft can't read yet): asks first, and OK runs `command` +/// with `params` and `acknowledgeLoss` → `{pending}` while it asks, `None` when nothing is lost. +fn ask_before_losing(app: &mut VectorcraftApp, path: &str, command: &str, params: &Value) -> Option { + let what = fileio::losses_summary(fileio::overwrite_losses(app.session.active()?, path, params)?); + let name = fileio::file_name(path); + let message = crate::i18n::fmt(tl!("Replace “{name}”, the file this document was opened from?"), &[("name", &name)]); + let detail = crate::i18n::fmt( + tl!("Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it."), + &[("what", &what)], + ); + let mut params = acknowledged(params); + if command != "file.save" { + params["path"] = json!(path); + } + dialogs::confirm::ask(app, &message, &detail, command, params); + Some(json!({ "pending": dialogs::confirm::KIND })) +} + /// Does a save (`mode`) to a picked file of format `f` ask for its options first? Native and `.ai` /// files only on Save As (the options dialog after the save panel); Save, a Copy and templates /// reuse the remembered options or Use Compression. @@ -421,6 +464,13 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option std::sync::Arc::new(d), }; let path = target_path(app, path, f.extensions[0])?; + let mut again = params.clone(); + if let Some(o) = again.as_object_mut() { + o.insert("format".into(), json!(f.id)); + } + if let Some(r) = ask_before_losing(app, &path, "file.exportAs", &again) { + return Ok(r); + } // An SVG given a .svgz name is written compressed. let f = match fileio::format_for_name(&path) { Some(z) if f.id == "svg" && z.id == "svgz" => z, diff --git a/crates/ui-egui/src/tests_save.rs b/crates/ui-egui/src/tests_save.rs index 30b0398e..8a39c50a 100644 --- a/crates/ui-egui/src/tests_save.rs +++ b/crates/ui-egui/src/tests_save.rs @@ -166,3 +166,42 @@ fn a_document_reopens_at_its_saved_view() { assert!(!app.view().unwrap().fitted); let _ = std::fs::remove_dir_all(d); } + +#[test] +fn replacing_a_file_that_opening_left_things_out_of_asks_first() { + let d = dir("lossy"); + let path = d.join("a.vectorcraft").to_string_lossy().to_string(); + let mut app = app(); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + rect(&mut app); + app.run("file.saveAs", json!({"path": path})).unwrap(); + // As if reading the file had left out text it can't read yet. + let st = app.session.doc_mut().unwrap(); + st.imported_from = Some(path.clone()); + st.import_losses = vec!["hidden type".into()]; + let saved = std::fs::read(&path).unwrap(); + rect(&mut app); + let r = app.run("file.save", json!({})).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::KIND); + let dialog = app.ui.dialog.as_ref().unwrap(); + assert!(dialog.str("detail").contains("“hidden type”"), "{:?}", dialog.fields); + assert_eq!(std::fs::read(&path).unwrap(), saved, "nothing is written before OK"); + // Export As over it asks too; Cancel leaves the file. + app.ui.dialog = None; + assert_eq!(app.run("file.exportAs", json!({"path": path, "format": "svg"})).unwrap()["pending"], dialogs::confirm::KIND); + app.ui.dialog = None; + assert_eq!(std::fs::read(&path).unwrap(), saved); + // Another name doesn't ask. + let copy = d.join("b.svg").to_string_lossy().to_string(); + assert!(app.run("file.exportAs", json!({"path": copy, "format": "svg"})).unwrap().get("pending").is_none()); + crate::background::wait_all(&mut app); + assert!(app.ui.dialog.is_none()); + // OK replaces it. + app.run("file.save", json!({})).unwrap(); + press_ok(&mut app); + crate::background::wait_all(&mut app); + assert!(app.ui.dialog.is_none()); + assert_ne!(std::fs::read(&path).unwrap(), saved); + assert!(!app.session.active().unwrap().is_dirty()); + let _ = std::fs::remove_dir_all(d); +} diff --git a/crates/ui-egui/src/unsaved.rs b/crates/ui-egui/src/unsaved.rs index fc8e6d17..35c52dca 100644 --- a/crates/ui-egui/src/unsaved.rs +++ b/crates/ui-egui/src/unsaved.rs @@ -77,6 +77,11 @@ pub fn confirm(app: &mut VectorcraftApp) -> Result { if !d.bool("discard") { app.session.set_active(i); let r = io::save(app, vectorcraft_engine::cmd::fileio::SaveMode::Save, &json!({}), false)?; + // A save that asks first (replacing a file that would lose what opening it left out) keeps + // the document open: it closes once saved. + if r.get("pending").is_some() { + return Ok(r); + } // Closing needs the file written: wait for a background save, and stop if it failed. if r["background"] == true { crate::background::wait_all(app); From 26b6893064f3445abc1af23b1e611e969b3652a3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 15:06:39 +0700 Subject: [PATCH 14/14] EPS import: clippy's simpler form of the off-page test Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/native.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index a40e2d9d..af9c4e41 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -1146,7 +1146,7 @@ fn prune_unseen(page: &Document, doc: &mut Document) -> usize { let mut with = difference(page, doc, rect); let mut removed = 0; for (id, bounds) in found { - if !bounds.is_some_and(|b| !b.intersect(rect).is_zero_area()) { + if bounds.is_none_or(|b| b.intersect(rect).is_zero_area()) { continue; } let mut without = doc.clone();