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/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 ab1d4a22..fc56a6af 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; @@ -166,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)?; @@ -181,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/load.rs b/crates/engine/src/cmd/fileio/load.rs index 810e8ebb..73aa81c2 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,47 @@ 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(losses) = overwrite_losses(st, target, p) else { return Ok(()) }; + let what = losses_summary(losses); + 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" + ), + )) +} + +/// 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)) { + (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..4a71c4db 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -47,11 +47,13 @@ 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; 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/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/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/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/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/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/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..5e1b06e3 --- /dev/null +++ b/crates/engine/tests/lossy_overwrite.rs @@ -0,0 +1,110 @@ +//! 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(); +} + +#[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/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..dedfb280 --- /dev/null +++ b/crates/eps/src/import/ate.rs @@ -0,0 +1,552 @@ +//! 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}; + +/// 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. +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 `%`. + // 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() + .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().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")?; + 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 { + /// 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) { + 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 = 0usize; + let styles: Vec<&(usize, Style)> = self.runs.iter().collect(); + for (n, st) in styles { + 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() { + continue; + } + 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::new(chars.get(from..).unwrap_or_default().iter().collect::(), 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..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; @@ -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..af9c4e41 --- /dev/null +++ b/crates/eps/src/import/native.rs @@ -0,0 +1,1280 @@ +//! 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 (no Adobe software was run, no Adobe file is in this repo, and no Adobe specification, +//! SDK or installation was used): +//! +//! - 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: 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. + +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 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"; +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, super::graphics::TOO_MUCH, super::graphics::FAR_AWAY].iter().any(|n| note.starts_with(n)) +} + +/// 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). +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_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) +} + +/// 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); + 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; + // 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); + 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(':') => { + if dict_depth == 0 { + text_commented = true; + } + rest.trim() + } + _ => { + if dict_depth == 0 { + text_commented = false; + } + line + } + }; + if line.starts_with('%') { + match line { + "%AI5_BeginLayer" => { + count += 1; + // 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() { + parent.flush(); + } + 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() { + 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() + { + // 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; + } 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'); + } + } + if dict_depth > 0 { + return None; + } + 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; + text.name = Some(MADE.into()); + 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 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(); + // 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.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.get(*j).copied().unwrap_or(true) && parts.get(*j).and_then(|n| baseline(n)).is_some_and(|b| (b - line).abs() <= SAME_LINE) + }) + .collect(); + 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() + .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)); + } + } + let mut gone: BTreeSet = BTreeSet::new(); + for (first, rest, gaps) in &joins { + 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(); + // The layout cached for the first piece is not this text's. + t.cached_bounds = None; + t.cached_baselines.clear(); + } + 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`. +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()), + } + } + 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) + && 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()); + } + // 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); + // 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); + 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 }) +} + +/// 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_none_or(|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> { + 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], 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| { + 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) + }); + 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 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)) + }); + 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 +} + +#[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_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 new file mode 100644 index 00000000..20a3ff61 --- /dev/null +++ b/crates/eps/src/import/tests_native.rs @@ -0,0 +1,523 @@ +//! 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 small ones written by hand for the tests, with the +//! operators seen in files their users own. + +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); + } + // 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. +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(); + // One text, with the line's space between the pieces. + let group = &r.document.layers[0].children().unwrap()[0]; + 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"]); + // 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] +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); +} + +#[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); +} + +#[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); +} + +#[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/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) } 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..88359e91 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,49 @@ 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 = 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). +/// 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(&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 bc496312..937a65d9 100644 --- a/crates/pdf/src/tests_import_layers.rs +++ b/crates/pdf/src/tests_import_layers.rs @@ -366,3 +366,26 @@ 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()); + // 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/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 290ea0bf..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 @@ -3118,6 +3120,18 @@ 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 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 +@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/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b249163c..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,6 +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 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 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 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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f833c0c5..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 @@ -3118,6 +3120,18 @@ 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 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 +@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/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 4929c927..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 Справа @@ -3114,6 +3116,18 @@ 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 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 его данные для редактирования повреждены +@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 Смешивание цветов: выберите не менее трёх объектов с цветными заливками 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);