diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index d20201dea..8ac7b7bd9 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3426,6 +3426,19 @@ proptest! { let eps = ai::eps(data.as_bytes(), ai::page_ps()); survive("multi-layer masks in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?; } + + /// The same file with placed EPS files in the version 7 form (no `%AI5_BeginPlace`), one + /// linked and one included whole with a nested document and counted data in it, damaged. + #[test] + fn ps_form_ai_with_placed_files_never_panics(cut in 0usize..1_600, edits in prop::collection::vec((0usize..1_600, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '[', ']', '%', '`', '~', 'E', 'D'])), 0..12)) { + let placed = "[1 0 0 1 10 20] 10 20 50 50 (a.eps) `\n%%IncludeFile: a.eps\n~\n\ + [-1 0 0 1 100 0] 10 20 50 50 4 4 0 (b.eps) `\n%%BeginDocument: b.eps\n%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 10 20 50 50\n\ + %%BeginDocument: c.eps\n~\n%%EndDocument\n%%BeginData: 14 Binary Bytes\n%%EndDocument\n0 0 1 setrgbcolor 10 20 40 30 rectfill\n%%EndDocument\n~\n"; + let fixture = NAMED_PROLOG_AI.replacen("S\nu\n", &format!("S\n{placed}u\n"), 1); + prop_assert!(fixture.contains("(b.eps)")); + let text = mutate_text(&fixture, cut, &edits); + survive("mutated PostScript-form .ai with placed files", || vectorcraft_engine::cmd::fileio::load("x.ai", text.as_bytes()).ok().map(|l| l.doc))?; + } } /// An operand of a layer's options (`Lb`): a flag, a colour index or component, a percentage, or @@ -3665,6 +3678,16 @@ fn arb_ai_token() -> impl Strategy { "\n%AI28_EndPlacedRelativePath\n", "`", "~", + // The version 7 form (§16): an 11-value `` ` `` with no `%AI5_BeginPlace`, its file + // linked or included whole, a nested document and counted data in that. + "[1 0 0 1 10 20] 10 20 50 50 (a.eps) `", + "[0 0 0 0 0 0] 0 0 0 0 (a.eps) `", + "\n%%IncludeFile: a.eps\n", + "\n%%BeginDocument: a.eps\n", + "\n%%EndDocument\n", + "\n%%BeginData: 14 Binary Bytes\n", + "\n%%BeginBinary: 99999999999\n", + "\n%%BeginData: 3 Hex Lines\n", "\n%AI5_EndPlace\n", "Xm", "50 -50 -100 -100 5000 -2000 Xm", diff --git a/crates/eps/src/import/ai/lex.rs b/crates/eps/src/import/ai/lex.rs index 49746a1ed..eb8841e0d 100644 --- a/crates/eps/src/import/ai/lex.rs +++ b/crates/eps/src/import/ai/lex.rs @@ -8,6 +8,9 @@ //! line starts a comment that is skipped. //! - The binary image data after `%%BeginData:` is one [`Tok::Data`]; the binary preview of a placed //! file is one [`Tok::Preview`]. +//! - A document included whole (`%%BeginDocument:` … `%%EndDocument` lines, DSC 3.0 p. 68, nested +//! ones counted, the counted payloads of `%%BeginData:` and `%%BeginBinary:` inside skipped) is +//! one [`Tok::Document`]. use super::super::lex::{find, hex_decode}; @@ -29,6 +32,9 @@ pub(super) enum Tok<'a> { Data(&'a [u8]), /// A placed file's preview: what is between `%AI26_BeginPlacedObjectPreview` and its end. Preview(&'a [u8]), + /// A document included whole: what is between `%%BeginDocument:`'s line and the line of the + /// `%%EndDocument` that ends it, or `None` when none does (the rest of the data is skipped). + Document(Option<&'a [u8]>), } #[derive(Clone, Debug, PartialEq)] @@ -46,7 +52,7 @@ const PLACE: [&[u8]; 2] = [b"%AI5_BeginPlace", b"%AI5_EndPlace"]; const NOTES: [&[u8]; 3] = [b"%AI3_Note:", b"%%Note:", b"%AI5_NoteMore:"]; /// The markers around a placed file's relative folder, which a hidden one has on `%_` lines too. const RELATIVE: [&[u8]; 2] = [b"%AI28_BeginPlacedRelativePath", b"%AI28_EndPlacedRelativePath"]; -/// A binary section skipped whole: (begin, end) markers. +/// A document included whole: (begin, end) markers. const DOCUMENT: (&[u8], &[u8]) = (b"%%BeginDocument", b"%%EndDocument"); fn is_space(b: u8) -> bool { @@ -93,6 +99,18 @@ impl<'a> Lexer<'a> { rest.get(..n).unwrap_or_default() } + /// [`Self::line`], the position moved past its end too (`\r`, `\n` or `\r\n`). + fn whole_line(&mut self) -> &'a [u8] { + let line = self.line(); + if self.peek() == Some(b'\r') { + self.pos += 1; + } + if self.peek() == Some(b'\n') { + self.pos += 1; + } + line + } + /// Where the next token is looked for. pub fn offset(&self) -> usize { self.pos @@ -185,13 +203,15 @@ impl<'a> Lexer<'a> { /// The token a comment line stands for when it starts a section read differently: image data /// (`%%BeginData:` followed by `XI`), a placed file's preview and skipped binary sections. fn special(&mut self, line: &'a [u8]) -> Option> { - if let Some((_, end)) = [PREVIEW, DOCUMENT].iter().find(|(b, _)| line.starts_with(b)) { + if line.starts_with(PREVIEW.0) { let from = self.pos; - let after = find(self.rest(), end).map_or(self.src.len(), |i| self.pos + i); + let after = find(self.rest(), PREVIEW.1).map_or(self.src.len(), |i| self.pos + i); self.pos = after; self.line(); - let tok = if *end == PREVIEW.1 { Tok::Preview(self.slice(from, after)) } else { Tok::Comment(line.get(1..).unwrap_or_default()) }; - return Some(Token { tok, hidden: false }); + return Some(Token { tok: Tok::Preview(self.slice(from, after)), hidden: false }); + } + if line.starts_with(DOCUMENT.0) { + return Some(Token { tok: Tok::Document(self.document()), hidden: false }); } let n = line.strip_prefix(b"%%BeginData:")?; let n: usize = std::str::from_utf8(n).ok()?.split_whitespace().next()?.parse().ok()?; @@ -226,6 +246,55 @@ impl<'a> Lexer<'a> { Some(Token { tok: Tok::Data(samples), hidden: false }) } + /// After a `%%BeginDocument:` line: the document up to the start of the `%%EndDocument` line + /// that ends it, the position moved past that line. A `%%BeginDocument:` line inside starts a + /// nested document, and the bytes a `%%BeginData:` or `%%BeginBinary:` line inside counts are + /// skipped (DSC 3.0 pp. 68 and 72): a marker in them ends nothing. `None` when no line ends it: + /// the position moves to the end of the data. + fn document(&mut self) -> Option<&'a [u8]> { + // The rest of the `%%BeginDocument:` line is its end; the line after the document starts + // tokens afresh (not hidden unless it starts with `%_`). + self.whole_line(); + self.hidden = false; + let from = self.pos; + let mut depth = 0usize; + while self.pos < self.src.len() { + let start = self.pos; + // A hidden object's document is on `%_` lines. + let line = self.whole_line(); + let line = line.strip_prefix(b"%_").unwrap_or(line); + if line.starts_with(DOCUMENT.1) { + if depth == 0 { + return Some(self.slice(from, start)); + } + depth -= 1; + } else if line.starts_with(DOCUMENT.0) { + depth = depth.saturating_add(1); + } else if let Some(n) = line.strip_prefix(b"%%BeginData:").or_else(|| line.strip_prefix(b"%%BeginBinary:")) { + self.skip_counted(n); + } + } + None + } + + /// Skip what a `%%BeginData:` or `%%BeginBinary:` comment counts (`n`, its text after the + /// colon): `count [type [Bytes|Lines]]`, bytes unless it says `Lines`. + fn skip_counted(&mut self, n: &[u8]) { + let mut words = std::str::from_utf8(n).unwrap_or_default().split_whitespace(); + let Some(count) = words.next().and_then(|c| c.parse::().ok()) else { return }; + if words.nth(1).is_some_and(|unit| unit.eq_ignore_ascii_case("lines")) { + for _ in 0..count { + if self.pos >= self.src.len() { + break; + } + self.whole_line(); + } + } else { + // The count starts after the comment's line end, which `document` has passed. + self.pos = self.pos.saturating_add(count).min(self.src.len()); + } + } + /// A regular token's characters. fn word(&mut self) -> &'a [u8] { let start = self.pos; @@ -372,11 +441,38 @@ mod tests { let toks = all(b"%AI26_BeginPlacedObjectPreview\r\n2 1 (\x00\xff\r\n%AI26_EndPlacedObjectPreview\r\nN\r\n"); assert_eq!(toks, vec![t(Tok::Preview(b"\r\n2 1 (\x00\xff\r\n"), false), t(Tok::Word(b"N"), false)]); let toks = all(b"%%BeginDocument: a.eps\n(\x00 frobnicate\n%%EndDocument\nN\n"); - assert_eq!(toks, vec![t(Tok::Comment(b"%BeginDocument: a.eps"), false), t(Tok::Word(b"N"), false)]); + assert_eq!(toks, vec![t(Tok::Document(Some(b"(\x00 frobnicate\n")), false), t(Tok::Word(b"N"), false)]); // One that doesn't end takes the rest. assert_eq!(all(b"%AI26_BeginPlacedObjectPreview\r1 (").len(), 1); } + #[test] + fn an_included_document_ends_at_its_own_end_line() { + // A nested document, `%%EndDocument` later in a line and in counted data end nothing; `\r\n`, + // `\r` and `\n` lines alike. + let body = b"1 0 0 setrgbcolor\r\n%%BeginDocument: inner.eps\r(%%EndDocument) pop\r%%EndDocument\r\n\ + %%BeginData: 14 Binary Bytes\n%%EndDocument\n%%BeginBinary: 14\n%%EndDocument\n\ + %%BeginData: 1 Hex Lines\n%%EndDocument\n"; + let mut src = b"%%BeginDocument: outer.eps\r\n".to_vec(); + src.extend_from_slice(body); + src.extend_from_slice(b"%%EndDocument\r\n~\r\n"); + assert_eq!(all(&src), vec![t(Tok::Document(Some(body)), false), t(Tok::Word(b"~"), false)]); + // On a hidden object's `%_` lines. + let toks = all(b"%_%%BeginDocument: a.eps\r%_1 0 0 setrgbcolor\r%_%%EndDocument\r%_~\r"); + assert_eq!(toks, vec![t(Tok::Document(Some(b"%_1 0 0 setrgbcolor\r")), true), t(Tok::Word(b"~"), true)]); + // The line after it is hidden only when it starts with `%_`. + let toks = all(b"%_%%BeginDocument: a.eps\r%_%%EndDocument\rN\r"); + assert_eq!(toks, vec![t(Tok::Document(Some(b"")), true), t(Tok::Word(b"N"), false)]); + } + + #[test] + fn an_included_document_that_doesnt_end_takes_the_rest() { + for src in [&b"%%BeginDocument: a.eps\n%%BeginDocument: b.eps\n%%EndDocument\n~\n"[..], b"%%BeginDocument: a.eps\n%%BeginData: 99999999999\n"] + { + assert_eq!(all(src), vec![t(Tok::Document(None), false)]); + } + } + #[test] fn skip_past_data() { let mut l = Lexer::new(b"/Binary : /ASCII85Decode ,\r\n%_(abc[%\r\n%x~>\r\n%_; 1"); diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 40c667f1d..7fedcc826 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -40,6 +40,11 @@ //! box is as large as the file has pixels; the matrix takes them into art space from the canvas //! (y down, its centre the template box's), where they start at the point of art space's origin, //! the first row the top one. Each is a linked image showing the preview until its file is found. +//! The version 7 form (§16) has no `%AI5_BeginPlace`: `[matrix] llx lly urx ury (path)` and +//! `` ` ``, then `%%IncludeFile:` or the file between `%%BeginDocument:` and `%%EndDocument`, +//! then `~`; its box is the file's `%%BoundingBox`. Newer files have three more numbers before +//! the path. Operands of any other shape, a second `` ` `` before `~` or an included document +//! that doesn't end leave the placed file unread. //! - Art with an appearance the format can't write as plain art (several fills or strokes, //! effects, brushes) comes first as that look, drawn (a group, or an image where an effect //! rasterizes it), then the object itself on `%_` @@ -454,6 +459,13 @@ impl<'a> Reader<'a> { p.preview = preview_png(d); } } + // A placed file's document, included whole: its art isn't read here. When no + // `%%EndDocument` ends it, the placed file isn't read. + Tok::Document(d) => { + if let Some(p) = self.place.as_mut() { + p.broken |= d.is_none(); + } + } } } // A pattern's or symbol's reader opens a group of its own and checks it is the only frame @@ -468,6 +480,10 @@ impl<'a> Reader<'a> { if self.multi_mask.is_some() { return Err("it ends inside a multi-layer mask".into()); } + // A placed file that no `~` ends isn't read. + if let Some(open) = self.place.take() { + self.unreadable("placed files", open.hidden); + } Ok(()) } } diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index 5ab5965c9..36974f559 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -533,6 +533,10 @@ impl<'a> Reader<'a> { /// without a preview, a path or a matrix isn't read. pub(super) fn end_place(&mut self, hidden: bool) -> Result<(), String> { let (m, b, path, png, w, h, folder) = match self.place.take() { + Some(Place { broken: true, .. }) => { + self.unreadable("placed files", hidden); + return Ok(()); + } Some(Place { matrix: Some(m), bounds: Some(b), path, preview: Some((png, w, h)), folder, .. }) if !path.is_empty() => { (m, b, path, png, w, h, folder) } diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index bb0bbbc05..be4f6214c 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -149,7 +149,11 @@ impl<'a> Reader<'a> { return self.image_from(vals, &hex); } } else if line.starts_with("AI5_BeginPlace") { - self.place = Some(Place::default()); + // One that is still open (no `~` ended it) isn't read. + let unseen = hidden || self.gs.hidden || !self.shown(); + if let Some(open) = self.place.replace(Place { hidden: unseen, ..Place::default() }) { + self.unreadable("placed files", open.hidden); + } } else if line.starts_with("AI5_EndPlace") { // One that `~` didn't end isn't read. if self.place.is_some() { diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index fd37dfcb6..5d3cd3280 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1,6 +1,6 @@ use super::containers::close; use super::document::slot_of; -use super::types::{BLENDS, FLIP, Frame, GRAPH_OPS, GRAPH_PLAIN, GRAPH_UNENDED, IGNORED, Kind, MAX_DEPTH, MAX_NODES, line_width}; +use super::types::{BLENDS, FLIP, Frame, GRAPH_OPS, GRAPH_PLAIN, GRAPH_UNENDED, IGNORED, Kind, MAX_DEPTH, MAX_NODES, Place, line_width}; use super::{ Affine, Appearance, AppearanceItem, Arc, BlendMode, Dash, FillLayer, FillRule, Gradient, GradientDef, Graph, HashMap, HashSet, Instance, Knockout, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, Reader, Rect, StrokeLayer, V, obj, paint, @@ -288,14 +288,20 @@ impl<'a> Reader<'a> { } } // Placed files. - "`" if self.place.is_some() => { - let matrix = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }); - let path = vals.iter().rev().find_map(V::text).unwrap_or_default(); - if let Some(p) = self.place.as_mut() { - p.matrix = matrix.and_then(|m| <[f64; 6]>::try_from(m).ok()).map(Affine::new); - p.bounds = - Self::nums(&vals).get(..4).and_then(|b| <[f64; 4]>::try_from(b).ok()).map(|[x0, y0, x1, y1]| Rect::new(x0, y0, x1, y1)); - p.path = path; + // `[a b c d tx ty] llx lly urx ury (path) `` ` ``, three more numbers before the path in + // newer files: the version 7 form has no `%AI5_BeginPlace` before it. Before the art, + // the setup's procedures run. + "`" if self.place.is_some() || !self.frames.is_empty() => { + let unseen = hidden || self.gs.hidden || !self.shown(); + let p = self.place.get_or_insert_with(|| Place { hidden: unseen, ..Place::default() }); + if p.ticked { + p.broken = true; + } else { + p.ticked = true; + match tick(&vals) { + Some((m, b, path)) => (p.matrix, p.bounds, p.path) = (Some(m), Some(b), path), + None => p.broken = true, + } } } "~" if self.place.is_some() => self.end_place(hidden)?, @@ -691,6 +697,20 @@ struct StylePaints<'a> { space: Affine, } +/// A placed file's `` ` `` operands → its matrix, its box and its path: an array of six numbers, four +/// numbers, three more or none, and a string. `None` for any other shape or a number that isn't +/// finite. +fn tick(vals: &[V]) -> Option<(Affine, Rect, String)> { + let [V::Arr(m), rest @ .., V::Str(path)] = vals else { return None }; + let m = <[f64; 6]>::try_from(m.iter().map(V::num).collect::>>()?).ok()?; + let nums: Vec = rest.iter().map(V::num).collect::>()?; + let [x0, y0, x1, y1] = match nums.as_slice() { + [x0, y0, x1, y1] | [x0, y0, x1, y1, _, _, _] => [*x0, *y0, *x1, *y1], + _ => return None, + }; + m.iter().chain(&nums).all(|v| v.is_finite()).then(|| (Affine::new(m), Rect::new(x0, y0, x1, y1), obj::text(path))) +} + /// The gradients and patterns `look` paints with, placed as it draws them, by their swatch's name /// (the first of each; a pattern's name after `PATTERN_KEY`). fn placed_paints(look: &Node, out: &mut HashMap, depth: usize) { diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 7727460d3..39b77cf74 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -242,6 +242,13 @@ pub(super) struct Place { pub(super) folder: Option, /// The operand stack's height at `%AI28_BeginPlacedRelativePath`, until its end. pub(super) folder_at: Option, + /// Its `` ` `` was read. + pub(super) ticked: bool, + /// It began on a `%_` line, in a hidden object or on a layer that doesn't show. + pub(super) hidden: bool, + /// What it holds can't be read: operands of another shape, a second `` ` ``, or a document + /// included in it that doesn't end. + pub(super) broken: bool, } /// The longest relative folder of a placed file read (bytes). @@ -278,7 +285,6 @@ pub(super) const IGNORED: &[&str] = &[ "XD", "Xi", "XI", - "`", "LB2", "Lc", "Xv", diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 7e31c9ae7..a41b0a22a 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -423,7 +423,7 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { } continue; } - Tok::Comment(_) | Tok::Data(_) | Tok::Preview(_) => {} + Tok::Comment(_) | Tok::Data(_) | Tok::Preview(_) | Tok::Document(_) => {} } if operands.len() > 64 { operands.clear(); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 603a98f3b..549df50e6 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1513,6 +1513,173 @@ fn previewless_placement_records_box_corner_as_alternative() { assert_ne!(r.unread_elsewhere[0].1, r.doc.node(r.unread[0].0).unwrap().visual_bounds().unwrap()); } +/// Editing data in the version 7 format (§2): art size 200 × 100 around the template box's centre +/// (0, 0), then `body`. +fn placed_stream7(body: &str) -> String { + format!( + "%!PS-Adobe-3.0\n%%Creator: test\n%%BoundingBox: 0 0 200 100\n%AI5_FileFormat 2.0\n%AI5_ArtSize: 200 100\n\ + %AI3_TemplateBox: 0 0 0 0\n%%EndComments\n%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n{body}\n\ + %%PageTrailer\nshowpage\n%%Trailer\n%%EOF\n" + ) +} + +/// One layer of the version 7 format (ten operands before `Lb`), showing or not, holding `art`. +fn placed_layer7(visible: bool, art: &str) -> String { + format!("%AI5_BeginLayer\n{} 1 1 1 0 0 1 79 128 255 Lb\n(L) Ln\n0 A\n{art}\nLB\n%AI5_EndLayer--\n", u8::from(visible)) +} + +fn placed_read7(visible: bool, art: &str) -> Result { + read(placed_stream7(&placed_layer7(visible, art)).as_bytes()) +} + +/// The placed file's box (`llx lly urx ury`) taken by `matrix` into the document: where the +/// version 7 format puts it (§16.1). +fn placed_box(r: &Structure, matrix: [f64; 6], b: [f64; 4]) -> Rect { + (r.to_doc * kurbo::Affine::new(matrix)).transform_rect_bbox(Rect::new(b[0], b[1], b[2], b[3])) +} + +/// The placeholder's bounds and the other bounds [`Structure::unread_elsewhere`] keeps for it. +fn placeholder_boxes(r: &Structure) -> Vec { + let id = r.unread[0].0; + let mut boxes = vec![r.doc.node(id).unwrap().visual_bounds().unwrap()]; + boxes.extend(r.unread_elsewhere.iter().filter(|(o, _)| *o == id).map(|(_, b)| *b)); + boxes +} + +fn near(a: Rect, b: Rect) -> bool { + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-6) +} + +#[test] +fn version7_placed_files_are_read_without_a_begin_place() { + // `~` was an operator the reader didn't know, so the file's layers weren't read. + let matrices = [ + [1.0, 0.0, 0.0, 1.0, 10.0, 20.0], + [2.0, 0.0, 0.0, 0.5, -5.0, 3.0], + [-1.29904, 0.75, 0.375, 0.649519, 125.8461, 159.7668], + [-1.0, 0.0, 0.0, 1.0, 100.0, 0.0], + ]; + for m in matrices { + for extra in ["", " 4 4 0"] { + let [a, b, c, d, e, f] = m; + let body = format!( + "[{a} {b} {c} {d} {e} {f}] 10 20 50 50{extra} (art.eps) `\n%%IncludeFile: art.eps\n~\n{RED}{}f", + rect_path(0.0, 0.0, 5.0, 5.0) + ); + let r = placed_read7(true, &body).unwrap_or_else(|e| panic!("{m:?}{extra}: {e}")); + assert_eq!(art_kinds(&r.doc), ["path", "path"], "{m:?}{extra}: a placeholder, then the path after it"); + assert_unread(&r, "placed files"); + let want = placed_box(&r, m, [10.0, 20.0, 50.0, 50.0]); + assert!(placeholder_boxes(&r).iter().any(|b| near(*b, want)), "{m:?}{extra}: {:?} {want:?}", placeholder_boxes(&r)); + } + } +} + +/// The kinds of the first layer's objects. +fn art_kinds(doc: &Document) -> Vec<&'static str> { + art(doc) + .iter() + .map(|n| match n.kind { + NodeKind::Group { .. } => "group", + NodeKind::Path { .. } => "path", + NodeKind::Image(_) => "image", + _ => "other", + }) + .collect() +} + +#[test] +fn version7_placed_file_included_whole_is_read() { + // The included document's operators, a `~`, a nested document and counted data in it aren't + // the editing data's. + let eps = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 10 20 50 50\n%%EndComments\n1 0 0 setrgbcolor frobnicate ~\n\ + %%BeginDocument: inner.eps\n~\n%%EndDocument\n%%BeginData: 14 Binary Bytes\n%%EndDocument\n%%EOF\n"; + let body = format!("[1 0 0 1 10 20] 10 20 50 50 (art.eps) `\n%%BeginDocument: art.eps\n{eps}%%EndDocument\n~\n"); + let r = placed_read7(true, &body).unwrap(); + assert_eq!(art_kinds(&r.doc), ["path"]); + assert_unread(&r, "placed files"); + // One that doesn't end leaves the layer without its end. + let cut = format!("[1 0 0 1 10 20] 10 20 50 50 (art.eps) `\n%%BeginDocument: art.eps\n{eps}~\n"); + assert!(placed_read7(true, &cut).is_err()); +} + +#[test] +fn version7_placed_file_without_a_file_is_read() { + // `` ` `` followed by `~`: a placed file whose file isn't there. + let r = placed_read7(true, "[1 0 0 1 10 20] 10 20 50 50 (gone.eps) `\n~\n").unwrap(); + assert_unread(&r, "placed files"); + // In a hidden layer it is left out, with a note. + let r = placed_read7(false, "[1 0 0 1 10 20] 10 20 50 50 (gone.eps) `\n%%IncludeFile: gone.eps\n~\n").unwrap(); + assert!(r.unread.is_empty() && art(&r.doc).is_empty()); + assert!(r.hidden_unread.contains("placed files"), "{:?}", r.hidden_unread); +} + +#[test] +fn version7_placed_file_in_a_group_keeps_its_place() { + // In a group, the group is what isn't read. + let body = format!("u\n{RED}{}f\n[1 0 0 1 10 20] 10 20 50 50 (art.eps) `\n%%IncludeFile: art.eps\n~\nU\n", rect_path(0.0, 0.0, 5.0, 5.0)); + let r = placed_read7(true, &body).unwrap(); + assert_eq!(art_kinds(&r.doc), ["group"]); + assert_eq!(r.unread.iter().map(|(id, kinds)| (*id, kinds.clone())).collect::>(), [(art(&r.doc)[0].id, vec!["placed files".to_string()])]); + // A locked one keeps its lock. + let r = placed_read7(true, "1 A\n[1 0 0 1 10 20] 10 20 50 50 (art.eps) `\n%%IncludeFile: art.eps\n~\n").unwrap(); + assert_unread(&r, "placed files"); + assert!(r.doc.node(r.unread[0].0).unwrap().locked); +} + +#[test] +fn placed_file_operands_of_another_shape_arent_read() { + for operands in [ + // Five numbers in the matrix, three box numbers, two extra numbers, no path, a name for the + // path, a number that isn't finite. + "[1 0 0 1 10] 10 20 50 50 (a.eps)", + "[1 0 0 1 10 20] 10 20 50 (a.eps)", + "[1 0 0 1 10 20] 10 20 50 50 4 4 (a.eps)", + "[1 0 0 1 10 20] 10 20 50 50", + "[1 0 0 1 10 20] 10 20 50 50 /a.eps", + "[1 0 0 1 10 20] 10 20 50 1e999 (a.eps)", + ] { + // Without a box to put a placeholder in, the file isn't read. + let e = placed_read7(true, &format!("{operands} `\n~\n")).err().unwrap_or_default(); + assert!(e.contains("placed files"), "{operands}: {e}"); + } +} + +#[test] +fn a_placed_file_with_a_zero_area_box_or_a_second_tick_isnt_read() { + for body in [ + "[1 0 0 1 10 20] 10 20 10 50 (a.eps) `\n~\n", + // The operands of a second `` ` `` before `~` don't take the first one's place. + "[1 0 0 1 10 20] 10 20 50 50 (a.eps) `\n[1 0 0 1 0 0] 0 0 5 5 (b.eps) `\n~\n", + ] { + let e = placed_read7(true, body).err().unwrap_or_default(); + assert!(e.contains("placed files"), "{body}: {e}"); + } + // A `%AI5_BeginPlace` while one is open: the open one isn't read either. + let body = "%AI5_BeginPlace\n[1 0 0 1 10 20] 10 20 50 50 (a.eps) `\n%AI5_BeginPlace\n[1 0 0 1 0 0] 0 0 5 5 (b.eps) `\n~\n%AI5_EndPlace\n"; + assert!(placed_read7(true, body).err().unwrap_or_default().contains("placed files")); + let r = placed_read7(false, body).unwrap(); + assert!(r.hidden_unread.contains("placed files") && art(&r.doc).is_empty()); + // One that no `~` ends isn't read; a hidden one, or one on a layer that doesn't show, is left + // out with a note. + let open = "[1 0 0 1 10 20] 10 20 50 50 (a.eps) `\n"; + assert!(placed_read7(true, &format!("%AI5_BeginPlace\n{open}")).err().unwrap_or_default().contains("placed files")); + assert!(placed_read7(true, open).err().unwrap_or_default().contains("placed files")); + for (visible, art) in [(true, format!("%_%AI5_BeginPlace\n%_{open}")), (true, format!("%_{open}")), (false, open.to_string())] { + let r = placed_read7(visible, &art).unwrap_or_else(|e| panic!("{art}: {e}")); + assert!(r.hidden_unread.contains("placed files"), "{art}: {:?}", r.hidden_unread); + } +} + +#[test] +fn a_tilde_without_a_placed_file_is_an_unknown_operator() { + let e = placed_read7(true, "~\n").err().unwrap_or_default(); + assert!(e.contains('~'), "{e}"); + // An apostrophe isn't the placed file's operator. + let e = placed_read7(true, "[1 0 0 1 10 20] 10 20 50 50 (a.eps) '\n~\n").err().unwrap_or_default(); + assert!(e.contains('\''), "{e}"); +} + /// A previewless locked placement keeps its edit lock on the page-art placeholder. #[test] fn previewless_placement_placeholder_inherits_lock() { diff --git a/crates/eps/src/import/tests_illustrator.rs b/crates/eps/src/import/tests_illustrator.rs index f8b92b4bb..402ecfc25 100644 --- a/crates/eps/src/import/tests_illustrator.rs +++ b/crates/eps/src/import/tests_illustrator.rs @@ -297,6 +297,33 @@ 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 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] +fn a_legacy_file_with_a_placed_eps_comes_in_through_its_layers() { + // `` ` `` takes the file's user space into the page's (`concat`); `~` restores it. + let prolog = "/Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def /A {pop} def /k {setcmykcolor} def\n\ + /` {gsave 5 {pop} repeat concat} def /~ {grestore} def"; + let eps = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 10 20 50 50\n%%EndComments\n1 0 0 setrgbcolor\n\ + newpath 10 20 moveto 50 20 lineto 50 50 lineto 10 50 lineto closepath fill\n%%EOF\n"; + let placed = format!("[1 0 0 1 10 20] 10 20 50 50 (art.eps) `\n%%BeginDocument: art.eps\n{eps}%%EndDocument\n~\n"); + let body = format!("%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Back) Ln\n0 A\n0 0 0 1 k\n{} {placed}LB\n%AI5_EndLayer--\n", tri(10)); + let file = String::from_utf8(ai(AI8, "", &body)).unwrap().replace("%%EndProlog", &format!("{prolog}\n%%EndProlog")); + let r = import(file.as_bytes()).unwrap(); + assert!(!r.preview); + let names: Vec<_> = r.document.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert_eq!(names, ["Back"], "{:?}", r.warnings); + let art = r.document.layers[0].children().unwrap(); + assert_eq!(tree(art), "p g[p]", "{:?}", r.warnings); + // The file's box, moved by the matrix: 20 40 60 70 on the page, y up. + let bounds = art[1].geometric_bounds().unwrap(); + assert!( + (bounds.x0 - 20.0).abs() < 0.01 && (bounds.y0 - 30.0).abs() < 0.01 && (bounds.x1 - 60.0).abs() < 0.01 && (bounds.y1 - 60.0).abs() < 0.01, + "{bounds:?}" + ); + assert!(r.warnings.iter().any(|w| w.contains("placed files")), "{:?}", r.warnings); +} + /// A version 7 palette writes no swatch names (the AI7 spec's §11 example): its swatches come in /// named by their paint, in its order, in place of the defaults. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a57b10599..0717b5584 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -96,6 +96,9 @@ private editing data. VectorCraft: without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868. lines lost (#779), some files 4× slower to open since the editing data is read whole (#758), live effects, brushes and symbols come in as their drawn look (#637; pattern fills are read since #1025); legacy `.ai` + without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868, and its placed EPS files + in the version 7 form (`` ` `` … `~` with no `%AI5_BeginPlace`) are read like other placed files without a + preview: as the page's art in their box. without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868. Graphs (`%_Gs` … `%_GS`) come in as their drawn art, as plain groups, paths and type, without their data or settings, with a note. without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868, with its paragraphs' letter