Merge pull request #727 from storytold/feat/637-structure-reader
Part of #637: Illustrator editing data read whole: colours, gradients, transparency, names, images, artboards
This commit is contained in:
commit
008b5e6d6c
File diff suppressed because one or more lines are too long
|
|
@ -21,8 +21,6 @@ pub struct Clip {
|
|||
pub id: u32,
|
||||
/// The region and its rule; `None` for a plain group.
|
||||
pub region: Option<(BezPath, FillRule)>,
|
||||
/// The group is hidden (its art is in it, not shown).
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
/// Deepest nesting of clips, and of groups, an importer records ([`nest`] goes one level deeper
|
||||
|
|
@ -80,7 +78,5 @@ fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize) -> Arc<N
|
|||
children.push(Arc::new(path));
|
||||
}
|
||||
children.extend(nest_at(doc, items, depth + 1));
|
||||
let mut group = Node::new(doc.alloc_id(), NodeKind::Group { children, clip: c.region.is_some() });
|
||||
group.visible = !c.hidden;
|
||||
Arc::new(group)
|
||||
Arc::new(Node::new(doc.alloc_id(), NodeKind::Group { children, clip: c.region.is_some() }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -938,6 +938,9 @@ mod tests_psd;
|
|||
#[cfg(test)]
|
||||
mod tests_epsimport;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_aiimport;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_pdflayers;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
//! Illustrator EPS and `.ai` files open from the editing data they carry: layers, groups, names,
|
||||
//! hidden objects, artboards, colours and images as they were; what the reader doesn't read yet
|
||||
//! opens as before, with a warning saying why.
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_testkit::ai;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn open(s: &mut Session, name: &str, bytes: &[u8], params: Value) -> Result<Value> {
|
||||
let mut p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)});
|
||||
if let (Some(p), Some(extra)) = (p.as_object_mut(), params.as_object()) {
|
||||
p.extend(extra.clone());
|
||||
}
|
||||
s.execute("document.open", &p)
|
||||
}
|
||||
|
||||
/// The `.ai` private data of `data`, compressed as older `.ai` files compress it.
|
||||
fn compressed(data: &[u8]) -> Vec<u8> {
|
||||
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
e.write_all(data).unwrap();
|
||||
[&b"%AI12_CompressedData"[..], &e.finish().unwrap()].concat()
|
||||
}
|
||||
|
||||
fn names(nodes: &[Arc<Node>]) -> Vec<String> {
|
||||
nodes.iter().map(|n| n.name.clone().unwrap_or_default()).collect()
|
||||
}
|
||||
|
||||
/// The sample's structure, as [`ai::sample_data`] builds it.
|
||||
fn check_sample(s: &Session) {
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!(names(&doc.layers), ["Art", "Hidden"]);
|
||||
assert_eq!(doc.artboards.len(), 1);
|
||||
assert_eq!(doc.artboards[0].rect, vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 100.0));
|
||||
let art = doc.layers[0].children().unwrap();
|
||||
assert_eq!(names(art), ["Pair", "Ring", "Window"]);
|
||||
let pair = art[0].children().unwrap();
|
||||
assert_eq!(names(pair), ["Red", ""]);
|
||||
assert!(pair[0].visible && !pair[1].visible, "the hidden member stays hidden");
|
||||
assert!(matches!(art[1].kind, NodeKind::Compound { .. }));
|
||||
assert!(matches!(&art[2].kind, NodeKind::Group { clip: true, children } if matches!(children[0].kind, NodeKind::Path { clipping: true, .. })));
|
||||
let hidden = &doc.layers[1];
|
||||
assert!(!hidden.visible);
|
||||
let kids = hidden.children().unwrap();
|
||||
assert_eq!(names(kids), ["Shaded", "", "Sub", ""]);
|
||||
assert!(matches!(kids[0].appearance.fill().map(|f| &f.paint), Some(vectorcraft_color::Paint::Gradient(_))));
|
||||
assert_eq!((kids[1].opacity, kids[1].blend), (0.5, vectorcraft_color::BlendMode::Screen));
|
||||
assert!(kids[2].locked && matches!(kids[2].kind, NodeKind::Layer { .. }));
|
||||
let NodeKind::Image(im) = &kids[3].kind else { panic!("an image") };
|
||||
assert_eq!((im.width, im.height), (2, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_illustrator_eps_opens_with_its_layers() {
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "art.eps", &ai::eps(&ai::sample_data(), ai::page_ps()), json!({})).unwrap();
|
||||
assert_eq!((&r["format"], &r["warnings"]), (&json!("eps"), &json!([])), "{r}");
|
||||
check_sample(&s);
|
||||
// Saved as EPS and opened again, it comes back the same.
|
||||
let eps = s.execute("document.exportEps", &json!({})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(eps["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let r = open(&mut s, "again.eps", &bytes, json!({})).unwrap();
|
||||
assert_eq!(r["restored"], json!(true), "{r}");
|
||||
check_sample(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_illustrator_ai_file_opens_from_its_editing_data() {
|
||||
let mut s = Session::new();
|
||||
let file = ai::ai(&compressed(&ai::sample_data()), ai::page_pdf());
|
||||
let r = open(&mut s, "art.ai", &file, json!({})).unwrap();
|
||||
assert_eq!(r["format"], json!("ai"), "{r}");
|
||||
assert!(r["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("weren't read")), "{r}");
|
||||
check_sample(&s);
|
||||
// Pages picked: the PDF's.
|
||||
open(&mut s, "art.ai", &file, json!({"pages": "1"})).unwrap();
|
||||
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_reader_doesnt_read_opens_as_before_with_a_warning() {
|
||||
let symbols = ai::editing_data(200.0, 100.0, &ai::layer("Art", "/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n"));
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "symbols.eps", &ai::eps(symbols.as_bytes(), ai::page_ps()), json!({})).unwrap();
|
||||
assert!(r["warnings"].to_string().contains("layers weren't read") && r["warnings"].to_string().contains("symbols"), "{r}");
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Layer 1"]);
|
||||
let r = open(&mut s, "symbols.ai", &ai::ai(&compressed(symbols.as_bytes()), ai::page_pdf()), json!({})).unwrap();
|
||||
assert!(r["warnings"].to_string().contains("symbols") && r["warnings"].to_string().contains("PDF part"), "{r}");
|
||||
}
|
||||
|
|
@ -1707,3 +1707,180 @@ proptest! {
|
|||
survive("mutated .ai editing data", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![]).0))?;
|
||||
}
|
||||
}
|
||||
|
||||
/// The operators, operands and section comments of hostile editing data.
|
||||
fn arb_ai_token() -> impl Strategy<Value = String> {
|
||||
prop_oneof![
|
||||
arb_num(),
|
||||
prop::sample::select(vec![
|
||||
"m",
|
||||
"l",
|
||||
"L",
|
||||
"c",
|
||||
"C",
|
||||
"v",
|
||||
"V",
|
||||
"y",
|
||||
"Y",
|
||||
"h",
|
||||
"H",
|
||||
"N",
|
||||
"n",
|
||||
"F",
|
||||
"f",
|
||||
"S",
|
||||
"s",
|
||||
"B",
|
||||
"b",
|
||||
"W",
|
||||
"(b) *",
|
||||
"u",
|
||||
"U",
|
||||
"*u",
|
||||
"*U",
|
||||
"q",
|
||||
"Q",
|
||||
"LB",
|
||||
"Lb",
|
||||
"Ln",
|
||||
"(name) Ln",
|
||||
"g",
|
||||
"G",
|
||||
"k",
|
||||
"K",
|
||||
"x",
|
||||
"X",
|
||||
"Xa",
|
||||
"XA",
|
||||
"Xx",
|
||||
"XX",
|
||||
"(Ink) 0.5 x",
|
||||
"O",
|
||||
"R",
|
||||
"XR",
|
||||
"w",
|
||||
"J",
|
||||
"j",
|
||||
"M",
|
||||
"d",
|
||||
"[6 3] 0 d",
|
||||
"[]0 d",
|
||||
"Xy",
|
||||
"2 0.5 1 1 1 Xy",
|
||||
"99 -4 Xy",
|
||||
"Xw",
|
||||
"1 Xw",
|
||||
"A",
|
||||
"1 A",
|
||||
"Ae",
|
||||
"XW",
|
||||
"1 (style) XW",
|
||||
"9 () XW",
|
||||
"Bd",
|
||||
"(G) 1 3 Bd",
|
||||
"Bs",
|
||||
"0 0 0 0 1 0 0 2 1 6 50 0 Bs",
|
||||
"BD",
|
||||
"Bb",
|
||||
"1 Bb",
|
||||
"BB",
|
||||
"2 BB",
|
||||
"1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg",
|
||||
"Bg",
|
||||
"Bm",
|
||||
"1e308 0 0 1e-308 0 0 Bm",
|
||||
"Bh",
|
||||
"XN",
|
||||
"/DeviceCMYK XN",
|
||||
"/DeviceGray XN",
|
||||
"[ 1 0 0 1 0 0 ] 0 0 2 2 2 2 8 3 1 0 1 0",
|
||||
"[ 1 0 0 1 0 0 ] 0 0 99999 99999 99999 99999 1 1 0 0 0 0",
|
||||
"[",
|
||||
"]",
|
||||
"/Name",
|
||||
"(text)",
|
||||
"<ff00>",
|
||||
":",
|
||||
";",
|
||||
",",
|
||||
"/ArtDictionary :",
|
||||
"/XMLUID : (A_x41_) ; (AI10_ArtUID) ,",
|
||||
"(n) /String (AIArtName) ,",
|
||||
"/Document :",
|
||||
"/Array :",
|
||||
"/Dictionary :",
|
||||
"0 0 /RealPoint (PositionPoint1) ,",
|
||||
"/AI11Text :",
|
||||
"0 /StoryIndex ,",
|
||||
"/SymbolInstance :",
|
||||
"/Binary : /ASCII85Decode ,",
|
||||
"~>",
|
||||
"p",
|
||||
"To",
|
||||
"frobnicate",
|
||||
"\n%AI5_BeginLayer\n",
|
||||
"\n%AI5_EndLayer--\n",
|
||||
"\n%_",
|
||||
"\n%AI5_BeginRaster\n",
|
||||
"\n%AI5_EndRaster\n",
|
||||
"\n%%BeginData: 12\rXI\n",
|
||||
"\n%%EndData\n",
|
||||
"\n%AI5_BeginGradient: (G)\n",
|
||||
"\n%AI14_BeginSymbol\n",
|
||||
"\n%AI10_EndSymbol\n",
|
||||
"\n%AI17_Begin_Content_if_version_gt:24 4\n",
|
||||
"\n%AI17_Alternate_Content\n",
|
||||
"\n%AI17_End_Versioned_Content\n",
|
||||
"\n%AI3_Cropmarks: 0 0 1e308 -1e308\n",
|
||||
"\n%AI5_ArtSize: 0 0\n",
|
||||
"\n%AI9_ColorModel: 2\n",
|
||||
"\n%AI5_BeginPlace\n",
|
||||
"\n%AI26_BeginPlacedObjectPreview\n",
|
||||
"\n%%PageTrailer\n",
|
||||
])
|
||||
.prop_map(str::to_string),
|
||||
]
|
||||
}
|
||||
|
||||
/// The testkit's sample editing data, compressed as older `.ai` files compress it.
|
||||
fn ai_sample_compressed() -> Vec<u8> {
|
||||
use std::io::Write as _;
|
||||
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
e.write_all(&vectorcraft_testkit::ai::sample_data()).unwrap();
|
||||
[&b"%AI12_CompressedData"[..], &e.finish().unwrap()].concat()
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
/// Hostile editing data in Illustrator EPS and `.ai` files: operators in any order with any
|
||||
/// operands, sections and dictionaries left open, images of any size.
|
||||
#[test]
|
||||
fn ai_hostile_editing_data_never_panics(tokens in prop::collection::vec(arb_ai_token(), 0..80)) {
|
||||
use vectorcraft_testkit::ai;
|
||||
let data = ai::editing_data(200.0, 100.0, &format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n{}\nLB\n", tokens.join(" ")));
|
||||
survive("hostile editing data", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![]).0))?;
|
||||
let eps = ai::eps(data.as_bytes(), ai::page_ps());
|
||||
survive("hostile editing data in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?;
|
||||
}
|
||||
|
||||
/// The testkit's sample damaged, as it is and compressed, in EPS and `.ai` files.
|
||||
#[test]
|
||||
fn ai_mutated_sample_never_panics(cut in 0usize..4_000, edits in prop::collection::vec((0usize..4_000, any::<u8>()), 0..12)) {
|
||||
use vectorcraft_testkit::ai;
|
||||
static PLAIN: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
|
||||
static PACKED: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
|
||||
for (what, sample) in [("editing data", PLAIN.get_or_init(ai::sample_data)), ("compressed editing data", PACKED.get_or_init(ai_sample_compressed))] {
|
||||
let mut bytes = sample.clone();
|
||||
for &(at, b) in &edits {
|
||||
let n = bytes.len();
|
||||
bytes[at % n] = b;
|
||||
}
|
||||
bytes.truncate(cut.max(4));
|
||||
let file = ai::ai(&bytes, ai::page_pdf());
|
||||
survive(what, || vectorcraft_engine::cmd::fileio::load("x.ai", &file).ok().map(|l| l.doc))?;
|
||||
let file = ai::eps(&bytes, ai::page_ps());
|
||||
survive(what, || vectorcraft_engine::cmd::fileio::load("x.eps", &file).ok().map(|l| l.doc))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,15 +21,15 @@ fn ascii85(data: &[u8]) -> String {
|
|||
out
|
||||
}
|
||||
|
||||
/// A square that is gradient-filled in the layers (which can't be read) and flat on the page: the
|
||||
/// file comes in as its page, and its hidden layer is left out.
|
||||
/// A square drawn with an operator the layers' reader doesn't know and flat on the page: the file
|
||||
/// comes in as its page, and its hidden layer is left out.
|
||||
fn lossy_eps() -> Vec<u8> {
|
||||
let layer = |name: &str, visible: u8, body: &str| {
|
||||
format!("%AI5_BeginLayer\n{visible} 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{body}\nLB\n%AI5_EndLayer--\n")
|
||||
};
|
||||
let editing = 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{}{}%%Trailer\n",
|
||||
layer("Art", 1, "1 0 0 0 1 0 Bg\n0 0 1 0 k\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf"),
|
||||
layer("Art", 1, "1 0 0 0 1 0 Zq\n0 0 1 0 k\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf"),
|
||||
layer("Hidden", 0, "0 0 1 0 k\n50 50 m\n54 50 L\n54 54 L\n50 54 L\nf"),
|
||||
);
|
||||
let packed = ruzstd::encoding::compress_to_vec(editing.as_bytes(), ruzstd::encoding::CompressionLevel::Fastest);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,333 @@
|
|||
//! The scanner of the editing data: PostScript-like tokens, read line by line.
|
||||
//!
|
||||
//! - A line starting with `%_` holds tokens like any other line (they are marked [`Token::hidden`]:
|
||||
//! readers of the printed format skip them as comments).
|
||||
//! - Any other line starting with `%` is a [`Tok::Comment`] (the section markers).
|
||||
//! - `%` later in a line starts a comment that is skipped.
|
||||
//! - The binary image data after `%%BeginData:` is one [`Tok::Data`]; the binary previews of placed
|
||||
//! files are skipped.
|
||||
|
||||
use super::super::lex::{find, hex_decode};
|
||||
|
||||
/// A token.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) enum Tok<'a> {
|
||||
Num(f64),
|
||||
/// A literal `(…)` string, escapes undone.
|
||||
Str(Vec<u8>),
|
||||
/// A hexadecimal `<…>` string.
|
||||
Hex(Vec<u8>),
|
||||
/// A literal `/name`.
|
||||
Name(&'a [u8]),
|
||||
/// An operator (any other word, `[` and `]` included).
|
||||
Word(&'a [u8]),
|
||||
/// A comment line, without its first `%`.
|
||||
Comment(&'a [u8]),
|
||||
/// An image's samples: what follows `XI` in a `%%BeginData:` section.
|
||||
Data(&'a [u8]),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct Token<'a> {
|
||||
pub tok: Tok<'a>,
|
||||
/// Read from a `%_` line.
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
/// Binary sections skipped whole: (begin, end) markers.
|
||||
const SKIPPED: [(&[u8], &[u8]); 2] = [(b"%AI26_BeginPlacedObjectPreview", b"%AI26_EndPlacedObjectPreview"), (b"%%BeginDocument", b"%%EndDocument")];
|
||||
|
||||
fn is_space(b: u8) -> bool {
|
||||
matches!(b, b' ' | b'\t' | b'\r' | b'\n' | b'\x0c' | b'\0')
|
||||
}
|
||||
|
||||
fn is_delim(b: u8) -> bool {
|
||||
matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%')
|
||||
}
|
||||
|
||||
fn is_eol(b: u8) -> bool {
|
||||
matches!(b, b'\r' | b'\n')
|
||||
}
|
||||
|
||||
pub(super) struct Lexer<'a> {
|
||||
src: &'a [u8],
|
||||
pos: usize,
|
||||
/// The current line started with `%_`.
|
||||
hidden: bool,
|
||||
}
|
||||
|
||||
impl<'a> Lexer<'a> {
|
||||
pub fn new(src: &'a [u8]) -> Self {
|
||||
Self { src, pos: 0, hidden: false }
|
||||
}
|
||||
|
||||
fn peek(&self) -> Option<u8> {
|
||||
self.src.get(self.pos).copied()
|
||||
}
|
||||
|
||||
fn rest(&self) -> &'a [u8] {
|
||||
self.src.get(self.pos..).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn at_line_start(&self) -> bool {
|
||||
self.pos == 0 || self.src.get(self.pos - 1).is_some_and(|b| is_eol(*b))
|
||||
}
|
||||
|
||||
/// The rest of the current line (without its end), the position moved past it.
|
||||
fn line(&mut self) -> &'a [u8] {
|
||||
let rest = self.rest();
|
||||
let n = rest.iter().position(|b| is_eol(*b)).unwrap_or(rest.len());
|
||||
self.pos += n;
|
||||
rest.get(..n).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Skip past the next `needle` (the end of data that isn't tokens) → what was skipped.
|
||||
pub fn skip_past(&mut self, needle: &[u8]) -> &'a [u8] {
|
||||
let rest = self.rest();
|
||||
let n = find(rest, needle).map_or(rest.len(), |i| i + needle.len());
|
||||
self.pos += n;
|
||||
rest.get(..n).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The next token; `None` at the end.
|
||||
pub fn next_token(&mut self) -> Option<Token<'a>> {
|
||||
loop {
|
||||
let b = self.peek()?;
|
||||
if is_eol(b) {
|
||||
self.pos += 1;
|
||||
self.hidden = false;
|
||||
continue;
|
||||
}
|
||||
if is_space(b) {
|
||||
self.pos += 1;
|
||||
continue;
|
||||
}
|
||||
if b == b'%' {
|
||||
if self.at_line_start() {
|
||||
if self.rest().starts_with(b"%_") {
|
||||
self.pos += 2;
|
||||
self.hidden = true;
|
||||
continue;
|
||||
}
|
||||
let line = self.line();
|
||||
if let Some(t) = self.special(line) {
|
||||
return Some(t);
|
||||
}
|
||||
return Some(Token { tok: Tok::Comment(line.get(1..).unwrap_or_default()), hidden: false });
|
||||
}
|
||||
// A comment later in a line.
|
||||
self.line();
|
||||
continue;
|
||||
}
|
||||
let hidden = self.hidden;
|
||||
self.pos += 1;
|
||||
let tok = match b {
|
||||
b'(' => Tok::Str(self.string()),
|
||||
b'<' => {
|
||||
let rest = self.rest();
|
||||
let end = rest.iter().position(|b| *b == b'>').unwrap_or(rest.len());
|
||||
self.pos += (end + 1).min(rest.len());
|
||||
// Malformed digits read as an empty string.
|
||||
Tok::Hex(rest.get(..end).and_then(hex_decode).unwrap_or_default())
|
||||
}
|
||||
b'[' | b']' | b'{' | b'}' | b')' | b'>' => Tok::Word(self.src.get(self.pos - 1..self.pos).unwrap_or_default()),
|
||||
b'/' => Tok::Name(self.word()),
|
||||
_ => {
|
||||
self.pos -= 1;
|
||||
let w = self.word();
|
||||
match number(w) {
|
||||
Some(v) => Tok::Num(v),
|
||||
None => Tok::Word(w),
|
||||
}
|
||||
}
|
||||
};
|
||||
return Some(Token { tok, hidden });
|
||||
}
|
||||
}
|
||||
|
||||
/// The token a comment line stands for when it starts a section read differently: image data
|
||||
/// (`%%BeginData:` followed by `XI`) and skipped binary sections.
|
||||
fn special(&mut self, line: &'a [u8]) -> Option<Token<'a>> {
|
||||
if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| line.starts_with(b)) {
|
||||
let after = find(self.rest(), end).map_or(self.src.len(), |i| self.pos + i);
|
||||
self.pos = after;
|
||||
self.line();
|
||||
return Some(Token { tok: Tok::Comment(line.get(1..).unwrap_or_default()), hidden: false });
|
||||
}
|
||||
let n = line.strip_prefix(b"%%BeginData:")?;
|
||||
let n: usize = std::str::from_utf8(n).ok()?.split_whitespace().next()?.parse().ok()?;
|
||||
// The count starts after the comment's line end character (a following `\n` counts).
|
||||
let from = self.pos + 1;
|
||||
let region = self.src.get(from..)?;
|
||||
let skip = region.iter().take_while(|b| is_space(**b)).count();
|
||||
if !region.get(skip..)?.starts_with(b"XI") {
|
||||
return None;
|
||||
}
|
||||
let mut data = from + skip + 2;
|
||||
match (self.src.get(data), self.src.get(data + 1)) {
|
||||
(Some(b'\r'), Some(b'\n')) => data += 2,
|
||||
(Some(b'\r' | b'\n'), _) => data += 1,
|
||||
_ => {}
|
||||
}
|
||||
// The count leaves out an alpha channel: the data ends where `%%EndData` starts.
|
||||
let declared = from.saturating_add(n).min(self.src.len()).max(data);
|
||||
let window =
|
||||
self.src.get(declared.saturating_sub(4).max(data)..declared.saturating_add(n.saturating_mul(2)).saturating_add(64).min(self.src.len()));
|
||||
let end = match window.and_then(|w| find(w, b"%%EndData")) {
|
||||
Some(i) => declared.saturating_sub(4).max(data) + i,
|
||||
None => declared,
|
||||
};
|
||||
self.pos = end;
|
||||
Some(Token { tok: Tok::Data(self.src.get(data..end).unwrap_or_default()), hidden: false })
|
||||
}
|
||||
|
||||
/// A regular token's characters.
|
||||
fn word(&mut self) -> &'a [u8] {
|
||||
let start = self.pos;
|
||||
while let Some(b) = self.peek() {
|
||||
if is_space(b) || is_delim(b) {
|
||||
break;
|
||||
}
|
||||
self.pos += 1;
|
||||
}
|
||||
self.src.get(start..self.pos).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A literal string after its `(` (an unterminated one ends with the data).
|
||||
fn string(&mut self) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
let mut depth = 0usize;
|
||||
while let Some(b) = self.peek() {
|
||||
self.pos += 1;
|
||||
match b {
|
||||
b'(' => {
|
||||
depth += 1;
|
||||
out.push(b);
|
||||
}
|
||||
b')' if depth == 0 => break,
|
||||
b')' => {
|
||||
depth -= 1;
|
||||
out.push(b);
|
||||
}
|
||||
b'\\' => {
|
||||
let Some(e) = self.peek() else { break };
|
||||
self.pos += 1;
|
||||
match e {
|
||||
b'n' => out.push(b'\n'),
|
||||
b'r' => out.push(b'\r'),
|
||||
b't' => out.push(b'\t'),
|
||||
b'b' => out.push(8),
|
||||
b'f' => out.push(12),
|
||||
b'\r' => {
|
||||
if self.peek() == Some(b'\n') {
|
||||
self.pos += 1;
|
||||
}
|
||||
}
|
||||
b'\n' => {}
|
||||
b'0'..=b'7' => {
|
||||
let mut v = u32::from(e - b'0');
|
||||
for _ in 0..2 {
|
||||
match self.peek() {
|
||||
Some(d @ b'0'..=b'7') => {
|
||||
v = v * 8 + u32::from(d - b'0');
|
||||
self.pos += 1;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
out.push((v & 0xff) as u8);
|
||||
}
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
_ => out.push(b),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A finite number token (`12`, `-3.5`, `.5`, `1e-3`), or `None`.
|
||||
fn number(w: &[u8]) -> Option<f64> {
|
||||
let first = *w.first()?;
|
||||
if !(first.is_ascii_digit() || matches!(first, b'+' | b'-' | b'.')) {
|
||||
return None;
|
||||
}
|
||||
if w.iter().any(|b| b.is_ascii_alphabetic() && !matches!(b, b'e' | b'E')) {
|
||||
return None;
|
||||
}
|
||||
let v: f64 = std::str::from_utf8(w).ok()?.parse().ok()?;
|
||||
v.is_finite().then_some(v)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn all(src: &[u8]) -> Vec<Token<'_>> {
|
||||
let mut l = Lexer::new(src);
|
||||
std::iter::from_fn(|| l.next_token()).collect()
|
||||
}
|
||||
|
||||
fn t(tok: Tok<'_>, hidden: bool) -> Token<'_> {
|
||||
Token { tok, hidden }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_lines_and_comments() {
|
||||
let toks = all(b"%AI5_BeginLayer\r\n1 (a\\)b) Ln\r%_/X : ;\n0 %_BS\n<4142>\n");
|
||||
assert_eq!(
|
||||
toks,
|
||||
vec![
|
||||
t(Tok::Comment(b"AI5_BeginLayer"), false),
|
||||
t(Tok::Num(1.0), false),
|
||||
t(Tok::Str(b"a)b".to_vec()), false),
|
||||
t(Tok::Word(b"Ln"), false),
|
||||
t(Tok::Name(b"X"), true),
|
||||
t(Tok::Word(b":"), true),
|
||||
t(Tok::Word(b";"), true),
|
||||
t(Tok::Num(0.0), false),
|
||||
t(Tok::Hex(b"AB".to_vec()), false),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_data_is_one_token() {
|
||||
// The count starts after the comment's `\r`: `\nXI\n` and 3 samples; an alpha channel
|
||||
// after them isn't counted.
|
||||
let src = b"[1 0 0 1 0 0] XI\r\n%%BeginData: 7\r\nXI\n(%)A%%EndData\r\nXH\r\n";
|
||||
let toks = all(src);
|
||||
assert!(toks.contains(&t(Tok::Data(b"(%)A"), false)), "{toks:?}");
|
||||
assert_eq!(toks.last(), Some(&t(Tok::Word(b"XH"), false)));
|
||||
// A section of hex lines is comments.
|
||||
let toks = all(b"%%BeginData: 9 Hex Bytes\r\n%00FF\r\n%%EndData\r\n1\r\n");
|
||||
assert_eq!(toks.last(), Some(&t(Tok::Num(1.0), false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_previews_are_skipped() {
|
||||
let toks = all(b"%AI26_BeginPlacedObjectPreview\r\n2 1 (\x00\xff\r\n%AI26_EndPlacedObjectPreview\r\nN\r\n");
|
||||
assert_eq!(toks.last(), Some(&t(Tok::Word(b"N"), false)));
|
||||
assert_eq!(toks.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_past_data() {
|
||||
let mut l = Lexer::new(b"/Binary : /ASCII85Decode ,\r\n%_(abc[%\r\n%x~>\r\n%_; 1");
|
||||
for _ in 0..4 {
|
||||
l.next_token();
|
||||
}
|
||||
assert_eq!(l.skip_past(b"~>"), b"\r\n%_(abc[%\r\n%x~>");
|
||||
assert_eq!(l.next_token().map(|t| t.tok), Some(Tok::Word(b";")));
|
||||
assert_eq!(l.next_token().map(|t| t.tok), Some(Tok::Num(1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_ends() {
|
||||
for src in [&b"(unterminated \\"[..], b"<12", b"%%BeginData: 99999999999\r\nXI", b"%%BeginData: 5\rXI\r", b"/", b"1e999 nan"] {
|
||||
let toks = all(src);
|
||||
assert!(toks.len() < 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
//! Illustrator's editing data: the document as the app keeps it, which an Illustrator EPS carries
|
||||
//! after its printed page and a `.ai` file (PDF compatible or not) in its first page's
|
||||
//! `/PieceInfo /Illustrator /Private` streams.
|
||||
//!
|
||||
//! - In an EPS it starts at the `%AI9_PrivateDataBegin` comment: header comments, then
|
||||
//! `%AI24_DataStream` (Zstandard) or `%AI9_DataStream` (zlib) and the compressed data as ASCII85
|
||||
//! on comment lines, up to `%AI9_PrivateDataEnd`. Data that isn't compressed follows the header
|
||||
//! as it is.
|
||||
//! - In a `.ai` file the streams joined start with `%AI24_ZStandard_Data` (Zstandard) or
|
||||
//! `%AI12_CompressedData` (zlib), else are the data itself ([`decode_private`]).
|
||||
//!
|
||||
//! The data is a PostScript-like program of the legacy Illustrator format's operators (layers,
|
||||
//! groups, paths, colours, gradients, images) with newer ones (transparency, hidden objects) and
|
||||
//! dictionaries (names, artboards, type). `lex` scans it, `read` builds the document.
|
||||
//!
|
||||
//! Reading is bounded: the data decompresses to at most [`MAX_DATA`], and the reader caps nesting,
|
||||
//! objects and points. Anything it doesn't read is an error: the caller opens the file the way it
|
||||
//! did before (the printed page, or the PDF), with a warning saying why.
|
||||
|
||||
mod lex;
|
||||
mod obj;
|
||||
mod paint;
|
||||
mod read;
|
||||
|
||||
use std::io::Read as _;
|
||||
|
||||
use super::lex::find;
|
||||
use crate::ps;
|
||||
|
||||
pub(crate) use read::slot_of;
|
||||
pub use read::{Structure, read};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// Largest editing data read, decompressed (bytes).
|
||||
pub const MAX_DATA: u64 = 256 << 20;
|
||||
/// Largest Zstandard window accepted (a frame asking for more is damaged data).
|
||||
const MAX_ZSTD_WINDOW: u64 = 64 << 20;
|
||||
/// Most Zstandard frames read (the app writes one).
|
||||
const MAX_FRAMES: usize = 4096;
|
||||
|
||||
const EPS_BEGIN: &[u8] = b"%AI9_PrivateDataBegin";
|
||||
const EPS_END: &[u8] = b"%AI9_PrivateDataEnd";
|
||||
|
||||
/// The editing data an Illustrator EPS carries (`ps`: its PostScript), decoded: `None` without
|
||||
/// one, an error when it can't be decoded.
|
||||
pub fn eps_data(ps: &[u8]) -> Option<Result<Vec<u8>, String>> {
|
||||
let start = find(ps, EPS_BEGIN)? + EPS_BEGIN.len();
|
||||
let rest = ps.get(start..)?;
|
||||
let section = rest.get(..find(rest, EPS_END).unwrap_or(rest.len())).unwrap_or_default();
|
||||
Some(eps_section(section))
|
||||
}
|
||||
|
||||
/// The editing data between the EPS markers: header comments, then the data (encoded or not).
|
||||
fn eps_section(section: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let mut header = Vec::new();
|
||||
let mut lines = section.split(|b| matches!(b, b'\n' | b'\r')).filter(|l| !l.is_empty());
|
||||
let mut codec = None;
|
||||
for line in lines.by_ref() {
|
||||
if line.starts_with(b"%AI24_DataStream") {
|
||||
codec = Some(Codec::Zstd);
|
||||
break;
|
||||
}
|
||||
if line.starts_with(b"%AI9_DataStream") {
|
||||
codec = Some(Codec::Zlib);
|
||||
break;
|
||||
}
|
||||
header.extend_from_slice(line);
|
||||
header.extend_from_slice(b"\n");
|
||||
if header.len() as u64 > MAX_DATA {
|
||||
return Err(too_large());
|
||||
}
|
||||
}
|
||||
let Some(codec) = codec else {
|
||||
// Not compressed: the section is the data, as it is (images' samples are binary).
|
||||
if section.len() as u64 > MAX_DATA {
|
||||
return Err(too_large());
|
||||
}
|
||||
return Ok(section.to_vec());
|
||||
};
|
||||
let mut text = String::new();
|
||||
for line in lines {
|
||||
let body = line.strip_prefix(b"%").unwrap_or(line);
|
||||
text.push_str(std::str::from_utf8(body).map_err(|_| damaged("its ASCII85 encoding"))?);
|
||||
if text.len() as u64 > MAX_DATA {
|
||||
return Err(too_large());
|
||||
}
|
||||
}
|
||||
if !text.contains("~>") {
|
||||
text.push_str("~>");
|
||||
}
|
||||
let packed = ps::ascii85_decode(&text).ok_or_else(|| damaged("its ASCII85 encoding"))?;
|
||||
let mut data = header;
|
||||
data.extend(codec.decode(&packed, MAX_DATA.saturating_sub(data.len() as u64))?);
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// The editing data of a `.ai` file: its `AIPrivateData` streams joined (`raw`), decompressed. The
|
||||
/// compressed data may follow header comments that aren't.
|
||||
pub fn decode_private(raw: &[u8]) -> Result<Vec<u8>, String> {
|
||||
if raw.len() as u64 > MAX_DATA {
|
||||
return Err(too_large());
|
||||
}
|
||||
for (marker, codec) in [(&b"%AI24_ZStandard_Data"[..], Codec::Zstd), (b"%AI12_CompressedData", Codec::Zlib)] {
|
||||
if let Some(at) = find(raw, marker) {
|
||||
let (header, body) = raw.split_at(at);
|
||||
let mut data = header.to_vec();
|
||||
data.extend(codec.decode(body.get(marker.len()..).unwrap_or_default(), MAX_DATA.saturating_sub(header.len() as u64))?);
|
||||
return Ok(data);
|
||||
}
|
||||
}
|
||||
Ok(raw.to_vec())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Codec {
|
||||
Zstd,
|
||||
Zlib,
|
||||
}
|
||||
|
||||
impl Codec {
|
||||
/// `data` decompressed, at most `max` bytes.
|
||||
fn decode(self, data: &[u8], max: u64) -> Result<Vec<u8>, String> {
|
||||
let mut out = Vec::new();
|
||||
match self {
|
||||
Codec::Zlib => {
|
||||
flate2::read::ZlibDecoder::new(data).take(max + 1).read_to_end(&mut out).map_err(|_| damaged("its zlib compression"))?;
|
||||
}
|
||||
Codec::Zstd => {
|
||||
// The data may be several frames one after the other, padded with zeros.
|
||||
let end = data.iter().rposition(|b| *b != 0).map_or(0, |i| i + 1);
|
||||
let mut src = data.get(..end).unwrap_or_default();
|
||||
let mut frames = 0usize;
|
||||
while !src.is_empty() {
|
||||
frames += 1;
|
||||
if frames > MAX_FRAMES {
|
||||
return Err(damaged("its Zstandard compression"));
|
||||
}
|
||||
let left = (max + 1).saturating_sub(out.len() as u64);
|
||||
if left == 0 {
|
||||
break;
|
||||
}
|
||||
let d = ruzstd::decoding::StreamingDecoder::new_with_max_window_size(&mut src, MAX_ZSTD_WINDOW)
|
||||
.map_err(|_| damaged("its Zstandard compression"))?;
|
||||
d.take(left).read_to_end(&mut out).map_err(|_| damaged("its Zstandard compression"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.len() as u64 > max {
|
||||
return Err(too_large());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn damaged(what: &str) -> String {
|
||||
format!("{what} is damaged")
|
||||
}
|
||||
|
||||
fn too_large() -> String {
|
||||
format!("it decompresses to more than {} MB", MAX_DATA >> 20)
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
//! Operand values, and the dictionaries the editing data writes postfix: `/Type :` opens one,
|
||||
//! `values key ,` adds an entry (a type name such as `/Real` may stand before the key; in an
|
||||
//! `/Array` there is no key), `;` closes it (after a last `values /key` entry, or a bare value: its
|
||||
//! content), leaving it as a value.
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) enum V {
|
||||
Num(f64),
|
||||
Str(Vec<u8>),
|
||||
Name(String),
|
||||
Arr(Vec<V>),
|
||||
Obj(Rc<Obj>),
|
||||
/// `[`, until its `]`.
|
||||
Mark,
|
||||
}
|
||||
|
||||
impl V {
|
||||
pub fn num(&self) -> Option<f64> {
|
||||
match self {
|
||||
V::Num(v) => Some(*v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn obj(&self) -> Option<&Obj> {
|
||||
match self {
|
||||
V::Obj(o) => Some(o),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// A string's or name's text.
|
||||
pub fn text(&self) -> Option<String> {
|
||||
match self {
|
||||
V::Str(s) => Some(text(s)),
|
||||
V::Name(n) => Some(n.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text from the data's bytes: UTF-8, else Windows-1252 (Latin-1 for its gaps).
|
||||
pub(super) fn text(bytes: &[u8]) -> String {
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(_) => bytes.iter().map(|b| cp1252(*b)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cp1252(b: u8) -> char {
|
||||
const HIGH: [char; 32] = [
|
||||
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8d}', 'Ž', '\u{8f}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—',
|
||||
'˜', '™', 'š', '›', 'œ', '\u{9d}', 'ž', 'Ÿ',
|
||||
];
|
||||
match b {
|
||||
0x80..=0x9f => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or('?'),
|
||||
_ => char::from(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// A dictionary.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(super) struct Obj {
|
||||
pub ty: String,
|
||||
/// Entries in order: the key (`None` in an array) and the values before it.
|
||||
pub entries: Vec<(Option<String>, Vec<V>)>,
|
||||
/// Values closed without a key.
|
||||
pub content: Vec<V>,
|
||||
}
|
||||
|
||||
impl Obj {
|
||||
/// The values of entry `key` (the first one).
|
||||
pub fn get(&self, key: &str) -> Option<&[V]> {
|
||||
self.entries.iter().find(|(k, _)| k.as_deref() == Some(key)).map(|(_, v)| v.as_slice())
|
||||
}
|
||||
/// The numbers of entry `key`.
|
||||
pub fn nums(&self, key: &str) -> Vec<f64> {
|
||||
self.get(key).unwrap_or_default().iter().filter_map(V::num).collect()
|
||||
}
|
||||
/// The first text value of entry `key`.
|
||||
pub fn text(&self, key: &str) -> Option<String> {
|
||||
self.get(key)?.iter().find(|v| matches!(v, V::Str(_))).and_then(V::text)
|
||||
}
|
||||
/// The dictionary value of entry `key`.
|
||||
pub fn obj(&self, key: &str) -> Option<&Obj> {
|
||||
self.get(key)?.iter().find_map(V::obj)
|
||||
}
|
||||
/// The dictionaries an array holds, in order.
|
||||
pub fn items(&self) -> impl Iterator<Item = &Obj> {
|
||||
self.entries.iter().flat_map(|(_, v)| v.iter()).chain(self.content.iter()).filter_map(V::obj)
|
||||
}
|
||||
/// The first text of the content.
|
||||
pub fn content_text(&self) -> Option<String> {
|
||||
self.content.iter().find_map(V::text)
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the values on `stack` above `base` to `o` as one entry (`,`): the last is the key, except
|
||||
/// in an array.
|
||||
pub(super) fn add_entry(o: &mut Obj, stack: &mut Vec<V>, base: usize) {
|
||||
let mut vals: Vec<V> = stack.drain(base.min(stack.len())..).collect();
|
||||
if o.ty == "Array" {
|
||||
o.entries.push((None, vals));
|
||||
return;
|
||||
}
|
||||
let key = match vals.last() {
|
||||
Some(V::Str(_) | V::Name(_)) => vals.pop().and_then(|k| k.text()),
|
||||
_ => None,
|
||||
};
|
||||
o.entries.push((key, vals));
|
||||
}
|
||||
|
||||
/// Close `o` (`;`): a last entry ending with a name is an entry, other values are its content.
|
||||
pub(super) fn close(o: &mut Obj, stack: &mut Vec<V>, base: usize) {
|
||||
if stack.len() <= base {
|
||||
return;
|
||||
}
|
||||
if o.ty != "Array" && matches!(stack.last(), Some(V::Name(_))) {
|
||||
add_entry(o, stack, base);
|
||||
} else {
|
||||
let vals: Vec<V> = stack.drain(base.min(stack.len())..).collect();
|
||||
o.content.extend(vals);
|
||||
}
|
||||
}
|
||||
|
||||
/// An object name from its XML id (`AI10_ArtUID`): `_` stands for a space and `_xHH_` for a
|
||||
/// character; ids made unique end with `_` and 20 or more digits.
|
||||
pub(super) fn xml_name(id: &str) -> String {
|
||||
let id = strip_unique(id);
|
||||
let mut out = String::with_capacity(id.len());
|
||||
let mut rest = id;
|
||||
while let Some(c) = rest.chars().next() {
|
||||
if c == '_' {
|
||||
if let Some((ch, n)) = escaped(rest) {
|
||||
out.push(ch);
|
||||
rest = rest.get(n..).unwrap_or_default();
|
||||
continue;
|
||||
}
|
||||
out.push(' ');
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
rest = rest.get(c.len_utf8()..).unwrap_or_default();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `_xHHHH_` at the start of `s`: the character and the escape's length.
|
||||
fn escaped(s: &str) -> Option<(char, usize)> {
|
||||
let body = s.strip_prefix("_x")?;
|
||||
let end = body.find('_')?;
|
||||
let hex = body.get(..end)?;
|
||||
if hex.is_empty() || hex.len() > 6 {
|
||||
return None;
|
||||
}
|
||||
let c = char::from_u32(u32::from_str_radix(hex, 16).ok()?)?;
|
||||
Some((c, end + 3))
|
||||
}
|
||||
|
||||
/// `id` without the `_000…_` suffix that makes it unique.
|
||||
fn strip_unique(id: &str) -> &str {
|
||||
let Some(body) = id.strip_suffix('_') else { return id };
|
||||
let Some(at) = body.rfind('_') else { return id };
|
||||
let digits = body.get(at + 1..).unwrap_or_default();
|
||||
if digits.len() >= 20 && digits.bytes().all(|b| b.is_ascii_digit()) { body.get(..at).unwrap_or(id) } else { id }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names_from_xml_ids() {
|
||||
assert_eq!(xml_name("Layer_1"), "Layer 1");
|
||||
assert_eq!(xml_name("My_Rect__x23_2"), "My Rect #2");
|
||||
assert_eq!(xml_name("_x31_abc__x28_paren_x29_"), "1abc (paren)");
|
||||
assert_eq!(xml_name("Ünï_日本"), "Ünï 日本");
|
||||
assert_eq!(xml_name("S_00000092429200007574234970000005974767392385616558_"), "S");
|
||||
assert_eq!(xml_name("a_x_"), "a x ");
|
||||
assert_eq!(xml_name("_xD800_"), " xD800 ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_and_content() {
|
||||
let mut o = Obj { ty: "XMLUID".into(), ..Obj::default() };
|
||||
let mut st = vec![V::Num(9.0), V::Str(b"Main".to_vec())];
|
||||
close(&mut o, &mut st, 1);
|
||||
assert_eq!(o.content_text().as_deref(), Some("Main"));
|
||||
assert_eq!(st, vec![V::Num(9.0)]);
|
||||
let mut d = Obj { ty: "Dictionary".into(), ..Obj::default() };
|
||||
let mut st = vec![V::Num(1.0), V::Num(2.0), V::Name("RealPoint".into()), V::Str(b"P".to_vec())];
|
||||
add_entry(&mut d, &mut st, 0);
|
||||
let mut st = vec![V::Name("Def".into())];
|
||||
close(&mut d, &mut st, 0);
|
||||
assert_eq!(d.nums("P"), vec![1.0, 2.0]);
|
||||
assert_eq!(d.get("Def"), Some(&[][..]));
|
||||
let mut a = Obj { ty: "Array".into(), ..Obj::default() };
|
||||
let mut st = vec![V::Obj(Rc::new(d.clone()))];
|
||||
add_entry(&mut a, &mut st, 0);
|
||||
assert_eq!(a.items().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latin_text() {
|
||||
assert_eq!(text(b"caf\xe9 \x80"), "café €");
|
||||
assert_eq!(text("ü".as_bytes()), "ü");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
//! Colours and gradients of the editing data.
|
||||
//!
|
||||
//! - Colours: `g`/`G` grey (1 is white), `k`/`K` CMYK, `x`/`X` a named CMYK colour at a tint
|
||||
//! (`c m y k (name) tint`; tint 0 is the full colour), `Xa`/`XA` RGB (`c m y k r g b`, the CMYK
|
||||
//! values its equivalent), `Xx`/`XX` a named colour of either model
|
||||
//! (`c m y k [r g b] (name) tint type`). Lower case paints fills, upper case strokes.
|
||||
//! - Gradient definitions: `(name) type stops Bd`, its stops as `colour style [opacity 6]
|
||||
//! midpoint ramp Bs` (style 0 grey, 1 CMYK, 2 RGB with its CMYK, 3 and 4 named colours), `BD`.
|
||||
//! - A gradient on an object: `[flag] Bb` (1: the stroke), `flag (name) x y angle length a b c d
|
||||
//! tx ty Bg`, the gradient matrix `a b c d tx ty Bm`, the focal point `x y angle length Bh`,
|
||||
//! `flag BB`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use kurbo::{Affine, Point, Vec2};
|
||||
use vectorcraft_color::gradient::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
|
||||
use super::obj::V;
|
||||
|
||||
/// Largest number of stops read in one gradient.
|
||||
const MAX_STOPS: usize = 256;
|
||||
|
||||
/// A colour read with the swatch it names (spot and global colours).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct Named {
|
||||
pub name: String,
|
||||
/// The full colour.
|
||||
pub color: Color,
|
||||
pub tint: f32,
|
||||
}
|
||||
|
||||
/// The paint of a colour operator's operands (`op`: its name, lower case or not), with the named
|
||||
/// colour it uses. `None` when the operands don't fit.
|
||||
pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option<Named>)> {
|
||||
let nums: Vec<f32> = vals.iter().filter_map(V::num).map(|v| v as f32).collect();
|
||||
let unit = |v: f32| v.clamp(0.0, 1.0);
|
||||
let last = |n: usize| nums.get(nums.len().checked_sub(n)?..);
|
||||
match op.to_ascii_lowercase().as_str() {
|
||||
"g" => Some((Paint::solid(Color::gray(1.0 - unit(*nums.last()?))), None)),
|
||||
"k" => {
|
||||
let [c, m, y, k] = last(4)? else { return None };
|
||||
Some((Paint::solid(Color::cmyk(unit(*c), unit(*m), unit(*y), unit(*k))), None))
|
||||
}
|
||||
"xa" => {
|
||||
let [r, g, b] = last(3)? else { return None };
|
||||
Some((Paint::solid(Color::rgb(unit(*r), unit(*g), unit(*b))), None))
|
||||
}
|
||||
"x" => named(vals, Some(false)),
|
||||
"xx" => named(vals, None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `c m y k (name) tint x` or `c m y k [r g b] (name) tint type Xx` (type 1: RGB); `rgb` forces
|
||||
/// the model.
|
||||
fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
|
||||
let at = vals.iter().rposition(|v| matches!(v, V::Str(_)))?;
|
||||
let name = vals.get(at)?.text()?;
|
||||
let before: Vec<f32> = vals.get(..at)?.iter().filter_map(V::num).map(|v| v as f32).collect();
|
||||
let after: Vec<f32> = vals.get(at + 1..)?.iter().filter_map(V::num).map(|v| v as f32).collect();
|
||||
let unit = |v: f32| v.clamp(0.0, 1.0);
|
||||
let tint = 1.0 - unit(*after.first()?);
|
||||
let n = before.len();
|
||||
let color = if rgb.unwrap_or(after.get(1).is_some_and(|t| *t == 1.0)) {
|
||||
let [r, g, b] = before.get(n.checked_sub(3)?..)? else { return None };
|
||||
Color::rgb(unit(*r), unit(*g), unit(*b))
|
||||
} else {
|
||||
// The CMYK values come first (an RGB equivalent may follow them).
|
||||
let from = if n >= 7 { n - 7 } else { n.checked_sub(4)? };
|
||||
let [c, m, y, k] = before.get(from..from + 4)? else { return None };
|
||||
Color::cmyk(unit(*c), unit(*m), unit(*y), unit(*k))
|
||||
};
|
||||
let paint = Paint::Solid { color: color.tinted(tint), swatch: Some(name.clone()), tint };
|
||||
Some((paint, Some(Named { name, color, tint })))
|
||||
}
|
||||
|
||||
/// The colour of a gradient stop's colour values and style.
|
||||
fn stop_color(style: f64, comps: &[V]) -> Option<(Color, Option<Named>)> {
|
||||
let nums: Vec<f32> = comps.iter().filter_map(V::num).map(|v| v as f32).collect();
|
||||
let unit = |v: f32| v.clamp(0.0, 1.0);
|
||||
let tail = |n: usize| nums.get(nums.len().checked_sub(n)?..);
|
||||
Some(match style as i64 {
|
||||
0 => (Color::gray(1.0 - unit(*nums.last()?)), None),
|
||||
1 => {
|
||||
let [c, m, y, k] = tail(4)? else { return None };
|
||||
(Color::cmyk(unit(*c), unit(*m), unit(*y), unit(*k)), None)
|
||||
}
|
||||
2 => {
|
||||
let [r, g, b] = tail(3)? else { return None };
|
||||
(Color::rgb(unit(*r), unit(*g), unit(*b)), None)
|
||||
}
|
||||
_ => {
|
||||
let (paint, named) = named(comps, Some(style as i64 == 4))?;
|
||||
(paint.color()?, named)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A gradient definition being read.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct GradientDef {
|
||||
pub name: String,
|
||||
pub gradient: Gradient,
|
||||
}
|
||||
|
||||
impl GradientDef {
|
||||
/// `(name) type stops Bd`.
|
||||
pub fn begin(vals: &[V]) -> Option<Self> {
|
||||
let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None })?;
|
||||
let nums: Vec<f64> = vals.iter().filter_map(V::num).collect();
|
||||
let kind = if nums.first().copied() == Some(1.0) { GradientKind::Radial } else { GradientKind::Linear };
|
||||
Some(Self { name, gradient: Gradient { kind, stops: vec![] } })
|
||||
}
|
||||
|
||||
/// A stop (`Bs`).
|
||||
pub fn stop(&mut self, vals: &[V], spots: &mut Vec<Named>) {
|
||||
if self.gradient.stops.len() >= MAX_STOPS {
|
||||
return;
|
||||
}
|
||||
let n = vals.len();
|
||||
let num = |i: usize| vals.get(i).and_then(V::num);
|
||||
let (Some(ramp), Some(mid)) = (n.checked_sub(1).and_then(num), n.checked_sub(2).and_then(num)) else { return };
|
||||
// Newer stops have an opacity and the marker 6 before the midpoint.
|
||||
let newer = n >= 5 && n.checked_sub(3).and_then(num) == Some(6.0);
|
||||
let (style_at, opacity) = if newer { (n.checked_sub(5), n.checked_sub(4).and_then(num).unwrap_or(1.0)) } else { (n.checked_sub(3), 1.0) };
|
||||
let (Some(style_at), Some(style)) = (style_at, style_at.and_then(num)) else { return };
|
||||
let Some((color, named)) = stop_color(style, vals.get(..style_at).unwrap_or_default()) else { return };
|
||||
let mut stop = GradientStop::new((ramp / 100.0).clamp(0.0, 1.0) as f32, color);
|
||||
stop.opacity = opacity.clamp(0.0, 1.0) as f32;
|
||||
stop.midpoint = (mid / 100.0).clamp(0.13, 0.87) as f32;
|
||||
if let Some(n) = named {
|
||||
stop.set_color(n.color.tinted(n.tint), Some((n.name.clone(), n.tint)));
|
||||
spots.push(n);
|
||||
}
|
||||
self.gradient.stops.push(stop);
|
||||
}
|
||||
|
||||
/// The finished gradient (`BD`): stops in order of their ramp points.
|
||||
pub fn finish(mut self) -> Option<(String, Gradient)> {
|
||||
if self.gradient.stops.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.gradient.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
|
||||
Some((self.name, self.gradient))
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient instance on an object, between `Bb` and `BB`.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct Instance {
|
||||
pub stroke: bool,
|
||||
pub name: Option<String>,
|
||||
origin: Point,
|
||||
angle: f64,
|
||||
length: f64,
|
||||
/// `Bg`'s own matrix.
|
||||
bg: Affine,
|
||||
/// The gradient matrix (`Bm`), when there is one.
|
||||
bm: Option<Affine>,
|
||||
/// The focal point's offset from the origin (`Bh`).
|
||||
hilight: Vec2,
|
||||
}
|
||||
|
||||
fn affine(n: &[f64]) -> Option<Affine> {
|
||||
let [a, b, c, d, e, f] = n else { return None };
|
||||
let m = Affine::new([*a, *b, *c, *d, *e, *f]);
|
||||
(m.as_coeffs().iter().all(|v| v.is_finite())).then_some(m)
|
||||
}
|
||||
|
||||
impl Instance {
|
||||
pub fn begin(vals: &[V]) -> Self {
|
||||
let stroke = vals.iter().filter_map(V::num).next_back() == Some(1.0);
|
||||
Self { stroke, length: 1.0, ..Self::default() }
|
||||
}
|
||||
|
||||
/// `flag (name) x y angle length a b c d tx ty Bg`.
|
||||
pub fn bg(&mut self, vals: &[V]) {
|
||||
let Some(at) = vals.iter().rposition(|v| matches!(v, V::Str(_))) else { return };
|
||||
self.name = vals.get(at).and_then(V::text);
|
||||
let n: Vec<f64> = vals.get(at + 1..).unwrap_or_default().iter().filter_map(V::num).collect();
|
||||
if let [x, y, angle, length, m @ ..] = n.as_slice() {
|
||||
self.origin = Point::new(*x, *y);
|
||||
self.angle = *angle;
|
||||
self.length = *length;
|
||||
self.bg = affine(m).unwrap_or(Affine::IDENTITY);
|
||||
}
|
||||
}
|
||||
|
||||
/// `a b c d tx ty Bm`.
|
||||
pub fn bm(&mut self, vals: &[V]) {
|
||||
let n: Vec<f64> = vals.iter().filter_map(V::num).collect();
|
||||
self.bm = n.get(n.len().saturating_sub(6)..).and_then(affine).filter(|m| m.determinant().abs() > 1e-12);
|
||||
}
|
||||
|
||||
/// `x y angle length Bh`.
|
||||
pub fn bh(&mut self, vals: &[V]) {
|
||||
let n: Vec<f64> = vals.iter().filter_map(V::num).collect();
|
||||
if let [x, y, ..] = n.as_slice() {
|
||||
self.hilight = Vec2::new(*x, *y);
|
||||
}
|
||||
}
|
||||
|
||||
/// The paint, through `to_doc` (art space → the document), when the gradient is defined.
|
||||
pub fn paint(&self, defs: &HashMap<String, Gradient>, to_doc: Affine) -> Option<Paint> {
|
||||
let name = self.name.as_ref()?;
|
||||
let gradient = defs.get(name)?.clone();
|
||||
let m = to_doc * self.bm.unwrap_or(Affine::IDENTITY) * self.bg;
|
||||
let a = self.angle.to_radians();
|
||||
let dir = Vec2::new(a.cos(), a.sin()) * self.length;
|
||||
let start = m * self.origin;
|
||||
let end = m * (self.origin + dir);
|
||||
if !(start.is_finite() && end.is_finite()) || start.distance(end) < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
// Across the vector, for an elliptical radial gradient.
|
||||
let across = m * (self.origin + Vec2::new(-dir.y, dir.x));
|
||||
let aspect = (across.distance(start) / start.distance(end)).clamp(0.01, 100.0);
|
||||
let focal = (self.hilight.hypot() > 1e-9).then(|| m * (self.origin + self.hilight)).filter(|p| p.is_finite());
|
||||
let radial = gradient.kind == GradientKind::Radial;
|
||||
let geom = GradientGeom { start, end, aspect: if radial { aspect } else { 1.0 }, focal: if radial { focal } else { None } };
|
||||
let angle = -(end - start).atan2().to_degrees();
|
||||
Some(Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), angle, swatch: Some(name.clone()), ..GradientPaint::new(gradient) })))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn nums(v: &[f64]) -> Vec<V> {
|
||||
v.iter().map(|n| V::Num(*n)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colours() {
|
||||
let (p, _) = color_op("g", &nums(&[0.7])).unwrap();
|
||||
assert!(matches!(p.color(), Some(Color::Gray { k }) if (k - 0.3).abs() < 1e-6));
|
||||
let (p, _) = color_op("XA", &nums(&[0.0, 0.99, 1.0, 0.0, 1.0, 0.0, 0.0])).unwrap();
|
||||
assert_eq!(p.color(), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
let mut v = nums(&[0.0, 0.5, 1.0, 0.0]);
|
||||
v.push(V::Str(b"MySpot".to_vec()));
|
||||
v.push(V::Num(0.4));
|
||||
let (p, named) = color_op("x", &v).unwrap();
|
||||
let named = named.unwrap();
|
||||
assert_eq!(named.name, "MySpot");
|
||||
assert!((named.tint - 0.6).abs() < 1e-6);
|
||||
assert!(matches!(p, Paint::Solid { swatch: Some(_), .. }));
|
||||
assert!(color_op("k", &nums(&[1.0])).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_definition_and_instance() {
|
||||
let mut d = GradientDef::begin(&[V::Str(b"G".to_vec()), V::Num(1.0), V::Num(2.0)]).unwrap();
|
||||
let mut spots = vec![];
|
||||
d.stop(&nums(&[0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 2.0, 0.5, 6.0, 30.0, 100.0]), &mut spots);
|
||||
d.stop(&nums(&[0.81, 0.0, 1.0, 6.0, 50.0, 0.0]), &mut spots);
|
||||
let (name, g) = d.finish().unwrap();
|
||||
assert_eq!(name, "G");
|
||||
assert_eq!(g.kind, GradientKind::Radial);
|
||||
assert_eq!(g.stops.len(), 2);
|
||||
assert_eq!(g.stops[1].opacity, 0.5);
|
||||
assert!(matches!(g.stops[0].color, Color::Gray { k } if (k - 0.19).abs() < 1e-6));
|
||||
let defs = HashMap::from([(name, g)]);
|
||||
let mut i = Instance::begin(&[]);
|
||||
let mut bg = vec![V::Num(1.0), V::Str(b"G".to_vec())];
|
||||
bg.extend(nums(&[0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
|
||||
i.bg(&bg);
|
||||
i.bm(&nums(&[50.0, 0.0, 0.0, -50.0, 100.0, 100.0]));
|
||||
let Some(Paint::Gradient(gp)) = i.paint(&defs, Affine::IDENTITY) else { panic!() };
|
||||
let geom = gp.geom.unwrap();
|
||||
assert_eq!(geom.start, Point::new(100.0, 100.0));
|
||||
assert_eq!(geom.end, Point::new(150.0, 100.0));
|
||||
assert!((geom.aspect - 1.0).abs() < 1e-9);
|
||||
assert!(i.paint(&HashMap::new(), Affine::IDENTITY).is_none());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,428 @@
|
|||
//! The editing data reader on synthetic data, written from the format as `read` documents it.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{Point, Rect};
|
||||
use vectorcraft_color::gradient::GradientKind;
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{ColorMode, Document, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Editing data: header comments (crop marks 0 0 200 100), then `body`.
|
||||
fn stream(body: &str) -> String {
|
||||
format!(
|
||||
"%!PS-Adobe-3.0\n%%Creator: test\n%AI5_FileFormat 14.0\n%AI3_Cropmarks: 0 0 200 100\n%%EndComments\n%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n{body}\n%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// One layer named `L` holding `art`.
|
||||
fn layer(art: &str) -> String {
|
||||
format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n0 A\n0 Xw\n{art}\nLB\n%AI5_EndLayer--\n")
|
||||
}
|
||||
|
||||
fn read_ok(body: &str) -> Structure {
|
||||
match read(stream(body).as_bytes()) {
|
||||
Ok(i) => i,
|
||||
Err(e) => panic!("{e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The children of the first layer.
|
||||
fn art(doc: &Document) -> Vec<Arc<Node>> {
|
||||
doc.layers.first().and_then(|l| l.children()).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn fill(n: &Node) -> Paint {
|
||||
n.appearance.fill().map(|f| f.paint.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn rect_path(x0: f64, y0: f64, x1: f64, y1: f64) -> String {
|
||||
format!("{x0} {y0} m\n{x0} {y1} L\n{x1} {y1} L\n{x1} {y0} L\n{x0} {y0} L\n")
|
||||
}
|
||||
|
||||
const RED: &str = "0 1 1 0 1 0 0 Xa\n";
|
||||
|
||||
#[test]
|
||||
fn layers_and_sublayers() {
|
||||
let body = format!(
|
||||
"%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Bottom) Ln\n{RED}{}f\n\
|
||||
%AI5_BeginLayer\n0 1 0 0 0 0 0 -1 10 200 30 0 50 0 Lb\n(Inner) Ln\n{}f\nLB\n%AI5_EndLayer--\nLB\n%AI5_EndLayer--\n\
|
||||
%AI5_BeginLayer\n1 0 1 1 1 0 1 3 79 79 255 0 70 0 Lb\n(Top) Ln\n{}f\nLB\n%AI5_EndLayer--\n",
|
||||
rect_path(0.0, 0.0, 10.0, 10.0),
|
||||
rect_path(20.0, 0.0, 30.0, 10.0),
|
||||
rect_path(40.0, 0.0, 50.0, 10.0)
|
||||
);
|
||||
let doc = read_ok(&body).doc;
|
||||
let names: Vec<_> = doc.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(names, ["Bottom", "Top"], "bottom first");
|
||||
let bottom = &doc.layers[0];
|
||||
assert!(bottom.visible && !bottom.locked);
|
||||
let kids = bottom.children().unwrap();
|
||||
assert_eq!(kids.len(), 2);
|
||||
let inner = &kids[1];
|
||||
assert_eq!(inner.name.as_deref(), Some("Inner"));
|
||||
assert!(!inner.visible && inner.locked);
|
||||
let NodeKind::Layer { color, printable, .. } = &inner.kind else { panic!("a sublayer") };
|
||||
assert_eq!((*color, *printable), (LayerColor::Custom([10, 200, 30]), false));
|
||||
let NodeKind::Layer { color, preview, dim_images, .. } = &doc.layers[1].kind else { panic!() };
|
||||
assert_eq!((*color, *preview, *dim_images), (LayerColor::Preset(3), false, Some(70)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_names_hidden_and_locked() {
|
||||
let art_ = format!(
|
||||
"0 Ae\nu\n{RED}{}f\n%_/ArtDictionary :\n%_/XMLUID : (My_Rect__x23_2) ; (AI10_ArtUID) ,\n%_;\n%_\n\
|
||||
1 A\n0 Ae\nu\n0 A\n1 Xw\n{}f\n0 Xw\nU\n%_/ArtDictionary :\n%_(Inner group) /String (AIArtName) ,\n%_;\n9 () XW\n\
|
||||
0 A\nU\n%_/ArtDictionary :\n%_/XMLUID : (Outer) ; (AI10_ArtUID) ,\n%_;\n%_\n9 () XW\n",
|
||||
rect_path(0.0, 0.0, 10.0, 10.0),
|
||||
rect_path(20.0, 0.0, 30.0, 10.0),
|
||||
);
|
||||
let doc = read_ok(&layer(&art_)).doc;
|
||||
let top = art(&doc);
|
||||
assert_eq!(top.len(), 1);
|
||||
let outer = &top[0];
|
||||
assert_eq!(outer.name.as_deref(), Some("Outer"));
|
||||
let kids = outer.children().unwrap();
|
||||
assert_eq!(kids[0].name.as_deref(), Some("My Rect #2"));
|
||||
let inner = &kids[1];
|
||||
assert_eq!(inner.name.as_deref(), Some("Inner group"));
|
||||
assert!(inner.locked, "locked when it opened");
|
||||
let hidden = &inner.children().unwrap()[0];
|
||||
assert!(!hidden.visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_colours_and_strokes() {
|
||||
let art_ = format!(
|
||||
"{RED}0 R\n0 0 1 0 0 0 1 XA\n1 J 2 j 3 w 4 M [6 3 ]1 d\n1 XR\n{}b\n\
|
||||
0.1 0.2 0.3 0.4 k\n{}f\n0.7 g\n{}F\n0 0.5 1 0 (Ink) 0.4 x\n{}f\n\
|
||||
10 50 m\n20 60 30 60 40 50 c\n50 40 60 40 v\n70 60 80 60 y\nS\n",
|
||||
rect_path(0.0, 0.0, 10.0, 10.0),
|
||||
rect_path(20.0, 0.0, 30.0, 10.0),
|
||||
rect_path(40.0, 0.0, 50.0, 10.0),
|
||||
rect_path(60.0, 0.0, 70.0, 10.0),
|
||||
);
|
||||
let doc = read_ok(&layer(&art_)).doc;
|
||||
let a = art(&doc);
|
||||
assert_eq!(a.len(), 5);
|
||||
// y up from the crop marks' top: (0, 0)-(10, 10) is at y 90-100.
|
||||
let NodeKind::Path { path, rule, .. } = &a[0].kind else { panic!() };
|
||||
assert_eq!(path.bounds(), Some(Rect::new(0.0, 90.0, 10.0, 100.0)));
|
||||
assert!(path.subpaths[0].closed);
|
||||
assert_eq!(path.subpaths[0].anchors.len(), 4, "the closing point merges with the first");
|
||||
assert_eq!(*rule, vectorcraft_geom::FillRule::EvenOdd);
|
||||
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
let st = a[0].appearance.stroke().unwrap();
|
||||
assert_eq!((st.width, st.cap, st.join, st.miter_limit), (3.0, LineCap::Round, LineJoin::Bevel, 4.0));
|
||||
assert_eq!(st.paint.color(), Some(Color::rgb(0.0, 0.0, 1.0)));
|
||||
let dash = st.dash.clone().unwrap();
|
||||
assert_eq!((dash.pattern, dash.offset), (vec![6.0, 3.0], 1.0));
|
||||
assert_eq!(fill(&a[1]).color(), Some(Color::cmyk(0.1, 0.2, 0.3, 0.4)));
|
||||
assert!(matches!(fill(&a[2]).color(), Some(Color::Gray { k }) if (k - 0.3).abs() < 1e-6));
|
||||
assert!(!matches!(&a[2].kind, NodeKind::Path { path, .. } if path.subpaths[0].closed), "F leaves the path open");
|
||||
let Paint::Solid { swatch, tint, .. } = fill(&a[3]) else { panic!() };
|
||||
assert_eq!(swatch.as_deref(), Some("Ink"));
|
||||
assert!((tint - 0.6).abs() < 1e-6);
|
||||
let ink = doc.swatch("Ink").unwrap();
|
||||
assert!(ink.spot && ink.global);
|
||||
// Curves: `c`, `v` (first control at the current point) and `y` (second at the end).
|
||||
let NodeKind::Path { path, .. } = &a[4].kind else { panic!() };
|
||||
assert_eq!(path.subpaths[0].anchors.len(), 4);
|
||||
assert!(a[4].appearance.fill().is_none() && a[4].appearance.stroke().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_paths_and_clipping() {
|
||||
let art_ = format!(
|
||||
"0 Ae\n*u\n{RED}{}f\n{}f\n*U\n%_/ArtDictionary :\n%_/XMLUID : (Donut) ; (AI10_ArtUID) ,\n%_;\n\
|
||||
0 Ae\nq\n{}f\n{}h\nW\nn\nQ\n%_/ArtDictionary :\n%_/XMLUID : (Clipped) ; (AI10_ArtUID) ,\n%_;\n9 () XW\n",
|
||||
rect_path(0.0, 0.0, 30.0, 30.0),
|
||||
rect_path(10.0, 10.0, 20.0, 20.0),
|
||||
rect_path(40.0, 0.0, 80.0, 40.0),
|
||||
rect_path(50.0, 10.0, 60.0, 20.0),
|
||||
);
|
||||
let doc = read_ok(&layer(&art_)).doc;
|
||||
let a = art(&doc);
|
||||
let NodeKind::Compound { children, .. } = &a[0].kind else { panic!("{:?}", a[0].kind) };
|
||||
assert_eq!((children.len(), a[0].name.as_deref()), (2, Some("Donut")));
|
||||
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
let NodeKind::Group { children, clip: true } = &a[1].kind else { panic!("a clipping group") };
|
||||
assert_eq!(a[1].name.as_deref(), Some("Clipped"));
|
||||
// The clipping path goes first.
|
||||
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
|
||||
assert!(matches!(children[1].kind, NodeKind::Path { clipping: false, .. }));
|
||||
// A clipping path in a layer clips the layer.
|
||||
let doc = read_ok(&layer(&format!("{}f\n{}W\nn\n", rect_path(0.0, 0.0, 9.0, 9.0), rect_path(0.0, 0.0, 5.0, 5.0)))).doc;
|
||||
let NodeKind::Layer { clip, children, .. } = &doc.layers[0].kind else { panic!() };
|
||||
assert!(*clip && matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_of_objects_and_groups() {
|
||||
let art_ = format!(
|
||||
"{RED}1 0.5 0 0 0 Xy\n{}f\n0 Ae\nu\n0 1 0 0 0 Xy\n{}f\nU\n2 0.7 1 1 0 Xy\n0 0 Xd\n6 () XW\n0 1 0 0 0 Xy\n{}f\n",
|
||||
rect_path(0.0, 0.0, 10.0, 10.0),
|
||||
rect_path(20.0, 0.0, 30.0, 10.0),
|
||||
rect_path(40.0, 0.0, 50.0, 10.0),
|
||||
);
|
||||
let doc = read_ok(&layer(&art_)).doc;
|
||||
let a = art(&doc);
|
||||
assert_eq!((a[0].opacity, a[0].blend), (0.5, BlendMode::Multiply));
|
||||
assert_eq!((a[1].opacity, a[1].blend, a[1].isolate, a[1].knockout), (0.7, BlendMode::Screen, true, Knockout::On));
|
||||
let inner = &a[1].children().unwrap()[0];
|
||||
assert_eq!(inner.opacity, 1.0);
|
||||
assert_eq!((a[2].opacity, a[2].blend), (1.0, BlendMode::Normal), "the next object's state isn't the group's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradients() {
|
||||
let defs = "%AI5_BeginGradient: (G)\n(G) 1 2 Bd\n[\n<00FF>\n0\n0 0 0 0 1 0 0 2 1 6 %_BS\n\
|
||||
%_0 0 0 0 1 0 0 2 1 6 40 0 Bs\n%_0 0 0 0 0 0 1 2 0.5 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
|
||||
let art_ = format!(
|
||||
"{RED}{}Bb\n0 0 0 0 Bh\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n50 0 0 -50 100 50 Bm\nf\n0 BB\n{}f\n",
|
||||
rect_path(50.0, 0.0, 150.0, 100.0),
|
||||
rect_path(0.0, 0.0, 10.0, 10.0),
|
||||
);
|
||||
let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc;
|
||||
let a = art(&doc);
|
||||
let Paint::Gradient(g) = fill(&a[0]) else { panic!("{:?}", fill(&a[0])) };
|
||||
assert_eq!(g.gradient.kind, GradientKind::Radial);
|
||||
assert_eq!(g.gradient.stops.len(), 2);
|
||||
assert!((g.gradient.stops[0].midpoint - 0.4).abs() < 1e-6);
|
||||
assert_eq!(g.gradient.stops[1].opacity, 0.5);
|
||||
let geom = g.geom.unwrap();
|
||||
assert_eq!((geom.start, geom.end), (Point::new(100.0, 50.0), Point::new(150.0, 50.0)));
|
||||
// The gradient was the one object's.
|
||||
assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artboards() {
|
||||
let data = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\
|
||||
%_/Dictionary :\n%_0 200 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_300 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_(One) /UnicodeString (Name) ,\n%_; ,\n\
|
||||
%_/Dictionary :\n%_400 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_600 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
|
||||
%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n";
|
||||
let art_ = rect_path(420.0, -70.0, 470.0, -20.0) + "f\n";
|
||||
let doc = read_ok(&format!("{data}{}", layer(&art_))).doc;
|
||||
let boards: Vec<_> = doc.artboards.iter().map(|a| (a.name.clone(), a.rect)).collect();
|
||||
assert_eq!(boards, [("One".to_string(), Rect::new(0.0, 0.0, 300.0, 200.0)), ("Artboard 2".to_string(), Rect::new(400.0, 200.0, 600.0, 350.0))]);
|
||||
let NodeKind::Path { path, .. } = &art(&doc)[0].kind else { panic!() };
|
||||
assert_eq!(path.bounds(), Some(Rect::new(420.0, 220.0, 470.0, 270.0)));
|
||||
// Without artboards or crop marks: the art size around the template box's centre.
|
||||
let old = format!(
|
||||
"%!PS-Adobe-3.0\n%AI5_ArtSize: 600 400\n%AI3_TemplateBox: 300.5 199.5 300.5 199.5\n%%EndComments\n{}",
|
||||
layer("10 10 m\n20 20 L\nS\n")
|
||||
);
|
||||
let doc = read(old.as_bytes()).unwrap().doc;
|
||||
assert_eq!(doc.artboards[0].rect, Rect::new(0.0, 0.0, 600.0, 400.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drawn_look_stands_for_its_object() {
|
||||
// The look (a group, with a part on `%_` lines for newer readers), then the object on `%_`
|
||||
// lines and its style.
|
||||
let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
|
||||
let art_ = format!(
|
||||
"0 Ae\nu\n%_0 Ae\n%_u\n%_{hidden_rect}S\n%_U\n{RED}{}f\n0 0 0 0 0 0 1 XA\n{}S\nU\n\
|
||||
%_/ArtDictionary :\n%_/XMLUID : (Neon_00000092429200007574234970000005974767392385616558_) ; (AI10_ArtUID) ,\n%_;\n\
|
||||
0 0.4 0 0 0 Xy\n0 0 Xd\n6 () XW\n%_{hidden_rect}n\n%_/ArtDictionary :\n%_/XMLUID : (Neon) ; (AI10_ArtUID) ,\n%_;\n1 (Neon style) XW\n\
|
||||
0 1 0 0 0 Xy\n{}f\n",
|
||||
rect_path(0.0, 0.0, 10.0, 10.0),
|
||||
rect_path(0.0, 0.0, 10.0, 10.0),
|
||||
rect_path(30.0, 0.0, 40.0, 10.0),
|
||||
);
|
||||
let r = read_ok(&layer(&art_));
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 2, "{a:#?}");
|
||||
assert_eq!(a[0].name.as_deref(), Some("Neon"));
|
||||
assert_eq!(a[0].opacity, 0.4);
|
||||
assert_eq!(a[0].children().unwrap().len(), 2, "the newer readers' part is left out");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("drawn look (1)")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// Editing data with one layer holding an image: `matrix` (`[a b c d tx ty]`), its size, colour
|
||||
/// space, kind and alpha channels, its samples, and the size the data comment declares.
|
||||
fn image(matrix: &str, (w, h): (u32, u32), (space, kind, alpha): (&str, u32, u32), data: &[u8], declared: usize) -> Vec<u8> {
|
||||
let mut s = format!(
|
||||
"%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/{space} XN\n{matrix} {w} {h} 0 Xh\n\
|
||||
{matrix} 0 0 {w} {h} {w} {h} 8 {kind} {alpha} 0 1 0 4 4 0 0\n%%BeginData: {declared}\rXI\n"
|
||||
)
|
||||
.into_bytes();
|
||||
s.extend_from_slice(data);
|
||||
s.extend_from_slice(b"%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\r\nLB\r\n%AI5_EndLayer--\r\n");
|
||||
let mut out = stream("").into_bytes();
|
||||
let at = out.windows(14).position(|w| w == b"%%PageTrailer\n").unwrap();
|
||||
out.splice(at..at, s);
|
||||
out
|
||||
}
|
||||
|
||||
fn pixels(doc: &Document) -> (image::RgbaImage, kurbo::Affine) {
|
||||
let a = art(doc);
|
||||
let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) };
|
||||
let blob = doc.images.get(&im.key).unwrap();
|
||||
(image::load_from_memory(&blob.bytes).unwrap().to_rgba8(), im.xf)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_with_alpha_channels() {
|
||||
// RGB samples, then the alpha channel (not counted in the data's size).
|
||||
let data = [255, 0, 0, 0, 255, 0, 255, 128];
|
||||
let doc = read(&image("[ 10 0 0 10 50 90 ]", (2, 1), ("DeviceRGB", 3, 1), &data, 10)).unwrap().doc;
|
||||
let (img, xf) = pixels(&doc);
|
||||
assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([255, 0, 0, 255], [0, 255, 0, 128]));
|
||||
// Pixel space (y down) to the top left at (50, 90) in art space: (50, 10) on the page.
|
||||
assert_eq!(xf * Point::ZERO, Point::new(50.0, 10.0));
|
||||
assert_eq!(xf * Point::new(2.0, 1.0), Point::new(70.0, 20.0));
|
||||
// CMYK and grey.
|
||||
let doc = read(&image("[ 1 0 0 1 0 100 ]", (1, 1), ("DeviceCMYK", 4, 0), &[0, 0, 0, 255], 6)).unwrap().doc;
|
||||
let px = pixels(&doc).0.get_pixel(0, 0).0;
|
||||
assert!(px[0] < 64 && px[3] == 255, "100% black is dark: {px:?}");
|
||||
let doc = read(&image("[ 1 0 0 1 0 100 ]", (2, 1), ("DeviceGray", 1, 0), &[0, 200], 6)).unwrap().doc;
|
||||
assert_eq!(pixels(&doc).0.get_pixel(1, 0).0, [200, 200, 200, 255]);
|
||||
// Data cut short: the image goes, with a warning.
|
||||
let r = read(&image("[ 1 0 0 1 0 100 ]", (4, 4), ("DeviceRGB", 3, 0), &[1, 2, 3], 6)).unwrap();
|
||||
assert!(art(&r.doc).is_empty() && r.warnings.iter().any(|w| w.contains("cut short")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_objects_are_slots_named_after_their_story() {
|
||||
let words = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n3 /StoryIndex ,\n;\n%_/ArtDictionary :\n%_/XMLUID : (Title) ; (AI10_ArtUID) ,\n%_;\n";
|
||||
let r = read_ok(&layer(&format!("1 Xw\n{words}0 Xw\n")));
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(slot_of(a[0].name.as_deref()), Some(Some(3)));
|
||||
assert!(!a[0].visible);
|
||||
assert_eq!(r.slot_names.get(&a[0].id).map(String::as_str), Some("Title"));
|
||||
// A copy on `%_` lines of a story that has a plain text object goes; one without stays.
|
||||
let commented = |story: u32| -> String { format!("/AI11Text :\n{story} /StoryIndex ,\n;\n").lines().map(|l| format!("%_{l}\n")).collect() };
|
||||
let r = read_ok(&layer(&format!("/AI11Text :\n0 /StoryIndex ,\n;\n{}{}", commented(0), commented(1))));
|
||||
let stories: Vec<_> = art(&r.doc).iter().map(|n| slot_of(n.name.as_deref())).collect();
|
||||
assert_eq!(stories, [Some(Some(0)), Some(Some(1))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_it_doesnt_read_is_an_error_where_it_shows() {
|
||||
let e = read(stream(&layer("1 2 frobnicate\n")).as_bytes()).unwrap_err();
|
||||
assert!(e.contains("`frobnicate`"), "{e}");
|
||||
let e = read(stream(&layer("(Jive) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n")).as_bytes()).unwrap_err();
|
||||
assert!(e.contains("pattern fills"), "{e}");
|
||||
let e = read(stream(&layer("/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n")).as_bytes()).unwrap_err();
|
||||
assert!(e.contains("symbols"), "{e}");
|
||||
// On a layer that doesn't show, it is left out of it.
|
||||
let hidden = "%AI5_BeginLayer\n0 1 1 1 0 0 0 1 255 79 79 0 50 0 Lb\n(Off) Ln\n1 2 frobnicate\n(J) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\nLB\n%AI5_EndLayer--\n";
|
||||
let r = read_ok(hidden);
|
||||
assert_eq!(r.hidden_unread.iter().cloned().collect::<Vec<_>>(), ["`frobnicate`", "pattern fills"]);
|
||||
// Unbalanced containers, a layer that doesn't end, no layers.
|
||||
assert!(read(stream(&layer("U\n")).as_bytes()).is_err());
|
||||
assert!(read(stream("%AI5_BeginLayer\n(L) Ln\n0 Ae\nu\n").as_bytes()).is_err());
|
||||
assert!(read(stream("").as_bytes()).is_err());
|
||||
// The setup's procedures and what follows the page don't count.
|
||||
read_ok(&format!("Adobe_level2_AI5 /initialize get exec\n{}", layer("")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_it_skips() {
|
||||
let body = "%AI5_Begin_NonPrinting\n%AI14_BeginSymbol\n0 Ae\nu\nfrobnicate\n%AI10_EndSymbol\n%AI3_BeginPattern: (P)\nU\nU\n%AI3_EndPattern\n%AI5_End_NonPrinting--\n\
|
||||
%AI17_Begin_Content_if_version_gt:24 4\n%AI17_Alternate_Content\n%AI17_Begin_Content_if_version_gt:23 1\nfrobnicate\n%AI17_End_Versioned_Content\nfrobnicate\n%AI17_End_Versioned_Content\n\
|
||||
%_/Document :\n%_/Binary : /ASCII85Decode ,\n%_(]<[%\n%;;;~>\n%_; (Profile) ,\n%_;\n";
|
||||
let doc = read_ok(&format!("{body}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")))).doc;
|
||||
assert_eq!(art(&doc).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_documents() {
|
||||
let doc = read_ok(&format!("%AI9_ColorModel: 2\n{}", layer(""))).doc;
|
||||
assert_eq!(doc.color_mode, ColorMode::Cmyk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_data_ends_without_panics() {
|
||||
let deep = "0 Ae\nu\n".repeat(300) + &"U\n".repeat(300);
|
||||
assert!(read(stream(&layer(&deep)).as_bytes()).unwrap_err().contains("nested too deeply"));
|
||||
let keys = "/X :\n".repeat(300);
|
||||
assert!(read(stream(&layer(&keys)).as_bytes()).is_err());
|
||||
for junk in [
|
||||
&b"%AI5_BeginLayer\n(\xff\xfe"[..],
|
||||
b"%AI5_BeginLayer\n<zz",
|
||||
b"]]]] [ [ ; , : : ;",
|
||||
b"1e308 1e308 m 1e308 -1e308 L f",
|
||||
b"%%BeginData: 5\rXI\n",
|
||||
] {
|
||||
let _ = read(junk);
|
||||
}
|
||||
// Images with absurd sizes are left out.
|
||||
let r = read(&image("[ 1 0 0 1 0 0 ]", (1 << 20, 1 << 20), ("DeviceRGB", 3, 0), &[0; 12], 12)).unwrap();
|
||||
assert!(art(&r.doc).is_empty());
|
||||
}
|
||||
|
||||
fn zstd(data: &[u8]) -> Vec<u8> {
|
||||
ruzstd::encoding::compress_to_vec(data, ruzstd::encoding::CompressionLevel::Fastest)
|
||||
}
|
||||
|
||||
/// An EPS whose page draws a line, and the editing data `packed` after it (`marker`: its codec).
|
||||
fn eps(marker: &str, packed: &[u8]) -> Vec<u8> {
|
||||
let lines: String = crate::ps::ascii85(packed).lines().map(|l| format!("%{l}\n")).collect();
|
||||
format!(
|
||||
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n%%EndComments\n0 0 moveto 10 10 lineto stroke\nshowpage\n%%EOF\n\
|
||||
%AI9_PrivateDataBegin\n%!PS-Adobe-3.0 EPSF-3.0\n%AI3_Cropmarks: 0 0 200 100\n%AI5_FileFormat 14.0\n{marker}\n{lines}%AI9_PrivateDataEnd\n"
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_containers() {
|
||||
let body = stream(&layer(&(rect_path(0.0, 0.0, 10.0, 10.0) + "f\n")));
|
||||
let body = body.as_bytes();
|
||||
for file in [eps("%AI24_DataStream", &zstd(body)), eps("%AI9_DataStream", &crate::ps::deflate(body))] {
|
||||
let (ps, _) = crate::sections(&file).unwrap();
|
||||
let data = eps_data(ps).unwrap().unwrap();
|
||||
assert!(data.starts_with(b"%!PS-Adobe-3.0 EPSF-3.0\n%AI3_Cropmarks"), "the header comes first");
|
||||
assert!(data.ends_with(body));
|
||||
assert_eq!(read(&data).unwrap().doc.layers.len(), 1);
|
||||
}
|
||||
// Damaged data says why.
|
||||
let (ps, _) = crate::sections(&eps("%AI24_DataStream", b"not zstd")).map(|(p, t)| (p.to_vec(), t.map(<[u8]>::to_vec))).unwrap();
|
||||
assert!(eps_data(&ps).unwrap().unwrap_err().contains("Zstandard"));
|
||||
// Data that isn't compressed is used as it is.
|
||||
let plain = format!("%!PS-Adobe-3.0\n%%EOF\n%AI9_PrivateDataBegin\n{}%AI9_PrivateDataEnd\n", stream(&layer("")));
|
||||
assert!(read(&eps_data(plain.as_bytes()).unwrap().unwrap()).is_ok());
|
||||
// An EPS without editing data has none.
|
||||
let other = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n%%EndComments\n0 0 moveto 10 10 lineto stroke\nshowpage\n";
|
||||
assert!(eps_data(other).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_private_data() {
|
||||
let body = stream(&layer(""));
|
||||
let body = body.as_bytes();
|
||||
let header = b"%%BoundingBox: 0 0 1 1\r\n";
|
||||
for raw in [
|
||||
[&b"%AI24_ZStandard_Data"[..], &zstd(body)].concat(),
|
||||
[&header[..], b"%AI12_CompressedData", &crate::ps::deflate(body)].concat(),
|
||||
body.to_vec(),
|
||||
] {
|
||||
let data = decode_private(&raw).unwrap();
|
||||
assert!(data.ends_with(b"%%EOF\n"));
|
||||
assert!(read(&data).is_ok());
|
||||
}
|
||||
assert!(decode_private(b"%AI12_CompressedDataxx").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_zstandard_frames_are_read_in_one_pass_and_capped() {
|
||||
let frame = zstd(b" ");
|
||||
let frames = |n: usize| -> Vec<u8> { [&b"%AI24_ZStandard_Data"[..], &frame.repeat(n), &[0; 64]].concat() };
|
||||
let started = std::time::Instant::now();
|
||||
assert_eq!(decode_private(&frames(4000)).unwrap().len(), 4000);
|
||||
assert!(decode_private(&frames(50_000)).unwrap_err().contains("Zstandard"));
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(20), "{:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_brackets_without_their_opening_dont_rescan_the_stack() {
|
||||
let n = 200_000;
|
||||
let body = format!("{}\n{}\n", "1 ".repeat(n), "] ".repeat(n));
|
||||
let started = std::time::Instant::now();
|
||||
let _ = read(stream(&layer(&body)).as_bytes());
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(20), "{:?}", started.elapsed());
|
||||
}
|
||||
|
|
@ -153,18 +153,6 @@ 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.
|
||||
|
|
@ -181,14 +169,6 @@ 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.
|
||||
|
|
@ -224,9 +204,6 @@ impl Out {
|
|||
next_clip: 0,
|
||||
groups: vec![],
|
||||
too_deep: 0,
|
||||
open_clips: vec![],
|
||||
compounds: vec![],
|
||||
hidden: false,
|
||||
elements: 0,
|
||||
merge: None,
|
||||
cmyk: 0,
|
||||
|
|
@ -252,7 +229,7 @@ impl Out {
|
|||
return;
|
||||
}
|
||||
self.next_clip += 1;
|
||||
self.groups.push(Arc::new(Clip { id: self.next_clip, region: None, hidden: self.hidden }));
|
||||
self.groups.push(Arc::new(Clip { id: self.next_clip, region: None }));
|
||||
}
|
||||
|
||||
/// End the innermost group open (an end without a beginning ends nothing).
|
||||
|
|
@ -264,76 +241,6 @@ 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>> {
|
||||
|
|
@ -365,7 +272,6 @@ 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)
|
||||
|
|
@ -420,7 +326,7 @@ impl Out {
|
|||
return None;
|
||||
}
|
||||
self.next_clip += 1;
|
||||
Some(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)), hidden: false }))
|
||||
Some(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)) }))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule};
|
||||
use vectorcraft_geom::Affine;
|
||||
|
||||
use super::graphics::{GState, Out};
|
||||
use super::lex::{IMMEDIATE, Lexer};
|
||||
|
|
@ -65,11 +65,6 @@ 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)
|
||||
|
|
@ -169,8 +164,6 @@ impl<'a> Interp<'a> {
|
|||
saved: vec![],
|
||||
out,
|
||||
illustrator: false,
|
||||
native: false,
|
||||
clip_region: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -179,9 +172,6 @@ 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 {
|
||||
|
|
@ -205,32 +195,6 @@ 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) {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
//! 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 ai;
|
||||
mod ate;
|
||||
mod data;
|
||||
mod graphics;
|
||||
|
|
|
|||
|
|
@ -1,84 +1,46 @@
|
|||
//! 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.
|
||||
//! `%%EOF`, a `.ai` in its `AIPrivateData` streams (see `ai` for the containers). It has what the
|
||||
//! page doesn't: the layers with their names and options, the groups, compound paths and clipping
|
||||
//! groups, the objects' names, the hidden objects and layers, the art outside the artboards and
|
||||
//! every artboard. `ai` reads that structure into a document, with the colours, gradients,
|
||||
//! transparency and images of the art.
|
||||
//!
|
||||
//! 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 slots in that document: 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.
|
||||
//!
|
||||
//! - 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 (no Adobe software was run, no Adobe file is in this repo, and no Adobe specification,
|
||||
//! SDK or installation was used):
|
||||
//!
|
||||
//! - The layer, group, compound-path and clipping vocabulary of the legacy format, the container
|
||||
//! (`%AI9_PrivateDataBegin`, `%AI24_DataStream`, `%AI24_ZStandard_Data`, the `AIPrivateData`
|
||||
//! streams), the meaning of the `Xw` hidden flag and the text document were worked out from `.eps`
|
||||
//! and `.ai` files their users own, used locally and never committed. (Adobe published a
|
||||
//! specification of the legacy format, listed by PRONOM at
|
||||
//! <https://www.nationalarchives.gov.uk/PRONOM/fmt/423>; it was not used here.)
|
||||
//! - The wrappers: the text is the standard ASCII85 encoding; the compressed stream is Zstandard,
|
||||
//! RFC 8878 (<https://www.rfc-editor.org/rfc/rfc8878>), read with the permissively licensed
|
||||
//! `ruzstd` crate (MIT); the older one is zlib, read with `flate2`. The PostScript the layers are
|
||||
//! translated into is run by this project's own interpreter (`interp`).
|
||||
//! What a layer that shows has that `ai` doesn't read makes the file come in as its page, as it did
|
||||
//! before; on a layer that doesn't show it is left out of that layer, with a warning. The page is
|
||||
//! also the safety net: when the layers' art looks different from it, the page is used.
|
||||
//!
|
||||
//! 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 vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use super::Imported;
|
||||
use super::ai::{self, Structure, slot_of};
|
||||
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 largest Zstandard window a frame may ask for: the decoder allocates it before reading.
|
||||
const MAX_ZSTD_WINDOW: u64 = 64 << 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, super::graphics::TOO_MUCH, super::graphics::FAR_AWAY].iter().any(|n| note.starts_with(n))
|
||||
|| [ART_LEFT_OUT, LAYERS_UNREAD, super::graphics::TOO_MUCH, super::graphics::FAR_AWAY].iter().any(|n| note.starts_with(n))
|
||||
}
|
||||
|
||||
/// Deepest nesting of layers read (a file with deeper ones comes in as its page).
|
||||
const MAX_DEPTH: usize = 32;
|
||||
/// Most layers read.
|
||||
const MAX_LAYERS: usize = 4096;
|
||||
/// Most text objects in the layers, and most pieces of type in one place on the page, that are matched
|
||||
/// to each other (more than this and the layers aren't used).
|
||||
const MAX_SLOTS: usize = 5000;
|
||||
|
|
@ -88,378 +50,6 @@ const MAX_TEXT: usize = 16 << 20;
|
|||
/// Most pairs of a text object and a piece of the page's type compared.
|
||||
const MAX_PAIRS: usize = 4_000_000;
|
||||
|
||||
/// 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_with_max_window_size(packed, MAX_ZSTD_WINDOW).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;
|
||||
// Too many, too deep, or in the middle of a dictionary: not a file this reads.
|
||||
if count > MAX_LAYERS || stack.len() > MAX_DEPTH || dict_depth > 0 {
|
||||
return None;
|
||||
}
|
||||
if let Some(parent) = stack.last_mut() {
|
||||
parent.flush();
|
||||
}
|
||||
stack.push(Layer { visible: true, ..Layer::default() });
|
||||
}
|
||||
"%AI5_EndLayer--" => {
|
||||
if dict_depth > 0 {
|
||||
return None;
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
if dict_depth > 0 {
|
||||
return None;
|
||||
}
|
||||
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;
|
||||
|
|
@ -471,19 +61,6 @@ fn few(names: BTreeSet<String>) -> String {
|
|||
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>,
|
||||
|
|
@ -503,54 +80,6 @@ fn collect_slots(nodes: &[Arc<Node>], shown: bool, out: &mut Vec<SlotInfo>) {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
|
|
@ -657,24 +186,46 @@ enum Content {
|
|||
}
|
||||
|
||||
/// 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) {
|
||||
/// left out): the page's type takes its place as it is, made type keeps the slot's name. `empty`
|
||||
/// counts those left out.
|
||||
fn fill_slots(
|
||||
doc: &mut Document,
|
||||
nodes: &mut Vec<Arc<Node>>,
|
||||
content: &mut std::vec::IntoIter<Content>,
|
||||
names: &std::collections::HashMap<NodeId, String>,
|
||||
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 name = names.get(&n.id);
|
||||
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)
|
||||
// The page's type (and the strokes round it) take the slot's place as they are.
|
||||
let parts: Vec<Arc<Node>> = parts
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut p = reid(doc, p);
|
||||
if !visible {
|
||||
Arc::make_mut(&mut p).visible = false;
|
||||
}
|
||||
if let Some(name) = name.filter(|_| matches!(p.kind, NodeKind::Text(_))) {
|
||||
Arc::make_mut(&mut p).name = Some(name.clone());
|
||||
}
|
||||
p
|
||||
})
|
||||
.collect();
|
||||
let n = parts.len();
|
||||
nodes.splice(i..=i, parts);
|
||||
i += n;
|
||||
continue;
|
||||
}
|
||||
Some(Content::Made(mut text)) => {
|
||||
text.id = doc.alloc_id();
|
||||
text.visible = visible;
|
||||
text.name = Some(MADE.into());
|
||||
text.name = Some(format!("{MADE}{}", name.map_or("", String::as_str)));
|
||||
Some(*text)
|
||||
}
|
||||
Some(Content::Merged) => {
|
||||
|
|
@ -700,7 +251,7 @@ fn fill_slots(doc: &mut Document, nodes: &mut Vec<Arc<Node>>, content: &mut std:
|
|||
&& !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);
|
||||
fill_slots(doc, c, content, names, empty);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
|
@ -976,22 +527,50 @@ fn plan(
|
|||
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);
|
||||
/// `doc` with its layers that don't print hidden, when it has any.
|
||||
fn as_printed(doc: &Document) -> Option<Document> {
|
||||
fn hide(nodes: &mut [Arc<Node>]) -> bool {
|
||||
let mut any = false;
|
||||
for n in nodes {
|
||||
if matches!(n.kind, NodeKind::Layer { printable: false, .. }) && n.visible {
|
||||
Arc::make_mut(n).visible = false;
|
||||
any = true;
|
||||
} else if matches!(n.kind, NodeKind::Layer { .. })
|
||||
&& let Some(c) = Arc::make_mut(n).children_mut()
|
||||
{
|
||||
any |= hide(c);
|
||||
}
|
||||
}
|
||||
any
|
||||
}
|
||||
let mut d = doc.clone();
|
||||
hide(&mut d.layers).then_some(d)
|
||||
}
|
||||
|
||||
/// The lightness (0 to 255, Rec. 601 weights) of a premultiplied pixel seen on white.
|
||||
fn lightness(p: &[u8; 4]) -> u8 {
|
||||
let on_white = |c: u8| u32::from(c) + 255 - u32::from(p[3]).min(255);
|
||||
((on_white(p[0]) * 299 + on_white(p[1]) * 587 + on_white(p[2]) * 114) / 1000).min(255) as u8
|
||||
}
|
||||
|
||||
/// How much two documents draw differently (0 to 1), `a` on `ra` and `b` on `rb` (rectangles of
|
||||
/// the same size), each seen on white. Pixels are compared by lightness: the page of an RGB
|
||||
/// document is written in the colours' CMYK equivalents, and what the comparison looks for is art
|
||||
/// that is missing or out of place.
|
||||
fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> f64 {
|
||||
// The page doesn't print what isn't printed.
|
||||
let printed = as_printed(b);
|
||||
let b = printed.as_ref().unwrap_or(b);
|
||||
let scale = (400.0 / ra.width().max(ra.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));
|
||||
// Drawn on nothing and then seen on white: blending modes have no page to blend with, as in
|
||||
// the app that wrote the file.
|
||||
let (x, y) = (r.render_region(a, ra, scale, false), r.render_region(b, rb, scale, false));
|
||||
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();
|
||||
let differing =
|
||||
x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).filter(|(p, q)| lightness(p).abs_diff(lightness(q)) > 48).count();
|
||||
differing as f64 / (f64::from(x.width) * f64::from(x.height)).max(1.0)
|
||||
}
|
||||
|
||||
|
|
@ -1010,49 +589,45 @@ enum Page {
|
|||
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 page (`visible`, the file read as its page) and the place of its area in the layers'
|
||||
/// document.
|
||||
struct Frame<'a> {
|
||||
page: &'a Document,
|
||||
/// The page's area in `page`.
|
||||
page_rect: Rect,
|
||||
/// The same area in the layers' document.
|
||||
rect: Rect,
|
||||
}
|
||||
|
||||
/// 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")?,
|
||||
/// The document of the editing copy `data`, with the text (and strokes round it) of `visible`, the
|
||||
/// file read as its page, put in its text objects → the document, its notes and where the page is
|
||||
/// in it.
|
||||
fn build(data: &[u8], visible: &Document, page: Page) -> Result<(Document, Vec<String>, Rect), String> {
|
||||
let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, warnings } = ai::read(data)?;
|
||||
let art_box = match page {
|
||||
Page::Art => bbox.map(|[a, b, c, d]| Rect::new(a, b, c, d)).ok_or("its editing data doesn't say where its art is")?,
|
||||
Page::Artboard => 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 {
|
||||
let page_rect = visible.artboards.first().map(|a| a.rect).ok_or("it has no page")?;
|
||||
if (art_box.width() - page_rect.width()).abs() > 0.5 || (art_box.height() - page_rect.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)));
|
||||
}
|
||||
// The page's area in the layers' document, and the page's type moved there.
|
||||
let rect = to_doc.transform_rect_bbox(art_box);
|
||||
let shift = Affine::translate(Vec2::new(rect.x0 - page_rect.x0, rect.y0 - page_rect.y0));
|
||||
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")?;
|
||||
for object in shown.iter_mut().chain(hidden.iter_mut()) {
|
||||
for n in object.iter_mut() {
|
||||
Arc::make_mut(n).transform(shift, false);
|
||||
}
|
||||
}
|
||||
let mut infos = vec![];
|
||||
collect_slots(&layers, true, &mut infos);
|
||||
collect_slots(&doc.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());
|
||||
}
|
||||
|
|
@ -1073,21 +648,26 @@ fn build(data: &[u8], visible: &Document, base: Document, page: Page) -> Result<
|
|||
if stories.iter().flatten().map(|s| s.len()).sum::<usize>() > MAX_TEXT {
|
||||
return Err("it has too much text".into());
|
||||
}
|
||||
let template = parsed.template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0));
|
||||
let mut notes = vec![];
|
||||
let template = template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0));
|
||||
let mut notes = warnings;
|
||||
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);
|
||||
}
|
||||
let mut layers = std::mem::take(&mut doc.layers);
|
||||
fill_slots(&mut doc, &mut layers, &mut content.into_iter(), &slot_names, &mut empty);
|
||||
doc.layers = layers;
|
||||
if empty > 0 {
|
||||
out.warn(&format!("{empty} {TEXT_LEFT_OUT}"));
|
||||
notes.push(format!("{empty} {TEXT_LEFT_OUT}"));
|
||||
}
|
||||
if !off.is_empty() {
|
||||
out.warn(&format!("{ART_LEFT_OUT} ({}), left out of them", few(off)));
|
||||
if !hidden_unread.is_empty() {
|
||||
notes.push(format!("{ART_LEFT_OUT} ({}), left out of them", few(hidden_unread)));
|
||||
}
|
||||
Ok(Built { out, layers })
|
||||
let frame = Frame { page: visible, page_rect, rect };
|
||||
let unseen = prune_unseen(&frame, &mut doc);
|
||||
if unseen > 0 {
|
||||
notes.push(format!("{unseen} {UNSEEN_TEXT}"));
|
||||
}
|
||||
notes.extend(compare(&frame, &doc)?);
|
||||
Ok((doc, notes, rect))
|
||||
}
|
||||
|
||||
/// Marks (in a node's name, until [`prune_unseen`] has looked) the text objects made from the file's
|
||||
|
|
@ -1104,11 +684,12 @@ const UNSEEN_MARGIN: f64 = 5e-5;
|
|||
fn unmark(nodes: &mut [Arc<Node>], shown: bool, found: &mut Vec<(NodeId, Option<Rect>)>) {
|
||||
for n in nodes {
|
||||
let shown = shown && n.visible;
|
||||
if n.name.as_deref() == Some(MADE) {
|
||||
if let Some(name) = n.name.as_deref().and_then(|m| m.strip_prefix(MADE)) {
|
||||
if shown {
|
||||
found.push((n.id, n.visual_bounds()));
|
||||
}
|
||||
Arc::make_mut(n).name = None;
|
||||
let name = (!name.is_empty()).then(|| name.to_string());
|
||||
Arc::make_mut(n).name = name;
|
||||
} else if !matches!(n.kind, NodeKind::Compound { .. })
|
||||
&& let Some(c) = Arc::make_mut(n).children_mut()
|
||||
{
|
||||
|
|
@ -1136,14 +717,14 @@ fn take_out(nodes: &mut Vec<Arc<Node>>, id: NodeId, hide: bool) -> bool {
|
|||
/// has no type to match them, and that the layers look no worse without, are not drawn by the app
|
||||
/// that wrote the file (which paints every shown text object; it keeps the type of what was turned
|
||||
/// to outlines): they are taken out. How many.
|
||||
fn prune_unseen(page: &Document, doc: &mut Document) -> usize {
|
||||
fn prune_unseen(frame: &Frame<'_>, doc: &mut Document) -> usize {
|
||||
let mut found = vec![];
|
||||
unmark(&mut doc.layers, true, &mut found);
|
||||
let Some(rect) = page.artboards.first().map(|a| a.rect) else { return 0 };
|
||||
let rect = frame.rect;
|
||||
if found.is_empty() || found.len() > MAX_WEIGHED {
|
||||
return 0;
|
||||
}
|
||||
let mut with = difference(page, doc, rect);
|
||||
let mut with = difference(frame.page, frame.page_rect, doc, rect);
|
||||
let mut removed = 0;
|
||||
for (id, bounds) in found {
|
||||
if bounds.is_none_or(|b| b.intersect(rect).is_zero_area()) {
|
||||
|
|
@ -1153,7 +734,7 @@ fn prune_unseen(page: &Document, doc: &mut Document) -> usize {
|
|||
if !take_out(&mut without.layers, id, true) {
|
||||
continue;
|
||||
}
|
||||
let d = difference(page, &without, rect);
|
||||
let d = difference(frame.page, frame.page_rect, &without, rect);
|
||||
if d - with <= UNSEEN_MARGIN {
|
||||
take_out(&mut doc.layers, id, false);
|
||||
with = d;
|
||||
|
|
@ -1165,9 +746,8 @@ fn prune_unseen(page: &Document, doc: &mut Document) -> usize {
|
|||
|
||||
/// 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);
|
||||
fn compare(frame: &Frame<'_>, layered: &Document) -> Result<Option<String>, String> {
|
||||
let diff = difference(frame.page, frame.page_rect, layered, frame.rect);
|
||||
if diff > MAX_DIFFERENCE {
|
||||
return Err(format!("the art on them differs from the page's in {:.0}% of it", diff * 100.0));
|
||||
}
|
||||
|
|
@ -1180,38 +760,20 @@ fn compare(page: &Document, layered: &Document) -> Result<Option<String>, String
|
|||
}
|
||||
|
||||
/// 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], mut visible: Imported) -> Imported {
|
||||
let Some(data) = decode(ps) else {
|
||||
// A file that says it has the editing copy but has none that can be read is a loss too.
|
||||
let has = |n: &[u8]| ps.windows(n.len()).any(|w| w == n);
|
||||
if has(BEGIN) && (has(STREAM) || has(STREAM_ZLIB)) {
|
||||
visible.warnings.push(format!("{LAYERS_UNREAD} (its editing data is damaged or too large): it comes in as its page, in one layer"));
|
||||
}
|
||||
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 unseen = prune_unseen(&visible.document, &mut done.document);
|
||||
if unseen > 0 {
|
||||
done.warnings.push(format!("{unseen} {UNSEEN_TEXT}"));
|
||||
}
|
||||
let note = compare(&visible.document, &done.document)?;
|
||||
done.warnings.extend(note);
|
||||
Ok(done)
|
||||
});
|
||||
/// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the
|
||||
/// strokes around it of the page. When the file has no editing copy, `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) = ai::eps_data(ps) else { return visible };
|
||||
let result = data.and_then(|data| build(&data, &visible.document, Page::Art));
|
||||
match result {
|
||||
Ok(mut done) => {
|
||||
Ok((document, mut warnings, _)) => {
|
||||
for w in &visible.warnings {
|
||||
if !done.warnings.contains(w) {
|
||||
done.warnings.push(w.clone());
|
||||
if !warnings.contains(w) {
|
||||
warnings.push(w.clone());
|
||||
}
|
||||
}
|
||||
done
|
||||
Imported { document, warnings, preview: false }
|
||||
}
|
||||
Err(why) => {
|
||||
let mut v = visible;
|
||||
|
|
@ -1221,25 +783,17 @@ pub(super) fn layered(ps: &[u8], mut visible: Imported) -> Imported {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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'.
|
||||
/// The `.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, its artboards, and the art that lies outside them, 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 mut done = finish_layers(built.out, built.layers);
|
||||
let unseen = prune_unseen(&visible, &mut done);
|
||||
if unseen > 0 {
|
||||
notes.push(format!("{unseen} {UNSEEN_TEXT}"));
|
||||
}
|
||||
notes.extend(compare(&visible, &done)?);
|
||||
Ok((done, notes))
|
||||
});
|
||||
match result {
|
||||
Ok((done, notes)) => {
|
||||
let Ok(data) = ai::decode_private(private) else { return (visible, warnings) };
|
||||
if !data.windows(15).any(|w| w == b"%AI5_BeginLayer") {
|
||||
return (visible, warnings);
|
||||
}
|
||||
match build(&data, &visible, Page::Artboard) {
|
||||
Ok((done, notes, _)) => {
|
||||
let mut all = warnings;
|
||||
all.extend(notes.into_iter().filter(|n| !all.contains(n)).collect::<Vec<_>>());
|
||||
(done, all)
|
||||
|
|
@ -1252,12 +806,6 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>) -> (
|
|||
}
|
||||
}
|
||||
|
||||
fn finish_layers(out: Out, layers: Vec<Arc<Node>>) -> Document {
|
||||
let mut doc = out.doc;
|
||||
doc.layers = layers;
|
||||
doc
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -95,7 +95,8 @@ fn layers_come_in_with_their_names_colours_and_hidden_state() {
|
|||
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]), .. }));
|
||||
// Colour 0 of the layer colours (light blue), by its place in the list.
|
||||
assert!(matches!(&d.layers[0].kind, NodeKind::Layer { color: LayerColor::Preset(0), .. }));
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
// The page is where the page's art was.
|
||||
let b = d.layers[0].children().unwrap()[0].visual_bounds().unwrap();
|
||||
|
|
@ -143,13 +144,13 @@ fn sublayers_groups_compounds_clip_groups_and_hidden_objects_come_in() {
|
|||
|
||||
#[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))));
|
||||
// An operator that isn't read (`Zq`) on a layer that shows: the layers would draw without it.
|
||||
let shown = native(&layer("Art", true, &format!("{}\n0 0 0 1 0 0 Zq", square(10, 10))));
|
||||
let r = import(&eps(&page_square(10, 10), &shown)).unwrap();
|
||||
assert_eq!(names(&r.document), ["Layer 1"]);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("layers weren't read") && w.contains("`Bg`")), "{:?}", r.warnings);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("layers weren't read") && w.contains("`Zq`")), "{:?}", r.warnings);
|
||||
// On a hidden layer it only costs that layer's look.
|
||||
let hidden = native(&(layer("Art", true, &square(10, 10)) + &layer("Old", false, "0 0 0 1 0 0 Bg")));
|
||||
let hidden = native(&(layer("Art", true, &square(10, 10)) + &layer("Old", false, "0 0 0 1 0 0 Zq")));
|
||||
let r = import(&eps(&page_square(10, 10), &hidden)).unwrap();
|
||||
assert_eq!(names(&r.document), ["Art", "Old"]);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("hidden layers have art this can't read")), "{:?}", r.warnings);
|
||||
|
|
@ -166,9 +167,8 @@ fn text_and_its_strokes_come_from_the_page_into_the_layer_of_its_text_object() {
|
|||
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);
|
||||
// The text and the path of its stroke, where the text object is (no group of their own).
|
||||
assert_eq!(kinds(&r.document.layers[1]), ["text", "path"], "{:?}", r.warnings);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
|
|
@ -238,14 +238,15 @@ fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() {
|
|||
}
|
||||
|
||||
#[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.
|
||||
fn a_gray_is_a_grayscale_colour() {
|
||||
// `g` is a gray: 0.25 is 75 % of the black ink, as the page's own CMYK black says; the colour
|
||||
// stays in its grayscale model.
|
||||
let art = native(&layer("Art", true, "0.25 g\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf"));
|
||||
let page = "0 0 0 0.75 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto 10 14 lineto closepath fill";
|
||||
let r = import(&eps(page, &art)).unwrap();
|
||||
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
|
||||
let fill = r.document.layers[0].children().unwrap()[0].appearance.fill().and_then(|f| f.paint.color());
|
||||
assert_eq!(fill, Some(vectorcraft_color::Color::cmyk(0.0, 0.0, 0.0, 0.75)));
|
||||
assert_eq!(fill, Some(vectorcraft_color::Color::gray(0.75)));
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
|
|
@ -378,7 +379,7 @@ fn shown_type_keeps_the_pages_and_type_off_the_page_comes_from_the_text_document
|
|||
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.
|
||||
// The page's own object, and the other made from the file.
|
||||
let all: Vec<String> = {
|
||||
let mut v = vec![];
|
||||
r.document.walk(|n| {
|
||||
|
|
@ -390,7 +391,7 @@ fn shown_type_keeps_the_pages_and_type_off_the_page_comes_from_the_text_document
|
|||
};
|
||||
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_eq!(texts.len(), 2, "the page's type is in the layer as it is, not in a group");
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("order the page paints")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
|
|
@ -422,13 +423,12 @@ fn a_line_the_page_draws_in_pieces_goes_to_its_text_object_whole() {
|
|||
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi) show 70 61.5 moveto (there) show";
|
||||
let r = import(&eps(page, &art)).unwrap();
|
||||
// One text, with the line's space between the pieces.
|
||||
let group = &r.document.layers[0].children().unwrap()[0];
|
||||
assert_eq!(kinds(group), ["text"], "{:?}", r.warnings);
|
||||
let texts: Vec<String> =
|
||||
group.children().unwrap().iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect();
|
||||
let art = &r.document.layers[0];
|
||||
assert_eq!(kinds(art), ["text"], "{:?}", r.warnings);
|
||||
let texts: Vec<String> = text_of(art).iter().map(|t| t.plain_text()).collect();
|
||||
assert_eq!(texts, ["Hi there"]);
|
||||
// Its bounds are laid out afresh for the whole text, not those of the first piece.
|
||||
let NodeKind::Text(t) = &group.children().unwrap()[0].kind else { panic!("text") };
|
||||
let NodeKind::Text(t) = &art.children().unwrap()[0].kind else { panic!("text") };
|
||||
assert!(t.cached_bounds.is_none() && t.cached_baselines.is_empty());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -700,7 +700,7 @@ impl Player {
|
|||
return self.warn(&deep_clips());
|
||||
}
|
||||
self.next_clip += 1;
|
||||
self.dc.clips.push(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)), hidden: false }));
|
||||
self.dc.clips.push(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)) }));
|
||||
}
|
||||
|
||||
/// SelectClipPath (`mode`: 1 and, 5 copy; the others cut out, which isn't read).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
//! Hand-written Illustrator editing data (the format `vectorcraft_eps`'s reader documents) and the
|
||||
//! EPS and `.ai` files that carry it, for import tests. Nothing here comes from files another app
|
||||
//! wrote.
|
||||
|
||||
/// Editing data of a `width` × `height` page: header comments (the art's bounding box and the crop
|
||||
/// marks are the page), `body` (setup and layers), the trailer.
|
||||
pub fn editing_data(width: f64, height: f64, body: &str) -> String {
|
||||
format!(
|
||||
"{PS_HEADER}\n%%Creator: VectorCraft tests\n%%BoundingBox: 0 0 {width} {height}\n%%HiResBoundingBox: 0 0 {width} {height}\n\
|
||||
%AI5_FileFormat 14.0\n%AI3_Cropmarks: 0 0 {width} {height}\n%%EndComments\n\
|
||||
%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n{body}\n%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// The first line of a PostScript file (the DSC version comment).
|
||||
const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header comment
|
||||
/// The `PieceInfo` key the private data streams are under.
|
||||
const PIECE: &str = "Illustrator"; // brand-ok: the key the files use
|
||||
|
||||
/// A layer named `name` (shown, unlocked, printing, light blue) holding `art`.
|
||||
pub fn layer(name: &str, art: &str) -> String {
|
||||
format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n0 A\n0 Xw\n{art}\nLB\n%AI5_EndLayer--\n")
|
||||
}
|
||||
|
||||
/// The path of the rectangle `x0 y0 x1 y1` (art space: y up).
|
||||
pub fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> String {
|
||||
format!("{x0} {y0} m\n{x0} {y1} L\n{x1} {y1} L\n{x1} {y0} L\n{x0} {y0} L\n")
|
||||
}
|
||||
|
||||
/// `name` given to the object before it (its XML id).
|
||||
pub fn named(name: &str) -> String {
|
||||
format!("%_/ArtDictionary :\n%_/XMLUID : ({}) ; (AI10_ArtUID) ,\n%_;\n%_\n", name.replace(' ', "_"))
|
||||
}
|
||||
|
||||
/// A document of 200 × 100 pt with a bit of everything the reader reads. A layer `Art` that shows:
|
||||
/// a group `Pair` (a red square `Red` and a hidden one), a compound path `Ring`, a clipping group
|
||||
/// `Window` (a grey square seen through a smaller one), what [`page_ps`] and [`page_pdf`] draw. A
|
||||
/// hidden layer `Hidden`: a radially shaded square `Shaded`, a screened square at half opacity, a
|
||||
/// locked sublayer `Sub`, and a 2 × 1 image with an alpha channel.
|
||||
pub fn sample_data() -> Vec<u8> {
|
||||
let red = "0 1 1 0 1 0 0 Xa\n";
|
||||
let gradient =
|
||||
"%AI5_BeginGradient: (Glow)\n(Glow) 1 2 Bd\n[\n%_0 0 0 0 1 1 1 2 1 6 50 0 Bs\n%_0 0 0 1 0 0 0 2 0.5 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
|
||||
let group = format!(
|
||||
"0 Ae\nu\n{red}{}f\n{}1 Xw\n0 0 1 0 0 0 1 XA\n2 w\n{}b\n0 Xw\nU\n{}9 () XW\n",
|
||||
rect(10.0, 50.0, 40.0, 90.0),
|
||||
named("Red"),
|
||||
rect(50.0, 50.0, 80.0, 90.0),
|
||||
named("Pair")
|
||||
);
|
||||
let compound = format!("0 Ae\n*u\n{red}1 XR\n{}f\n{}f\n*U\n{}", rect(100.0, 50.0, 140.0, 90.0), rect(110.0, 60.0, 130.0, 80.0), named("Ring"));
|
||||
let clip =
|
||||
format!("0 Ae\nq\n0.5 g\n{}f\n{}h\nW\nn\nQ\n{}9 () XW\n", rect(150.0, 50.0, 190.0, 90.0), rect(160.0, 60.0, 180.0, 80.0), named("Window"));
|
||||
let shaded = format!(
|
||||
"{}Bb\n1 (Glow) 0 0 0 1 1 0 0 1 0 0 1 Bg\n20 0 0 -20 30 25 Bm\nf\n0 BB\n{}2 0.5 0 0 0 Xy\n{red}{}f\n0 1 0 0 0 Xy\n",
|
||||
rect(10.0, 5.0, 50.0, 45.0),
|
||||
named("Shaded"),
|
||||
rect(60.0, 5.0, 90.0, 45.0)
|
||||
);
|
||||
let sub = "%AI5_BeginLayer\n1 1 0 1 0 0 1 2 79 255 79 0 50 0 Lb\n(Sub) Ln\n0 g\n10 10 m\n30 30 L\nS\nLB\n%AI5_EndLayer--\n";
|
||||
let mut image = b"%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceRGB XN\n\
|
||||
[ 20 0 0 20 100 40 ] 2 1 0 Xh\n[ 20 0 0 20 100 40 ] 0 0 2 1 2 1 8 3 1 17 1 0 4 4 0 0\n%%BeginData: 10\rXI\n"
|
||||
.to_vec();
|
||||
image.extend_from_slice(&[255, 0, 0, 0, 0, 255, 255, 128]);
|
||||
image.extend_from_slice(b"%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\r\n");
|
||||
let head = format!(
|
||||
"{gradient}{}%AI5_BeginLayer\n0 1 1 1 0 0 0 1 255 79 79 0 50 0 Lb\n(Hidden) Ln\n{shaded}{sub}",
|
||||
layer("Art", &format!("{group}{compound}{clip}"))
|
||||
);
|
||||
let mut body = head.into_bytes();
|
||||
body.extend_from_slice(&image);
|
||||
body.extend_from_slice(b"LB\n%AI5_EndLayer--\n");
|
||||
let mut data = editing_data(200.0, 100.0, "").into_bytes();
|
||||
let at = data.windows(14).position(|w| w == b"%%PageTrailer\n").unwrap_or(data.len());
|
||||
data.splice(at..at, body);
|
||||
data
|
||||
}
|
||||
|
||||
/// The sample's page in PostScript (what shows of it).
|
||||
pub fn page_ps() -> &'static str {
|
||||
"1 0 0 setrgbcolor 10 50 30 40 rectfill\n\
|
||||
newpath 100 50 moveto 140 50 lineto 140 90 lineto 100 90 lineto closepath 110 60 moveto 130 60 lineto 130 80 lineto 110 80 lineto closepath eofill\n\
|
||||
0.5 setgray 160 60 20 20 rectfill\n"
|
||||
}
|
||||
|
||||
/// The sample's page as PDF content (what shows of it).
|
||||
pub fn page_pdf() -> &'static str {
|
||||
"1 0 0 rg 10 50 30 40 re f 100 50 40 40 re 110 60 20 20 re f* 0.5 g 160 60 20 20 re f"
|
||||
}
|
||||
|
||||
/// An Illustrator EPS: its page drawing `page` (PostScript, 200 × 100 pt), and the editing data
|
||||
/// `data` after it, not compressed.
|
||||
pub fn eps(data: &[u8], page: &str) -> Vec<u8> {
|
||||
let mut out = format!(
|
||||
"{PS_HEADER} EPSF-3.0\n%%Creator: VectorCraft tests\n%%BoundingBox: 0 0 200 100\n%%HiResBoundingBox: 0 0 200 100\n%%EndComments\n{page}showpage\n%%EOF\n%AI9_PrivateDataBegin\n"
|
||||
)
|
||||
.into_bytes();
|
||||
out.extend_from_slice(data);
|
||||
out.extend_from_slice(b"\n%AI9_PrivateDataEnd\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// An Illustrator `.ai` file: a 200 × 100 pt PDF page drawing `page` (PDF content), carrying
|
||||
/// `private` (the private data streams' contents, such as `%AI12_CompressedData` and zlib data) in
|
||||
/// two streams.
|
||||
pub fn ai(private: &[u8], page: &str) -> Vec<u8> {
|
||||
let (first, second) = private.split_at(private.len() / 2);
|
||||
let stream = |part: &[u8]| {
|
||||
let mut s = format!("<< /Length {} >>\nstream\n", part.len()).into_bytes();
|
||||
s.extend_from_slice(part);
|
||||
s.extend_from_slice(b"\nendstream");
|
||||
s
|
||||
};
|
||||
let objects: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R \
|
||||
/Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> \
|
||||
/PieceInfo << /{PIECE} << /Private << /AIPrivateData1 5 0 R /AIPrivateData2 6 0 R /NumBlock 2 >> >> >> >>"
|
||||
)
|
||||
.into_bytes(),
|
||||
stream(page.as_bytes()),
|
||||
stream(first),
|
||||
stream(second),
|
||||
];
|
||||
let mut out = b"%PDF-1.5\n".to_vec();
|
||||
let mut offsets = vec![];
|
||||
for (i, o) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend(format!("{} 0 obj\n", i + 1).bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).bytes());
|
||||
for off in offsets {
|
||||
out.extend(format!("{off:010} 00000 n \n").bytes());
|
||||
}
|
||||
out.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).bytes());
|
||||
out
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
//! - [`raster`]: rendering helpers and image comparison with a perceptual tolerance.
|
||||
//! - [`geom`]: geometry assertions (approximate equality, curve sampling, Hausdorff distance).
|
||||
//! - [`pdf`]: hand-written PDF files (page boxes, colour spaces, encryption) for import tests.
|
||||
//! - [`ai`]: hand-written Illustrator editing data, and the EPS and `.ai` files that carry it.
|
||||
//! - [`ase`]: hand-written swatch exchange (`.ase`) files for swatch library tests.
|
||||
//!
|
||||
//! This crate may only be used as a dev-dependency (enforced by `cargo xtask layers`).
|
||||
|
|
@ -19,6 +20,7 @@
|
|||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod ai;
|
||||
pub mod ase;
|
||||
pub mod fixtures;
|
||||
pub mod geom;
|
||||
|
|
|
|||
|
|
@ -3660,6 +3660,22 @@ axis Both Ambos
|
|||
@msg x and y must both be numbers x e y deben ser números
|
||||
@msg "{0}" is not a colour: write it as #rrggbb "{0}" no es un color: escríbelo como #rrggbb
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead esto no es un logotipo de colores planos ({_1}% de sus zonas sólidas coinciden con unos pocos colores planos), así que el modo Logotipo plano convertiría sus degradados en manchas: usa el modo Color
|
||||
@msg `{what}` closes nothing `{what}` no cierra nada
|
||||
@msg `{what}` closes something it didn't open `{what}` cierra algo que no abrió
|
||||
@msg a path has too many points un trazado tiene demasiados puntos
|
||||
@msg it ends inside a layer terminan dentro de una capa
|
||||
@msg it has art outside its layers tienen ilustraciones fuera de sus capas
|
||||
@msg it has more than {MAX_NODES} objects tienen más de {MAX_NODES} objetos
|
||||
@msg it has no layers no tienen capas
|
||||
@msg it has too many values in a row tienen demasiados valores seguidos
|
||||
@msg it has {_1}, which VectorCraft doesn't read from it yet tienen {_1}, que VectorCraft aún no lee de ellos
|
||||
@msg its objects are nested too deeply sus objetos están anidados a demasiada profundidad
|
||||
@msg its section ending with `%{end}` doesn't end su sección que termina con `%{end}` no termina
|
||||
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet los objetos con varios rellenos o trazos, efectos o pinceles se importaron con su aspecto dibujado ({_1}): su aspecto aún no se puede editar
|
||||
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it el degradado “{name}” no está definido en el archivo: lo sustituye un color sólido
|
||||
@msg an image VectorCraft can't read was left out se omitió una imagen que VectorCraft no puede leer
|
||||
@msg an image whose data is cut short was left out se omitió una imagen con datos incompletos
|
||||
@msg its editing data doesn't say where its art is sus datos de edición no dicen dónde está su ilustración
|
||||
|
||||
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
|
||||
Services Servicios
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3657,6 +3657,22 @@ axis Both Les deux
|
|||
@msg x and y must both be numbers x et y doivent tous deux être des nombres
|
||||
@msg "{0}" is not a colour: write it as #rrggbb « {0} » n’est pas une couleur : écrivez-la sous la forme #rrggbb
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead ce n’est pas un logo en aplats ({_1} % de ses zones pleines correspondent à quelques couleurs unies), donc le mode Logo en aplats transformerait ses dégradés en taches : utilisez le mode Couleur
|
||||
@msg `{what}` closes nothing `{what}` ne ferme rien
|
||||
@msg `{what}` closes something it didn't open `{what}` ferme quelque chose qu’il n’a pas ouvert
|
||||
@msg a path has too many points un tracé a trop de points
|
||||
@msg it ends inside a layer elles se terminent à l’intérieur d’un calque
|
||||
@msg it has art outside its layers elles contiennent des illustrations hors de leurs calques
|
||||
@msg it has more than {MAX_NODES} objects elles contiennent plus de {MAX_NODES} objets
|
||||
@msg it has no layers elles n’ont aucun calque
|
||||
@msg it has too many values in a row elles contiennent trop de valeurs à la suite
|
||||
@msg it has {_1}, which VectorCraft doesn't read from it yet elles contiennent {_1}, que VectorCraft n’y lit pas encore
|
||||
@msg its objects are nested too deeply leurs objets sont imbriqués trop profondément
|
||||
@msg its section ending with `%{end}` doesn't end leur section qui se termine par `%{end}` ne se termine pas
|
||||
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet les objets à plusieurs fonds ou contours, effets ou formes ont été importés tels qu’ils sont dessinés ({_1}) : leur aspect n’est pas encore modifiable
|
||||
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it le dégradé « {name} » n’est pas défini dans le fichier : une couleur unie le remplace
|
||||
@msg an image VectorCraft can't read was left out une image que VectorCraft ne peut pas lire a été ignorée
|
||||
@msg an image whose data is cut short was left out une image aux données tronquées a été ignorée
|
||||
@msg its editing data doesn't say where its art is ses données d’édition ne disent pas où se trouve son illustration
|
||||
|
||||
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
|
||||
Services Services
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3660,6 +3660,22 @@ axis Both Entrambi
|
|||
@msg x and y must both be numbers x e y devono essere entrambi numeri
|
||||
@msg "{0}" is not a colour: write it as #rrggbb "{0}" non è un colore: scrivilo come #rrggbb
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead questo non è un logo a colori piatti ({_1}% delle sue aree piene corrisponde a pochi colori piatti), quindi la modalità Logo piatto trasformerebbe i suoi sfumati in macchie: usa la modalità Colore
|
||||
@msg `{what}` closes nothing `{what}` non chiude nulla
|
||||
@msg `{what}` closes something it didn't open `{what}` chiude qualcosa che non ha aperto
|
||||
@msg a path has too many points un tracciato ha troppi punti
|
||||
@msg it ends inside a layer terminano all'interno di un livello
|
||||
@msg it has art outside its layers contengono grafica al di fuori dei loro livelli
|
||||
@msg it has more than {MAX_NODES} objects contengono più di {MAX_NODES} oggetti
|
||||
@msg it has no layers non hanno livelli
|
||||
@msg it has too many values in a row contengono troppi valori di seguito
|
||||
@msg it has {_1}, which VectorCraft doesn't read from it yet contengono {_1}, che VectorCraft non vi legge ancora
|
||||
@msg its objects are nested too deeply i loro oggetti sono annidati troppo in profondità
|
||||
@msg its section ending with `%{end}` doesn't end la loro sezione che termina con `%{end}` non termina
|
||||
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet gli oggetti con più riempimenti o tratti, effetti o pennelli sono stati importati come appaiono disegnati ({_1}): il loro aspetto non è ancora modificabile
|
||||
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it la sfumatura “{name}” non è definita nel file: la sostituisce un colore pieno
|
||||
@msg an image VectorCraft can't read was left out un'immagine che VectorCraft non può leggere è stata omessa
|
||||
@msg an image whose data is cut short was left out un'immagine con dati incompleti è stata omessa
|
||||
@msg its editing data doesn't say where its art is i suoi dati di modifica non dicono dove si trova la grafica
|
||||
|
||||
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
|
||||
Services Servizi
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3656,6 +3656,22 @@ axis Both Оба
|
|||
@msg x and y must both be numbers x и y должны быть числами
|
||||
@msg "{0}" is not a colour: write it as #rrggbb «{0}» не является цветом: запишите его как #rrggbb
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead это не логотип из плоских цветов ({_1}% его сплошных областей совпадает с несколькими плоскими цветами), поэтому режим «Плоский логотип» превратил бы градиенты в пятна: используйте режим «Цвет»
|
||||
@msg `{what}` closes nothing `{what}` ничего не закрывает
|
||||
@msg `{what}` closes something it didn't open `{what}` закрывает то, что не открывал
|
||||
@msg a path has too many points в контуре слишком много точек
|
||||
@msg it ends inside a layer они заканчиваются внутри слоя
|
||||
@msg it has art outside its layers в них есть графика вне слоёв
|
||||
@msg it has more than {MAX_NODES} objects в них больше {MAX_NODES} объектов
|
||||
@msg it has no layers в них нет слоёв
|
||||
@msg it has too many values in a row в них слишком много значений подряд
|
||||
@msg it has {_1}, which VectorCraft doesn't read from it yet в них есть {_1}, которые VectorCraft пока не читает
|
||||
@msg its objects are nested too deeply объекты в них вложены слишком глубоко
|
||||
@msg its section ending with `%{end}` doesn't end раздел, заканчивающийся на `%{end}`, не заканчивается
|
||||
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet объекты с несколькими заливками или обводками, эффектами или кистями импортированы в нарисованном виде ({_1}): их оформление пока нельзя редактировать
|
||||
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it градиент «{name}» не определён в файле: вместо него используется сплошной цвет
|
||||
@msg an image VectorCraft can't read was left out изображение, которое VectorCraft не может прочитать, пропущено
|
||||
@msg an image whose data is cut short was left out изображение с обрезанными данными пропущено
|
||||
@msg its editing data doesn't say where its art is в его данных редактирования не указано, где находится графика
|
||||
|
||||
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
|
||||
Services Службы
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
15
docs/mcp.md
15
docs/mcp.md
|
|
@ -407,6 +407,17 @@ and `flattener` options over it (as `object.flattenTransparency` takes them); im
|
|||
transparency and rasterized areas are clipped to their regions. A file that would still have transparency fails the
|
||||
export. PDF/X-1a and PDF/X-3 files are PDF 1.3 files too.
|
||||
|
||||
An Illustrator EPS (version 9 on) or `.ai` file opens from the editing data it carries (after the EPS page; in the
|
||||
`.ai` file's PDF private data): layers and sublayers (name, order, visibility, lock, printing, preview, dimming,
|
||||
colour), groups as they were nested, compound paths, clipping groups, object names, hidden and locked objects, fills and
|
||||
strokes (spot colours as spot swatches), linear and radial gradients, opacity, blend modes, isolation and knockout,
|
||||
embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or
|
||||
strokes, effects or a brush comes in as its drawn look (a group named after it). Type that shows is the page's, in its
|
||||
text object's place; hidden point type is made from the file's text document. A file whose editing data has symbols,
|
||||
pattern fills, placed files or anything else the reader doesn't read on a layer that shows, or whose layers look
|
||||
different from its page, opens as before: an EPS as its printed page, a `.ai` file from its PDF content (where plain
|
||||
groups open ungrouped), with a warning saying why.
|
||||
|
||||
Opening a PDF (or `.ai`) imports every page as an artboard and layer; `document.open` takes `pages` ("2-3, 5", 1-based),
|
||||
`cropTo` (`bounding` (the art's bounds), `art`, `crop` (default), `trim`, `bleed`, `media`: the box each artboard gets)
|
||||
and `password` for an encrypted file. `document.pdfInfo` reads a file without opening it: the page count, each page's
|
||||
|
|
@ -2128,9 +2139,7 @@ runs their data; `flushfile` skips it), axial and radial shadings and shading pa
|
|||
masks (data in the file through ASCII85, hex, run-length, Flate, LZW or DCT filters, or from procedures), and type as
|
||||
point type in the font the file names (embedded font programs are skipped). In a file in Illustrator's own format
|
||||
(Illustrator 3–8 `.ai` and their EPS, written with the prolog that defines its operators) the groups it writes (`u` …
|
||||
`U`) come in as groups, nested as they were; clips and groups nest at most 128 deep. A PDF-compatible `.ai` doesn't
|
||||
mark its plain groups (only layers, clipping groups and groups with opacity, blending or a mask), so they open
|
||||
ungrouped. The artboard is the `%%HiResBoundingBox`
|
||||
`U`) come in as groups, nested as they were; clips and groups nest at most 128 deep. The artboard is the `%%HiResBoundingBox`
|
||||
(else `%%BoundingBox`; a letter page without one). A program the interpreter can't run (an operator it doesn't know,
|
||||
an error, a runaway loop) or that draws nothing comes in as its TIFF preview (palette previews with an alpha channel
|
||||
too) with a warning; without a preview, the art drawn up to the error is kept with a warning, and a file with none is
|
||||
|
|
|
|||
Loading…
Reference in New Issue