From 26a9c7a4cc5ba87dba5cc46f2e4175051ae32532 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:02:38 +0300 Subject: [PATCH 1/2] eps: a Large Canvas editing copy is read at the page's scale (#2031) --- crates/eps/src/import/ai/read.rs | 21 ++++++++++++++++-- crates/eps/src/import/ate.rs | 28 ++++++++++++++++++++++- crates/eps/src/import/native.rs | 6 +++-- crates/eps/src/import/tests_native.rs | 32 +++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 5 deletions(-) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 6e65c9e3e..be9f84eb1 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -391,6 +391,9 @@ struct Reader<'a> { /// The artboard's size and centre in files without artboards or crop marks. art_size: Option<(f64, f64)>, template_center: Option<(f64, f64)>, + /// `%AI24_LargeCanvasScale`: a Large Canvas document's editing data is written at 1/N scale + /// (see [`Reader::ensure_space`]); 1 (no scaling) when the file doesn't declare one. + large_canvas: f64, /// This reader reads a pattern's definition (see [`Reader::define_pattern`]). pattern: Option, /// The tile's top left (y down) of each pattern read, by name. @@ -525,6 +528,7 @@ impl<'a> Reader<'a> { slot_names: HashMap::new(), art_size: None, template_center: None, + large_canvas: 1.0, pattern: None, pattern_tiles: HashMap::new(), text: None, @@ -660,6 +664,16 @@ impl<'a> Reader<'a> { } else if let Some(v) = line.strip_prefix("AI3_TemplateBox:") { 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 Ok(n) = v.trim().parse::() + && (1.0..=100.0).contains(&n) + { + self.large_canvas = n; + } } else if let Some(v) = line.strip_prefix("AI9_ColorModel:") { self.color_model = Some(v.trim() == "2"); } @@ -1858,14 +1872,17 @@ impl<'a> Reader<'a> { Some(Rect::new(x0, y0, x1, y1)).filter(|r| r.width() > 0.0 && r.height() > 0.0) } - /// Art space → the document (fixed when the art starts, from the artboards read by then). + /// Art space → the document (fixed when the art starts, from the artboards read by then). A + /// Large Canvas editing copy is written at 1/N scale ([`Self::large_canvas`]): the factor makes + /// its art and boxes reach the page's size, which is what the page it is opened beside holds. fn ensure_space(&mut self) -> Affine { if let Some(m) = self.to_doc { return m; } let first = self.first_board().or_else(|| self.bbox_board()); let top_left = first.map_or(Point::new(0.0, 792.0), |r| Point::new(r.x0, r.y1)); - let m = Affine::new([1.0, 0.0, 0.0, -1.0, -top_left.x, top_left.y]); + let s = self.large_canvas; + let m = Affine::new([s, 0.0, 0.0, -s, -s * top_left.x, s * top_left.y]); self.to_doc = Some(m); m } diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index add817c35..002f6f533 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -583,7 +583,7 @@ impl Story { /// Roughly where the story's type lies on the document: its frame's box, or round its lines /// for point type (what the page draws of it lies there). pub(super) fn region(&self, template: (f64, f64), to_doc: Affine) -> Option { - let size = self.runs.iter().map(|(_, s)| s.size).fold(0.0, f64::max); + let size = self.runs.iter().map(|(_, s)| s.size).fold(0.0, f64::max) * art_scale(to_doc); let bounds = |segments: &[[f64; 8]]| path_of(segments, template, to_doc).bounds(); let r = match self.frames.first()? { FrameKind::Area(s) => bounds(s)?, @@ -634,6 +634,9 @@ impl Story { if text.trim().is_empty() && !threaded { return None; } + // The text document measures in the art's units too: the document's scale carries its + // sizes and spacings onto the page (a Large Canvas copy is written at 1:10; #2031). + let scale = scale * art_scale(to_doc); let leading = self.line_leading(scale); let chars: Vec = text.chars().collect(); let mut runs = vec![]; @@ -776,6 +779,13 @@ fn char_style(s: &Style, scale: f64, leading: Option) -> CharStyle { } } +/// How many document points one unit of the art is: `to_doc`'s scale, which carries the art's +/// lengths onto the document (10 for a Large Canvas copy written at 1:10; see `ai::read`). +fn art_scale(to_doc: Affine) -> f64 { + let [a, b, ..] = to_doc.as_coeffs(); + (a * a + b * b).sqrt().max(1e-9) +} + #[cfg(test)] mod tests { use super::*; @@ -852,6 +862,22 @@ mod tests { assert!((s.line_leading(0.5).unwrap() - 7.0).abs() < 1e-9); } + /// #2031: a Large Canvas editing copy measures its text document in its art's units, a tenth + /// of the page's: the story's size and leading reach the page through `to_doc`'s scale. + #[test] + fn a_story_in_a_scaled_art_space_keeps_its_size_and_leading() { + let s = story("one\rtwo\r", &[0.0, 20.0], &Para::default()); + // A copy written at 1:10 (see `ai::read::ensure_space`): its art's units reach the + // document, 100 points to the page, through `to_doc`'s scale. + let to_doc = Affine::scale(10.0) * Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 100.0]); + let t = match s.node(NodeId(1), 0, (0.0, 0.0), to_doc).unwrap().kind { + NodeKind::Text(t) => t, + _ => panic!("not text"), + }; + assert_eq!(t.runs[0].style.size, 130.0, "13 pt at the page's scale"); + assert_eq!(t.runs[0].style.leading, Some(200.0), "a 20-unit line pitch at the page's scale"); + } + #[test] fn leading_between_lines_of_a_paragraph_is_their_distance() { // A line break inside a paragraph has no paragraph spacing between its lines. diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 7a979de43..32a6a966e 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -881,11 +881,13 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool, tr Page::Artboard => artboard.ok_or("its editing data doesn't say where its artboard is")?, }; let page_rect = visible.artboards.first().map(|a| a.rect).ok_or("it has no page")?; - if (art_box.width() - page_rect.width()).abs() > 0.5 || (art_box.height() - page_rect.height()).abs() > 0.5 { + // The layers' art in the document's terms: a Large Canvas editing copy is written at 1/N + // scale, which `to_doc` carries, so its art and the page reach the same size (#2031). + let rect = to_doc.transform_rect_bbox(art_box); + if (rect.width() - page_rect.width()).abs() > 0.5 || (rect.height() - page_rect.height()).abs() > 0.5 { return Err("its editing data is for a different page".into()); } // The page's area in the layers' document, and the page's type moved there. - let rect = to_doc.transform_rect_bbox(art_box); let shift = Affine::translate(Vec2::new(rect.x0 - page_rect.x0, rect.y0 - page_rect.y0)); for l in &visible.layers { if let Some(c) = l.children() { diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 0b3f7153e..51f88251a 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -34,6 +34,15 @@ fn native(layers: &str) -> String { ) } +/// The editing copy of a Large Canvas file (#2031): art and boxes written at 1/`scale`, declared +/// with `%AI24_LargeCanvasScale` as Illustrator writes it. The file's page is `size × scale` +/// points. +fn native_large_canvas(size: f64, scale: f64, layers: &str) -> String { + format!( + "%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 {size} {size}\n%%HiResBoundingBox: 0 0 {size} {size}\n%AI3_Cropmarks: 0 0 {size} {size}\n%AI24_LargeCanvasScale: {scale}\n%AI5_NumLayers: 1\n{layers}%%Trailer\n" + ) +} + /// A layer as the format writes it. fn layer(name: &str, visible: bool, body: &str) -> String { format!("%AI5_BeginLayer\n{} 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{body}\nLB\n%AI5_EndLayer--\n", u8::from(visible)) @@ -239,6 +248,29 @@ fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() { assert!(w.iter().any(|w| w.contains("different page")), "{w:?}"); } +/// #2031: a Large Canvas editing copy (`%AI24_LargeCanvasScale: 10`) writes its art and boxes at +/// 1/10 scale; the import scales them back, so its layers are read instead of being rejected as +/// another page's — with the art at the page's size. Art that doesn't match the page at that +/// scale is still another page's. +#[test] +fn a_large_canvas_eps_keeps_its_layers_at_the_pages_size() { + // 10 art units for the page's 100 points: a 0.4-unit square at (5, 5) is the page's 4-point + // square at (50, 50). + let art = native_large_canvas(10.0, 10.0, &layer("Art", true, "0 0 1 0 k\n5 5 m\n5.4 5 L\n5.4 5.4 L\n5 5.4 L\nf")); + let r = import(&eps(&page_square(50, 50), &art)).unwrap(); + let d = &r.document; + assert_eq!(names(d), ["Art"], "{:?}", r.warnings); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let b = d.layers[0].children().unwrap()[0].visual_bounds().unwrap(); + assert!((b.x0 - 50.0).abs() < 1e-6 && (b.y0 - 46.0).abs() < 1e-6, "at the page's place: {b:?}"); + assert!((b.width() - 4.0).abs() < 1e-6 && (b.height() - 4.0).abs() < 1e-6, "at the page's size: {b:?}"); + // A declared scale the art doesn't fit still makes it another page's. + let art = native_large_canvas(20.0, 10.0, &layer("Art", true, &square(10, 10))); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings); + assert!(r.warnings.iter().any(|w| w.contains("different page")), "{:?}", r.warnings); +} + /// A `.ai` whose layers can't be read comes in as its PDF part, a page per artboard in a row; its /// pages still go where its editing data puts its artboards, with their art, and keep their names (#1068). #[test] From 7beebfabb93bc027c46d1f7d00d289af2284b012 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 01:19:36 +0700 Subject: [PATCH 2/2] eps: apply the Large Canvas scale to EPS files only; a .ai keeps its art space (#2031) --- crates/eps/src/import/ai/read.rs | 20 ++++++++++++-------- crates/eps/src/import/ai/tests.rs | 5 +++++ crates/eps/src/import/native.rs | 4 +++- crates/eps/src/import/tests_native.rs | 4 ++++ 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index be9f84eb1..6956c01ab 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -114,9 +114,12 @@ pub(crate) fn slot_frame(name: Option<&str>) -> usize { name.and_then(|n| n.strip_prefix(TEXT_SLOT)).and_then(|rest| rest.split_once(':')).and_then(|(_, f)| f.parse().ok()).unwrap_or(0) } -/// Read the structure of the editing data `data`. -pub fn read(data: &[u8]) -> Result { +/// Read the structure of the editing data `data`, applying the file's `%AI24_LargeCanvasScale` only +/// when `large_canvas` is set: an EPS's page is drawn at the full size, but a `.ai`'s PDF part may +/// keep the reduced one, which the PDF import reads as it is (#2031). +pub fn read(data: &[u8], large_canvas: bool) -> Result { let mut r = Reader::new(data); + r.large_canvas = large_canvas.then_some(1.0); r.run()?; r.finish() } @@ -392,8 +395,9 @@ struct Reader<'a> { art_size: Option<(f64, f64)>, template_center: Option<(f64, f64)>, /// `%AI24_LargeCanvasScale`: a Large Canvas document's editing data is written at 1/N scale - /// (see [`Reader::ensure_space`]); 1 (no scaling) when the file doesn't declare one. - large_canvas: f64, + /// (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, /// This reader reads a pattern's definition (see [`Reader::define_pattern`]). pattern: Option, /// The tile's top left (y down) of each pattern read, by name. @@ -528,7 +532,7 @@ impl<'a> Reader<'a> { slot_names: HashMap::new(), art_size: None, template_center: None, - large_canvas: 1.0, + large_canvas: None, pattern: None, pattern_tiles: HashMap::new(), text: None, @@ -669,10 +673,10 @@ impl<'a> Reader<'a> { // 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::() + if let (Some(scale), Ok(n)) = (&mut self.large_canvas, v.trim().parse::()) && (1.0..=100.0).contains(&n) { - self.large_canvas = n; + *scale = n; } } else if let Some(v) = line.strip_prefix("AI9_ColorModel:") { self.color_model = Some(v.trim() == "2"); @@ -1881,7 +1885,7 @@ impl<'a> Reader<'a> { } let first = self.first_board().or_else(|| self.bbox_board()); let top_left = first.map_or(Point::new(0.0, 792.0), |r| Point::new(r.x0, r.y1)); - let s = self.large_canvas; + let s = self.large_canvas.unwrap_or(1.0); let m = Affine::new([s, 0.0, 0.0, -s, -s * top_left.x, s * top_left.y]); self.to_doc = Some(m); m diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 2f9552b55..5b201fd68 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -9,6 +9,11 @@ use vectorcraft_doc::{AppearanceItem, ColorMode, Document, Knockout, LayerColor, use super::*; +/// [`super::read`] as an EPS reads it. +fn read(data: &[u8]) -> Result { + super::read(data, true) +} + /// Editing data: header comments (crop marks 0 0 200 100), then `body`. fn stream(body: &str) -> String { format!( diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 32a6a966e..729bb807d 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -872,7 +872,9 @@ struct Frame<'a> { /// nothing is compared. `outlined`: the page's type was read as outlines, so it has none to put in /// the text objects. fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool, trust: bool) -> Result<(Document, Vec), String> { - let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, warnings } = ai::read(data)?; + // An EPS (its page is its art) draws a Large Canvas page at full size; a `.ai` keeps its art + // space, as its PDF part may (#2031). + let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, warnings } = ai::read(data, matches!(page, Page::Art))?; let (mut shown, mut hidden) = (vec![], vec![]); let mut frame = None; if let Some(visible) = visible { diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 51f88251a..0c5ee221d 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -269,6 +269,10 @@ fn a_large_canvas_eps_keeps_its_layers_at_the_pages_size() { let r = import(&eps(&page_square(10, 10), &art)).unwrap(); assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings); assert!(r.warnings.iter().any(|w| w.contains("different page")), "{:?}", r.warnings); + // A `.ai` keeps its art space: its PDF part is read at the size it declares. + let art = native_large_canvas(100.0, 10.0, &layer("Art", true, &square(10, 10))); + let (d, w, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false); + assert!(read && names(&d) == ["Art"], "{w:?}"); } /// A `.ai` whose layers can't be read comes in as its PDF part, a page per artboard in a row; its