From b8279befba85bb281597a4eba5fd37c7a0cbe80e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 17:45:42 -0700 Subject: [PATCH] Save As .ai: write the editing data's header, palette and layers in the full layout The editing data now has the layout of the .ai files it was learned from: - Lines end in CR LF. Line breaks inside strings stay escaped. - The header adds %%For (the document's author), %%Canvassize 16383, %AI7_ImageSettings, %AI5_ArtFlags, %AI5_TargetResolution, %AI5_OpenViewLayers (a digit for each layer, 7 shown and 6 hidden) and %AI7_GridSettings (the document's grid spacing and subdivisions), in the order those files have them. - The setup starts with 0 Bn (no gradient definitions). - The palette starts with the Registration swatch: Xs in a CMYK document, Xz with black's CMYK equivalent in an RGB one. Reading the file back does not add a swatch for it. - Each layer and sublayer carries its name as an XML id in an /ArtDictionary after Ln. An object named like a layer gets a numbered id. - The LastModified dates of the PieceInfo dictionaries end in +00'00'. --- crates/eps/src/ai_editing.rs | 65 +++++++++++++++++++----- crates/eps/src/ai_editing/tests.rs | 79 +++++++++++++++++++++++++---- crates/eps/src/ai_editing/writer.rs | 34 +++++++++++-- crates/pdf/src/piece_info.rs | 9 ++-- 4 files changed, 153 insertions(+), 34 deletions(-) diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 0ad1b932a..272e92755 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -8,15 +8,20 @@ //! groups and compound paths (§5.3.5), guides (§6) and layers (§9), with the newer forms the reader //! reads from files users sent: //! -//! - Header comments: `%%BoundingBox`, `%%HiResBoundingBox`, `%AI5_FileFormat 14.0`, the first -//! artboard as `%AI3_Cropmarks` and `%AI5_ArtSize`, `%AI5_RulerUnits`, `%AI9_ColorModel` (1 RGB, 2 -//! CMYK), `%AI5_NumLayers`, and the version comment `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]). -//! - Setup: the color swatches (`%AI5_BeginPalette`, `0 0 Pb`, each swatch's color operator and -//! `(name) Pc`, a color group as `0 (name) count Pg` before its swatches, `PB`), then the -//! artboards as the `ArtboardArray` of the `/Document` dictionary, on `%_` lines. +//! - Header comments: `%%For` (the document's author), `%%Canvassize`, `%%BoundingBox`, +//! `%%HiResBoundingBox`, `%AI5_FileFormat 14.0`, `%AI7_ImageSettings`, the first artboard as +//! `%AI3_Cropmarks` and `%AI5_ArtSize`, `%AI5_RulerUnits`, `%AI9_ColorModel` (1 RGB, 2 CMYK), +//! `%AI5_ArtFlags`, `%AI5_TargetResolution`, `%AI5_NumLayers`, `%AI5_OpenViewLayers` (a digit for +//! each layer, 7 shown and 6 hidden), `%AI7_GridSettings` (§2.1.1), and the version comment +//! `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]). +//! - Setup: `0 Bn` (no gradient definitions, §3), the color swatches (`%AI5_BeginPalette`, +//! `0 0 Pb`, the Registration swatch, each swatch's color operator and `(name) Pc`, a color group as `0 (name) count Pg` +//! before its swatches, `PB`), then the artboards as the `ArtboardArray` of the `/Document` +//! dictionary, on `%_` lines. //! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed 0 1 colorIndex r g b 0 //! dimPercent 0 Lb` (14 operands; a color that isn't one of the preset layer colors has the -//! index -1), `(name) Ln`, its art and sublayers, `LB`, `%AI5_EndLayer--`. +//! index -1), `(name) Ln`, the layer's name as an XML id in an `/ArtDictionary`, its art and +//! sublayers, `LB`, `%AI5_EndLayer--`. //! - Colors: `g` gray, `k` CMYK, `c m y k r g b Xa` RGB (Lab is written as RGB); a spot color //! `c m y k (name) tint x` (CMYK) or `c m y k r g b|L a b (name) tint 1|2 Xx` (RGB or Lab); a //! global process color `… (name) tint model Xk`; Registration `1 1 1 1 ([Registration]) tint @@ -25,7 +30,7 @@ //! an `/ArtDictionary` whose `AI10_ArtUID` is the name as an XML id. Guides are `(N) *`, the //! path, `(N) *` (`(n) *` when closed). //! -//! Strings are written in UTF-8. Art space has y up, its origin at the first artboard's bottom-left +//! Lines end in CR LF. Strings are written in UTF-8. Art space has y up, its origin at the first artboard's bottom-left //! corner. What the form above can't hold is written as plain filled and stroked paths, each such //! loss a warning: type as its outlines, gradients as their average color, pattern fills as their //! tiles cut to the shape, clipping groups and clipping layers as their art cut to the clipping @@ -60,6 +65,16 @@ const ROUNDTRIP_VERSION: u32 = 24; const ROUNDTRIP_STREAM_TYPE: u32 = 2; /// What starts the data streams of Zstandard-compressed data. const ZSTD_MARKER: &[u8] = b"%AI24_ZStandard_Data"; +/// `%%Canvassize`: the side of the canvas in points, as the files users sent have it. +const CANVAS_SIZE: u32 = 16383; +/// `%AI5_ArtFlags` (§2.1.1, Table 4), as the files users sent have them: page setup, print tiles, +/// placed images, preview patterns, split long paths, tile full pages, printer's default +/// screens, auto default screens, compatible gradient printing. +const ART_FLAGS: &str = "0 0 0 1 0 0 1 0 0"; +/// `%AI5_TargetResolution` (§2.1.1). +const TARGET_RESOLUTION: u32 = 800; +/// The gridlines' gray: VectorCraft's default grid color (200 of 255). +const GRID_GRAY: f64 = 200.0 / 255.0; /// The first line of a PostScript file (the DSC version comment). const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header comment @@ -98,7 +113,6 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result Result Vec { + s.replace('\n', "\r\n").into_bytes() } /// `doc` with its geometry effects baked ([`vectorcraft_effects::bake_document`]), in its symbols' @@ -163,17 +184,20 @@ pub fn private_data(doc: &Document, o: &AiEditingOptions) -> Result<(PrivateData /// The header comments, through `%%EndComments`. `art`: the visible art's bounds, `size` the first /// artboard's, in art space. -fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, (width, height): (f64, f64), layers: usize) -> String { +fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, (width, height): (f64, f64)) -> String { let mut s = String::new(); s.push_str(PS_HEADER); s.push('\n'); let _ = writeln!(s, "%%Creator: VectorCraft {}", env!("CARGO_PKG_VERSION")); let _ = writeln!(s, "%%AI8_CreatorVersion: {CREATOR_VERSION}"); + // The user and the organization (§2.1.1). + let _ = writeln!(s, "%%For: {} ()", ps::bytes(doc.metadata.author.as_bytes())); let _ = writeln!(s, "%%Title: {}", ps::bytes(o.title.as_bytes())); if let Some(t) = o.created { let [y, mo, d, h, mi, _] = vectorcraft_doc::metadata::civil(t); let _ = writeln!(s, "%%CreationDate: ({mo}/{d}/{y:04}) ({h:02}:{mi:02})"); } + let _ = writeln!(s, "%%Canvassize: {CANVAS_SIZE}"); let _ = writeln!(s, "%%BoundingBox: {} {} {} {}", art.x0.floor() as i64, art.y0.floor() as i64, art.x1.ceil() as i64, art.y1.ceil() as i64); s.push_str("%%HiResBoundingBox: "); ps::push_nums(&mut s, &[art.x0, art.y0, art.x1, art.y1]); @@ -181,6 +205,8 @@ fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, (width, h s.push('\n'); s.push_str("%%DocumentProcessColors: Cyan Magenta Yellow Black\n"); s.push_str("%AI5_FileFormat 14.0\n%AI3_ColorUsage: Color\n"); + // Linked images aren't embedded (the data holds no images). + s.push_str("%AI7_ImageSettings: 0\n"); let spots = w.spot_swatches(); for (i, (name, _)) in spots.iter().enumerate() { let _ = writeln!(s, "{} {}", if i == 0 { "%%DocumentCustomColors:" } else { "%%+" }, ps::bytes(name.as_bytes())); @@ -201,7 +227,18 @@ fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, (width, h s.push_str("%AI24_LargeCanvasScale: 1\n"); let model = if doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { 2 } else { 1 }; let _ = writeln!(s, "%AI9_ColorModel: {model}"); - let _ = writeln!(s, "%AI5_NumLayers: {layers}"); + let _ = writeln!(s, "%AI5_ArtFlags: {ART_FLAGS}"); + let _ = writeln!(s, "%AI5_TargetResolution: {TARGET_RESOLUTION}"); + let _ = writeln!(s, "%AI5_NumLayers: {}", doc.layers.len()); + // A digit for each layer: 7 shown, 6 hidden. + let views: String = doc.layers.iter().map(|l| if l.visible { '7' } else { '6' }).collect(); + if !views.is_empty() { + let _ = writeln!(s, "%AI5_OpenViewLayers: {views}"); + } + // Gridlines and subdivisions (§2.1.1, Table 6): in back, lines, in VectorCraft's grid gray. + let (space, subdiv) = (ps::num(doc.grid.spacing), doc.grid.subdivisions); + let gray = ps::num(GRID_GRAY); + let _ = writeln!(s, "%AI7_GridSettings: {space} {subdiv} {space} {subdiv} 1 0 {gray} {gray} {gray} {gray} {gray} {gray}"); s.push_str("%%EndComments\n"); s } diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 0490ae391..e0d0b2d68 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -16,9 +16,9 @@ fn opts() -> AiEditingOptions { AiEditingOptions { title: "Sample".into(), created: Some(0) } } -/// The program written from `doc`, as text. +/// The program written from `doc`, as text with its CR LF line ends read as LF. fn program(doc: &Document) -> String { - String::from_utf8(export_ai_editing(doc, &opts()).unwrap().program).unwrap() + String::from_utf8(export_ai_editing(doc, &opts()).unwrap().program).unwrap().replace("\r\n", "\n") } /// `doc` written, then read back from the private data alone. @@ -266,7 +266,8 @@ fn writing_again_gives_the_same_bytes() { assert_eq!(first.program, export_ai_editing(&doc, &opts()).unwrap().program); assert_eq!(private_data(&doc, &opts()).unwrap().0, private_data(&doc, &opts()).unwrap().0); // What the reader reads back is written as the same program. - assert_eq!(program(&read_back(&doc)), String::from_utf8(first.program).unwrap()); + let read = export_ai_editing(&read_back(&doc), &opts()).unwrap(); + assert_eq!(read.program, first.program); assert!(first.warnings.is_empty(), "{:?}", first.warnings); } @@ -283,17 +284,18 @@ fn the_data_is_zstandard_compressed_with_the_header_as_metadata() { assert!(out.program.starts_with(&out.header)); assert_eq!(p.metadata, out.header); let header = String::from_utf8(out.header).unwrap(); - assert!(header.ends_with("%%EndComments\n")); + assert!(header.ends_with("%%EndComments\r\n")); + let header = header.replace("\r\n", "\n"); for line in [ - "%%AI8_CreatorVersion: 30.7.0\n", - "%AI5_FileFormat 14.0\n", + "%%AI8_CreatorVersion: 30.7.0\n%%For: () ()\n%%Title: (Sample)\n", + "%%Canvassize: 16383\n", + "%AI5_FileFormat 14.0\n%AI3_ColorUsage: Color\n%AI7_ImageSettings: 0\n%%DocumentCustomColors: (Gold)\n%%+ (Sky)\n%%+ (Mint)\n", "%AI3_Cropmarks: 0 0 200 100\n", "%AI5_ArtSize: 200 100\n", - "%AI9_ColorModel: 1\n", - "%AI5_NumLayers: 2\n", - "%%DocumentCustomColors: (Gold)\n%%+ (Sky)\n%%+ (Mint)\n", + "%AI9_ColorModel: 1\n%AI5_ArtFlags: 0 0 0 1 0 0 1 0 0\n%AI5_TargetResolution: 800\n%AI5_NumLayers: 2\n", + "%AI5_OpenViewLayers: 77\n%AI7_GridSettings: 72 8 72 8 1 0 0.7843 0.7843 0.7843 0.7843 0.7843 0.7843\n%%EndComments\n", ] { - assert!(header.contains(line), "{line}"); + assert!(header.contains(line), "{line}\n{header}"); } assert_eq!((p.container_version, p.creator_version, p.roundtrip_version, p.roundtrip_stream_type), (12, 30, 24, 2)); } @@ -328,7 +330,7 @@ fn many_objects_of_one_name_are_numbered_in_turn() { doc.insert(Some(layer), usize::MAX, n).unwrap(); } let text = program(&doc); - let ids: std::collections::HashSet<&str> = text.lines().filter(|l| l.starts_with("%_/XMLUID")).collect(); + let ids: std::collections::HashSet<&str> = text.lines().filter(|l| l.starts_with("%_/XMLUID : (Dot")).collect(); assert_eq!(ids.len(), 20_000); assert!(text.contains("(Dot_00000000000000019999_)")); } @@ -340,6 +342,61 @@ fn layers_have_fourteen_operands() { assert!(text.contains("%AI5_BeginLayer\n0 1 0 0 1 0 1 -1 10 200 30 0 70 0 Lb\n(Sub) Ln\n"), "{text}"); } +#[test] +fn layers_are_named_as_xml_ids_after_their_names() { + let mut doc = sample(); + let layer = doc.layers[0].id; + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default()); + n.name = Some("Back".into()); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let text = program(&doc); + assert!(text.contains("(Back) Ln\n%_/ArtDictionary :\n%_/XMLUID : (Back) ; (AI10_ArtUID) ,\n%_;\n%_\n"), "{text}"); + assert!(text.contains("(Sub) Ln\n%_/ArtDictionary :\n%_/XMLUID : (Sub) ; (AI10_ArtUID) ,\n"), "{text}"); + // An object named as a layer is numbered. + assert!(text.contains("%_/XMLUID : (Back_00000000000000000001_) ; (AI10_ArtUID) ,"), "{text}"); + // The reader keeps the names where they were. + let read = read_back(&doc); + assert_eq!(read.layers[0].name.as_deref(), Some("Back")); + let names: Vec> = read.layers[0].children().unwrap().iter().map(|c| c.name.as_deref()).collect(); + assert_eq!(names.last(), Some(&Some("Back"))); +} + +#[test] +fn lines_end_in_cr_lf() { + let mut doc = sample(); + doc.metadata.author = "A\nB".into(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let text = String::from_utf8(out.program).unwrap(); + assert_eq!(text.matches('\n').count(), text.matches("\r\n").count()); + assert_eq!(text.matches('\r').count(), text.matches("\r\n").count()); + // A line break in a string stays in the string, escaped. + assert!(text.contains("%%For: (A\\nB) ()\r\n"), "{text}"); +} + +#[test] +fn the_palette_starts_with_registration() { + let mut doc = sample(); + let rgb = program(&doc); + assert!(rgb.contains("0 0 Pb\n"), "{rgb}"); + let cell = rgb.split("0 0 Pb\n").nth(1).unwrap().lines().take(2).collect::>(); + assert!(cell[0].ends_with(" 0 0 0 ([Registration]) 0 1 Xz") && cell[0].split(' ').count() == 11, "{cell:?}"); + assert_eq!(cell[1], "([Registration]) Pc"); + doc.color_mode = vectorcraft_doc::ColorMode::Cmyk; + assert!(program(&doc).contains("0 0 Pb\n1 1 1 1 ([Registration]) 0 Xs\n([Registration]) Pc\n")); + // It's the built-in swatch: reading back adds no swatch. + assert_eq!(swatches(&read_back(&doc)), swatches(&doc)); +} + +#[test] +fn hidden_layers_open_hidden() { + let mut doc = sample(); + let mut hidden = Node::layer(doc.alloc_id(), "Off", LayerColor::Preset(1)); + hidden.visible = false; + doc.layers.push(Arc::new(hidden)); + let text = program(&doc); + assert!(text.contains("%AI5_NumLayers: 3\n%AI5_OpenViewLayers: 776\n"), "{text}"); +} + /// A 200 × 100 document of what slice 1 writes as plain paths: a gradient fill, a clipping group, /// a compound path, an image, type, a symbol instance and a half-transparent square. fn unsupported() -> Document { diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index ff0d6ed77..39a19b4c1 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -113,7 +113,7 @@ impl<'a> Writer<'a> { } } - /// A layer or sublayer: `%AI5_BeginLayer`, its options `Lb`, its name `Ln`, its art, then + /// A layer or sublayer: `%AI5_BeginLayer`, its options `Lb`, its name `Ln` and as an XML id, its art, then /// `after` (outside its clipping path when it clips), `LB`, `%AI5_EndLayer--`. fn write_layer(&mut self, n: &Node, after: &[Arc]) { let NodeKind::Layer { color, template, printable, children, clip, preview, dim_images } = &n.kind else { return }; @@ -131,6 +131,7 @@ impl<'a> Writer<'a> { self.transparency(n); let flag = |b: bool| u8::from(b); let dim = dim_images.unwrap_or(50); + let name = n.name.as_deref().unwrap_or("Layer"); let _ = writeln!( self.body, "%AI5_BeginLayer\n{} {} {} {} {} 0 1 {index} {r} {g} {b} 0 {dim} 0 Lb\n{} Ln", @@ -139,8 +140,9 @@ impl<'a> Writer<'a> { flag(!n.locked), flag(*printable && !*template), flag(dim_images.is_some() || *template), - ps::bytes(n.name.as_deref().unwrap_or("Layer").as_bytes()), + ps::bytes(name.as_bytes()), ); + self.xml_id(if name.is_empty() { "Layer" } else { name }); self.state = None; if *clip { self.warn(CLIPPING); @@ -269,10 +271,15 @@ impl<'a> Writer<'a> { } } - /// `n`'s name, after it: an `/ArtDictionary` whose `AI10_ArtUID` is the name as an XML id, - /// numbered (`_` and 20 digits, `_`) when another object has that id. + /// `n`'s name, after it (see [`Self::xml_id`]). fn name(&mut self, n: &Node) { let Some(name) = n.name.as_deref().filter(|s| !s.is_empty()) else { return }; + self.xml_id(name); + } + + /// An `/ArtDictionary` whose `AI10_ArtUID` is `name` as an XML id, numbered (`_` and 20 + /// digits, `_`) when a layer or another object has that id. + fn xml_id(&mut self, name: &str) { let base = vectorcraft_doc::xml_id::from_object_name(name); let mut id = base.clone(); let next = self.next.entry(base.clone()).or_insert(0); @@ -604,11 +611,12 @@ impl<'a> Writer<'a> { // ---------- swatches ---------- - /// The swatches: `%AI5_BeginPalette`, `0 0 Pb`, each color swatch's color and `(name) Pc` + /// The swatches: `%AI5_BeginPalette`, `0 0 Pb`, the Registration swatch, each color swatch's color and `(name) Pc` /// (the ungrouped ones first, then each color group's after `0 (name) count Pg`), `PB`, /// `%AI5_EndPalette`. pub(super) fn palette(&mut self, s: &mut String) { s.push_str("%AI5_BeginPalette\n0 0 Pb\n"); + self.registration_cell(s); let doc = self.doc; for sw in doc.swatches.iter() { self.swatch(s, sw); @@ -623,6 +631,22 @@ impl<'a> Writer<'a> { s.push_str("PB\n%AI5_EndPalette\n"); } + /// The Registration swatch, which every palette in the files users sent starts with: in a + /// CMYK document `1 1 1 1 ([Registration]) 0 Xs`, in an RGB one black's CMYK equivalent, + /// `0 0 0 ([Registration]) 0 1 Xz`; then `([Registration]) Pc`. + fn registration_cell(&self, s: &mut String) { + let name = ps::bytes(REGISTRATION.as_bytes()); + if self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { + let _ = writeln!(s, "1 1 1 1 {name} 0 Xs"); + } else { + let black = Color::rgb(0.0, 0.0, 0.0); + let cmyk = self.cms.to_cmyk(&black, self.cms.settings().intent).map(unit); + ps::push_nums(s, &cmyk.map(f64::from)); + let _ = writeln!(s, "0 0 0 {name} 0 1 Xz"); + } + let _ = writeln!(s, "{name} Pc"); + } + /// One swatch of the palette: a color swatch's color and `(name) Pc`; gradient and pattern /// swatches are left out. fn swatch(&mut self, s: &mut String, sw: &Swatch) { diff --git a/crates/pdf/src/piece_info.rs b/crates/pdf/src/piece_info.rs index c6e457ef3..157f36c5c 100644 --- a/crates/pdf/src/piece_info.rs +++ b/crates/pdf/src/piece_info.rs @@ -72,10 +72,11 @@ fn stream(bytes: &[u8]) -> Vec { s } -/// `at` (Unix seconds) as a PDF date string in UTC (ISO 32000-1 §7.9.4). +/// `at` (Unix seconds) as a PDF date string in UTC (ISO 32000-1 §7.9.4), with the offset written +/// as hours and minutes, as in the files users sent. fn pdf_date(at: i64) -> String { let [y, mo, d, h, mi, s] = vectorcraft_doc::metadata::civil(at); - format!("(D:{y:04}{mo:02}{d:02}{h:02}{mi:02}{s:02}Z)") + format!("(D:{y:04}{mo:02}{d:02}{h:02}{mi:02}{s:02}+00'00')") } #[cfg(test)] @@ -120,7 +121,7 @@ mod tests { #[test] fn dates_are_in_utc() { - assert_eq!(pdf_date(0), "(D:19700101000000Z)"); - assert_eq!(pdf_date(1_793_000_000), "(D:20261026073320Z)"); + assert_eq!(pdf_date(0), "(D:19700101000000+00'00')"); + assert_eq!(pdf_date(1_793_000_000), "(D:20261026073320+00'00')"); } }