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)