From f87ba9224c47cf661f1614c6d320d39a4aa4f367 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 16:02:28 -0800 Subject: [PATCH] .ai save: artboards larger than the canvas are written on a large canvas Artboards that together did not fit on the 16,383 pt canvas were written with %AI24_LargeCanvasScale: 1, so part of them lay outside the canvas. The editing data now declares a scale of 10 for them and writes the art, boxes, artboards, strokes and effects at 1/10 of their size, symbol and pattern art included. Reading such a .ai, the editing data comes in at its declared scale when it matches the page that way rather than in the units of the page's UserUnit, also when the PDF part can't be read, and a file saved without its PDF part applies the declared scale too. --- crates/eps/src/ai_editing.rs | 58 +++++++++++++++++++-- crates/eps/src/ai_editing/tests.rs | 55 +++++++++++++++++++ crates/eps/src/import.rs | 1 + crates/eps/src/import/ai/read.rs | 5 ++ crates/eps/src/import/ai/read/directives.rs | 26 +++++---- crates/eps/src/import/ai/read/document.rs | 1 + crates/eps/src/import/native.rs | 1 + crates/eps/src/import/native/build.rs | 41 +++++++++++---- crates/eps/src/import/native/layers.rs | 3 +- 9 files changed, 166 insertions(+), 25 deletions(-) diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 7d9e1a529..7ca953703 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -12,7 +12,9 @@ //! document's author), `%%Canvassize`, `%%BoundingBox`, //! `%%HiResBoundingBox`, `%AI5_FileFormat 14.0`, `%AI7_ImageSettings`, the first artboard as //! `%AI3_Cropmarks` and `%AI3_TileBox`, the canvas's center as `%AI3_TemplateBox`, -//! `%AI5_ArtSize 14400 14400`, `%AI5_RulerUnits`, `%AI9_ColorModel` (1 RGB, 2 CMYK), +//! `%AI5_ArtSize 14400 14400`, `%AI5_RulerUnits`, `%AI24_LargeCanvasScale` (1, or 10 when the +//! artboards together don't fit on the canvas: the art, boxes and artboards are then written at +//! 1/10 of their size), `%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`]). @@ -73,6 +75,9 @@ const ROUNDTRIP_STREAM_TYPE: u32 = 2; 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; +/// The scale of a large canvas: its sides are ten times the canvas's, as the format's large canvas +/// documents have them (2270 inches square). +const LARGE_CANVAS_SCALE: f64 = 10.0; /// `%AI5_ArtSize`, as the files users sent have it. const ART_SIZE: u32 = 14400; /// `%AI5_ArtFlags` (§2.1.1, Table 4), as the files users sent have them: page setup, print tiles, @@ -120,6 +125,25 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result Result (f64, f64) { ((mid - c.0).floor(), (mid + c.1).floor()) } +/// `%AI24_LargeCanvasScale`: 1 when the artboards together fit on the canvas, with a point to +/// spare on each side for the rounding of [`ruler_origin`], else [`LARGE_CANVAS_SCALE`]: then the +/// art, boxes and artboards are written at 1/[`LARGE_CANVAS_SCALE`] of their size. +fn canvas_scale(doc: &Document) -> f64 { + let all = doc.artboards.iter().map(|ab| ab.rect).filter(|r| r.is_finite()).reduce(|a, b| a.union(b)); + let room = f64::from(CANVAS_SIZE) - 2.0; + if all.is_some_and(|r| r.width() > room || r.height() > room) { LARGE_CANVAS_SCALE } else { 1.0 } +} + +/// The strokes and effects of `n` and of what it holds, `k` times as large. +fn scale_strokes(n: &mut Node, k: f64) { + n.appearance.scale_strokes(k); + n.appearance.scale_effects(k, vectorcraft_effects::scale_effect); + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + scale_strokes(Arc::make_mut(&mut mask.art), k); + } + if let Some(children) = n.children_mut() { + for c in children { + scale_strokes(Arc::make_mut(c), k); + } + } +} + /// 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 { +/// artboard, in art space; `origin` art space's origin on the canvas ([`ruler_origin`]); `scale` +/// the large canvas scale ([`canvas_scale`]). +fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, first: Rect, origin: (f64, f64), scale: f64) -> String { let mut s = String::new(); s.push_str(PS_HEADER); s.push('\n'); @@ -267,7 +315,7 @@ fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, first: Re s.push_str("%AI3_DocumentPreview: None\n"); 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 _ = writeln!(s, "%AI24_LargeCanvasScale: {}", ps::num(scale)); let model = if doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { 2 } else { 1 }; let _ = writeln!(s, "%AI9_ColorModel: {model}"); let _ = writeln!(s, "%AI5_ArtFlags: {ART_FLAGS}"); diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 4f533193d..cb96fa9ff 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -412,6 +412,61 @@ fn artboards_of_different_sizes_keep_their_names_and_places() { assert_eq!(bounds, Rect::new(50.0, 50.0, 590.0, 100.0)); } +/// An artboard larger than the canvas (20,000 points square): the data is written on a large +/// canvas, at 1/10 of the size, so the artboard lies on the canvas; read beside a page of the +/// artboard's size in points, it comes back at its size. +#[test] +fn an_artboard_larger_than_the_canvas_is_written_on_a_large_canvas() { + let mut doc = Document::new(20_000.0, 20_000.0); + add_rect(&mut doc, Rect::new(1000.0, 1000.0, 3000.0, 2000.0)); + let text = program(&doc); + for line in [ + "%AI24_LargeCanvasScale: 10\n", + "%AI3_Cropmarks: 0 -2000 2000 0\n", + "\n100 -100 m\n", + "%_0 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_2000 -2000 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_7191 7191 /RealPoint\n%_ (RulerOrigin) ,\n", + RECORDED, + ] { + assert!(text.contains(line), "{line}\n{text}"); + } + // A page of the artboard's size in points, as a PDF part in larger units gives it. + let mut page = Document::new(20_000.0, 20_000.0); + add_rect(&mut page, Rect::new(1000.0, 1000.0, 3000.0, 2000.0)); + let (p, _) = private_data(&doc, &opts()).unwrap(); + let (read, notes, layers) = crate::layered_ai(&p.data, page, vec![], false, true, 1.389); + assert!(layers, "{notes:?}"); + assert_eq!(read.artboards.iter().map(|a| a.rect).collect::>(), [Rect::new(0.0, 0.0, 20_000.0, 20_000.0)], "{notes:?}"); + let bounds = read.layers.iter().filter_map(|l| l.visual_bounds()).reduce(|a, b| a.union(b)).unwrap(); + assert!((bounds.x0 - 1000.0).abs() < 1e-6 && (bounds.y1 - 2000.0).abs() < 1e-6 && (bounds.width() - 2000.0).abs() < 1e-6, "{bounds:?}"); + // The same without a readable PDF part, on its first page's size. + let (alone, notes) = crate::ai_alone_on_page(&p.data, (20_000.0, 20_000.0), 1.389).unwrap(); + assert_eq!(alone.artboards.iter().map(|a| a.rect).collect::>(), [Rect::new(0.0, 0.0, 20_000.0, 20_000.0)], "{notes:?}"); + // And from the editing data alone, as a file saved without its PDF part opens. + let alone = read_back(&doc); + assert_eq!(alone.artboards.iter().map(|a| a.rect).collect::>(), [Rect::new(0.0, 0.0, 20_000.0, 20_000.0)]); + let bounds = alone.layers.iter().filter_map(|l| l.visual_bounds()).reduce(|a, b| a.union(b)).unwrap(); + assert!((bounds.x0 - 1000.0).abs() < 1e-6 && (bounds.width() - 2000.0).abs() < 1e-6, "{bounds:?}"); + + // A symbol's 30-point stroke is written at 1/10 too, as the layers' strokes are. + let mut doc = Document::new(20_000.0, 20_000.0); + let line = shapes::rectangle(Rect::new(0.0, 0.0, 500.0, 500.0)); + let art = Node::path(doc.alloc_id(), line, Appearance::basic(Paint::None, solid(Color::gray(0.5)), 30.0)); + doc.symbols.push(Symbol { dynamic: false, name: "Box".into(), art: Arc::new(art) }); + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((1000.0, 1000.0)) }); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + let (p, _) = private_data(&doc, &opts()).unwrap(); + let (read, notes, layers) = crate::layered_ai(&p.data, Document::new(20_000.0, 20_000.0), vec![], false, true, 1.389); + assert!(layers, "{notes:?}"); + let mut all = vec![]; + paths(read.layers[0].children().unwrap(), &mut all); + let widths: Vec = all.iter().filter_map(|n| n.appearance.stroke().map(|s| s.width)).collect(); + assert!(!widths.is_empty() && widths.iter().all(|w| (w - 30.0).abs() < 1e-6), "{widths:?}"); + + // Artboards that fit stay on the canvas at their size. + assert!(program(&Document::new(16_000.0, 16_000.0)).contains("%AI24_LargeCanvasScale: 1\n")); +} + #[test] fn a_pdf_carries_the_data_on_its_first_page() { let doc = sample(); diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 9c84001f7..f12281271 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -70,6 +70,7 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; +pub(crate) use native::scale_document; pub use native::{ai_alone, ai_alone_on_page, is_loss, layered_ai}; pub use text::family_style; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 93cc0af6b..88def834d 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -159,6 +159,8 @@ pub struct Structure { pub envelopes_drawn: Vec<(NodeId, Node)>, /// The document's graphic styles are those of the file's style list, in place of the defaults. pub styles_read: bool, + /// The file's `%AI24_LargeCanvasScale` (1 when it declares none), applied or not. + pub canvas_scale: f64, pub warnings: Vec, } @@ -325,6 +327,8 @@ struct Reader<'a> { /// (see [`Reader::ensure_space`]); 1 (no scaling) when the file doesn't declare one, `None` /// when the declared factor is not applied ([`read`]). large_canvas: Option, + /// The declared `%AI24_LargeCanvasScale` (1 without one), applied or not. + canvas_scale: f64, /// `%AI5_RulerUnits` from the header (Illustrator 7 spec table 3, plus centimeters and pixels). ruler_units: Option, /// This reader reads a pattern's definition (see [`Reader::define_pattern`]). @@ -435,6 +439,7 @@ impl<'a> Reader<'a> { art_size: None, template_center: None, large_canvas: None, + canvas_scale: 1.0, ruler_units: None, pattern: None, pattern_tiles: HashMap::new(), diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index ed838b51b..cf047363a 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -208,21 +208,29 @@ impl<'a> Reader<'a> { self.template_center = two(v); self.template = four(v).or(self.template); } else if let Some(v) = line.strip_prefix("AI24_LargeCanvasScale:") { - // Illustrator's Large Canvas writes the editing data (art and boxes) at 1/N scale, N - // being this; the printed page applies the same factor, so the art reaches the page's - // size once it is applied to the art space ([`Self::ensure_space`]). A value outside - // the range Illustrator writes is left alone. - if let (Some(scale), Ok(n)) = (&mut self.large_canvas, v.trim().parse::()) - && (1.0..=100.0).contains(&n) - { - *scale = n; - } + self.large_canvas_scale(v); } else if let Some(v) = line.strip_prefix("AI9_ColorModel:") { self.color_model = Some(v.trim() == "2"); } Ok(()) } + /// `%AI24_LargeCanvasScale:` with its value `v`. + fn large_canvas_scale(&mut self, v: &str) { + // Illustrator's Large Canvas writes the editing data (art and boxes) at 1/N scale, N + // being this; the printed page applies the same factor, so the art reaches the page's + // size once it is applied to the art space ([`Self::ensure_space`]). A value outside + // the range Illustrator writes is left alone. + if let Ok(n) = v.trim().parse::() + && (1.0..=100.0).contains(&n) + { + self.canvas_scale = n; + if let Some(scale) = &mut self.large_canvas { + *scale = n; + } + } + } + /// `%AI3_Note:` (or `%%Note:`) sets the note of the paths that follow, an empty one clearing it, /// and `%AI5_NoteMore:` adds a line to it. The text is kept as written, up to [`MAX_NOTE`] /// characters. diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index f38ec7623..622e6912c 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -723,6 +723,7 @@ impl<'a> Reader<'a> { placed_elsewhere: self.placed_elsewhere, envelopes_drawn: self.envelopes_drawn, styles_read, + canvas_scale: self.canvas_scale, warnings, }) } diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 128ce4cb8..2537ed203 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -45,6 +45,7 @@ mod layers; mod plan; mod slots; +pub(crate) use build::scale_document; #[cfg(test)] use build::{Frame, MADE, prune_unseen}; #[cfg(test)] diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index f73121f23..27967ea6a 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -56,7 +56,8 @@ pub(super) fn build( /// [`build`] without a page, for a `.ai` whose PDF part can't be read, its first page `page` /// (width and height, either way round) points large in units of `unit` points: the document is /// drawn `unit` times as large when its first artboard, or its art's box, has the page's size in -/// those units and not in points, as [`build_from`] decides with a page. +/// those units and not in points, as [`build_from`] decides with a page; else as many times as +/// large as its declared large canvas scale when it has the page's size at that scale. pub(super) fn build_on_page(data: &[u8], page: (f64, f64), unit: f64) -> Result<(Document, Vec), String> { let structure = ai::read(data, false)?; let board = structure.artboard.map(|a| structure.to_doc.transform_rect_bbox(a)); @@ -65,12 +66,12 @@ pub(super) fn build_on_page(data: &[u8], page: (f64, f64), unit: f64) -> Result< let fits = |r: Option, k: f64| { r.is_some_and(|r| [(w, h), (h, w)].iter().any(|(a, b)| (r.width() - a / k).abs() <= 0.5 && (r.height() - b / k).abs() <= 0.5)) }; - let in_units = |r: Option| unit.is_finite() && unit > 1.0 && fits(r, unit) && !fits(r, 1.0); - let scale = in_units(board) || (!fits(board, 1.0) && in_units(art)); + let in_units = |r: Option, k: f64| k.is_finite() && k > 1.0 && fits(r, k) && !fits(r, 1.0); + let scale = [unit, structure.canvas_scale].into_iter().find(|&k| in_units(board, k) || (!fits(board, 1.0) && in_units(art, k))); let (mut doc, notes) = build_from(structure, data, None, Page::Artboard, false, false, 1.0)?; - if scale { + if let Some(k) = scale { // The graphic styles here are all the file's, drawn in its units too. - scale_document(&mut doc, unit, true); + scale_document(&mut doc, k, true); } Ok((doc, notes)) } @@ -99,16 +100,19 @@ pub(super) fn build_from( placed_elsewhere, envelopes_drawn, styles_read, + canvas_scale, warnings, } = structure; - // Editing data in the units of the page's user space ([`in_units`]) is compared with the page - // scaled down to those units, and the document read from it is scaled up by `unit`. + // Editing data in the units of the page's user space ([`in_units`]), or at its declared large + // canvas scale ([`page_scale`]), is compared with the page scaled down to those units, and the + // document read from it is scaled up by `unit`. let board = artboard.map(|a| to_doc.transform_rect_bbox(a)); // A page cropped to the art (see below) is in those units as well, when the art's box is and // the artboard isn't the page in points. let art = bbox.map(|[a, b, c, d]| to_doc.transform_rect_bbox(Rect::new(a, b, c, d))); - let units = |v: &Document| in_units(board, v, unit) || (!same_size(board, v) && in_units(art, v, unit)); - let small = visible.filter(|v| matches!(page, Page::Artboard) && units(v)).map(|v| { + let scaled = page_scale(board, art, visible.filter(|_| matches!(page, Page::Artboard)), unit, canvas_scale); + let unit = scaled.unwrap_or(unit); + let small = visible.filter(|_| scaled.is_some()).map(|v| { let mut v = v.clone(); scale_document(&mut v, 1.0 / unit, false); v @@ -1001,6 +1005,23 @@ fn placed_files(nodes: &[Arc], out: &mut Vec) { } } +/// Is the editing data in units of `unit` points beside `page` ([`in_units`])? Its first artboard +/// `board` is, or, when that isn't the page in points, its art's box `art` is (a page cropped to the +/// art). +fn in_units_or_art(board: Option, art: Option, page: &Document, unit: f64) -> bool { + in_units(board, page, unit) || (!same_size(board, page) && in_units(art, page, unit)) +} + +/// The size in points of a unit of the editing data whose first artboard is `board` and whose +/// art's box is `art` (the document's coordinates), read beside `page`: `unit` (the page's +/// `UserUnit`) when the data is in those units ([`in_units_or_art`]), else `canvas_scale` (its +/// declared large canvas scale) when the data is at that scale; `None` when it is in points or +/// there is no page. +fn page_scale(board: Option, art: Option, page: Option<&Document>, unit: f64, canvas_scale: f64) -> Option { + let page = page?; + [unit, canvas_scale].into_iter().find(|&k| in_units_or_art(board, art, page, k)) +} + /// Does the editing data's first artboard `board` (in the document read from it: art space through /// `to_doc`) have the size of `page`'s first artboard in units of `unit` points, `unit` being above /// 1, and not its size in points? `page` is a PDF part read in points from pages of `unit` points a @@ -1054,7 +1075,7 @@ fn drop_guides_off_boards(doc: &mut Document) { /// Draw `doc`'s layers, artboards and guides `k` times as large, from the origin, with their /// strokes, effects and pattern tiles, and with `styles` the strokes and effects of its graphic /// styles. -fn scale_document(doc: &mut Document, k: f64, styles: bool) { +pub(crate) fn scale_document(doc: &mut Document, k: f64, styles: bool) { let a = Affine::scale(k); let scaling = Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() }; for layer in &mut doc.layers { diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index 7f91438be..a8bbd0837 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -242,9 +242,10 @@ fn place_art(layer: &mut Node, pages: &[Rect], moves: &[Vec2]) { /// A `.ai` saved without its PDF part (its pages show only a placeholder) from its editing copy /// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes. +/// Editing data written on a large canvas comes in at its declared scale: no page holds the size. pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec), String> { let data = ai::decode_private(private)?; - build(&data, None, Page::Artboard, false, false, 1.0) + build_from(ai::read(&data, true)?, &data, None, Page::Artboard, false, false, 1.0) } /// [`ai_alone`] for a `.ai` whose PDF part can't be read, its first page `page` (width and height)