From b4e28e504a0b5a6ed9b07bb53498da8e266180ed Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 08:53:28 -0700 Subject: [PATCH] .ai save: write the artboards in the recorded part of the document data The editing data put ArtboardArray and AIDocumentCanvasSize in the part of the /Document dictionary marked /NotRecorded. Apps that read the editing data do not read artboards from there: a file opened with one default artboard the size of the first, the other artboards and the names were lost, and the art landed one artboard's height above it. The two entries now close a /Recorded part, followed by an empty /NotRecorded one, as in the files users sent. --- crates/eps/src/ai_editing.rs | 10 +++-- crates/eps/src/ai_editing/tests.rs | 70 ++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index e89584b8e..0ca9958b5 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -19,7 +19,8 @@ //! - Setup: `0 Bn` (no gradient definitions, ยง3), the color swatches (`%AI5_BeginPalette`, //! `0 0 Pb`, the Registration swatch, each swatch's color operator and `(name) Pc`, a color group as `0 (name) count Pg` //! before its swatches, `PB`), then the artboards as the `ArtboardArray` of the `/Document` -//! dictionary and the canvas size as its `AIDocumentCanvasSize`, on `%_` lines. +//! dictionary's `/Recorded` part and the canvas size as its `AIDocumentCanvasSize`, on `%_` +//! lines. //! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed 0 1 colorIndex r g b 0 //! dimPercent 0 Lb` (14 operands; a color that isn't one of the preset layer colors has the //! index -1), `(name) Ln`, the layer's name as an XML id in an `/ArtDictionary`, its art and @@ -304,7 +305,10 @@ fn ruler_units(u: Unit) -> u8 { /// the canvas (`RulerOrigin`, `origin` being art space's origin there), its pixel aspect ratio /// (`PAR`), `DisplayMark` 0 (as in the files users sent), whether it is the selected one (the /// first) and its name; then the canvas's size. Apps that read the editing data drop a record that -/// lacks any of the first five. +/// lacks any of the first five. Both sit in the dictionary the `/Document` marks `/Recorded`, +/// followed by an empty `/NotRecorded` one, as in the files users sent: apps that read the editing +/// data do not read artboards from the `/NotRecorded` dictionary, and then place the art one +/// artboard's height above a default artboard. fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64)) { s.push_str("%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n%_/Array :\n"); for (i, ab) in doc.artboards.iter().enumerate() { @@ -323,7 +327,7 @@ fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64)) } s.push_str("%_; (ArtboardArray) ,\n"); let _ = writeln!(s, "%_{CANVAS_SIZE} /Int (AIDocumentCanvasSize) ,"); - s.push_str("%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n"); + s.push_str("%_; /Recorded ,\n%_/Dictionary : /NotRecorded ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n"); } #[cfg(test)] diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index bede50515..0fe80683b 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -342,6 +342,76 @@ fn art_lies_on_its_artboard_in_ruler_space() { assert_eq!(bounds, Rect::new(10.0, 20.0, 30.0, 40.0)); } +/// A gray rectangle at `r` (document space) in `doc`'s first layer. +fn add_rect(doc: &mut Document, r: Rect) { + let rect = Node::path(doc.alloc_id(), shapes::rectangle(r), Appearance::basic(solid(Color::gray(0.5)), Paint::None, 0.0)); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, rect).unwrap(); +} + +/// The end of the document data: the artboards and the canvas size in the `/Recorded` part of the +/// `/Document` dictionary, then an empty `/NotRecorded` part. +const RECORDED: &str = "%_; (ArtboardArray) ,\n%_16383 /Int (AIDocumentCanvasSize) ,\n%_; /Recorded ,\n%_/Dictionary : /NotRecorded ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n"; + +/// One letter-size artboard: the art 36 points in from its top-left corner, the artboard's record +/// and the boxes, with the artboards in the `/Recorded` part of the `/Document` dictionary. +#[test] +fn one_artboard_is_recorded_with_its_art_in_place() { + let mut doc = Document::new(612.0, 792.0); + doc.artboards[0].name = "Page".into(); + add_rect(&mut doc, Rect::new(36.0, 36.0, 576.0, 756.0)); + let text = program(&doc); + for line in [ + "%%BoundingBox: 36 -756 576 -36\n", + "%AI3_Cropmarks: 0 -792 612 0\n", + "%AI3_TemplateBox: 306.5 -396.5 306.5 -396.5\n", + "\n36 -36 m\n", + "%_0 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_612 -792 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_7885 7795 /RealPoint\n%_ (RulerOrigin) ,\n", + "%_(Page) /UnicodeString (Name) ,\n%_; ,\n", + RECORDED, + ] { + assert!(text.contains(line), "{line}\n{text}"); + } + let read = read_back(&doc); + assert_eq!(read.artboards.iter().map(|a| (a.name.as_str(), a.rect)).collect::>(), [("Page", Rect::new(0.0, 0.0, 612.0, 792.0))]); + let bounds = read.layers.iter().filter_map(|l| l.visual_bounds()).reduce(|a, b| a.union(b)).unwrap(); + assert_eq!(bounds, Rect::new(36.0, 36.0, 576.0, 756.0)); +} + +/// Two named artboards of different sizes, tops aligned: each record holds its corners in art +/// space and its top-left corner on the canvas, where the two together are centered. +#[test] +fn artboards_of_different_sizes_keep_their_names_and_places() { + let mut doc = Document::new(400.0, 300.0); + doc.artboards[0].name = "Front".into(); + let mut back = doc.artboards[0].clone(); + (back.id, back.name, back.rect) = (2, "Back".into(), Rect::new(440.0, 0.0, 740.0, 500.0)); + doc.artboards.push(back); + add_rect(&mut doc, Rect::new(50.0, 50.0, 150.0, 100.0)); + add_rect(&mut doc, Rect::new(490.0, 50.0, 590.0, 100.0)); + let text = program(&doc); + for line in [ + "%AI3_Cropmarks: 0 -300 400 0\n", + "%AI3_TemplateBox: 370.5 -250.5 370.5 -250.5\n", + "\n50 -50 m\n", + "\n490 -50 m\n", + "%_0 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_400 -300 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_7821 7941 /RealPoint\n%_ (RulerOrigin) ,\n", + "%_1 /Bool (IsArtboardSelected) ,\n%_(Front) /UnicodeString (Name) ,\n", + "%_440 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_740 -500 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_8261 7941 /RealPoint\n%_ (RulerOrigin) ,\n", + "%_0 /Bool (IsArtboardSelected) ,\n%_(Back) /UnicodeString (Name) ,\n", + RECORDED, + ] { + assert!(text.contains(line), "{line}\n{text}"); + } + let read = read_back(&doc); + assert_eq!( + read.artboards.iter().map(|a| (a.name.as_str(), a.rect)).collect::>(), + [("Front", Rect::new(0.0, 0.0, 400.0, 300.0)), ("Back", Rect::new(440.0, 0.0, 740.0, 500.0))] + ); + let bounds = read.layers.iter().filter_map(|l| l.visual_bounds()).reduce(|a, b| a.union(b)).unwrap(); + assert_eq!(bounds, Rect::new(50.0, 50.0, 590.0, 100.0)); +} + #[test] fn a_pdf_carries_the_data_on_its_first_page() { let doc = sample();