From f46b0f7dc4fe551b2850009dcc7e77a36e3b880b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 15 Oct 2026 16:21:46 -0700 Subject: [PATCH] PDF export: a page larger than 14,400 pt is written with a UserUnit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDF export and .ai saves wrote every page in points. ISO 32000-1 (Annex C.2) gives 14,400 units as the longest page side, and a page with a longer side got a media box past that limit. A PDF/A-2b file with such a page failed veraPDF's page size rule (6.1.13). Such a page now gets a UserUnit (PDF 1.6): its longer side divided by 14,400 and rounded up to the next thousandth, with the page's sizes and coordinates in those units. A 20,000 × 10,000 pt artboard is written as a 14,398.85 × 7,199.42 page with /UserUnit 1.389, and VectorCraft's PDF import reads it back at 20,000 × 10,000 pt. Pages up to 14,400 pt, PDF 1.3 to 1.5 files, PDF/X files and printing are written as before. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/fileio/tests_pdfedit.rs | 21 ++++++++++ crates/pdf/src/export.rs | 36 +++++++++++++--- crates/pdf/src/post.rs | 19 ++++++++- crates/pdf/src/print/mod.rs | 1 + crates/pdf/src/settings.rs | 5 +++ crates/pdf/src/tests_marks.rs | 42 +++++++++++++++++++ docs/mcp.md | 3 ++ docs/target-app-parity.md | 1 + 8 files changed, 122 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs index e57f46de6..6cf5b963e 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfedit.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs @@ -139,6 +139,27 @@ fn exports_write_ai_as_save_as_does() { assert!(b64(&r).starts_with(b"%PDF")); } +/// A `.ai` with an artboard larger than 14,400 pt has its PDF page written with a `UserUnit`, +/// and reopens at its size from its editing data and from its PDF part. +#[test] +fn an_ai_with_an_artboard_past_14400_pt_reopens_at_its_size() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 20000, "height": 10000})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 1000, "y": 1000, "width": 5000, "height": 3000})).unwrap(); + let ai = b64(&s.execute("document.export", &json!({"format": "ai", "compress": false})).unwrap()); + assert!(String::from_utf8_lossy(&ai).contains("/UserUnit 1.389"), "20,000 / 14,400 rounded up to the next thousandth"); + let size = |s: &Session| { + let r = s.doc().unwrap().doc.artboards[0].rect; + (r.width().round(), r.height().round()) + }; + assert_eq!(open(&mut s, "big.ai", &ai)["restored"], true); + assert_eq!(size(&s), (20_000.0, 10_000.0)); + // Picking its page opens the PDF part. + let r = s.execute("document.open", &json!({"name": "big.ai", "dataBase64": vectorcraft_format::base64_encode(&ai), "pages": "1"})).unwrap(); + assert_eq!(r["restored"], false, "{r}"); + assert_eq!(size(&s), (20_000.0, 10_000.0)); +} + #[test] fn ait_templates_open_their_document_untitled() { let mut s = rich(); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index ba497e78c..d1901a7d4 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -73,14 +73,23 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions) -> Result 1.0 { Affine::scale(1.0 / unit) * shift } else { shift }; let sheet = Sheet { - size: (m.width(), m.height()), + size: (m.width() / unit, m.height() / unit), + unit, trim: view.transform_rect_bbox(boxes.trim), bleed: view.transform_rect_bbox(r), view, @@ -99,6 +108,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions) -> Result Result, PdfErr Ok(pages(doc, opts)?.into_iter().map(|(_, b)| b.media).collect()) } +/// The largest side of a page in units of its default user space that ISO 32000-1 (Annex C.2) +/// gives: 14,400 (200 inches in units of 1/72 inch). +pub(crate) const MAX_PAGE_SIDE: f64 = 14_400.0; + +/// The `UserUnit` (PDF 1.6) of a page `w` × `h` points large: 1 when both sides are at most +/// [`MAX_PAGE_SIDE`], else the longer side divided by it, rounded up to the next thousandth, so +/// that both sides are at most [`MAX_PAGE_SIDE`] units. +fn user_unit(w: f64, h: f64) -> f64 { + let side = w.abs().max(h.abs()); + if side.is_finite() && side > MAX_PAGE_SIDE { (side / MAX_PAGE_SIDE * 1000.0).ceil() / 1000.0 } else { 1.0 } +} + /// A PDF being written, shared by export and print: the file's configuration and metadata, then /// its pages ([`Self::page`]), each drawn by an [`Exporter`]. pub(crate) struct Writer { @@ -144,8 +166,11 @@ pub(crate) struct Writer { /// One page as [`Writer::page`] draws it: the art placed in a drawing space ([`Self::place`]), /// which [`Self::view`] maps onto the page. pub(crate) struct Sheet<'a> { - /// The page's size in points. + /// The page's size in its units. pub size: (f64, f64), + /// The size of the page's unit in points (its `UserUnit`): a page in points has 1. A side is + /// written at least 1 pt long. + pub unit: f64, /// The TrimBox and BleedBox, in page space (y down from the top-left corner). pub trim: Rect, pub bleed: Rect, @@ -215,7 +240,8 @@ impl Writer { /// Draw one page: the art of `ex`'s document placed as `sheet` says, then its marks. pub(crate) fn page(&mut self, ex: &mut Exporter, sheet: &Sheet) -> Result<(), PdfError> { let (w, h) = sheet.size; - let size = Size::from_wh(w.max(1.0) as f32, h.max(1.0) as f32).ok_or_else(|| PdfError::BadSetting(format!("page size {w} × {h}")))?; + let least = 1.0 / sheet.unit; + let size = Size::from_wh(w.max(least) as f32, h.max(least) as f32).ok_or_else(|| PdfError::BadSetting(format!("page size {w} × {h}")))?; let at = |b: Rect| krilla::geom::Rect::from_ltrb(b.x0 as f32, b.y0 as f32, b.x1 as f32, b.y1 as f32); let mut page = self.pdf.document_mut().start_page_with(PageSettings::new(size).with_trim_box(at(sheet.trim)).with_bleed_box(at(sheet.bleed))); let mut s = page.surface(); diff --git a/crates/pdf/src/post.rs b/crates/pdf/src/post.rs index c7c2dc5b5..27f77e5c4 100644 --- a/crates/pdf/src/post.rs +++ b/crates/pdf/src/post.rs @@ -2,7 +2,8 @@ //! (General › Embed page thumbnails), 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. +//! is PDF 1.3 ([`declare_pdf13`]), for PDF 1.3 and PDF/X-1a and PDF/X-3 files, and the pages' +//! user units ([`user_units`]), which the export writes before the thumbnails. use std::borrow::Cow; @@ -60,6 +61,22 @@ fn thumbnails(pdf: Vec, thumbs: &[Thumbnail], gray: bool) -> Result, patch.apply(&pdf, &xref) } +/// `pdf` with `units` as the `UserUnit` entries of its pages, in page order (a unit of 1 writes +/// none); unchanged when every unit is 1. +pub(crate) fn user_units(pdf: Vec, units: &[f64]) -> Result, PdfError> { + if units.iter().all(|u| *u == 1.0) { + return Ok(pdf); + } + let failed = |why: &str| PdfError::Write(format!("can't write the pages' user units: {why}")); + let Parsed { xref, pages, .. } = linearize::parse(&pdf).map_err(failed)?; + let mut patch = Patch::new(&xref); + for (page, unit) in pages.iter().zip(units).filter(|(_, u)| **u != 1.0) { + let (dict, _) = xref.dict(&pdf, *page).ok_or_else(|| failed("a page can't be read"))?; + patch.replace(dict, dict, format!("/UserUnit {unit}").into_bytes()); + } + patch.apply(&pdf, &xref) +} + /// Linearise `pdf`, encrypting it on the way as `set` says. fn linearised(pdf: &[u8], set: &PdfSettings) -> Result, PdfError> { let (prepared, plan) = linearize::prepare(pdf)?; diff --git a/crates/pdf/src/print/mod.rs b/crates/pdf/src/print/mod.rs index ff94d013d..440231032 100644 --- a/crates/pdf/src/print/mod.rs +++ b/crates/pdf/src/print/mod.rs @@ -318,6 +318,7 @@ impl PlannedSheet { fn sheet(&self) -> Sheet<'_> { Sheet { size: self.size, + unit: 1.0, trim: self.trim, bleed: self.bleed, view: self.view, diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index 566ab8b8d..329e8c7fd 100644 --- a/crates/pdf/src/settings.rs +++ b/crates/pdf/src/settings.rs @@ -109,6 +109,11 @@ impl Compatibility { pub fn has_layers(self) -> bool { matches!(self, Self::Pdf15 | Self::Pdf16 | Self::Pdf17 | Self::Pdf20) } + + /// The version has a page's `UserUnit` (PDF 1.6). + pub fn has_user_unit(self) -> bool { + matches!(self, Self::Pdf16 | Self::Pdf17 | Self::Pdf20) + } } choice! { diff --git a/crates/pdf/src/tests_marks.rs b/crates/pdf/src/tests_marks.rs index 6f807e6fc..e2b72b354 100644 --- a/crates/pdf/src/tests_marks.rs +++ b/crates/pdf/src/tests_marks.rs @@ -82,6 +82,48 @@ fn bleed_grows_the_media_box_around_the_trim_box() { assert_eq!(page_box(&bytes, CropTo::Trim), Rect::new(3.0, 2.0, 203.0, 102.0)); } +/// Each page's `UserUnit` and media box as the file holds them (in its units). +fn units_and_media(bytes: &[u8]) -> Vec<(f32, Rect)> { + let file = crate::pages::open(bytes, None).unwrap(); + file.pages().iter().map(|p| (p.user_unit(), Rect::new(p.media_box().x0, p.media_box().y0, p.media_box().x1, p.media_box().y1))).collect() +} + +/// A page with a side longer than ISO 32000-1's 14,400 units is written in larger units (its +/// `UserUnit`), so that its sides are 14,400 units at most, and reads back at its size in points. +/// Pages up to 14,400 pt, files before PDF 1.6 and PDF/X files keep their sizes in points. +#[test] +fn a_page_past_14400_pt_is_written_with_a_user_unit_and_reads_back_at_its_size() { + let mut d = doc(); + d.artboards[0].rect = Rect::new(0.0, 0.0, 20_000.0, 10_000.0); + let mut small = d.artboards[0].clone(); + small.rect = Rect::new(0.0, 11_000.0, 14_400.0, 11_300.0); + d.artboards.push(small.clone()); + small.rect = Rect::new(0.0, 12_000.0, 20_000.0, 12_001.0); + d.artboards.push(small); + let near = |a: Rect, b: Rect| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 0.01); + let bytes = pdf(&d, json!({})); + let pages = units_and_media(&bytes); + // 20,000 / 14,400 = 1.3889, rounded up to 1.389: the page is 14,398.85 × 7,199.42 units. + assert_eq!(pages[0].0, 1.389, "{pages:?}"); + assert!(pages[0].1.width() <= 14_400.0 && near(pages[0].1.scale_from_origin(1.389), Rect::new(0.0, 0.0, 20_000.0, 10_000.0)), "{pages:?}"); + assert_eq!(pages[1], (1.0, Rect::new(0.0, 0.0, 14_400.0, 300.0)), "a page of 14,400 pt keeps its points"); + assert!(pages[2].0 == 1.389 && (pages[2].1.height() * 1.389 - 1.0).abs() < 1e-3, "a side of 1 pt stays 1 pt: {pages:?}"); + assert_eq!(String::from_utf8_lossy(&bytes).matches("/UserUnit").count(), 2); + // Read back in points: the page, its trim box and the square reaching 5 pt left of the page. + let page = &info(&bytes, None).unwrap().pages[0]; + assert!((page.width - 20_000.0).abs() < 0.01 && (page.height - 10_000.0).abs() < 0.01, "{page:?}"); + assert!(near(page_box(&bytes, CropTo::Trim), Rect::new(0.0, 0.0, 20_000.0, 10_000.0))); + let back = import(&bytes).unwrap(); + assert!(near(back.artboards[0].rect, Rect::new(0.0, 0.0, 20_000.0, 10_000.0)), "{:?}", back.artboards[0].rect); + let red = painted(&back, |p| rgb(p, [255, 0, 0])); + assert!(red.len() == 1 && near(red[0], Rect::new(-5.0, 10.0, 40.0, 50.0)), "{red:?}"); + // PDF 1.5 and PDF/X-4 files have the page in points, past 14,400. + for v in [json!({"compatibility": "1.5"}), json!({"standard": "pdfX4", "compatibility": "1.6"})] { + let pages = units_and_media(&pdf(&d, v.clone())); + assert_eq!(pages[0], (1.0, Rect::new(0.0, 0.0, 20_000.0, 10_000.0)), "{v}"); + } +} + #[test] fn art_in_the_bleed_is_kept_and_clipped_to_the_bleed_box() { let d = doc(); diff --git a/docs/mcp.md b/docs/mcp.md index f397888cb..1db7301ef 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -536,6 +536,9 @@ with `flattenerPreset` (empty: High Resolution; `high`, `medium`, `low` or a sav and `flattener` options over it (as `object.flattenTransparency` takes them); images with see-through pixels count as transparency and rasterized areas are clipped to their regions. A file that would still have transparency fails the export. PDF/X-1a and PDF/X-3 files are PDF 1.3 files too. +A page with a side longer than 14,400 pt is written with a `UserUnit` (PDF 1.6): its longer side divided by 14,400, +rounded up to the next thousandth, with the page's sizes and coordinates in those units, so that no side is longer than +14,400 units (ISO 32000-1, Annex C.2). In PDF 1.3 to 1.5 files and PDF/X files, such a page is written in points. An Illustrator EPS (version 9 on) or `.ai` file opens from the editing data it carries (after the EPS page; in the `.ai` file's PDF private data): layers and sublayers (name, order, visibility, lock, printing, preview, dimming, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 67cbf410d..1448e43a5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -587,6 +587,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A PDF saved with Illustrator's editing data opens through it as a PDF-compatible `.ai` does, with its layers and groups and with art reaching past its page unclipped; such a PDF holds the data in `AIPDFPrivateData` streams (part of #868). A shape a PDF or `.ai` paints with a gradient (a clip path round a shading) opens as one path filled with the gradient, not a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on its outline (#1304). In a `.ai` opened from its editing data, a text object on a layer that shows and doesn't print stays on that layer, made from the file's text document, even where the page doesn't draw it (`crates/eps/src/import/tests_native.rs`). A PDF page whose `UserUnit` (PDF 1.6) is above 1 opens at its size in points, its numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size, with its layers, and when the layers can't be read its pages still take the artboard layout of the data, scaled the same way (part of #1482). + PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); in PDF 1.3 to 1.5 files and PDF/X files such a page is written in points. Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests_links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part; CCITT Group 3 and planar files with extra channels are not read); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs.