mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 13:17:19 -06:00
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:
co-authored by
Claude Sonnet 5.5
parent
f149f10059
commit
ec43ac5a1e
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user