From 9cd5e3df8f775784f9344dbcefd20e8caa1a47e9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 21:35:06 -0700 Subject: [PATCH] Save As .ai: place the editing data's art on its artboard and carry it on every page The editing data wrote art space with its origin at the first artboard's bottom-left corner, its artboard records lacked keys a reader requires, and its %%Creator line made the reader measure %AI3_TemplateBox on an 8640-point canvas. Other apps opened the art 3,871.5 points away from its artboard. Art space is now ruler space, as in the .ai files users sent: y up from the first artboard's top-left corner. The artboards together are centered on the 16383-point canvas. Each artboard's record gives its top-left corner there (RulerOrigin), its pixel aspect ratio (PAR), DisplayMark and whether it is selected, and %AI3_TemplateBox holds the canvas's center. %%Creator names the format's app and holds no version number. %AI3_Cropmarks and %AI3_TileBox give the first artboard, %AI5_ArtSize is 14400 14400, and the /Document dictionary gives the canvas size. A file with several artboards carried the data on its first page only, and other apps opened it as a PDF, page by page. Every page now refers to the one data dictionary, as the files users sent do. The format details come from users' own .ai files, static analysis of Illustrator's program and the user's manual testing in Illustrator. --- crates/engine/src/cmd/fileio/pdf.rs | 2 +- crates/eps/src/ai_editing.rs | 99 +++++++++++++++++++++-------- crates/eps/src/ai_editing/tests.rs | 46 +++++++++++++- crates/pdf/src/lib.rs | 2 +- crates/pdf/src/piece_info.rs | 45 +++++++++++-- 5 files changed, 156 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 641aa0845..14b692e73 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -282,7 +282,7 @@ fn overflow_warning(cmd: &str, doc: &Document, p: &Value) -> Result Result Result Option { Some(out) } -/// The editing data of `doc` as a `.ai` file's first page carries it: the program compressed with +/// The editing data of `doc` as a `.ai` file's pages carry it: the program compressed with /// Zstandard after `%AI24_ZStandard_Data`, its header as the `AIMetaData`, and the version numbers /// that go with that form; and what it couldn't hold. pub fn private_data(doc: &Document, o: &AiEditingOptions) -> Result<(PrivateData, Vec), String> { @@ -182,13 +197,28 @@ pub fn private_data(doc: &Document, o: &AiEditingOptions) -> Result<(PrivateData Ok((p, out.warnings)) } -/// The header comments, through `%%EndComments`. `art`: the visible art's bounds, `size` the first -/// artboard's, in art space. -fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, (width, height): (f64, f64)) -> String { +/// Where art space's origin lies on the canvas (y down, [`CANVAS_SIZE`] points square): the +/// artboards together centered on the canvas's center, on whole points, as the files users sent +/// place them. +fn ruler_origin(doc: &Document, to_art: Affine) -> (f64, f64) { + let all = doc.artboards.iter().map(|ab| to_art.transform_rect_bbox(ab.rect)).filter(|r| r.is_finite()).reduce(|a, b| a.union(b)); + let c = all.map_or((0.0, 0.0), |r| (r.center().x, r.center().y)); + let mid = canvas_center(); + ((mid - c.0).floor(), (mid + c.1).floor()) +} + +/// The canvas's center, on its own axes and in both directions. +fn canvas_center() -> f64 { + f64::from(CANVAS_SIZE) / 2.0 +} + +/// The header comments, through `%%EndComments`. `art`: the visible art's bounds, `first` the first +/// artboard, in art space; `origin` art space's origin on the canvas ([`ruler_origin`]). +fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, first: Rect, origin: (f64, f64)) -> String { let mut s = String::new(); s.push_str(PS_HEADER); s.push('\n'); - let _ = writeln!(s, "%%Creator: VectorCraft {}", env!("CARGO_PKG_VERSION")); + let _ = writeln!(s, "%%Creator: {CREATOR_TEXT}"); let _ = writeln!(s, "%%AI8_CreatorVersion: {CREATOR_VERSION}"); // The user and the organization (§2.1.1). let _ = writeln!(s, "%%For: {} ()", ps::bytes(doc.metadata.author.as_bytes())); @@ -216,13 +246,17 @@ fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, (width, h ps::push_nums(&mut s, &cmyk.map(f64::from)); let _ = writeln!(s, "{}", ps::bytes(name.as_bytes())); } - let (w2, h2) = (ps::num(width), ps::num(height)); - let _ = writeln!(s, "%AI3_Cropmarks: 0 0 {w2} {h2}"); - let (cx, cy) = (ps::num(width / 2.0), ps::num(height / 2.0)); + // The first artboard, left, bottom, right, top. + let mut board = String::new(); + ps::push_nums(&mut board, &[first.x0, first.y0, first.x1, first.y1]); + board.pop(); + let _ = writeln!(s, "%AI3_Cropmarks: {board}"); + // The canvas's center in art space. + let (cx, cy) = (ps::num(canvas_center() - origin.0), ps::num(origin.1 - canvas_center())); let _ = writeln!(s, "%AI3_TemplateBox: {cx} {cy} {cx} {cy}"); - let _ = writeln!(s, "%AI3_TileBox: 0 0 {w2} {h2}"); + let _ = writeln!(s, "%AI3_TileBox: {board}"); s.push_str("%AI3_DocumentPreview: None\n"); - let _ = writeln!(s, "%AI5_ArtSize: {w2} {h2}"); + let _ = writeln!(s, "%AI5_ArtSize: {ART_SIZE} {ART_SIZE}"); let _ = writeln!(s, "%AI5_RulerUnits: {}", ruler_units(doc.units)); s.push_str("%AI24_LargeCanvasScale: 1\n"); let model = if doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { 2 } else { 1 }; @@ -256,19 +290,30 @@ fn ruler_units(u: Unit) -> u8 { } /// The artboards as the `ArtboardArray` of the `/Document` dictionary: each one's top-left and -/// bottom-right corners in art space (`PositionPoint1`, `PositionPoint2`) and its name. -fn artboards(s: &mut String, doc: &Document, to_art: Affine) { +/// bottom-right corners in art space (`PositionPoint1`, `PositionPoint2`), its top-left corner on +/// the canvas (`RulerOrigin`, `origin` being art space's origin there), its pixel aspect ratio +/// (`PAR`), `DisplayMark` 0 (as in the files users sent), whether it is the selected one (the +/// first) and its name; then the canvas's size. Apps that read the editing data drop a record that +/// lacks any of the first five. +fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64)) { s.push_str("%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n%_/Array :\n"); - for ab in &doc.artboards { + for (i, ab) in doc.artboards.iter().enumerate() { let r = to_art.transform_rect_bbox(ab.rect); s.push_str("%_/Dictionary :\n%_"); ps::push_nums(s, &[r.x0, r.y1]); s.push_str("/RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_"); ps::push_nums(s, &[r.x1, r.y0]); - s.push_str("/RealPointRelToROrigin\n%_ (PositionPoint2) ,\n"); + s.push_str("/RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_"); + ps::push_nums(s, &[origin.0 + r.x0, origin.1 - r.y1]); + s.push_str("/RealPoint\n%_ (RulerOrigin) ,\n%_"); + let par = ab.video_ruler_pixel_aspect_ratio; + ps::push_num(s, if par.is_finite() && par > 0.0 { par } else { 1.0 }); + let _ = writeln!(s, " /Real (PAR) ,\n%_0 /Int (DisplayMark) ,\n%_{} /Bool (IsArtboardSelected) ,", u8::from(i == 0)); let _ = writeln!(s, "%_{} /UnicodeString (Name) ,\n%_; ,", ps::bytes(ab.name.as_bytes())); } - s.push_str("%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n"); + s.push_str("%_; (ArtboardArray) ,\n"); + let _ = writeln!(s, "%_{CANVAS_SIZE} /Int (AIDocumentCanvasSize) ,"); + s.push_str("%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n"); } #[cfg(test)] diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 402e34f4d..e7f9c9d39 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -290,8 +290,8 @@ fn the_data_is_zstandard_compressed_with_the_header_as_metadata() { "%%AI8_CreatorVersion: 30.7.0\n%%For: () ()\n%%Title: (Sample)\n", "%%Canvassize: 16383\n", "%AI5_FileFormat 14.0\n%AI3_ColorUsage: Color\n%AI7_ImageSettings: 0\n%%DocumentCustomColors: (Gold)\n%%+ (Sky)\n%%+ (Mint)\n", - "%AI3_Cropmarks: 0 0 200 100\n", - "%AI5_ArtSize: 200 100\n", + "%AI3_Cropmarks: 0 -100 200 0\n%AI3_TemplateBox: 225.5 -50.5 225.5 -50.5\n%AI3_TileBox: 0 -100 200 0\n", + "%AI5_ArtSize: 14400 14400\n", "%AI9_ColorModel: 1\n%AI5_ArtFlags: 0 0 0 1 0 0 1 0 0\n%AI5_TargetResolution: 800\n%AI5_NumLayers: 2\n", "%AI5_OpenViewLayers: 77\n%AI7_GridSettings: 72 8 72 8 1 0 0.7843 0.7843 0.7843 0.7843 0.7843 0.7843\n%%EndComments\n", ] { @@ -300,6 +300,48 @@ fn the_data_is_zstandard_compressed_with_the_header_as_metadata() { assert_eq!((p.container_version, p.creator_version, p.roundtrip_version, p.roundtrip_stream_type), (12, 30, 24, 2)); } +/// Art space is ruler space: y up from the first artboard's top-left corner. The artboards +/// together sit centered on the canvas: each one's top-left corner on the canvas (y down) is its +/// `RulerOrigin`, and the template box holds the canvas's center in art space. +#[test] +fn art_lies_on_its_artboard_in_ruler_space() { + let mut doc = Document::new(200.0, 100.0); + let mut second = doc.artboards[0].clone(); + (second.id, second.name, second.rect) = (2, "Back".into(), Rect::new(250.0, 0.0, 450.0, 100.0)); + doc.artboards.push(second); + let rect = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(10.0, 20.0, 30.0, 40.0)), + Appearance::basic(solid(Color::gray(0.5)), Paint::None, 0.0), + ); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, rect).unwrap(); + let text = program(&doc); + for line in [ + "%%BoundingBox: 10 -40 30 -20\n", + "%AI3_Cropmarks: 0 -100 200 0\n", + "%AI3_TemplateBox: 225.5 -50.5 225.5 -50.5\n", + "\n10 -20 m\n", + "%_0 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_200 -100 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_7966 8141 /RealPoint\n%_ (RulerOrigin) ,\n%_1 /Real (PAR) ,\n%_0 /Int (DisplayMark) ,\n%_1 /Bool (IsArtboardSelected) ,\n", + "%_250 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_450 -100 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_8216 8141 /RealPoint\n%_ (RulerOrigin) ,\n%_1 /Real (PAR) ,\n%_0 /Int (DisplayMark) ,\n%_0 /Bool (IsArtboardSelected) ,\n", + "%_16383 /Int (AIDocumentCanvasSize) ,\n", + ] { + assert!(text.contains(line), "{line}\n{text}"); + } + // The creator names the format's app, before the canvas size and the template box, and holds + // no version number another app could read as the format's version. + let creator = text.lines().find_map(|l| l.strip_prefix("%%Creator: ")).unwrap(); + assert!(creator.contains("Illustrator"), "{creator}"); + assert!(!creator.contains("88") && !creator.chars().any(|c| c.is_ascii_digit()), "{creator}"); + assert!(text.find("%%Creator: ").unwrap() < text.find("%%Canvassize: 16383").unwrap()); + assert!(text.find("%%Canvassize: 16383").unwrap() < text.find("%AI3_TemplateBox").unwrap()); + // The reader puts the art back where it was, on its artboard. + let read = read_back(&doc); + assert_eq!(read.artboards.iter().map(|a| a.rect).collect::>(), doc.artboards.iter().map(|a| a.rect).collect::>()); + let bounds = read.layers.iter().filter_map(|l| l.visual_bounds()).reduce(|a, b| a.union(b)).unwrap(); + assert_eq!(bounds, Rect::new(10.0, 20.0, 30.0, 40.0)); +} + #[test] fn a_pdf_carries_the_data_on_its_first_page() { let doc = sample(); diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 7ca44a4fd..c12459fbe 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -105,7 +105,7 @@ pub struct PdfOptions { pub modified: Option, /// The native document (`.vectorcraft` bytes) Preserve Editing embeds. pub native: Option>, - /// The editing copy of the art for the first page's `PieceInfo`, as a `.ai` file carries it. + /// The editing copy of the art for every page's `PieceInfo`, as a `.ai` file carries it. pub private_data: Option, /// The pages drawn small, in page order: what Embed Page Thumbnails embeds (the writer /// doesn't draw; [`page_areas`] says what each page shows). diff --git a/crates/pdf/src/piece_info.rs b/crates/pdf/src/piece_info.rs index 157f36c5c..60cd80228 100644 --- a/crates/pdf/src/piece_info.rs +++ b/crates/pdf/src/piece_info.rs @@ -1,9 +1,11 @@ -//! An editor's private data on the first page: a page-piece dictionary (ISO 32000-1 §14.5, +//! An editor's private data on every page: a page-piece dictionary (ISO 32000-1 §14.5, //! `PieceInfo`) holding the editing copy of the art that `.ai` files carry, laid out as in the //! `.ai` files users sent, the form [`crate::illustrator_data`] reads. //! -//! - The page dictionary gets `/PieceInfo << /Illustrator n 0 R >>` and its own `/LastModified` -//! (which §14.5 requires with `PieceInfo`). +//! - Every page dictionary gets `/PieceInfo << /Illustrator n 0 R >>`, the same object `n` on +//! each page, and its own `/LastModified` (which §14.5 requires with `PieceInfo`). A file with +//! several pages whose pages do not all refer to the one data dictionary opens in other apps as +//! a PDF, without the editing data. //! - Object `n` is the data dictionary: `<< /LastModified (D:…) /Private m 0 R >>`. //! - Object `m` holds `AIMetaData` (a stream of the data's header comments), `AIPrivateData1` … //! `AIPrivateDataN` (the data in streams of [`PRIVATE_CHUNK`] bytes, the last one shorter), @@ -35,12 +37,15 @@ pub struct PrivateData { pub roundtrip_stream_type: u32, } -/// `pdf` with `p` in its first page's `PieceInfo`, dated `modified` (Unix seconds, UTC). +/// `pdf` with `p` in every page's `PieceInfo`, dated `modified` (Unix seconds, UTC). pub(crate) fn add(pdf: Vec, p: &PrivateData, modified: i64) -> Result, PdfError> { let failed = |why: &str| PdfError::Write(format!("can't add the editing data for other apps: {why}")); let Parsed { xref, pages, .. } = linearize::parse(&pdf).map_err(failed)?; - let first = *pages.first().ok_or_else(|| failed("a page can't be read"))?; - let (dict, _) = xref.dict(&pdf, first).ok_or_else(|| failed("a page can't be read"))?; + if pages.is_empty() { + return Err(failed("a page can't be read")); + } + let dicts = pages.iter().map(|&n| xref.dict(&pdf, n).map(|(at, _)| at)).collect::>>(); + let dicts = dicts.ok_or_else(|| failed("a page can't be read"))?; let mut patch = Patch::new(&xref); let metadata = patch.add_object(stream(&p.metadata)); let blocks: Vec = p.data.chunks(PRIVATE_CHUNK).map(|c| patch.add_object(stream(c))).collect(); @@ -60,7 +65,9 @@ pub(crate) fn add(pdf: Vec, p: &PrivateData, modified: i64) -> Result>").into_bytes()); - patch.replace(dict, dict, format!("/PieceInfo << /{OWNER} {owner} 0 R >> /LastModified {date} ").into_bytes()); + for dict in dicts { + patch.replace(dict, dict, format!("/PieceInfo << /{OWNER} {owner} 0 R >> /LastModified {date} ").into_bytes()); + } patch.apply(&pdf, &xref) } @@ -112,6 +119,30 @@ mod tests { assert!(text.contains(&format!("<< /Length {PRIVATE_CHUNK} >>")) && text.contains("<< /Length 10 >>")); } + #[test] + fn every_page_refers_to_the_one_data_dictionary() { + let mut doc = Document::new(200.0, 100.0); + let mut second = doc.artboards[0].clone(); + (second.id, second.name, second.rect) = (2, "Back".into(), vectorcraft_geom::Rect::new(240.0, 0.0, 440.0, 100.0)); + doc.artboards.push(second); + let opts = PdfOptions { private_data: Some(data(10)), ..PdfOptions::uncompressed() }; + let pdf = export(&doc, &opts).unwrap(); + let Parsed { xref, pages, .. } = linearize::parse(&pdf).unwrap(); + assert_eq!(pages.len(), 2); + let owners: Vec = pages + .iter() + .map(|&n| { + let (at, end) = xref.dict(&pdf, n).unwrap(); + let page = String::from_utf8_lossy(&pdf[at..end]).into_owned(); + assert!(page.contains("/LastModified (D:"), "{page}"); + let i = page.find("/PieceInfo << /Illustrator ").expect("every page carries the data"); + page[i..].split(">>").next().unwrap().to_string() + }) + .collect(); + assert_eq!(owners[0], owners[1]); + assert_eq!(String::from_utf8_lossy(&pdf).matches("/AIPrivateData1 ").count(), 1, "the data is written once"); + } + #[test] fn without_the_data_the_page_has_no_piece_info() { let pdf = export(&Document::new(200.0, 100.0), &PdfOptions::uncompressed()).unwrap();