Read Illustrator EPS and .ai layers from their editing data

An Illustrator EPS is a flattened page plus, after %%EOF, an editing copy of
the document; a PDF-compatible .ai carries the same in its private data.
Importing only the page lost layer names, order, visibility, hidden layers
and hidden objects and hidden text.

- eps: read the editing copy (Zstandard %AI24_DataStream, older zlib
  %AI9_DataStream) into layers, sublayers, groups, compound paths, clip
  groups and hidden objects, run by the existing PostScript interpreter.
  Falls back to the page, with a warning, when a shown layer uses an
  operator that isn't read or the art differs from the page by over 5%.
- eps: read the text document (ate.rs) to place type on hidden layers and
  objects; the page's type is matched to the file's text objects by
  position (pieces of a line included) and kept with the nearest one when
  none claims it. Area type and type on a path are left out with a note.
- pdf/engine: .ai files go through the same reader.
- engine/cli: a file whose import left things out isn't overwritten unless
  asked (acknowledgeLoss, --replace-lossy).
- eps: font names re-encoded with a *<n> suffix resolve to their family.
- i18n: es/it/ru for the new import messages; ROADMAP updated.

Container and text-document formats were inferred from user-owned files; the
Adobe file format specification was not read.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Matthew Farrow
2026-10-09 03:49:49 -04:00
co-authored by Claude Sonnet 5.5
parent f149f10059
commit ec43ac5a1e
28 changed files with 2526 additions and 25 deletions
+541
View File
@@ -0,0 +1,541 @@
//! The text of an Illustrator editing copy.
//!
//! The editing copy of a file keeps the characters of its text objects, with their fonts, sizes,
//! colours and where they sit, in one text document of its own: between `%AI11_BeginTextDocument`
//! and `%AI11_EndTextDocument`, ASCII85 of nested dictionaries with numbers for keys. A text object
//! in a layer is only a stub naming its story in it (`/StoryIndex`).
//!
//! The format isn't documented. What is read here was worked out from the files of the project's
//! users, used locally and never committed, by comparing it with the type their pages draw: the
//! meaning of each key below was checked against the page's own type in more than a thousand text
//! objects, and it is only used for the keys that matched. A story the reader doesn't recognise (area
//! type, type on a path, anything with a frame that has more than a point and a matrix) is left
//! alone: it has no text, and the importer says so.
//!
//! Where type sits: a story lays its lines out round a point `F` (the anchor: the start of a line of
//! left-aligned type, the middle of a centred one, the end of a right-aligned one), each line with an
//! offset from it. The frame of the story gives a matrix that carries that layout onto the canvas of
//! the app, which is 16383 points a side; the canvas's centre, 8191.5, is the centre of the file's
//! `%AI3_TemplateBox`. See [`Story::place`].
use std::collections::BTreeMap;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaStyle, TextObject, TextRun};
use vectorcraft_geom::{Affine, Point};
/// Biggest text document read (bytes, decoded).
const MAX_BYTES: usize = 64 << 20;
/// Deepest nesting read.
const MAX_DEPTH: usize = 64;
/// Most values read.
const MAX_VALUES: usize = 4_000_000;
/// The centre of the app's canvas, in the units of its text document.
const CANVAS_CENTRE: f64 = 8191.5;
/// A value of the text document.
#[derive(Debug, Clone, PartialEq)]
pub(super) enum Val {
Num(f64),
Str(String),
Name(String),
Bool(bool),
List(Vec<Val>),
Dict(BTreeMap<String, Val>),
}
impl Val {
fn get(&self, key: &str) -> Option<&Val> {
match self {
Val::Dict(d) => d.get(key),
_ => None,
}
}
fn at(&self, i: usize) -> Option<&Val> {
match self {
Val::List(l) => l.get(i),
_ => None,
}
}
fn num(&self) -> Option<f64> {
match self {
Val::Num(n) if n.is_finite() => Some(*n),
_ => None,
}
}
fn str(&self) -> Option<&str> {
match self {
Val::Str(s) => Some(s),
_ => None,
}
}
fn list(&self) -> &[Val] {
match self {
Val::List(l) => l,
_ => &[],
}
}
fn nums(&self) -> Option<Vec<f64>> {
self.list().iter().map(Val::num).collect()
}
/// The `name` entry of a dictionary that is a typed node (`/99 /name`).
fn is_node(&self, name: &str) -> bool {
matches!(self.get("99"), Some(Val::Name(n)) if n == name)
}
}
struct Reader<'a> {
b: &'a [u8],
at: usize,
budget: usize,
}
impl Reader<'_> {
fn skip_space(&mut self) {
while self.b.get(self.at).is_some_and(u8::is_ascii_whitespace) {
self.at += 1;
}
}
fn value(&mut self, depth: usize) -> Option<Val> {
self.skip_space();
self.budget = self.budget.checked_sub(1)?;
let c = *self.b.get(self.at)?;
match c {
b'<' if self.b.get(self.at + 1) == Some(&b'<') => {
if depth > MAX_DEPTH {
return None;
}
self.at += 2;
let mut d = BTreeMap::new();
loop {
self.skip_space();
if self.b.get(self.at..self.at + 2) == Some(b">>") {
self.at += 2;
return Some(Val::Dict(d));
}
let key = self.key()?;
let v = self.value(depth + 1)?;
d.insert(key, v);
}
}
b'[' => {
if depth > MAX_DEPTH {
return None;
}
self.at += 1;
let mut l = vec![];
loop {
self.skip_space();
if self.b.get(self.at) == Some(&b']') {
self.at += 1;
return Some(Val::List(l));
}
l.push(self.value(depth + 1)?);
}
}
b'(' => self.string(),
b'/' => Some(Val::Name(self.key()?)),
_ => {
let start = self.at;
while self.b.get(self.at).is_some_and(|c| !c.is_ascii_whitespace() && !matches!(c, b'<' | b'>' | b'[' | b']' | b'(' | b')' | b'/')) {
self.at += 1;
}
let word = std::str::from_utf8(self.b.get(start..self.at)?).ok()?;
match word {
"true" => Some(Val::Bool(true)),
"false" => Some(Val::Bool(false)),
"" => None,
w => Some(Val::Num(w.parse().ok()?)),
}
}
}
}
/// A `/name`, without its slash.
fn key(&mut self) -> Option<String> {
self.skip_space();
if self.b.get(self.at) != Some(&b'/') {
return None;
}
self.at += 1;
let start = self.at;
while self.b.get(self.at).is_some_and(|c| !c.is_ascii_whitespace() && !matches!(c, b'<' | b'>' | b'[' | b']' | b'(' | b')' | b'/')) {
self.at += 1;
}
Some(String::from_utf8_lossy(self.b.get(start..self.at)?).into_owned())
}
/// A string: up to the first `)` that isn't escaped; UTF-16 when it starts with a byte order mark.
fn string(&mut self) -> Option<Val> {
self.at += 1;
let mut bytes = vec![];
loop {
let c = *self.b.get(self.at)?;
self.at += 1;
match c {
b')' => break,
b'\\' => {
let e = *self.b.get(self.at)?;
self.at += 1;
bytes.push(match e {
b'n' => b'\n',
b'r' => b'\r',
b't' => b'\t',
b'b' => 8,
b'f' => 12,
b'0'..=b'7' => {
let mut v = u32::from(e - b'0');
for _ in 0..2 {
match self.b.get(self.at) {
Some(d @ b'0'..=b'7') => {
v = v * 8 + u32::from(d - b'0');
self.at += 1;
}
_ => break,
}
}
(v & 0xff) as u8
}
other => other,
});
}
c => bytes.push(c),
}
if bytes.len() > MAX_BYTES {
return None;
}
}
Some(Val::Str(match bytes.strip_prefix(&[0xfe, 0xff]) {
Some(rest) => {
let units: Vec<u16> = rest.as_chunks::<2>().0.iter().map(|p| u16::from_be_bytes(*p)).collect();
String::from_utf16_lossy(&units)
}
None => bytes.iter().map(|b| char::from(*b)).collect(),
}))
}
}
/// The values of a text document: its top-level `/key value` pairs.
fn read(bytes: &[u8]) -> Option<Val> {
let mut r = Reader { b: bytes, at: 0, budget: MAX_VALUES };
let mut d = BTreeMap::new();
loop {
r.skip_space();
if r.at >= bytes.len() {
return Some(Val::Dict(d));
}
let key = r.key()?;
let v = r.value(0)?;
d.insert(key, v);
}
}
/// How a paragraph's lines sit against its anchor.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Align {
Left,
Center,
Right,
}
/// The text of one story, in the units of the text document.
#[derive(Debug)]
pub(super) struct Story {
/// The characters, with `\r` ending paragraphs and `\x03` ending lines.
text: String,
/// The anchor of the layout.
anchor: (f64, f64),
/// Where each line starts, from the anchor.
lines: Vec<(f64, f64)>,
/// Carries the layout onto the canvas.
matrix: [f64; 6],
align: Align,
/// `(characters, style)` in order.
runs: Vec<(usize, Style)>,
}
#[derive(Clone, Debug)]
struct Style {
font: Option<String>,
size: f64,
fill: Option<[f64; 4]>,
stroke: Option<[f64; 4]>,
stroke_width: f64,
}
/// The text document of an editing copy.
pub(super) struct Texts {
stories: Vec<Val>,
fonts: Vec<String>,
/// The character style a story starts from.
defaults: Val,
frames: Vec<Val>,
}
impl Texts {
/// The text document in the editing copy `data`, if it has one that can be read.
pub(super) fn read(data: &[u8]) -> Option<Texts> {
fn find(h: &[u8], n: &[u8], from: usize) -> Option<usize> {
h.get(from..)?.windows(n.len()).position(|w| w == n).map(|p| p + from)
}
let start = find(data, b"%AI11_BeginTextDocument", 0)?;
let from = find(data, b"/ASCII85Decode", start)? + b"/ASCII85Decode".len();
let end = find(data, b"~>", from)? + 2;
// Each line after the first starts with a `%`.
let packed: String = String::from_utf8_lossy(data.get(from..end)?)
.split(['\r', '\n'])
.enumerate()
.map(|(i, l)| if i > 0 { l.strip_prefix('%').unwrap_or(l) } else { l })
.collect::<String>()
.trim_start_matches([' ', ','])
.to_string();
let bytes = crate::ps::ascii85_decode(&packed)?;
if bytes.len() > MAX_BYTES {
return None;
}
let doc = read(&bytes)?;
let fonts = doc
.get("0")?
.get("1")?
.get("0")?
.list()
.iter()
.map(|f| f.get("0").and_then(|f| f.get("0")).and_then(|f| f.get("0")).and_then(Val::str).unwrap_or_default().to_string())
.collect();
let main = doc.get("1")?;
Some(Texts {
stories: main.get("1")?.list().to_vec(),
fonts,
defaults: main.get("2")?.clone(),
frames: doc.get("0")?.get("8")?.get("0")?.list().to_vec(),
})
}
/// Story `i`, if it is one of point type this reads.
pub(super) fn story(&self, i: usize) -> Option<Story> {
let s = self.stories.get(i)?;
let text = s.get("0")?.get("0")?.str()?.to_string();
// The frame the story is in: a matrix and nothing else but its place.
let frame = self.frames.get(s.get("1")?.get("0")?.at(0)?.get("0")?.num()? as usize)?.get("0")?;
let carries = frame.get("2")?;
if carries.get("2").is_none() || carries.as_dict().is_some_and(|d| d.keys().any(|k| k != "2")) {
return None;
}
let matrix: [f64; 6] = carries.get("2")?.nums()?.try_into().ok()?;
let f = find_node(s.get("1")?.get("2")?, "F")?;
let anchor = f.get("0")?.get("0")?.nums()?;
let anchor = (*anchor.first()?, *anchor.get(1)?);
let mut lines = vec![];
let mut found = vec![];
find_nodes(f, "L", &mut found);
for l in found {
let off = l.get("0").and_then(|o| o.get("0")).and_then(Val::nums).unwrap_or_else(|| vec![0.0, 0.0]);
let seg = l.get("6")?.list().iter().find(|c| c.is_node("S"))?;
let so = seg.get("0").and_then(|o| o.get("0")).and_then(Val::nums).unwrap_or_else(|| vec![0.0, 0.0]);
lines.push((off.first()? + so.first()?, *off.get(1)?));
}
if lines.is_empty() {
return None;
}
let align = match s
.get("0")
.and_then(|t| t.get("5"))
.and_then(|p| p.get("0"))
.and_then(|p| p.at(0))
.and_then(|p| p.get("0"))
.and_then(|p| p.get("0"))
.and_then(|p| p.get("5"))
.and_then(|p| p.get("0"))
.and_then(Val::num)
{
Some(1.0) => Align::Right,
Some(2.0) => Align::Center,
_ => Align::Left,
};
let mut runs = vec![];
for run in s.get("0")?.get("6")?.get("0")?.list() {
let n = run.get("1")?.num()? as usize;
let style = run.get("0")?.get("0")?.get("6")?;
runs.push((n, self.style(style)));
}
Some(Story { text, anchor, lines, matrix, align, runs })
}
/// A character style: its own keys, else the document's.
fn style(&self, s: &Val) -> Style {
let pick = |k: &str| s.get(k).or_else(|| self.defaults.get(k));
let paint = |k: &str| -> Option<[f64; 4]> {
let p = pick(k)?.get("0")?;
// Kind 2 is CMYK: an opacity, then the four inks.
(p.get("0")?.num()? == 2.0).then(|| p.get("1")?.nums()).flatten().and_then(|v| Some([*v.get(1)?, *v.get(2)?, *v.get(3)?, *v.get(4)?]))
};
let on = |k: &str| !matches!(pick(k), Some(Val::Bool(false)));
let font = pick("0").and_then(Val::num).and_then(|i| self.fonts.get(i as usize)).filter(|f| !f.is_empty()).cloned();
Style {
font,
size: pick("1").and_then(Val::num).filter(|v| *v > 0.0).unwrap_or(12.0),
fill: if on("56") { Some(paint("53").unwrap_or([0.0, 0.0, 0.0, 1.0])) } else { None },
stroke: if matches!(pick("57"), Some(Val::Bool(true))) { paint("54") } else { None },
stroke_width: pick("63").and_then(Val::num).filter(|v| *v >= 0.0).unwrap_or(1.0),
}
}
}
impl Val {
fn as_dict(&self) -> Option<&BTreeMap<String, Val>> {
match self {
Val::Dict(d) => Some(d),
_ => None,
}
}
}
fn find_node<'a>(v: &'a Val, name: &str) -> Option<&'a Val> {
let mut out = vec![];
find_nodes(v, name, &mut out);
out.into_iter().next()
}
/// The typed nodes called `name` in `v`, in order, outermost first.
fn find_nodes<'a>(v: &'a Val, name: &str, out: &mut Vec<&'a Val>) {
match v {
Val::Dict(d) => {
if v.is_node(name) {
out.push(v);
}
for x in d.values() {
find_nodes(x, name, out);
}
}
Val::List(l) => {
for x in l {
find_nodes(x, name, out);
}
}
_ => {}
}
}
/// Where a story's first line starts: on the canvas of the app (what [`Story::place`] gives).
impl Story {
/// The anchor of the story on the file's canvas: `(x, y)` in the units of the art, y up, given
/// the centre `(tx, ty)` of the file's template box.
pub(super) fn place(&self, template: (f64, f64)) -> (f64, f64) {
let [a, b, c, d, e, f] = self.matrix;
let (x, y) = (a * self.anchor.0 + c * self.anchor.1 + e, b * self.anchor.0 + d * self.anchor.1 + f);
(x - CANVAS_CENTRE + template.0, CANVAS_CENTRE + template.1 - y)
}
/// Each line's characters and where it starts (in the units of the art, y up): where the page
/// draws the line when the type is shown.
pub(super) fn line_starts(&self, template: (f64, f64)) -> Vec<(String, (f64, f64))> {
let [a, b, c, d, e, f] = self.matrix;
let text: Vec<&str> = self.text.split(['\r', '\x03']).collect();
self.lines
.iter()
.enumerate()
.map(|(i, (lx, ly))| {
let (x, y) = (self.anchor.0 + lx, self.anchor.1 + ly);
let (x, y) = (a * x + c * y + e, b * x + d * y + f);
(text.get(i).copied().unwrap_or_default().to_string(), (x - CANVAS_CENTRE + template.0, CANVAS_CENTRE + template.1 - y))
})
.collect()
}
/// The story as a text object whose anchor is at `at` on the document (`to_doc` takes the art's
/// units onto it), or `None` for type that is mirrored or has no size.
pub(super) fn node(&self, id: NodeId, template: (f64, f64), to_doc: Affine) -> Option<Node> {
let [a, b, c, d, ..] = self.matrix;
let scale = (a * d - b * c).abs().sqrt();
if !(scale.is_finite() && scale > 1e-6 && a * d - b * c > 0.0) {
return None;
}
let (x, y) = self.place(template);
let at = to_doc * Point::new(x, y);
let text: String = self.text.trim_end_matches('\r').chars().map(|c| if matches!(c, '\r' | '\x03') { '\n' } else { c }).collect();
if text.trim().is_empty() {
return None;
}
let leading = self.lines.windows(2).map(|w| (w[1].1 - w[0].1) * scale).find(|l| l.is_finite() && *l > 0.0);
let chars: Vec<char> = text.chars().collect();
let mut runs = vec![];
let mut from = 0;
let styles: Vec<&(usize, Style)> = self.runs.iter().collect();
for (n, st) in styles {
let to = (from + n).min(chars.len());
let piece: String = chars.get(from..to).unwrap_or_default().iter().collect();
from = to;
if piece.is_empty() {
continue;
}
runs.push(TextRun { text: piece, style: char_style(st, scale, leading) });
}
if from < chars.len() {
let last = self.runs.last().map(|r| &r.1)?;
runs.push(TextRun { text: chars.get(from..).unwrap_or_default().iter().collect(), style: char_style(last, scale, leading) });
}
if runs.is_empty() {
return None;
}
let mut t = TextObject::point(Point::ZERO, "", runs.first()?.style.clone());
t.runs = runs;
t.para = ParaStyle {
justify: match self.align {
Align::Left => Justify::Left,
Align::Center => Justify::Center,
Align::Right => Justify::Right,
},
..ParaStyle::default()
};
t.xf = Affine::new([a / scale, b / scale, c / scale, d / scale, at.x, at.y]);
Some(Node::new(id, NodeKind::Text(Box::new(t))))
}
}
fn char_style(s: &Style, scale: f64, leading: Option<f64>) -> CharStyle {
let (family, style) = s.font.as_deref().map_or_else(|| (CharStyle::default().font_family, "Regular".to_string()), crate::family_style);
let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family);
let ink = |c: [f64; 4]| Paint::solid(Color::cmyk(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32));
CharStyle {
font_family: family,
font_style: style,
size: s.size * scale,
leading,
fill: s.fill.map_or(Paint::None, ink),
stroke: s.stroke.map_or(Paint::None, ink),
stroke_width: if s.stroke.is_some() { s.stroke_width * scale } else { 0.0 },
..CharStyle::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn values_read_nested_dictionaries_lists_and_utf16_strings() {
let v = read(b" /1 << /0 [ 1 2.5 -3 ] /2 (\xfe\xff\x00h\x00i) /3 true /99 /Name >> /0 (a\\)b\\n)").unwrap();
let one = v.get("1").unwrap();
assert_eq!(one.get("0").unwrap().nums(), Some(vec![1.0, 2.5, -3.0]));
assert_eq!(one.get("2").unwrap().str(), Some("hi"));
assert_eq!(one.get("3"), Some(&Val::Bool(true)));
assert!(one.is_node("Name"));
assert_eq!(v.get("0").unwrap().str(), Some("a)b\n"));
}
#[test]
fn damaged_documents_are_not_read() {
for bad in [&b"/0 << /1"[..], b"/0 [ 1 2", b"/0 (abc", b"/0 nope", b"0 1"] {
assert!(read(bad).is_none(), "{}", String::from_utf8_lossy(bad));
}
let deep = format!("/0 {}", "[".repeat(200));
assert!(read(deep.as_bytes()).is_none());
}
}
+96 -2
View File
@@ -153,6 +153,18 @@ impl Default for GState {
}
}
/// The name of the empty group that stands for a text object (see [`Out::text_slot`]), followed by
/// the number of its story in the text document when the file says.
pub(crate) const TEXT_SLOT: &str = "\u{0}text";
/// Is `name` that of a text slot, and which story is it of?
pub(crate) fn slot_of(name: Option<&str>) -> Option<Option<u32>> {
name?.strip_prefix(TEXT_SLOT).map(|rest| rest.parse().ok())
}
/// An object drawn, with its chain of clips and groups.
type DrawnItem = (Vec<Arc<Clip>>, Node);
/// What the program draws.
pub(crate) struct Out {
/// The document being made: ids, images and swatches.
@@ -169,6 +181,14 @@ pub(crate) struct Out {
/// goes in the innermost group kept).
groups: Vec<Arc<Clip>>,
too_deep: usize,
/// Illustrator clipping groups open (`q` … `Q`): the group's id (none when it opened past
/// [`MAX_NEST`]) and how many objects were drawn before it.
open_clips: Vec<(Option<u32>, usize)>,
/// Illustrator compound paths open (`*u` … `*U`): how many objects were drawn before each.
compounds: Vec<(usize, bool)>,
/// What the file says of the objects it draws now: hidden (Illustrator's `Xw`, a state it
/// changes only when the next object differs).
pub hidden: bool,
/// Path elements (and the clips and groups of each object) drawn so far (see [`MAX_DRAWN`]).
elements: usize,
/// The path the last object was filled with: a stroke of the same path right after joins it.
@@ -204,6 +224,9 @@ impl Out {
next_clip: 0,
groups: vec![],
too_deep: 0,
open_clips: vec![],
compounds: vec![],
hidden: false,
elements: 0,
merge: None,
cmyk: 0,
@@ -229,7 +252,7 @@ impl Out {
return;
}
self.next_clip += 1;
self.groups.push(Arc::new(Clip { id: self.next_clip, region: None }));
self.groups.push(Arc::new(Clip { id: self.next_clip, region: None, hidden: self.hidden }));
}
/// End the innermost group open (an end without a beginning ends nothing).
@@ -241,6 +264,76 @@ impl Out {
}
}
/// Open an Illustrator clipping group (`q`): a group that [`Self::end_clip_group`] clips with
/// the clip path the file gives at its end.
pub fn begin_clip_group(&mut self) {
let deep = self.too_deep;
self.begin_group();
let id = if self.too_deep == deep { self.groups.last().map(|c| c.id) } else { None };
self.open_clips.push((id, self.drawn.len()));
}
/// End the innermost clipping group open (`Q`), clipped to `region` when the file gave one
/// (without, it stays a plain group). An end without a beginning ends nothing.
pub fn end_clip_group(&mut self, region: Option<(BezPath, FillRule)>) {
let Some((id, start)) = self.open_clips.pop() else { return };
let hidden = self.groups.last().is_some_and(|c| Some(c.id) == id && c.hidden);
self.end_group();
let (Some(id), Some(region)) = (id, region) else { return };
// The objects drawn in the group hold the group's clip as it was when they were drawn,
// without its region.
let clip = Arc::new(Clip { id, region: Some(region), hidden });
for (chain, _) in self.drawn.iter_mut().skip(start) {
for c in chain.iter_mut().filter(|c| c.id == id) {
*c = clip.clone();
}
}
}
/// Open an Illustrator compound path (`*u`): the paths drawn until [`Self::end_compound`] are
/// its parts.
pub fn begin_compound(&mut self) {
self.compounds.push((self.drawn.len(), self.hidden));
}
/// End the innermost compound path open (`*U`): its parts become one compound path painted
/// with the first part's appearance (what Illustrator does). One part stays a plain path.
pub fn end_compound(&mut self) {
let Some((start, hidden)) = self.compounds.pop() else { return };
let parts = self.drawn.get(start..).unwrap_or_default();
let same_chain = |a: &DrawnItem, b: &DrawnItem| a.0.iter().map(|c| c.id).eq(b.0.iter().map(|c| c.id));
let Some(first) = parts.first() else { return };
if parts.len() < 2 || !parts.iter().all(|p| matches!(p.1.kind, NodeKind::Path { .. }) && same_chain(first, p)) {
return;
}
let parts = self.drawn.split_off(start);
let chain = parts.first().map(|p| p.0.clone()).unwrap_or_default();
let (mut rule, mut appearance) = (FillRule::NonZero, Appearance::default());
let mut children = Vec::with_capacity(parts.len());
for (i, (_, mut part)) in parts.into_iter().enumerate() {
if i == 0 {
appearance = std::mem::take(&mut part.appearance);
if let NodeKind::Path { rule: r, .. } = &part.kind {
rule = *r;
}
}
part.appearance = Appearance::default();
children.push(Arc::new(part));
}
let mut compound = Node::new(self.doc.alloc_id(), NodeKind::Compound { children, rule });
compound.appearance = appearance;
compound.visible = !hidden;
self.drawn.push((chain, compound));
}
/// Where a text object of the file's editing data goes: an empty group named [`TEXT_SLOT`],
/// which the importer replaces with the text it read from the file's page.
pub fn text_slot(&mut self, story: Option<u32>) {
let mut n = Node::group(NodeId(0), vec![]);
n.name = Some(story.map_or_else(|| TEXT_SLOT.to_string(), |s| format!("{TEXT_SLOT}{s}")));
self.push(n, &[]);
}
/// The chain an object drawn under `clips` is drawn in: the clips and the groups open, in the
/// order they opened.
pub fn chain(&self, clips: &[Arc<Clip>]) -> Vec<Arc<Clip>> {
@@ -272,6 +365,7 @@ impl Out {
return None;
}
node.id = self.doc.alloc_id();
node.visible = !self.hidden;
let id = node.id;
self.drawn.push((chain, node));
Some(id)
@@ -326,7 +420,7 @@ impl Out {
return None;
}
self.next_clip += 1;
Some(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)) }))
Some(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)), hidden: false }))
}
}
+37 -1
View File
@@ -7,7 +7,7 @@
use std::cell::RefCell;
use std::rc::Rc;
use vectorcraft_geom::Affine;
use vectorcraft_geom::{Affine, BezPath, FillRule};
use super::graphics::{GState, Out};
use super::lex::{IMMEDIATE, Lexer};
@@ -65,6 +65,11 @@ pub(crate) struct Interp<'a> {
/// The program is in the legacy Illustrator format: its group operators `u` … `U` (at the top
/// level, whatever its prolog defines them as) are groups (see the module docs of `import`).
pub illustrator: bool,
/// The program is the editing copy of an Illustrator file's art (see `native`): its clipping
/// groups, compound paths and text objects have operators of their own too.
pub native: bool,
/// The clip path `W` set for the clipping group about to end.
clip_region: Option<(BezPath, FillRule)>,
}
/// Where a PostScript error was raised: the operator that raised it (none for an unknown name)
@@ -164,6 +169,8 @@ impl<'a> Interp<'a> {
saved: vec![],
out,
illustrator: false,
native: false,
clip_region: None,
}
}
@@ -172,6 +179,9 @@ impl<'a> Interp<'a> {
loop {
let Some(o) = self.lex.next()? else { return Ok(()) };
let o = self.scanned(o, self.lex.immediate)?;
if self.native && self.native_op(&o)? {
continue;
}
// A group begins before its `u` runs (`Some(true)`) and ends after its `U`
// has (`Some(false)`).
let group = match &o {
@@ -195,6 +205,32 @@ impl<'a> Interp<'a> {
}
}
/// The operators only the editing copy of an Illustrator file's art has (`native`), run
/// instead of defined; `false` for any other token.
fn native_op(&mut self, o: &Obj) -> Res<bool> {
let Obj::Exec(name) = o else { return Ok(false) };
match &**name {
"q" => self.out.begin_clip_group(),
"Q" => {
let region = self.clip_region.take();
self.out.end_clip_group(region);
}
"W" => {
let path = self.take_path();
self.clip_region = (!path.elements().is_empty()).then_some((path, FillRule::NonZero));
}
"*u" => self.out.begin_compound(),
"*U" => self.out.end_compound(),
"Xw" => self.out.hidden = self.pop_num()? != 0.0,
"__txt" => {
let story = self.pop_num()?;
self.out.text_slot((story >= 0.0 && story < f64::from(u32::MAX)).then_some(story as u32));
}
_ => return Ok(false),
}
Ok(true)
}
/// Empty the standard dictionaries, so the memory they hold is freed with the interpreter:
/// systemdict holds itself, and programs link dictionaries into each other.
pub fn release(&mut self) {
+18 -6
View File
@@ -10,6 +10,7 @@
//! - `data`: data the program reads from itself through decoding filters, and images.
//! - `text`: fonts by name and type as point type; Type 3 fonts' glyphs drawn by their procedures.
//! - `preview`: the previews a file carries, for when its PostScript can't be read.
//! - `native`: the layers of an Illustrator file, from the editing copy of its art that it carries.
//!
//! Clipped art comes in as clipping groups. In a file in the legacy Illustrator format (versions
//! 3–8, with the prolog that defines its operators), the group operators `u` … `U` (nested) come in
@@ -24,10 +25,12 @@
//! error, the operator and the procedures it ran in; without a preview, what was drawn before the
//! error is kept (with that warning), else the file is refused.
mod ate;
mod data;
mod graphics;
mod interp;
mod lex;
mod native;
mod obj;
mod preview;
mod shading;
@@ -43,6 +46,8 @@ mod tests_generators;
mod tests_illustrator;
#[cfg(test)]
mod tests_images;
#[cfg(test)]
mod tests_native;
use std::sync::Arc;
@@ -55,6 +60,7 @@ use graphics::{GState, Out};
use interp::{Fault, Interp};
use obj::PsError;
pub use native::{is_loss, layered_ai};
pub use text::family_style;
/// The page of a PostScript file without a bounding box (US Letter).
@@ -182,7 +188,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, String> {
}
let drew = !out.drawn.is_empty();
let why = match (&result, drew) {
(Ok(()), true) => return Ok(finish(out)),
(Ok(()), true) => return Ok(native::layered(ps, finish(out))),
(Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(),
(Err(e), _) => reason(e, fault.as_ref()),
};
@@ -211,6 +217,16 @@ fn finish(mut out: Out) -> Imported {
if !out.shadings.is_empty() {
graphics::collapse(&mut children, &out.shadings);
}
let mut layer = Node::layer(out.doc.alloc_id(), "Layer 1", LayerColor::Preset(0));
if let Some(c) = layer.children_mut() {
*c = children;
}
finish_with(out, vec![Arc::new(layer)])
}
/// The document of what `out` drew, as `layers`: the spot inks as spot swatches, in CMYK when
/// most process colours were.
fn finish_with(out: Out, layers: Vec<Arc<Node>>) -> Imported {
let mut doc = out.doc;
if out.cmyk > out.rgb {
doc.color_mode = ColorMode::Cmyk;
@@ -227,10 +243,6 @@ fn finish(mut out: Out) -> Imported {
doc.swatches.push(Swatch { paint: Paint::Pattern { pattern: name.clone(), xf: Affine::IDENTITY }, name, global: false, spot: false });
}
}
let mut layer = Node::layer(doc.alloc_id(), "Layer 1", LayerColor::Preset(0));
if let Some(c) = layer.children_mut() {
*c = children;
}
doc.layers = vec![Arc::new(layer)];
doc.layers = layers;
Imported { document: doc, warnings: out.warnings, preview: false }
}
File diff suppressed because it is too large Load Diff
+438
View File
@@ -0,0 +1,438 @@
//! 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 made from the public specification's operators.
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"]);
assert!(matches!(&d.layers[0].kind, NodeKind::Layer { color: LayerColor::Custom([79, 128, 255]), .. }));
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() {
// A gradient (`Bg`) on a layer that shows: the layers would draw it as a flat colour.
let shown = native(&layer("Art", true, &format!("{}\n0 0 0 1 0 0 Bg", 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("`Bg`")), "{:?}", 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 Bg")));
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, together where the text object is.
let group = &r.document.layers[1].children().unwrap()[0];
assert_eq!(kinds(group), ["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);
}
}
/// 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()]);
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![]);
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![]);
assert_eq!(names(&d), names(&pdf));
assert!(w.iter().any(|w| w.contains("different page")), "{w:?}");
}
#[test]
fn a_gray_is_the_black_ink_as_in_a_cmyk_document() {
// `g` is a gray: 0.25 is 75 % of the black ink, as the page's own CMYK black says.
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::cmyk(0.0, 0.0, 0.0, 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 0 0 0 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto 10 14 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 is a group (its type and strokes), and the other is 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(), 1, "the type of the page sits in a group of its own");
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("Spare", false, &(square(10, 10) + "\n" + &commented)), &doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
let texts = text_of(&r.document.layers[0]);
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();
let group = &r.document.layers[0].children().unwrap()[0];
assert_eq!(kinds(group), ["text", "text"], "{:?}", r.warnings);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
#[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);
}
+1 -1
View File
@@ -34,7 +34,7 @@ mod ps;
mod scene;
mod tiff;
pub use import::{Imported, family_style, import};
pub use import::{Imported, family_style, import, is_loss, layered_ai};
pub use print::{PrintJob, PrintPage, print};
use vectorcraft_doc::Document;