.ai import: placed EPS files in the version 7 form are read

Squashed from #5187 onto main.
This commit is contained in:
dotCooCoo
2026-11-02 14:32:56 +07:00
committed by bflatastic
parent 2a41c74690
commit 01bc69cc80
11 changed files with 384 additions and 18 deletions
+23
View File
@@ -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<Value = String> {
"\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",
+102 -6
View File
@@ -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<Token<'a>> {
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::<usize>().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");
+16
View File
@@ -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(())
}
}
+4
View File
@@ -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)
}
+5 -1
View File
@@ -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() {
+29 -9
View File
@@ -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::<Option<Vec<f64>>>()?).ok()?;
let nums: Vec<f64> = rest.iter().map(V::num).collect::<Option<_>>()?;
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<String, Paint>, depth: usize) {
+7 -1
View File
@@ -242,6 +242,13 @@ pub(super) struct Place {
pub(super) folder: Option<String>,
/// The operand stack's height at `%AI28_BeginPlacedRelativePath`, until its end.
pub(super) folder_at: Option<usize>,
/// 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",
+1 -1
View File
@@ -423,7 +423,7 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap<String, ArtStyle> {
}
continue;
}
Tok::Comment(_) | Tok::Data(_) | Tok::Preview(_) => {}
Tok::Comment(_) | Tok::Data(_) | Tok::Preview(_) | Tok::Document(_) => {}
}
if operands.len() > 64 {
operands.clear();
+167
View File
@@ -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<Structure, String> {
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<Rect> {
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::<Vec<_>>(), [(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() {
@@ -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]
+3
View File
@@ -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