diff --git a/Cargo.lock b/Cargo.lock index 6bb070f05..ae8a3ee6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6284,6 +6284,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 a318616d5..7aa436f4e 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -333,9 +333,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 6c2b00258..1331d6f89 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -105,6 +105,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 2a3829acd..812b8ac97 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 every 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)?; // The pages' background colors, behind the art (the editing data keeps them as colors). let pages = opts.artboards.clone().unwrap_or_else(|| (0..doc.artboards.len()).collect()); @@ -295,6 +302,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 80575c23f..e646c43f8 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,14 +95,14 @@ 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 - /// 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/save.rs b/crates/engine/src/cmd/fileio/save.rs index 89339850e..cb513e316 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -576,6 +576,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"; /// `f` (`.ai` or `.ait`) named, in its option errors, as a file saved without PDF content. fn without_pdf(f: &Format) -> Format { @@ -605,6 +608,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 `.ai` files (and `.ait` templates, the same file): /// PDF-compatible by default, without PDF content (`pdfCompatible: false`), or legacy version 7 /// `.ai` files (`aiVersion: "7"`). @@ -636,16 +647,46 @@ 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 carries_editing = std::cell::Cell::new(false); + 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, mut resolved_warnings): (&Document, Vec) = if pdf_compatible { + (&pages, Vec::new()) + } else { + let (d, w) = crate::cmd::place::document::full_documents(&edited); + 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))?; + // 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/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(); diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs index 35d394540..7b9f9774f 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfedit.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs @@ -403,3 +403,77 @@ 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()); +} + +/// 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]); +} 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] diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 08071df05..243dda722 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -290,6 +290,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() { diff --git a/crates/engine/src/tests/saveoptions.rs b/crates/engine/src/tests/saveoptions.rs index 2a55357eb..130a7da18 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(); 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..bdcefb56b --- /dev/null +++ b/crates/eps/src/ai_editing.rs @@ -0,0 +1,380 @@ +//! The editing copy of the art that a PDF-compatible `.ai` file carries in its pages' +//! `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: `%%Creator` (naming the format's app, see [`CREATOR_TEXT`]), `%%For` (the +//! 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`, `%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`]). +//! - 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'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 +//! 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). +//! +//! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at +//! 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 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 (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 +//! 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"; +/// `%%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, +/// 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; + +/// `%%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 + +/// 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); + // Artboards that don't fit on the canvas are written on a large canvas, at 1/scale. + let scale = canvas_scale(doc); + let reduced = (scale != 1.0).then(|| { + let mut d = doc.clone(); + crate::import::scale_document(&mut d, 1.0 / scale, true); + // Symbol and pattern art is placed without scaling its strokes, so its strokes and effects + // are scaled here. + for s in d.symbols.iter_mut() { + scale_strokes(Arc::make_mut(&mut s.art), 1.0 / scale); + } + for p in d.patterns.iter_mut() { + for a in &mut p.art { + scale_strokes(Arc::make_mut(a), 1.0 / scale); + } + } + d + }); + let doc = reduced.as_ref().unwrap_or(doc); + let board = doc.artboards.first().map_or(board, |a| a.rect); + // The document is y down; art space is ruler space: y up, its origin the first artboard's + // top-left corner. + let to_art = Affine::new([1.0, 0.0, 0.0, -1.0, -board.x0, board.y0]); + let mut w = Writer::new(doc, to_art); + for layer in &doc.layers { + w.layer(layer); + } + let first = to_art.transform_rect_bbox(board); + 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(first); + let origin = ruler_origin(doc, to_art); + let header = header(doc, o, &w, art, first, origin, scale); + let mut s = header.clone(); + // No gradient definitions: gradients are written as their average color. + s.push_str("%%BeginProlog\n%%EndProlog\n%%BeginSetup\n0 Bn\n"); + w.palette(&mut s); + artboards(&mut s, doc, to_art, origin); + s.push_str("%%EndSetup\n"); + s.push_str(&w.body); + s.push_str("%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n"); + // Lines end in CR LF, as in the files users sent; line breaks inside strings are escaped. + Ok(AiEditingOutput { program: crlf(&s), header: crlf(&header), warnings: w.warnings }) +} + +/// `s` with each line ending in CR LF. +fn crlf(s: &str) -> Vec { + s.replace('\n', "\r\n").into_bytes() +} + +/// `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); + 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, then each pattern's art, baked as the layers of a copy of the document. + let mut art = d.clone(); + 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 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) +} + +/// 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> { + 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)) +} + +/// 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()) +} + +/// `%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`]); `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'); + 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())); + 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]); + 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"); + // 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())); + } + 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())); + } + // 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: {board}"); + 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)); + 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}"); + 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 +} + +/// `%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 | Unit::Q | Unit::Ha => 2, + } +} + +/// The artboards as the `ArtboardArray` of the `/Document` dictionary: each one's top-left and +/// 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. 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() { + 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%_"); + 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 }); + // 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"); + let _ = writeln!(s, "%_{CANVAS_SIZE} /Int (AIDocumentCanvasSize) ,"); + s.push_str("%_; /Recorded ,\n%_/Dictionary : /NotRecorded ,\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..5a355fe3d --- /dev/null +++ b/crates/eps/src/ai_editing/tests.rs @@ -0,0 +1,1031 @@ +//! 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 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().replace("\r\n", "\n") +} + +/// `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. + let read = export_ai_editing(&read_back(&doc), &opts()).unwrap(); + assert_eq!(read.program, first.program); + 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\r\n")); + let header = header.replace("\r\n", "\n"); + for line in [ + "%%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 -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", + ] { + 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)); +} + +/// 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 (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}"); + 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)); +} + +/// 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)); +} + +/// 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(); + 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 : (Dot")).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}"); +} + +#[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 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(); + 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 { + 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 { 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))); + 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 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 { 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)); + 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"); +} + +/// 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] +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 { 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 { 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(); + 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); + 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 { 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); + 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 { 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); + 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 { 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); + 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 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"); +} + +/// 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); + 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..e0afd0366 --- /dev/null +++ b/crates/eps/src/ai_editing/writer.rs @@ -0,0 +1,819 @@ +//! 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 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"; +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, 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"; +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> { + 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>, + /// The objects of the symbol art written so far, within [`vectorcraft_doc::MAX_SYMBOL_NODES`]. + symbol_nodes: u64, +} + +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, + 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()); + } + } + + 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` 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 }; + 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 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", + flag(n.visible), + flag(*preview), + flag(!n.locked), + flag(*printable && !*template), + flag(dim_images.is_some() || *template), + ps::bytes(name.as_bytes()), + ); + self.xml_id(if name.is_empty() { "Layer" } else { name }); + 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"); + let doc = self.doc; + if let Some(sym) = doc.symbols.iter().find(|s| &s.name == symbol) + && self.nest < MAX_NEST + { + // 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; + // 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"); + } + 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 (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); + 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()); + // 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); + 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; 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"); + 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 }; + 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. + 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"); + } + + // ---------- 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`, 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); + } + 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"); + } + + /// 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) { + 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/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 07e88e540..350a0ee42 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 @@ -1142,6 +1146,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 @@ -1195,7 +1216,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) diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 2e8f5e03e..290fe3c41 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -65,13 +65,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 b8055c32f..ee8de7c16 100644 --- a/crates/eps/src/legacy_ai/encoding.rs +++ b/crates/eps/src/legacy_ai/encoding.rs @@ -1,7 +1,7 @@ use super::writer::Writer; use super::{ - Affine, AreaOptions, BezPath, CharPosition, CharStyle, Color, Gradient, GradientKind, Paint, ParaStyle, PathEffect, StrokeLayer, TextKind, - TextObject, Write, latin, ps, stroke, + Affine, AreaOptions, BezPath, CharPosition, CharStyle, Gradient, GradientKind, Paint, ParaStyle, PathEffect, StrokeLayer, TextKind, TextObject, + Write, latin, ps, stroke, }; /// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd`, `[`, the stops @@ -63,9 +63,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 { @@ -189,10 +187,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 73a6e5842..1fe58d659 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -3,11 +3,11 @@ use super::encoding::{ unit, writable, }; use super::{ - Affine, AnchorKind, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, - Gradient, GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE, - LAYER_COLORS, LAYER_NAME, LIVE, LayerColor, LegacyAiOptions, LineCap, LineJoin, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, - Paint, ParaDirection, ParaStyle, PathData, Point, ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, - TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, latin, ps, stroke, + Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, Gradient, + GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE, + LAYER_COLORS, LAYER_NAME, LIVE, LayerColor, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection, + ParaStyle, PathData, ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, + TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke, }; pub(super) struct Writer<'a> { @@ -363,13 +363,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 @@ -528,71 +524,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 cbeed588e..8fe04326c 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, ai_alone_on_page, family_style, import, import_trusting, import_with, is_loss, layered_ai, page, }; diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 8a8bd10e0..9c52e1999 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::{IllustratorData, PageInfo, PdfInfo, has_illustrator_data, illustrator_data, illustrator_data_without_pdf_part, 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 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). 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..60cd80228 --- /dev/null +++ b/crates/pdf/src/piece_info.rs @@ -0,0 +1,158 @@ +//! 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. +//! +//! - 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), +//! `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 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)?; + 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(); + 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()); + for dict in dicts { + 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), 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}+00'00')") +} + +#[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 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(); + 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:19700101000000+00'00')"); + assert_eq!(pdf_date(1_793_000_000), "(D:20261026073320+00'00')"); + } +} 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 00811a08b..3d0cb1442 100644 --- a/crates/ui-egui/src/dialogs/save_options.rs +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -105,6 +105,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 e0b464671..4977e2599 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3501,6 +3501,7 @@ digits Malayalam Малаялам 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/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 0a730a8bb..e11ef427d 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -3635,6 +3635,7 @@ theme Appearance Izgled Type Object Selection by Path Only Odabir tekstualnog objekta samo putanjom UI Scaling Skaliranje interfejsa Use Compression Koristi kompresiju + Include Editing Data for Other Apps Uključi podatke za uređivanje za druge aplikacije Use Japanese Crop Marks Koristi japanske oznake za izrezivanje Use Low Resolution Proxy for Linked EPS Koristi proksi niske rezolucije za povezani EPS Use Precise Cursors Koristi precizne kursore diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index c54c8b11d..1fc553a28 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -4508,6 +4508,7 @@ digits Malayalam Malaiàlam Use Artboards Utilitza els taulers Use Black Point Compensation Utilitza la compensació del punt negre Use Compression Utilitza la compressió + Include Editing Data for Other Apps Inclou les dades d’edició per a altres aplicacions Use Default Utilitza el valor per defecte Use document bleed settings Utilitza la configuració de sangrat del document Use Document Raster Effects Resolution Utilitza la resolució dels efectes de ràster del document diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3e535f31d..644310374 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3677,6 +3677,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 dd3b54a4e..5f4a70ae4 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3404,6 +3404,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 4956d08d4..2038450a2 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3529,6 +3529,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 d73cddb44..807866d6e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3871,6 +3871,7 @@ digits Malayalam Μαλαγιάλαμ 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 b43199c7e..cc2f82edc 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3585,6 +3585,7 @@ digits Malayalam Malayálam 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 b211b4c48..b03abbcd0 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3601,6 +3601,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 d80d26230..935dc6f07 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3577,6 +3577,7 @@ digits Malayalam Malayalam 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 5e5fae204..363db7e26 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4298,6 +4298,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 987d8db0d..7faf03f43 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3404,6 +3404,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 1f43f530b..1c34cd4df 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3622,6 +3622,7 @@ digits Malayalam Malayalam 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 6c3b72869..f1cf9a537 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3280,6 +3280,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 f85b3bcd5..67836865c 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3214,6 +3214,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 7a83c0316..bc96cb447 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3269,6 +3269,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 8d1deb504..ddaa5df32 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3418,6 +3418,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 c3ba69653..8dd10bf76 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3540,6 +3540,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 e0af75dda..33a3f312f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2947,6 +2947,7 @@ digits Malayalam Malaiala 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 b37b9a319..08c587c20 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3617,6 +3617,7 @@ digits Malayalam Малаялам 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 ab678406c..b9112257c 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3507,6 +3507,7 @@ digits Malayalam Malayalam 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 78ffc5ae7..df46c7f2b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3397,6 +3397,7 @@ digits Malayalam Malayalam 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 87a8a1a61..7d0aed742 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3615,6 +3615,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 3856eef44..eddc7d529 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3990,6 +3990,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 78542f8a2..72c26ec25 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3457,6 +3457,7 @@ digits Malayalam 马拉雅拉姆数字 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 a7bcd23d8..4b9a52bf0 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4479,6 +4479,7 @@ digits Malayalam 馬來亞拉姆數字 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/docs/file-format-parity.md b/docs/file-format-parity.md index be0bc3596..7a90a0d53 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 | 29 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, PICT, 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 | @@ -58,8 +58,13 @@ private editing data. VectorCraft: When the PDF part of either can't be read (a stream the import refuses), the file opens from its editing data alone, with the PDF part's error as its first warning, and without the bleed and File Info the PDF part would give (part of #4366). -- **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