mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 19:27:21 -06:00
- A PostScript font name's shortened style words (Md, Lt, Bd, XBd, Cn, It, ...)
weren't matched to installed faces, so AkzidenzGroteskPro-Md came in as
the condensed face. They are spelled out before the face is looked up.
- A line the page draws in pieces (around kerning) came in as several text
objects ("swe", "ep"); the pieces that add up to one line of the file's
text object now join into one, with the line's spaces between them.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1123 lines
48 KiB
Rust
1123 lines
48 KiB
Rust
//! The layers of an Illustrator EPS or `.ai`.
|
|
//!
|
|
//! Besides the page it prints, such a file carries the app's own copy of the art: an EPS after its
|
|
//! `%%EOF`, between `%AI9_PrivateDataBegin` and `%AI9_PrivateDataEnd` (ASCII85 text of a Zstandard
|
|
//! stream, `%AI24_DataStream`; older files: zlib, `%AI9_DataStream`); a PDF-compatible `.ai` in its `AIPrivateData` streams (the Zstandard
|
|
//! stream after `%AI24_ZStandard_Data`). It is the document as the legacy Illustrator format writes
|
|
//! it (`%AI5_BeginLayer`, `Lb`, `Ln`, `u` … `U`, `*u` … `*U`, `q` … `Q`, `m` `l` `c` `f` `S` …). It
|
|
//! has what the page doesn't: the layers with their names, which of them are hidden (and what is on
|
|
//! them), the hidden objects, the art outside the artboards, the groups, the compound paths and the
|
|
//! clipping groups.
|
|
//!
|
|
//! The page stays the source of what the art looks like; this copy only gives it its structure. Only
|
|
//! the legacy format's own operators and comments are read, never what the app keeps in its private
|
|
//! comments (`%_`) and dictionaries. So:
|
|
//!
|
|
//! - Text objects are stubs here: their characters, fonts and places are in the file's text document
|
|
//! (see `ate`). Type that shows comes from the page, which draws it, into the slot whose text is
|
|
//! where the page has it. Type that doesn't show (a hidden layer or object, or off the page) is made
|
|
//! from the text document; where that can't be read (area type, type on a path) it's left out, with
|
|
//! a warning.
|
|
//! - A line with an operator that isn't read (gradients, patterns, images, blends, …) isn't read. If
|
|
//! one is on a layer that shows, the art of this copy would differ from the page's, so the layers
|
|
//! aren't used: the file comes in as its page, as it did before.
|
|
//!
|
|
//! Sources (public documentation only; no Adobe software was run and no Adobe file is in this repo):
|
|
//!
|
|
//! - The legacy format: *Adobe Illustrator File Format Specification*, version 7.0, published by its
|
|
//! maker for third-party developers; PRONOM lists it at
|
|
//! <https://www.nationalarchives.gov.uk/PRONOM/fmt/423> (the record links the document itself).
|
|
//! The layer, group, compound-path and clipping vocabulary is that format's; PRONOM's record for
|
|
//! versions 5.0/5.5 (<https://pronom.nationalarchives.gov.uk/fmt/420>) shows the layer-era comments
|
|
//! date from then.
|
|
//! - The wrappers: ASCII85 is PostScript Level 2 (*PostScript Language Reference*, 3rd ed.); the
|
|
//! compressed stream is Zstandard, RFC 8878 (<https://www.rfc-editor.org/rfc/rfc8878>).
|
|
//! - The container (`%AI9_PrivateDataBegin`, `%AI24_DataStream`, `%AI24_ZStandard_Data`, the
|
|
//! `AIPrivateData` streams), the meaning of the `Xw` hidden flag and the text document are NOT in
|
|
//! those documents: they
|
|
//! were worked out from `.eps` and `.ai` files their users own, used locally. Where a file doesn't
|
|
//! match what this module expects, it is left alone and its page is imported.
|
|
|
|
use std::collections::BTreeSet;
|
|
use std::io::Read;
|
|
use std::sync::Arc;
|
|
|
|
use vectorcraft_doc::clipnest::nest;
|
|
use vectorcraft_doc::{AppearanceItem, Document, LayerColor, Node, NodeId, NodeKind};
|
|
use vectorcraft_geom::{Affine, Point, Rect};
|
|
|
|
use super::ate::{Story, Texts};
|
|
use super::graphics::{GState, Out, slot_of};
|
|
use super::interp::Interp;
|
|
use super::{Imported, box_of, finish_with, reason};
|
|
|
|
const BEGIN: &[u8] = b"%AI9_PrivateDataBegin";
|
|
const END: &[u8] = b"%AI9_PrivateDataEnd";
|
|
const STREAM: &[u8] = b"%AI24_DataStream";
|
|
/// The same, in files from before Zstandard.
|
|
const STREAM_ZLIB: &[u8] = b"%AI9_DataStream";
|
|
/// What a `.ai`'s private data starts with, before its Zstandard stream.
|
|
const ZSTD_MARK: &[u8] = b"%AI24_ZStandard_Data";
|
|
/// Most bytes the editing copy may have once decompressed.
|
|
const MAX_DECODED: u64 = 256 << 20;
|
|
/// The notes that say the import left out something the file has (see [`is_loss`]).
|
|
const TEXT_LEFT_OUT: &str = "text objects on hidden layers or hidden objects couldn't be read, so they are left out";
|
|
const ART_LEFT_OUT: &str = "hidden layers have art this can't read";
|
|
const PART_LEFT_OUT: &str = "part of a layer was left out";
|
|
const LAYERS_UNREAD: &str = "the file's layers weren't read";
|
|
|
|
/// Does this import note say that the file has something the document doesn't (hidden text, art or
|
|
/// layers that could not be read)? Writing the document over the file would lose it for good.
|
|
pub fn is_loss(note: &str) -> bool {
|
|
note.ends_with(TEXT_LEFT_OUT) || [ART_LEFT_OUT, PART_LEFT_OUT, LAYERS_UNREAD].iter().any(|n| note.starts_with(n))
|
|
}
|
|
|
|
/// Deepest nesting of layers read (deeper ones are left out).
|
|
const MAX_DEPTH: usize = 32;
|
|
/// Most layers read.
|
|
const MAX_LAYERS: usize = 4096;
|
|
|
|
/// What the operators of the format do, as PostScript: paths are the path operators, a fill or a
|
|
/// stroke uses the colour the last `k` `K` `g` `G` set (a file's objects each set their own).
|
|
const PROLOGUE: &str = "\
|
|
/m {moveto} def /l {lineto} def /L {lineto} def /c {curveto} def /C {curveto} def
|
|
/v {currentpoint 6 2 roll curveto} def /V {currentpoint 6 2 roll curveto} def
|
|
/y {2 copy curveto} def /Y {2 copy curveto} def
|
|
/h {closepath} def /N {newpath} def /n {closepath newpath} def
|
|
/fc [0 0 0 0] def /sc [0 0 0 1] def /eo false def
|
|
/setc {dup length 1 eq {0 get setgray} {dup length 3 eq {aload pop setrgbcolor} {aload pop setcmykcolor} ifelse} ifelse} def
|
|
/k {4 array astore /fc exch def} def /K {4 array astore /sc exch def} def
|
|
/g {1 exch sub 0 0 0 4 -1 roll 4 array astore /fc exch def} def /G {1 exch sub 0 0 0 4 -1 roll 4 array astore /sc exch def} def
|
|
/Xk {pop pop pop 4 array astore /fc exch def} def /XK {pop pop pop 4 array astore /sc exch def} def
|
|
/x {pop pop 4 array astore /fc exch def} def /X {pop pop 4 array astore /sc exch def} def
|
|
/Xx {6 {pop} repeat 4 array astore /fc exch def} def /XX {6 {pop} repeat 4 array astore /sc exch def} def
|
|
/H {} def
|
|
/D {1 eq /eo exch def} def
|
|
/doF {fc setc eo {eofill} {fill} ifelse} def /doS {sc setc stroke} def
|
|
/F {doF} def /f {closepath doF} def /S {doS} def /s {closepath doS} def
|
|
/B {gsave doF grestore doS} def /b {closepath B} def
|
|
/w {setlinewidth} def /J {setlinecap} def /j {setlinejoin} def /M {setmiterlimit} def /d {setdash} def
|
|
/u {} def /U {} def
|
|
";
|
|
|
|
/// Operators that draw, set the state they draw with, or group (`q` `Q` `W` `*u` `*U` are the
|
|
/// interpreter's own, see `Interp::native_op`).
|
|
const KEEP: &[&str] = &[
|
|
"m", "l", "L", "c", "C", "v", "V", "y", "Y", "h", "n", "N", "f", "F", "s", "S", "b", "B", "k", "K", "g", "G", "Xk", "XK", "x", "X", "Xx", "XX",
|
|
"H", "w", "J", "j", "M", "d", "D", "u", "U", "*u", "*U", "q", "Q", "W", "Xw",
|
|
];
|
|
/// Operators that only describe an object to the app (its lock, its overprint, its name's id, …):
|
|
/// the line is left out.
|
|
const IGNORE: &[&str] = &["A", "Ae", "AE", "As", "Ap", "O", "R", "i", "Xd", "Xy", "XR", "XW", "XP"];
|
|
|
|
/// One token of a line.
|
|
enum Tok<'a> {
|
|
Num(f64),
|
|
Str(Vec<u8>),
|
|
Name(&'a str),
|
|
}
|
|
|
|
/// The tokens of `line`: numbers, strings, names (the brackets of an array are left out); `None`
|
|
/// for a string that doesn't end.
|
|
fn tokens(line: &str) -> Option<Vec<Tok<'_>>> {
|
|
let b = line.as_bytes();
|
|
let mut out = vec![];
|
|
let mut i = 0;
|
|
while let Some(&c) = b.get(i) {
|
|
match c {
|
|
c if c.is_ascii_whitespace() || c == b'[' || c == b']' => i += 1,
|
|
b'(' => {
|
|
let (mut depth, mut s) = (1, vec![]);
|
|
i += 1;
|
|
loop {
|
|
let c = *b.get(i)?;
|
|
i += 1;
|
|
match c {
|
|
b'\\' => {
|
|
let e = *b.get(i)?;
|
|
i += 1;
|
|
s.push(match e {
|
|
b'n' => b'\n',
|
|
b'r' => b'\r',
|
|
b't' => b'\t',
|
|
b'0'..=b'7' => {
|
|
let mut v = u32::from(e - b'0');
|
|
for _ in 0..2 {
|
|
match b.get(i) {
|
|
Some(d @ b'0'..=b'7') => {
|
|
v = v * 8 + u32::from(d - b'0');
|
|
i += 1;
|
|
}
|
|
_ => break,
|
|
}
|
|
}
|
|
(v & 0xff) as u8
|
|
}
|
|
other => other,
|
|
});
|
|
}
|
|
b'(' => {
|
|
depth += 1;
|
|
s.push(c);
|
|
}
|
|
b')' => {
|
|
depth -= 1;
|
|
if depth == 0 {
|
|
break;
|
|
}
|
|
s.push(c);
|
|
}
|
|
_ => s.push(c),
|
|
}
|
|
}
|
|
out.push(Tok::Str(s));
|
|
}
|
|
_ => {
|
|
let start = i;
|
|
while b.get(i).is_some_and(|c| !c.is_ascii_whitespace() && !matches!(c, b'[' | b']' | b'(')) {
|
|
i += 1;
|
|
}
|
|
let word = line.get(start..i)?;
|
|
out.push(word.parse::<f64>().map_or(Tok::Name(word), Tok::Num));
|
|
}
|
|
}
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
/// What the editing copy holds of a layer.
|
|
#[derive(Default)]
|
|
struct Layer {
|
|
name: String,
|
|
visible: bool,
|
|
color: Option<[u8; 3]>,
|
|
items: Vec<Item>,
|
|
/// The code read since the last sublayer.
|
|
code: String,
|
|
/// Operators on this layer that aren't read.
|
|
unsupported: BTreeSet<String>,
|
|
}
|
|
|
|
enum Item {
|
|
Code(String),
|
|
Sub(Layer),
|
|
}
|
|
|
|
impl Layer {
|
|
fn flush(&mut self) {
|
|
if !self.code.is_empty() {
|
|
self.items.push(Item::Code(std::mem::take(&mut self.code)));
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Parsed {
|
|
/// The art's bounding box `llx lly urx ury`.
|
|
bbox: [f64; 4],
|
|
/// The first artboard (`%AI3_Cropmarks`), in the same space.
|
|
artboard: Option<[f64; 4]>,
|
|
/// The template box (`%AI3_TemplateBox`): its centre is the centre of the app's canvas.
|
|
template: Option<[f64; 4]>,
|
|
layers: Vec<Layer>,
|
|
}
|
|
|
|
/// The editing copy of the art, decompressed.
|
|
fn decode(ps: &[u8]) -> Option<Vec<u8>> {
|
|
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 begin = find(ps, BEGIN, 0)?;
|
|
let end = find(ps, END, begin).unwrap_or(ps.len());
|
|
let block = ps.get(begin..end)?;
|
|
// Newer files compress with Zstandard, older ones (CS-era) with zlib.
|
|
let (at, zstd) = match find(block, STREAM, 0) {
|
|
Some(at) => (at + STREAM.len(), true),
|
|
None => (find(block, STREAM_ZLIB, 0)? + STREAM_ZLIB.len(), false),
|
|
};
|
|
// The stream's lines each start with a `%`.
|
|
let text: String = block
|
|
.get(at..)?
|
|
.split(|b| matches!(b, b'\r' | b'\n'))
|
|
.filter(|l| !l.is_empty())
|
|
.flat_map(|l| l.strip_prefix(b"%").unwrap_or(l).iter())
|
|
.map(|b| char::from(*b))
|
|
.collect();
|
|
let packed = crate::ps::ascii85_decode(&text)?;
|
|
if zstd { unzstd(&packed) } else { unzlib(&packed) }
|
|
}
|
|
|
|
/// The editing copy of a PDF-compatible `.ai`'s `AIPrivateData` streams, joined, decompressed.
|
|
fn decode_ai(private: &[u8]) -> Option<Vec<u8>> {
|
|
unzstd(private.strip_prefix(ZSTD_MARK)?)
|
|
}
|
|
|
|
/// `packed` as a zlib stream decompressed (up to [`MAX_DECODED`] bytes).
|
|
fn unzlib(packed: &[u8]) -> Option<Vec<u8>> {
|
|
let mut data = vec![];
|
|
flate2::read::ZlibDecoder::new(packed).take(MAX_DECODED).read_to_end(&mut data).ok()?;
|
|
(data.len() as u64 != MAX_DECODED).then_some(data)
|
|
}
|
|
|
|
/// `packed` as a Zstandard stream decompressed (up to [`MAX_DECODED`] bytes).
|
|
fn unzstd(packed: &[u8]) -> Option<Vec<u8>> {
|
|
let mut zstd = ruzstd::decoding::StreamingDecoder::new(packed).ok()?;
|
|
let mut data = vec![];
|
|
(&mut zstd).take(MAX_DECODED).read_to_end(&mut data).ok()?;
|
|
(data.len() as u64 != MAX_DECODED).then_some(data)
|
|
}
|
|
|
|
/// The stories that text objects written plainly (not as comments) name.
|
|
fn plain_stories(data: &[u8]) -> BTreeSet<u32> {
|
|
data.split(|b| matches!(b, b'\r' | b'\n'))
|
|
.filter_map(|raw| {
|
|
let line = String::from_utf8_lossy(raw);
|
|
line.trim().strip_suffix("/StoryIndex ,").and_then(|n| n.trim().parse().ok())
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The layers of the editing copy `data`.
|
|
fn parse(data: &[u8]) -> Option<Parsed> {
|
|
let (mut bbox, mut hires, mut artboard, mut template) = (None, None, None, None);
|
|
let mut stack: Vec<Layer> = vec![];
|
|
let mut done: Vec<Layer> = vec![];
|
|
let mut dict_depth = 0usize;
|
|
// In a text object's dictionary: the story it names, once the dictionary says.
|
|
let mut text_story: Option<Option<u32>> = None;
|
|
// Whether that text object is written as comments, which the file does for some of its stories
|
|
// twice (once plain, once commented) and for others only commented.
|
|
let mut text_commented = false;
|
|
let plain = plain_stories(data);
|
|
let mut count = 0usize;
|
|
for raw in data.split(|b| matches!(b, b'\r' | b'\n')) {
|
|
let line = String::from_utf8_lossy(raw);
|
|
let line = line.trim();
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
// A text object's dictionary may be written as comments (`%_`), which a reader that doesn't
|
|
// know text objects skips: what that reader skips is read here, for the text object only.
|
|
let line = match line.strip_prefix("%_") {
|
|
Some(rest) if dict_depth > 0 || rest.starts_with("/AI11Text") && rest.ends_with(':') => {
|
|
if dict_depth == 0 {
|
|
text_commented = true;
|
|
}
|
|
rest.trim()
|
|
}
|
|
_ => {
|
|
if dict_depth == 0 {
|
|
text_commented = false;
|
|
}
|
|
line
|
|
}
|
|
};
|
|
if line.starts_with('%') {
|
|
match line {
|
|
"%AI5_BeginLayer" => {
|
|
count += 1;
|
|
if count > MAX_LAYERS {
|
|
return None;
|
|
}
|
|
if let Some(parent) = stack.last_mut() {
|
|
parent.flush();
|
|
}
|
|
stack.push(Layer { visible: true, ..Layer::default() });
|
|
}
|
|
"%AI5_EndLayer--" => {
|
|
let mut l = stack.pop()?;
|
|
l.flush();
|
|
match stack.last_mut() {
|
|
Some(parent) => parent.items.push(Item::Sub(l)),
|
|
None => done.push(l),
|
|
}
|
|
}
|
|
_ if stack.is_empty() => {
|
|
if let Some(v) = line.strip_prefix("%%HiResBoundingBox:") {
|
|
hires = box_of(v.trim()).or(hires);
|
|
} else if let Some(v) = line.strip_prefix("%%BoundingBox:") {
|
|
bbox = box_of(v.trim()).or(bbox);
|
|
} else if let Some(v) = line.strip_prefix("%AI3_Cropmarks:") {
|
|
artboard = box_of(v.trim()).or(artboard);
|
|
} else if let Some(v) = line.strip_prefix("%AI3_TemplateBox:") {
|
|
template = four_numbers(v).or(template);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
continue;
|
|
}
|
|
let Some(layer) = stack.last_mut() else { continue };
|
|
// The app's dictionaries (`/Name :` … `;`): a text object's is a slot for its text.
|
|
if dict_depth > 0 {
|
|
// A dictionary ends with `;`, which may have its key after it (`; /ConfiningPath ,`).
|
|
if line == ";" || line.starts_with("; /") {
|
|
dict_depth -= 1;
|
|
if dict_depth == 0
|
|
&& let Some(story) = text_story.take()
|
|
{
|
|
// A commented copy of a story that has a plain text object is not another object.
|
|
if !(text_commented && story.is_some_and(|n| plain.contains(&n))) {
|
|
layer.code.push_str(&format!("{} __txt\n", story.map_or(-1, i64::from)));
|
|
}
|
|
}
|
|
} else if line.ends_with(':') {
|
|
dict_depth += 1;
|
|
} else if text_story.is_some()
|
|
&& let Some(n) = line.strip_suffix("/StoryIndex ,")
|
|
{
|
|
text_story = Some(n.trim().parse().ok());
|
|
}
|
|
continue;
|
|
}
|
|
if line.starts_with('/') {
|
|
if line.ends_with(':') {
|
|
dict_depth = 1;
|
|
if line.starts_with("/AI11Text") {
|
|
text_story = Some(None);
|
|
} else {
|
|
layer.unsupported.insert("an object with its own dictionary".into());
|
|
}
|
|
} else {
|
|
layer.unsupported.insert("an unknown object".into());
|
|
}
|
|
continue;
|
|
}
|
|
let Some(toks) = tokens(line) else {
|
|
layer.unsupported.insert("an unreadable line".into());
|
|
continue;
|
|
};
|
|
let last = toks.last().and_then(|t| if let Tok::Name(n) = t { Some(*n) } else { None });
|
|
match last {
|
|
Some("Lb") => {
|
|
let n: Vec<f64> = toks.iter().filter_map(|t| if let Tok::Num(v) = t { Some(*v) } else { None }).collect();
|
|
layer.visible = n.first().is_none_or(|v| *v != 0.0);
|
|
if let [r, g, b] = n.get(8..11).unwrap_or_default() {
|
|
let byte = |v: &f64| (v.is_finite() && (0.0..=255.0).contains(v)).then_some(*v as u8);
|
|
layer.color = byte(r).zip(byte(g)).zip(byte(b)).map(|((r, g), b)| [r, g, b]);
|
|
}
|
|
continue;
|
|
}
|
|
Some("Ln") => {
|
|
if let Some(Tok::Str(s)) = toks.first() {
|
|
layer.name = String::from_utf8_lossy(s).into_owned();
|
|
}
|
|
continue;
|
|
}
|
|
Some("LB") => continue,
|
|
_ => {}
|
|
}
|
|
let mut ignored = false;
|
|
let mut missing: Option<&str> = None;
|
|
for t in &toks {
|
|
if let Tok::Name(n) = t {
|
|
if IGNORE.contains(n) {
|
|
ignored = true;
|
|
} else if !KEEP.contains(n) {
|
|
missing = missing.or(Some(n));
|
|
}
|
|
}
|
|
}
|
|
if let Some(n) = missing {
|
|
let shown = n.len() <= 8 && n.chars().all(|c| c.is_ascii_graphic());
|
|
layer.unsupported.insert(if shown { format!("`{n}`") } else { "binary data".to_string() });
|
|
} else if !ignored {
|
|
layer.code.push_str(line);
|
|
layer.code.push('\n');
|
|
}
|
|
}
|
|
while let Some(mut l) = stack.pop() {
|
|
l.flush();
|
|
match stack.last_mut() {
|
|
Some(parent) => parent.items.push(Item::Sub(l)),
|
|
None => done.push(l),
|
|
}
|
|
}
|
|
Some(Parsed { bbox: hires.or(bbox)?, artboard, template, layers: done })
|
|
}
|
|
|
|
/// Four numbers (a box that may be a point).
|
|
fn four_numbers(v: &str) -> Option<[f64; 4]> {
|
|
let n: Vec<f64> = v.split_whitespace().map_while(|w| w.parse::<f64>().ok()).collect();
|
|
let [a, b, c, d] = n.as_slice() else { return None };
|
|
[a, b, c, d].iter().all(|v| v.is_finite() && v.abs() <= 1e7).then_some([*a, *b, *c, *d])
|
|
}
|
|
|
|
/// `names` for a note: the first few, and how many more.
|
|
fn few(names: BTreeSet<String>) -> String {
|
|
const SHOWN: usize = 5;
|
|
let more = names.len().saturating_sub(SHOWN);
|
|
let mut list: Vec<String> = names.into_iter().take(SHOWN).collect();
|
|
if more > 0 {
|
|
list.push(format!("{more} more"));
|
|
}
|
|
list.join(", ")
|
|
}
|
|
|
|
/// What the operators not read are, on layers that show and on layers that don't.
|
|
fn unread(layers: &[Layer], shown: bool, on: &mut BTreeSet<String>, off: &mut BTreeSet<String>) {
|
|
for l in layers {
|
|
let shown = shown && l.visible;
|
|
let set = if shown { &mut *on } else { &mut *off };
|
|
set.extend(l.unsupported.iter().cloned());
|
|
let subs: Vec<&Layer> = l.items.iter().filter_map(|i| if let Item::Sub(s) = i { Some(s) } else { None }).collect();
|
|
for s in subs {
|
|
unread(std::slice::from_ref(s), shown, on, off);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A text slot of the layers.
|
|
struct SlotInfo {
|
|
story: Option<u32>,
|
|
/// It, its layer and the objects round it show.
|
|
shown: bool,
|
|
}
|
|
|
|
/// The text slots of the layers and objects, in order.
|
|
fn collect_slots(nodes: &[Arc<Node>], shown: bool, out: &mut Vec<SlotInfo>) {
|
|
for n in nodes {
|
|
let shown = shown && n.visible;
|
|
if let Some(story) = slot_of(n.name.as_deref()) {
|
|
out.push(SlotInfo { story, shown });
|
|
} else if let Some(c) = n.children().filter(|_| !matches!(n.kind, NodeKind::Compound { .. })) {
|
|
collect_slots(c, shown, out);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Builds the layers' nodes by running each stretch of their code.
|
|
struct Builder {
|
|
out: Option<Out>,
|
|
}
|
|
|
|
impl Builder {
|
|
/// What `code` draws, as the nodes of a layer.
|
|
fn run(&mut self, code: &str) -> Vec<Arc<Node>> {
|
|
let Some(out) = self.out.take() else { return vec![] };
|
|
let src = format!("{PROLOGUE}{code}");
|
|
let mut out = out;
|
|
out.hidden = false;
|
|
let mut it = Interp::new(src.as_bytes(), GState::default(), out);
|
|
it.illustrator = true;
|
|
it.native = true;
|
|
let result = it.run();
|
|
it.release();
|
|
let fault = it.fault.take();
|
|
let mut out = it.out;
|
|
if let Err(e) = result {
|
|
out.warn(&format!("{PART_LEFT_OUT}: {}", reason(&e, fault.as_ref())));
|
|
}
|
|
let drawn = std::mem::take(&mut out.drawn);
|
|
let nodes = nest(&mut out.doc, drawn);
|
|
self.out = Some(out);
|
|
nodes
|
|
}
|
|
|
|
fn layer(&mut self, l: &Layer, depth: usize) -> Option<Arc<Node>> {
|
|
let mut children = vec![];
|
|
for item in &l.items {
|
|
match item {
|
|
Item::Code(code) => children.extend(self.run(code)),
|
|
Item::Sub(sub) if depth < MAX_DEPTH => children.extend(self.layer(sub, depth + 1)),
|
|
Item::Sub(_) => {}
|
|
}
|
|
}
|
|
let out = self.out.as_mut()?;
|
|
let name = if l.name.is_empty() { "Layer" } else { &l.name };
|
|
let mut node = Node::layer(out.doc.alloc_id(), name, l.color.map_or(LayerColor::Preset(0), LayerColor::Custom));
|
|
node.visible = l.visible;
|
|
if let Some(c) = node.children_mut() {
|
|
*c = children;
|
|
}
|
|
Some(Arc::new(node))
|
|
}
|
|
}
|
|
|
|
/// How far from a text object's own extent the strokes drawn around it reach (points).
|
|
const OUTLINE_REACH: f64 = 6.0;
|
|
|
|
/// Does `n` look like part of a text object's outline: a path with a stroke, inside `around`?
|
|
fn outlines(n: &Node, around: Rect) -> bool {
|
|
matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
|
|
&& n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.visible && s.width > 0.0))
|
|
&& n.visual_bounds().is_some_and(|b| {
|
|
let a = around.inflate(OUTLINE_REACH, OUTLINE_REACH);
|
|
b.x0 >= a.x0 && b.y0 >= a.y0 && b.x1 <= a.x1 && b.y1 <= a.y1
|
|
})
|
|
}
|
|
|
|
/// How far apart (points) the baselines of two text nodes may be for them to be on one line.
|
|
const SAME_LINE: f64 = 0.5;
|
|
|
|
/// Where `n`'s baseline lies across the direction it runs in (points), if it is text.
|
|
fn baseline(n: &Node) -> Option<f64> {
|
|
let NodeKind::Text(t) = &n.kind else { return None };
|
|
let [a, b, _, _, e, f] = t.xf.as_coeffs();
|
|
let len = a.hypot(b);
|
|
(len > 1e-9).then(|| (a * f - b * e) / len)
|
|
}
|
|
|
|
/// The text objects the page paints, each as the run of nodes that make it up (its text, once for
|
|
/// each fill and stroke it has, with the paths of its strokes), in painting order: those that
|
|
/// show in `shown`, those of hidden layers and objects (a PDF has them) in `hidden`.
|
|
fn text_objects(nodes: &[Arc<Node>], on: bool, shown: &mut Vec<Vec<Arc<Node>>>, hidden: &mut Vec<Vec<Arc<Node>>>) {
|
|
let is_text = |n: &Node| matches!(n.kind, NodeKind::Text(_));
|
|
let (mut i, mut free) = (0, 0);
|
|
while let Some(n) = nodes.get(i) {
|
|
if !is_text(n) {
|
|
if let Some(c) = n.children().filter(|_| !matches!(n.kind, NodeKind::Compound { .. })) {
|
|
text_objects(c, on && n.visible, shown, hidden);
|
|
free = i + 1;
|
|
}
|
|
i += 1;
|
|
continue;
|
|
}
|
|
let mut bounds = n.visual_bounds().unwrap_or_default();
|
|
let (mut start, mut end) = (i, i);
|
|
while start > free
|
|
&& let Some(p) = nodes.get(start - 1)
|
|
&& outlines(p, bounds)
|
|
{
|
|
start -= 1;
|
|
bounds = bounds.union(p.visual_bounds().unwrap_or_default());
|
|
}
|
|
while let Some(next) = nodes.get(end + 1)
|
|
&& let Some(b) = next.visual_bounds()
|
|
&& (is_text(next)
|
|
&& !b.intersect(bounds.inflate(OUTLINE_REACH, OUTLINE_REACH)).is_zero_area()
|
|
&& baseline(next)
|
|
.zip(nodes.get(start..=end).unwrap_or_default().iter().find_map(|m| baseline(m)))
|
|
.is_some_and(|(x, y)| (x - y).abs() <= SAME_LINE)
|
|
|| outlines(next, bounds))
|
|
{
|
|
end += 1;
|
|
bounds = bounds.union(b);
|
|
}
|
|
let object = nodes.get(start..=end).unwrap_or_default().to_vec();
|
|
let into = if on && n.visible { &mut *shown } else { &mut *hidden };
|
|
into.push(object);
|
|
i = end + 1;
|
|
free = i;
|
|
}
|
|
}
|
|
|
|
/// `n` and what it holds with ids of `doc`.
|
|
fn reid(doc: &mut Document, n: &Arc<Node>) -> Arc<Node> {
|
|
let mut n = n.as_ref().clone();
|
|
n.id = doc.alloc_id();
|
|
if let Some(children) = n.children_mut() {
|
|
for c in children.iter_mut() {
|
|
*c = reid(doc, c);
|
|
}
|
|
}
|
|
Arc::new(n)
|
|
}
|
|
|
|
/// The page's text objects given to each of `slots` text slots: in painting order, and when there
|
|
/// are more of one than of the other (a label may be a few text objects, or one with several
|
|
/// strokes), each goes where the share of the order it is at falls.
|
|
fn assign(objects: &[Vec<Arc<Node>>], slots: usize) -> Vec<Vec<&[Arc<Node>]>> {
|
|
let mut given: Vec<Vec<&[Arc<Node>]>> = vec![vec![]; slots];
|
|
for (j, o) in objects.iter().enumerate() {
|
|
if let Some(at) = given.get_mut(((2 * j + 1) * slots / (2 * objects.len())).min(slots.saturating_sub(1))) {
|
|
at.push(o);
|
|
}
|
|
}
|
|
given
|
|
}
|
|
|
|
/// What goes into a text slot.
|
|
enum Content {
|
|
/// Text objects (with the strokes round them) of the page.
|
|
Page(Vec<Arc<Node>>),
|
|
/// A text object made from the file's text document.
|
|
Made(Box<Node>),
|
|
/// Nothing the file lets this read.
|
|
Empty,
|
|
/// Its type is on the page with another text object's; there is nothing to put here.
|
|
Merged,
|
|
}
|
|
|
|
/// Replace the text slots in `nodes` with what `content` has for each, in order (a slot given none is
|
|
/// left out). `empty` counts those.
|
|
fn fill_slots(doc: &mut Document, nodes: &mut Vec<Arc<Node>>, content: &mut std::vec::IntoIter<Content>, empty: &mut usize) {
|
|
let mut i = 0;
|
|
while i < nodes.len() {
|
|
let Some(n) = nodes.get(i) else { break };
|
|
if slot_of(n.name.as_deref()).is_some() {
|
|
let visible = n.visible;
|
|
let replacement = match content.next() {
|
|
Some(Content::Page(parts)) if !parts.is_empty() => {
|
|
let children = parts.iter().map(|p| reid(doc, p)).collect();
|
|
let mut group = Node::group(doc.alloc_id(), children);
|
|
group.visible = visible;
|
|
Some(group)
|
|
}
|
|
Some(Content::Made(mut text)) => {
|
|
text.id = doc.alloc_id();
|
|
text.visible = visible;
|
|
Some(*text)
|
|
}
|
|
Some(Content::Merged) => {
|
|
nodes.remove(i);
|
|
continue;
|
|
}
|
|
_ => None,
|
|
};
|
|
match replacement {
|
|
Some(node) => {
|
|
if let Some(slot) = nodes.get_mut(i) {
|
|
*slot = Arc::new(node);
|
|
}
|
|
i += 1;
|
|
}
|
|
None => {
|
|
*empty += 1;
|
|
nodes.remove(i);
|
|
}
|
|
}
|
|
} else {
|
|
if n.children().is_some()
|
|
&& !matches!(n.kind, NodeKind::Compound { .. })
|
|
&& let Some(c) = nodes.get_mut(i).map(Arc::make_mut).and_then(Node::children_mut)
|
|
{
|
|
fill_slots(doc, c, content, empty);
|
|
}
|
|
i += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// How far, in points, the page's type may be from where the file's text document puts it for the
|
|
/// two to be the same type.
|
|
const SAME_PLACE: f64 = 0.75;
|
|
|
|
/// Its letters and digits, which is what is compared of type (the page may spell a mark another way).
|
|
fn letters(s: &str) -> String {
|
|
s.chars().filter(char::is_ascii_alphanumeric).collect()
|
|
}
|
|
|
|
/// Does `n` hold type that is where `story` lays one of its lines out: the line, or a piece of it
|
|
/// (the page splits a line where its kerning or its styles change), on that line's baseline and
|
|
/// after its start? How far from where the line starts it is, if so.
|
|
fn same_type(n: &Node, lines: &[(String, Point)]) -> Option<f64> {
|
|
let NodeKind::Text(t) = &n.kind else { return None };
|
|
let have = letters(&t.plain_text());
|
|
let [a, b, _, _, e, f] = t.xf.as_coeffs();
|
|
let len = a.hypot(b);
|
|
if have.is_empty() || len < 1e-9 {
|
|
return None;
|
|
}
|
|
let (ux, uy) = (a / len, b / len);
|
|
// About how far a character of it advances, to say how far along a line a piece may be.
|
|
let advance = n.visual_bounds().map_or(0.0, |r| r.width().max(r.height())) / have.len() as f64;
|
|
lines
|
|
.iter()
|
|
.filter_map(|(text, at)| {
|
|
let line = letters(text);
|
|
let skipped = line.find(&have)?;
|
|
if skipped == 0 {
|
|
return Some(((at.x - e).powi(2) + (at.y - f).powi(2)).sqrt()).filter(|d| *d <= SAME_PLACE);
|
|
}
|
|
let (dx, dy) = (e - at.x, f - at.y);
|
|
let (along, across) = (dx * ux + dy * uy, (dx * uy - dy * ux).abs());
|
|
((have.len() > 1 || skipped + 1 == line.len())
|
|
&& across <= SAME_PLACE
|
|
&& along >= -SAME_PLACE
|
|
&& along <= line.len() as f64 * advance * 2.0 + 4.0)
|
|
.then_some(across + 0.001 * along)
|
|
})
|
|
.min_by(f64::total_cmp)
|
|
}
|
|
|
|
/// The page draws a line in pieces where its kerning or styles change ("swe", "ep"), and the file
|
|
/// has it as one line of one text object: the pieces that on one baseline add up to a line of
|
|
/// `lines` become one text (each keeping its own style, the line's spaces between them).
|
|
fn join_pieces(parts: &mut Vec<Arc<Node>>, lines: &[(String, Point)]) {
|
|
let text_of = |n: &Arc<Node>| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None };
|
|
let texts: Vec<usize> = parts.iter().enumerate().filter(|(_, n)| matches!(n.kind, NodeKind::Text(_))).map(|(i, _)| i).collect();
|
|
let mut taken: Vec<usize> = vec![];
|
|
// The first piece of each line to join, the others, and the spaces to put after each piece.
|
|
let mut joins: Vec<(usize, Vec<usize>, Vec<String>)> = vec![];
|
|
for &i in &texts {
|
|
if taken.contains(&i) {
|
|
continue;
|
|
}
|
|
let Some(line) = parts.get(i).and_then(|n| baseline(n)) else { continue };
|
|
let group: Vec<usize> = texts
|
|
.iter()
|
|
.copied()
|
|
.filter(|j| !taken.contains(j) && parts.get(*j).and_then(|n| baseline(n)).is_some_and(|b| (b - line).abs() <= SAME_LINE))
|
|
.collect();
|
|
taken.extend(&group);
|
|
let pieces: Vec<String> = group.iter().filter_map(|j| parts.get(*j).and_then(text_of)).collect();
|
|
let places: Vec<(f64, f64)> = group
|
|
.iter()
|
|
.filter_map(|j| parts.get(*j))
|
|
.filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some((t.xf.as_coeffs()[4], t.xf.as_coeffs()[5])) } else { None })
|
|
.collect();
|
|
let distinct = places.iter().enumerate().all(|(a, p)| places.iter().skip(a + 1).all(|q| (p.0 - q.0).hypot(p.1 - q.1) > 0.01));
|
|
let joined: String = pieces.iter().map(|p| letters(p)).collect();
|
|
if group.len() < 2 || !distinct || joined.is_empty() {
|
|
continue;
|
|
}
|
|
let Some((whole, _)) = lines.iter().find(|(text, _)| letters(text) == joined) else { continue };
|
|
// Where the line has spaces after a piece's last character that the page doesn't draw.
|
|
let line_chars: Vec<char> = whole.chars().collect();
|
|
let mut at = 0;
|
|
let mut gaps = vec![];
|
|
for (k, piece) in pieces.iter().enumerate() {
|
|
for _ in piece.chars().filter(|c| !c.is_whitespace()) {
|
|
while line_chars.get(at).is_some_and(|c| c.is_whitespace()) {
|
|
at += 1;
|
|
}
|
|
at += 1;
|
|
}
|
|
let space: String = line_chars.iter().skip(at).take_while(|c| c.is_whitespace()).collect();
|
|
let next = pieces.get(k + 1);
|
|
let drawn = piece.ends_with(char::is_whitespace) || next.is_none_or(|n| n.starts_with(char::is_whitespace));
|
|
gaps.push(if drawn { String::new() } else { space });
|
|
}
|
|
if at <= line_chars.len() {
|
|
joins.push((group[0], group[1..].to_vec(), gaps));
|
|
}
|
|
}
|
|
for (first, rest, gaps) in joins.iter().rev() {
|
|
let mut runs: Vec<Vec<vectorcraft_doc::TextRun>> = [first]
|
|
.into_iter()
|
|
.chain(rest)
|
|
.filter_map(|j| parts.get(*j))
|
|
.filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.runs.clone()) } else { None })
|
|
.collect();
|
|
for (r, gap) in runs.iter_mut().zip(gaps) {
|
|
if let Some(last) = r.last_mut() {
|
|
last.text.push_str(gap);
|
|
}
|
|
}
|
|
if let Some(node) = parts.get_mut(*first).map(Arc::make_mut)
|
|
&& let NodeKind::Text(t) = &mut node.kind
|
|
{
|
|
t.runs = runs.into_iter().flatten().collect();
|
|
}
|
|
let mut gone: Vec<usize> = rest.clone();
|
|
gone.sort_unstable_by(|a, b| b.cmp(a));
|
|
for j in gone {
|
|
if j < parts.len() {
|
|
parts.remove(j);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The box round `parts`.
|
|
fn extent(parts: &[Arc<Node>]) -> Option<Rect> {
|
|
parts.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b))
|
|
}
|
|
|
|
/// What goes into each of `infos`' slots: the page's type that the file's text document puts where the
|
|
/// page has it, else type made from the text document, else (for a file whose text document can't say)
|
|
/// the page's type in painting order.
|
|
fn plan(
|
|
infos: &[SlotInfo],
|
|
stories: &[Option<Story>],
|
|
template: Option<(f64, f64)>,
|
|
to_doc: Affine,
|
|
pages: [&[Vec<Arc<Node>>]; 2],
|
|
warn: &mut Vec<String>,
|
|
) -> Vec<Content> {
|
|
let [shown, hidden] = pages;
|
|
let lines: Vec<Option<Vec<(String, Point)>>> = stories
|
|
.iter()
|
|
.map(|s| {
|
|
let (s, t) = (s.as_ref()?, template?);
|
|
Some(s.line_starts(t).into_iter().map(|(text, (x, y))| (text, to_doc * Point::new(x, y))).collect())
|
|
})
|
|
.collect();
|
|
// The page's objects that are where a shown slot's lines are, nearest first.
|
|
let mut pairs: Vec<(f64, usize, usize)> = vec![];
|
|
for (k, info) in infos.iter().enumerate() {
|
|
let Some(l) = lines.get(k).and_then(Option::as_ref).filter(|_| info.shown) else { continue };
|
|
for (j, object) in shown.iter().enumerate() {
|
|
if let Some(d) = object.iter().filter_map(|n| same_type(n, l)).min_by(f64::total_cmp) {
|
|
pairs.push((d, k, j));
|
|
}
|
|
}
|
|
}
|
|
pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
|
let mut owner: Vec<Option<usize>> = vec![None; shown.len()];
|
|
for (_, k, j) in pairs {
|
|
if let Some(o) = owner.get_mut(j).filter(|o| o.is_none()) {
|
|
*o = Some(k);
|
|
}
|
|
}
|
|
// The file's text document is trusted where it agrees with the page about something, or where the
|
|
// page has no type to disagree with.
|
|
let trusted = owner.iter().any(Option::is_some) || shown.is_empty();
|
|
let readable = |k: usize| trusted && lines.get(k).is_some_and(Option::is_some);
|
|
let mut content: Vec<Content> = infos.iter().map(|_| Content::Empty).collect();
|
|
let mut spare: Vec<Vec<Arc<Node>>> = vec![];
|
|
for (j, object) in shown.iter().enumerate() {
|
|
match owner.get(j).copied().flatten().filter(|_| trusted) {
|
|
Some(k) => match content.get_mut(k) {
|
|
Some(Content::Page(p)) => p.extend(object.iter().cloned()),
|
|
Some(c) => *c = Content::Page(object.clone()),
|
|
None => {}
|
|
},
|
|
None => spare.push(object.clone()),
|
|
}
|
|
}
|
|
for (k, c) in content.iter_mut().enumerate() {
|
|
if let (Content::Page(parts), Some(Some(l))) = (c, lines.get(k)) {
|
|
join_pieces(parts, l);
|
|
}
|
|
}
|
|
// Type of the text document where the page has none: hidden type, and shown type off the page.
|
|
for (k, c) in content.iter_mut().enumerate() {
|
|
if matches!(c, Content::Empty)
|
|
&& readable(k)
|
|
&& let (Some(Some(story)), Some(t)) = (stories.get(k), template)
|
|
&& let Some(node) = story.node(NodeId(0), t, to_doc)
|
|
{
|
|
*c = Content::Made(Box::new(node));
|
|
}
|
|
}
|
|
// What is left of the page goes by painting order into the shown slots the file can't say.
|
|
let loose: Vec<usize> = infos
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(k, i)| i.shown && matches!(content.get(*k), Some(Content::Empty)) && !readable(*k))
|
|
.map(|(k, _)| k)
|
|
.collect();
|
|
if loose.is_empty() {
|
|
// Type of the page that no text object says it owns goes with the nearest text object that has
|
|
// type of the page; with none, it is left out.
|
|
let mut left_out = false;
|
|
for object in &spare {
|
|
let (centre, near) = (extent(object).map(|r| r.center()), |c: &Content| match c {
|
|
Content::Page(parts) => extent(parts).map(|r| r.center()),
|
|
_ => None,
|
|
});
|
|
let nearest = content
|
|
.iter_mut()
|
|
.filter_map(|c| near(c).zip(centre).map(|(p, q)| ((p.x - q.x).hypot(p.y - q.y), c)))
|
|
.min_by(|a, b| a.0.total_cmp(&b.0));
|
|
match nearest {
|
|
Some((_, Content::Page(parts))) => parts.extend(object.iter().cloned()),
|
|
_ => left_out = true,
|
|
}
|
|
}
|
|
if !spare.is_empty() {
|
|
warn.push(
|
|
if left_out {
|
|
"some of the page's type has no text object in the layers, so it is left out"
|
|
} else {
|
|
"some of the page's type has no text object of its own in the layers, so it is with another's"
|
|
}
|
|
.into(),
|
|
);
|
|
}
|
|
} else {
|
|
for (slot, parts) in loose.iter().zip(assign(&spare, loose.len())) {
|
|
if let Some(c) = content.get_mut(*slot) {
|
|
// A text object the page shows as part of another's has nothing to hold of its own.
|
|
*c = if parts.is_empty() && !spare.is_empty() {
|
|
Content::Merged
|
|
} else {
|
|
Content::Page(parts.into_iter().flat_map(|p| p.iter().cloned()).collect())
|
|
};
|
|
}
|
|
}
|
|
if spare.len() != loose.len() {
|
|
warn.push("the text is on the layers in the order the page paints it, so a text object may be in another group than it was".into());
|
|
}
|
|
}
|
|
// The same for the slots that don't show.
|
|
let unread: Vec<usize> =
|
|
infos.iter().enumerate().filter(|(k, i)| !i.shown && matches!(content.get(*k), Some(Content::Empty))).map(|(k, _)| k).collect();
|
|
for (slot, parts) in unread.iter().zip(assign(hidden, unread.len())) {
|
|
if let Some(c) = content.get_mut(*slot) {
|
|
*c = Content::Page(parts.into_iter().flat_map(|p| p.iter().cloned()).collect());
|
|
}
|
|
}
|
|
content
|
|
}
|
|
|
|
/// How much of `page` two documents draw differently (0 to 1), each seen on white.
|
|
fn difference(a: &Document, b: &Document, page: Rect) -> f64 {
|
|
let scale = (400.0 / page.width().max(page.height()).max(1.0)).min(4.0);
|
|
let mut r = vectorcraft_render::Renderer::new();
|
|
let (x, y) = (r.render_region(a, page, scale, true), r.render_region(b, page, scale, true));
|
|
if x.width != y.width || x.height != y.height {
|
|
return 1.0;
|
|
}
|
|
let differing = x
|
|
.pixels
|
|
.as_chunks::<4>()
|
|
.0
|
|
.iter()
|
|
.zip(y.pixels.as_chunks::<4>().0)
|
|
.filter(|(p, q)| p.iter().zip(q.iter()).any(|(a, b)| a.abs_diff(*b) > 48))
|
|
.count();
|
|
differing as f64 / (f64::from(x.width) * f64::from(x.height)).max(1.0)
|
|
}
|
|
|
|
/// More of the page than this differing between the layers' art and the page's own, and the
|
|
/// layers aren't used.
|
|
const MAX_DIFFERENCE: f64 = 0.05;
|
|
/// More than this and the import says so.
|
|
const NOTABLE_DIFFERENCE: f64 = 0.002;
|
|
|
|
/// Which box of the editing copy is the page.
|
|
#[derive(Clone, Copy)]
|
|
enum Page {
|
|
/// An EPS's page is its art's bounding box.
|
|
Art,
|
|
/// A `.ai` is read on its (first and only) artboard.
|
|
Artboard,
|
|
}
|
|
|
|
/// What [`build`] made: the layers, and the drawing state they were made in (swatches, warnings).
|
|
struct Built {
|
|
out: Out,
|
|
layers: Vec<Arc<Node>>,
|
|
}
|
|
|
|
/// The layers of the editing copy `data`, with the text (and strokes round it) of `visible`, the
|
|
/// file read as its page, put on them. `base` is the document they go in.
|
|
fn build(data: &[u8], visible: &Document, base: Document, page: Page) -> Result<Built, String> {
|
|
let parsed = parse(data).ok_or("its editing data is damaged")?;
|
|
if parsed.layers.is_empty() {
|
|
return Err("its editing data has no layers".into());
|
|
}
|
|
let [llx, lly, urx, ury] = match page {
|
|
Page::Art => parsed.bbox,
|
|
Page::Artboard => parsed.artboard.ok_or("its editing data doesn't say where its artboard is")?,
|
|
};
|
|
let artboard = visible.artboards.first().map(|a| a.rect).ok_or("it has no page")?;
|
|
if matches!(page, Page::Artboard) && visible.artboards.len() != 1 {
|
|
return Err("it has more than one artboard".into());
|
|
}
|
|
if ((urx - llx) - artboard.width()).abs() > 0.5 || ((ury - lly) - artboard.height()).abs() > 0.5 {
|
|
return Err("its editing data is for a different page".into());
|
|
}
|
|
let (mut on, mut off) = (BTreeSet::new(), BTreeSet::new());
|
|
unread(&parsed.layers, true, &mut on, &mut off);
|
|
if !on.is_empty() {
|
|
return Err(format!("its layers use {}, which aren't read yet", few(on)));
|
|
}
|
|
let (mut shown, mut hidden) = (vec![], vec![]);
|
|
for l in &visible.layers {
|
|
if let Some(c) = l.children() {
|
|
text_objects(c, l.visible, &mut shown, &mut hidden);
|
|
}
|
|
}
|
|
let mut doc = base;
|
|
doc.layers.clear();
|
|
let to_doc = Affine::translate((artboard.x0, artboard.y0)) * Affine::new([1.0, 0.0, 0.0, -1.0, -llx, ury]);
|
|
let mut b = Builder { out: Some(Out::new(doc, to_doc, artboard)) };
|
|
let mut layers: Vec<Arc<Node>> = parsed.layers.iter().filter_map(|l| b.layer(l, 0)).collect();
|
|
let mut out = b.out.ok_or("a layer couldn't be read")?;
|
|
let mut infos = vec![];
|
|
collect_slots(&layers, true, &mut infos);
|
|
if infos.iter().all(|i| !i.shown) && !shown.is_empty() {
|
|
return Err("its page has text, and its layers have no text objects to put it in".into());
|
|
}
|
|
let texts = Texts::read(data);
|
|
let stories: Vec<Option<Story>> = infos.iter().map(|i| texts.as_ref().and_then(|t| t.story(i.story? as usize))).collect();
|
|
let template = parsed.template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0));
|
|
let mut notes = vec![];
|
|
let content = plan(&infos, &stories, template, to_doc, [&shown, &hidden], &mut notes);
|
|
let mut empty = 0;
|
|
fill_slots(&mut out.doc, &mut layers, &mut content.into_iter(), &mut empty);
|
|
for n in notes {
|
|
out.warn(&n);
|
|
}
|
|
if empty > 0 {
|
|
out.warn(&format!("{empty} {TEXT_LEFT_OUT}"));
|
|
}
|
|
if !off.is_empty() {
|
|
out.warn(&format!("{ART_LEFT_OUT} ({}), left out of them", few(off)));
|
|
}
|
|
Ok(Built { out, layers })
|
|
}
|
|
|
|
/// Do the layers' art and the page's own look alike? An error says they don't; `Ok` has the note
|
|
/// to give when they differ a little.
|
|
fn compare(page: &Document, layered: &Document) -> Result<Option<String>, String> {
|
|
let Some(rect) = page.artboards.first().map(|a| a.rect) else { return Ok(None) };
|
|
let diff = difference(page, layered, rect);
|
|
if diff > MAX_DIFFERENCE {
|
|
return Err(format!("the art on them differs from the page's in {:.0}% of it", diff * 100.0));
|
|
}
|
|
Ok((diff > NOTABLE_DIFFERENCE).then(|| {
|
|
format!(
|
|
"the art on the layers differs from the file's own page in about {:.1}% of it (brush strokes, live effects and the like that the layers' data doesn't describe)",
|
|
diff * 100.0
|
|
)
|
|
}))
|
|
}
|
|
|
|
/// The EPS `visible` (read as its page) read through its editing copy instead: its layers (the
|
|
/// hidden ones too), groups, compound paths and clipping groups, with the text and the strokes
|
|
/// around it of the page. When the file has no editing copy this reads, `visible` as it was; when
|
|
/// it has one that can't be used, `visible` with a warning that says why.
|
|
pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
|
|
let Some(data) = decode(ps) else { return visible };
|
|
let Some(page) = visible.document.artboards.first().map(|a| a.rect) else { return visible };
|
|
let base = Document::new(page.width(), page.height());
|
|
let result = build(&data, &visible.document, base, Page::Art).and_then(|built| {
|
|
let mut done = finish_with(built.out, built.layers);
|
|
let note = compare(&visible.document, &done.document)?;
|
|
done.warnings.extend(note);
|
|
Ok(done)
|
|
});
|
|
match result {
|
|
Ok(mut done) => {
|
|
for w in &visible.warnings {
|
|
if !done.warnings.contains(w) {
|
|
done.warnings.push(w.clone());
|
|
}
|
|
}
|
|
done
|
|
}
|
|
Err(why) => {
|
|
let mut v = visible;
|
|
v.warnings.push(format!("{LAYERS_UNREAD} ({why}): it comes in as its page, in one layer"));
|
|
v
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The PDF-compatible `.ai` `visible` (read as its PDF part) read through its editing copy
|
|
/// (`private`, the joined `AIPrivateData` streams) instead, as [`layered`] reads an EPS: with the
|
|
/// layers and objects of the file, and the art that lies outside its artboard, which its PDF part
|
|
/// doesn't have. `warnings` are the notes of reading the PDF part; the notes returned are those,
|
|
/// and the layers'.
|
|
pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>) -> (Document, Vec<String>) {
|
|
let Some(data) = decode_ai(private) else { return (visible, warnings) };
|
|
let result = build(&data, &visible, visible.clone(), Page::Artboard).and_then(|built| {
|
|
let mut notes = built.out.warnings.clone();
|
|
let done = finish_layers(built.out, built.layers);
|
|
notes.extend(compare(&visible, &done)?);
|
|
Ok((done, notes))
|
|
});
|
|
match result {
|
|
Ok((done, notes)) => {
|
|
let mut all = warnings;
|
|
all.extend(notes.into_iter().filter(|n| !all.contains(n)).collect::<Vec<_>>());
|
|
(done, all)
|
|
}
|
|
Err(why) => {
|
|
let mut all = warnings;
|
|
all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part"));
|
|
(visible, all)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn finish_layers(out: Out, layers: Vec<Arc<Node>>) -> Document {
|
|
let mut doc = out.doc;
|
|
doc.layers = layers;
|
|
doc
|
|
}
|