diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 60aace2351..2ca51b3e00 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -393,8 +393,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result return Err(err(format!("can't open `{}`: {}", file_name(name), crate::cmd::place::dcs::LINK_ONLY))); } let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes))); + // A `.ai` keeps the artboard its header gives; an EPS comes in on its page. + let import = if matches!(format.id, "ai" | "ait") { vectorcraft_eps::import_ai } else { vectorcraft_eps::import_names }; restore_or_import(editing, || { - let r = vectorcraft_eps::import_names(bytes, opts.editing_data, opts.trust_editing_data, opts.legacy_names) + let r = import(bytes, opts.editing_data, opts.trust_editing_data, opts.legacy_names) .map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?; Ok((r.document, r.warnings)) })? diff --git a/crates/engine/src/cmd/fileio/tests_epsimport.rs b/crates/engine/src/cmd/fileio/tests_epsimport.rs index 59caf85d6e..6245a001bd 100644 --- a/crates/engine/src/cmd/fileio/tests_epsimport.rs +++ b/crates/engine/src/cmd/fileio/tests_epsimport.rs @@ -277,3 +277,17 @@ fn ps_and_epsf_files_open_and_place() { let e = writable_format(None, Some("out.ps")).unwrap_err(); assert_eq!(e, "PostScript files can be opened but not written (see document.formats)"); } + +/// A program in the legacy `.ai` format with a template box and no art size (version 3) opens as a +/// `.ai` or `.ait` on a US Letter artboard around the box's center, and as an EPS on its bounding +/// box. +#[test] +fn a_legacy_ai_program_opens_on_its_artboard_and_an_eps_on_its_page() { + let program = b"%!PS-Adobe-2.0 EPSF-1.2\n%%Creator: a script\n%%BoundingBox: 10 10 30 30\n%AI3_TemplateBox: 100 100 100 100\n\ + %%EndComments\n%%BeginProlog\n/m {moveto} def /L {lineto} def /f {closepath fill} def /k {setcmykcolor} def\n\ + %%EndProlog\n%%BeginSetup\n%%EndSetup\n0 0 0 1 k\n10 10 m\n30 10 L\n30 30 L\n10 30 L\nf\n%%PageTrailer\nshowpage\n%%Trailer\n%%EOF\n"; + for (name, size) in [("old.ai", (612.0, 792.0)), ("old.ait", (612.0, 792.0)), ("old.eps", (20.0, 20.0))] { + let r = load(name, program).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(r.doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(size.0, size.1), "{name}: {:?}", r.warnings); + } +} diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index c791f7c459..1adfaf0d4b 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -24,7 +24,9 @@ //! from those layers alone, without its page. //! //! The page is the file's `%%BoundingBox` (`%%HiResBoundingBox` when it has one), the first page -//! of a PostScript file without one. A file whose program can't be read (an operator the +//! of a PostScript file without one. A `.ai` in the legacy format ([`import_ai`]) that comes in +//! through its layers keeps the artboard its header gives, with its art outside it; an EPS comes in +//! on its page. A file whose program can't be read (an operator the //! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in from its //! layers when its program has them (a `.ai` in PostScript form whose prolog is named but not //! included, as other apps write it), or with its art on one layer when it is in the legacy format @@ -279,7 +281,20 @@ pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result< /// and data sets) read in `names`. The type of the document it gives that is set in families that /// aren't available shows in the fallback font, and a warning names them. pub fn import_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding) -> Result { - let mut r = read_names(bytes, editing_data, trust, names)?; + read_checked(bytes, editing_data, trust, names, false) +} + +/// [`import_names`] for a `.ai` (or `.ait`) in PostScript form: a program in the legacy format +/// that comes in through its layers keeps the artboard its header gives, with its art outside it, +/// where an EPS comes in on its page, its art's box. A `.ai` of versions 3 and 4, which doesn't +/// give its artboard's size, has a US Letter artboard around its template box's center. +pub fn import_ai(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding) -> Result { + read_checked(bytes, editing_data, trust, names, true) +} + +/// [`import_names`] (a `.ai` when `ai_file`, see [`import_ai`]). +fn read_checked(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding, ai_file: bool) -> Result { + let mut r = read_names(bytes, editing_data, trust, names, ai_file)?; let missing = missing_fonts(&r.document); if !missing.is_empty() { r.warnings.push(format!("{MISSING_FONTS_NOTE}: {}", missing.join(", "))); @@ -314,8 +329,8 @@ fn missing_fonts(d: &Document) -> Vec { missing } -/// [`import_names`] before the type's fonts are checked. -fn read_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding) -> Result { +/// [`import_names`] before the type's fonts are checked (a `.ai` when `ai_file`, see [`import_ai`]). +fn read_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding, ai_file: bool) -> Result { let ps = program(bytes)?; let dsc = Dsc::read(ps); // A legacy `.ai` VectorCraft wrote: its prolog prints the art plainly (gradients flat, no @@ -323,7 +338,7 @@ fn read_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding // printed). if editing_data && dsc.own_legacy - && let Some(Ok((document, warnings))) = native::program_alone(ps, dsc.illustrator, names) + && let Some(Ok((document, warnings))) = native::program_alone(ps, dsc.illustrator, ai_file, names) { return Ok(Imported { document, warnings, preview: false }); } @@ -343,7 +358,7 @@ fn read_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding } let drew = !out.drawn.is_empty(); let why = match (&result, drew) { - (Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out), trust, dsc.illustrator, names)), + (Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out), trust, dsc.illustrator, ai_file, names)), (Ok(()), true) => return Ok(finish(out)), (Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(), (Err(e), _) => reason(e, fault.as_ref()), @@ -351,7 +366,7 @@ fn read_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding // A file in the legacy Illustrator format whose program can't be run (a `.ai` that names the // prolog defining its operators without including it): its layers, which the program has, or // its art on one layer when it has none (Illustrator 3). - let why = match editing_data.then(|| native::program_alone(ps, dsc.illustrator, names)).flatten() { + let why = match editing_data.then(|| native::program_alone(ps, dsc.illustrator, ai_file, names)).flatten() { Some(Ok((document, warnings))) => return Ok(Imported { document, warnings, preview: false }), Some(Err(e)) => format!("{why}; its layers couldn't be read either ({e})"), None => why, diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index 4595061151..1a73f64a50 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -55,8 +55,8 @@ pub use read::{ palette, read_beside, read_in, symbols, variables, }; pub(crate) use read::{ - MAX_NODES as MAX_EDITING_OBJECTS, MAX_SYMBOLS, join_and, read_art_style, read_over_page, read_style_list, slot_frame, slot_of, style_moves_with, - symbol_art_member, + MAX_NODES as MAX_EDITING_OBJECTS, MAX_SYMBOLS, join_and, read_art_style, read_over_page, read_program, read_style_list, slot_frame, slot_of, + style_moves_with, symbol_art_member, }; pub(crate) use read::is_look_slot; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index bc1b82c22d..d32ae4c177 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -21,9 +21,10 @@ //! - State: hidden `Xw`, locked `A`, transparency `mode opacity isolate knockout shape Xy`. //! - Names: an `/ArtDictionary` after an object, its `AI10_ArtUID` the name's XML id. //! - Artboards: the `ArtboardArray` of the `/Document` dictionary, with each one's display -//! options, else `%AI3_Cropmarks`, else the art size around the template box's centre -//! (`%AI3_TemplateBox`, `%%TemplateBox` in older files), else (a file in the legacy format -//! another app wrote) the bounding box. +//! options, else `%AI3_Cropmarks`, else the art size (`%AI5_ArtSize`; VectorCraft's own default, +//! US Letter, in a `.ai` of versions 3 and 4, which don't give it) around the template box's center (`%AI3_TemplateBox`, +//! `%%TemplateBox` in older files), which the version 7 specification (§2.2) puts at the +//! artboard's center, else (a file in the legacy format another app wrote) the bounding box. //! - Document settings: Document Raster Effects Settings from the `/Document` dictionary's //! `AI Auto Rasterize`; the author from the header's `%%For`; swatches for the custom colors of //! `%%CMYKCustomColor` and `%%RGBCustomColor` that the document has none of. @@ -273,12 +274,28 @@ pub(crate) fn read_over_page(data: &[u8], large_canvas: bool, names: NameEncodin read_with(data, large_canvas, true, names) } -fn read_with(data: &[u8], large_canvas: bool, page_art: bool, names: NameEncoding) -> Result { +/// [`read_in`] (or [`read_over_page`] when `page_art`) for the program of a `.ai` in the legacy +/// format: one of versions 3 and 4 that has a template box but no art size has a US Letter +/// artboard ([`Reader::sized_board`]). +pub(crate) fn read_program(data: &[u8], large_canvas: bool, page_art: bool, names: NameEncoding) -> Result { let mut r = Reader::with_names(data, names); - r.large_canvas = large_canvas.then_some(1.0); - r.page_art = page_art; - r.run()?; - r.finish() + r.legacy_ai = true; + r.read_as(large_canvas, page_art) +} + +fn read_with(data: &[u8], large_canvas: bool, page_art: bool, names: NameEncoding) -> Result { + Reader::with_names(data, names).read_as(large_canvas, page_art) +} + +impl Reader<'_> { + /// The structure read, the declared large canvas scale applied when `large_canvas`, the page's + /// art replacing the objects not read when `page_art` ([`read_over_page`]). + fn read_as(mut self, large_canvas: bool, page_art: bool) -> Result { + self.large_canvas = large_canvas.then_some(1.0); + self.page_art = page_art; + self.run()?; + self.finish() + } } /// How many objects [`read`] counts against [`MAX_NODES`] in the editing data `data`. @@ -645,6 +662,11 @@ 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)>, + /// The data is the program of a `.ai` in the legacy format ([`read_program`]), not an EPS's: + /// without an art size, its artboard can be US Letter ([`Reader::sized_board`]). + legacy_ai: bool, + /// The header has `%AI5_FileFormat`, which files of version 5 and later write. + file_format: bool, /// `%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, `None` /// when the declared factor is not applied ([`read`]). @@ -827,6 +849,8 @@ impl<'a> Reader<'a> { xml_id_bytes: 0, art_size: None, template_center: None, + legacy_ai: false, + file_format: false, large_canvas: None, page_art: false, canvas_scale: 1.0, diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index 0fe6b9da87..8ca3781717 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -242,10 +242,13 @@ impl<'a> Reader<'a> { && let Ok(n) = v.trim().parse::() { self.ruler_units = Some(n); + } else if self.frames.is_empty() && line.starts_with("AI5_FileFormat") { + self.file_format = true; } else if let Some(v) = line.strip_prefix("AI5_ArtSize:") { self.art_size = two(v); } else if let Some(v) = line.strip_prefix("AI3_TemplateBox:").or_else(|| line.strip_prefix("%TemplateBox:")) { - self.template_center = two(v); + // The center of the box (a file without a template writes it with no width or height). + self.template_center = four(v).map(|[x0, y0, x1, y1]| ((x0 + x1) / 2.0, (y0 + y1) / 2.0)).or_else(|| two(v)); self.template = four(v).or(self.template); } else if let Some(v) = line.strip_prefix("AI24_LargeCanvasScale:") { self.large_canvas_scale(v); diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index ba6a8bd2de..091b440758 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -871,9 +871,15 @@ impl<'a> Reader<'a> { // ---- the document ---------------------------------------------------------------------- /// The artboard of a file without artboards or crop marks: its size around the template - /// box's centre (half a point off the grid). + /// box's center (half a point off the grid). The program of a `.ai` of versions 3 and 4 + /// ([`Reader::legacy_ai`], no `%AI5_FileFormat`), which don't record the artboard's size, has + /// VectorCraft's own default there, a US Letter artboard (612 × 792 pt); an EPS, or a newer + /// file without `%AI5_ArtSize`, has none (its page is its bounding box). fn sized_board(&self) -> Option { - let ((w, h), (cx, cy)) = (self.art_size?, self.template_center?); + let (cx, cy) = self.template_center?; + let [_, _, letter_w, letter_h] = crate::import::LETTER; + let letter = self.legacy_ai && !self.file_format; + let (w, h) = self.art_size.or(letter.then_some((letter_w, letter_h)))?; // Rounded out, but not off a whole point the decimals in the file only just miss. let near = |v: f64| (v - v.round()).abs() < 1e-6; let (left, top) = (cx - w / 2.0, cy + h / 2.0); diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index c469f02cd4..09368870e8 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -19,6 +19,16 @@ pub(super) enum Page { Art, /// A `.ai` is read on its (first and only) artboard. Artboard, + /// A `.ai` in the legacy format (PostScript, not an EPS): its page is its art's bounding box, as + /// an EPS's is, and its artboard is the one its header gives. + Program, +} + +impl Page { + /// Is the page the art's bounding box? + fn of_art(self) -> bool { + matches!(self, Page::Art | Page::Program) + } } /// How art moved from a page to its place in the layers' document transforms: its pattern tiles @@ -62,8 +72,12 @@ pub(super) fn build( ) -> Result<(Document, Vec), String> { // 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 large_canvas = matches!(page, Page::Art); - let structure = if visible.is_some() { ai::read_over_page(data, large_canvas, names)? } else { ai::read_in(data, large_canvas, names)? }; + let large_canvas = page.of_art(); + let structure = match page { + Page::Program => ai::read_program(data, large_canvas, visible.is_some(), names)?, + _ if visible.is_some() => ai::read_over_page(data, large_canvas, names)?, + _ => ai::read_in(data, large_canvas, names)?, + }; build_from(structure, data, visible, page, outlined, trust, unit) } @@ -184,7 +198,7 @@ fn page_scale_alone(structure: &Structure, pages: &[(f64, f64)], unit: f64) -> O /// page, which draws nothing for them, the linked images' `placeholders`. A PDF part draws those /// images' pixels, which the placeholders don't match. fn left_out_of_comparisons(page: Page, mut restyled: Vec, placeholders: Vec) -> Vec { - if matches!(page, Page::Art) { + if page.of_art() { restyled.extend(placeholders); } restyled @@ -295,7 +309,7 @@ pub(super) fn build_weighing( let mut frame = None; if let Some(visible) = visible { let art_box = match page { - Page::Art => bbox.map(|[a, b, c, d]| Rect::new(a, b, c, d)).ok_or("its editing data doesn't say where its art is")?, + Page::Art | Page::Program => bbox.map(|[a, b, c, d]| Rect::new(a, b, c, d)).ok_or("its editing data doesn't say where its art is")?, 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")?; @@ -393,8 +407,9 @@ pub(super) fn build_weighing( // An EPS saved as its artboard has the art outside it left out of its page. let artboard_page = artboard.is_some_and(|a| [a.x0 - art_box.x0, a.y0 - art_box.y0, a.x1 - art_box.x1, a.y1 - art_box.y1].iter().all(|d| d.abs() <= 1.0)); - let whole = matches!(page, Page::Art) && !artboard_page; - if whole { + let whole = page.of_art() && !artboard_page; + // A `.ai` keeps the artboard its editing copy gives, with the art outside it. + if whole && matches!(page, Page::Art) { // Its page is the file's own (its bounding box), as when it comes in as its page: art // reaching past the editing data's artboard stays on it (#1102). doc.artboards.truncate(1); @@ -1815,7 +1830,7 @@ fn last_boards(boards: &[vectorcraft_doc::Artboard], pages: &[Rect]) -> Option bool) -> usize { - if matches!(page, Page::Art) || visible.artboards.len() >= doc.artboards.len() { + if page.of_art() || visible.artboards.len() >= doc.artboards.len() { return 0; } let pages: Vec = visible.artboards.iter().map(|a| a.rect).collect(); diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index afd8f2568c..5154718a2f 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -13,12 +13,16 @@ use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect /// ([`use_symbols`]) and its variable library ([`use_variables`]) when it could be read, and with /// its layer tree, empty, below the page's layer when that could be read (#2033). A program in the /// legacy format without layers (`illustrator`, see [`program_alone`]) is read with its art on one -/// layer; when that can't be used, the file is `visible` as it was. `names`: the encoding of names -/// that aren't UTF-8. -pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illustrator: bool, names: ai::NameEncoding) -> Imported { +/// layer; when that can't be used, the file is `visible` as it was. The program of a `.ai` +/// (`ai_file`) in the legacy format keeps the artboard its header gives ([`Page::Program`]). +/// `names`: the encoding of names that aren't UTF-8. +pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illustrator: bool, ai_file: bool, names: ai::NameEncoding) -> Imported { + let program_page = if ai_file { Page::Program } else { Page::Art }; let Some(data) = editing_copy(ps) else { - let read = - illustrator.then(|| one_layer(ps)).flatten().and_then(|p| build(&p, Some(&visible.document), Page::Art, false, trust, 1.0, names).ok()); + let read = illustrator + .then(|| one_layer(ps)) + .flatten() + .and_then(|p| build(&p, Some(&visible.document), program_page, false, trust, 1.0, names).ok()); return match read { Some((document, warnings)) => with_page_notes(document, warnings, &visible), None => visible, @@ -28,11 +32,17 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illus // can't be used. // Private data kept apart from the program: the program's own header gives the author and // custom colors the private data's header may lack. - let read = - |d: &[u8]| if matches!(data, Ok(Cow::Owned(_))) { ai::read_beside(d, true, ps, true, names) } else { ai::read_over_page(d, true, names) }; + let private = matches!(data, Ok(Cow::Owned(_))); + let read = |d: &[u8]| match (private, program_page) { + (true, _) => ai::read_beside(d, true, ps, true, names), + (false, Page::Program) => ai::read_program(d, true, true, names), + (false, _) => ai::read_over_page(d, true, names), + }; + // Private data is an EPS's editing copy, whatever the file is named. + let page = if private { Page::Art } else { program_page }; let (tree, result) = match data.as_deref() { Ok(d) => match read(d) { - Ok(s) => (layer_tree(&s.doc.layers), build_from(s, d, Some(&visible.document), Page::Art, false, trust, 1.0)), + Ok(s) => (layer_tree(&s.doc.layers), build_from(s, d, Some(&visible.document), page, false, trust, 1.0)), Err(why) => (vec![], Err(why)), }, Err(why) => (vec![], Err(why.clone())), @@ -607,16 +617,23 @@ fn editing_copy(ps: &[u8]) -> Option, String>> { /// but whose program has no layers (Illustrator 3 had none; image editors still export their /// paths so, storytold/photocraft#6036) is read with its art on one layer. `None` when the program /// has no layers and isn't in the format: other PostScript isn't read as the format's operators. +/// The program of a `.ai` (`ai_file`) has the artboard its header gives ([`Page::Program`]). /// `names`: the encoding of names that aren't UTF-8. -pub(in super::super) fn program_alone(ps: &[u8], illustrator: bool, names: ai::NameEncoding) -> Option), String>> { +pub(in super::super) fn program_alone( + ps: &[u8], + illustrator: bool, + ai_file: bool, + names: ai::NameEncoding, +) -> Option), String>> { if ai::has_eps_data(ps) { return None; } + let page = if ai_file { Page::Program } else { Page::Art }; if has_layers(ps) { - return Some(build(ps, None, Page::Art, false, false, 1.0, names)); + return Some(build(ps, None, page, false, false, 1.0, names)); } let program = illustrator.then(|| one_layer(ps)).flatten()?; - Some(build(&program, None, Page::Art, false, false, 1.0, names)) + Some(build(&program, None, page, false, false, 1.0, names)) } /// `ps` with its art (from its `%%EndSetup` to its `%%PageTrailer`, else its `%%Trailer`, else its diff --git a/crates/eps/src/import/tests_illustrator.rs b/crates/eps/src/import/tests_illustrator.rs index 19d89ad33f..4cae36ac52 100644 --- a/crates/eps/src/import/tests_illustrator.rs +++ b/crates/eps/src/import/tests_illustrator.rs @@ -9,7 +9,7 @@ use vectorcraft_doc::clipnest::MAX_NEST; use vectorcraft_doc::{LayerColor, LineCap, Node, NodeKind}; use vectorcraft_geom::Rect; -use crate::import::import; +use crate::import::{import, import_ai}; /// A minimal file in Illustrator's format: its header comments, a prolog of our own defining the /// operators `body` uses (`u` and `U` as `ugroup` and `Ugroup`), and `body`. @@ -329,6 +329,100 @@ fn a_legacy_file_with_its_prolog_comes_in_through_its_layers() { assert_eq!(page.document.layers[0].name.as_deref(), Some("Layer 1")); } +/// A `.ai` in the legacy format (version 8, `%AI5_FileFormat 4.0`) whose prolog is included: a +/// square on its 200-point artboard and one far to its right. Its bounding box is the box of that +/// art; its artboard is its art size around the template box's center (version 7 specification +/// §2.2). Its first line names the EPSF version, as the format's files may. +const LEGACY_AI_ART_OFF_ITS_ARTBOARD: &str = "%!PS-Adobe-2.0 EPSF-1.2\n%%Creator: a script\n%%BoundingBox: 10 10 320 30\n\ + %%HiResBoundingBox: 10 10 320 30\n%AI5_FileFormat 4.0\n%AI3_TemplateBox: 100.5 99.5 100.5 99.5\n%AI5_ArtSize: 200 200\n\ + %%EndComments\n%%BeginProlog\n/m {moveto} def /L {lineto} def /f {closepath fill} def\n\ + /Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def /A {pop} def /k {setcmykcolor} def\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n\ + %AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Art) Ln\n0 A\n0 0 0 1 k\n\ + 10 10 m\n30 10 L\n30 30 L\n10 30 L\nf\n300 10 m\n320 10 L\n320 30 L\n300 30 L\nf\nLB\n%AI5_EndLayer--\n\ + %%PageTrailer\nshowpage\n%%Trailer\n%%EOF\n"; + +/// `bytes` opened as a `.ai`. +fn open_ai(bytes: &[u8]) -> crate::Imported { + import_ai(bytes, true, false, crate::NameEncoding::default()).unwrap() +} + +/// Such a `.ai` keeps the artboard its header gives, with the square outside it, when it comes in +/// through its layers. The same program opened as an EPS comes in on its page, its art's box +/// (#1102). +#[test] +fn a_legacy_ai_keeps_its_artboard_and_the_art_outside_it() { + let r = open_ai(LEGACY_AI_ART_OFF_ITS_ARTBOARD.as_bytes()); + assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings); + let d = &r.document; + let boards: Vec<_> = d.artboards.iter().map(|a| a.rect).collect(); + assert_eq!(boards, [Rect::new(0.0, 0.0, 200.0, 200.0)]); + let art: Vec<_> = d.layers[0].children().unwrap().iter().map(|n| n.geometric_bounds().unwrap()).collect(); + // Art space has y up from the artboard's bottom: (10, 10) is 10 above it. + assert_eq!(art, [Rect::new(10.0, 170.0, 30.0, 190.0), Rect::new(300.0, 170.0, 320.0, 190.0)]); + let eps = import(LEGACY_AI_ART_OFF_ITS_ARTBOARD.as_bytes()).unwrap(); + let boards: Vec<_> = eps.document.artboards.iter().map(|a| a.rect.size()).collect(); + assert_eq!(boards, [kurbo::Size::new(310.0, 20.0)], "{:?}", eps.warnings); +} + +/// A version 3 `.ai` doesn't give its artboard's size (`%AI5_ArtSize` came with version 5): its +/// artboard is VectorCraft's own default, a US Letter page, around the template box's center, the +/// artboard's center (version 7 specification §2.2). Without a template box it is the art's box, +/// and so is the page of the same file opened as an EPS. +#[test] +fn a_version_3_ai_has_a_letter_artboard_around_its_template_boxs_center() { + let v3 = PATHS_ONLY.replace("%AI3_Cropmarks: 0 0 200 100\n", "%AI3_TemplateBox: 100 100 100 100\n"); + let r = open_ai(v3.as_bytes()); + assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings); + let d = &r.document; + let boards: Vec<_> = d.artboards.iter().map(|a| a.rect).collect(); + assert_eq!(boards, [Rect::new(0.0, 0.0, 612.0, 792.0)]); + // The artboard runs from -206 to 406 across and from -296 to 496 up in art space. + let art = d.layers[0].children().unwrap(); + assert_eq!(art[1].geometric_bounds(), Some(Rect::new(226.0, 446.0, 266.0, 476.0))); + // The center of a template box with a size (a file with a template image). + let r = open_ai(PATHS_ONLY.replace("%AI3_Cropmarks: 0 0 200 100\n", "%AI3_TemplateBox: 50 50 150 150\n").as_bytes()); + assert_eq!(r.document.layers[0].children().unwrap()[1].geometric_bounds(), Some(Rect::new(226.0, 446.0, 266.0, 476.0))); + let r = open_ai(PATHS_ONLY.replace("%AI3_Cropmarks: 0 0 200 100\n", "%AI3_ColorUsage: Color\n").as_bytes()); + assert_eq!(r.document.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), "{:?}", r.warnings); + let r = import(v3.as_bytes()).unwrap(); + assert_eq!(r.document.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), "{:?}", r.warnings); + // A newer file (`%AI5_FileFormat`, version 5 on) without an art size keeps the art's box too. + let r = open_ai(v3.replace("%%EndComments", "%AI5_FileFormat 1.0\n%%EndComments").as_bytes()); + assert_eq!(r.document.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), "{:?}", r.warnings); +} + +/// A version 3 `.ai` whose prolog is included and whose program has no layers: its page draws, and +/// its art comes in on one layer around a US Letter artboard, the square far to the right outside +/// it. +#[test] +fn a_version_3_ai_that_draws_keeps_its_letter_artboard_and_the_art_outside_it() { + let v3 = "%!PS-Adobe-2.0 EPSF-1.2\n%%Creator: a script\n%%BoundingBox: 10 10 720 30\n%AI3_TemplateBox: 100 100 100 100\n\ + %%EndComments\n%%BeginProlog\n/m {moveto} def /L {lineto} def /f {closepath fill} def /k {setcmykcolor} def\n\ + %%EndProlog\n%%BeginSetup\n%%EndSetup\n0 0 0 1 k\n10 10 m\n30 10 L\n30 30 L\n10 30 L\nf\n\ + 700 10 m\n720 10 L\n720 30 L\n700 30 L\nf\n%%PageTrailer\nshowpage\n%%Trailer\n%%EOF\n"; + let r = open_ai(v3.as_bytes()); + assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings); + let boards: Vec<_> = r.document.artboards.iter().map(|a| a.rect).collect(); + assert_eq!(boards, [Rect::new(0.0, 0.0, 612.0, 792.0)]); + assert_eq!(r.document.layers.len(), 1); + let art: Vec<_> = r.document.layers[0].children().unwrap().iter().map(|n| n.geometric_bounds().unwrap()).collect(); + // The artboard runs from -206 to 406 across and from -296 to 496 up in art space. + assert_eq!(art, [Rect::new(216.0, 466.0, 236.0, 486.0), Rect::new(906.0, 466.0, 926.0, 486.0)]); + let eps = import(v3.as_bytes()).unwrap(); + assert_eq!(eps.document.artboards[0].rect, Rect::new(0.0, 0.0, 710.0, 20.0), "{:?}", eps.warnings); +} + +/// A `.ai` another app wrote (#1027) that gives a template box but no art size opens on a US Letter +/// artboard around the box's center, from -18 to 594 across and -12 to 780 up in art space (as an +/// EPS it opens on its bounding box, see above). +#[test] +fn a_postscript_ai_with_a_template_box_and_no_art_size_opens_on_a_letter_artboard() { + let r = open_ai(NAMED_PROLOG.as_bytes()); + assert_eq!(r.document.artboards[0].rect, Rect::new(0.0, 0.0, 612.0, 792.0), "{:?}", r.warnings); + let square = &r.document.layers[0].children().unwrap()[0]; + assert_eq!(square.geometric_bounds(), Some(Rect::new(28.0, 690.0, 108.0, 770.0))); +} + /// A placed EPS in the legacy format (version 7 specification §16, no `%AI5_BeginPlace`), its file /// included whole: the layers come in, the placed file as the art the page draws in its box. #[test] diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 4c5b8dde8a..327dc22f6f 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -4791,6 +4791,9 @@ fn an_eps_page_of_its_art_stays_its_page_through_the_layers() { assert_eq!(boards.len(), 1); assert!((boards[0].width() - 4.0).abs() < 1e-6 && (boards[0].height() - 4.0).abs() < 1e-6, "{boards:?}"); assert!(boards[0].inflate(1e-6, 1e-6).contains_rect(art), "{boards:?} round {art:?}"); + // Its private data is an EPS's editing copy, whatever the file is named. + let named_ai = crate::import::import_ai(&file, true, false, crate::NameEncoding::default()).unwrap(); + assert_eq!(named_ai.document.artboards.iter().map(|a| a.rect).collect::>(), boards, "{:?}", named_ai.warnings); } #[test] diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index ada3171e0a..e36422b759 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -49,7 +49,7 @@ mod tiff; pub use ai_editing::{AiEditingOptions, AiEditingOutput, CREATOR_VERSION, export_ai_editing, private_data as ai_private_data}; pub use import::{ AiLayers, Imported, NameEncoding, POSTSCRIPT_ERRORS, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, - family_style, import, import_names, import_trusting, import_with, is_loss, layered_ai, layered_ai_from, page, + family_style, import, import_ai, import_names, import_trusting, import_with, is_loss, layered_ai, layered_ai_from, page, }; pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai, writes_image_mask}; pub use print::{PrintJob, PrintPage, print};