Part of #637: Illustrator EPS and .ai files open from their editing data

An Illustrator EPS (version 9 on) carries the document as the app keeps
it after its printed page (%AI9_PrivateDataBegin: Zstandard or zlib data
in ASCII85 comment lines, or plain), and a .ai file in its first page's
/PieceInfo /Illustrator /Private streams (%AI24_ZStandard_Data,
%AI12_CompressedData or plain, after header comments). Both now open from
it: layers and sublayers (name, order, visibility, lock, printing,
preview, dimming, colour), groups as they were nested, compound paths,
clipping groups and layer clipping, object names, hidden and locked
objects, fills and strokes (grey, CMYK, RGB, spot colours as spot
swatches, caps, joins, dashes, fill rule), linear and radial gradients,
opacity, blend modes, isolation and knockout, embedded images with their
alpha channel, guides and every artboard where it is.

Objects with several fills or strokes, effects or brushes come in as
their drawn look, named after them, with a warning. Type, symbols,
pattern fills, placed files and operators the reader doesn't know are
errors: the file opens as before (the EPS page, the .ai file's PDF
content) with a first warning saying what was left out. A .ai opened with
pages picked, and a .pdf, read the PDF as before.

Reading is bounded (512 MB decompressed, nesting, objects, points, stack,
image sizes checked against their data) and fuzzed in import_fuzz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-09 14:52:31 +07:00
co-authored by Claude Opus 5.5
parent f149f10059
commit 4550b89051
24 changed files with 3188 additions and 14 deletions
Generated
+1
View File
@@ -4476,6 +4476,7 @@ dependencies = [
"flate2",
"image",
"kurbo 0.13.1",
"ruzstd",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
+5 -5
View File
File diff suppressed because one or more lines are too long
+14 -1
View File
@@ -171,7 +171,20 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
.then(|| vectorcraft_pdf::editing_with(bytes, opts.password.as_deref()))
.flatten()
.map(|e| (e.intact, move || Some(e.data)));
restore_or_import(editing, || super::pdfimport::import(bytes, opts))?
restore_or_import(editing, || {
// An Illustrator file: the document its editing data holds, else its PDF.
let whole = opts.pages.is_none() && matches!(format.id, "ai" | "ait");
let why = match whole.then(|| vectorcraft_pdf::ai_private_data(bytes, opts.password.as_deref())).flatten() {
None => return super::pdfimport::import(bytes, opts),
Some(raw) => match raw.and_then(|raw| vectorcraft_eps::import_ai(&raw)) {
Ok(i) => return Ok((i.document, i.warnings)),
Err(why) => why,
},
};
let (doc, mut warnings) = super::pdfimport::import(bytes, opts)?;
warnings.insert(0, vectorcraft_eps::editing_fallback(&why, vectorcraft_eps::PDF_CONTENT));
Ok((doc, warnings))
})?
}
"dxf" => {
let (doc, warnings) = super::dxfimport::import(bytes, &opts.dxf)?;
+3
View File
@@ -936,6 +936,9 @@ mod tests_psd;
#[cfg(test)]
mod tests_epsimport;
#[cfg(test)]
mod tests_aiimport;
#[cfg(test)]
mod tests_pdflayers;
@@ -0,0 +1,103 @@
//! 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)
}
fn zlib(data: &[u8]) -> Vec<u8> {
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(data).unwrap();
e.finish().unwrap()
}
/// The `.ai` private data of `data`, compressed as such files compress it.
fn compressed(data: &[u8]) -> Vec<u8> {
[&b"%AI12_CompressedData"[..], &zlib(data)].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`] builds it.
fn check_sample(s: &Session) {
let doc = &s.doc().unwrap().doc;
assert_eq!(names(&doc.layers), ["Art", "Hidden", "Images"]);
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", "Shaded", ""]);
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, .. })));
assert!(matches!(art[3].appearance.fill().map(|f| &f.paint), Some(vectorcraft_color::Paint::Gradient(_))));
assert_eq!((art[4].opacity, art[4].blend), (0.5, vectorcraft_color::BlendMode::Screen));
let hidden = &doc.layers[1];
assert!(!hidden.visible);
let sub = &hidden.children().unwrap()[0];
assert!(sub.locked && matches!(sub.kind, NodeKind::Layer { .. }));
let image = &doc.layers[2].children().unwrap()[0];
let NodeKind::Image(im) = &image.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()), 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 r = open(&mut s, "art.ai", &ai::ai(&compressed(&ai::sample_data())), json!({})).unwrap();
assert_eq!((&r["format"], &r["warnings"]), (&json!("ai"), &json!([])), "{r}");
check_sample(&s);
// Pages picked: the PDF's.
let r = open(&mut s, "art.ai", &ai::ai(&compressed(&ai::sample_data())), json!({"pages": "1"})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers).len(), 1, "{r}");
// A PDF that happens to carry the data opens as a PDF.
open(&mut s, "art.pdf", &ai::ai(&compressed(&ai::sample_data())), json!({})).unwrap();
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden", "Images"]);
}
#[test]
fn what_the_reader_doesnt_read_opens_as_before_with_a_warning() {
let text = ai::editing_data(200.0, 100.0, &ai::layer("Words", "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n"));
let mut s = Session::new();
let r = open(&mut s, "words.eps", &ai::eps(text.as_bytes()), json!({})).unwrap();
let first = r["warnings"][0].as_str().unwrap();
assert!(first.contains("editing data couldn't be read") && first.contains("type") && first.contains("printed page"), "{r}");
assert!(s.doc().unwrap().doc.layers.iter().any(|l| l.children().is_some_and(|c| !c.is_empty())), "the page's line");
let r = open(&mut s, "words.ai", &ai::ai(&compressed(text.as_bytes())), json!({})).unwrap();
let first = r["warnings"][0].as_str().unwrap();
assert!(first.contains("type") && first.contains("PDF content"), "{r}");
// Damaged data says so.
let r = open(&mut s, "damaged.ai", &ai::ai(b"%AI12_CompressedData not zlib at all"), json!({})).unwrap();
assert!(r["warnings"][0].as_str().unwrap().contains("zlib"), "{r}");
}
+178 -1
View File
@@ -4,7 +4,8 @@
//! must load as an error or as a document that then renders and exports, without a panic; nor may
//! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records
//! of every kind with random contents) opened, placed or pasted, nor EPS and PostScript files
//! (damaged ones, hostile programs) read by the PostScript interpreter, nor Affinity documents
//! (damaged ones, hostile programs) read by the PostScript interpreter, nor the editing data of
//! Illustrator EPS and `.ai` files (damaged, hostile operators and sections), nor Affinity documents
//! (mutated object streams, archives and indexed PNG previews, hostile image dimensions).
//!
//! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test import_fuzz` runs a deeper search.
@@ -1295,6 +1296,182 @@ proptest! {
}
}
// ---------- Illustrator editing data ----------
/// 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 :",
"/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 sample editing data, compressed as `.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", || vectorcraft_eps::import_ai(data.as_bytes()).ok().map(|r| r.document))?;
let eps = ai::eps(data.as_bytes());
survive("hostile editing data in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?;
}
/// Damaged editing data, as it is and compressed in a `.ai` file.
#[test]
fn ai_mutated_editing_data_never_panics(cut in 0usize..4_000, edits in prop::collection::vec((0usize..4_000, any::<u8>()), 0..12)) {
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(vectorcraft_testkit::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));
survive(what, || vectorcraft_eps::import_ai(&bytes).ok().map(|r| r.document))?;
let file = vectorcraft_testkit::ai::ai(&bytes);
survive(what, || vectorcraft_engine::cmd::fileio::load("x.ai", &file).ok().map(|l| l.doc))?;
}
}
}
// ---------- DXF ----------
/// The rich document exported as DXF (2018: hatches, splines, text, images; R12: polylines).
+2
View File
@@ -22,6 +22,8 @@ vectorcraft-render = { workspace = true }
kurbo = { workspace = true }
image = { workspace = true }
flate2 = { workspace = true }
# Illustrator's editing data (Zstandard): pure Rust, builds for wasm.
ruzstd = "0.9"
[dev-dependencies]
vectorcraft-testkit = { workspace = true }
+333
View File
@@ -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);
}
}
}
+158
View File
@@ -0,0 +1,158 @@
//! 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;
#[cfg(test)]
mod tests;
use std::io::Read as _;
use super::lex::find;
use crate::ps;
pub use read::read;
/// Largest editing data read, decompressed (bytes).
pub const MAX_DATA: u64 = 512 << 20;
/// Largest Zstandard window accepted.
const MAX_WINDOW: u64 = 128 << 20;
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 => {
let mut src = data;
// The data may be several frames one after the other.
while src.iter().any(|b| *b != 0) {
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_WINDOW)
.map_err(|_| damaged("its Zstandard compression"))?;
let before = out.len();
d.take(left).read_to_end(&mut out).map_err(|_| damaged("its Zstandard compression"))?;
if out.len() == before {
break;
}
}
}
}
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)
}
+208
View File
@@ -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()), "ü");
}
}
+277
View File
@@ -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
+393
View File
@@ -0,0 +1,393 @@
//! 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::*;
use crate::import::Imported;
/// 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) -> Imported {
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).document;
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_)).document;
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_)).document;
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_)).document;
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)))).document;
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_)).document;
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_))).document;
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_))).document;
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().document;
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.document);
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().document;
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().document;
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().document;
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.document).is_empty() && r.warnings.iter().any(|w| w.contains("cut short")));
}
#[test]
fn what_it_doesnt_read_is_an_error() {
let text = layer("/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n");
let e = read(stream(&text).as_bytes()).unwrap_err();
assert!(e.contains("type"), "{e}");
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}");
// 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")))).document;
assert_eq!(art(&doc).len(), 1);
}
#[test]
fn cmyk_documents() {
let doc = read_ok(&format!("%AI9_ColorModel: 2\n{}", layer(""))).document;
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.document).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");
let i = crate::import(&file).unwrap();
assert_eq!(i.document.layers.len(), 1);
assert_eq!(i.document.layers[0].name.as_deref(), Some("L"));
assert!(i.warnings.is_empty(), "{:?}", i.warnings);
}
// Damaged data: the page, with a warning that says why.
let i = crate::import(&eps("%AI24_DataStream", b"not zstd")).unwrap();
assert!(i.warnings[0].contains("editing data couldn't be read") && i.warnings[0].contains("Zstandard"), "{:?}", i.warnings);
// An EPS without editing data is read as before.
let plain = 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(plain).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!(crate::import_ai(&raw).is_ok());
}
assert!(decode_private(b"%AI12_CompressedDataxx").is_err());
}
+34 -1
View File
@@ -24,6 +24,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 data;
mod graphics;
mod interp;
@@ -159,12 +160,44 @@ fn reason(e: &PsError, fault: Option<&Fault>) -> String {
}
}
/// Read an EPS or PostScript file (its first page).
/// Read an EPS or PostScript file: an Illustrator EPS from the editing data it carries ([`ai`]),
/// else its first page.
pub fn import(bytes: &[u8]) -> Result<Imported, String> {
let (ps, _) = crate::sections(bytes).ok_or("the file's preview header points outside the file")?;
if !ps.starts_with(b"%!") {
return Err("this is not a PostScript file".into());
}
let why = match ai::eps_data(ps) {
None => return page(bytes, ps),
Some(data) => match data.and_then(|d| ai::read(&d)) {
Ok(i) => return Ok(i),
Err(why) => why,
},
};
let mut r = page(bytes, ps)?;
r.warnings.insert(0, editing_fallback(&why, PRINTED_PAGE));
Ok(r)
}
/// What an Illustrator EPS whose editing data can't be read opens as.
const PRINTED_PAGE: &str = "its printed page: one layer, without hidden objects or hidden type";
/// What an Illustrator `.ai` file whose editing data can't be read opens as.
pub const PDF_CONTENT: &str = "its PDF content: groups flattened, without hidden objects or hidden type";
/// The warning for an Illustrator file whose editing data couldn't be read (`why`), opened as
/// `instead` ([`PDF_CONTENT`] for a `.ai` file).
pub fn editing_fallback(why: &str, instead: &str) -> String {
format!("this file's Illustrator editing data couldn't be read ({why}): it opened as {instead}")
}
/// Read an Illustrator `.ai` file from its editing data (`raw`: its `AIPrivateData` streams
/// joined).
pub fn import_ai(raw: &[u8]) -> Result<Imported, String> {
ai::read(&ai::decode_private(raw)?)
}
/// The first page of an EPS or PostScript file (`ps`: its PostScript).
fn page(bytes: &[u8], ps: &[u8]) -> Result<Imported, String> {
let dsc = Dsc::read(ps);
let [llx, lly, urx, ury] = dsc.bbox.unwrap_or(LETTER);
let frame = Rect::new(0.0, 0.0, urx - llx, ury - lly);
+1 -1
View File
@@ -34,7 +34,7 @@ mod ps;
mod scene;
mod tiff;
pub use import::{Imported, family_style, import};
pub use import::{Imported, PDF_CONTENT, editing_fallback, family_style, import, import_ai};
pub use print::{PrintJob, PrintPage, print};
use vectorcraft_doc::Document;
+1 -1
View File
@@ -70,7 +70,7 @@ pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing, editing_w
pub use encrypt::Encryption;
pub use export::{export, export_with_report, page_areas};
pub use import::{import, import_with_report};
pub use pages::{PageInfo, PdfInfo, info, is_postscript};
pub use pages::{PageInfo, PdfInfo, ai_private_data, info, is_postscript};
pub use post::{THUMBNAIL_SIZE, Thumbnail};
pub use presets::*;
pub use print::*;
+26 -1
View File
@@ -1,7 +1,7 @@
//! The pages of a PDF to import: opening with a password, the pages picked, the box each page
//! is cropped to ([`CropTo`]) and [`info`].
use hayro_syntax::object::Dict;
use hayro_syntax::object::{Dict, Stream};
use hayro_syntax::page::Page;
use hayro_syntax::{DecryptionError, LoadPdfError, Pdf};
use kurbo::{Affine, Rect};
@@ -136,3 +136,28 @@ pub(crate) fn has_private_data(page: &Page<'_>) -> bool {
}
page.raw().get::<Dict<'_>>(b"PieceInfo").is_some_and(|d| walk(&d, 0))
}
/// Most `AIPrivateData` streams read.
const MAX_PRIVATE_BLOCKS: usize = 1 << 16;
/// Largest private data read (bytes).
const MAX_PRIVATE: usize = 512 << 20;
/// The editor's private data the first page of a `.ai` file carries: its `/PieceInfo
/// /Illustrator /Private` streams `AIPrivateData1`, `2`, … joined. `None` without any, an error
/// when they can't be read.
pub fn ai_private_data(bytes: &[u8], password: Option<&str>) -> Option<Result<Vec<u8>, String>> {
let pdf = open(bytes, password).ok()?;
let pages = pdf.pages();
let page = pages.iter().next()?;
let private = page.raw().get::<Dict<'_>>(b"PieceInfo")?.get::<Dict<'_>>(b"Illustrator")?.get::<Dict<'_>>(b"Private")?;
let mut out = Vec::new();
for i in 1..=MAX_PRIVATE_BLOCKS {
let Some(stream) = private.get::<Stream<'_>>(format!("AIPrivateData{i}").as_bytes()) else { break };
let Ok(data) = stream.decoded() else { return Some(Err("one of its parts can't be decoded".into())) };
if out.len() + data.len() > MAX_PRIVATE {
return Some(Err(format!("it is larger than {} MB", MAX_PRIVATE >> 20)));
}
out.extend_from_slice(&data);
}
(!out.is_empty()).then_some(Ok(out))
}
+122
View File
@@ -0,0 +1,122 @@
//! 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: header comments (crop marks `0 0 width height`), `body` (setup and layers), the
/// trailer.
pub fn editing_data(width: f64, height: f64, body: &str) -> String {
format!(
"%!PS-Adobe-3.0\n%%Creator: VectorCraft tests\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"
)
}
/// 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` with a
/// named group (one member hidden), a compound path, a clipping group, a radial gradient and a
/// multiplied object, and a hidden layer `Hidden` with a locked sublayer `Sub` in it.
pub fn sample() -> String {
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}{}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{}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 hidden = format!("%AI5_BeginLayer\n0 1 1 1 0 0 0 1 255 79 79 0 50 0 Lb\n(Hidden) Ln\n{sub}LB\n%AI5_EndLayer--\n");
format!("{gradient}{}{hidden}", layer("Art", &format!("{group}{compound}{clip}{shaded}")))
}
/// A layer `Images` with a 2 × 1 RGB image with an alpha channel at (100, 40) in art space, 20 pt a
/// pixel.
pub fn image_layer() -> Vec<u8> {
let mut s = b"%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Images) Ln\n%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();
s.extend_from_slice(&[255, 0, 0, 0, 0, 255, 255, 128]);
s.extend_from_slice(b"%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\r\nLB\r\n%AI5_EndLayer--\r\n");
s
}
/// [`sample`] with [`image_layer`] on top, as the editing data of a 200 × 100 pt document.
pub fn sample_data() -> Vec<u8> {
let mut data = editing_data(200.0, 100.0, &sample()).into_bytes();
let at = data.windows(14).position(|w| w == b"%%PageTrailer\n").unwrap();
data.splice(at..at, image_layer());
data
}
/// An Illustrator EPS: a page drawing one line (what readers without the editing data see), and
/// the editing data `data` after it, not compressed.
pub fn eps(data: &[u8]) -> Vec<u8> {
let mut out = b"%!PS-Adobe-3.0 EPSF-3.0\n%%Creator: VectorCraft tests\n%%BoundingBox: 0 0 200 100\n%%EndComments\n\
0 0 moveto 200 100 lineto stroke\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n"
.to_vec();
out.extend_from_slice(data);
out.extend_from_slice(b"\n%AI9_PrivateDataEnd\n");
out
}
/// An Illustrator `.ai` file: a PDF page drawing one line, carrying `data` (the private data
/// streams' contents, such as `%AI12_CompressedData` and zlib data) in two streams.
pub fn ai(data: &[u8]) -> Vec<u8> {
let (first, second) = data.split_at(data.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(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R /Resources << >> \
/PieceInfo << /Illustrator << /Private << /AIPrivateData1 5 0 R /AIPrivateData2 6 0 R /NumBlock 2 >> >> >> >>"
.to_vec(),
stream(b"0 0 m 200 100 l S"),
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
}
+2
View File
@@ -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;
+20
View File
@@ -3640,6 +3640,26 @@ axis Both Ambos
@msg {tool} isn't in a toolbar group of several tools {tool} no está en un grupo de la barra de herramientas con varias herramientas
@msg onto must name a panel floating in another group onto debe nombrar un panel que flote en otro grupo
@msg x and y must both be numbers x e y deben ser números
@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 it is larger than {_1} MB son mayores de {_1} MB
@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 one of its parts can't be decoded una de sus partes no se puede decodificar
@msg this file's Illustrator editing data couldn't be read ({why}): it opened as {instead} no se pudieron leer los datos de edición de Illustrator de este archivo ({why}): se abrió como {instead}
@msg its printed page: one layer, without hidden objects or hidden type su página impresa: una sola capa, sin objetos ni textos ocultos
@msg its PDF content: groups flattened, without hidden objects or hidden type su contenido PDF: grupos deshechos, sin objetos ni textos ocultos
@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
# 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.
+20
View File
@@ -3637,6 +3637,26 @@ axis Both Les deux
@msg {tool} isn't in a toolbar group of several tools {tool} ne fait pas partie d’un groupe de plusieurs outils dans la barre d’outils
@msg onto must name a panel floating in another group onto doit désigner un panneau flottant dans un autre groupe
@msg x and y must both be numbers x et y doivent tous deux être des nombres
@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 it is larger than {_1} MB elles dépassent {_1} Mo
@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 one of its parts can't be decoded l’une de leurs parties ne peut pas être décodée
@msg this file's Illustrator editing data couldn't be read ({why}): it opened as {instead} impossible de lire les données d’édition Illustrator de ce fichier ({why}) : il s’est ouvert comme {instead}
@msg its printed page: one layer, without hidden objects or hidden type sa page imprimée : un seul calque, sans objets ni textes masqués
@msg its PDF content: groups flattened, without hidden objects or hidden type son contenu PDF : groupes dissociés, sans objets ni textes masqués
@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
# 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.
+20
View File
@@ -3640,6 +3640,26 @@ axis Both Entrambi
@msg {tool} isn't in a toolbar group of several tools {tool} non fa parte di un gruppo di più strumenti nella barra degli strumenti
@msg onto must name a panel floating in another group onto deve indicare un pannello mobile in un altro gruppo
@msg x and y must both be numbers x e y devono essere entrambi numeri
@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 it is larger than {_1} MB superano {_1} MB
@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 one of its parts can't be decoded una delle loro parti non può essere decodificata
@msg this file's Illustrator editing data couldn't be read ({why}): it opened as {instead} impossibile leggere i dati di modifica di Illustrator di questo file ({why}): è stato aperto come {instead}
@msg its printed page: one layer, without hidden objects or hidden type la sua pagina stampata: un solo livello, senza oggetti né testo nascosti
@msg its PDF content: groups flattened, without hidden objects or hidden type il suo contenuto PDF: gruppi separati, senza oggetti né testo nascosti
@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
# 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.
+20
View File
@@ -3636,6 +3636,26 @@ axis Both Оба
@msg {tool} isn't in a toolbar group of several tools {tool} не входит в группу панели инструментов из нескольких инструментов
@msg onto must name a panel floating in another group onto должен называть панель, плавающую в другой группе
@msg x and y must both be numbers x и y должны быть числами
@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 it is larger than {_1} MB они больше {_1} МБ
@msg its objects are nested too deeply объекты в них вложены слишком глубоко
@msg its section ending with `%{end}` doesn't end раздел, заканчивающийся на `%{end}`, не заканчивается
@msg one of its parts can't be decoded одну из их частей не удаётся декодировать
@msg this file's Illustrator editing data couldn't be read ({why}): it opened as {instead} не удалось прочитать данные редактирования Illustrator этого файла ({why}): он открыт как {instead}
@msg its printed page: one layer, without hidden objects or hidden type его печатная страница: один слой, без скрытых объектов и скрытого текста
@msg its PDF content: groups flattened, without hidden objects or hidden type его PDF-содержимое: группы разобраны, без скрытых объектов и скрытого текста
@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 изображение с обрезанными данными пропущено
# 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.
+10 -3
View File
@@ -378,6 +378,15 @@ 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). A file whose editing data has type,
symbols, pattern fills, placed files or anything else the reader doesn't read yet opens as before: an EPS as its printed
page, a `.ai` file from its PDF content (where plain groups open ungrouped), with a first warning saying what.
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
@@ -2098,9 +2107,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