mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
**What:** Illustrator type styled through the Appearance panel came in as its drawn look (outlined compound paths). Comic and "text effect" templates are the common case, and the type couldn't be retyped. This keeps it as editable type with the style as a live VectorCraft appearance. **Changes** - **Art styles parsed as a tree.** Compound filters close at their `;`, basic ones at `/Part`. Each fill and stroke in a stack keeps its own effects, opacity and blend mode, and object-level effects stay on the object. - **Type keeps any plain stack.** That covers several fills and strokes, pattern and gradient fills, Warp, Offset Path, Transform with copies, Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief. Bas Relief settings are left at their defaults, because Illustrator stores them in a Photoshop plug-in's binary block. Gradients and patterns are placed as the drawn look places them. Other objects keep the existing plain-style rule, since their drawn look is the more faithful option. - **Point type without a frame matrix.** Illustrator 24 omits the matrix when point type is neither rotated nor scaled. Such stories were left out entirely; they're now read with an identity matrix. - **Page comparison.** The page's rendering of a style isn't held to match VectorCraft's live rendering where the replaced drawn look lay, and styled made type isn't pruned as unseen. - **EPS override.** `editingData: "always"` / `convert --editing-data` now applies to Illustrator EPS as well as `.ai`. **Tests:** 6 new tests, written from scratch; `vectorcraft-eps` passes all 298 on current main. clippy `-D warnings` and rustfmt are clean for eps, engine and cli. `docs/file-format-parity.md` has been updated, including its revision history. **Clean-room note:** I found the bugs with a user's licensed stock EPS, used locally only; nothing from it is committed. Filter and key meanings come from the public Illustrator Effect docs and public LiveEffect scripting examples (`jntp`, the Warp style order). **Not included:** VectorCraft's pixel effects treat transparent content as empty instead of using Document Raster Effects Settings › Background (white by default). Because of that, Bas Relief on a transparent pattern gives flat grey. That needs a separate renderer change. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
341 lines
16 KiB
Rust
341 lines
16 KiB
Rust
//! EPS and PostScript import: a small clean-room PostScript interpreter that turns what a file
|
||
//! draws into document objects.
|
||
//!
|
||
//! - `lex`: the scanner; `obj`: objects, operators and errors; `interp`: the stacks, running
|
||
//! procedures, and the operators that don't draw.
|
||
//! - `graphics`: the graphics state, coordinates, paths, colours (grey, RGB, CMYK, spot inks as
|
||
//! spot swatches at a tint, indexed, patterns), painting, clipping, axial and radial shadings as
|
||
//! gradients and tiling patterns as pattern swatches.
|
||
//! - `shading`: function-based and mesh shadings as gradient meshes, sampled functions.
|
||
//! - `data`: data the program reads from itself through decoding filters, and images.
|
||
//! - `text`: fonts by name and type as point type; Type 3 fonts' glyphs drawn by their procedures.
|
||
//! - `preview`: the previews a file carries, for when its PostScript can't be read.
|
||
//! - `native`: the layers of an Illustrator file, from the editing copy of its art that it carries.
|
||
//!
|
||
//! Clipped art comes in as clipping groups. In a file in the legacy Illustrator format (versions
|
||
//! 3–8, with the prolog that defines its operators), the group operators `u` … `U` (nested) come in
|
||
//! as groups. The format's published specification documents its header comments (`%AI…`,
|
||
//! `%%Creator`) and these operators: Adobe Illustrator File Format Specification, version 7.0
|
||
//! (1998), listed by PRONOM at https://www.nationalarchives.gov.uk/PRONOM/fmt/423. A file in that
|
||
//! format whose program has its layers (`%AI5_BeginLayer`) comes in through them, as `native` reads
|
||
//! an Illustrator EPS's editing copy (#1027). One VectorCraft wrote (its prolog the procedure set
|
||
//! [`crate::legacy_ai::PROCSET`], which prints its gradients flat and leaves its type out) comes in
|
||
//! from those layers alone, without its page.
|
||
//!
|
||
//! The page is the file's `%%BoundingBox` (`%%HiResBoundingBox` when it has one), the first page
|
||
//! of a PostScript file without one. A file whose program can't be read (an operator the
|
||
//! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in from its
|
||
//! layers when its program has them (a `.ai` in PostScript form whose prolog is named but not
|
||
//! included, as other apps write it), or with its art on one layer when it is in the legacy format
|
||
//! without layers (Illustrator 3, as image editors still export their paths), else as its preview
|
||
//! (a TIFF image, a Windows metafile or an EPSI bitmap), with a warning that names the 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;
|
||
mod interp;
|
||
mod lex;
|
||
mod native;
|
||
mod obj;
|
||
mod preview;
|
||
mod shading;
|
||
mod text;
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|
||
#[cfg(test)]
|
||
mod tests_fontnames;
|
||
#[cfg(test)]
|
||
mod tests_generators;
|
||
#[cfg(test)]
|
||
mod tests_illustrator;
|
||
#[cfg(test)]
|
||
mod tests_images;
|
||
#[cfg(test)]
|
||
mod tests_native;
|
||
|
||
use std::collections::HashSet;
|
||
use std::sync::Arc;
|
||
|
||
use vectorcraft_color::Paint;
|
||
use vectorcraft_color::swatch::{REGISTRATION, Swatch};
|
||
use vectorcraft_doc::{ColorMode, Document, LayerColor, Node};
|
||
use vectorcraft_geom::{Affine, Rect};
|
||
|
||
use graphics::{GState, Out};
|
||
use interp::{Fault, Interp};
|
||
use obj::PsError;
|
||
|
||
pub use native::{ai_alone, is_loss, layered_ai};
|
||
pub use text::family_style;
|
||
|
||
/// The page of a PostScript file without a bounding box (US Letter).
|
||
const LETTER: [f64; 4] = [0.0, 0.0, 612.0, 792.0];
|
||
/// Largest page side read (points).
|
||
const MAX_SIDE: f64 = 1e5;
|
||
|
||
/// A read file.
|
||
#[derive(Debug)]
|
||
pub struct Imported {
|
||
pub document: Document,
|
||
pub warnings: Vec<String>,
|
||
/// The document shows the file's preview: its PostScript couldn't be read.
|
||
pub preview: bool,
|
||
}
|
||
|
||
/// The DSC comments the reader uses.
|
||
#[derive(Debug, Default, PartialEq)]
|
||
struct Dsc {
|
||
/// `llx lly urx ury` in PostScript's default space.
|
||
bbox: Option<[f64; 4]>,
|
||
pages: Option<u32>,
|
||
/// In the legacy Illustrator format: its header has `%AI…` comments or names the app as the
|
||
/// creator (see the module docs for the specification).
|
||
illustrator: bool,
|
||
/// Its prolog is the one VectorCraft's legacy `.ai` writer includes
|
||
/// ([`crate::legacy_ai::PROCSET`] in `%%DocumentSuppliedResources`).
|
||
own_legacy: bool,
|
||
}
|
||
|
||
/// The four numbers of a bounding box comment's value, when it has them and they make a box.
|
||
fn box_of(v: &str) -> Option<[f64; 4]> {
|
||
let n: Vec<f64> = v.split_whitespace().map_while(|w| w.parse::<f64>().ok()).collect();
|
||
let [x0, y0, x1, y1] = n.as_slice() else { return None };
|
||
let b = [*x0, *y0, *x1, *y1];
|
||
(b.iter().all(|v| v.is_finite() && v.abs() <= MAX_SIDE) && x1 > x0 && y1 > y0).then_some(b)
|
||
}
|
||
|
||
impl Dsc {
|
||
/// The comments of `ps`: from the header, or (`(atend)`) the last ones in the file.
|
||
fn read(ps: &[u8]) -> Self {
|
||
let mut d = Dsc::default();
|
||
let (mut bbox, mut hires, mut atend) = (None, None, false);
|
||
let mut header = true;
|
||
for raw in ps.split(|b| matches!(b, b'\n' | b'\r')).filter(|l| !l.is_empty()) {
|
||
// Past the header only comments can matter.
|
||
if !header && !raw.starts_with(b"%%") {
|
||
continue;
|
||
}
|
||
let line = String::from_utf8_lossy(raw);
|
||
let line = line.trim_end();
|
||
if header && (line.starts_with("%%EndComments") || !line.starts_with('%')) {
|
||
header = false;
|
||
if !atend {
|
||
break;
|
||
}
|
||
}
|
||
let value = |key: &str| line.strip_prefix(key).map(str::trim);
|
||
let creator = value("%%Creator:").is_some_and(|v| v.contains("Illustrator")); // brand-ok: the creator its files name
|
||
if header && (line.starts_with("%AI") || line.starts_with("%%AI") || creator) {
|
||
d.illustrator = true;
|
||
}
|
||
if header && value("%%DocumentSuppliedResources:").is_some_and(|v| v.strip_prefix("procset ") == Some(crate::legacy_ai::PROCSET)) {
|
||
d.own_legacy = true;
|
||
}
|
||
if let Some(v) = value("%%HiResBoundingBox:") {
|
||
if header || atend {
|
||
hires = box_of(v).or(hires);
|
||
}
|
||
} else if let Some(v) = value("%%BoundingBox:") {
|
||
if v.starts_with("(atend)") {
|
||
atend = true;
|
||
} else if header || atend {
|
||
bbox = box_of(v).or(bbox);
|
||
}
|
||
} else if let Some(v) = value("%%Pages:")
|
||
&& header
|
||
{
|
||
d.pages = v.split_whitespace().next().and_then(|n| n.parse().ok());
|
||
}
|
||
}
|
||
d.bbox = hires.or(bbox);
|
||
d
|
||
}
|
||
}
|
||
|
||
/// The PostScript errors the reader raises and what each means, which import warnings and errors
|
||
/// show after the error's name. `undefined` is not listed: its message names the undefined name
|
||
/// and leaves the error's name out.
|
||
pub const POSTSCRIPT_ERRORS: &[(&str, &str)] = &[
|
||
("dictstackunderflow", "the program ends a dictionary it didn't begin"),
|
||
("invalidfileaccess", "the program opens a file, which the reader can't do"),
|
||
("invalidrestore", "the program restores a saved state that was already restored"),
|
||
("ioerror", "data the program reads is damaged or cut short, or the program asks for a position in a file that has none"),
|
||
("limitcheck", "the program goes past one of the reader's limits"),
|
||
("nocurrentpoint", "the path has no point to continue from"),
|
||
("rangecheck", "a value is outside the range the operator takes"),
|
||
("stackunderflow", "the operator needs more values than the stack holds"),
|
||
("syntaxerror", "part of the program isn't valid PostScript text"),
|
||
("typecheck", "a value has the wrong type for the operator"),
|
||
("undefinedfilename", "the program runs, deletes or renames a file, which the reader can't do"),
|
||
("undefinedresource", "the program asks for a resource that isn't defined"),
|
||
("undefinedresult", "a calculation has no result, as in a division by zero"),
|
||
("unmatchedmark", "the operator looks for a mark on the stack and finds none"),
|
||
];
|
||
|
||
/// What went wrong, for the user (and for us, reading a report): the PostScript error with what
|
||
/// it means ([`POSTSCRIPT_ERRORS`]), the operator that raised it and the procedure it ran in
|
||
/// (`fault`).
|
||
fn reason(e: &PsError, fault: Option<&Fault>) -> String {
|
||
let what = match e {
|
||
PsError::Ps("undefined", at) => format!("it uses `{at}`, which VectorCraft's PostScript reader doesn't know"),
|
||
PsError::Ps(name, at) => {
|
||
let detail = match fault.and_then(|f| f.op) {
|
||
Some(op) if at.is_empty() || at == op => format!("{name} in `{op}`"),
|
||
Some(op) => format!("{name} in `{op}`: {at}"),
|
||
None if at.is_empty() => name.to_string(),
|
||
None => format!("{name} in `{at}`"),
|
||
};
|
||
match POSTSCRIPT_ERRORS.iter().find(|(n, _)| n == name) {
|
||
Some((_, meaning)) => format!("a PostScript error ({detail}; {meaning})"),
|
||
None => format!("a PostScript error ({detail})"),
|
||
}
|
||
}
|
||
PsError::Limit(what) => return what.to_string(),
|
||
PsError::Exit | PsError::Stop => return "the program stopped with an error".into(),
|
||
PsError::Quit => return "the program ended early".into(),
|
||
};
|
||
match fault.map(|f| f.procs.as_slice()).unwrap_or_default() {
|
||
[] => what,
|
||
procs => {
|
||
let names: Vec<String> = procs.iter().map(|p| format!("`{p}`")).collect();
|
||
format!("{what}, in {}", names.join(" in "))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Read an EPS or PostScript file (its first page), an Illustrator EPS through the editing copy of
|
||
/// its art when it carries one (see `native`).
|
||
pub fn import(bytes: &[u8]) -> Result<Imported, String> {
|
||
import_with(bytes, true)
|
||
}
|
||
|
||
/// The PostScript of an EPS or PostScript file (behind its binary header, if it has one).
|
||
fn program(bytes: &[u8]) -> Result<&[u8], 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());
|
||
}
|
||
Ok(ps)
|
||
}
|
||
|
||
/// An EPS or PostScript file's page at the origin, read from its comments without running its
|
||
/// program: its bounding box (`%%HiResBoundingBox` when it has one), a US Letter page without
|
||
/// one. [`import_with`] runs the program on the same page when it reads what the page prints.
|
||
pub fn page(bytes: &[u8]) -> Result<Rect, String> {
|
||
let [llx, lly, urx, ury] = Dsc::read(program(bytes)?).bbox.unwrap_or(LETTER);
|
||
Ok(Rect::new(0.0, 0.0, urx - llx, ury - lly))
|
||
}
|
||
|
||
/// [`import`], an Illustrator EPS through the editing copy of its art only when `editing_data`
|
||
/// (else what its page prints, as any EPS).
|
||
pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String> {
|
||
import_trusting(bytes, editing_data, false)
|
||
}
|
||
|
||
/// [`import_with`]; `trust`: an Illustrator EPS's layers are used even where they draw differently
|
||
/// from its page, with a note (`editingData: "always"`, as for a `.ai`).
|
||
pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result<Imported, String> {
|
||
let ps = program(bytes)?;
|
||
let dsc = Dsc::read(ps);
|
||
// A legacy `.ai` VectorCraft wrote: its prolog prints the art plainly (gradients flat, no
|
||
// type), and its program has all of it, which is read as the format (as before the file
|
||
// printed).
|
||
if editing_data
|
||
&& dsc.own_legacy
|
||
&& let Some(Ok((document, warnings))) = native::program_alone(ps, dsc.illustrator)
|
||
{
|
||
return Ok(Imported { document, warnings, preview: false });
|
||
}
|
||
let [llx, lly, urx, ury] = dsc.bbox.unwrap_or(LETTER);
|
||
let frame = Rect::new(0.0, 0.0, urx - llx, ury - lly);
|
||
let mut doc = Document::new(frame.width(), frame.height());
|
||
doc.layers.clear();
|
||
let page = Affine::new([1.0, 0.0, 0.0, -1.0, -llx, ury]);
|
||
let mut it = Interp::new(ps, GState::default(), Out::new(doc, page, frame));
|
||
it.illustrator = dsc.illustrator;
|
||
let result = it.run();
|
||
it.release();
|
||
let fault = it.fault.take();
|
||
let mut out = it.out;
|
||
if let Some(n) = dsc.pages.filter(|n| *n > 1) {
|
||
out.warn(&format!("only the first of the file's {n} pages was read"));
|
||
}
|
||
let drew = !out.drawn.is_empty();
|
||
let why = match (&result, drew) {
|
||
(Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out), trust)),
|
||
(Ok(()), true) => return Ok(finish(out)),
|
||
(Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(),
|
||
(Err(e), _) => reason(e, fault.as_ref()),
|
||
};
|
||
// A file in the legacy Illustrator format whose program can't be run (a `.ai` that names the
|
||
// prolog defining its operators without including it): its layers, which the program has, or
|
||
// its art on one layer when it has none (Illustrator 3).
|
||
let why = match editing_data.then(|| native::program_alone(ps, dsc.illustrator)).flatten() {
|
||
Some(Ok((document, warnings))) => return Ok(Imported { document, warnings, preview: false }),
|
||
Some(Err(e)) => format!("{why}; its layers couldn't be read either ({e})"),
|
||
None => why,
|
||
};
|
||
if let Some(p) = preview::preview(bytes, ps, frame) {
|
||
let warnings = vec![format!("this file's PostScript couldn't be read ({why}): {}", p.what)];
|
||
return Ok(Imported { document: p.document, warnings, preview: true });
|
||
}
|
||
if drew {
|
||
out.warnings.insert(0, format!("the file was read up to an error ({why}): what follows it was left out"));
|
||
return Ok(finish(out));
|
||
}
|
||
match result {
|
||
Ok(()) => {
|
||
out.warn("the file draws nothing");
|
||
Ok(finish(out))
|
||
}
|
||
Err(_) => Err(format!("this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it")),
|
||
}
|
||
}
|
||
|
||
/// The document of what `out` drew: one layer, clipped art in clipping groups (grouped art in
|
||
/// groups), the spot inks as spot swatches, in CMYK when most process colours were.
|
||
fn finish(mut out: Out) -> Imported {
|
||
let drawn = std::mem::take(&mut out.drawn);
|
||
let mut children = vectorcraft_doc::clipnest::nest(&mut out.doc, drawn);
|
||
if !out.shadings.is_empty() {
|
||
graphics::collapse(&mut children, &out.shadings);
|
||
}
|
||
let mut layer = Node::layer(out.doc.alloc_id(), "Layer 1", LayerColor::Preset(0));
|
||
if let Some(c) = layer.children_mut() {
|
||
*c = children;
|
||
}
|
||
finish_with(out, vec![Arc::new(layer)])
|
||
}
|
||
|
||
/// The document of what `out` drew, as `layers`: the spot inks as spot swatches, in CMYK when
|
||
/// most process colours were.
|
||
fn finish_with(out: Out, layers: Vec<Arc<Node>>) -> Imported {
|
||
let mut doc = out.doc;
|
||
if out.cmyk > out.rgb {
|
||
doc.color_mode = ColorMode::Cmyk;
|
||
let (swatches, groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model());
|
||
(doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into());
|
||
}
|
||
// `taken` holds the names `Document::swatch` finds: each swatch's name, and Registration.
|
||
let mut taken: HashSet<String> = doc.swatches_iter().map(|s| s.name.clone()).chain([REGISTRATION.to_string()]).collect();
|
||
for (name, color) in out.spots {
|
||
if taken.insert(name.clone()) {
|
||
doc.swatches.push(Swatch { name, paint: Paint::solid(color), global: true, spot: true });
|
||
}
|
||
}
|
||
// Tiling patterns are pattern swatches.
|
||
for name in doc.patterns.iter().map(|p| p.name.clone()).collect::<Vec<_>>() {
|
||
if taken.insert(name.clone()) {
|
||
doc.swatches.push(Swatch { paint: Paint::Pattern { pattern: name.clone(), xf: Affine::IDENTITY }, name, global: false, spot: false });
|
||
}
|
||
}
|
||
doc.layers = layers;
|
||
let mut warnings = out.warnings;
|
||
doc.limit_depth_noting(&mut warnings);
|
||
Imported { document: doc, warnings, preview: false }
|
||
}
|