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();