From f33617142c5d30f0fb17c36d1018950e631f7af9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 16:36:05 -0700 Subject: [PATCH 01/21] Save As .ai: carry editing data that other apps open as layers and paths A PDF-compatible .ai now also carries, on its first page's PieceInfo, an editing copy of the art in the form VectorCraft's own .ai reader reads: AIPrivateData streams of 64 KiB holding the program compressed with Zstandard after %AI24_ZStandard_Data, an AIMetaData stream of its header comments, and the ContainerVersion, CreatorVersion, NumBlock, RoundtripStreamType and RoundtripVersion entries. The program holds the header comments, the color swatches and color groups, every artboard, layers and sublayers (name, order, visibility, lock, color, printing, dimming), groups, paths, fills and strokes (width, caps, joins, miter limit, dashes, overprint, fill rule), process, spot and global colors, guides, hidden and locked objects and object names. Type, gradients, patterns, images, symbols, live objects, brushes, clipping groups and compound paths are written as plain filled and stroked paths, each with a warning; transparency and effects other than geometry effects are left out. The .ai option editingData (on by default, Include Editing Data for Other Apps in Save As) turns it off. The legacy version 7 writer is unchanged; the path, guide and line style operators both writers write move to eps/src/ai_ops.rs. LoadOptions::native off opens a file from its editing copy or pages instead of the VectorCraft document it carries. --- Cargo.lock | 1 + crates/engine/src/cmd/fileio/load.rs | 4 +- crates/engine/src/cmd/fileio/native.rs | 6 + crates/engine/src/cmd/fileio/pdf.rs | 17 +- crates/engine/src/cmd/fileio/pdfimport.rs | 6 +- crates/engine/src/cmd/fileio/save.rs | 38 +- crates/engine/src/cmd/fileio/tests_pdfedit.rs | 48 ++ crates/eps/Cargo.toml | 2 + crates/eps/src/ai_editing.rs | 238 ++++++ crates/eps/src/ai_editing/tests.rs | 645 ++++++++++++++++ crates/eps/src/ai_editing/writer.rs | 710 ++++++++++++++++++ crates/eps/src/ai_ops.rs | 97 +++ crates/eps/src/legacy_ai.rs | 8 +- crates/eps/src/legacy_ai/encoding.rs | 11 +- crates/eps/src/legacy_ai/writer.rs | 73 +- crates/eps/src/lib.rs | 6 + crates/pdf/src/lib.rs | 7 +- crates/pdf/src/piece_info.rs | 126 ++++ crates/pdf/src/post.rs | 10 +- crates/ui-egui/src/dialogs/save_options.rs | 1 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + 44 files changed, 1984 insertions(+), 94 deletions(-) create mode 100644 crates/eps/src/ai_editing.rs create mode 100644 crates/eps/src/ai_editing/tests.rs create mode 100644 crates/eps/src/ai_editing/writer.rs create mode 100644 crates/eps/src/ai_ops.rs create mode 100644 crates/pdf/src/piece_info.rs diff --git a/Cargo.lock b/Cargo.lock index ef60276c1..2778315c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6240,6 +6240,7 @@ dependencies = [ "vectorcraft-effects", "vectorcraft-geom", "vectorcraft-metafile", + "vectorcraft-pathops", "vectorcraft-pdf", "vectorcraft-render", "vectorcraft-testkit", diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 56f5aa0d7..7a56aa7d0 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -263,9 +263,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result } "pdf" | "ai" | "ait" => { // Saved with Preserve Editing: the document it carries, unless some pages are picked. - let editing = opts - .pages - .is_none() + let editing = (opts.pages.is_none() && opts.native) .then(|| vectorcraft_pdf::editing_with(bytes, opts.password.as_deref())) .flatten() .map(|e| (e.intact, move || Some(e.data))); diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index bcca24c34..fb1ff6c8c 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -99,6 +99,12 @@ pub const AI_OPTIONS: &[FormatOption] = &[ default: "true", description: "compress the PDF's content and the native document it carries (the PDF option compression.compressText)", }, + FormatOption { + name: "editingData", + ty: "boolean", + default: "true", + description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, paths, fills and strokes, colors, swatches and artboards, with type, gradients, patterns, images, symbols, clipping and live objects as plain filled and stroked paths, without transparency (written only with every artboard, as Preserve Editing is)", + }, FormatOption { name: "aiVersion", ty: "string", diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index df512f629..641aa0845 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -263,7 +263,7 @@ pub const EDITING_NEEDS_EVERY_ARTBOARD: &str = pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec, Vec)> { // Keep the editable object tree independent of preview fallbacks used by the drawn pages. let (full, mut warnings) = crate::cmd::place::document::full_documents(doc); - let (bytes, drawn_warnings) = encode_carrying(cmd, &full, p, || Ok(vectorcraft_format::save(doc, false)))?; + let (bytes, drawn_warnings) = encode_carrying(cmd, &full, p, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?; warnings.extend(drawn_warnings); warnings.extend(overflow_warning(cmd, &full, p)?); Ok((bytes, warnings)) @@ -281,8 +281,15 @@ fn overflow_warning(cmd: &str, doc: &Document, p: &Value) -> Result Result>) -> Result<(Vec, Vec)> { +/// native document with its save options, whose pages may be left blank), and `private()`, the +/// editing copy of the art for other apps with what it couldn't hold, on the first page. +pub(super) fn encode_carrying( + cmd: &str, + doc: &Document, + p: &Value, + native: impl FnOnce() -> Result>, + private: impl FnOnce() -> Result)>>, +) -> Result<(Vec, Vec)> { let mut opts = options(cmd, doc, p)?; if opts.settings.thumbnails { opts.thumbnails = thumbnails(doc, &opts).map_err(|e| pdf_error(cmd, e))?; @@ -291,6 +298,10 @@ pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl if opts.settings.preserve_editing { if opts.artboards.as_ref().is_none_or(|v| v.iter().copied().eq(0..doc.artboards.len())) { opts.native = Some(native()?); + if let Some((data, lost)) = private()? { + opts.private_data = Some(data); + warnings.extend(lost); + } } else { opts.settings.preserve_editing = false; warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string()); diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index e747be71f..0273efd92 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -39,6 +39,9 @@ pub struct LoadOptions { /// A `.ai` opens from its editing copy even where that draws differently from its PDF part /// (which can be older than it), with a warning, instead of from the PDF part. pub trust_editing_data: bool, + /// A PDF or `.ai` saved with Preserve Editing opens from the VectorCraft document it carries; + /// off, it opens as another app would open it, from the editing copy of its art or its pages. + pub native: bool, } impl Default for LoadOptions { @@ -54,6 +57,7 @@ impl Default for LoadOptions { dxf: Default::default(), editing_data: true, trust_editing_data: false, + native: true, } } } @@ -91,7 +95,7 @@ impl LoadOptions { Some(v) => (v.as_bool().ok_or_else(|| bad(cmd, "editingData must be true, false or \"always\""))?, false), }; let dxf = super::dxfimport::options(cmd, p.get("dxf"))?; - Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data, trust_editing_data }) + Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data, trust_editing_data, native: true }) } /// Does the document read only part of the file, or read it differently (a page range, another diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 486777a24..e211fd719 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -584,6 +584,14 @@ fn named_for_save(cmd: &str, e: crate::EngineError) -> crate::EngineError { } } +/// The `.ai` option `editingData`: whether the file also carries the editing copy of the art that +/// other apps read (default on). +#[derive(Default, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct AiEditingData { + editing_data: Option, +} + /// The shared Save and Export encoder for PDF-compatible `.ai` files (and `.ait` templates, the /// same file). pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result { @@ -613,13 +621,35 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res return Err(bad(cmd, "pdfCompatible: false needs every artboard to keep the editable artwork")); } } - let pages = doc.without_edit_modes(); + let edited = doc.without_edit_modes(); // Resolve placed files only for the drawn pages. Missing files become preview images there; // the native attachment keeps the original placed objects and their relinkable metadata. - let (pages, placed_warnings) = - if pdf_compatible { crate::cmd::place::document::full_documents(&pages) } else { (std::borrow::Cow::Owned(blank_pages(&pages)), Vec::new()) }; + let (pages, placed_warnings) = if pdf_compatible { + crate::cmd::place::document::full_documents(&edited) + } else { + (std::borrow::Cow::Owned(blank_pages(&edited)), Vec::new()) + }; let native = || super::native::ai_native(cmd, doc, &native_options); - let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, ¶ms, native)?; + let other_apps: AiEditingData = super::encode::options(f, ¶ms)?; + let private = || { + if !other_apps.editing_data.unwrap_or(true) { + return Ok(None); + } + // The art with placed files resolved as the pages draw them, also when the pages are blank. + let resolved; + let art: &Document = if pdf_compatible { + &pages + } else { + resolved = crate::cmd::place::document::full_documents(&edited).0; + &resolved + }; + let o = vectorcraft_eps::AiEditingOptions { + title: doc.title.clone(), + created: doc.metadata.created.or_else(vectorcraft_doc::metadata::output_time), + }; + vectorcraft_eps::ai_private_data(art, &o).map(Some).map_err(|e| bad(cmd, e)) + }; + let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, ¶ms, native, private)?; warnings.extend(placed_warnings); if !pdf_compatible { warnings.push(NOT_PDF_COMPATIBLE.to_string()); diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs index 35d394540..358c9840c 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfedit.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs @@ -403,3 +403,51 @@ fn ai_export_without_artboards_keeps_art_outside_the_original_pages() { assert_eq!(comparable(&s.doc().unwrap().doc), before); assert!(s.execute("document.export", &json!({"format": "ai", "useArtboards": "yes"})).is_err()); } + +/// The layers of `doc` as their names and the kinds and bounds of what they hold, rounded. +fn outline(doc: &vectorcraft_doc::Document) -> Vec { + fn walk(nodes: &[std::sync::Arc], depth: usize, out: &mut Vec) { + for n in nodes { + let b = n.visual_bounds().map(|b| [b.x0, b.y0, b.x1, b.y1].map(|v| v.round() as i64)); + let kind = match &n.kind { + vectorcraft_doc::NodeKind::Layer { .. } => "layer", + vectorcraft_doc::NodeKind::Group { .. } => "group", + vectorcraft_doc::NodeKind::Path { .. } => "path", + _ => "other", + }; + out.push(format!("{depth} {kind} {:?} {} {b:?}", n.name, n.visible)); + walk(n.children().map(Vec::as_slice).unwrap_or_default(), depth + 1, out); + } + } + let mut out = vec![]; + walk(&doc.layers, 0, &mut out); + out +} + +/// A `.ai` also carries the editing copy of its art that other apps read: opened without the +/// VectorCraft document it carries, it gives back the same layers, groups and paths, its page +/// matching them. `editingData: false` leaves the copy out. +#[test] +fn ai_files_carry_an_editing_copy_for_other_apps() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 60, "y": 10, "width": 50, "height": 30})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.execute("layer.new", &json!({"name": "Top"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 120, "y": 50, "width": 60, "height": 40})).unwrap(); + let before = outline(&s.doc().unwrap().doc); + let bytes = b64(&s.execute("document.export", &json!({"format": "ai"})).unwrap()); + let private = vectorcraft_pdf::illustrator_data(&bytes, None).expect("the editing copy for other apps"); + assert!(private.starts_with(b"%AI24_ZStandard_Data")); + let opts = LoadOptions { native: false, ..Default::default() }; + let l = load::load_with("art.ai", &bytes, &opts).unwrap(); + assert!(l.editing_data && !l.restored); + assert!(!l.warnings.iter().any(|w| w.contains("editing data")), "{:?}", l.warnings); + assert_eq!(outline(&l.doc), before); + let r = s.execute("document.export", &json!({"format": "ai", "editingData": false})).unwrap(); + assert_eq!(vectorcraft_pdf::illustrator_data(&b64(&r), None), None); + assert!(s.execute("document.export", &json!({"format": "ai", "editingData": "no"})).is_err()); +} diff --git a/crates/eps/Cargo.toml b/crates/eps/Cargo.toml index 6743c2a2a..0004a65d3 100644 --- a/crates/eps/Cargo.toml +++ b/crates/eps/Cargo.toml @@ -13,6 +13,8 @@ vectorcraft-geom = { workspace = true } vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true } vectorcraft-effects = { workspace = true } +# The editing data for other apps writes clipped art cut to its clipping path. +vectorcraft-pathops = { workspace = true } vectorcraft-brush = { workspace = true } vectorcraft-text = { workspace = true } # Windows metafile previews, opened when a file's PostScript can't be read. diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs new file mode 100644 index 000000000..0ad1b932a --- /dev/null +++ b/crates/eps/src/ai_editing.rs @@ -0,0 +1,238 @@ +//! The editing copy of the art that a PDF-compatible `.ai` file carries in its first page's +//! `PieceInfo` ([`vectorcraft_pdf::PrivateData`]): the document's layers, sublayers, groups, paths, +//! colors, swatches and artboards in the form [`crate::import`]'s `.ai` reader reads, so that apps +//! that open `.ai` files from that copy get them as editable objects. +//! +//! The form is the one the published specification documents (Adobe Illustrator File Format +//! Specification, version 7.0, see [`crate::legacy_ai`]) for paths (§5.3), colors (§5.4), +//! groups and compound paths (§5.3.5), guides (§6) and layers (§9), with the newer forms the reader +//! reads from files users sent: +//! +//! - Header comments: `%%BoundingBox`, `%%HiResBoundingBox`, `%AI5_FileFormat 14.0`, the first +//! artboard as `%AI3_Cropmarks` and `%AI5_ArtSize`, `%AI5_RulerUnits`, `%AI9_ColorModel` (1 RGB, 2 +//! CMYK), `%AI5_NumLayers`, and the version comment `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]). +//! - Setup: the color swatches (`%AI5_BeginPalette`, `0 0 Pb`, each swatch's color operator and +//! `(name) Pc`, a color group as `0 (name) count Pg` before its swatches, `PB`), then the +//! artboards as the `ArtboardArray` of the `/Document` dictionary, on `%_` lines. +//! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed 0 1 colorIndex r g b 0 +//! dimPercent 0 Lb` (14 operands; a color that isn't one of the preset layer colors has the +//! index -1), `(name) Ln`, its art and sublayers, `LB`, `%AI5_EndLayer--`. +//! - Colors: `g` gray, `k` CMYK, `c m y k r g b Xa` RGB (Lab is written as RGB); a spot color +//! `c m y k (name) tint x` (CMYK) or `c m y k r g b|L a b (name) tint 1|2 Xx` (RGB or Lab); a +//! global process color `… (name) tint model Xk`; Registration `1 1 1 1 ([Registration]) tint +//! Xs`. Upper case paints the stroke. +//! - State before each object: locked `A`, hidden `Xw` (`1` or `0`). An object's name follows it as +//! an `/ArtDictionary` whose `AI10_ArtUID` is the name as an XML id. Guides are `(N) *`, the +//! path, `(N) *` (`(n) *` when closed). +//! +//! Strings are written in UTF-8. Art space has y up, its origin at the first artboard's bottom-left +//! corner. What the form above can't hold is written as plain filled and stroked paths, each such +//! loss a warning: type as its outlines, gradients as their average color, pattern fills as their +//! tiles cut to the shape, clipping groups and clipping layers as their art cut to the clipping +//! path (the clipping path's fill cut too, its stroke not), images as their outlines filled with +//! their average color, symbols and live objects as their art, strokes a plain line can't draw as +//! their filled outlines, several fills or strokes as a group of a path for each, gradient and +//! pattern swatches, transparency and live effects other than geometry effects left out. +//! +//! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with +//! the version numbers of the newest files users sent ([`private_data`]). + +use std::fmt::Write; +use std::sync::Arc; + +use vectorcraft_doc::{Document, Node, Unit}; +use vectorcraft_geom::{Affine, Rect}; +use vectorcraft_pdf::PrivateData; + +use crate::ps; + +mod writer; + +use writer::Writer; + +/// The version comment's value: the creator version of the newest files users sent. +pub const CREATOR_VERSION: &str = "30.7.0"; +/// The `PieceInfo` numbers that go with Zstandard-compressed data: container 12, creator 30, +/// round trip 24, stream type 2. +const CONTAINER_VERSION: u32 = 12; +const CREATOR: u32 = 30; +const ROUNDTRIP_VERSION: u32 = 24; +const ROUNDTRIP_STREAM_TYPE: u32 = 2; +/// What starts the data streams of Zstandard-compressed data. +const ZSTD_MARKER: &[u8] = b"%AI24_ZStandard_Data"; + +/// The first line of a PostScript file (the DSC version comment). +const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header comment + +/// What to write in the header. +#[derive(Clone, Debug, Default)] +pub struct AiEditingOptions { + /// `%%Title`. + pub title: String, + /// `%%CreationDate`, Unix seconds. + pub created: Option, +} + +/// The editing data and what it couldn't hold. +#[derive(Clone, Debug, Default)] +pub struct AiEditingOutput { + /// The program: header comments, setup, layers, trailer. + pub program: Vec, + /// Its header comments, up to `%%EndComments`. + pub header: Vec, + pub warnings: Vec, +} + +/// Write `doc` as the editing data of a `.ai` file: every artboard and layer. +pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result { + let board = doc.artboards.first().ok_or("the document has no artboard")?.rect; + if !(board.is_finite() && board.width() > 0.0 && board.height() > 0.0) { + return Err("the document has no artboard".into()); + } + // Live geometry effects as their result, as the PDF part draws them. + let baked = baked(doc); + let doc = baked.as_ref().unwrap_or(doc); + // The document is y down; art space y up from the first artboard's bottom-left corner. + let to_art = Affine::new([1.0, 0.0, 0.0, -1.0, -board.x0, board.y1]); + let mut w = Writer::new(doc, to_art); + for layer in &doc.layers { + w.layer(layer); + } + let layers = doc.layers.len(); + let (width, height) = (board.width(), board.height()); + let art = doc + .layers + .iter() + .filter(|l| l.visible) + .filter_map(|l| l.visual_bounds()) + .map(|b| to_art.transform_rect_bbox(b)) + .filter(|b| b.is_finite()) + .reduce(|a, b| a.union(b)) + .unwrap_or(Rect::new(0.0, 0.0, width, height)); + let header = header(doc, o, &w, art, (width, height), layers); + let mut s = header.clone(); + s.push_str("%%BeginProlog\n%%EndProlog\n%%BeginSetup\n"); + w.palette(&mut s); + artboards(&mut s, doc, to_art); + s.push_str("%%EndSetup\n"); + s.push_str(&w.body); + s.push_str("%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n"); + Ok(AiEditingOutput { program: s.into_bytes(), header: header.into_bytes(), warnings: w.warnings }) +} + +/// `doc` with its geometry effects baked ([`vectorcraft_effects::bake_document`]), in its symbols' +/// art too (which instances are written as); `None` when nothing changes. +fn baked(doc: &Document) -> Option { + let baked = vectorcraft_effects::bake_document(doc); + let d = baked.as_ref().unwrap_or(doc); + if !d.symbols.iter().any(|s| vectorcraft_effects::needs_bake(&s.art)) { + return baked; + } + // The symbols' art baked as the layers of a copy of the document. + let mut art = d.clone(); + art.layers = d.symbols.iter().map(|s| Arc::new(Node::group(s.art.id, vec![s.art.clone()]))).collect(); + // The copy keeps what baking adds to the document (the images color adjustments make). + let Some(mut out) = vectorcraft_effects::bake_document(&art) else { return baked }; + let arts: Vec>> = out.layers.iter().map(|l| l.children().and_then(|c| c.first()).cloned()).collect(); + out.layers = d.layers.clone(); + for (s, a) in out.symbols.iter_mut().zip(arts) { + if let Some(a) = a { + s.art = a; + } + } + Some(out) +} + +/// The editing data of `doc` as a `.ai` file's first page carries it: the program compressed with +/// Zstandard after `%AI24_ZStandard_Data`, its header as the `AIMetaData`, and the version numbers +/// that go with that form; and what it couldn't hold. +pub fn private_data(doc: &Document, o: &AiEditingOptions) -> Result<(PrivateData, Vec), String> { + let out = export_ai_editing(doc, o)?; + let mut data = ZSTD_MARKER.to_vec(); + data.extend(ruzstd::encoding::compress_to_vec(out.program.as_slice(), ruzstd::encoding::CompressionLevel::Fastest)); + let p = PrivateData { + data, + metadata: out.header, + container_version: CONTAINER_VERSION, + creator_version: CREATOR, + roundtrip_version: ROUNDTRIP_VERSION, + roundtrip_stream_type: ROUNDTRIP_STREAM_TYPE, + }; + Ok((p, out.warnings)) +} + +/// The header comments, through `%%EndComments`. `art`: the visible art's bounds, `size` the first +/// artboard's, in art space. +fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, (width, height): (f64, f64), layers: usize) -> String { + let mut s = String::new(); + s.push_str(PS_HEADER); + s.push('\n'); + let _ = writeln!(s, "%%Creator: VectorCraft {}", env!("CARGO_PKG_VERSION")); + let _ = writeln!(s, "%%AI8_CreatorVersion: {CREATOR_VERSION}"); + let _ = writeln!(s, "%%Title: {}", ps::bytes(o.title.as_bytes())); + if let Some(t) = o.created { + let [y, mo, d, h, mi, _] = vectorcraft_doc::metadata::civil(t); + let _ = writeln!(s, "%%CreationDate: ({mo}/{d}/{y:04}) ({h:02}:{mi:02})"); + } + let _ = writeln!(s, "%%BoundingBox: {} {} {} {}", art.x0.floor() as i64, art.y0.floor() as i64, art.x1.ceil() as i64, art.y1.ceil() as i64); + s.push_str("%%HiResBoundingBox: "); + ps::push_nums(&mut s, &[art.x0, art.y0, art.x1, art.y1]); + s.pop(); + s.push('\n'); + s.push_str("%%DocumentProcessColors: Cyan Magenta Yellow Black\n"); + s.push_str("%AI5_FileFormat 14.0\n%AI3_ColorUsage: Color\n"); + let spots = w.spot_swatches(); + for (i, (name, _)) in spots.iter().enumerate() { + let _ = writeln!(s, "{} {}", if i == 0 { "%%DocumentCustomColors:" } else { "%%+" }, ps::bytes(name.as_bytes())); + } + for (i, (name, cmyk)) in spots.iter().enumerate() { + s.push_str(if i == 0 { "%%CMYKCustomColor: " } else { "%%+ " }); + ps::push_nums(&mut s, &cmyk.map(f64::from)); + let _ = writeln!(s, "{}", ps::bytes(name.as_bytes())); + } + let (w2, h2) = (ps::num(width), ps::num(height)); + let _ = writeln!(s, "%AI3_Cropmarks: 0 0 {w2} {h2}"); + let (cx, cy) = (ps::num(width / 2.0), ps::num(height / 2.0)); + let _ = writeln!(s, "%AI3_TemplateBox: {cx} {cy} {cx} {cy}"); + let _ = writeln!(s, "%AI3_TileBox: 0 0 {w2} {h2}"); + s.push_str("%AI3_DocumentPreview: None\n"); + let _ = writeln!(s, "%AI5_ArtSize: {w2} {h2}"); + let _ = writeln!(s, "%AI5_RulerUnits: {}", ruler_units(doc.units)); + s.push_str("%AI24_LargeCanvasScale: 1\n"); + let model = if doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { 2 } else { 1 }; + let _ = writeln!(s, "%AI9_ColorModel: {model}"); + let _ = writeln!(s, "%AI5_NumLayers: {layers}"); + s.push_str("%%EndComments\n"); + s +} + +/// `%AI5_RulerUnits` of `u` (as the reader reads it; units it has no number for are points). +fn ruler_units(u: Unit) -> u8 { + match u { + Unit::Inches => 0, + Unit::Millimeters => 1, + Unit::Picas => 3, + Unit::Centimeters => 4, + Unit::Pixels => 6, + Unit::Points | Unit::FeetInches | Unit::Meters | Unit::Yards | Unit::Feet => 2, + } +} + +/// The artboards as the `ArtboardArray` of the `/Document` dictionary: each one's top-left and +/// bottom-right corners in art space (`PositionPoint1`, `PositionPoint2`) and its name. +fn artboards(s: &mut String, doc: &Document, to_art: Affine) { + s.push_str("%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n%_/Array :\n"); + for ab in &doc.artboards { + let r = to_art.transform_rect_bbox(ab.rect); + s.push_str("%_/Dictionary :\n%_"); + ps::push_nums(s, &[r.x0, r.y1]); + s.push_str("/RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_"); + ps::push_nums(s, &[r.x1, r.y0]); + s.push_str("/RealPointRelToROrigin\n%_ (PositionPoint2) ,\n"); + let _ = writeln!(s, "%_{} /UnicodeString (Name) ,\n%_; ,", ps::bytes(ab.name.as_bytes())); + } + s.push_str("%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n"); +} + +#[cfg(test)] +mod tests; diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs new file mode 100644 index 000000000..0490ae391 --- /dev/null +++ b/crates/eps/src/ai_editing/tests.rs @@ -0,0 +1,645 @@ +//! The editing data written, then read back by VectorCraft's own reader ([`crate::ai_alone`]). + +use std::sync::Arc; + +use vectorcraft_color::swatch::{Swatch, SwatchGroup}; +use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; +use vectorcraft_doc::{ + Appearance, AppearanceItem, Artboard, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeKind, + StrokeLayer, Symbol, +}; +use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes}; + +use super::*; + +fn opts() -> AiEditingOptions { + AiEditingOptions { title: "Sample".into(), created: Some(0) } +} + +/// The program written from `doc`, as text. +fn program(doc: &Document) -> String { + String::from_utf8(export_ai_editing(doc, &opts()).unwrap().program).unwrap() +} + +/// `doc` written, then read back from the private data alone. +fn read_back(doc: &Document) -> Document { + let (p, _) = private_data(doc, &opts()).unwrap(); + crate::ai_alone(&p.data).unwrap().0 +} + +fn solid(c: Color) -> Paint { + Paint::solid(c) +} + +/// A swatch's color at `tint`, linked to it. +fn linked(name: &str, full: Color, tint: f32) -> Paint { + Paint::Solid { color: full.tinted(tint), swatch: Some(name.into()), tint } +} + +const BRAND: Color = Color::Cmyk { c: 0.1, m: 0.7, y: 0.2, k: 0.05 }; +const GOLD: Color = Color::Cmyk { c: 0.0, m: 0.25, y: 0.9, k: 0.1 }; +const SKY: Color = Color::Rgb { r: 0.25, g: 0.5, b: 1.0 }; +const MINT: Color = Color::Lab { l: 80.0, a: -40.0, b: 10.0 }; + +/// A 200 × 100 document with a second artboard: swatches (process, global process, CMYK, RGB and +/// Lab spot colors, a color group); a layer `Back` (preset color 2) holding a named group `Pair` +/// of a named rectangle `Red` (stroked 2 pt, round caps, bevel joins, miter limit 4, dashed, over +/// a gray fill that overprints) and a hidden, locked one, an even-odd ellipse in a global color at +/// half tint, an open path stroked in a spot color, a guide, and a hidden locked sublayer `Sub` +/// (a custom color, not printing, dimmed) holding a path filled with an RGB and stroked with a Lab +/// spot color; a second layer `Front`, locked, with a CMYK square named like the rectangle. +fn sample() -> Document { + let mut doc = Document::new(200.0, 100.0); + let mut second = doc.artboards[0].clone(); + second.id = 2; + second.name = "Back side".into(); + second.rect = Rect::new(250.0, 0.0, 450.0, 100.0); + doc.artboards.push(second); + let swatch = |name: &str, c: Color, global: bool, spot: bool| Swatch { name: name.into(), paint: solid(c), global, spot }; + doc.swatches = vec![ + Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }, + swatch("Teal", Color::rgb(0.0, 0.5, 0.5), false, false), + swatch("Brand", BRAND, true, false), + swatch("Gold", GOLD, true, true), + swatch("Sky", SKY, true, true), + swatch("Mint", MINT, true, true), + ] + .into(); + doc.swatch_groups = vec![SwatchGroup { name: "Grays".into(), swatches: vec![swatch("Mid", Color::gray(0.5), false, false)] }].into(); + doc.layers.clear(); + let back = doc.add_layer(Some("Back")); + let front = doc.add_layer(Some("Front")); + + let mut stroke = StrokeLayer::new(solid(Color::rgb(0.0, 0.0, 1.0)), 2.0); + stroke.cap = LineCap::Round; + stroke.join = LineJoin::Bevel; + stroke.miter_limit = 4.0; + stroke.dash = Some(Dash { pattern: vec![3.0, 2.0], offset: 1.0, align_corners: false }); + let fill = FillLayer { overprint: true, ..FillLayer::new(solid(Color::gray(0.25))) }; + let mut red = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), + Appearance { items: vec![AppearanceItem::Fill(fill), AppearanceItem::Stroke(stroke)], ..Appearance::default() }, + ); + red.name = Some("Red".into()); + let mut hidden = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(50.0, 10.0, 80.0, 40.0)), + Appearance::basic(solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)), Paint::None, 0.0), + ); + hidden.visible = false; + hidden.locked = true; + let mut pair = Node::group(doc.alloc_id(), vec![Arc::new(red), Arc::new(hidden)]); + pair.name = Some("Pair".into()); + doc.insert(Some(back), usize::MAX, pair).unwrap(); + + let ellipse = Node::new( + doc.alloc_id(), + NodeKind::Path { + path: shapes::ellipse(Rect::new(100.0, 10.0, 160.0, 50.0)), + rule: FillRule::EvenOdd, + live: None, + clipping: false, + guide: false, + }, + ); + let ellipse = Node { appearance: Appearance::basic(linked("Brand", BRAND, 0.5), Paint::None, 0.0).into(), ..ellipse }; + doc.insert(Some(back), usize::MAX, ellipse).unwrap(); + let mut open = PathData::from_bezpath(&kurbo::BezPath::from_vec(vec![ + kurbo::PathEl::MoveTo((10.0, 60.0).into()), + kurbo::PathEl::LineTo((60.0, 90.0).into()), + ])); + open.subpaths.iter_mut().for_each(|sp| sp.closed = false); + let line = Node::path(doc.alloc_id(), open, Appearance::basic(Paint::None, linked("Gold", GOLD, 1.0), 3.0)); + doc.insert(Some(back), usize::MAX, line).unwrap(); + let guide = Node::new( + doc.alloc_id(), + NodeKind::Path { + path: shapes::rectangle(Rect::new(0.0, 50.0, 200.0, 50.0)), + rule: FillRule::NonZero, + live: None, + clipping: false, + guide: true, + }, + ); + doc.insert(Some(back), usize::MAX, guide).unwrap(); + + let mut sub = Node::layer(doc.alloc_id(), "Sub", LayerColor::Custom([10, 200, 30])); + if let NodeKind::Layer { printable, dim_images, .. } = &mut sub.kind { + *printable = false; + *dim_images = Some(70); + } + sub.visible = false; + sub.locked = true; + let sub = doc.insert(Some(back), usize::MAX, sub).unwrap(); + let skyfill = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(120.0, 60.0, 190.0, 95.0)), + Appearance::basic(linked("Sky", SKY, 0.8), linked("Mint", MINT, 1.0), 1.0), + ); + doc.insert(Some(sub), usize::MAX, skyfill).unwrap(); + + let mut square = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(300.0, 20.0, 340.0, 60.0)), + Appearance::basic(solid(Color::cmyk(0.5, 0.0, 1.0, 0.0)), Paint::None, 0.0), + ); + square.name = Some("Red".into()); + doc.insert(Some(front), usize::MAX, square).unwrap(); + if let Some(l) = doc.layers.get_mut(1) { + let l = Arc::make_mut(l); + l.locked = true; + if let NodeKind::Layer { color, .. } = &mut l.kind { + *color = LayerColor::Preset(2); + } + } + doc +} + +/// `v` to three decimals. +fn r3(v: f64) -> String { + format!("{:.3}", (v * 1000.0).round() / 1000.0 + 0.0) +} + +fn color(c: &Color) -> String { + let f = |v: f32| r3(f64::from(v)); + match *c { + Color::Gray { k } => format!("gray {}", f(k)), + Color::Cmyk { c, m, y, k } => format!("cmyk {} {} {} {}", f(c), f(m), f(y), f(k)), + Color::Rgb { r, g, b } => format!("rgb {} {} {}", f(r), f(g), f(b)), + Color::Lab { l, a, b } => format!("lab {} {} {}", f(l), f(a), f(b)), + } +} + +fn paint(p: &Paint) -> String { + match p { + Paint::Solid { color: c, swatch, tint } => format!("{} {swatch:?} {}", color(c), r3(f64::from(*tint))), + Paint::None => "none".into(), + other => format!("{other:?}"), + } +} + +/// What the reader's view of the slice's constructs is made of, one line per object. +fn view(nodes: &[Arc], depth: usize, out: &mut Vec) { + for n in nodes { + let head = format!("{depth} {:?} shown={} locked={}", n.name, n.visible, n.locked); + match &n.kind { + NodeKind::Layer { color, printable, preview, dim_images, clip, children, .. } => { + out.push(format!("{head} layer {color:?} printable={printable} preview={preview} dim={dim_images:?} clip={clip}")); + view(children, depth + 1, out); + } + NodeKind::Group { children, clip } => { + out.push(format!("{head} group clip={clip}")); + view(children, depth + 1, out); + } + NodeKind::Path { path, rule, guide, .. } => { + let mut s = format!("{head} path {rule:?} guide={guide}"); + for sp in &path.subpaths { + s += &format!(" [closed={}", sp.closed); + // Points are written to four decimals. + let r2 = |v: f64| format!("{:.2}", v + 0.0); + for a in &sp.anchors { + s += &format!(" {},{} {},{} {},{}", r2(a.h_in.x), r2(a.h_in.y), r2(a.p.x), r2(a.p.y), r2(a.h_out.x), r2(a.h_out.y)); + } + s += "]"; + } + if !guide { + // Items that paint nothing aren't written. + for it in n.appearance.items.iter().filter(|i| !i.paint().is_none()) { + s += &match it { + AppearanceItem::Fill(f) => format!(" fill({} op={})", paint(&f.paint), f.overprint), + AppearanceItem::Stroke(st) => format!( + " stroke({} w={} {:?} {:?} m={} {:?} op={})", + paint(&st.paint), + r3(st.width), + st.cap, + st.join, + r3(st.miter_limit), + st.dash.as_ref().map(|d| (d.pattern.clone(), d.offset)), + st.overprint + ), + }; + } + } + out.push(s); + } + other => out.push(format!("{head} {other:?}")), + } + } +} + +fn lines(doc: &Document) -> Vec { + let mut out = vec![]; + view(&doc.layers, 0, &mut out); + out +} + +fn swatches(doc: &Document) -> Vec { + let one = |s: &Swatch| format!("{} global={} spot={} {}", s.name, s.global, s.spot, paint(&s.paint)); + let mut out: Vec = doc.swatches.iter().filter(|s| !matches!(s.paint, Paint::None)).map(one).collect(); + for g in doc.swatch_groups.iter() { + out.push(format!("group {}", g.name)); + out.extend(g.swatches.iter().map(one)); + } + out +} + +#[test] +fn the_reader_reads_back_every_construct_of_the_slice() { + let doc = sample(); + let back = read_back(&doc); + assert_eq!(lines(&back), lines(&doc)); + assert_eq!(swatches(&back), swatches(&doc)); + let boards: Vec<(String, Rect)> = back.artboards.iter().map(|a| (a.name.clone(), a.rect)).collect(); + assert_eq!( + boards, + vec![("Artboard 1".to_string(), Rect::new(0.0, 0.0, 200.0, 100.0)), ("Back side".to_string(), Rect::new(250.0, 0.0, 450.0, 100.0))] + ); + assert_eq!(back.color_mode, doc.color_mode); + assert_eq!(back.units, doc.units); +} + +#[test] +fn writing_again_gives_the_same_bytes() { + let doc = sample(); + let first = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(first.program, export_ai_editing(&doc, &opts()).unwrap().program); + assert_eq!(private_data(&doc, &opts()).unwrap().0, private_data(&doc, &opts()).unwrap().0); + // What the reader reads back is written as the same program. + assert_eq!(program(&read_back(&doc)), String::from_utf8(first.program).unwrap()); + assert!(first.warnings.is_empty(), "{:?}", first.warnings); +} + +#[test] +fn the_data_is_zstandard_compressed_with_the_header_as_metadata() { + let doc = sample(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let (p, _) = private_data(&doc, &opts()).unwrap(); + assert!(p.data.starts_with(ZSTD_MARKER)); + let mut body = p.data.get(ZSTD_MARKER.len()..).unwrap(); + let mut decoded = vec![]; + std::io::Read::read_to_end(&mut ruzstd::decoding::StreamingDecoder::new(&mut body).unwrap(), &mut decoded).unwrap(); + assert_eq!(decoded, out.program); + assert!(out.program.starts_with(&out.header)); + assert_eq!(p.metadata, out.header); + let header = String::from_utf8(out.header).unwrap(); + assert!(header.ends_with("%%EndComments\n")); + for line in [ + "%%AI8_CreatorVersion: 30.7.0\n", + "%AI5_FileFormat 14.0\n", + "%AI3_Cropmarks: 0 0 200 100\n", + "%AI5_ArtSize: 200 100\n", + "%AI9_ColorModel: 1\n", + "%AI5_NumLayers: 2\n", + "%%DocumentCustomColors: (Gold)\n%%+ (Sky)\n%%+ (Mint)\n", + ] { + assert!(header.contains(line), "{line}"); + } + assert_eq!((p.container_version, p.creator_version, p.roundtrip_version, p.roundtrip_stream_type), (12, 30, 24, 2)); +} + +#[test] +fn a_pdf_carries_the_data_on_its_first_page() { + let doc = sample(); + let (p, _) = private_data(&doc, &opts()).unwrap(); + let pdf = + vectorcraft_pdf::export(&doc, &vectorcraft_pdf::PdfOptions { private_data: Some(p.clone()), ..vectorcraft_pdf::PdfOptions::uncompressed() }) + .unwrap(); + assert_eq!(vectorcraft_pdf::illustrator_data(&pdf, None).as_deref(), Some(&p.data[..])); + let (read, _) = crate::ai_alone(&vectorcraft_pdf::illustrator_data(&pdf, None).unwrap()).unwrap(); + assert_eq!(lines(&read), lines(&doc)); +} + +#[test] +fn names_are_xml_ids_numbered_when_taken() { + let text = program(&sample()); + assert!(text.contains("%_/XMLUID : (Red) ; (AI10_ArtUID) ,")); + assert!(text.contains("%_/XMLUID : (Red_00000000000000000001_) ; (AI10_ArtUID) ,")); + assert!(text.contains("%_/XMLUID : (Pair) ; (AI10_ArtUID) ,")); +} + +#[test] +fn many_objects_of_one_name_are_numbered_in_turn() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + for _ in 0..20_000 { + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default()); + n.name = Some("Dot".into()); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let text = program(&doc); + let ids: std::collections::HashSet<&str> = text.lines().filter(|l| l.starts_with("%_/XMLUID")).collect(); + assert_eq!(ids.len(), 20_000); + assert!(text.contains("(Dot_00000000000000019999_)")); +} + +#[test] +fn layers_have_fourteen_operands() { + let text = program(&sample()); + assert!(text.contains("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Back) Ln\n"), "{text}"); + assert!(text.contains("%AI5_BeginLayer\n0 1 0 0 1 0 1 -1 10 200 30 0 70 0 Lb\n(Sub) Ln\n"), "{text}"); +} + +/// A 200 × 100 document of what slice 1 writes as plain paths: a gradient fill, a clipping group, +/// a compound path, an image, type, a symbol instance and a half-transparent square. +fn unsupported() -> Document { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let fill = |p: Paint| Appearance::basic(p, Paint::None, 0.0); + let g = Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 0.0))], + ); + let shaded = + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), fill(Paint::Gradient(Box::new(GradientPaint::new(g))))); + doc.insert(Some(layer), usize::MAX, shaded).unwrap(); + let clip = Node::new( + doc.alloc_id(), + NodeKind::Path { + path: shapes::rectangle(Rect::new(40.0, 10.0, 50.0, 30.0)), + rule: FillRule::NonZero, + live: None, + clipping: true, + guide: false, + }, + ); + let clipped = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(35.0, 15.0, 60.0, 25.0)), + Appearance::basic(solid(Color::gray(0.5)), solid(Color::BLACK), 1.0), + ); + let mut window = Node::new(doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(clipped)], clip: true }); + window.name = Some("Window".into()); + doc.insert(Some(layer), usize::MAX, window).unwrap(); + let outer = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(70.0, 10.0, 90.0, 30.0)), Appearance::default()); + let inner = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(75.0, 15.0, 85.0, 25.0)), Appearance::default()); + let mut ring = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(inner)], rule: FillRule::EvenOdd }); + ring.appearance = fill(solid(Color::cmyk(0.0, 1.0, 1.0, 0.0))).into(); + doc.insert(Some(layer), usize::MAX, ring).unwrap(); + let mut png = vec![]; + let img = image::RgbaImage::from_raw(2, 1, vec![255, 0, 0, 255, 0, 0, 255, 255]).unwrap(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let blob = ImageBlob::new("image/png", png); + let key = blob.content_key(); + doc.images.insert(key.clone(), blob); + let im = ImageObject { + key, + width: 2, + height: 1, + xf: Affine::new([10.0, 0.0, 0.0, 10.0, 100.0, 10.0]), + link: None, + placement: Default::default(), + pixels: None, + }; + let im = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im))); + doc.insert(Some(layer), usize::MAX, im).unwrap(); + let t = vectorcraft_doc::TextObject::point( + vectorcraft_geom::Point::new(10.0, 70.0), + "Type", + vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), ..vectorcraft_doc::CharStyle::default() }, + ); + let t = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.insert(Some(layer), usize::MAX, t).unwrap(); + let art = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), fill(solid(Color::rgb(0.0, 1.0, 0.0)))); + doc.symbols.push(Symbol { name: "Dot".into(), art: Arc::new(art) }); + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((150.0, 10.0)) }); + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + let mut faded = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(170.0, 10.0, 190.0, 30.0)), fill(solid(Color::BLACK))); + faded.opacity = 0.5; + doc.insert(Some(layer), usize::MAX, faded).unwrap(); + doc +} + +/// Every path in `nodes` and their descendants. +fn paths(nodes: &[Arc], out: &mut Vec) { + for n in nodes { + match n.children() { + Some(c) => paths(c, out), + None => out.push((**n).clone()), + } + } +} + +#[test] +fn art_the_slice_does_not_cover_is_written_as_plain_paths() { + let doc = unsupported(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + for w in [writer::GRADIENTS, writer::CLIPPING, writer::IMAGES, writer::TYPE, writer::LIVE, writer::TRANSPARENCY] { + assert!(out.warnings.iter().any(|x| x == w), "{w}: {:?}", out.warnings); + } + let back = read_back(&doc); + let layer = back.layers[0].children().unwrap(); + let kinds: Vec<&str> = layer + .iter() + .map(|n| match n.kind { + NodeKind::Path { .. } => "path", + NodeKind::Group { .. } => "group", + NodeKind::Compound { .. } => "compound", + _ => "other", + }) + .collect(); + assert_eq!(kinds, ["path", "group", "compound", "path", "group", "group", "path"]); + let mut all = vec![]; + paths(layer, &mut all); + assert!(all.iter().all(|n| matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }))); + // The gradient as its average color. + assert_eq!(paint(back.layers[0].children().unwrap()[0].appearance.items[0].paint()), paint(&solid(Color::rgb(0.5, 0.5, 0.5)))); + // The clipping group: the gray fill and the black stroke's outline, cut to the 10 × 20 clip. + let window = &layer[1]; + assert_eq!(window.name.as_deref(), Some("Window")); + let b = window.visual_bounds().unwrap(); + assert!((b.x0 - 40.0).abs() < 0.01 && (b.x1 - 50.0).abs() < 0.01, "{b:?}"); + // The image's outline filled with its pixels' average. + assert_eq!(paint(layer[3].appearance.items[0].paint()), paint(&solid(Color::rgb(0.5, 0.0, 0.5)))); + assert_eq!(layer[3].visual_bounds().map(|b| (r3(b.x0), r3(b.y0), r3(b.x1), r3(b.y1))), Some((r3(100.0), r3(10.0), r3(120.0), r3(20.0)))); + // The half-transparent square, opaque. + assert_eq!(layer[6].opacity, 1.0); +} + +#[test] +fn brushed_strokes_are_written_as_their_brush_art() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut line = Node::path( + doc.alloc_id(), + shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()), + Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), 4.0), + ); + line.appearance.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); + doc.insert(Some(layer), usize::MAX, line).unwrap(); + let pieces = vectorcraft_brush::stroke_pieces( + &doc, + &vectorcraft_brush::find(&doc, "3 pt. Round").unwrap(), + &shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()).to_bezpath(), + doc.layers[0].children().unwrap()[0].appearance.stroke().unwrap(), + ); + let back = read_back(&doc); + let NodeKind::Group { children, .. } = &back.layers[0].children().unwrap()[0].kind else { panic!("a group of the brush's art") }; + assert_eq!(children.len(), pieces.len()); + let bounds = |n: &Node| n.visual_bounds().map(|b| [b.x0, b.y0, b.x1, b.y1].map(|v| (v * 10.0).round())); + assert_eq!(children.iter().map(|c| bounds(c)).collect::>(), pieces.iter().map(bounds).collect::>()); +} + +#[test] +fn type_is_written_as_create_outlines_makes_it() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let style = vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), underline: true, ..vectorcraft_doc::CharStyle::default() }; + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "Under", style); + let mut t = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + t.name = Some("Title".into()); + doc.insert(Some(layer), usize::MAX, t.clone()).unwrap(); + let outlined = vectorcraft_effects::outline_text(&t).unwrap(); + let back = read_back(&doc); + let group = &back.layers[0].children().unwrap()[0]; + assert_eq!(group.name.as_deref(), Some("Title")); + let rounded = |n: &Node| n.visual_bounds().map(|b| [b.x0, b.y0, b.x1, b.y1].map(|v| (v * 100.0).round())); + // The underline reaches below the glyphs: the bounds match only with it. + assert_eq!(rounded(group), rounded(&outlined)); +} + +#[test] +fn a_stained_symbol_instance_is_written_in_its_stained_colors() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let art = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), + Appearance::basic(solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0), + ); + doc.symbols.push(Symbol { name: "Dot".into(), art: Arc::new(art) }); + let mut instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((50.0, 10.0)) }); + instance.appearance = Appearance::basic(solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into(); + let stained = vectorcraft_brush::instance_art(&doc.symbols[0].art, &instance); + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + let back = read_back(&doc); + let NodeKind::Group { children, .. } = &back.layers[0].children().unwrap()[0].kind else { panic!("the instance's group") }; + assert_eq!(paint(children[0].appearance.items[0].paint()), paint(stained.appearance.items[0].paint())); + assert_ne!(paint(stained.appearance.items[0].paint()), paint(&solid(Color::rgb(0.0, 1.0, 0.0))), "the instance stains its art"); +} + +#[test] +fn a_layer_with_crop_marks_keeps_its_art_and_gets_the_marks() { + for clipping in [false, true] { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let at = Rect::new(50.0, 30.0, 90.0, 60.0); + if clipping { + let clip = Node::new( + doc.alloc_id(), + NodeKind::Path { path: shapes::rectangle(at), rule: FillRule::NonZero, live: None, clipping: true, guide: false }, + ); + doc.insert(Some(layer), usize::MAX, clip).unwrap(); + } + let square = Node::path(doc.alloc_id(), shapes::rectangle(at), Appearance::basic(solid(Color::BLACK), Paint::None, 0.0)); + doc.insert(Some(layer), usize::MAX, square).unwrap(); + if let Some(l) = doc.layers.get_mut(0) { + let fx = vectorcraft_doc::Effect { id: vectorcraft_effects::CROP_MARKS.into(), params: serde_json::json!({}), visible: true }; + let l = Arc::make_mut(l); + let mut ap = (*l.appearance).clone(); + ap.effects.push(fx); + l.appearance = ap.into(); + if let NodeKind::Layer { clip, .. } = &mut l.kind { + *clip = clipping; + } + } + let name = doc.layers[0].name.clone(); + let back = read_back(&doc); + assert_eq!(back.layers.len(), 1); + assert_eq!(back.layers[0].name, name); + let members = back.layers[0].children().unwrap(); + assert_eq!(members.len(), 2, "clipping {clipping}: the square and the marks"); + assert!(matches!(members[0].kind, NodeKind::Path { .. })); + assert!(matches!(members[1].kind, NodeKind::Group { .. })); + // The marks lie around the square, outside a clipping path. + let marks = members[1].visual_bounds().unwrap(); + assert!(marks.x0 < at.x0 - 1.0 && marks.x1 > at.x1 + 1.0, "clipping {clipping}: {marks:?}"); + } +} + +#[test] +fn geometry_effects_in_a_symbol_are_written_as_their_result() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut ap = Appearance::basic(solid(Color::BLACK), Paint::None, 0.0); + ap.effects.push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: serde_json::json!({"offset": 5}), visible: true }); + let art = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), ap); + doc.symbols.push(Symbol { name: "Dot".into(), art: Arc::new(art) }); + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((50.0, 20.0)) }); + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + let back = read_back(&doc); + let b = back.layers[0].children().unwrap()[0].visual_bounds().unwrap(); + // The 10 pt square offset by 5 pt, at (50, 20). + assert!((b.x0 - 45.0).abs() < 0.1 && (b.x1 - 65.0).abs() < 0.1 && (b.y0 - 15.0).abs() < 0.1 && (b.y1 - 35.0).abs() < 0.1, "{b:?}"); +} + +#[test] +fn an_adjusted_image_in_a_symbol_is_written_in_its_adjusted_color() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut png = vec![]; + image::RgbaImage::from_pixel(2, 2, image::Rgba([0, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let blob = ImageBlob::new("image/png", png); + let key = blob.content_key(); + doc.images.insert(key.clone(), blob); + let im = ImageObject { key, width: 2, height: 2, xf: Affine::scale(5.0), link: None, placement: Default::default(), pixels: None }; + let mut im = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im))); + let shift = serde_json::json!({"amount": 100, "preserveLightness": false}); + let mut ap = (*im.appearance).clone(); + ap.effects.push(vectorcraft_doc::Effect { id: "adjust.shiftToColor".into(), params: shift, visible: true }); + im.appearance = ap.into(); + doc.symbols.push(Symbol { name: "Picture".into(), art: Arc::new(im) }); + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Picture".into(), xf: Affine::translate((50.0, 20.0)) }); + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + let back = read_back(&doc); + let mut all = vec![]; + paths(back.layers[0].children().unwrap(), &mut all); + let fill = paint(all[0].appearance.items[0].paint()); + assert_ne!(fill, paint(&solid(Color::gray(0.5))), "the adjusted image is found"); + assert_ne!(fill, paint(&solid(Color::rgb(0.0, 0.0, 0.0))), "the image is adjusted"); +} + +#[test] +fn effects_on_a_fill_are_reported_left_out() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut fill = FillLayer::new(solid(Color::BLACK)); + fill.effects.push(vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({}), visible: true }); + let ap = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Appearance::default() }; + let square = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), ap); + doc.insert(Some(layer), usize::MAX, square).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); +} + +#[test] +fn images_too_large_to_average_are_mid_gray() { + let mut png = vec![]; + image::GrayImage::new(5000, 4000).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + assert_eq!(image_average_of(&png), paint(&solid(Color::gray(0.5)))); + let mut small = vec![]; + image::GrayImage::new(50, 40).write_to(&mut std::io::Cursor::new(&mut small), image::ImageFormat::Png).unwrap(); + assert_eq!(image_average_of(&small), paint(&solid(Color::rgb(0.0, 0.0, 0.0)))); +} + +/// The fill an image of `png` is written with, as the reader reads it. +fn image_average_of(png: &[u8]) -> String { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let blob = ImageBlob::new("image/png", png.to_vec()); + let key = blob.content_key(); + doc.images.insert(key.clone(), blob); + let im = ImageObject { key, width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + let im = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im))); + doc.insert(Some(layer), usize::MAX, im).unwrap(); + paint(read_back(&doc).layers[0].children().unwrap()[0].appearance.items[0].paint()) +} + +#[test] +fn a_document_without_an_artboard_is_an_error() { + let mut doc = Document::new(200.0, 100.0); + doc.artboards.clear(); + assert!(export_ai_editing(&doc, &opts()).is_err()); + doc.artboards.push(Artboard { rect: Rect::new(0.0, 0.0, 0.0, 10.0), ..Document::new(1.0, 1.0).artboards[0].clone() }); + assert!(export_ai_editing(&doc, &opts()).is_err()); +} diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs new file mode 100644 index 000000000..ff0d6ed77 --- /dev/null +++ b/crates/eps/src/ai_editing/writer.rs @@ -0,0 +1,710 @@ +//! The program's art and swatches (see [`super`] for the form). + +use std::collections::{HashMap, HashSet}; +use std::fmt::Write; +use std::sync::Arc; + +use vectorcraft_color::cms::Cms; +use vectorcraft_color::swatch::{REGISTRATION, Swatch}; +use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{AppearanceItem, Document, FillLayer, LAYER_COLORS, LayerColor, Node, NodeKind, StrokeLayer}; +use vectorcraft_effects::stroke; +use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Shape, SubPath}; +use vectorcraft_pathops::BoolOp; + +use crate::ai_ops::{self, average, unit}; +use crate::ps; + +/// How deep symbols, live objects and pattern tiles may nest in one another (deeper art is left +/// out). Groups and layers need no limit of their own: a document is never deeper than +/// [`vectorcraft_doc::MAX_DEPTH`]. +const MAX_NEST: u32 = 8; +/// The side of the image whose pixels are averaged (larger ones are scaled down to it first). +const AVERAGE_SIDE: u32 = 64; +/// Most pixels an image may have to be decoded and averaged (more: mid gray). +const MAX_AVERAGED_PIXELS: u64 = 1 << 24; + +pub(super) const TYPE: &str = "type is written as its outlines in the editing data for other apps"; +pub(super) const GRADIENTS: &str = "gradients are written as their average color in the editing data for other apps"; +const PATTERNS: &str = "pattern fills are written as their tiles cut to the shape in the editing data for other apps"; +pub(super) const CLIPPING: &str = + "clipping groups and clipping layers are written as their art cut to the clipping path in the editing data for other apps"; +pub(super) const IMAGES: &str = "images are written as their outlines filled with their average color in the editing data for other apps"; +pub(super) const LIVE: &str = + "symbols, blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps"; +const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps"; +pub(super) const TRANSPARENCY: &str = "transparency (opacity, blending modes, opacity masks) is left out of the editing data for other apps"; +pub(super) const EFFECTS: &str = "live effects other than geometry effects are left out of the editing data for other apps"; +const SWATCHES: &str = "gradient and pattern swatches are left out of the editing data for other apps"; +const TEMPLATES: &str = "template layers are written as dimmed layers that don't print in the editing data for other apps"; + +pub(super) struct Writer<'a> { + doc: &'a Document, + /// The document → art space. + to_art: Affine, + cms: Arc, + pub(super) body: String, + pub(super) warnings: Vec, + /// The lock and hidden state (`A`, `Xw`) the last operators set; `None` at a layer's start. + state: Option<(bool, bool)>, + /// The region the art being written is cut to (a clipping path's, in document space). + clip: Option, + /// Symbols, live objects and pattern tiles being written inside one another. + nest: u32, + /// The XML ids objects are named with so far. + ids: HashSet, + /// The number the next object named after each XML id tries first. + next: HashMap, + /// The document's brushes, read the first time a brushed stroke is written. + brushes: Option>, +} + +impl<'a> Writer<'a> { + pub(super) fn new(doc: &'a Document, to_art: Affine) -> Self { + Self { + doc, + to_art, + cms: vectorcraft_color::cms::active(), + body: String::new(), + warnings: vec![], + state: None, + clip: None, + nest: 0, + ids: HashSet::new(), + next: HashMap::new(), + brushes: None, + } + } + + fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + fn line(&mut self, s: &str) { + self.body.push_str(s); + self.body.push('\n'); + } + + // ---------- layers and objects ---------- + + /// A top-level layer. A layer that baking its effects made a group of the layer and the art an + /// effect adds (Crop Marks) is written as that layer holding the added art too, and any other + /// object at the top level in a layer of its own. + pub(super) fn layer(&mut self, n: &Node) { + if n.is_layer() { + return self.write_layer(n, &[]); + } + let members = n.children().map(Vec::as_slice).unwrap_or_default(); + self.transparency(n); + match members.iter().find(|c| c.is_layer()) { + Some(l) => { + // The group took the layer's name. The added art is outside the layer's clipping. + let layer = Node { name: l.name.clone().or_else(|| n.name.clone()), ..(**l).clone() }; + let added: Vec> = members.iter().filter(|c| !c.is_layer()).cloned().collect(); + self.write_layer(&layer, &added); + } + None => { + let mut layer = Node::layer(n.id, n.name.as_deref().unwrap_or("Layer"), LayerColor::Preset(0)); + (layer.visible, layer.locked) = (n.visible, n.locked); + self.write_layer(&layer, &[Arc::new(n.clone())]); + } + } + } + + /// A layer or sublayer: `%AI5_BeginLayer`, its options `Lb`, its name `Ln`, its art, then + /// `after` (outside its clipping path when it clips), `LB`, `%AI5_EndLayer--`. + fn write_layer(&mut self, n: &Node, after: &[Arc]) { + let NodeKind::Layer { color, template, printable, children, clip, preview, dim_images } = &n.kind else { return }; + let [r, g, b] = match color { + LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1), + LayerColor::Custom(rgb) => *rgb, + }; + let index = match color { + LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i), + _ => -1, + }; + if *template { + self.warn(TEMPLATES); + } + self.transparency(n); + let flag = |b: bool| u8::from(b); + let dim = dim_images.unwrap_or(50); + let _ = writeln!( + self.body, + "%AI5_BeginLayer\n{} {} {} {} {} 0 1 {index} {r} {g} {b} 0 {dim} 0 Lb\n{} Ln", + flag(n.visible), + flag(*preview), + flag(!n.locked), + flag(*printable && !*template), + flag(dim_images.is_some() || *template), + ps::bytes(n.name.as_deref().unwrap_or("Layer").as_bytes()), + ); + self.state = None; + if *clip { + self.warn(CLIPPING); + self.clip_contents(children); + } else { + for c in children { + self.node(c); + } + } + for c in after { + self.node(c); + } + self.line("LB\n%AI5_EndLayer--"); + // The reader's state carries over into the art that follows. + self.state = None; + } + + /// Set the lock and hidden state of the object that follows. + fn state(&mut self, locked: bool, hidden: bool) { + let (was_locked, was_hidden) = match self.state { + Some((l, h)) => (Some(l), Some(h)), + None => (None, None), + }; + if was_locked != Some(locked) { + self.line(if locked { "1 A" } else { "0 A" }); + } + if was_hidden != Some(hidden) { + self.line(if hidden { "1 Xw" } else { "0 Xw" }); + } + self.state = Some((locked, hidden)); + } + + /// Warn when `n` has transparency or effects the data leaves out. + fn transparency(&mut self, n: &Node) { + if n.opacity < 1.0 || n.blend != BlendMode::Normal || n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { + self.warn(TRANSPARENCY); + } + let items = n.appearance.items.iter().filter(|i| i.visible()).flat_map(|i| i.effects()); + if n.appearance.effects.iter().chain(items).any(|e| e.visible) { + self.warn(EFFECTS); + } + } + + /// One object, its state before it and its name after it. + fn node(&mut self, n: &Node) { + if let NodeKind::Layer { .. } = n.kind { + return self.write_layer(n, &[]); + } + self.transparency(n); + self.state(n.locked, !n.visible); + let start = self.body.len(); + match &n.kind { + NodeKind::Layer { .. } => {} + NodeKind::Group { children, clip } => { + self.line("u"); + if *clip { + self.warn(CLIPPING); + self.clip_contents(children); + } else { + for c in children { + self.node(c); + } + } + self.line("U"); + } + NodeKind::Path { path, rule, guide, .. } => { + if *guide { + ai_ops::guide(&mut self.body, self.to_art, path, true); + } else { + self.shape(n, path, *rule); + } + } + NodeKind::Compound { children, rule } => { + let mut subpaths = vec![]; + for p in children.iter().filter_map(|c| c.path_data()) { + subpaths.extend(p.subpaths.iter().cloned()); + } + self.shape(n, &PathData::new(subpaths), *rule); + } + NodeKind::Text(_) => self.text(n), + NodeKind::Image(im) => { + let bounds = PathData::from_bezpath(&(im.xf * Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1))); + let color = image_average(self.doc.image_blob(im).map(|b| b.bytes.as_slice())); + self.warn(IMAGES); + self.fill_object(&bounds, FillRule::NonZero, &FillLayer::new(Paint::solid(color))); + } + NodeKind::SymbolInstance { symbol, xf } => { + self.warn(LIVE); + self.line("u"); + if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) + && self.nest < MAX_NEST + { + // Stained by the instance's fill, as the canvas draws it. + let mut art = vectorcraft_brush::instance_art(&sym.art, n); + art.transform(*xf, false); + art.visible = true; + art.locked = false; + art.name = None; + self.nest += 1; + self.node(&art); + self.nest -= 1; + } + self.line("U"); + } + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => { + self.warn(LIVE); + self.line("u"); + if self.nest < MAX_NEST { + let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); + self.nest += 1; + for c in g.children().map(Vec::as_slice).unwrap_or_default() { + self.node(c); + } + self.nest -= 1; + } + self.line("U"); + } + } + if self.body.len() > start { + self.name(n); + } + } + + /// `n`'s name, after it: an `/ArtDictionary` whose `AI10_ArtUID` is the name as an XML id, + /// numbered (`_` and 20 digits, `_`) when another object has that id. + fn name(&mut self, n: &Node) { + let Some(name) = n.name.as_deref().filter(|s| !s.is_empty()) else { return }; + let base = vectorcraft_doc::xml_id::from_object_name(name); + let mut id = base.clone(); + let next = self.next.entry(base.clone()).or_insert(0); + while self.ids.contains(&id) { + *next += 1; + id = format!("{base}_{next:020}_"); + } + self.ids.insert(id.clone()); + let _ = writeln!(self.body, "%_/ArtDictionary :\n%_/XMLUID : {} ; (AI10_ArtUID) ,\n%_;\n%_", ps::bytes(id.as_bytes())); + } + + /// A container's members, the first one's outline cutting the others: its fills painted + /// behind them (cut to the region outside too), its strokes over them, not cut by it. + fn clip_contents(&mut self, children: &[Arc]) { + let Some((first, rest)) = children.split_first() else { return }; + // Nothing to clip by hides the clipped art. + let Some((outline, rule)) = vectorcraft_effects::clip_outline(first) else { return }; + let paint = first.clip_paint(); + let outer = self.clip.clone(); + let region = self.cut(&PathData::from_bezpath(&outline), rule); + self.clip = Some(region); + if let Some(fill) = &paint.fill { + self.node(fill); + } + for c in rest { + self.node(c); + } + self.clip = outer; + if let Some(stroke) = &paint.stroke { + self.node(stroke); + } + } + + /// `path` (filled by `rule`) cut to the region the art is cut to, as a non-zero path. + fn cut(&self, path: &PathData, rule: FillRule) -> PathData { + match &self.clip { + Some(clip) => vectorcraft_pathops::boolean(path, rule, clip, FillRule::NonZero, BoolOp::Intersect), + None => vectorcraft_pathops::boolean(path, rule, path, rule, BoolOp::Intersect), + } + } + + // ---------- paths ---------- + + /// A path's fills and strokes: one object with one fill and a plain stroke over it where it + /// can be, else a group of an object for each (in paint order). + fn shape(&mut self, n: &Node, path: &PathData, rule: FillRule) { + if path.is_empty() { + return; + } + let items: Vec<&AppearanceItem> = n + .appearance + .items + .iter() + .filter(|i| match i { + AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(), + AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0 && s.width.is_finite(), + }) + .collect(); + if items.iter().any(|i| i.opacity() < 1.0 || i.blend() != BlendMode::Normal) { + self.warn(TRANSPARENCY); + } + let solid = |p: &Paint| matches!(p, Paint::Solid { .. }); + match items.as_slice() { + [] => self.path_object(path, rule, None, None), + [AppearanceItem::Fill(f)] if solid(&f.paint) => self.path_object(path, rule, Some(f), None), + [AppearanceItem::Stroke(s)] if direct_stroke(s) => self.path_object(path, rule, None, Some(s)), + [AppearanceItem::Fill(f), AppearanceItem::Stroke(s)] if solid(&f.paint) && direct_stroke(s) => { + self.path_object(path, rule, Some(f), Some(s)) + } + [one] => self.item(path, rule, one), + items => { + self.line("u"); + for i in items { + self.item(path, rule, i); + } + self.line("U"); + } + } + } + + /// One fill or stroke of a path as an object of its own. + fn item(&mut self, path: &PathData, rule: FillRule, item: &AppearanceItem) { + match item { + AppearanceItem::Fill(f) => self.fill_object(path, rule, f), + AppearanceItem::Stroke(s) if direct_stroke(s) => self.path_object(path, rule, None, Some(s)), + AppearanceItem::Stroke(s) => { + self.warn(STROKES); + if self.brush_art(path, s) { + return; + } + let outline = stroke::outline_region(path, rule, s); + let fill = FillLayer { overprint: s.overprint, ..FillLayer::new(s.paint.clone()) }; + self.fill_object(&outline, FillRule::NonZero, &fill); + } + } + } + + /// A brushed stroke `st` of `path` as a group of its brush's art. False when it has no brush + /// the document knows (or brushes nest too deep). + fn brush_art(&mut self, path: &PathData, st: &StrokeLayer) -> bool { + if self.nest >= MAX_NEST { + return false; + } + let doc = self.doc; + let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)); + let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) else { return false }; + let art = vectorcraft_brush::stroke_pieces(doc, b, &path.to_bezpath(), st); + let state = self.state; + self.nest += 1; + self.line("u"); + for piece in &art { + self.node(piece); + } + if let Some((locked, hidden)) = state { + self.state(locked, hidden); + } + self.line("U"); + self.nest -= 1; + true + } + + /// A fill as an object of its own: a gradient as its average color, a pattern as its tiles + /// cut to the path. + fn fill_object(&mut self, path: &PathData, rule: FillRule, f: &FillLayer) { + match &f.paint { + Paint::Solid { .. } => self.path_object(path, rule, Some(f), None), + Paint::Gradient(g) => { + self.warn(GRADIENTS); + let flat = FillLayer { paint: Paint::solid(average(&g.gradient)), ..f.clone() }; + self.path_object(path, rule, Some(&flat), None); + } + Paint::Pattern { pattern, xf } => { + self.warn(PATTERNS); + let doc = self.doc; + let Some(def) = doc.pattern(pattern).filter(|_| self.nest < MAX_NEST) else { + let flat = FillLayer { paint: Paint::solid(Color::gray(0.5)), ..f.clone() }; + return self.path_object(path, rule, Some(&flat), None); + }; + let tiles = def.instances_in(*xf, path.to_bezpath().bounding_box()); + let outer = self.clip.clone(); + let region = self.cut(path, rule); + self.clip = Some(region); + self.nest += 1; + let state = self.state; + self.line("u"); + for t in &tiles { + self.node(t); + } + if let Some((locked, hidden)) = state { + self.state(locked, hidden); + } + self.line("U"); + self.nest -= 1; + self.clip = outer; + } + Paint::None => {} + } + } + + /// A path object painted with `fill` (a color) and `stroke` (a plain line of a color) over + /// it; several subpaths make a compound path. Where the art is cut to a clipping path, the + /// fill is the path's region cut to it and the stroke its outline cut to it, filled. + fn path_object(&mut self, path: &PathData, rule: FillRule, fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>) { + if self.clip.is_none() { + return self.plain_object(path, rule, fill, stroke); + } + let both = fill.is_some() && stroke.is_some(); + if both { + self.line("u"); + } + if fill.is_some() || stroke.is_none() { + let region = self.cut(path, rule); + self.plain_object(®ion, FillRule::NonZero, fill, None); + } + if let Some(s) = stroke { + let outline = stroke::outline_region(path, rule, s); + let region = self.cut(&outline, FillRule::NonZero); + let fill = FillLayer { overprint: s.overprint, ..FillLayer::new(s.paint.clone()) }; + self.plain_object(®ion, FillRule::NonZero, Some(&fill), None); + } + if both { + self.line("U"); + } + } + + /// [`Self::path_object`] without cutting: the paint (`O`/`R` overprint, line style), `XR`, + /// each subpath's points and painting operator (`*u` … `*U` around several). + fn plain_object(&mut self, path: &PathData, rule: FillRule, fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>) { + let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect(); + if subpaths.is_empty() { + return; + } + if let Some(f) = fill { + self.color(&f.paint, false); + self.line(if f.overprint { "1 O" } else { "0 O" }); + } + if let Some(s) = stroke { + self.color(&s.paint, true); + self.line(if s.overprint { "1 R" } else { "0 R" }); + ai_ops::line_style(&mut self.body, s); + } + self.line(if rule == FillRule::EvenOdd { "1 XR" } else { "0 XR" }); + let op = match (fill.is_some(), stroke.is_some()) { + (true, true) => 'b', + (true, false) => 'f', + (false, true) => 's', + (false, false) => 'n', + }; + let several = subpaths.len() > 1; + if several { + self.line("*u"); + } + for sp in subpaths { + ai_ops::subpath(&mut self.body, self.to_art, sp); + let op = if sp.closed { op } else { op.to_ascii_uppercase() }; + self.body.push(op); + self.body.push('\n'); + } + if several { + self.line("*U"); + } + } + + // ---------- type ---------- + + /// Type `n` as a group of what Type › Create Outlines makes of it + /// ([`vectorcraft_effects::outline_text`]): each run's glyphs with their underline and + /// strikethrough, its color glyphs, and the object's own fills and strokes. + fn text(&mut self, n: &Node) { + self.warn(TYPE); + self.line("u"); + if let Some(outlined) = vectorcraft_effects::outline_text(n) { + let state = self.state; + for c in outlined.children().map(Vec::as_slice).unwrap_or_default() { + self.node(c); + } + if let Some((locked, hidden)) = state { + self.state(locked, hidden); + } + } + self.line("U"); + } + + // ---------- color ---------- + + /// Set the fill (or with `stroke`, the stroke) color to solid paint `p`: a spot or global + /// swatch by its name, Registration, else the color itself. + fn color(&mut self, p: &Paint, stroke: bool) { + let Paint::Solid { color, swatch, tint } = p else { + return self.plain_color(Color::gray(1.0), stroke); + }; + if swatch.as_deref() == Some(REGISTRATION) { + let mut s = String::from("1 1 1 1 "); + s.push_str(&ps::bytes(REGISTRATION.as_bytes())); + s.push(' '); + ps::push_num(&mut s, f64::from(1.0 - unit(*tint))); + s.push_str(if stroke { " XS" } else { " Xs" }); + return self.line(&s); + } + let doc = self.doc; + if let Some(name) = swatch + && let Some(sw) = doc.swatch(name).filter(|s| s.global || s.spot) + && let Paint::Solid { color: full, swatch: None, .. } = sw.paint + { + let mut s = String::new(); + self.named(&mut s, name, full, *tint, sw.spot, stroke); + return self.body.push_str(&s); + } + self.plain_color(*color, stroke); + } + + /// A color that names no swatch: `g` (gray), `k` (CMYK), else `c m y k r g b Xa` (RGB, and + /// Lab as RGB), with its CMYK equivalent. + fn plain_color(&mut self, color: Color, stroke: bool) { + let s = &mut self.body; + let op = |fill: &'static str, line: &'static str| if stroke { line } else { fill }; + match color { + // Gray is ink coverage; the format's gray is lightness. + Color::Gray { k } => { + ps::push_num(s, f64::from(1.0 - unit(k))); + let _ = writeln!(s, " {}", op("g", "G")); + } + Color::Cmyk { c, m, y, k } => { + ps::push_nums(s, &[c, m, y, k].map(|v| f64::from(unit(v)))); + let _ = writeln!(s, "{}", op("k", "K")); + } + Color::Rgb { .. } | Color::Lab { .. } => { + let cmyk = self.cms.to_cmyk(&color, self.cms.settings().intent).map(unit); + ps::push_nums(s, &cmyk.map(f64::from)); + ps::push_nums(s, &color.to_rgb().map(|v| f64::from(unit(v)))); + let _ = writeln!(s, "{}", op("Xa", "XA")); + } + } + } + + /// A spot (`spot`) or global process color `name` of full color `full` at `tint`, written to + /// `s`: its CMYK equivalent, its RGB or Lab values, the name, how much lighter than the full + /// color, and for any but a CMYK spot color (`x`) the model (0 CMYK, 1 RGB, 2 Lab) before + /// `Xx` (spot) or `Xk` (global process). + fn named(&self, s: &mut String, name: &str, full: Color, tint: f32, spot: bool, stroke: bool) { + let cmyk = self.cms.to_cmyk(&self.doc.linked_color(full, spot), self.cms.settings().intent).map(unit); + ps::push_nums(s, &cmyk.map(f64::from)); + let model = match full { + Color::Rgb { r, g, b } => { + ps::push_nums(s, &[r, g, b].map(|v| f64::from(unit(v)))); + Some(1) + } + Color::Lab { l, a, b } => { + ps::push_nums(s, &[f64::from(l), f64::from(a), f64::from(b)]); + Some(2) + } + _ if spot => None, + _ => Some(0), + }; + s.push_str(&ps::bytes(name.as_bytes())); + s.push(' '); + ps::push_num(s, f64::from(1.0 - unit(tint))); + let op = match (model, spot) { + (None, _) => "x", + (Some(_), true) => "Xx", + (Some(_), false) => "Xk", + }; + if let Some(m) = model { + let _ = write!(s, " {m}"); + } + let op = if stroke { op.to_ascii_uppercase() } else { op.to_string() }; + let _ = writeln!(s, " {op}"); + } + + // ---------- swatches ---------- + + /// The swatches: `%AI5_BeginPalette`, `0 0 Pb`, each color swatch's color and `(name) Pc` + /// (the ungrouped ones first, then each color group's after `0 (name) count Pg`), `PB`, + /// `%AI5_EndPalette`. + pub(super) fn palette(&mut self, s: &mut String) { + s.push_str("%AI5_BeginPalette\n0 0 Pb\n"); + let doc = self.doc; + for sw in doc.swatches.iter() { + self.swatch(s, sw); + } + for g in doc.swatch_groups.iter() { + let colors: Vec<&Swatch> = g.swatches.iter().filter(|sw| writable(sw)).collect(); + let _ = writeln!(s, "0 {} {} Pg", ps::bytes(g.name.as_bytes()), colors.len()); + for sw in &g.swatches { + self.swatch(s, sw); + } + } + s.push_str("PB\n%AI5_EndPalette\n"); + } + + /// One swatch of the palette: a color swatch's color and `(name) Pc`; gradient and pattern + /// swatches are left out. + fn swatch(&mut self, s: &mut String, sw: &Swatch) { + if sw.name == REGISTRATION || matches!(sw.paint, Paint::None) { + return; + } + if !writable(sw) { + return self.warn(SWATCHES); + } + let Paint::Solid { color, swatch, .. } = &sw.paint else { return }; + if swatch.is_none() && (sw.global || sw.spot) { + self.named(s, &sw.name, *color, 1.0, sw.spot, false); + } else { + let body = std::mem::take(&mut self.body); + self.plain_color(*color, false); + s.push_str(&std::mem::replace(&mut self.body, body)); + } + let _ = writeln!(s, "{} Pc", ps::bytes(sw.name.as_bytes())); + } + + /// The spot color swatches, with their full colors' CMYK equivalents, for the header. + pub(super) fn spot_swatches(&self) -> Vec<(String, [f32; 4])> { + let intent = self.cms.settings().intent; + self.doc + .swatches_iter() + .filter(|sw| sw.spot) + .filter_map(|sw| match sw.paint { + Paint::Solid { color, swatch: None, .. } => { + Some((sw.name.clone(), self.cms.to_cmyk(&self.doc.linked_color(color, true), intent).map(unit))) + } + _ => None, + }) + .collect() + } +} + +/// Is `sw` a color swatch the palette writes? +fn writable(sw: &Swatch) -> bool { + matches!(sw.paint, Paint::Solid { .. }) && sw.name != REGISTRATION +} + +/// Does a plain line of one color draw `s` (the line style operators can write it)? +fn direct_stroke(s: &StrokeLayer) -> bool { + matches!(s.paint, Paint::Solid { .. }) && stroke::is_plain(s) +} + +/// The image `bytes` decoded, when its header gives at most [`MAX_AVERAGED_PIXELS`] pixels; the +/// decoder may allocate at most 8 bytes a pixel. +fn decode_bounded(bytes: &[u8]) -> Option { + let reader = || -> image::ImageResult>> { + let mut r = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(MAX_AVERAGED_PIXELS * 8); + r.limits(limits); + Ok(r) + }; + let (w, h) = reader().and_then(|r| r.into_dimensions()).ok()?; + if u64::from(w) * u64::from(h) > MAX_AVERAGED_PIXELS { + return None; + } + reader().and_then(|r| r.decode()).ok() +} + +/// The average color of the image `bytes` (scaled down first), transparent pixels over white; +/// mid gray when it can't be decoded or has more than [`MAX_AVERAGED_PIXELS`] pixels. +fn image_average(bytes: Option<&[u8]>) -> Color { + let Some(img) = bytes.and_then(decode_bounded) else { return Color::gray(0.5) }; + let img = if img.width() > AVERAGE_SIDE || img.height() > AVERAGE_SIDE { img.thumbnail(AVERAGE_SIDE, AVERAGE_SIDE) } else { img }; + let rgba = img.to_rgba8(); + let mut sum = [0u64; 3]; + let mut n = 0u64; + for px in rgba.pixels() { + let [r, g, b, a] = px.0; + let over = |c: u8| (u64::from(c) * u64::from(a) + 255 * (255 - u64::from(a))) / 255; + sum[0] += over(r); + sum[1] += over(g); + sum[2] += over(b); + n += 1; + } + if n == 0 { + return Color::gray(0.5); + } + let c = |v: u64| (v as f32 / n as f32 / 255.0).clamp(0.0, 1.0); + Color::rgb(c(sum[0]), c(sum[1]), c(sum[2])) +} diff --git a/crates/eps/src/ai_ops.rs b/crates/eps/src/ai_ops.rs new file mode 100644 index 000000000..cfa3928ec --- /dev/null +++ b/crates/eps/src/ai_ops.rs @@ -0,0 +1,97 @@ +//! Operators the legacy `.ai` writer ([`crate::legacy_ai`]) and the editing data writer +//! ([`crate::ai_editing`]) both write: path points, guides and line style (Adobe Illustrator File +//! Format Specification, version 7.0, §5.3, §5.4 and §6), and the colors they write in place of +//! the paint they can't hold. + +use std::fmt::Write; + +use vectorcraft_color::{Color, Gradient}; +use vectorcraft_doc::{LineCap, LineJoin, StrokeLayer}; +use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, SubPath}; + +use crate::ps; + +/// One subpath's points, taken into art space by `xf`: `m`, then a line or curve to each point +/// (lower case: the point is smooth, upper case: a corner, §5.3.2), and for a closed one the +/// segment back to the start, which says whether the start is smooth or a corner (as the +/// specification's examples close paths: the start point again, then the painting operator). +pub(crate) fn subpath(s: &mut String, xf: Affine, sp: &SubPath) { + let Some(first) = sp.anchors.first() else { return }; + let pt = |s: &mut String, p: Point| { + let p = xf * p; + ps::push_nums(s, &[p.x, p.y]); + }; + pt(s, first.p); + s.push_str("m\n"); + let segment = |s: &mut String, a: &Anchor, b: &Anchor| { + let straight = a.h_out == a.p && b.h_in == b.p; + let smooth = b.kind == AnchorKind::Smooth; + if straight { + pt(s, b.p); + s.push_str(if smooth { "l\n" } else { "L\n" }); + } else { + pt(s, a.h_out); + pt(s, b.h_in); + pt(s, b.p); + s.push_str(if smooth { "c\n" } else { "C\n" }); + } + }; + for pair in sp.anchors.windows(2) { + if let [a, b] = pair { + segment(s, a, b); + } + } + if sp.closed + && sp.anchors.len() > 1 + && let Some(last) = sp.anchors.last() + { + segment(s, last, first); + } +} + +/// A guide (§6): for each subpath, `(N) *`, its points, `(N) *`; with `closing`, a closed +/// subpath ends `(n) *` (the painting operator that closes it first). +pub(crate) fn guide(s: &mut String, xf: Affine, path: &PathData, closing: bool) { + for sp in path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()) { + s.push_str("(N) *\n"); + subpath(s, xf, sp); + s.push_str(if closing && sp.closed { "(n) *\n" } else { "(N) *\n" }); + } +} + +pub(crate) fn unit(v: f32) -> f32 { + if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } +} + +/// The average colour of a gradient's stops (in RGB). +pub(crate) fn average(g: &Gradient) -> Color { + let n = g.stops.len().max(1) as f32; + let sum = g.stops.iter().map(|s| s.color.to_rgb()).fold([0.0f32; 3], |a, c| [a[0] + c[0], a[1] + c[1], a[2] + c[2]]); + Color::rgb(unit(sum[0] / n), unit(sum[1] / n), unit(sum[2] / n)) +} + +/// The stroke's width, cap, join, miter limit and dashes: `w J j M d`. +pub(crate) fn line_style(s: &mut String, st: &StrokeLayer) { + let cap = match st.cap { + LineCap::Butt => 0, + LineCap::Round => 1, + LineCap::Square => 2, + }; + let join = match st.join { + LineJoin::Miter => 0, + LineJoin::Round => 1, + LineJoin::Bevel => 2, + }; + ps::push_num(s, st.width); + let _ = write!(s, " w {cap} J {join} j "); + ps::push_num(s, if st.miter_limit.is_finite() { st.miter_limit.max(1.0) } else { 10.0 }); + s.push_str(" M ["); + let dash = st.dash.as_ref().filter(|d| d.is_dashed()); + if let Some(d) = dash { + ps::push_nums(s, &d.pattern); + s.pop(); + } + s.push_str("] "); + ps::push_num(s, dash.map_or(0.0, |d| d.offset)); + s.push_str(" d\n"); +} diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index fc7d6e9d9..9fd1b8632 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -64,13 +64,13 @@ use std::sync::Arc; use vectorcraft_color::cms::Cms; use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{ - AppearanceItem, AreaOptions, CharPosition, CharStyle, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, LineCap, LineJoin, Node, - NodeKind, ParaDirection, ParaStyle, PathEffect, StrokeLayer, TextKind, TextObject, + AppearanceItem, AreaOptions, CharPosition, CharStyle, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, Node, NodeKind, + ParaDirection, ParaStyle, PathEffect, StrokeLayer, TextKind, TextObject, }; use vectorcraft_effects::stroke; -use vectorcraft_geom::{Affine, AnchorKind, BezPath, FillRule, PathData, Point, Rect, Shape, SubPath, Vec2}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, SubPath, Vec2}; -use crate::{latin, ps}; +use crate::{ai_ops, latin, ps}; mod encoding; mod prolog; diff --git a/crates/eps/src/legacy_ai/encoding.rs b/crates/eps/src/legacy_ai/encoding.rs index b67296e72..21903cac0 100644 --- a/crates/eps/src/legacy_ai/encoding.rs +++ b/crates/eps/src/legacy_ai/encoding.rs @@ -60,9 +60,7 @@ pub(super) enum Ink { }, } -pub(super) fn unit(v: f32) -> f32 { - if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } -} +pub(super) use crate::ai_ops::{average, unit}; /// Can `a` be drawn through (a finite, non-flat map)? pub(super) fn invertible(a: Affine) -> bool { @@ -172,10 +170,3 @@ pub(super) fn direct_fill(p: &Paint) -> bool { pub(super) fn direct_stroke(s: &StrokeLayer) -> bool { matches!(s.paint, Paint::Solid { .. }) && stroke::is_plain(s) } - -/// The average colour of a gradient's stops (in RGB). -pub(super) fn average(g: &Gradient) -> Color { - let n = g.stops.len().max(1) as f32; - let sum = g.stops.iter().map(|s| s.color.to_rgb()).fold([0.0f32; 3], |a, c| [a[0] + c[0], a[1] + c[1], a[2] + c[2]]); - Color::rgb(unit(sum[0] / n), unit(sum[1] / n), unit(sum[2] / n)) -} diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 31caf715f..152df7d1e 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -357,13 +357,9 @@ impl<'a> Writer<'a> { self.line(if sp.closed { "h\nW\nn" } else { "H\nW\nN" }); } - /// A guide (§6): `(N) *`, its path, `(N) *`. + /// A guide ([`ai_ops::guide`]). fn guide(&mut self, path: &PathData) { - for sp in path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()) { - self.line("(N) *"); - self.subpath(sp); - self.line("(N) *"); - } + ai_ops::guide(&mut self.body, self.to_art, path, false); } /// A path's fills and strokes: one object with one fill and a plain stroke over it where it @@ -522,71 +518,14 @@ impl<'a> Writer<'a> { self.line(if rule == FillRule::EvenOdd { "1 XR" } else { "0 XR" }); } - /// One subpath's points, in art space: `m`, then a line or curve to each point (lower case: the - /// point is smooth, upper case: a corner, §5.3.2), and for a closed one the segment back to the - /// start, which says whether the start is smooth or a corner (as the specification's examples - /// close paths: the start point again, then the painting operator). + /// One subpath's points, in art space ([`ai_ops::subpath`]). fn subpath(&mut self, sp: &SubPath) { - let Some(first) = sp.anchors.first() else { return }; - let xf = self.to_art; - let s = &mut self.body; - let pt = |s: &mut String, p: Point| { - let p = xf * p; - ps::push_nums(s, &[p.x, p.y]); - }; - pt(s, first.p); - s.push_str("m\n"); - let segment = |s: &mut String, a: &vectorcraft_geom::Anchor, b: &vectorcraft_geom::Anchor| { - let straight = a.h_out == a.p && b.h_in == b.p; - let smooth = b.kind == AnchorKind::Smooth; - if straight { - pt(s, b.p); - s.push_str(if smooth { "l\n" } else { "L\n" }); - } else { - pt(s, a.h_out); - pt(s, b.h_in); - pt(s, b.p); - s.push_str(if smooth { "c\n" } else { "C\n" }); - } - }; - for pair in sp.anchors.windows(2) { - if let [a, b] = pair { - segment(s, a, b); - } - } - if sp.closed - && sp.anchors.len() > 1 - && let Some(last) = sp.anchors.last() - { - segment(s, last, first); - } + ai_ops::subpath(&mut self.body, self.to_art, sp); } - /// The stroke's width, cap, join, miter limit and dashes: `w J j M d`. + /// The stroke's width, cap, join, miter limit and dashes ([`ai_ops::line_style`]). fn line_style(&mut self, st: &StrokeLayer) { - let cap = match st.cap { - LineCap::Butt => 0, - LineCap::Round => 1, - LineCap::Square => 2, - }; - let join = match st.join { - LineJoin::Miter => 0, - LineJoin::Round => 1, - LineJoin::Bevel => 2, - }; - let s = &mut self.body; - ps::push_num(s, st.width); - let _ = write!(s, " w {cap} J {join} j "); - ps::push_num(s, if st.miter_limit.is_finite() { st.miter_limit.max(1.0) } else { 10.0 }); - s.push_str(" M ["); - let dash = st.dash.as_ref().filter(|d| d.is_dashed()); - if let Some(d) = dash { - ps::push_nums(s, &d.pattern); - s.pop(); - } - s.push_str("] "); - ps::push_num(s, dash.map_or(0.0, |d| d.offset)); - s.push_str(" d\n"); + ai_ops::line_style(&mut self.body, st); } // ---------- colour ---------- diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 59926fe18..3747376e8 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -28,7 +28,12 @@ //! - `import`: the PostScript interpreter. //! - `print`: print jobs as PostScript files ([`print()`]), their pages drawn by `scene`. //! - `legacy_ai`: legacy `.ai` files of version 7 ([`export_legacy_ai`]), with their layers and groups. +//! - `ai_editing`: the editing copy of the art a PDF-compatible `.ai` file carries +//! ([`export_ai_editing`], [`ai_private_data`]). +//! - `ai_ops`: the path and line style operators those two write. +mod ai_editing; +mod ai_ops; mod import; mod latin; mod legacy_ai; @@ -37,6 +42,7 @@ mod ps; mod scene; mod tiff; +pub use ai_editing::{AiEditingOptions, AiEditingOutput, CREATOR_VERSION, export_ai_editing, private_data as ai_private_data}; pub use import::{Imported, POSTSCRIPT_ERRORS, ai_alone, family_style, import, import_trusting, import_with, is_loss, layered_ai, page}; pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai}; pub use print::{PrintJob, PrintPage, print}; diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 95a0260c6..7ca44a4fd 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -17,7 +17,8 @@ //! printer's marks around it (the media box), drawn in Registration. [`PdfSettings`] is the Save PDF //! dialog's model (standard, compatibility, General, Compression, Marks and Bleeds, Output, //! Advanced, Security); options the writer doesn't apply yet come back as warnings. With Preserve -//! Editing the native document rides along as an embedded file ([`editing()`]). Each page can +//! Editing the native document rides along as an embedded file ([`editing()`]), and the first +//! page can carry a `.ai` file's editing copy of the art ([`PrivateData`]). Each page can //! carry a thumbnail ([`Thumbnail`], drawn by the caller), and Fast Web View writes a linearised //! file (still linearised when encrypted). A PDF 1.3 file ([`PdfSettings::pdf13`]) must be flat: //! the app flattens the document first, and the writer refuses transparency that is left. @@ -63,6 +64,7 @@ mod output; mod pages; mod patch; mod pdfx; +mod piece_info; mod post; mod presets; mod print; @@ -79,6 +81,7 @@ pub use import::{ import, import_with_report, is_glyph_outlines, is_outlines_note, }; pub use pages::{PageInfo, PdfInfo, has_illustrator_data, illustrator_data, info, is_postscript}; +pub use piece_info::{PRIVATE_CHUNK, PrivateData}; pub use post::{THUMBNAIL_SIZE, Thumbnail}; pub use presets::*; pub use print::*; @@ -102,6 +105,8 @@ pub struct PdfOptions { pub modified: Option, /// The native document (`.vectorcraft` bytes) Preserve Editing embeds. pub native: Option>, + /// The editing copy of the art for the first page's `PieceInfo`, as a `.ai` file carries it. + pub private_data: Option, /// The pages drawn small, in page order: what Embed Page Thumbnails embeds (the writer /// doesn't draw; [`page_areas`] says what each page shows). pub thumbnails: Vec, diff --git a/crates/pdf/src/piece_info.rs b/crates/pdf/src/piece_info.rs new file mode 100644 index 000000000..c6e457ef3 --- /dev/null +++ b/crates/pdf/src/piece_info.rs @@ -0,0 +1,126 @@ +//! An editor's private data on the first page: a page-piece dictionary (ISO 32000-1 §14.5, +//! `PieceInfo`) holding the editing copy of the art that `.ai` files carry, laid out as in the +//! `.ai` files users sent, the form [`crate::illustrator_data`] reads. +//! +//! - The page dictionary gets `/PieceInfo << /Illustrator n 0 R >>` and its own `/LastModified` +//! (which §14.5 requires with `PieceInfo`). +//! - Object `n` is the data dictionary: `<< /LastModified (D:…) /Private m 0 R >>`. +//! - Object `m` holds `AIMetaData` (a stream of the data's header comments), `AIPrivateData1` … +//! `AIPrivateDataN` (the data in streams of [`PRIVATE_CHUNK`] bytes, the last one shorter), +//! `ContainerVersion`, `CreatorVersion`, `NumBlock` (N), `RoundtripStreamType` and +//! `RoundtripVersion`. Every stream has a `/Length` and no filter: the data comes compressed. + +use std::fmt::Write as _; + +use crate::PdfError; +use crate::linearize::{self, Parsed}; +use crate::patch::Patch; + +/// Bytes in each private data stream but the last. +pub const PRIVATE_CHUNK: usize = 1 << 16; + +/// The `PieceInfo` key the data dictionary is under. +const OWNER: &str = "Illustrator"; // brand-ok: the key the files use + +/// The private data a page-piece dictionary carries (see the module documentation). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PrivateData { + /// The data, as its streams hold it joined. + pub data: Vec, + /// What the `AIMetaData` stream holds. + pub metadata: Vec, + pub container_version: u32, + pub creator_version: u32, + pub roundtrip_version: u32, + pub roundtrip_stream_type: u32, +} + +/// `pdf` with `p` in its first page's `PieceInfo`, dated `modified` (Unix seconds, UTC). +pub(crate) fn add(pdf: Vec, p: &PrivateData, modified: i64) -> Result, PdfError> { + let failed = |why: &str| PdfError::Write(format!("can't add the editing data for other apps: {why}")); + let Parsed { xref, pages, .. } = linearize::parse(&pdf).map_err(failed)?; + let first = *pages.first().ok_or_else(|| failed("a page can't be read"))?; + let (dict, _) = xref.dict(&pdf, first).ok_or_else(|| failed("a page can't be read"))?; + let mut patch = Patch::new(&xref); + let metadata = patch.add_object(stream(&p.metadata)); + let blocks: Vec = p.data.chunks(PRIVATE_CHUNK).map(|c| patch.add_object(stream(c))).collect(); + let mut private = format!("<< /AIMetaData {metadata} 0 R"); + for (i, n) in blocks.iter().enumerate() { + let _ = write!(private, " /AIPrivateData{} {n} 0 R", i + 1); + } + let _ = write!( + private, + " /ContainerVersion {} /CreatorVersion {} /NumBlock {} /RoundtripStreamType {} /RoundtripVersion {} >>", + p.container_version, + p.creator_version, + blocks.len(), + p.roundtrip_stream_type, + p.roundtrip_version + ); + let private = patch.add_object(private.into_bytes()); + let date = pdf_date(modified); + let owner = patch.add_object(format!("<< /LastModified {date} /Private {private} 0 R >>").into_bytes()); + patch.replace(dict, dict, format!("/PieceInfo << /{OWNER} {owner} 0 R >> /LastModified {date} ").into_bytes()); + patch.apply(&pdf, &xref) +} + +/// A stream object of `bytes`, not filtered. +fn stream(bytes: &[u8]) -> Vec { + let mut s = format!("<< /Length {} >>\nstream\n", bytes.len()).into_bytes(); + s.extend_from_slice(bytes); + s.extend_from_slice(b"\nendstream"); + s +} + +/// `at` (Unix seconds) as a PDF date string in UTC (ISO 32000-1 §7.9.4). +fn pdf_date(at: i64) -> String { + let [y, mo, d, h, mi, s] = vectorcraft_doc::metadata::civil(at); + format!("(D:{y:04}{mo:02}{d:02}{h:02}{mi:02}{s:02}Z)") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{PdfOptions, export}; + use vectorcraft_doc::Document; + + fn data(len: usize) -> PrivateData { + PrivateData { + data: (0..len).map(|i| (i % 251) as u8).collect(), + metadata: b"%!PS-Adobe-3.0\n%%EndComments\n".to_vec(), + container_version: 12, + creator_version: 30, + roundtrip_version: 24, + roundtrip_stream_type: 2, + } + } + + #[test] + fn the_first_page_carries_the_data_in_streams_of_64_kib() { + let doc = Document::new(200.0, 100.0); + let p = data(PRIVATE_CHUNK * 2 + 10); + let opts = PdfOptions { private_data: Some(p.clone()), ..PdfOptions::uncompressed() }; + let pdf = export(&doc, &opts).unwrap(); + assert_eq!(crate::illustrator_data(&pdf, None).as_deref(), Some(&p.data[..])); + let text = String::from_utf8_lossy(&pdf); + for key in ["/AIPrivateData1 ", "/AIPrivateData2 ", "/AIPrivateData3 ", "/NumBlock 3 ", "/ContainerVersion 12 ", "/CreatorVersion 30 "] { + assert!(text.contains(key), "{key}"); + } + assert!(!text.contains("/AIPrivateData4")); + assert_eq!(text.matches("/LastModified (D:").count(), 2, "the page's and the data dictionary's"); + assert!(text.contains(&format!("<< /Length {PRIVATE_CHUNK} >>")) && text.contains("<< /Length 10 >>")); + } + + #[test] + fn without_the_data_the_page_has_no_piece_info() { + let pdf = export(&Document::new(200.0, 100.0), &PdfOptions::uncompressed()).unwrap(); + assert!(!String::from_utf8_lossy(&pdf).contains("/PieceInfo")); + assert_eq!(crate::illustrator_data(&pdf, None), None); + } + + #[test] + fn dates_are_in_utc() { + assert_eq!(pdf_date(0), "(D:19700101000000Z)"); + assert_eq!(pdf_date(1_793_000_000), "(D:20261026073320Z)"); + } +} diff --git a/crates/pdf/src/post.rs b/crates/pdf/src/post.rs index eda3b9a5a..e735d079f 100644 --- a/crates/pdf/src/post.rs +++ b/crates/pdf/src/post.rs @@ -1,5 +1,6 @@ //! What the export does to the file once it is written, in this order: the pages' thumbnails -//! (General › Embed page thumbnails), the seal of the editing data, fast web view (a linearised +//! (General › Embed page thumbnails), a `.ai` file's editing copy of the art +//! ([`crate::PrivateData`]), the seal of the editing data, fast web view (a linearised //! file, [`crate::linearize`]) and encryption ([`crate::encrypt`]), which a linearised file goes //! through between its two steps so that it stays linearised. Also here: what makes a file say it //! is PDF 1.3 ([`declare_pdf13`]), for PDF 1.3 and PDF/X-1a and PDF/X-3 files, the file's @@ -214,6 +215,13 @@ pub(crate) fn finish(pdf: Vec, opts: &PdfOptions, editing: bool, warnings: & } (false, _) => pdf, }; + let pdf = match &opts.private_data { + Some(p) => { + let at = opts.modified.or(opts.created).or_else(vectorcraft_doc::metadata::output_time).unwrap_or(0); + crate::piece_info::add(pdf, p, at)? + } + None => pdf, + }; let pdf = if editing { crate::editing::seal(pdf)? } else { pdf }; if !set.fast_web_view { return crate::encrypt::protect(pdf, set); diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs index b320897de..d4d4ca577 100644 --- a/crates/ui-egui/src/dialogs/save_options.rs +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -100,6 +100,7 @@ fn label(o: &FormatOption) -> String { "embedProfiles" => tl!("Embed ICC Profiles").into(), "pdfCompatible" => tl!("Create PDF-Compatible File").into(), "compress" => tl!("Use Compression").into(), + "editingData" => tl!("Include Editing Data for Other Apps").into(), _ => form::humanize(o.name), } } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1f5588aea..0f504eafd 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3402,6 +3402,7 @@ font-technology Variable Променливи Use Artboards Използване на работните области Use Black Point Compensation Използване на компенсация на черната точка Use Compression Използване на компресия + Include Editing Data for Other Apps Включване на данни за редактиране за други приложения Use document bleed settings Използване на настройките за изтичане на документа Use Even-Odd Fill Rule Използване на правило за запълване четно-нечетно Use Japanese Crop Marks Използване на японски марки за изрязване diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ef160b85a..0292db6cd 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3561,6 +3561,7 @@ theme Appearance Motiv Type Object Selection by Path Only Výběr textového objektu pouze podle cesty UI Scaling Měřítko rozhraní Use Compression Použít kompresi + Include Editing Data for Other Apps Zahrnout data pro úpravy v jiných aplikacích Use Japanese Crop Marks Použít japonské ořezové značky Use Low Resolution Proxy for Linked EPS Pro propojené EPS použít zástupce v nízkém rozlišení Use Precise Cursors Používat přesné kurzory diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 4e4ca0275..7180aa916 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3310,6 +3310,7 @@ theme Appearance Udseende Type Object Selection by Path Only Markering af tekstobjekter udelukkende via kurve UI Scaling UI-skalering Use Compression Anvend komprimering + Include Editing Data for Other Apps Medtag redigeringsdata til andre apps Use Japanese Crop Marks Anvend japanske beskæringsmærker Use Low Resolution Proxy for Linked EPS Anvend proxy med lav opløsning til koblede EPS-filer Use Precise Cursors Anvend præcise markører diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7f1c43e6c..2a2e2cb67 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3423,6 +3423,7 @@ effect Points Punkte Use Artboards Zeichenflächen verwenden Use Black Point Compensation Tiefenkompensierung verwenden Use Compression Komprimierung verwenden + Include Editing Data for Other Apps Bearbeitungsdaten für andere Apps einschließen Use document bleed settings Anschnitteinstellungen des Dokuments verwenden Use Even-Odd Fill Rule Gerade-Ungerade-Füllregel verwenden Use Japanese Crop Marks Japanische Schnittmarken verwenden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 26de8c133..0087e072c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3753,6 +3753,7 @@ theme Appearance Εμφάνιση Use Artboards Χρήση πινάκων σχεδίασης Use Black Point Compensation Χρήση αντιστάθμισης μαύρου σημείου Use Compression Χρήση συμπίεσης + Include Editing Data for Other Apps Συμπερίληψη δεδομένων επεξεργασίας για άλλες εφαρμογές Use document bleed settings Χρήση ρυθμίσεων ξακρίσματος εγγράφου Use Even-Odd Fill Rule Χρήση κανόνα γεμίσματος άρτιου-περιττού Use Japanese Crop Marks Χρήση ιαπωνικών σημαδιών περικοπής diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f3ecd85fe..36aabd434 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3470,6 +3470,7 @@ font-technology Variable Variables Use Artboards Usar mesas de trabajo Use Black Point Compensation Usar compensación de punto negro Use Compression Usar compresión + Include Editing Data for Other Apps Incluir datos de edición para otras aplicaciones Use document bleed settings Usar los ajustes de sangrado del documento Use Even-Odd Fill Rule Usar regla de relleno par-impar Use Japanese Crop Marks Usar marcas de corte japonesas diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 917393059..140610b20 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3487,6 +3487,7 @@ theme Appearance Ulkoasu Type Object Selection by Path Only Tekstiobjektin valinta vain polusta UI Scaling Käyttöliittymän skaalaus Use Compression Käytä pakkausta + Include Editing Data for Other Apps Sisällytä muokkaustiedot muille sovelluksille Use Japanese Crop Marks Käytä japanilaisia rajausmerkkejä Use Low Resolution Proxy for Linked EPS Käytä matalan tarkkuuden välityskuvaa linkitetyille EPS-tiedostoille Use Precise Cursors Käytä tarkkoja kohdistimia diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e6b8a9459..3c7600328 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3467,6 +3467,7 @@ font-technology Variable Variables Use Artboards Utiliser les plans de travail Use Black Point Compensation Utiliser la compensation du point noir Use Compression Utiliser la compression + Include Editing Data for Other Apps Inclure les données de modification pour d’autres applications Use document bleed settings Utiliser les paramètres de fond perdu du document Use Even-Odd Fill Rule Utiliser la règle de remplissage pair-impair Use Japanese Crop Marks Utiliser les traits de coupe japonais diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 84b1cc37b..ca0e1c7cb 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4180,6 +4180,7 @@ mark-style Japanese Japán Use Artboards Rajztáblák használata Use Black Point Compensation Feketepont-kompenzáció használata Use Compression Tömörítés használata + Include Editing Data for Other Apps Szerkesztési adatok hozzáadása más alkalmazásokhoz Use Default Alapértelmezett használata Use document bleed settings A dokumentum kifutóbeállításainak használata Use Even-Odd Fill Rule Páros-páratlan kitöltési szabály diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 4d813f3ba..5e84209fd 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3310,6 +3310,7 @@ theme Appearance Tampilan Type Object Selection by Path Only Pemilihan objek teks hanya berdasarkan jalur UI Scaling Penskalaan UI Use Compression Gunakan kompresi + Include Editing Data for Other Apps Sertakan data penyuntingan untuk aplikasi lain Use Japanese Crop Marks Gunakan tanda potong Jepang Use Low Resolution Proxy for Linked EPS Gunakan proksi resolusi rendah untuk EPS tertaut Use Precise Cursors Gunakan kursor presisi diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8a065733e..cad595326 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3505,6 +3505,7 @@ mark-style Japanese Giapponese Use Artboards Usa tavole da disegno Use Black Point Compensation Usa compensazione punto nero Use Compression Usa compressione + Include Editing Data for Other Apps Includi dati di modifica per altre app Use document bleed settings Usa impostazioni abbondanza del documento Use Even-Odd Fill Rule Usa regola di riempimento pari-dispari Use Japanese Crop Marks Usa segni di ritaglio giapponesi diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6e6ed4a70..f29474c6d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3153,6 +3153,7 @@ mark-style Japanese 日本式 Use Artboards アートボードを使用 Use Black Point Compensation ブラックポイントの補正を使用 Use Compression 圧縮を使用 + Include Editing Data for Other Apps 他のアプリ用の編集データを含める Use Even-Odd Fill Rule 奇偶規則を使用 Use Japanese Crop Marks 日本式トンボを使用 Use Low Resolution Proxy for Linked EPS リンク EPS に低解像度プロキシを使用 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index d85bbf2f1..70fb41012 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3118,6 +3118,7 @@ theme Appearance იერსახე Type Object Selection by Path Only ტექსტური ობიექტის მონიშვნა მხოლოდ ტრაექტორიით UI Scaling ინტერფეისის მასშტაბირება Use Compression შეკუმშვის გამოყენება + Include Editing Data for Other Apps სხვა აპებისთვის რედაქტირების მონაცემების ჩართვა Use Japanese Crop Marks იაპონური ჭრის ნიშნების გამოყენება Use Low Resolution Proxy for Linked EPS დაბალი გარჩევადობის პროქსის გამოყენება დაკავშირებული EPS-ისთვის Use Precise Cursors ზუსტი კურსორების გამოყენება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index a6bccad0b..58449d4de 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3147,6 +3147,7 @@ mark-style Japanese 일본식 Use Artboards 아트보드 사용 Use Black Point Compensation 블랙 포인트 보정 사용 Use Compression 압축 사용 + Include Editing Data for Other Apps 다른 앱용 편집 데이터 포함 Use Even-Odd Fill Rule 짝홀 칠 규칙 사용 Use Japanese Crop Marks 일본식 자르기 표시 사용 Use Low Resolution Proxy for Linked EPS 연결된 EPS에 저해상도 프록시 사용 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index d53f44c37..40051c6e1 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3324,6 +3324,7 @@ theme Appearance Vormgeving Type Object Selection by Path Only Tekstobjecten alleen via hun pad selecteren UI Scaling UI-schaling Use Compression Compressie gebruiken + Include Editing Data for Other Apps Bewerkingsgegevens voor andere apps opnemen Use Japanese Crop Marks Japanse aftekenlijnen gebruiken Use Low Resolution Proxy for Linked EPS Proxy met lage resolutie gebruiken voor gekoppelde EPS Use Precise Cursors Nauwkeurige cursors gebruiken diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e1ccecb25..78f20c0a3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3423,6 +3423,7 @@ theme Appearance Wygląd Type Object Selection by Path Only Zaznaczanie obiektów tekstowych tylko po ścieżce UI Scaling Skalowanie interfejsu Use Compression Używaj kompresji + Include Editing Data for Other Apps Dołącz dane edycji dla innych aplikacji Use Japanese Crop Marks Używaj japońskich znaczników przycięcia Use Low Resolution Proxy for Linked EPS Używaj podglądu niskiej rozdzielczości dla połączonych EPS Use Precise Cursors Używaj precyzyjnych kursorów diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index d4fc9c9a7..6f0b78095 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2835,6 +2835,7 @@ Use Artboards Usar Mesas de Trabalho Use Black Point Compensation Usar Compensação de Ponto Preto Use Compression Usar Compressão + Include Editing Data for Other Apps Incluir Dados de Edição para Outros Aplicativos Use Even-Odd Fill Rule Usar Regra de Preenchimento Par-Ímpar Use Japanese Crop Marks Usar Marcas de Corte Japonesas Use Low Resolution Proxy for Linked EPS Usar Proxy de Baixa Resolução para EPS Vinculado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 972f4a2b2..5dc2ab0ad 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3501,6 +3501,7 @@ mark-style Japanese Японский Use Artboards Использовать монтажные области Use Black Point Compensation Использовать компенсацию чёрной точки Use Compression Использовать сжатие + Include Editing Data for Other Apps Включать данные редактирования для других приложений Use document bleed settings Использовать параметры вылета документа Use Even-Odd Fill Rule Использовать правило заливки «чёт-нечет» Use Japanese Crop Marks Использовать японские метки обрезки diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index b755101a8..88cfae71a 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3402,6 +3402,7 @@ font-technology Variable Variabla Use Artboards Använd ritytor Use Black Point Compensation Använd svartpunktskompensering Use Compression Använd komprimering + Include Editing Data for Other Apps Inkludera redigeringsdata för andra appar Use document bleed settings Använd dokumentets inställningar för utfall Use Even-Odd Fill Rule Använd fyllningsregeln jämn-udda Use Japanese Crop Marks Använd japanska beskärningsmärken diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index cc84fbb5d..d541483ea 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3295,6 +3295,7 @@ font-technology Variable Değişken Use Artboards Çalışma Yüzeylerini Kullan Use Black Point Compensation Siyah Nokta Telafisi Kullan Use Compression Sıkıştırmayı Kullan + Include Editing Data for Other Apps Diğer Uygulamalar İçin Düzenleme Verilerini Ekle Use document bleed settings Belge taşma payı ayarlarını kullan Use Even-Odd Fill Rule Çift-Tek Doldurma Kuralını Kullan Use Japanese Crop Marks Japonca Kırpma İşaretlerini Kullan diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 32f3ba05c..f7d9bc25b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3498,6 +3498,7 @@ effect Points Точки Use Artboards Використовувати монтажні області Use Black Point Compensation Використовувати компенсацію чорної точки Use Compression Використовувати стиск + Include Editing Data for Other Apps Включати дані редагування для інших програм Use document bleed settings Використовувати параметри вильоту документа Use Even-Odd Fill Rule Використовувати правило заливки «парне–непарне» Use Japanese Crop Marks Використовувати японські мітки обрізання diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 01be6b1e7..48af3294e 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3870,6 +3870,7 @@ mark-style Japanese Kiểu Nhật Use Artboards Dùng bảng vẽ Use Black Point Compensation Dùng bù điểm đen Use Compression Dùng nén + Include Editing Data for Other Apps Bao gồm dữ liệu chỉnh sửa cho ứng dụng khác Use document bleed settings Dùng cài đặt bleed của tài liệu Use Even-Odd Fill Rule Dùng quy tắc tô chẵn-lẻ Use Japanese Crop Marks Dùng dấu cắt kiểu Nhật diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 599fe0e80..7491686c7 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3342,6 +3342,7 @@ font-script Japanese 汉字 Use Artboards 使用画板 Use Black Point Compensation 使用黑场补偿 Use Compression 使用压缩 + Include Editing Data for Other Apps 包含供其他应用使用的编辑数据 Use document bleed settings 使用文档的出血设置 Use Even-Odd Fill Rule 使用奇偶填色规则 Use Japanese Crop Marks 使用日式裁切标记 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8f19370ad..6197799da 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4286,6 +4286,7 @@ font-script Japanese 漢字 Use Artboards 使用工作區域 Use Black Point Compensation 使用黑點補償 Use Compression 使用壓縮 + Include Editing Data for Other Apps 包含供其他應用程式使用的編輯資料 Use document bleed settings 使用文件的出血設定 Use Even-Odd Fill Rule 使用奇偶填色規則 Use Japanese Crop Marks 使用日式裁切標記 From b8279befba85bb281597a4eba5fd37c7a0cbe80e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 17:45:42 -0700 Subject: [PATCH 02/21] Save As .ai: write the editing data's header, palette and layers in the full layout The editing data now has the layout of the .ai files it was learned from: - Lines end in CR LF. Line breaks inside strings stay escaped. - The header adds %%For (the document's author), %%Canvassize 16383, %AI7_ImageSettings, %AI5_ArtFlags, %AI5_TargetResolution, %AI5_OpenViewLayers (a digit for each layer, 7 shown and 6 hidden) and %AI7_GridSettings (the document's grid spacing and subdivisions), in the order those files have them. - The setup starts with 0 Bn (no gradient definitions). - The palette starts with the Registration swatch: Xs in a CMYK document, Xz with black's CMYK equivalent in an RGB one. Reading the file back does not add a swatch for it. - Each layer and sublayer carries its name as an XML id in an /ArtDictionary after Ln. An object named like a layer gets a numbered id. - The LastModified dates of the PieceInfo dictionaries end in +00'00'. --- crates/eps/src/ai_editing.rs | 65 +++++++++++++++++++----- crates/eps/src/ai_editing/tests.rs | 79 +++++++++++++++++++++++++---- crates/eps/src/ai_editing/writer.rs | 34 +++++++++++-- crates/pdf/src/piece_info.rs | 9 ++-- 4 files changed, 153 insertions(+), 34 deletions(-) diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 0ad1b932a..272e92755 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -8,15 +8,20 @@ //! groups and compound paths (§5.3.5), guides (§6) and layers (§9), with the newer forms the reader //! reads from files users sent: //! -//! - Header comments: `%%BoundingBox`, `%%HiResBoundingBox`, `%AI5_FileFormat 14.0`, the first -//! artboard as `%AI3_Cropmarks` and `%AI5_ArtSize`, `%AI5_RulerUnits`, `%AI9_ColorModel` (1 RGB, 2 -//! CMYK), `%AI5_NumLayers`, and the version comment `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]). -//! - Setup: the color swatches (`%AI5_BeginPalette`, `0 0 Pb`, each swatch's color operator and -//! `(name) Pc`, a color group as `0 (name) count Pg` before its swatches, `PB`), then the -//! artboards as the `ArtboardArray` of the `/Document` dictionary, on `%_` lines. +//! - Header comments: `%%For` (the document's author), `%%Canvassize`, `%%BoundingBox`, +//! `%%HiResBoundingBox`, `%AI5_FileFormat 14.0`, `%AI7_ImageSettings`, the first artboard as +//! `%AI3_Cropmarks` and `%AI5_ArtSize`, `%AI5_RulerUnits`, `%AI9_ColorModel` (1 RGB, 2 CMYK), +//! `%AI5_ArtFlags`, `%AI5_TargetResolution`, `%AI5_NumLayers`, `%AI5_OpenViewLayers` (a digit for +//! each layer, 7 shown and 6 hidden), `%AI7_GridSettings` (§2.1.1), and the version comment +//! `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]). +//! - Setup: `0 Bn` (no gradient definitions, §3), the color swatches (`%AI5_BeginPalette`, +//! `0 0 Pb`, the Registration swatch, each swatch's color operator and `(name) Pc`, a color group as `0 (name) count Pg` +//! before its swatches, `PB`), then the artboards as the `ArtboardArray` of the `/Document` +//! dictionary, on `%_` lines. //! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed 0 1 colorIndex r g b 0 //! dimPercent 0 Lb` (14 operands; a color that isn't one of the preset layer colors has the -//! index -1), `(name) Ln`, its art and sublayers, `LB`, `%AI5_EndLayer--`. +//! index -1), `(name) Ln`, the layer's name as an XML id in an `/ArtDictionary`, its art and +//! sublayers, `LB`, `%AI5_EndLayer--`. //! - Colors: `g` gray, `k` CMYK, `c m y k r g b Xa` RGB (Lab is written as RGB); a spot color //! `c m y k (name) tint x` (CMYK) or `c m y k r g b|L a b (name) tint 1|2 Xx` (RGB or Lab); a //! global process color `… (name) tint model Xk`; Registration `1 1 1 1 ([Registration]) tint @@ -25,7 +30,7 @@ //! an `/ArtDictionary` whose `AI10_ArtUID` is the name as an XML id. Guides are `(N) *`, the //! path, `(N) *` (`(n) *` when closed). //! -//! Strings are written in UTF-8. Art space has y up, its origin at the first artboard's bottom-left +//! Lines end in CR LF. Strings are written in UTF-8. Art space has y up, its origin at the first artboard's bottom-left //! corner. What the form above can't hold is written as plain filled and stroked paths, each such //! loss a warning: type as its outlines, gradients as their average color, pattern fills as their //! tiles cut to the shape, clipping groups and clipping layers as their art cut to the clipping @@ -60,6 +65,16 @@ const ROUNDTRIP_VERSION: u32 = 24; const ROUNDTRIP_STREAM_TYPE: u32 = 2; /// What starts the data streams of Zstandard-compressed data. const ZSTD_MARKER: &[u8] = b"%AI24_ZStandard_Data"; +/// `%%Canvassize`: the side of the canvas in points, as the files users sent have it. +const CANVAS_SIZE: u32 = 16383; +/// `%AI5_ArtFlags` (§2.1.1, Table 4), as the files users sent have them: page setup, print tiles, +/// placed images, preview patterns, split long paths, tile full pages, printer's default +/// screens, auto default screens, compatible gradient printing. +const ART_FLAGS: &str = "0 0 0 1 0 0 1 0 0"; +/// `%AI5_TargetResolution` (§2.1.1). +const TARGET_RESOLUTION: u32 = 800; +/// The gridlines' gray: VectorCraft's default grid color (200 of 255). +const GRID_GRAY: f64 = 200.0 / 255.0; /// The first line of a PostScript file (the DSC version comment). const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header comment @@ -98,7 +113,6 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result Result Vec { + s.replace('\n', "\r\n").into_bytes() } /// `doc` with its geometry effects baked ([`vectorcraft_effects::bake_document`]), in its symbols' @@ -163,17 +184,20 @@ pub fn private_data(doc: &Document, o: &AiEditingOptions) -> Result<(PrivateData /// The header comments, through `%%EndComments`. `art`: the visible art's bounds, `size` the first /// artboard's, in art space. -fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, (width, height): (f64, f64), layers: usize) -> String { +fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, (width, height): (f64, f64)) -> String { let mut s = String::new(); s.push_str(PS_HEADER); s.push('\n'); let _ = writeln!(s, "%%Creator: VectorCraft {}", env!("CARGO_PKG_VERSION")); let _ = writeln!(s, "%%AI8_CreatorVersion: {CREATOR_VERSION}"); + // The user and the organization (§2.1.1). + let _ = writeln!(s, "%%For: {} ()", ps::bytes(doc.metadata.author.as_bytes())); let _ = writeln!(s, "%%Title: {}", ps::bytes(o.title.as_bytes())); if let Some(t) = o.created { let [y, mo, d, h, mi, _] = vectorcraft_doc::metadata::civil(t); let _ = writeln!(s, "%%CreationDate: ({mo}/{d}/{y:04}) ({h:02}:{mi:02})"); } + let _ = writeln!(s, "%%Canvassize: {CANVAS_SIZE}"); let _ = writeln!(s, "%%BoundingBox: {} {} {} {}", art.x0.floor() as i64, art.y0.floor() as i64, art.x1.ceil() as i64, art.y1.ceil() as i64); s.push_str("%%HiResBoundingBox: "); ps::push_nums(&mut s, &[art.x0, art.y0, art.x1, art.y1]); @@ -181,6 +205,8 @@ fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, (width, h s.push('\n'); s.push_str("%%DocumentProcessColors: Cyan Magenta Yellow Black\n"); s.push_str("%AI5_FileFormat 14.0\n%AI3_ColorUsage: Color\n"); + // Linked images aren't embedded (the data holds no images). + s.push_str("%AI7_ImageSettings: 0\n"); let spots = w.spot_swatches(); for (i, (name, _)) in spots.iter().enumerate() { let _ = writeln!(s, "{} {}", if i == 0 { "%%DocumentCustomColors:" } else { "%%+" }, ps::bytes(name.as_bytes())); @@ -201,7 +227,18 @@ fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, (width, h s.push_str("%AI24_LargeCanvasScale: 1\n"); let model = if doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { 2 } else { 1 }; let _ = writeln!(s, "%AI9_ColorModel: {model}"); - let _ = writeln!(s, "%AI5_NumLayers: {layers}"); + let _ = writeln!(s, "%AI5_ArtFlags: {ART_FLAGS}"); + let _ = writeln!(s, "%AI5_TargetResolution: {TARGET_RESOLUTION}"); + let _ = writeln!(s, "%AI5_NumLayers: {}", doc.layers.len()); + // A digit for each layer: 7 shown, 6 hidden. + let views: String = doc.layers.iter().map(|l| if l.visible { '7' } else { '6' }).collect(); + if !views.is_empty() { + let _ = writeln!(s, "%AI5_OpenViewLayers: {views}"); + } + // Gridlines and subdivisions (§2.1.1, Table 6): in back, lines, in VectorCraft's grid gray. + let (space, subdiv) = (ps::num(doc.grid.spacing), doc.grid.subdivisions); + let gray = ps::num(GRID_GRAY); + let _ = writeln!(s, "%AI7_GridSettings: {space} {subdiv} {space} {subdiv} 1 0 {gray} {gray} {gray} {gray} {gray} {gray}"); s.push_str("%%EndComments\n"); s } diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 0490ae391..e0d0b2d68 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -16,9 +16,9 @@ fn opts() -> AiEditingOptions { AiEditingOptions { title: "Sample".into(), created: Some(0) } } -/// The program written from `doc`, as text. +/// The program written from `doc`, as text with its CR LF line ends read as LF. fn program(doc: &Document) -> String { - String::from_utf8(export_ai_editing(doc, &opts()).unwrap().program).unwrap() + String::from_utf8(export_ai_editing(doc, &opts()).unwrap().program).unwrap().replace("\r\n", "\n") } /// `doc` written, then read back from the private data alone. @@ -266,7 +266,8 @@ fn writing_again_gives_the_same_bytes() { assert_eq!(first.program, export_ai_editing(&doc, &opts()).unwrap().program); assert_eq!(private_data(&doc, &opts()).unwrap().0, private_data(&doc, &opts()).unwrap().0); // What the reader reads back is written as the same program. - assert_eq!(program(&read_back(&doc)), String::from_utf8(first.program).unwrap()); + let read = export_ai_editing(&read_back(&doc), &opts()).unwrap(); + assert_eq!(read.program, first.program); assert!(first.warnings.is_empty(), "{:?}", first.warnings); } @@ -283,17 +284,18 @@ fn the_data_is_zstandard_compressed_with_the_header_as_metadata() { assert!(out.program.starts_with(&out.header)); assert_eq!(p.metadata, out.header); let header = String::from_utf8(out.header).unwrap(); - assert!(header.ends_with("%%EndComments\n")); + assert!(header.ends_with("%%EndComments\r\n")); + let header = header.replace("\r\n", "\n"); for line in [ - "%%AI8_CreatorVersion: 30.7.0\n", - "%AI5_FileFormat 14.0\n", + "%%AI8_CreatorVersion: 30.7.0\n%%For: () ()\n%%Title: (Sample)\n", + "%%Canvassize: 16383\n", + "%AI5_FileFormat 14.0\n%AI3_ColorUsage: Color\n%AI7_ImageSettings: 0\n%%DocumentCustomColors: (Gold)\n%%+ (Sky)\n%%+ (Mint)\n", "%AI3_Cropmarks: 0 0 200 100\n", "%AI5_ArtSize: 200 100\n", - "%AI9_ColorModel: 1\n", - "%AI5_NumLayers: 2\n", - "%%DocumentCustomColors: (Gold)\n%%+ (Sky)\n%%+ (Mint)\n", + "%AI9_ColorModel: 1\n%AI5_ArtFlags: 0 0 0 1 0 0 1 0 0\n%AI5_TargetResolution: 800\n%AI5_NumLayers: 2\n", + "%AI5_OpenViewLayers: 77\n%AI7_GridSettings: 72 8 72 8 1 0 0.7843 0.7843 0.7843 0.7843 0.7843 0.7843\n%%EndComments\n", ] { - assert!(header.contains(line), "{line}"); + assert!(header.contains(line), "{line}\n{header}"); } assert_eq!((p.container_version, p.creator_version, p.roundtrip_version, p.roundtrip_stream_type), (12, 30, 24, 2)); } @@ -328,7 +330,7 @@ fn many_objects_of_one_name_are_numbered_in_turn() { doc.insert(Some(layer), usize::MAX, n).unwrap(); } let text = program(&doc); - let ids: std::collections::HashSet<&str> = text.lines().filter(|l| l.starts_with("%_/XMLUID")).collect(); + let ids: std::collections::HashSet<&str> = text.lines().filter(|l| l.starts_with("%_/XMLUID : (Dot")).collect(); assert_eq!(ids.len(), 20_000); assert!(text.contains("(Dot_00000000000000019999_)")); } @@ -340,6 +342,61 @@ fn layers_have_fourteen_operands() { assert!(text.contains("%AI5_BeginLayer\n0 1 0 0 1 0 1 -1 10 200 30 0 70 0 Lb\n(Sub) Ln\n"), "{text}"); } +#[test] +fn layers_are_named_as_xml_ids_after_their_names() { + let mut doc = sample(); + let layer = doc.layers[0].id; + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default()); + n.name = Some("Back".into()); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let text = program(&doc); + assert!(text.contains("(Back) Ln\n%_/ArtDictionary :\n%_/XMLUID : (Back) ; (AI10_ArtUID) ,\n%_;\n%_\n"), "{text}"); + assert!(text.contains("(Sub) Ln\n%_/ArtDictionary :\n%_/XMLUID : (Sub) ; (AI10_ArtUID) ,\n"), "{text}"); + // An object named as a layer is numbered. + assert!(text.contains("%_/XMLUID : (Back_00000000000000000001_) ; (AI10_ArtUID) ,"), "{text}"); + // The reader keeps the names where they were. + let read = read_back(&doc); + assert_eq!(read.layers[0].name.as_deref(), Some("Back")); + let names: Vec> = read.layers[0].children().unwrap().iter().map(|c| c.name.as_deref()).collect(); + assert_eq!(names.last(), Some(&Some("Back"))); +} + +#[test] +fn lines_end_in_cr_lf() { + let mut doc = sample(); + doc.metadata.author = "A\nB".into(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let text = String::from_utf8(out.program).unwrap(); + assert_eq!(text.matches('\n').count(), text.matches("\r\n").count()); + assert_eq!(text.matches('\r').count(), text.matches("\r\n").count()); + // A line break in a string stays in the string, escaped. + assert!(text.contains("%%For: (A\\nB) ()\r\n"), "{text}"); +} + +#[test] +fn the_palette_starts_with_registration() { + let mut doc = sample(); + let rgb = program(&doc); + assert!(rgb.contains("0 0 Pb\n"), "{rgb}"); + let cell = rgb.split("0 0 Pb\n").nth(1).unwrap().lines().take(2).collect::>(); + assert!(cell[0].ends_with(" 0 0 0 ([Registration]) 0 1 Xz") && cell[0].split(' ').count() == 11, "{cell:?}"); + assert_eq!(cell[1], "([Registration]) Pc"); + doc.color_mode = vectorcraft_doc::ColorMode::Cmyk; + assert!(program(&doc).contains("0 0 Pb\n1 1 1 1 ([Registration]) 0 Xs\n([Registration]) Pc\n")); + // It's the built-in swatch: reading back adds no swatch. + assert_eq!(swatches(&read_back(&doc)), swatches(&doc)); +} + +#[test] +fn hidden_layers_open_hidden() { + let mut doc = sample(); + let mut hidden = Node::layer(doc.alloc_id(), "Off", LayerColor::Preset(1)); + hidden.visible = false; + doc.layers.push(Arc::new(hidden)); + let text = program(&doc); + assert!(text.contains("%AI5_NumLayers: 3\n%AI5_OpenViewLayers: 776\n"), "{text}"); +} + /// A 200 × 100 document of what slice 1 writes as plain paths: a gradient fill, a clipping group, /// a compound path, an image, type, a symbol instance and a half-transparent square. fn unsupported() -> Document { diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index ff0d6ed77..39a19b4c1 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -113,7 +113,7 @@ impl<'a> Writer<'a> { } } - /// A layer or sublayer: `%AI5_BeginLayer`, its options `Lb`, its name `Ln`, its art, then + /// A layer or sublayer: `%AI5_BeginLayer`, its options `Lb`, its name `Ln` and as an XML id, its art, then /// `after` (outside its clipping path when it clips), `LB`, `%AI5_EndLayer--`. fn write_layer(&mut self, n: &Node, after: &[Arc]) { let NodeKind::Layer { color, template, printable, children, clip, preview, dim_images } = &n.kind else { return }; @@ -131,6 +131,7 @@ impl<'a> Writer<'a> { self.transparency(n); let flag = |b: bool| u8::from(b); let dim = dim_images.unwrap_or(50); + let name = n.name.as_deref().unwrap_or("Layer"); let _ = writeln!( self.body, "%AI5_BeginLayer\n{} {} {} {} {} 0 1 {index} {r} {g} {b} 0 {dim} 0 Lb\n{} Ln", @@ -139,8 +140,9 @@ impl<'a> Writer<'a> { flag(!n.locked), flag(*printable && !*template), flag(dim_images.is_some() || *template), - ps::bytes(n.name.as_deref().unwrap_or("Layer").as_bytes()), + ps::bytes(name.as_bytes()), ); + self.xml_id(if name.is_empty() { "Layer" } else { name }); self.state = None; if *clip { self.warn(CLIPPING); @@ -269,10 +271,15 @@ impl<'a> Writer<'a> { } } - /// `n`'s name, after it: an `/ArtDictionary` whose `AI10_ArtUID` is the name as an XML id, - /// numbered (`_` and 20 digits, `_`) when another object has that id. + /// `n`'s name, after it (see [`Self::xml_id`]). fn name(&mut self, n: &Node) { let Some(name) = n.name.as_deref().filter(|s| !s.is_empty()) else { return }; + self.xml_id(name); + } + + /// An `/ArtDictionary` whose `AI10_ArtUID` is `name` as an XML id, numbered (`_` and 20 + /// digits, `_`) when a layer or another object has that id. + fn xml_id(&mut self, name: &str) { let base = vectorcraft_doc::xml_id::from_object_name(name); let mut id = base.clone(); let next = self.next.entry(base.clone()).or_insert(0); @@ -604,11 +611,12 @@ impl<'a> Writer<'a> { // ---------- swatches ---------- - /// The swatches: `%AI5_BeginPalette`, `0 0 Pb`, each color swatch's color and `(name) Pc` + /// The swatches: `%AI5_BeginPalette`, `0 0 Pb`, the Registration swatch, each color swatch's color and `(name) Pc` /// (the ungrouped ones first, then each color group's after `0 (name) count Pg`), `PB`, /// `%AI5_EndPalette`. pub(super) fn palette(&mut self, s: &mut String) { s.push_str("%AI5_BeginPalette\n0 0 Pb\n"); + self.registration_cell(s); let doc = self.doc; for sw in doc.swatches.iter() { self.swatch(s, sw); @@ -623,6 +631,22 @@ impl<'a> Writer<'a> { s.push_str("PB\n%AI5_EndPalette\n"); } + /// The Registration swatch, which every palette in the files users sent starts with: in a + /// CMYK document `1 1 1 1 ([Registration]) 0 Xs`, in an RGB one black's CMYK equivalent, + /// `0 0 0 ([Registration]) 0 1 Xz`; then `([Registration]) Pc`. + fn registration_cell(&self, s: &mut String) { + let name = ps::bytes(REGISTRATION.as_bytes()); + if self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { + let _ = writeln!(s, "1 1 1 1 {name} 0 Xs"); + } else { + let black = Color::rgb(0.0, 0.0, 0.0); + let cmyk = self.cms.to_cmyk(&black, self.cms.settings().intent).map(unit); + ps::push_nums(s, &cmyk.map(f64::from)); + let _ = writeln!(s, "0 0 0 {name} 0 1 Xz"); + } + let _ = writeln!(s, "{name} Pc"); + } + /// One swatch of the palette: a color swatch's color and `(name) Pc`; gradient and pattern /// swatches are left out. fn swatch(&mut self, s: &mut String, sw: &Swatch) { diff --git a/crates/pdf/src/piece_info.rs b/crates/pdf/src/piece_info.rs index c6e457ef3..157f36c5c 100644 --- a/crates/pdf/src/piece_info.rs +++ b/crates/pdf/src/piece_info.rs @@ -72,10 +72,11 @@ fn stream(bytes: &[u8]) -> Vec { s } -/// `at` (Unix seconds) as a PDF date string in UTC (ISO 32000-1 §7.9.4). +/// `at` (Unix seconds) as a PDF date string in UTC (ISO 32000-1 §7.9.4), with the offset written +/// as hours and minutes, as in the files users sent. fn pdf_date(at: i64) -> String { let [y, mo, d, h, mi, s] = vectorcraft_doc::metadata::civil(at); - format!("(D:{y:04}{mo:02}{d:02}{h:02}{mi:02}{s:02}Z)") + format!("(D:{y:04}{mo:02}{d:02}{h:02}{mi:02}{s:02}+00'00')") } #[cfg(test)] @@ -120,7 +121,7 @@ mod tests { #[test] fn dates_are_in_utc() { - assert_eq!(pdf_date(0), "(D:19700101000000Z)"); - assert_eq!(pdf_date(1_793_000_000), "(D:20261026073320Z)"); + assert_eq!(pdf_date(0), "(D:19700101000000+00'00')"); + assert_eq!(pdf_date(1_793_000_000), "(D:20261026073320+00'00')"); } } From a5be18b693aeb466d871476803a3b44ff6a9cb62 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 17:57:36 -0700 Subject: [PATCH 03/21] Save As .ai: name dots and corner-fitted dashes in the editing data's stroke warning Dotted strokes (zero-length dashes with round or projecting caps) and dashes fitted to corners are written as filled outlines in the editing data for other apps. The warning that lists the strokes written that way now names them. --- crates/eps/src/ai_editing/tests.rs | 20 ++++++++++++++++++++ crates/eps/src/ai_editing/writer.rs | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index e0d0b2d68..402e34f4d 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -387,6 +387,26 @@ fn the_palette_starts_with_registration() { assert_eq!(swatches(&read_back(&doc)), swatches(&doc)); } +#[test] +fn dotted_strokes_are_written_as_their_dots_with_a_warning() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut stroke = StrokeLayer::new(solid(Color::rgb(0.0, 0.0, 0.0)), 4.0); + stroke.cap = LineCap::Round; + stroke.dash = Some(Dash { pattern: vec![0.0, 10.0], offset: 0.0, align_corners: false }); + let line = PathData::from_bezpath(&kurbo::BezPath::from_vec(vec![ + kurbo::PathEl::MoveTo((10.0, 50.0).into()), + kurbo::PathEl::LineTo((60.0, 50.0).into()), + ])); + let n = Node::path(doc.alloc_id(), line, Appearance { items: vec![AppearanceItem::Stroke(stroke)], ..Appearance::default() }); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w.contains("dots")), "{:?}", out.warnings); + let text = program(&doc); + assert!(!text.contains("[0 10] 0 d"), "{text}"); + assert!(text.matches("\nf\n").count() >= 5, "a filled dot for each dash: {text}"); +} + #[test] fn hidden_layers_open_hidden() { let mut doc = sample(); diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 39a19b4c1..24c0d57b8 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -32,7 +32,7 @@ pub(super) const CLIPPING: &str = pub(super) const IMAGES: &str = "images are written as their outlines filled with their average color in the editing data for other apps"; pub(super) const LIVE: &str = "symbols, blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps"; -const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps"; +const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps"; pub(super) const TRANSPARENCY: &str = "transparency (opacity, blending modes, opacity masks) is left out of the editing data for other apps"; pub(super) const EFFECTS: &str = "live effects other than geometry effects are left out of the editing data for other apps"; const SWATCHES: &str = "gradient and pattern swatches are left out of the editing data for other apps"; From 9cd5e3df8f775784f9344dbcefd20e8caa1a47e9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 21:35:06 -0700 Subject: [PATCH 04/21] Save As .ai: place the editing data's art on its artboard and carry it on every page The editing data wrote art space with its origin at the first artboard's bottom-left corner, its artboard records lacked keys a reader requires, and its %%Creator line made the reader measure %AI3_TemplateBox on an 8640-point canvas. Other apps opened the art 3,871.5 points away from its artboard. Art space is now ruler space, as in the .ai files users sent: y up from the first artboard's top-left corner. The artboards together are centered on the 16383-point canvas. Each artboard's record gives its top-left corner there (RulerOrigin), its pixel aspect ratio (PAR), DisplayMark and whether it is selected, and %AI3_TemplateBox holds the canvas's center. %%Creator names the format's app and holds no version number. %AI3_Cropmarks and %AI3_TileBox give the first artboard, %AI5_ArtSize is 14400 14400, and the /Document dictionary gives the canvas size. A file with several artboards carried the data on its first page only, and other apps opened it as a PDF, page by page. Every page now refers to the one data dictionary, as the files users sent do. The format details come from users' own .ai files, static analysis of Illustrator's program and the user's manual testing in Illustrator. --- crates/engine/src/cmd/fileio/pdf.rs | 2 +- crates/eps/src/ai_editing.rs | 99 +++++++++++++++++++++-------- crates/eps/src/ai_editing/tests.rs | 46 +++++++++++++- crates/pdf/src/lib.rs | 2 +- crates/pdf/src/piece_info.rs | 45 +++++++++++-- 5 files changed, 156 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 641aa0845..14b692e73 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -282,7 +282,7 @@ fn overflow_warning(cmd: &str, doc: &Document, p: &Value) -> Result Result Result Option { Some(out) } -/// The editing data of `doc` as a `.ai` file's first page carries it: the program compressed with +/// The editing data of `doc` as a `.ai` file's pages carry it: the program compressed with /// Zstandard after `%AI24_ZStandard_Data`, its header as the `AIMetaData`, and the version numbers /// that go with that form; and what it couldn't hold. pub fn private_data(doc: &Document, o: &AiEditingOptions) -> Result<(PrivateData, Vec), String> { @@ -182,13 +197,28 @@ pub fn private_data(doc: &Document, o: &AiEditingOptions) -> Result<(PrivateData Ok((p, out.warnings)) } -/// The header comments, through `%%EndComments`. `art`: the visible art's bounds, `size` the first -/// artboard's, in art space. -fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, (width, height): (f64, f64)) -> String { +/// Where art space's origin lies on the canvas (y down, [`CANVAS_SIZE`] points square): the +/// artboards together centered on the canvas's center, on whole points, as the files users sent +/// place them. +fn ruler_origin(doc: &Document, to_art: Affine) -> (f64, f64) { + let all = doc.artboards.iter().map(|ab| to_art.transform_rect_bbox(ab.rect)).filter(|r| r.is_finite()).reduce(|a, b| a.union(b)); + let c = all.map_or((0.0, 0.0), |r| (r.center().x, r.center().y)); + let mid = canvas_center(); + ((mid - c.0).floor(), (mid + c.1).floor()) +} + +/// The canvas's center, on its own axes and in both directions. +fn canvas_center() -> f64 { + f64::from(CANVAS_SIZE) / 2.0 +} + +/// The header comments, through `%%EndComments`. `art`: the visible art's bounds, `first` the first +/// artboard, in art space; `origin` art space's origin on the canvas ([`ruler_origin`]). +fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, first: Rect, origin: (f64, f64)) -> String { let mut s = String::new(); s.push_str(PS_HEADER); s.push('\n'); - let _ = writeln!(s, "%%Creator: VectorCraft {}", env!("CARGO_PKG_VERSION")); + let _ = writeln!(s, "%%Creator: {CREATOR_TEXT}"); let _ = writeln!(s, "%%AI8_CreatorVersion: {CREATOR_VERSION}"); // The user and the organization (§2.1.1). let _ = writeln!(s, "%%For: {} ()", ps::bytes(doc.metadata.author.as_bytes())); @@ -216,13 +246,17 @@ fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, (width, h ps::push_nums(&mut s, &cmyk.map(f64::from)); let _ = writeln!(s, "{}", ps::bytes(name.as_bytes())); } - let (w2, h2) = (ps::num(width), ps::num(height)); - let _ = writeln!(s, "%AI3_Cropmarks: 0 0 {w2} {h2}"); - let (cx, cy) = (ps::num(width / 2.0), ps::num(height / 2.0)); + // The first artboard, left, bottom, right, top. + let mut board = String::new(); + ps::push_nums(&mut board, &[first.x0, first.y0, first.x1, first.y1]); + board.pop(); + let _ = writeln!(s, "%AI3_Cropmarks: {board}"); + // The canvas's center in art space. + let (cx, cy) = (ps::num(canvas_center() - origin.0), ps::num(origin.1 - canvas_center())); let _ = writeln!(s, "%AI3_TemplateBox: {cx} {cy} {cx} {cy}"); - let _ = writeln!(s, "%AI3_TileBox: 0 0 {w2} {h2}"); + let _ = writeln!(s, "%AI3_TileBox: {board}"); s.push_str("%AI3_DocumentPreview: None\n"); - let _ = writeln!(s, "%AI5_ArtSize: {w2} {h2}"); + let _ = writeln!(s, "%AI5_ArtSize: {ART_SIZE} {ART_SIZE}"); let _ = writeln!(s, "%AI5_RulerUnits: {}", ruler_units(doc.units)); s.push_str("%AI24_LargeCanvasScale: 1\n"); let model = if doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { 2 } else { 1 }; @@ -256,19 +290,30 @@ fn ruler_units(u: Unit) -> u8 { } /// The artboards as the `ArtboardArray` of the `/Document` dictionary: each one's top-left and -/// bottom-right corners in art space (`PositionPoint1`, `PositionPoint2`) and its name. -fn artboards(s: &mut String, doc: &Document, to_art: Affine) { +/// bottom-right corners in art space (`PositionPoint1`, `PositionPoint2`), its top-left corner on +/// the canvas (`RulerOrigin`, `origin` being art space's origin there), its pixel aspect ratio +/// (`PAR`), `DisplayMark` 0 (as in the files users sent), whether it is the selected one (the +/// first) and its name; then the canvas's size. Apps that read the editing data drop a record that +/// lacks any of the first five. +fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64)) { s.push_str("%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n%_/Array :\n"); - for ab in &doc.artboards { + for (i, ab) in doc.artboards.iter().enumerate() { let r = to_art.transform_rect_bbox(ab.rect); s.push_str("%_/Dictionary :\n%_"); ps::push_nums(s, &[r.x0, r.y1]); s.push_str("/RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_"); ps::push_nums(s, &[r.x1, r.y0]); - s.push_str("/RealPointRelToROrigin\n%_ (PositionPoint2) ,\n"); + s.push_str("/RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_"); + ps::push_nums(s, &[origin.0 + r.x0, origin.1 - r.y1]); + s.push_str("/RealPoint\n%_ (RulerOrigin) ,\n%_"); + let par = ab.video_ruler_pixel_aspect_ratio; + ps::push_num(s, if par.is_finite() && par > 0.0 { par } else { 1.0 }); + let _ = writeln!(s, " /Real (PAR) ,\n%_0 /Int (DisplayMark) ,\n%_{} /Bool (IsArtboardSelected) ,", u8::from(i == 0)); let _ = writeln!(s, "%_{} /UnicodeString (Name) ,\n%_; ,", ps::bytes(ab.name.as_bytes())); } - s.push_str("%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n"); + s.push_str("%_; (ArtboardArray) ,\n"); + let _ = writeln!(s, "%_{CANVAS_SIZE} /Int (AIDocumentCanvasSize) ,"); + s.push_str("%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n"); } #[cfg(test)] diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 402e34f4d..e7f9c9d39 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -290,8 +290,8 @@ fn the_data_is_zstandard_compressed_with_the_header_as_metadata() { "%%AI8_CreatorVersion: 30.7.0\n%%For: () ()\n%%Title: (Sample)\n", "%%Canvassize: 16383\n", "%AI5_FileFormat 14.0\n%AI3_ColorUsage: Color\n%AI7_ImageSettings: 0\n%%DocumentCustomColors: (Gold)\n%%+ (Sky)\n%%+ (Mint)\n", - "%AI3_Cropmarks: 0 0 200 100\n", - "%AI5_ArtSize: 200 100\n", + "%AI3_Cropmarks: 0 -100 200 0\n%AI3_TemplateBox: 225.5 -50.5 225.5 -50.5\n%AI3_TileBox: 0 -100 200 0\n", + "%AI5_ArtSize: 14400 14400\n", "%AI9_ColorModel: 1\n%AI5_ArtFlags: 0 0 0 1 0 0 1 0 0\n%AI5_TargetResolution: 800\n%AI5_NumLayers: 2\n", "%AI5_OpenViewLayers: 77\n%AI7_GridSettings: 72 8 72 8 1 0 0.7843 0.7843 0.7843 0.7843 0.7843 0.7843\n%%EndComments\n", ] { @@ -300,6 +300,48 @@ fn the_data_is_zstandard_compressed_with_the_header_as_metadata() { assert_eq!((p.container_version, p.creator_version, p.roundtrip_version, p.roundtrip_stream_type), (12, 30, 24, 2)); } +/// Art space is ruler space: y up from the first artboard's top-left corner. The artboards +/// together sit centered on the canvas: each one's top-left corner on the canvas (y down) is its +/// `RulerOrigin`, and the template box holds the canvas's center in art space. +#[test] +fn art_lies_on_its_artboard_in_ruler_space() { + let mut doc = Document::new(200.0, 100.0); + let mut second = doc.artboards[0].clone(); + (second.id, second.name, second.rect) = (2, "Back".into(), Rect::new(250.0, 0.0, 450.0, 100.0)); + doc.artboards.push(second); + let rect = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(10.0, 20.0, 30.0, 40.0)), + Appearance::basic(solid(Color::gray(0.5)), Paint::None, 0.0), + ); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, rect).unwrap(); + let text = program(&doc); + for line in [ + "%%BoundingBox: 10 -40 30 -20\n", + "%AI3_Cropmarks: 0 -100 200 0\n", + "%AI3_TemplateBox: 225.5 -50.5 225.5 -50.5\n", + "\n10 -20 m\n", + "%_0 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_200 -100 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_7966 8141 /RealPoint\n%_ (RulerOrigin) ,\n%_1 /Real (PAR) ,\n%_0 /Int (DisplayMark) ,\n%_1 /Bool (IsArtboardSelected) ,\n", + "%_250 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_450 -100 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_8216 8141 /RealPoint\n%_ (RulerOrigin) ,\n%_1 /Real (PAR) ,\n%_0 /Int (DisplayMark) ,\n%_0 /Bool (IsArtboardSelected) ,\n", + "%_16383 /Int (AIDocumentCanvasSize) ,\n", + ] { + assert!(text.contains(line), "{line}\n{text}"); + } + // The creator names the format's app, before the canvas size and the template box, and holds + // no version number another app could read as the format's version. + let creator = text.lines().find_map(|l| l.strip_prefix("%%Creator: ")).unwrap(); + assert!(creator.contains("Illustrator"), "{creator}"); + assert!(!creator.contains("88") && !creator.chars().any(|c| c.is_ascii_digit()), "{creator}"); + assert!(text.find("%%Creator: ").unwrap() < text.find("%%Canvassize: 16383").unwrap()); + assert!(text.find("%%Canvassize: 16383").unwrap() < text.find("%AI3_TemplateBox").unwrap()); + // The reader puts the art back where it was, on its artboard. + let read = read_back(&doc); + assert_eq!(read.artboards.iter().map(|a| a.rect).collect::>(), doc.artboards.iter().map(|a| a.rect).collect::>()); + let bounds = read.layers.iter().filter_map(|l| l.visual_bounds()).reduce(|a, b| a.union(b)).unwrap(); + assert_eq!(bounds, Rect::new(10.0, 20.0, 30.0, 40.0)); +} + #[test] fn a_pdf_carries_the_data_on_its_first_page() { let doc = sample(); diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 7ca44a4fd..c12459fbe 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -105,7 +105,7 @@ pub struct PdfOptions { pub modified: Option, /// The native document (`.vectorcraft` bytes) Preserve Editing embeds. pub native: Option>, - /// The editing copy of the art for the first page's `PieceInfo`, as a `.ai` file carries it. + /// The editing copy of the art for every page's `PieceInfo`, as a `.ai` file carries it. pub private_data: Option, /// The pages drawn small, in page order: what Embed Page Thumbnails embeds (the writer /// doesn't draw; [`page_areas`] says what each page shows). diff --git a/crates/pdf/src/piece_info.rs b/crates/pdf/src/piece_info.rs index 157f36c5c..60cd80228 100644 --- a/crates/pdf/src/piece_info.rs +++ b/crates/pdf/src/piece_info.rs @@ -1,9 +1,11 @@ -//! An editor's private data on the first page: a page-piece dictionary (ISO 32000-1 §14.5, +//! An editor's private data on every page: a page-piece dictionary (ISO 32000-1 §14.5, //! `PieceInfo`) holding the editing copy of the art that `.ai` files carry, laid out as in the //! `.ai` files users sent, the form [`crate::illustrator_data`] reads. //! -//! - The page dictionary gets `/PieceInfo << /Illustrator n 0 R >>` and its own `/LastModified` -//! (which §14.5 requires with `PieceInfo`). +//! - Every page dictionary gets `/PieceInfo << /Illustrator n 0 R >>`, the same object `n` on +//! each page, and its own `/LastModified` (which §14.5 requires with `PieceInfo`). A file with +//! several pages whose pages do not all refer to the one data dictionary opens in other apps as +//! a PDF, without the editing data. //! - Object `n` is the data dictionary: `<< /LastModified (D:…) /Private m 0 R >>`. //! - Object `m` holds `AIMetaData` (a stream of the data's header comments), `AIPrivateData1` … //! `AIPrivateDataN` (the data in streams of [`PRIVATE_CHUNK`] bytes, the last one shorter), @@ -35,12 +37,15 @@ pub struct PrivateData { pub roundtrip_stream_type: u32, } -/// `pdf` with `p` in its first page's `PieceInfo`, dated `modified` (Unix seconds, UTC). +/// `pdf` with `p` in every page's `PieceInfo`, dated `modified` (Unix seconds, UTC). pub(crate) fn add(pdf: Vec, p: &PrivateData, modified: i64) -> Result, PdfError> { let failed = |why: &str| PdfError::Write(format!("can't add the editing data for other apps: {why}")); let Parsed { xref, pages, .. } = linearize::parse(&pdf).map_err(failed)?; - let first = *pages.first().ok_or_else(|| failed("a page can't be read"))?; - let (dict, _) = xref.dict(&pdf, first).ok_or_else(|| failed("a page can't be read"))?; + if pages.is_empty() { + return Err(failed("a page can't be read")); + } + let dicts = pages.iter().map(|&n| xref.dict(&pdf, n).map(|(at, _)| at)).collect::>>(); + let dicts = dicts.ok_or_else(|| failed("a page can't be read"))?; let mut patch = Patch::new(&xref); let metadata = patch.add_object(stream(&p.metadata)); let blocks: Vec = p.data.chunks(PRIVATE_CHUNK).map(|c| patch.add_object(stream(c))).collect(); @@ -60,7 +65,9 @@ pub(crate) fn add(pdf: Vec, p: &PrivateData, modified: i64) -> Result>").into_bytes()); - patch.replace(dict, dict, format!("/PieceInfo << /{OWNER} {owner} 0 R >> /LastModified {date} ").into_bytes()); + for dict in dicts { + patch.replace(dict, dict, format!("/PieceInfo << /{OWNER} {owner} 0 R >> /LastModified {date} ").into_bytes()); + } patch.apply(&pdf, &xref) } @@ -112,6 +119,30 @@ mod tests { assert!(text.contains(&format!("<< /Length {PRIVATE_CHUNK} >>")) && text.contains("<< /Length 10 >>")); } + #[test] + fn every_page_refers_to_the_one_data_dictionary() { + let mut doc = Document::new(200.0, 100.0); + let mut second = doc.artboards[0].clone(); + (second.id, second.name, second.rect) = (2, "Back".into(), vectorcraft_geom::Rect::new(240.0, 0.0, 440.0, 100.0)); + doc.artboards.push(second); + let opts = PdfOptions { private_data: Some(data(10)), ..PdfOptions::uncompressed() }; + let pdf = export(&doc, &opts).unwrap(); + let Parsed { xref, pages, .. } = linearize::parse(&pdf).unwrap(); + assert_eq!(pages.len(), 2); + let owners: Vec = pages + .iter() + .map(|&n| { + let (at, end) = xref.dict(&pdf, n).unwrap(); + let page = String::from_utf8_lossy(&pdf[at..end]).into_owned(); + assert!(page.contains("/LastModified (D:"), "{page}"); + let i = page.find("/PieceInfo << /Illustrator ").expect("every page carries the data"); + page[i..].split(">>").next().unwrap().to_string() + }) + .collect(); + assert_eq!(owners[0], owners[1]); + assert_eq!(String::from_utf8_lossy(&pdf).matches("/AIPrivateData1 ").count(), 1, "the data is written once"); + } + #[test] fn without_the_data_the_page_has_no_piece_info() { let pdf = export(&Document::new(200.0, 100.0), &PdfOptions::uncompressed()).unwrap(); From 80c43c8133b524f351778b68fffdd4deee1b7610 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 21:52:04 -0700 Subject: [PATCH 05/21] Save As .ai: keep the group fills and strokes of pattern tiles in the editing data A pattern fill is written as its tiles cut to the shape. A group in the tile art with fills or strokes of its own was written as its members alone, without that paint, because only the layers and the symbols' art were baked before writing. The patterns' tile art is now baked the same way as the symbols' art, so its group paint and geometry effects are written as plain paths. --- crates/eps/src/ai_editing.rs | 27 ++++++++++++++++++--------- crates/eps/src/ai_editing/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 552a99fe0..d87a00396 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -156,26 +156,35 @@ fn crlf(s: &str) -> Vec { s.replace('\n', "\r\n").into_bytes() } -/// `doc` with its geometry effects baked ([`vectorcraft_effects::bake_document`]), in its symbols' -/// art too (which instances are written as); `None` when nothing changes. +/// `doc` with its geometry effects and group paint baked ([`vectorcraft_effects::bake_document`]), +/// in its symbols' art and its patterns' tile art too (which instances and pattern fills are +/// written as); `None` when nothing changes. fn baked(doc: &Document) -> Option { let baked = vectorcraft_effects::bake_document(doc); let d = baked.as_ref().unwrap_or(doc); - if !d.symbols.iter().any(|s| vectorcraft_effects::needs_bake(&s.art)) { + let needs = vectorcraft_effects::needs_bake; + if !d.symbols.iter().any(|s| needs(&s.art)) && !d.patterns.iter().any(|p| p.art.iter().any(|a| needs(a))) { return baked; } - // The symbols' art baked as the layers of a copy of the document. + // The symbols' art, then each pattern's art, baked as the layers of a copy of the document. let mut art = d.clone(); - art.layers = d.symbols.iter().map(|s| Arc::new(Node::group(s.art.id, vec![s.art.clone()]))).collect(); + let symbols = d.symbols.iter().map(|s| Arc::new(Node::group(s.art.id, vec![s.art.clone()]))); + let patterns = d.patterns.iter().map(|p| Arc::new(Node::group(vectorcraft_doc::NodeId(0), p.art.clone()))); + art.layers = symbols.chain(patterns).collect(); // The copy keeps what baking adds to the document (the images color adjustments make). let Some(mut out) = vectorcraft_effects::bake_document(&art) else { return baked }; - let arts: Vec>> = out.layers.iter().map(|l| l.children().and_then(|c| c.first()).cloned()).collect(); - out.layers = d.layers.clone(); - for (s, a) in out.symbols.iter_mut().zip(arts) { - if let Some(a) = a { + let layers = std::mem::replace(&mut out.layers, d.layers.clone()); + let mut members = layers.iter().map(|l| l.children().cloned().unwrap_or_default()); + for s in out.symbols.iter_mut() { + if let Some(a) = members.next().and_then(|m| m.first().cloned()) { s.art = a; } } + for p in out.patterns.iter_mut() { + if let Some(m) = members.next() { + p.art = m; + } + } Some(out) } diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index e7f9c9d39..696014fb9 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -718,6 +718,27 @@ fn an_adjusted_image_in_a_symbol_is_written_in_its_adjusted_color() { assert_ne!(fill, paint(&solid(Color::rgb(0.0, 0.0, 0.0))), "the image is adjusted"); } +#[test] +fn a_group_fill_in_a_pattern_tile_is_written() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let dot = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default()); + let mut group = Node::group(doc.alloc_id(), vec![Arc::new(dot)]); + group.appearance = Appearance::basic(solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into(); + doc.patterns.push(vectorcraft_doc::PatternDef::new("Dots", vec![Arc::new(group)])); + let filled = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(20.0, 20.0, 40.0, 40.0)), + Appearance::basic(Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }, Paint::None, 0.0), + ); + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + let back = read_back(&doc); + let mut all = vec![]; + paths(back.layers[0].children().unwrap(), &mut all); + let red = paint(&solid(Color::rgb(1.0, 0.0, 0.0))); + assert!(all.iter().any(|p| p.appearance.items.iter().any(|i| paint(i.paint()) == red)), "the tiles keep their group's fill"); +} + #[test] fn effects_on_a_fill_are_reported_left_out() { let mut doc = Document::new(200.0, 100.0); From 00fe03187993dbf6110346cc9e49fa581d244566 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 21:58:55 -0700 Subject: [PATCH 06/21] Save As .ai: write inline graphics in type as their art in the editing data Type is written as its outlines, and the outlines leave out inline graphics, so the editing data lost them while the PDF page drew them. Each inline graphic is now written as its symbol's art where the layout places it, over the characters and under the object's own fills and strokes above the Characters row, in the order the PDF page draws them. --- crates/eps/src/ai_editing.rs | 2 +- crates/eps/src/ai_editing/tests.rs | 34 +++++++++++++++++++ crates/eps/src/ai_editing/writer.rs | 52 ++++++++++++++++++++++++----- 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index d87a00396..9f3c24fe3 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -36,7 +36,7 @@ //! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the //! artboards together are centered; each artboard's `RulerOrigin` is its top-left corner there, and //! `%AI3_TemplateBox` holds the canvas's center in art space. What the form above can't hold is written as plain filled and stroked paths, each such -//! loss a warning: type as its outlines, gradients as their average color, pattern fills as their +//! loss a warning: type as its outlines and its inline graphics' art, gradients as their average color, pattern fills as their //! tiles cut to the shape, clipping groups and clipping layers as their art cut to the clipping //! path (the clipping path's fill cut too, its stroke not), images as their outlines filled with //! their average color, symbols and live objects as their art, strokes a plain line can't draw as diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 696014fb9..a3acb5d2f 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -617,6 +617,40 @@ fn type_is_written_as_create_outlines_makes_it() { assert_eq!(rounded(group), rounded(&outlined)); } +#[test] +fn inline_graphics_in_type_are_written_as_their_art() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mark = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), + Appearance::basic(solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0), + ); + doc.symbols.push(Symbol { name: "Mark".into(), art: Arc::new(mark) }); + let style = vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), ..vectorcraft_doc::CharStyle::default() }; + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "A", style.clone()); + t.runs.push(vectorcraft_doc::TextRun::inline(vectorcraft_doc::InlineArt::new("Mark"), style)); + let mut text = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + // A stroke of the object's own above the Characters row: drawn over the inline graphic. + let red = solid(Color::rgb(1.0, 0.0, 0.0)); + text.appearance = + Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(red.clone(), 3.0))], contents_index: Some(0), ..Appearance::default() } + .into(); + doc.insert(Some(layer), usize::MAX, text).unwrap(); + let back = read_back(&doc); + let mut all = vec![]; + paths(back.layers[0].children().unwrap(), &mut all); + let has = |p: &Node, c: &Paint| p.appearance.items.iter().any(|i| paint(i.paint()) == paint(c)); + let blue = solid(Color::rgb(0.0, 0.0, 1.0)); + let art: Vec<&Node> = all.iter().filter(|p| has(p, &blue)).collect(); + assert_eq!(art.len(), 1, "the inline graphic is written"); + // To the right of the letter, 18 pt high as the run's size makes it. + let b = art[0].visual_bounds().unwrap(); + assert!(b.x0 > 10.0 && (b.height() - 18.0).abs() < 0.01, "{b:?}"); + let at = |c: &Paint| all.iter().position(|p| has(p, c)).unwrap(); + assert!(at(&blue) < at(&red), "the stroke above the characters is over the inline graphic"); +} + #[test] fn a_stained_symbol_instance_is_written_in_its_stained_colors() { let mut doc = Document::new(200.0, 100.0); diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 24c0d57b8..58947715c 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -507,19 +507,55 @@ impl<'a> Writer<'a> { /// Type `n` as a group of what Type › Create Outlines makes of it /// ([`vectorcraft_effects::outline_text`]): each run's glyphs with their underline and - /// strikethrough, its color glyphs, and the object's own fills and strokes. + /// strikethrough, its color glyphs, and the object's own fills and strokes; then its inline + /// graphics as their symbols' art where the layout places them, as the PDF page draws them. fn text(&mut self, n: &Node) { self.warn(TYPE); self.line("u"); - if let Some(outlined) = vectorcraft_effects::outline_text(n) { - let state = self.state; - for c in outlined.children().map(Vec::as_slice).unwrap_or_default() { - self.node(c); - } - if let Some((locked, hidden)) = state { - self.state(locked, hidden); + let doc = self.doc; + let resolved = match &n.kind { + NodeKind::Text(t) => Some(doc.inline_resolved(t).into_owned()), + _ => None, + }; + let node = match &resolved { + Some(t) => Node { kind: NodeKind::Text(Box::new(t.clone())), ..n.clone() }, + None => n.clone(), + }; + let state = self.state; + let outlined = vectorcraft_effects::outline_text(&node); + let pieces = outlined.as_ref().and_then(|o| o.children()).map(Vec::as_slice).unwrap_or_default(); + // The object's fills and strokes above the Characters row, when they paint, are the last + // piece: the inline graphics go under it, as the PDF page draws them. + let above = !node.appearance.split_contents().1.is_empty() && { + let bare = Node { appearance: Default::default(), ..node.clone() }; + let glyphs = vectorcraft_effects::outline_text(&bare).and_then(|o| o.children().map(Vec::len)).unwrap_or_default(); + pieces.len() > glyphs + }; + let (under, over) = pieces.split_at(pieces.len() - usize::from(above)); + for c in under { + self.node(c); + } + if let Some(t) = resolved.as_ref().filter(|_| self.nest < MAX_NEST) { + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + for ig in &layout.inlines { + let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue }; + let Some(sym) = doc.symbols.iter().find(|x| x.name == art.symbol) else { continue }; + self.warn(LIVE); + let mut art_node = (*sym.art).clone(); + // Strokes scale with the art, as on the canvas. + art_node.transform(t.xf * ig.xf * doc.symbol_natural_xf(&art.symbol), true); + (art_node.visible, art_node.locked, art_node.name) = (true, false, None); + self.nest += 1; + self.node(&art_node); + self.nest -= 1; } } + for c in over { + self.node(c); + } + if let Some((locked, hidden)) = state { + self.state(locked, hidden); + } self.line("U"); } From 25a193e5a9e60f47c988853d31d752a31ea1ec17 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 22:03:38 -0700 Subject: [PATCH 07/21] Save As .ai: bound the symbol art the editing data writes Instances of symbols that hold instances of other symbols multiply, and the editing data wrote every level for each instance above it, limited only by nesting depth: eight levels of 100 instances each could hang a save or run out of memory. The writer now counts the objects of symbol art it writes, symbol instances and inline graphics alike, and leaves out instances past 16,000,000 objects in all, as the PDF part does, with a warning. --- crates/eps/src/ai_editing.rs | 5 +++-- crates/eps/src/ai_editing/tests.rs | 25 +++++++++++++++++++++++++ crates/eps/src/ai_editing/writer.rs | 21 +++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 9f3c24fe3..e89584b8e 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -39,8 +39,9 @@ //! loss a warning: type as its outlines and its inline graphics' art, gradients as their average color, pattern fills as their //! tiles cut to the shape, clipping groups and clipping layers as their art cut to the clipping //! path (the clipping path's fill cut too, its stroke not), images as their outlines filled with -//! their average color, symbols and live objects as their art, strokes a plain line can't draw as -//! their filled outlines, several fills or strokes as a group of a path for each, gradient and +//! their average color, symbols and live objects as their art (symbol art past +//! [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects in all left out), strokes a plain line can't draw +//! as their filled outlines, several fills or strokes as a group of a path for each, gradient and //! pattern swatches, transparency and live effects other than geometry effects left out. //! //! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index a3acb5d2f..bede50515 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -651,6 +651,31 @@ fn inline_graphics_in_type_are_written_as_their_art() { assert!(at(&blue) < at(&red), "the stroke above the characters is over the inline graphic"); } +/// Nested symbols whose instances multiply stop at [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects of +/// symbol art in all, with a warning. +#[test] +fn nested_symbol_instances_stop_at_the_symbol_art_limit() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + // Empty paths: counted, but written as nothing, so the test stays small; and one filled square. + let empty = Arc::new(Node::path(doc.alloc_id(), PathData::new(vec![]), Appearance::default())); + let mut members = vec![empty; 4999]; + let square = shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)); + members.push(Arc::new(Node::path(doc.alloc_id(), square, Appearance::basic(solid(Color::gray(0.5)), Paint::None, 0.0)))); + let leaves = Node::group(doc.alloc_id(), members); + doc.symbols.push(Symbol { name: "Leaf".into(), art: Arc::new(leaves) }); + let leaf = Arc::new(Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Leaf".into(), xf: Affine::IDENTITY })); + let trees = Node::group(doc.alloc_id(), vec![leaf; 5000]); + doc.symbols.push(Symbol { name: "Tree".into(), art: Arc::new(trees) }); + let tree = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Tree".into(), xf: Affine::IDENTITY }); + doc.insert(Some(layer), usize::MAX, tree).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_LIMIT), "{:?}", out.warnings); + // 5,001 objects for the tree and each leaf: 3,198 leaves fit in 16,000,000. + let text = String::from_utf8(out.program).unwrap(); + assert_eq!(text.matches("\r\nf\r\n").count(), 3198, "the squares of the leaves written"); +} + #[test] fn a_stained_symbol_instance_is_written_in_its_stained_colors() { let mut doc = Document::new(200.0, 100.0); diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 58947715c..1cf0390ca 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -36,6 +36,7 @@ const STROKES: &str = "strokes a plain line can't draw (inside or outside the pa pub(super) const TRANSPARENCY: &str = "transparency (opacity, blending modes, opacity masks) is left out of the editing data for other apps"; pub(super) const EFFECTS: &str = "live effects other than geometry effects are left out of the editing data for other apps"; const SWATCHES: &str = "gradient and pattern swatches are left out of the editing data for other apps"; +pub(super) const SYMBOL_LIMIT: &str = "symbol instances past 16,000,000 objects of symbol art in all are left out of the editing data for other apps"; const TEMPLATES: &str = "template layers are written as dimmed layers that don't print in the editing data for other apps"; pub(super) struct Writer<'a> { @@ -57,6 +58,8 @@ pub(super) struct Writer<'a> { next: HashMap, /// The document's brushes, read the first time a brushed stroke is written. brushes: Option>, + /// The objects of the symbol art written so far, within [`vectorcraft_doc::MAX_SYMBOL_NODES`]. + symbol_nodes: u64, } impl<'a> Writer<'a> { @@ -73,9 +76,23 @@ impl<'a> Writer<'a> { ids: HashSet::new(), next: HashMap::new(), brushes: None, + symbol_nodes: 0, } } + /// Whether the art of a symbol, `art`, may be written: the objects of the symbol art written + /// so far stay within [`vectorcraft_doc::MAX_SYMBOL_NODES`], as in the PDF part (instances of + /// symbols that hold instances multiply). Past it the art is left out, with a warning. + fn take_symbol(&mut self, art: &Node) -> bool { + let total = self.symbol_nodes.saturating_add(art.count() as u64); + if total > vectorcraft_doc::MAX_SYMBOL_NODES { + self.warn(SYMBOL_LIMIT); + return false; + } + self.symbol_nodes = total; + true + } + fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) { self.warnings.push(w.to_string()); @@ -234,6 +251,7 @@ impl<'a> Writer<'a> { self.line("u"); if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST + && self.take_symbol(&sym.art) { // Stained by the instance's fill, as the canvas draws it. let mut art = vectorcraft_brush::instance_art(&sym.art, n); @@ -540,6 +558,9 @@ impl<'a> Writer<'a> { for ig in &layout.inlines { let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue }; let Some(sym) = doc.symbols.iter().find(|x| x.name == art.symbol) else { continue }; + if !self.take_symbol(&sym.art) { + continue; + } self.warn(LIVE); let mut art_node = (*sym.art).clone(); // Strokes scale with the art, as on the canvas. From 1812ccda0804f4edb52469b1ec48c70dedcf716f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 08:53:28 -0700 Subject: [PATCH 08/21] .ai open: keep the last zero byte of a Zstandard frame in the editing data The reader cut every trailing zero byte off the Zstandard data before decoding it, to drop the padding after the last frame. A frame can end in a zero byte itself, and cutting it left the frame short, so the file failed to open with "its Zstandard compression is damaged". The reader now decodes frame after frame and stops when only zeros are left. --- crates/eps/src/import/ai.rs | 8 ++++---- crates/eps/src/import/ai/tests.rs | 13 +++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index e71fc7db4..73e248ac5 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -141,11 +141,11 @@ impl Codec { flate2::read::ZlibDecoder::new(data).take(max + 1).read_to_end(&mut out).map_err(|_| damaged("its zlib compression"))?; } Codec::Zstd => { - // The data may be several frames one after the other, padded with zeros. - let end = data.iter().rposition(|b| *b != 0).map_or(0, |i| i + 1); - let mut src = data.get(..end).unwrap_or_default(); + // The data may be several frames one after the other, padded with zeros. A frame + // can itself end in a zero byte, so the padding is what is left after the last frame. + let mut src = data; let mut frames = 0usize; - while !src.is_empty() { + while src.iter().any(|b| *b != 0) { frames += 1; if frames > MAX_FRAMES { return Err(damaged("its Zstandard compression")); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 721365c25..c9acad3ec 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1219,6 +1219,19 @@ fn many_zstandard_frames_are_read_in_one_pass_and_capped() { assert!(started.elapsed() < std::time::Duration::from_secs(20), "{:?}", started.elapsed()); } +/// A Zstandard frame that ends in a zero byte keeps it: only the zeros after the last frame are +/// padding. +#[test] +fn a_zstandard_frame_ending_in_a_zero_byte_is_read_whole() { + // Magic number, a single-segment header with a 1-byte content size of 3, and one raw block + // (last, 3 bytes) holding `ab` and a zero byte. + let frame = [0x28, 0xB5, 0x2F, 0xFD, 0x20, 3, 0x19, 0, 0, b'a', b'b', 0]; + for padding in [0, 64] { + let raw = [&b"%AI24_ZStandard_Data"[..], &frame, &frame, &vec![0; padding]].concat(); + assert_eq!(decode_private(&raw).unwrap(), b"ab\0ab\0"); + } +} + #[test] fn closing_brackets_without_their_opening_dont_rescan_the_stack() { let n = 200_000; From b4e28e504a0b5a6ed9b07bb53498da8e266180ed Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 08:53:28 -0700 Subject: [PATCH 09/21] .ai save: write the artboards in the recorded part of the document data The editing data put ArtboardArray and AIDocumentCanvasSize in the part of the /Document dictionary marked /NotRecorded. Apps that read the editing data do not read artboards from there: a file opened with one default artboard the size of the first, the other artboards and the names were lost, and the art landed one artboard's height above it. The two entries now close a /Recorded part, followed by an empty /NotRecorded one, as in the files users sent. --- crates/eps/src/ai_editing.rs | 10 +++-- crates/eps/src/ai_editing/tests.rs | 70 ++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index e89584b8e..0ca9958b5 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -19,7 +19,8 @@ //! - Setup: `0 Bn` (no gradient definitions, §3), the color swatches (`%AI5_BeginPalette`, //! `0 0 Pb`, the Registration swatch, each swatch's color operator and `(name) Pc`, a color group as `0 (name) count Pg` //! before its swatches, `PB`), then the artboards as the `ArtboardArray` of the `/Document` -//! dictionary and the canvas size as its `AIDocumentCanvasSize`, on `%_` lines. +//! dictionary's `/Recorded` part and the canvas size as its `AIDocumentCanvasSize`, on `%_` +//! lines. //! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed 0 1 colorIndex r g b 0 //! dimPercent 0 Lb` (14 operands; a color that isn't one of the preset layer colors has the //! index -1), `(name) Ln`, the layer's name as an XML id in an `/ArtDictionary`, its art and @@ -304,7 +305,10 @@ fn ruler_units(u: Unit) -> u8 { /// the canvas (`RulerOrigin`, `origin` being art space's origin there), its pixel aspect ratio /// (`PAR`), `DisplayMark` 0 (as in the files users sent), whether it is the selected one (the /// first) and its name; then the canvas's size. Apps that read the editing data drop a record that -/// lacks any of the first five. +/// lacks any of the first five. Both sit in the dictionary the `/Document` marks `/Recorded`, +/// followed by an empty `/NotRecorded` one, as in the files users sent: apps that read the editing +/// data do not read artboards from the `/NotRecorded` dictionary, and then place the art one +/// artboard's height above a default artboard. fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64)) { s.push_str("%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n%_/Array :\n"); for (i, ab) in doc.artboards.iter().enumerate() { @@ -323,7 +327,7 @@ fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64)) } s.push_str("%_; (ArtboardArray) ,\n"); let _ = writeln!(s, "%_{CANVAS_SIZE} /Int (AIDocumentCanvasSize) ,"); - s.push_str("%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n"); + s.push_str("%_; /Recorded ,\n%_/Dictionary : /NotRecorded ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n"); } #[cfg(test)] diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index bede50515..0fe80683b 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -342,6 +342,76 @@ fn art_lies_on_its_artboard_in_ruler_space() { assert_eq!(bounds, Rect::new(10.0, 20.0, 30.0, 40.0)); } +/// A gray rectangle at `r` (document space) in `doc`'s first layer. +fn add_rect(doc: &mut Document, r: Rect) { + let rect = Node::path(doc.alloc_id(), shapes::rectangle(r), Appearance::basic(solid(Color::gray(0.5)), Paint::None, 0.0)); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, rect).unwrap(); +} + +/// The end of the document data: the artboards and the canvas size in the `/Recorded` part of the +/// `/Document` dictionary, then an empty `/NotRecorded` part. +const RECORDED: &str = "%_; (ArtboardArray) ,\n%_16383 /Int (AIDocumentCanvasSize) ,\n%_; /Recorded ,\n%_/Dictionary : /NotRecorded ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n"; + +/// One letter-size artboard: the art 36 points in from its top-left corner, the artboard's record +/// and the boxes, with the artboards in the `/Recorded` part of the `/Document` dictionary. +#[test] +fn one_artboard_is_recorded_with_its_art_in_place() { + let mut doc = Document::new(612.0, 792.0); + doc.artboards[0].name = "Page".into(); + add_rect(&mut doc, Rect::new(36.0, 36.0, 576.0, 756.0)); + let text = program(&doc); + for line in [ + "%%BoundingBox: 36 -756 576 -36\n", + "%AI3_Cropmarks: 0 -792 612 0\n", + "%AI3_TemplateBox: 306.5 -396.5 306.5 -396.5\n", + "\n36 -36 m\n", + "%_0 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_612 -792 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_7885 7795 /RealPoint\n%_ (RulerOrigin) ,\n", + "%_(Page) /UnicodeString (Name) ,\n%_; ,\n", + RECORDED, + ] { + assert!(text.contains(line), "{line}\n{text}"); + } + let read = read_back(&doc); + assert_eq!(read.artboards.iter().map(|a| (a.name.as_str(), a.rect)).collect::>(), [("Page", Rect::new(0.0, 0.0, 612.0, 792.0))]); + let bounds = read.layers.iter().filter_map(|l| l.visual_bounds()).reduce(|a, b| a.union(b)).unwrap(); + assert_eq!(bounds, Rect::new(36.0, 36.0, 576.0, 756.0)); +} + +/// Two named artboards of different sizes, tops aligned: each record holds its corners in art +/// space and its top-left corner on the canvas, where the two together are centered. +#[test] +fn artboards_of_different_sizes_keep_their_names_and_places() { + let mut doc = Document::new(400.0, 300.0); + doc.artboards[0].name = "Front".into(); + let mut back = doc.artboards[0].clone(); + (back.id, back.name, back.rect) = (2, "Back".into(), Rect::new(440.0, 0.0, 740.0, 500.0)); + doc.artboards.push(back); + add_rect(&mut doc, Rect::new(50.0, 50.0, 150.0, 100.0)); + add_rect(&mut doc, Rect::new(490.0, 50.0, 590.0, 100.0)); + let text = program(&doc); + for line in [ + "%AI3_Cropmarks: 0 -300 400 0\n", + "%AI3_TemplateBox: 370.5 -250.5 370.5 -250.5\n", + "\n50 -50 m\n", + "\n490 -50 m\n", + "%_0 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_400 -300 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_7821 7941 /RealPoint\n%_ (RulerOrigin) ,\n", + "%_1 /Bool (IsArtboardSelected) ,\n%_(Front) /UnicodeString (Name) ,\n", + "%_440 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_740 -500 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_8261 7941 /RealPoint\n%_ (RulerOrigin) ,\n", + "%_0 /Bool (IsArtboardSelected) ,\n%_(Back) /UnicodeString (Name) ,\n", + RECORDED, + ] { + assert!(text.contains(line), "{line}\n{text}"); + } + let read = read_back(&doc); + assert_eq!( + read.artboards.iter().map(|a| (a.name.as_str(), a.rect)).collect::>(), + [("Front", Rect::new(0.0, 0.0, 400.0, 300.0)), ("Back", Rect::new(440.0, 0.0, 740.0, 500.0))] + ); + let bounds = read.layers.iter().filter_map(|l| l.visual_bounds()).reduce(|a, b| a.union(b)).unwrap(); + assert_eq!(bounds, Rect::new(50.0, 50.0, 590.0, 100.0)); +} + #[test] fn a_pdf_carries_the_data_on_its_first_page() { let doc = sample(); From 971eaddf21bfa1e56e7cec53e142f8a076fedd1c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 10:51:25 -0700 Subject: [PATCH 10/21] Docs: a saved .ai carries an editing copy of the art docs/file-format-parity.md said the PDF-compatible .ai writer never writes editing data. It now lists what the editing copy holds, what is written as plain paths, and the editingData option. --- docs/file-format-parity.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ce464cc58..915ebc126 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ from the public specifications, and users' own `.ai` files are used locally only | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| | Formats opened or placed | ~30 | 26 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, JPEG 2000, PICT, Pixar, Word `.doc`, PS | -| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (no Illustrator editing data) or as legacy version 7 (layers and groups, one artboard per file) | +| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, artboards) or as legacy version 7 (layers and groups, one artboard per file) | | Formats exported | ~16 | 17 | missing: DWG, 3D (OBJ/USDA/glTF); extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | | **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting | @@ -49,8 +49,13 @@ private editing data. VectorCraft: A PDF-compatible `.ai` opens on its PDF part's trim boxes (its artboards), the bleed boxes round them becoming the document's bleed, so art in the bleed shows outside the artboard, within the bleed guide (#1266). A PDF saved with its editing data (`AIPDFPrivateData`) opens the same way. -- **Writes** a PDF-compatible `.ai` (Save As › `ai`): Illustrator opens it as plain art. It never writes - Illustrator's private editing data, which is undocumented. +- **Writes** a PDF-compatible `.ai` (Save As › `ai`). With the option `editingData` (on by default, Include + Editing Data for Other Apps) every page's `PieceInfo` also carries an editing copy of the art in + `AIPrivateData` streams, in the form VectorCraft's `.ai` reader reads: layers and sublayers, groups, paths with + their fills and strokes (a compound path as one path of all its subpaths), process, spot and global colors, swatches and color groups, guides, + hidden and locked objects, object names and every artboard with its name. Type, gradients, patterns, images, + symbols, clipping groups and live objects are written as plain filled and stroked paths, each with a warning; + transparency and effects other than geometry effects are left out. - **Writes** a legacy `.ai` of version 7 (Save As › `ai` › Version: Legacy (version 7), `aiVersion: "7"`): the PostScript form, from the published *Adobe Illustrator File Format Specification* version 7.0 ([PRONOM fmt/423](https://www.nationalarchives.gov.uk/PRONOM/fmt/423)), which apps reading `.ai` open with its From b6f0b4de2bca7b4b57590938ceff807ad3935e05 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 11:33:50 -0700 Subject: [PATCH 11/21] .ai save: the editing copy warns about missing placed files A .ai saved with pdfCompatible false resolved placed files for the editing copy but dropped the warnings for the ones it couldn't find, so a missing placed file was written as its preview without a warning. The save now keeps those warnings. --- crates/engine/src/cmd/fileio/save.rs | 14 ++++++++----- crates/engine/src/tests/placed_document.rs | 24 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 2a22442c9..22ff2365f 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -637,17 +637,21 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res } // The art with placed files resolved as the pages draw them, also when the pages are blank. let resolved; - let art: &Document = if pdf_compatible { - &pages + let (art, mut resolved_warnings): (&Document, Vec) = if pdf_compatible { + (&pages, Vec::new()) } else { - resolved = crate::cmd::place::document::full_documents(&edited).0; - &resolved + let (d, w) = crate::cmd::place::document::full_documents(&edited); + resolved = d; + (&resolved, w) }; let o = vectorcraft_eps::AiEditingOptions { title: doc.title.clone(), created: doc.metadata.created.or_else(vectorcraft_doc::metadata::output_time), }; - vectorcraft_eps::ai_private_data(art, &o).map(Some).map_err(|e| bad(cmd, e)) + let (data, lost) = vectorcraft_eps::ai_private_data(art, &o).map_err(|e| bad(cmd, e))?; + // Placed files that weren't found are written as their previews here too. + resolved_warnings.extend(lost); + Ok(Some((data, resolved_warnings))) }; let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, ¶ms, native, private)?; warnings.extend(placed_warnings); diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index eb3164db8..45ac24970 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -288,6 +288,30 @@ fn ai_export_public_aliases_preserve_missing_placed_links() { } } +/// The `.ai` editing copy for other apps draws a missing placed file as its preview, with the +/// pages or without them, and says so. +#[test] +fn ai_editing_copy_warns_about_missing_placed_files() { + let dir = Folder::new("placed-ai-editing-copy-missing"); + let src = dir.file("logo.vectorcraft"); + star_source(&src, "placed-ai-editing-copy-missing-unique"); + let mut s = session(); + place(&mut s, &src); + let parent = dir.file("parent.vectorcraft"); + save(&mut s, &parent); + std::fs::remove_file(&src).unwrap(); + open(&mut s, &parent); + let doc = &s.doc().unwrap().doc; + let (_, missing) = cmd::place::document::full_documents(doc); + assert!(!missing.is_empty()); + for compatible in [true, false] { + let enc = cmd::fileio::encode_all(doc, "ai", &json!({"pdfCompatible": compatible})).unwrap(); + for w in &missing { + assert!(enc.warnings.contains(w), "pdfCompatible {compatible}: {w:?} not in {:?}", enc.warnings); + } + } +} + /// Include Linked Files must retain relinkable objects when only their previews are available. #[test] fn native_included_links_keep_missing_placed_documents_editable() { From a015666bd554a9614a5bd87eb4a09f6165e3d16b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 20:19:57 -0700 Subject: [PATCH 12/21] PDF import: a PDF opened without its VectorCraft document saves under a new name LoadOptions::is_partial now counts native off. A PDF saved with Preserve Editing and opened from its pages instead of the document it carries kept its save path, so Save wrote over the file and dropped that document. Save now asks for a name, as for a page range or another box. --- crates/engine/src/cmd/fileio/pdfimport.rs | 6 +++--- crates/engine/src/cmd/fileio/tests_pdffidelity.rs | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 28aeecd5d..e646c43f8 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -99,10 +99,10 @@ impl LoadOptions { } /// Does the document read only part of the file, or read it differently (a page range, another - /// box, a password, without its hidden layers)? Writing it back would lose the rest, so Save - /// doesn't (it asks for a name). + /// box, a password, without its hidden layers, without the VectorCraft document it carries)? + /// Writing it back would lose the rest, so Save doesn't (it asks for a name). pub fn is_partial(&self) -> bool { - self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() || !self.layers + self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() || !self.layers || !self.native } /// The PDF import options: `pages` resolved against the file's page count. A PDF-compatible diff --git a/crates/engine/src/cmd/fileio/tests_pdffidelity.rs b/crates/engine/src/cmd/fileio/tests_pdffidelity.rs index 862f3f0be..86cb84cfc 100644 --- a/crates/engine/src/cmd/fileio/tests_pdffidelity.rs +++ b/crates/engine/src/cmd/fileio/tests_pdffidelity.rs @@ -141,6 +141,8 @@ fn load_options_read_text_and_layers() { assert!(o.is_partial(), "without its hidden layers, Save doesn't write the file back"); let d = LoadOptions::default(); assert_eq!((d.text_as, d.layers, d.is_partial()), (TextAs::Text, true, false)); + let n = LoadOptions { native: false, ..Default::default() }; + assert!(n.is_partial(), "without the document it carries, Save doesn't write the file back"); } #[test] From 5dc36cac8d5b6fbdda59d5d0eb76647ab3893b2e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 20:19:57 -0700 Subject: [PATCH 13/21] .ai save: the editing data comments say what VectorCraft writes The comments on the %%Creator line and on the artboards' Recorded and NotRecorded parts of the document dictionary now describe what the writer writes. --- crates/eps/src/ai_editing.rs | 18 +++++++----------- crates/eps/src/ai_editing/tests.rs | 2 +- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 0ca9958b5..c818ecbf1 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -84,12 +84,10 @@ const TARGET_RESOLUTION: u32 = 800; /// The gridlines' gray: VectorCraft's default grid color (200 of 255). const GRID_GRAY: f64 = 200.0 / 255.0; -/// `%%Creator`. Apps that read the editing data take the file's version from this line while they -/// read the header, and only a creator that names the format's app gets the version that measures -/// `%AI3_TemplateBox` on a canvas of [`CANVAS_SIZE`] points; any other creator places the art -/// 3,871.5 points off its artboard. The text holds no version number (a number in it would be -/// read as the format's version). -const CREATOR_TEXT: &str = "VectorCraft, editing data for Illustrator"; // brand-ok: the creator the format's version is read from +/// `%%Creator`: the creator line VectorCraft writes in the header of the editing data. It names +/// VectorCraft and the format's app, and holds no version number; the version is written on its +/// own in `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]). +const CREATOR_TEXT: &str = "VectorCraft, editing data for Illustrator"; // brand-ok: the creator line VectorCraft writes /// The first line of a PostScript file (the DSC version comment). const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header comment @@ -304,11 +302,9 @@ fn ruler_units(u: Unit) -> u8 { /// bottom-right corners in art space (`PositionPoint1`, `PositionPoint2`), its top-left corner on /// the canvas (`RulerOrigin`, `origin` being art space's origin there), its pixel aspect ratio /// (`PAR`), `DisplayMark` 0 (as in the files users sent), whether it is the selected one (the -/// first) and its name; then the canvas's size. Apps that read the editing data drop a record that -/// lacks any of the first five. Both sit in the dictionary the `/Document` marks `/Recorded`, -/// followed by an empty `/NotRecorded` one, as in the files users sent: apps that read the editing -/// data do not read artboards from the `/NotRecorded` dictionary, and then place the art one -/// artboard's height above a default artboard. +/// first) and its name; then the canvas's size. Every record holds all of these entries. The +/// artboards and the canvas size are written in the `/Recorded` part of the `/Document` +/// dictionary, followed by an empty `/NotRecorded` part. fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64)) { s.push_str("%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n%_/Array :\n"); for (i, ab) in doc.artboards.iter().enumerate() { diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 0fe80683b..437a4f5fa 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -329,7 +329,7 @@ fn art_lies_on_its_artboard_in_ruler_space() { assert!(text.contains(line), "{line}\n{text}"); } // The creator names the format's app, before the canvas size and the template box, and holds - // no version number another app could read as the format's version. + // no version number (the version is written in `%%AI8_CreatorVersion`). let creator = text.lines().find_map(|l| l.strip_prefix("%%Creator: ")).unwrap(); assert!(creator.contains("Illustrator"), "{creator}"); assert!(!creator.contains("88") && !creator.chars().any(|c| c.is_ascii_digit()), "{creator}"); From bb80e741bff3f4d3ae7702077fed9f76d49e4d32 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 20:39:46 -0700 Subject: [PATCH 14/21] .ai save: the editing data tests build static symbols Symbol has a dynamic field on main; the tests set it to false. --- crates/eps/src/ai_editing/tests.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 437a4f5fa..915cda084 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -590,7 +590,7 @@ fn unsupported() -> Document { let t = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); doc.insert(Some(layer), usize::MAX, t).unwrap(); let art = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), fill(solid(Color::rgb(0.0, 1.0, 0.0)))); - doc.symbols.push(Symbol { name: "Dot".into(), art: Arc::new(art) }); + doc.symbols.push(Symbol { dynamic: false, name: "Dot".into(), art: Arc::new(art) }); let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((150.0, 10.0)) }); doc.insert(Some(layer), usize::MAX, instance).unwrap(); let mut faded = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(170.0, 10.0, 190.0, 30.0)), fill(solid(Color::BLACK))); @@ -696,7 +696,7 @@ fn inline_graphics_in_type_are_written_as_their_art() { shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0), ); - doc.symbols.push(Symbol { name: "Mark".into(), art: Arc::new(mark) }); + doc.symbols.push(Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(mark) }); let style = vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), ..vectorcraft_doc::CharStyle::default() }; let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "A", style.clone()); t.runs.push(vectorcraft_doc::TextRun::inline(vectorcraft_doc::InlineArt::new("Mark"), style)); @@ -733,10 +733,10 @@ fn nested_symbol_instances_stop_at_the_symbol_art_limit() { let square = shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)); members.push(Arc::new(Node::path(doc.alloc_id(), square, Appearance::basic(solid(Color::gray(0.5)), Paint::None, 0.0)))); let leaves = Node::group(doc.alloc_id(), members); - doc.symbols.push(Symbol { name: "Leaf".into(), art: Arc::new(leaves) }); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(leaves) }); let leaf = Arc::new(Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Leaf".into(), xf: Affine::IDENTITY })); let trees = Node::group(doc.alloc_id(), vec![leaf; 5000]); - doc.symbols.push(Symbol { name: "Tree".into(), art: Arc::new(trees) }); + doc.symbols.push(Symbol { dynamic: false, name: "Tree".into(), art: Arc::new(trees) }); let tree = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Tree".into(), xf: Affine::IDENTITY }); doc.insert(Some(layer), usize::MAX, tree).unwrap(); let out = export_ai_editing(&doc, &opts()).unwrap(); @@ -755,7 +755,7 @@ fn a_stained_symbol_instance_is_written_in_its_stained_colors() { shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0), ); - doc.symbols.push(Symbol { name: "Dot".into(), art: Arc::new(art) }); + doc.symbols.push(Symbol { dynamic: false, name: "Dot".into(), art: Arc::new(art) }); let mut instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((50.0, 10.0)) }); instance.appearance = Appearance::basic(solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into(); let stained = vectorcraft_brush::instance_art(&doc.symbols[0].art, &instance); @@ -812,7 +812,7 @@ fn geometry_effects_in_a_symbol_are_written_as_their_result() { let mut ap = Appearance::basic(solid(Color::BLACK), Paint::None, 0.0); ap.effects.push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: serde_json::json!({"offset": 5}), visible: true }); let art = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), ap); - doc.symbols.push(Symbol { name: "Dot".into(), art: Arc::new(art) }); + doc.symbols.push(Symbol { dynamic: false, name: "Dot".into(), art: Arc::new(art) }); let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((50.0, 20.0)) }); doc.insert(Some(layer), usize::MAX, instance).unwrap(); let back = read_back(&doc); @@ -836,7 +836,7 @@ fn an_adjusted_image_in_a_symbol_is_written_in_its_adjusted_color() { let mut ap = (*im.appearance).clone(); ap.effects.push(vectorcraft_doc::Effect { id: "adjust.shiftToColor".into(), params: shift, visible: true }); im.appearance = ap.into(); - doc.symbols.push(Symbol { name: "Picture".into(), art: Arc::new(im) }); + doc.symbols.push(Symbol { dynamic: false, name: "Picture".into(), art: Arc::new(im) }); let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Picture".into(), xf: Affine::translate((50.0, 20.0)) }); doc.insert(Some(layer), usize::MAX, instance).unwrap(); let back = read_back(&doc); From 2ca27de40bb3d689e445060219db55c4c57c7617 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 20:57:37 -0700 Subject: [PATCH 15/21] .ai save: the no-PDF-content warning names the editing data A .ai saved with pdfCompatible false warned that other apps show empty pages, also when the file carries the editing copy of its art. With the editing copy the warning now says that apps that read its editing data open it; with editingData false it is unchanged. --- crates/engine/src/cmd/fileio/save.rs | 7 ++++++- crates/engine/src/tests/saveoptions.rs | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 1a9d77ebb..35cef3e56 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -574,6 +574,9 @@ fn blank_pages(doc: &Document) -> Document { /// Why a `.ai` file saved without PDF content looks empty elsewhere. const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens it as before, other apps show empty pages"; +/// The same, for a file that carries the editing copy of its art. +const NOT_PDF_COMPATIBLE_EDITING: &str = + "saved without PDF content: VectorCraft and apps that read its editing data open it, other apps show empty pages"; /// `e`, an error of the export encoders, under the save command `cmd` instead of /// `document.export`. @@ -631,6 +634,7 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res }; let native = || super::native::ai_native(cmd, doc, &native_options); let other_apps: AiEditingData = super::encode::options(f, ¶ms)?; + let carries_editing = std::cell::Cell::new(false); let private = || { if !other_apps.editing_data.unwrap_or(true) { return Ok(None); @@ -651,12 +655,13 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res let (data, lost) = vectorcraft_eps::ai_private_data(art, &o).map_err(|e| bad(cmd, e))?; // Placed files that weren't found are written as their previews here too. resolved_warnings.extend(lost); + carries_editing.set(true); Ok(Some((data, resolved_warnings))) }; let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, ¶ms, native, private)?; warnings.extend(placed_warnings); if !pdf_compatible { - warnings.push(NOT_PDF_COMPATIBLE.to_string()); + warnings.push(if carries_editing.get() { NOT_PDF_COMPATIBLE_EDITING } else { NOT_PDF_COMPATIBLE }.to_string()); } Ok(Encoded { warnings, ..Encoded::one(bytes) }) } diff --git a/crates/engine/src/tests/saveoptions.rs b/crates/engine/src/tests/saveoptions.rs index ff17ef34e..feca82401 100644 --- a/crates/engine/src/tests/saveoptions.rs +++ b/crates/engine/src/tests/saveoptions.rs @@ -196,7 +196,11 @@ fn ai_without_pdf_content_and_compression() { }; let (full, _) = ai(&mut s, json!({})); let (blank, warnings) = ai(&mut s, json!({"pdfCompatible": false})); - assert!(warnings.as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("without PDF content")), "{warnings}"); + let says = |w: &Value, text: &str| w.as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains(text)); + assert!(says(&warnings, "without PDF content") && says(&warnings, "apps that read its editing data open it"), "{warnings}"); + // Without the editing copy, other apps show empty pages. + let (_, plain) = ai(&mut s, json!({"pdfCompatible": false, "editingData": false})); + assert!(says(&plain, "VectorCraft opens it as before") && !says(&plain, "editing data"), "{plain}"); // The pages are empty (an import of a page finds no art), the document comes back whole. let page = |s: &mut Session, b: &[u8]| { s.execute("document.open", &json!({"name": "x.ai", "dataBase64": vectorcraft_format::base64_encode(b), "pages": "1"})).unwrap(); From b949038a2b15efc0ef6d0e335af098fd30da595e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 21:09:39 -0700 Subject: [PATCH 16/21] .ai save: the editing copy draws the artboards' background colors The PDF pages of a .ai draw each artboard's background color (Include Artboard Fill, on by default), but the editing copy of the art left it out, so the two differed and the file reopened from its PDF part. The editing copy now takes the same option. --- crates/engine/src/cmd/fileio/save.rs | 4 +++- crates/engine/src/cmd/fileio/tests_artboard_fill.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 35cef3e56..85fea0cbd 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -648,11 +648,13 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res resolved = d; (&resolved, w) }; + // With the artboards' background colors, as the pages draw them. + let art = super::export::with_artboard_fills(art, ¶ms, 0..art.artboards.len()).map_err(|e| bad(cmd, e))?; let o = vectorcraft_eps::AiEditingOptions { title: doc.title.clone(), created: doc.metadata.created.or_else(vectorcraft_doc::metadata::output_time), }; - let (data, lost) = vectorcraft_eps::ai_private_data(art, &o).map_err(|e| bad(cmd, e))?; + let (data, lost) = vectorcraft_eps::ai_private_data(&art, &o).map_err(|e| bad(cmd, e))?; // Placed files that weren't found are written as their previews here too. resolved_warnings.extend(lost); carries_editing.set(true); diff --git a/crates/engine/src/cmd/fileio/tests_artboard_fill.rs b/crates/engine/src/cmd/fileio/tests_artboard_fill.rs index 28e170e06..9a7684686 100644 --- a/crates/engine/src/cmd/fileio/tests_artboard_fill.rs +++ b/crates/engine/src/cmd/fileio/tests_artboard_fill.rs @@ -97,6 +97,18 @@ fn pdf_and_svg_show_the_color_and_their_editing_data_keeps_it_as_the_artboards() assert_eq!(back.layers.len(), doc.layers.len(), "no art made of it"); } +#[test] +fn the_ai_editing_copy_for_other_apps_draws_the_color_as_the_pages_do() { + let mut s = session(); + let opts = LoadOptions { native: false, ..Default::default() }; + for (more, fill) in [(json!({}), true), (json!({"includeArtboardFill": false}), false)] { + let l = load::load_with("a.ai", &file(&mut s, with(json!({"format": "ai"}), &more)), &opts).unwrap(); + assert!(l.editing_data && !l.restored, "{more}: {:?}", l.warnings); + let names: Vec = l.doc.layers.iter().filter_map(|l| l.name.clone()).collect(); + assert_eq!(names.iter().any(|n| n == "Artboard Fill"), fill, "{more}: {names:?}"); + } +} + #[test] fn eps_and_postscript_print_draw_the_color() { let mut s = session(); From 1f24099dd99ac67f1ce6c62f80aefd9d320c3e61 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 06:22:13 -0800 Subject: [PATCH 17/21] .ai save: test that a file opens from its VectorCraft document when the editing copy differs A .ai whose editing copy for other apps holds no transparency, while the VectorCraft document it carries holds 50% opacity, opens in VectorCraft with the opacity of the VectorCraft document. Read with native: false, the same file gives the editing copy. --- crates/engine/src/cmd/fileio/tests_pdfedit.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs index 358c9840c..7b9f9774f 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfedit.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs @@ -451,3 +451,29 @@ fn ai_files_carry_an_editing_copy_for_other_apps() { assert_eq!(vectorcraft_pdf::illustrator_data(&b64(&r), None), None); assert!(s.execute("document.export", &json!({"format": "ai", "editingData": "no"})).is_err()); } + +/// A `.ai` whose editing copy for other apps differs from the VectorCraft document it carries (the +/// copy has no transparency) opens in VectorCraft from the VectorCraft document. +#[test] +fn an_ai_file_opens_from_its_vectorcraft_document_when_the_editing_copy_differs() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap(); + s.execute("transparency.set", &json!({"opacity": 50})).unwrap(); + let bytes = b64(&s.execute("document.export", &json!({"format": "ai"})).unwrap()); + let opacities = |doc: &vectorcraft_doc::Document| { + let mut out = vec![]; + doc.walk(|n| { + if matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }) { + out.push(n.opacity); + } + }); + out + }; + let copy = load::load_with("art.ai", &bytes, &LoadOptions { native: false, trust_editing_data: true, ..Default::default() }).unwrap(); + assert!(copy.editing_data && !copy.restored); + assert_eq!(opacities(©.doc), [1.0]); + let opened = load::load_with("art.ai", &bytes, &LoadOptions::default()).unwrap(); + assert!(opened.restored); + assert_eq!(opacities(&opened.doc), [0.5]); +} From 029661cf78fb1db0854d35417396953aa981a835 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 18:55:11 -0800 Subject: [PATCH 18/21] .ai save: the editing copy places symbol instances through their 9-slice guides A scaled instance of a symbol with 9-slice guides was written with its art stretched as a whole. The editing copy now places it as the canvas draws it, and leaves the instance out when placing it would pass the symbol art limit. --- crates/eps/src/ai_editing/tests.rs | 26 ++++++++++++++++++++++++++ crates/eps/src/ai_editing/writer.rs | 18 ++++++++++-------- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 915cda084..4f533193d 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -721,6 +721,32 @@ fn inline_graphics_in_type_are_written_as_their_art() { assert!(at(&blue) < at(&red), "the stroke above the characters is over the inline graphic"); } +/// A scaled instance of a symbol with 9-slice guides is written as the canvas draws it: the corner +/// keeps its size while the rest stretches. +#[test] +fn a_scaled_nine_slice_symbol_instance_keeps_its_corner_size() { + let mut doc = Document::new(400.0, 400.0); + let layer = doc.layers[0].id; + let square = |doc: &mut Document, r: Rect| { + Node::path(doc.alloc_id(), shapes::rectangle(r), Appearance::basic(solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0)) + }; + let frame = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0))); + let corner = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0))); + let art = Node::group(doc.alloc_id(), vec![frame, corner]); + doc.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); + doc.set_symbol_nine_slice("S", Some(vectorcraft_doc::SymbolNineSlice::default())); + let xf = Affine::scale_non_uniform(2.0, 1.5); + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf }); + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + let back = read_back(&doc); + let mut all = vec![]; + paths(back.layers[0].children().unwrap(), &mut all); + let sizes: Vec<(i64, i64)> = + all.iter().filter_map(|p| p.geometric_bounds()).map(|b| (b.width().round() as i64, b.height().round() as i64)).collect(); + assert!(sizes.contains(&(10, 10)), "the corner keeps its size: {sizes:?}"); + assert!(sizes.contains(&(200, 150)), "the frame is scaled: {sizes:?}"); +} + /// Nested symbols whose instances multiply stop at [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects of /// symbol art in all, with a warning. #[test] diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 1cf0390ca..cd5f80ced 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -254,14 +254,16 @@ impl<'a> Writer<'a> { && self.take_symbol(&sym.art) { // Stained by the instance's fill, as the canvas draws it. - let mut art = vectorcraft_brush::instance_art(&sym.art, n); - art.transform(*xf, false); - art.visible = true; - art.locked = false; - art.name = None; - self.nest += 1; - self.node(&art); - self.nest -= 1; + let stained = vectorcraft_brush::instance_art(&sym.art, n); + // Through the symbol's 9-slice guides when it has them, as the canvas draws it. + if let Some(mut art) = self.doc.place_symbol_art(symbol, &stained, *xf) { + art.visible = true; + art.locked = false; + art.name = None; + self.nest += 1; + self.node(&art); + self.nest -= 1; + } } self.line("U"); } From 5df9b2d1e093d909dacc6caa5b82a8c87beb88a6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 18:59:11 -0800 Subject: [PATCH 19/21] .ai save: nine-slice symbol placement counts toward the symbol art limit --- crates/eps/src/ai_editing/writer.rs | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index cd5f80ced..325203188 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -249,20 +249,38 @@ impl<'a> Writer<'a> { NodeKind::SymbolInstance { symbol, xf } => { self.warn(LIVE); self.line("u"); - if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) + let doc = self.doc; + if let Some(sym) = doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST - && self.take_symbol(&sym.art) { // Stained by the instance's fill, as the canvas draws it. let stained = vectorcraft_brush::instance_art(&sym.art, n); - // Through the symbol's 9-slice guides when it has them, as the canvas draws it. - if let Some(mut art) = self.doc.place_symbol_art(symbol, &stained, *xf) { + // Through the symbol's 9-slice guides when it has them, as the canvas draws it; + // placing through them needs room for the most objects it can make, and the + // objects it makes count. + let map = doc.symbol_slice_map(symbol, *xf); + let most = map.map_or(stained.count() as u64, |m| m.most_placed(&stained, doc)); + let placed = vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || { + let art = match map { + Some(m) => m.place(&stained, doc), + None => { + let mut a = stained.clone(); + a.transform(*xf, false); + a + } + }; + let count = if map.is_some() { art.count_with_masks() } else { art.count() } as u64; + (art, count) + }); + if let Some(mut art) = placed { art.visible = true; art.locked = false; art.name = None; self.nest += 1; self.node(&art); self.nest -= 1; + } else { + self.warn(SYMBOL_LIMIT); } } self.line("U"); From f87ba9224c47cf661f1614c6d320d39a4aa4f367 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 16:02:28 -0800 Subject: [PATCH 20/21] .ai save: artboards larger than the canvas are written on a large canvas Artboards that together did not fit on the 16,383 pt canvas were written with %AI24_LargeCanvasScale: 1, so part of them lay outside the canvas. The editing data now declares a scale of 10 for them and writes the art, boxes, artboards, strokes and effects at 1/10 of their size, symbol and pattern art included. Reading such a .ai, the editing data comes in at its declared scale when it matches the page that way rather than in the units of the page's UserUnit, also when the PDF part can't be read, and a file saved without its PDF part applies the declared scale too. --- crates/eps/src/ai_editing.rs | 58 +++++++++++++++++++-- crates/eps/src/ai_editing/tests.rs | 55 +++++++++++++++++++ crates/eps/src/import.rs | 1 + crates/eps/src/import/ai/read.rs | 5 ++ crates/eps/src/import/ai/read/directives.rs | 26 +++++---- crates/eps/src/import/ai/read/document.rs | 1 + crates/eps/src/import/native.rs | 1 + crates/eps/src/import/native/build.rs | 41 +++++++++++---- crates/eps/src/import/native/layers.rs | 3 +- 9 files changed, 166 insertions(+), 25 deletions(-) diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 7d9e1a529..7ca953703 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -12,7 +12,9 @@ //! document's author), `%%Canvassize`, `%%BoundingBox`, //! `%%HiResBoundingBox`, `%AI5_FileFormat 14.0`, `%AI7_ImageSettings`, the first artboard as //! `%AI3_Cropmarks` and `%AI3_TileBox`, the canvas's center as `%AI3_TemplateBox`, -//! `%AI5_ArtSize 14400 14400`, `%AI5_RulerUnits`, `%AI9_ColorModel` (1 RGB, 2 CMYK), +//! `%AI5_ArtSize 14400 14400`, `%AI5_RulerUnits`, `%AI24_LargeCanvasScale` (1, or 10 when the +//! artboards together don't fit on the canvas: the art, boxes and artboards are then written at +//! 1/10 of their size), `%AI9_ColorModel` (1 RGB, 2 CMYK), //! `%AI5_ArtFlags`, `%AI5_TargetResolution`, `%AI5_NumLayers`, `%AI5_OpenViewLayers` (a digit for //! each layer, 7 shown and 6 hidden), `%AI7_GridSettings` (§2.1.1), and the version comment //! `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]). @@ -73,6 +75,9 @@ const ROUNDTRIP_STREAM_TYPE: u32 = 2; const ZSTD_MARKER: &[u8] = b"%AI24_ZStandard_Data"; /// `%%Canvassize`: the side of the canvas in points, as the files users sent have it. const CANVAS_SIZE: u32 = 16383; +/// The scale of a large canvas: its sides are ten times the canvas's, as the format's large canvas +/// documents have them (2270 inches square). +const LARGE_CANVAS_SCALE: f64 = 10.0; /// `%AI5_ArtSize`, as the files users sent have it. const ART_SIZE: u32 = 14400; /// `%AI5_ArtFlags` (§2.1.1, Table 4), as the files users sent have them: page setup, print tiles, @@ -120,6 +125,25 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result Result (f64, f64) { ((mid - c.0).floor(), (mid + c.1).floor()) } +/// `%AI24_LargeCanvasScale`: 1 when the artboards together fit on the canvas, with a point to +/// spare on each side for the rounding of [`ruler_origin`], else [`LARGE_CANVAS_SCALE`]: then the +/// art, boxes and artboards are written at 1/[`LARGE_CANVAS_SCALE`] of their size. +fn canvas_scale(doc: &Document) -> f64 { + let all = doc.artboards.iter().map(|ab| ab.rect).filter(|r| r.is_finite()).reduce(|a, b| a.union(b)); + let room = f64::from(CANVAS_SIZE) - 2.0; + if all.is_some_and(|r| r.width() > room || r.height() > room) { LARGE_CANVAS_SCALE } else { 1.0 } +} + +/// The strokes and effects of `n` and of what it holds, `k` times as large. +fn scale_strokes(n: &mut Node, k: f64) { + n.appearance.scale_strokes(k); + n.appearance.scale_effects(k, vectorcraft_effects::scale_effect); + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + scale_strokes(Arc::make_mut(&mut mask.art), k); + } + if let Some(children) = n.children_mut() { + for c in children { + scale_strokes(Arc::make_mut(c), k); + } + } +} + /// The canvas's center, on its own axes and in both directions. fn canvas_center() -> f64 { f64::from(CANVAS_SIZE) / 2.0 } /// The header comments, through `%%EndComments`. `art`: the visible art's bounds, `first` the first -/// artboard, in art space; `origin` art space's origin on the canvas ([`ruler_origin`]). -fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, first: Rect, origin: (f64, f64)) -> String { +/// artboard, in art space; `origin` art space's origin on the canvas ([`ruler_origin`]); `scale` +/// the large canvas scale ([`canvas_scale`]). +fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, first: Rect, origin: (f64, f64), scale: f64) -> String { let mut s = String::new(); s.push_str(PS_HEADER); s.push('\n'); @@ -267,7 +315,7 @@ fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, first: Re s.push_str("%AI3_DocumentPreview: None\n"); let _ = writeln!(s, "%AI5_ArtSize: {ART_SIZE} {ART_SIZE}"); let _ = writeln!(s, "%AI5_RulerUnits: {}", ruler_units(doc.units)); - s.push_str("%AI24_LargeCanvasScale: 1\n"); + let _ = writeln!(s, "%AI24_LargeCanvasScale: {}", ps::num(scale)); let model = if doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { 2 } else { 1 }; let _ = writeln!(s, "%AI9_ColorModel: {model}"); let _ = writeln!(s, "%AI5_ArtFlags: {ART_FLAGS}"); diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 4f533193d..cb96fa9ff 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -412,6 +412,61 @@ fn artboards_of_different_sizes_keep_their_names_and_places() { assert_eq!(bounds, Rect::new(50.0, 50.0, 590.0, 100.0)); } +/// An artboard larger than the canvas (20,000 points square): the data is written on a large +/// canvas, at 1/10 of the size, so the artboard lies on the canvas; read beside a page of the +/// artboard's size in points, it comes back at its size. +#[test] +fn an_artboard_larger_than_the_canvas_is_written_on_a_large_canvas() { + let mut doc = Document::new(20_000.0, 20_000.0); + add_rect(&mut doc, Rect::new(1000.0, 1000.0, 3000.0, 2000.0)); + let text = program(&doc); + for line in [ + "%AI24_LargeCanvasScale: 10\n", + "%AI3_Cropmarks: 0 -2000 2000 0\n", + "\n100 -100 m\n", + "%_0 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_2000 -2000 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_7191 7191 /RealPoint\n%_ (RulerOrigin) ,\n", + RECORDED, + ] { + assert!(text.contains(line), "{line}\n{text}"); + } + // A page of the artboard's size in points, as a PDF part in larger units gives it. + let mut page = Document::new(20_000.0, 20_000.0); + add_rect(&mut page, Rect::new(1000.0, 1000.0, 3000.0, 2000.0)); + let (p, _) = private_data(&doc, &opts()).unwrap(); + let (read, notes, layers) = crate::layered_ai(&p.data, page, vec![], false, true, 1.389); + assert!(layers, "{notes:?}"); + assert_eq!(read.artboards.iter().map(|a| a.rect).collect::>(), [Rect::new(0.0, 0.0, 20_000.0, 20_000.0)], "{notes:?}"); + let bounds = read.layers.iter().filter_map(|l| l.visual_bounds()).reduce(|a, b| a.union(b)).unwrap(); + assert!((bounds.x0 - 1000.0).abs() < 1e-6 && (bounds.y1 - 2000.0).abs() < 1e-6 && (bounds.width() - 2000.0).abs() < 1e-6, "{bounds:?}"); + // The same without a readable PDF part, on its first page's size. + let (alone, notes) = crate::ai_alone_on_page(&p.data, (20_000.0, 20_000.0), 1.389).unwrap(); + assert_eq!(alone.artboards.iter().map(|a| a.rect).collect::>(), [Rect::new(0.0, 0.0, 20_000.0, 20_000.0)], "{notes:?}"); + // And from the editing data alone, as a file saved without its PDF part opens. + let alone = read_back(&doc); + assert_eq!(alone.artboards.iter().map(|a| a.rect).collect::>(), [Rect::new(0.0, 0.0, 20_000.0, 20_000.0)]); + let bounds = alone.layers.iter().filter_map(|l| l.visual_bounds()).reduce(|a, b| a.union(b)).unwrap(); + assert!((bounds.x0 - 1000.0).abs() < 1e-6 && (bounds.width() - 2000.0).abs() < 1e-6, "{bounds:?}"); + + // A symbol's 30-point stroke is written at 1/10 too, as the layers' strokes are. + let mut doc = Document::new(20_000.0, 20_000.0); + let line = shapes::rectangle(Rect::new(0.0, 0.0, 500.0, 500.0)); + let art = Node::path(doc.alloc_id(), line, Appearance::basic(Paint::None, solid(Color::gray(0.5)), 30.0)); + doc.symbols.push(Symbol { dynamic: false, name: "Box".into(), art: Arc::new(art) }); + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((1000.0, 1000.0)) }); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + let (p, _) = private_data(&doc, &opts()).unwrap(); + let (read, notes, layers) = crate::layered_ai(&p.data, Document::new(20_000.0, 20_000.0), vec![], false, true, 1.389); + assert!(layers, "{notes:?}"); + let mut all = vec![]; + paths(read.layers[0].children().unwrap(), &mut all); + let widths: Vec = all.iter().filter_map(|n| n.appearance.stroke().map(|s| s.width)).collect(); + assert!(!widths.is_empty() && widths.iter().all(|w| (w - 30.0).abs() < 1e-6), "{widths:?}"); + + // Artboards that fit stay on the canvas at their size. + assert!(program(&Document::new(16_000.0, 16_000.0)).contains("%AI24_LargeCanvasScale: 1\n")); +} + #[test] fn a_pdf_carries_the_data_on_its_first_page() { let doc = sample(); diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 9c84001f7..f12281271 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -70,6 +70,7 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; +pub(crate) use native::scale_document; pub use native::{ai_alone, ai_alone_on_page, is_loss, layered_ai}; pub use text::family_style; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 93cc0af6b..88def834d 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -159,6 +159,8 @@ pub struct Structure { pub envelopes_drawn: Vec<(NodeId, Node)>, /// The document's graphic styles are those of the file's style list, in place of the defaults. pub styles_read: bool, + /// The file's `%AI24_LargeCanvasScale` (1 when it declares none), applied or not. + pub canvas_scale: f64, pub warnings: Vec, } @@ -325,6 +327,8 @@ struct Reader<'a> { /// (see [`Reader::ensure_space`]); 1 (no scaling) when the file doesn't declare one, `None` /// when the declared factor is not applied ([`read`]). large_canvas: Option, + /// The declared `%AI24_LargeCanvasScale` (1 without one), applied or not. + canvas_scale: f64, /// `%AI5_RulerUnits` from the header (Illustrator 7 spec table 3, plus centimeters and pixels). ruler_units: Option, /// This reader reads a pattern's definition (see [`Reader::define_pattern`]). @@ -435,6 +439,7 @@ impl<'a> Reader<'a> { art_size: None, template_center: None, large_canvas: None, + canvas_scale: 1.0, ruler_units: None, pattern: None, pattern_tiles: HashMap::new(), diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index ed838b51b..cf047363a 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -208,21 +208,29 @@ impl<'a> Reader<'a> { self.template_center = two(v); self.template = four(v).or(self.template); } else if let Some(v) = line.strip_prefix("AI24_LargeCanvasScale:") { - // Illustrator's Large Canvas writes the editing data (art and boxes) at 1/N scale, N - // being this; the printed page applies the same factor, so the art reaches the page's - // size once it is applied to the art space ([`Self::ensure_space`]). A value outside - // the range Illustrator writes is left alone. - if let (Some(scale), Ok(n)) = (&mut self.large_canvas, v.trim().parse::()) - && (1.0..=100.0).contains(&n) - { - *scale = n; - } + self.large_canvas_scale(v); } else if let Some(v) = line.strip_prefix("AI9_ColorModel:") { self.color_model = Some(v.trim() == "2"); } Ok(()) } + /// `%AI24_LargeCanvasScale:` with its value `v`. + fn large_canvas_scale(&mut self, v: &str) { + // Illustrator's Large Canvas writes the editing data (art and boxes) at 1/N scale, N + // being this; the printed page applies the same factor, so the art reaches the page's + // size once it is applied to the art space ([`Self::ensure_space`]). A value outside + // the range Illustrator writes is left alone. + if let Ok(n) = v.trim().parse::() + && (1.0..=100.0).contains(&n) + { + self.canvas_scale = n; + if let Some(scale) = &mut self.large_canvas { + *scale = n; + } + } + } + /// `%AI3_Note:` (or `%%Note:`) sets the note of the paths that follow, an empty one clearing it, /// and `%AI5_NoteMore:` adds a line to it. The text is kept as written, up to [`MAX_NOTE`] /// characters. diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index f38ec7623..622e6912c 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -723,6 +723,7 @@ impl<'a> Reader<'a> { placed_elsewhere: self.placed_elsewhere, envelopes_drawn: self.envelopes_drawn, styles_read, + canvas_scale: self.canvas_scale, warnings, }) } diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 128ce4cb8..2537ed203 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -45,6 +45,7 @@ mod layers; mod plan; mod slots; +pub(crate) use build::scale_document; #[cfg(test)] use build::{Frame, MADE, prune_unseen}; #[cfg(test)] diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index f73121f23..27967ea6a 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -56,7 +56,8 @@ pub(super) fn build( /// [`build`] without a page, for a `.ai` whose PDF part can't be read, its first page `page` /// (width and height, either way round) points large in units of `unit` points: the document is /// drawn `unit` times as large when its first artboard, or its art's box, has the page's size in -/// those units and not in points, as [`build_from`] decides with a page. +/// those units and not in points, as [`build_from`] decides with a page; else as many times as +/// large as its declared large canvas scale when it has the page's size at that scale. pub(super) fn build_on_page(data: &[u8], page: (f64, f64), unit: f64) -> Result<(Document, Vec), String> { let structure = ai::read(data, false)?; let board = structure.artboard.map(|a| structure.to_doc.transform_rect_bbox(a)); @@ -65,12 +66,12 @@ pub(super) fn build_on_page(data: &[u8], page: (f64, f64), unit: f64) -> Result< let fits = |r: Option, k: f64| { r.is_some_and(|r| [(w, h), (h, w)].iter().any(|(a, b)| (r.width() - a / k).abs() <= 0.5 && (r.height() - b / k).abs() <= 0.5)) }; - let in_units = |r: Option| unit.is_finite() && unit > 1.0 && fits(r, unit) && !fits(r, 1.0); - let scale = in_units(board) || (!fits(board, 1.0) && in_units(art)); + let in_units = |r: Option, k: f64| k.is_finite() && k > 1.0 && fits(r, k) && !fits(r, 1.0); + let scale = [unit, structure.canvas_scale].into_iter().find(|&k| in_units(board, k) || (!fits(board, 1.0) && in_units(art, k))); let (mut doc, notes) = build_from(structure, data, None, Page::Artboard, false, false, 1.0)?; - if scale { + if let Some(k) = scale { // The graphic styles here are all the file's, drawn in its units too. - scale_document(&mut doc, unit, true); + scale_document(&mut doc, k, true); } Ok((doc, notes)) } @@ -99,16 +100,19 @@ pub(super) fn build_from( placed_elsewhere, envelopes_drawn, styles_read, + canvas_scale, warnings, } = structure; - // Editing data in the units of the page's user space ([`in_units`]) is compared with the page - // scaled down to those units, and the document read from it is scaled up by `unit`. + // Editing data in the units of the page's user space ([`in_units`]), or at its declared large + // canvas scale ([`page_scale`]), is compared with the page scaled down to those units, and the + // document read from it is scaled up by `unit`. let board = artboard.map(|a| to_doc.transform_rect_bbox(a)); // A page cropped to the art (see below) is in those units as well, when the art's box is and // the artboard isn't the page in points. let art = bbox.map(|[a, b, c, d]| to_doc.transform_rect_bbox(Rect::new(a, b, c, d))); - let units = |v: &Document| in_units(board, v, unit) || (!same_size(board, v) && in_units(art, v, unit)); - let small = visible.filter(|v| matches!(page, Page::Artboard) && units(v)).map(|v| { + let scaled = page_scale(board, art, visible.filter(|_| matches!(page, Page::Artboard)), unit, canvas_scale); + let unit = scaled.unwrap_or(unit); + let small = visible.filter(|_| scaled.is_some()).map(|v| { let mut v = v.clone(); scale_document(&mut v, 1.0 / unit, false); v @@ -1001,6 +1005,23 @@ fn placed_files(nodes: &[Arc], out: &mut Vec) { } } +/// Is the editing data in units of `unit` points beside `page` ([`in_units`])? Its first artboard +/// `board` is, or, when that isn't the page in points, its art's box `art` is (a page cropped to the +/// art). +fn in_units_or_art(board: Option, art: Option, page: &Document, unit: f64) -> bool { + in_units(board, page, unit) || (!same_size(board, page) && in_units(art, page, unit)) +} + +/// The size in points of a unit of the editing data whose first artboard is `board` and whose +/// art's box is `art` (the document's coordinates), read beside `page`: `unit` (the page's +/// `UserUnit`) when the data is in those units ([`in_units_or_art`]), else `canvas_scale` (its +/// declared large canvas scale) when the data is at that scale; `None` when it is in points or +/// there is no page. +fn page_scale(board: Option, art: Option, page: Option<&Document>, unit: f64, canvas_scale: f64) -> Option { + let page = page?; + [unit, canvas_scale].into_iter().find(|&k| in_units_or_art(board, art, page, k)) +} + /// Does the editing data's first artboard `board` (in the document read from it: art space through /// `to_doc`) have the size of `page`'s first artboard in units of `unit` points, `unit` being above /// 1, and not its size in points? `page` is a PDF part read in points from pages of `unit` points a @@ -1054,7 +1075,7 @@ fn drop_guides_off_boards(doc: &mut Document) { /// Draw `doc`'s layers, artboards and guides `k` times as large, from the origin, with their /// strokes, effects and pattern tiles, and with `styles` the strokes and effects of its graphic /// styles. -fn scale_document(doc: &mut Document, k: f64, styles: bool) { +pub(crate) fn scale_document(doc: &mut Document, k: f64, styles: bool) { let a = Affine::scale(k); let scaling = Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() }; for layer in &mut doc.layers { diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index 7f91438be..a8bbd0837 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -242,9 +242,10 @@ fn place_art(layer: &mut Node, pages: &[Rect], moves: &[Vec2]) { /// A `.ai` saved without its PDF part (its pages show only a placeholder) from its editing copy /// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes. +/// Editing data written on a large canvas comes in at its declared scale: no page holds the size. pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec), String> { let data = ai::decode_private(private)?; - build(&data, None, Page::Artboard, false, false, 1.0) + build_from(ai::read(&data, true)?, &data, None, Page::Artboard, false, false, 1.0) } /// [`ai_alone`] for a `.ai` whose PDF part can't be read, its first page `page` (width and height) From 41477041ea638dec807cb15015432f29737e9beb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 16:19:15 -0800 Subject: [PATCH 21/21] .ai save: the editing copy keeps artboard display marks and dense patterns Each artboard's DisplayMark was written as 0. It now holds the center mark (1), cross hairs (2) and video safe areas (4), as the reader reads them. A pattern fill that would place more tiles than a fill places came back with no tiles and was written as an empty group, so the shape was missing from the editing copy. Such a shape is now filled gray, as a pattern that isn't found is, with a warning. --- crates/eps/src/ai_editing.rs | 4 +++- crates/eps/src/ai_editing/tests.rs | 36 +++++++++++++++++++++++++++++ crates/eps/src/ai_editing/writer.rs | 8 +++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 7ca953703..bdcefb56b 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -366,7 +366,9 @@ fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64)) s.push_str("/RealPoint\n%_ (RulerOrigin) ,\n%_"); let par = ab.video_ruler_pixel_aspect_ratio; ps::push_num(s, if par.is_finite() && par > 0.0 { par } else { 1.0 }); - let _ = writeln!(s, " /Real (PAR) ,\n%_0 /Int (DisplayMark) ,\n%_{} /Bool (IsArtboardSelected) ,", u8::from(i == 0)); + // Display marks: 1 the center mark, 2 cross hairs, 4 video safe areas. + let mark = u8::from(ab.show_center_mark) | u8::from(ab.show_cross_hairs) << 1 | u8::from(ab.show_video_safe_areas) << 2; + let _ = writeln!(s, " /Real (PAR) ,\n%_{mark} /Int (DisplayMark) ,\n%_{} /Bool (IsArtboardSelected) ,", u8::from(i == 0)); let _ = writeln!(s, "%_{} /UnicodeString (Name) ,\n%_; ,", ps::bytes(ab.name.as_bytes())); } s.push_str("%_; (ArtboardArray) ,\n"); diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index cb96fa9ff..5a355fe3d 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -949,6 +949,42 @@ fn a_group_fill_in_a_pattern_tile_is_written() { assert!(all.iter().any(|p| p.appearance.items.iter().any(|i| paint(i.paint()) == red)), "the tiles keep their group's fill"); } +/// A pattern fill that would place more tiles than a fill places is written as the shape filled +/// gray, as a pattern that isn't found is, instead of as nothing. +#[test] +fn a_pattern_fill_of_too_many_tiles_is_written_gray() { + let mut doc = Document::new(1200.0, 1200.0); + let layer = doc.layers[0].id; + let dot = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 0.5, 0.5)), Appearance::basic(solid(Color::BLACK), Paint::None, 0.0)); + let tiny = vectorcraft_doc::PatternDef { tile: Rect::new(0.0, 0.0, 1.0, 1.0), ..vectorcraft_doc::PatternDef::new("Tiny", vec![Arc::new(dot)]) }; + doc.patterns.push(tiny); + let filled = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(100.0, 100.0, 1100.0, 1100.0)), + Appearance::basic(Paint::Pattern { pattern: "Tiny".into(), xf: Affine::IDENTITY }, Paint::None, 0.0), + ); + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + let back = read_back(&doc); + let mut all = vec![]; + paths(back.layers[0].children().unwrap(), &mut all); + let gray = paint(&solid(Color::gray(0.5))); + assert!(all.iter().any(|p| p.appearance.items.iter().any(|i| paint(i.paint()) == gray)), "{}", all.len()); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::PATTERN_TILES), "{:?}", out.warnings); +} + +/// An artboard's center mark, cross hairs and video safe areas are written as the bits of its +/// `DisplayMark` and read back. +#[test] +fn artboard_display_marks_are_written() { + let mut doc = Document::new(200.0, 100.0); + (doc.artboards[0].show_center_mark, doc.artboards[0].show_video_safe_areas) = (true, true); + assert!(program(&doc).contains("%_5 /Int (DisplayMark) ,\n")); + let back = read_back(&doc); + let ab = &back.artboards[0]; + assert!(ab.show_center_mark && !ab.show_cross_hairs && ab.show_video_safe_areas); +} + #[test] fn effects_on_a_fill_are_reported_left_out() { let mut doc = Document::new(200.0, 100.0); diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 325203188..e0afd0366 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -27,6 +27,7 @@ const MAX_AVERAGED_PIXELS: u64 = 1 << 24; pub(super) const TYPE: &str = "type is written as its outlines in the editing data for other apps"; pub(super) const GRADIENTS: &str = "gradients are written as their average color in the editing data for other apps"; const PATTERNS: &str = "pattern fills are written as their tiles cut to the shape in the editing data for other apps"; +pub(super) const PATTERN_TILES: &str = "pattern fills with more tiles than a fill places are written filled gray in the editing data for other apps"; pub(super) const CLIPPING: &str = "clipping groups and clipping layers are written as their art cut to the clipping path in the editing data for other apps"; pub(super) const IMAGES: &str = "images are written as their outlines filled with their average color in the editing data for other apps"; @@ -457,6 +458,13 @@ impl<'a> Writer<'a> { return self.path_object(path, rule, Some(&flat), None); }; let tiles = def.instances_in(*xf, path.to_bezpath().bounding_box()); + // More tiles than a fill places come back as none: the shape is filled gray, as a + // pattern that isn't found is. + if tiles.is_empty() && !def.art.is_empty() { + self.warn(PATTERN_TILES); + let flat = FillLayer { paint: Paint::solid(Color::gray(0.5)), ..f.clone() }; + return self.path_object(path, rule, Some(&flat), None); + } let outer = self.clip.clone(); let region = self.cut(path, rule); self.clip = Some(region);