Files
vectorcraft/crates/eps/src/import/mod.rs
T
bflatasticandClaude Opus 5.5 9f3b992ecc EPS import: other apps' EPS open as vectors, a preview that stands in says why
Discord feedback: "EPS files are always rasterized: they open as bitmaps, in
other programs as vectors". Every EPS saved by Illustrator 2026 fell back to
its TIFF preview (`gcheck` was unknown), and cairo/Inkscape files stopped at
their first image (`status`), dropped masked images (ImageType 3) and failed
on mesh gradients; the warning didn't say where the program failed.

Fallback never silent:
- The warning names the PostScript error, the operator that raised it and
  the named procedures it ran in, innermost first ("a PostScript error
  (typecheck in `add`), in `inner` in `outer`"); errors caught by `stopped`
  aren't reported. It reaches the status bar, document.open, app.open and
  file.open (which now return document.open's result, so MCP's open_file over
  the control channel gets it), and `vectorcraft-cli info` ("warnings").
- A file whose PostScript can't be read opens as its TIFF preview, else its
  Windows metafile preview (as vectors, scaled to the bounding box), else its
  EPSI bitmap. es @msg rows for the new messages.

Interpreter:
- Operators programs probe or use: gcheck/scheck, rootfont,
  currentcolorrendering, currenthsbcolor, status, token, read,
  bytesavailable, fileposition/setfileposition, resetfile, write*, pdfmark
  (marks dropped, as Ghostscript does), execform, nulldevice, strokepath,
  pathforall, user paths (ufill/ueofill/ustroke/uappend/upath/setbbox, the
  encoded form too), setcachedevice2, findcolorrendering, cache, device and
  VM queries, revision; (%stdout) (w) file gives a sink.
- `//name` is replaced by its value when read, inside procedures too.
- Tiling patterns become pattern swatches (their PaintProc drawn in pattern
  space, one cell; uncoloured ones in the colour under them; makepattern
  copies share one swatch); nested patterns stop at 8 levels.
- Type 3 fonts draw their glyphs (BuildGlyph/BuildChar, widths from
  setcachedevice/setcharwidth) as outlines grouped per string, spaced by
  ashow/widthshow/awidthshow/xshow/yshow/xyshow/kshow; glyphshow and
  stringwidth/cshow use them.
- Shadings of types 1 and 4-7 become gradient meshes (data from arrays,
  strings or files, packed or not, flags sharing edges), as fills through a
  shading pattern too; sampled functions (type 0) are evaluated;
  GradientMesh::coons/triangle are shared with PDF import.
- ImageType 3 masked images, interleaved by sample, by row or in separate
  sources; ReusableStreamDecode reads its data when made; Flate/LZW PNG and
  TIFF predictors.
- Bounded: DeviceN at 32 inks, sampled functions at 2^24 samples and 32
  outputs, 4096 patches a shading, strokepath ignores dashes finer than 1e5
  pieces, Type 3 glyphs nest 8 deep (a glyph that shows itself overflowed the
  stack before the limit), 64-bit index maths for 32-bit targets.

Tests: a synthetic program per generator (cairo/Inkscape, its Type 3 fonts,
matplotlib, CorelDRAW, Affinity, LibreOffice, Ghostscript eps2write,
Illustrator procsets), meshes and function shadings, masked images, fault
messages, probing operators, metafile/EPSI previews, `//name`, user paths,
hostile patterns/glyphs/shadings (run against main, 12 of the 14 generator
and feature tests fail; CorelDRAW- and LibreOffice-style programs already
read there). The import fuzz vocabulary covers the new operators. Checked
against real files made here with Inkscape 1.4 (cairo) and Illustrator 2026
(its own format and the CS-to-CC compatibility formats; all fell back or
stopped early on main and open as vectors now; its Illustrator 8 format
needs the next commit); those files aren't committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:23:36 +07:00

232 lines
9.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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.
//!
//! Clipped art comes in as clipping groups. In an Illustrator file (Illustrator 3–8, written with
//! the prolog that defines its operators), the groups it writes (`u` … `U`, nested) come in as
//! groups.
//!
//! 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 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 data;
mod graphics;
mod interp;
mod lex;
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;
use std::sync::Arc;
use vectorcraft_color::Paint;
use vectorcraft_color::swatch::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 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>,
/// Written by Illustrator (or in its format): `%AI…` comments, or Illustrator the creator.
illustrator: 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 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
}
}
/// What went wrong, for the user (and for us, reading a report): the PostScript error, 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) => match fault.and_then(|f| f.op) {
Some(op) if at.is_empty() || at == op => format!("a PostScript error ({name} in `{op}`)"),
Some(op) => format!("a PostScript error ({name} in `{op}`: {at})"),
None if at.is_empty() => format!("a PostScript error ({name})"),
None => format!("a PostScript error ({name} in `{at}`)"),
},
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).
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 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);
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) => return Ok(finish(out)),
(Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(),
(Err(e), _) => reason(e, fault.as_ref()),
};
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(e) => Err(format!("this PostScript file can't be read ({}): save it as PDF or SVG in the app that made it", reason(&e, fault.as_ref()))),
}
}
/// 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 doc = out.doc;
if out.cmyk > out.rgb {
doc.color_mode = ColorMode::Cmyk;
(doc.swatches, doc.swatch_groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model());
}
for (name, color) in out.spots {
if doc.swatch(&name).is_none() {
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 doc.swatch(&name).is_none() {
doc.swatches.push(Swatch { paint: Paint::Pattern { pattern: name.clone(), xf: Affine::IDENTITY }, name, global: false, spot: false });
}
}
let mut layer = Node::layer(doc.alloc_id(), "Layer 1", LayerColor::Preset(0));
if let Some(c) = layer.children_mut() {
*c = children;
}
doc.layers = vec![Arc::new(layer)];
Imported { document: doc, warnings: out.warnings, preview: false }
}