Files
vectorcraft/crates/eps/src/import/tests_native.rs
T
bflatasticandClaude Opus 5.5 c599522061 Fix #735: Illustrator EPS and .ai files match their page in Fit to Artwork Bounds and the page check
- Fit to Artwork Bounds fits the art exports draw: hidden objects and
  layers, guides and template layers don't count, and the bounds stop at
  a layer's clip.
- The comparison with the page catches an object one draws that the
  other doesn't (pixels whose neighbours all differ, away from type), a
  light colour against white, and, for an EPS whose page is its art's
  box, art the layers print outside it.
- textAs: outlines opens a .ai whose type shows from its PDF part, which
  has the outlines.
- document.open takes editingData: false to open an Illustrator EPS or
  .ai as its page or PDF part only, for print pipelines.

Part of #637.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:51:50 +07:00

609 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The layers of an Illustrator EPS and `.ai`: read from the editing copy of the art the file
//! carries (see `native`). The files here are small ones written by hand for the tests, with the
//! operators seen in files their users own.
use vectorcraft_doc::{Document, LayerColor, Node, NodeKind};
use crate::import::import;
/// `data` as ASCII85, up to the `~>`.
fn ascii85(data: &[u8]) -> String {
let mut out = String::new();
for chunk in data.chunks(4) {
let v = chunk.iter().enumerate().fold(0u32, |v, (i, b)| v | u32::from(*b) << (24 - 8 * i));
let mut d = [0u8; 5];
let mut x = v;
for c in d.iter_mut().rev() {
*c = (x % 85) as u8 + b'!';
x /= 85;
}
out.extend(d.iter().take(chunk.len() + 1).map(|c| char::from(*c)));
}
out.push_str("~>");
out
}
fn zstd(data: &str) -> Vec<u8> {
ruzstd::encoding::compress_to_vec(data.as_bytes(), ruzstd::encoding::CompressionLevel::Fastest)
}
/// The editing copy of a file with `layers` (made by [`layer`]), 100 × 100 points.
fn native(layers: &str) -> String {
format!(
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n%AI5_NumLayers: 2\n{layers}%%Trailer\n"
)
}
/// A layer as the format writes it.
fn layer(name: &str, visible: bool, body: &str) -> String {
format!("%AI5_BeginLayer\n{} 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{body}\nLB\n%AI5_EndLayer--\n", u8::from(visible))
}
/// A yellow square of 4 points at (`x`, `y`) in the format's operators.
fn square(x: u32, y: u32) -> String {
format!("0 0 1 0 k\n{x} {y} m\n{} {y} L\n{} {} L\n{x} {} L\nf", x + 4, x + 4, y + 4, y + 4)
}
/// An EPS whose page draws `page` and whose private data is `native`.
fn eps(page: &str, native: &str) -> Vec<u8> {
let stream = ascii85(&zstd(native));
let lines: Vec<String> = stream.as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
format!(
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{page}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI24_DataStream\n{}\n%AI9_PrivateDataEnd\n",
lines.join("\n")
)
.into_bytes()
}
/// The page's drawing of the square at (`x`, `y`).
fn page_square(x: u32, y: u32) -> String {
format!("0 0 1 0 setcmykcolor {x} {y} moveto {} {y} lineto {} {} lineto {x} {} lineto closepath fill", x + 4, x + 4, y + 4, y + 4)
}
fn names(d: &Document) -> Vec<String> {
d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect()
}
fn kinds(l: &Node) -> Vec<&'static str> {
l.children()
.into_iter()
.flatten()
.map(|n| match &n.kind {
NodeKind::Group { clip: true, children } => {
["clip[0]", "clip[1]", "clip[2]", "clip[3]"].get(children.len()).copied().unwrap_or("clip[many]")
}
NodeKind::Group { children, .. } => {
["group[0]", "group[1]", "group[2]", "group[3]"].get(children.len()).copied().unwrap_or("group[many]")
}
NodeKind::Compound { children, .. } => {
["compound[0]", "compound[1]", "compound[2]", "compound[3]"].get(children.len()).copied().unwrap_or("compound[many]")
}
NodeKind::Path { clipping: true, .. } => "clippath",
NodeKind::Path { .. } => "path",
NodeKind::Text(_) => "text",
_ => "other",
})
.collect()
}
#[test]
fn layers_come_in_with_their_names_colours_and_hidden_state() {
let art = native(&(layer("Back", true, &square(10, 10)) + &layer("Hidden", false, &square(50, 50))));
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
let d = &r.document;
assert_eq!(names(d), ["Back", "Hidden"], "{:?}", r.warnings);
assert_eq!([d.layers[0].visible, d.layers[1].visible], [true, false]);
// What is on a hidden layer stays: the page doesn't have it.
assert_eq!(kinds(&d.layers[1]), ["path"]);
// Colour 0 of the layer colours (light blue), by its place in the list.
assert!(matches!(&d.layers[0].kind, NodeKind::Layer { color: LayerColor::Preset(0), .. }));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
// The page is where the page's art was.
let b = d.layers[0].children().unwrap()[0].visual_bounds().unwrap();
assert!((b.x0 - 10.0).abs() < 1e-6 && (b.y0 - 86.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn sublayers_groups_compounds_clip_groups_and_hidden_objects_come_in() {
let body = [
"u".to_string(),
square(10, 10),
"U".into(),
"*u".into(),
square(20, 10),
square(30, 10),
"*U".into(),
// A clipping group: its objects, then its clip path.
"q".into(),
square(40, 10),
"50 10 m 60 10 L 60 20 L 50 20 L h W f".into(),
"Q".into(),
// Hidden until the next `Xw`.
"1 Xw".into(),
"u".into(),
square(70, 10),
"U".into(),
"0 Xw".into(),
]
.join("\n");
let sub = layer("Inner", true, &square(80, 10));
let art = native(&layer("Outer", true, &format!("{body}\n{sub}")));
// The page has what shows: the clip group's square is outside its clip path.
let page = [10, 20, 30, 80].map(|x| page_square(x, 10)).join("\n");
let r = import(&eps(&page, &art)).unwrap();
let outer = &r.document.layers[0];
assert_eq!(kinds(outer), ["group[1]", "compound[2]", "clip[2]", "group[1]", "other"]);
let children = outer.children().unwrap();
assert!(children[0].visible && !children[3].visible, "the hidden object is hidden, not left out");
// The clip path comes first in its group.
assert!(matches!(children[2].children().unwrap()[0].kind, NodeKind::Path { clipping: true, .. }));
// The sublayer is a layer of the layer.
assert!(matches!(&children[4].kind, NodeKind::Layer { .. }));
assert_eq!(children[4].name.as_deref(), Some("Inner"));
}
#[test]
fn what_the_layers_cant_say_keeps_the_page_when_it_shows_and_not_when_it_doesnt() {
// An operator that isn't read (`Zq`) on a layer that shows: the layers would draw without it.
let shown = native(&layer("Art", true, &format!("{}\n0 0 0 1 0 0 Zq", square(10, 10))));
let r = import(&eps(&page_square(10, 10), &shown)).unwrap();
assert_eq!(names(&r.document), ["Layer 1"]);
assert!(r.warnings.iter().any(|w| w.contains("layers weren't read") && w.contains("`Zq`")), "{:?}", r.warnings);
// On a hidden layer it only costs that layer's look.
let hidden = native(&(layer("Art", true, &square(10, 10)) + &layer("Old", false, "0 0 0 1 0 0 Zq")));
let r = import(&eps(&page_square(10, 10), &hidden)).unwrap();
assert_eq!(names(&r.document), ["Art", "Old"]);
assert!(r.warnings.iter().any(|w| w.contains("hidden layers have art this can't read")), "{:?}", r.warnings);
}
#[test]
fn text_and_its_strokes_come_from_the_page_into_the_layer_of_its_text_object() {
let page = format!(
"{}\n/Helvetica findfont 12 scalefont setfont 10 50 moveto (Hi) show\n10 50 moveto (Hi) false charpath 2 setlinewidth stroke",
page_square(10, 10)
);
let words = layer("Words", true, "/AI11Text :\n0 /FreeUndo ,\n;\n\n0 0 Xd");
let art = native(&(layer("Art", true, &square(10, 10)) + &words));
let r = import(&eps(&page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings);
assert_eq!(kinds(&r.document.layers[0]), ["path"]);
// The text and the path of its stroke, where the text object is (no group of their own).
assert_eq!(kinds(&r.document.layers[1]), ["text", "path"], "{:?}", r.warnings);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
#[test]
fn a_damaged_editing_copy_leaves_the_page() {
// Not ASCII85, ASCII85 of something that isn't Zstandard, Zstandard of something that isn't art.
let plain = |data: &str| {
let lines = format!("%{}", data);
format!(
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI24_DataStream\n{lines}\n%AI9_PrivateDataEnd\n",
page_square(10, 10)
)
.into_bytes()
};
for bytes in [plain("this is not ascii85 {{{~>"), plain(&ascii85(b"not zstandard at all")), eps(&page_square(10, 10), "nothing of the art here")]
{
let r = import(&bytes).unwrap();
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
}
// A file that has the data but not in a form that can be read says so (and a write over it asks).
let r = import(&plain("this is not ascii85 {{{~>")).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("damaged") && crate::is_loss(w)), "{:?}", r.warnings);
}
/// The `.ai`'s editing data: its marker, then the Zstandard stream.
fn private(native: &str) -> Vec<u8> {
[b"%AI24_ZStandard_Data".as_slice(), &zstd(native)].concat()
}
/// A page of 100 × 100 with a square at (`x`, `y`) in one layer, as a PDF part would give.
fn pdf_part(x: f64, y: f64) -> Document {
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, y, x + 4.0, y + 4.0));
let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 0.0, 1.0, 0.0));
let item = vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(paint));
let n = Node::path(id, path, vectorcraft_doc::Appearance { items: vec![item], ..Default::default() });
let layer = d.layers[0].id;
d.insert(Some(layer), 0, n).unwrap();
d
}
#[test]
fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() {
// A square on the artboard, and one above it (the artboard is y 0 to 100 here).
let art = native(&(layer("Jig", true, &square(10, 10)) + &layer("Parts", true, &square(30, 110)) + &layer("Old", false, &square(60, 60))));
let (d, warnings) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec!["a note".into()], false);
assert_eq!(names(&d), ["Jig", "Parts", "Old"], "{warnings:?}");
assert_eq!(warnings, ["a note"], "nothing else to say");
let above = d.layers[1].children().unwrap()[0].visual_bounds().unwrap();
assert!(above.y1 <= 0.0 + 1e-6, "the art above the artboard is kept, outside it: {above:?}");
assert!(!d.layers[2].visible);
}
#[test]
fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() {
let pdf = pdf_part(10.0, 86.0);
// No editing data this reads.
let (d, w) = crate::layered_ai(b"nothing", pdf.clone(), vec![], false);
assert_eq!(d.layers.len(), pdf.layers.len());
assert!(w.is_empty());
// Editing data that isn't for this page.
let other = native(&layer("Jig", true, &square(10, 10))).replace("%AI3_Cropmarks: 0 0 100 100", "%AI3_Cropmarks: 0 0 50 50");
let (d, w) = crate::layered_ai(&private(&other), pdf.clone(), vec![], false);
assert_eq!(names(&d), names(&pdf));
assert!(w.iter().any(|w| w.contains("different page")), "{w:?}");
}
#[test]
fn a_gray_is_a_grayscale_colour() {
// `g` is a gray: 0.25 is 75 % of the black ink, as the page's own CMYK black says; the colour
// stays in its grayscale model.
let art = native(&layer("Art", true, "0.25 g\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf"));
let page = "0 0 0 0.75 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto 10 14 lineto closepath fill";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
let fill = r.document.layers[0].children().unwrap()[0].appearance.fill().and_then(|f| f.paint.color());
assert_eq!(fill, Some(vectorcraft_color::Color::gray(0.75)));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
/// `native` as a CS-era file keeps it: zlib, under `%AI9_DataStream`.
fn older_eps(page: &str, native: &str) -> Vec<u8> {
use std::io::Write;
let mut z = flate2::write::ZlibEncoder::new(vec![], flate2::Compression::default());
z.write_all(native.as_bytes()).unwrap();
let stream = ascii85(&z.finish().unwrap());
let lines: Vec<String> = stream.as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
format!(
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{page}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI9_DataStream\n{}\n%AI9_PrivateDataEnd\n",
lines.join("\n")
)
.into_bytes()
}
#[test]
fn files_from_before_zstandard_give_their_layers_too() {
let art = native(&(layer("Back", true, &square(10, 10)) + &layer("Hidden", false, &square(50, 50))));
let r = import(&older_eps(&page_square(10, 10), &art)).unwrap();
assert_eq!(names(&r.document), ["Back", "Hidden"], "{:?}", r.warnings);
assert_eq!([r.document.layers[0].visible, r.document.layers[1].visible], [true, false]);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
#[test]
fn custom_colours_open_paths_and_planar_group_marks_are_read() {
// `x` and `X` are the custom colours of a fill and a stroke (four inks, a name, a tint), `Xx` one
// with its Lab values; `H` ends an open path, `XP` marks a group the app made from a planar map.
let body = "0.1 0.2 0.3 0.4 (Spot) 0 x\n0 0 0 1 (Edge) 0 X\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf\n\
0.8 0.1 0 0 62.7 -28 -42 (Blue) 0 2 Xx\n(Adobe Planar Group) 1 0 0 XP\n20 20 m\n24 20 L\n24 24 L\nH\nF";
let r = import(&older_eps(
"0.1 0.2 0.3 0.4 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto 10 14 lineto closepath fill 0.8 0.1 0 0 setcmykcolor 20 20 moveto 24 20 lineto 24 24 lineto closepath fill",
&native(&layer("Art", true, body)),
))
.unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
assert_eq!(kinds(&r.document.layers[0]), ["path", "path"]);
}
/// A text document of `stories` (each as the text, its alignment code, its anchor and the frame's
/// translation, and the start of its first line from the anchor), one font and a 14 point default.
/// A story: its text, alignment code, anchor, frame translation and first line's start.
type Story<'a> = (&'a str, u8, (f64, f64), (f64, f64), f64);
fn text_document(stories: &[Story<'_>]) -> String {
let story = |(text, align, anchor, _t, start): &Story<'_>| {
let n = text.chars().count();
format!(
"<< /0 << /0 ({text}) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 {align} >> /6 0 >> >> /1 {n} >> ] >> \
/6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /1 {n} >> ] >> >> \
/1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ {} {} ] >> /6 [ << /99 /R /6 [ << /99 /R /6 [ << /99 /L /6 [ \
<< /99 /S /0 << /0 [ {start} 0.0 ] >> /15 << /0 {n} >> >> ] >> ] >> ] >> ] >> ] >> ] >> >>",
anchor.0, anchor.1
)
};
let frame =
|(_, _, _, t, _): &(&str, u8, (f64, f64), (f64, f64), f64)| format!("<< /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 {} {} ] >> >> >>", t.0, t.1);
format!(
"/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ {} ] >> >>\n/1 << /1 [ {} ] /2 << /1 12.0 >> >>\n",
stories.iter().map(frame).collect::<Vec<_>>().join(" "),
stories.iter().map(story).collect::<Vec<_>>().join(" ")
)
}
/// The editing copy of a 100 × 100 file with `layers` and the text `document`; its template box is
/// centred at (50, 50).
fn native_with_text(layers: &str, document: &str) -> String {
let lines: Vec<String> = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
format!(
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n%AI3_TemplateBox: 50 50 50 50\n\
%AI5_NumLayers: 2\n{layers}%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n%%Trailer\n",
lines.join("\n")
)
}
/// A text object of story `story`, as the layers hold it.
fn text_object(story: u32) -> String {
format!("/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n{story} /StoryIndex ,\n2 /TextAntialiasing ,\n;\n")
}
fn text_of(l: &Node) -> Vec<&vectorcraft_doc::TextObject> {
l.children().into_iter().flatten().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(&**t) } else { None }).collect()
}
#[test]
fn type_on_a_hidden_layer_comes_from_the_text_document() {
// Canvas centre 8191.5 is the template box's centre (50, 50): the anchor is 8.5 right of it and
// 11.5 above it, so at (58.5, 61.5) in the art, which is 38.5 down the page.
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Mid\r", 2, (8191.5, 8191.5), (0.0, 0.0), -10.0)]);
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &(text_object(0) + &text_object(1)))), &doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
assert_eq!(names(&r.document), ["Back", "Spare"], "{:?}", r.warnings);
let spare = &r.document.layers[1];
assert!(!spare.visible);
let texts = text_of(spare);
assert_eq!(texts.len(), 2, "{:?}", r.warnings);
let [a, b] = [texts[0], texts[1]];
assert_eq!(a.plain_text(), "Hi there");
let [.., e, f] = a.xf.as_coeffs();
assert!((e - 58.5).abs() < 1e-6 && (f - 38.5).abs() < 1e-6, "{:?}", a.xf);
assert_eq!(a.runs[0].style.size, 14.0);
assert_eq!(a.para.justify, vectorcraft_doc::Justify::Left);
// Centred type is anchored at its middle.
assert_eq!(b.para.justify, vectorcraft_doc::Justify::Center);
let [.., e, f] = b.xf.as_coeffs();
assert!((e - 50.0).abs() < 1e-6 && (f - 50.0).abs() < 1e-6, "{:?}", b.xf);
assert!(!r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings);
}
#[test]
fn type_with_a_frame_it_cant_read_is_left_out_with_a_note() {
// A story whose frame says more than a matrix (a text area, say) isn't guessed at.
let mut doc = text_document(&[("Area\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
doc = doc.replace("/2 << /2 [ 1 0 0 1 0 0 ] >>", "/2 << /2 [ 1 0 0 1 0 0 ] /7 1 >>");
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
assert!(text_of(&r.document.layers[1]).is_empty());
assert!(r.warnings.iter().any(|w| w.contains("1 text objects on hidden layers")), "{:?}", r.warnings);
assert!(crate::is_loss(r.warnings.iter().find(|w| w.contains("couldn't be read")).unwrap()));
}
#[test]
fn shown_type_keeps_the_pages_and_type_off_the_page_comes_from_the_text_document() {
// Story 0 is where the page draws "Hi there"; story 1 is off the page, so the page has nothing of it.
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Away\r", 0, (8300.0, 8180.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
let texts = text_of(&r.document.layers[0]);
// The page's own object, and the other made from the file.
let all: Vec<String> = {
let mut v = vec![];
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
v.push(t.plain_text());
}
});
v
};
assert_eq!(all.len(), 2, "{all:?} {:?}", r.warnings);
assert!(all.contains(&"Hi there".to_string()) && all.contains(&"Away".to_string()), "{all:?}");
assert_eq!(texts.len(), 2, "the page's type is in the layer as it is, not in a group");
assert!(!r.warnings.iter().any(|w| w.contains("order the page paints")), "{:?}", r.warnings);
}
#[test]
fn a_text_object_written_as_comments_is_read_like_any_other() {
let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let commented: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect();
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &commented)), &doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
let texts = text_of(&r.document.layers[1]);
assert_eq!(texts.len(), 1, "{:?}", r.warnings);
assert_eq!(texts[0].plain_text(), "Hi");
}
#[test]
fn an_area_type_object_with_its_own_nested_art_doesnt_swallow_the_art_after_it() {
let area = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n3 /StoryIndex ,\n/Art :\n1 Ap\n0 0 m\n9 0 L\n9 9 L\nn\n; /ConfiningPath ,\n2 /TextAntialiasing ,\n;\n";
let art = native(&layer("Art", true, &(square(10, 10) + "\n" + area + &square(30, 30))));
let r = import(&eps(&(page_square(10, 10) + "\n" + &page_square(30, 30)), &art)).unwrap();
let paths = r.document.layers[0].children().map_or(0, Vec::len);
assert!(paths >= 2, "{paths} {:?}", r.warnings);
}
#[test]
fn a_line_the_page_draws_in_pieces_goes_to_its_text_object_whole() {
// The page sets "Hi there" as "Hi" and " there" (as when kerning changes); the story is one line.
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&layer("Art", true, &text_object(0)), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi) show 70 61.5 moveto (there) show";
let r = import(&eps(page, &art)).unwrap();
// One text, with the line's space between the pieces.
let art = &r.document.layers[0];
assert_eq!(kinds(art), ["text"], "{:?}", r.warnings);
let texts: Vec<String> = text_of(art).iter().map(|t| t.plain_text()).collect();
assert_eq!(texts, ["Hi there"]);
// Its bounds are laid out afresh for the whole text, not those of the first piece.
let NodeKind::Text(t) = &art.children().unwrap()[0].kind else { panic!("text") };
assert!(t.cached_bounds.is_none() && t.cached_baselines.is_empty());
}
#[test]
fn type_of_the_page_that_no_story_places_stays_with_the_nearest_text_object() {
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&layer("Art", true, &text_object(0)), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show 58.5 30 moveto (Extra) show";
let r = import(&eps(page, &art)).unwrap();
let mut all = vec![];
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
all.push(t.plain_text());
}
});
assert!(all.contains(&"Extra".to_string()), "{all:?} {:?}", r.warnings);
}
#[test]
fn a_commented_copy_of_a_story_that_has_a_plain_text_object_is_not_another_object() {
// The file writes some stories twice (plain, then as comments) and others only as comments.
let doc = text_document(&[("One\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Two\r", 0, (8300.0, 8180.0), (0.0, 0.0), 0.0)]);
let commented = |story| -> String { text_object(story).lines().map(|l| format!("%_{l}\n")).collect() };
let body = text_object(0) + &commented(0) + &commented(1);
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &body)), &doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
let texts: Vec<String> = text_of(&r.document.layers[1]).iter().map(|t| t.plain_text()).collect();
assert_eq!(texts, ["One", "Two"], "{:?}", r.warnings);
}
#[test]
fn a_shown_text_object_on_the_page_that_the_page_doesnt_draw_is_left_out() {
// Story 1 is at (58.5, 61.5) on the 100 × 100 page, where the page paints no type: the file
// keeps it but the app that wrote it doesn't draw it (type turned to outlines leaves one).
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Ghost\r", 0, (8200.0, 8200.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show";
let r = import(&eps(page, &art)).unwrap();
let mut all = vec![];
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
all.push(t.plain_text());
}
});
assert_eq!(all, ["Hi there"], "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.contains("doesn't draw")), "{:?}", r.warnings);
}
#[test]
fn pieces_of_two_lines_the_page_draws_alternately_go_each_to_its_own_text() {
// "Hello" is drawn as "He" and "llo", with "abcd" ("ab", "cd") drawn between them.
let doc = text_document(&[("Hello\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("abcd\r", 0, (8200.0, 8160.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (He) show 58.5 81.5 moveto (ab) show \
74.1 81.5 moveto (cd) show 76.4 61.5 moveto (llo) show";
let r = import(&eps(page, &art)).unwrap();
let mut all = vec![];
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
all.push(t.plain_text());
}
});
all.sort();
assert_eq!(all, ["Hello", "abcd"], "{:?}", r.warnings);
}
#[test]
fn a_run_longer_than_any_text_doesnt_overflow() {
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]).replace("/1 9 >>", "/1 1e30 >>");
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
assert_eq!(names(&r.document), ["Back", "Spare"], "{:?}", r.warnings);
}
#[test]
fn more_text_objects_than_are_matched_leave_the_page() {
let many = text_object(0).repeat(5001);
let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &many)), &doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.contains("too many text objects") && crate::is_loss(w)), "{:?}", r.warnings);
}
#[test]
fn a_dictionary_left_open_at_the_end_of_a_layer_is_a_damaged_file() {
let open = "/AI11Text :\n0 /FreeUndo ,\n";
let art = native(&(layer("A", true, &(square(10, 10) + open)) + &layer("B", true, &square(20, 20))));
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| crate::is_loss(w)), "{:?}", r.warnings);
}
#[test]
fn an_ai_file_without_its_pdf_part_reads_its_layers_and_makes_its_type() {
// Point type from the text document, shown or not; a story it can't read is left out, with a
// note that is a loss.
let mut doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Area\r", 0, (8200.0, 8160.0), (0.0, 0.0), 0.0)]);
// Story 1 in frame 1, a frame that says more than a matrix.
let at = doc.rfind("/1 << /0 [ << /0 0 >> ]").unwrap();
doc.replace_range(at..at + 23, "/1 << /0 [ << /0 1 >> ]");
let at = doc.rfind("/2 << /2 [ 1 0 0 1 0 0 ] >>").unwrap();
doc.replace_range(at..at + 27, "/2 << /2 [ 1 0 0 1 0 0 ] /7 1 >>");
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Words", true, &(text_object(0) + &text_object(1)))), &doc);
let (d, notes) = crate::ai_alone(&private(&art)).unwrap();
assert_eq!(names(&d), ["Back", "Words"], "{notes:?}");
let texts: Vec<String> = text_of(&d.layers[1]).iter().map(|t| t.plain_text()).collect();
assert_eq!(texts, ["Hi there"], "{notes:?}");
let left = notes.iter().find(|n| n.contains("left out")).unwrap();
assert!(left.starts_with("1 ") && crate::is_loss(left), "{notes:?}");
assert!(!notes.iter().any(|n| n.contains("order the page paints")), "{notes:?}");
assert!(crate::ai_alone(b"%AI24_ZStandard_Data nothing").is_err());
}
#[test]
fn an_object_the_page_doesnt_draw_makes_the_file_come_in_as_its_page() {
// A 20-point square that the page doesn't draw: 4% of the page, and an object all the same.
let extra = "0 0 1 0 k\n50 50 m\n70 50 L\n70 70 L\n50 70 L\nf";
let art = native(&layer("Back", true, &(square(10, 10) + "\n" + extra)));
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.contains("layers weren't read") && w.contains("differs from the page's")), "{:?}", r.warnings);
}
/// An EPS whose page is its art's box `[x0 y0 x1 y1]` (not its artboard, 0 0 100 100), drawing
/// `page`, with the editing copy of `layers`.
fn eps_of_art(art_box: [u32; 4], page: &str, layers: &str) -> Vec<u8> {
let [x0, y0, x1, y1] = art_box;
let native = native(layers).replace(
"%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100",
&format!("%%BoundingBox: {x0} {y0} {x1} {y1}\n%%HiResBoundingBox: {x0} {y0} {x1} {y1}"),
);
String::from_utf8(eps(page, &native))
.unwrap()
.replacen("%%BoundingBox: 0 0 100 100", &format!("%%BoundingBox: {x0} {y0} {x1} {y1}"), 1)
.into_bytes()
}
#[test]
fn art_the_layers_print_outside_an_eps_page_of_its_art_makes_it_come_in_as_its_page() {
// The page is the box of the art it prints, round the square at (10, 10).
let file = |far: &str| eps_of_art([10, 10, 14, 14], &page_square(10, 10), &(layer("Art", true, &square(10, 10)) + far));
// A square the layers print far from it: the page would have it.
let r = import(&file(&layer("Far", true, &square(80, 80)))).unwrap();
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.contains("layers weren't read")), "{:?}", r.warnings);
// Hidden, or on a layer that doesn't print, the page doesn't have it.
let r = import(&file(&layer("Far", false, &square(80, 80)))).unwrap();
assert_eq!(names(&r.document), ["Art", "Far"], "{:?}", r.warnings);
let not_printed = format!("%AI5_BeginLayer\n1 1 1 0 0 0 1 0 79 128 255 0 50 0 Lb\n(Notes) Ln\n{}\nLB\n%AI5_EndLayer--\n", square(80, 80));
let r = import(&file(&not_printed)).unwrap();
assert_eq!(names(&r.document), ["Art", "Notes"], "{:?}", r.warnings);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
#[test]
fn an_eps_saved_as_its_artboard_keeps_the_art_outside_it() {
// The page is the artboard (0 0 100 100): the art beyond it is the pasteboard's.
let art = native(&(layer("Art", true, &square(10, 10)) + &layer("Scraps", true, &square(120, 10))));
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
assert_eq!(names(&r.document), ["Art", "Scraps"], "{:?}", r.warnings);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
#[test]
fn type_asked_for_as_outlines_leaves_a_file_whose_type_shows_as_its_pdf_part() {
let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
// Type that shows: the PDF part's outlines are its type, which the text objects can't hold.
let shown = native_with_text(&layer("Art", true, &(square(10, 10) + "\n" + &text_object(0))), &doc);
let (d, w) = crate::layered_ai(&private(&shown), pdf_part(10.0, 86.0), vec![], true);
assert_eq!(names(&d), ["Layer 1"], "{w:?}");
assert!(w.iter().any(|w| w.contains("layers weren't read") && w.contains("outlines")), "{w:?}");
// Type only on a hidden layer: the layers come in.
let hidden = native_with_text(&(layer("Art", true, &square(10, 10)) + &layer("Notes", false, &text_object(0))), &doc);
let (d, w) = crate::layered_ai(&private(&hidden), pdf_part(10.0, 86.0), vec![], true);
assert_eq!(names(&d), ["Art", "Notes"], "{w:?}");
}