Files
vectorcraft/crates/eps/src/lib.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

412 lines
16 KiB
Rust

//! Encapsulated PostScript: a hand-written EPS writer, PostScript Level 2 and 3.
//!
//! [`export`] writes the visible art of a document as one EPS file: a DSC header with the
//! `%%BoundingBox` and `%%HiResBoundingBox` of [`EpsOptions::region`] (y up, from
//! [`EpsOptions::origin`]), paths filled and stroked with their caps, joins and dashes (strokes
//! a plain line can't draw as their filled outlines, brushed strokes as their brush art), clipping
//! groups, linear and radial gradients (smooth shadings at Level 3, stepped fills at Level 2 or
//! with compatible gradient printing), pattern fills as their tiles, freeform gradients and placed
//! images as images, type as glyph outlines, spot colours as Separation colour spaces and
//! overprints when preserved. RGB colours are written as CMYK in CMYK documents and, with
//! [`EpsOptions::cmyk`], in RGB ones.
//!
//! PostScript has no transparency: callers flatten it first. What is left (opacity, blending
//! modes, opacity masks, raster effects) is written opaque, and each such loss comes back as a
//! warning.
//!
//! The file can carry a TIFF preview ([`Preview`]) behind a binary header, a PNG thumbnail and the
//! native document, both in comments that PostScript interpreters skip ([`native`],
//! [`thumbnail`] read them back).
//!
//! [`import`] reads EPS and PostScript files back: the native document when the file carries one
//! is [`native`]'s job; other files are run through a small PostScript interpreter, or come in as
//! their TIFF preview.
//!
//! - `ps`: numbers, strings, paths and the data encodings (ASCII85, run-length, Flate).
//! - `scene`: the document walk that writes the page.
//! - `tiff`: the TIFF preview.
//! - `import`: the PostScript interpreter.
//! - `print`: print jobs as PostScript files ([`print()`]), their pages drawn by `scene`.
mod import;
mod print;
mod ps;
mod scene;
mod tiff;
pub use import::{Imported, family_style, import};
pub use print::{PrintJob, PrintPage, print};
use vectorcraft_doc::Document;
use vectorcraft_geom::{Point, Rect};
/// The PostScript language level the file needs.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
pub enum Level {
/// Gradients as stepped fills, images run-length encoded, image transparency written white.
Two,
/// Smooth shadings, Flate-compressed images, transparent image pixels masked out.
#[default]
Three,
}
impl Level {
pub const ALL: [Self; 2] = [Self::Two, Self::Three];
/// The `level` param value.
pub fn id(self) -> &'static str {
match self {
Self::Two => "2",
Self::Three => "3",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Two => "Language Level 2",
Self::Three => "Language Level 3",
}
}
/// A level by id (`2`, `3`, `level2`, `LanguageLevel 3`), any case.
pub fn from_id(s: &str) -> Option<Self> {
let digits: String = s.chars().filter(char::is_ascii_digit).collect();
Self::ALL.into_iter().find(|l| l.id() == digits)
}
}
/// The preview other apps show for the file: none, or a TIFF image behind a binary header.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Preview {
None,
/// One bit per pixel: black where the art is darker than mid-grey.
TiffBlackWhite,
/// 8 bits per channel, on white or with transparency ([`EpsOptions::transparent_preview`]).
#[default]
TiffColor,
}
impl Preview {
pub const ALL: [Self; 3] = [Self::None, Self::TiffBlackWhite, Self::TiffColor];
/// The `preview` param value.
pub fn id(self) -> &'static str {
match self {
Self::None => "none",
Self::TiffBlackWhite => "tiffBw",
Self::TiffColor => "tiffColor",
}
}
pub fn label(self) -> &'static str {
match self {
Self::None => "None",
Self::TiffBlackWhite => "TIFF (Black & White)",
Self::TiffColor => "TIFF (8-bit Color)",
}
}
pub fn from_id(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|p| s.trim().eq_ignore_ascii_case(p.id()))
}
}
/// What happens to overprinting fills and strokes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Overprint {
/// They overprint on press (`setoverprint`).
#[default]
Preserve,
/// They knock out like any other art.
Discard,
}
impl Overprint {
pub const ALL: [Self; 2] = [Self::Preserve, Self::Discard];
/// The `overprints` param value.
pub fn id(self) -> &'static str {
match self {
Self::Preserve => "preserve",
Self::Discard => "discard",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Preserve => "Preserve",
Self::Discard => "Discard",
}
}
pub fn from_id(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|o| s.trim().eq_ignore_ascii_case(o.id()))
}
}
/// An image the caller rendered: straight (not premultiplied) RGBA, row by row from the top.
#[derive(Clone, Debug, Default)]
pub struct Raster {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
/// The EPS export settings.
#[derive(Clone, Debug, Default)]
pub struct EpsOptions {
pub level: Level,
pub preview: Preview,
/// A colour preview keeps the art's transparency (else it is on white).
pub transparent_preview: bool,
pub overprint: Overprint,
/// Write RGB colours as CMYK (CMYK documents always are).
pub cmyk: bool,
/// Gradients as stepped fills at Level 3 too (for devices without smooth shading).
pub compatible_gradients: bool,
/// The area of the document the bounding box covers (document space, y down).
pub region: Rect,
/// The document point PostScript's origin is at (y up from it).
pub origin: Point,
/// `%%Title`.
pub title: String,
/// `%%CreationDate` as Unix seconds (UTC); `None`: left out.
pub created: Option<i64>,
/// The native document the file carries, for reopening it as it was.
pub native: Option<Vec<u8>>,
}
/// A written file.
#[derive(Clone, Debug, Default)]
pub struct EpsOutput {
pub bytes: Vec<u8>,
/// What was approximated or left out.
pub warnings: Vec<String>,
}
/// The `%%BoundingBox` of `region` with PostScript's origin at document point `origin`: whole
/// points enclosing it, y up (`[llx, lly, urx, ury]`).
pub fn bounding_box(region: Rect, origin: Point) -> [i64; 4] {
let [x0, y0, x1, y1] = hires_box(region, origin);
[x0.floor() as i64, y0.floor() as i64, x1.ceil() as i64, y1.ceil() as i64]
}
/// The `%%HiResBoundingBox` of `region` (see [`bounding_box`]).
fn hires_box(region: Rect, origin: Point) -> [f64; 4] {
let r = region.abs();
[r.x0 - origin.x, origin.y - r.y1, r.x1 - origin.x, origin.y - r.y0]
}
/// The document rect a preview must show: the whole-point [`bounding_box`], which it covers.
pub fn preview_rect(region: Rect, origin: Point) -> Rect {
let [x0, y0, x1, y1] = bounding_box(region, origin).map(|v| v as f64);
Rect::new(origin.x + x0, origin.y - y1, origin.x + x1, origin.y - y0)
}
/// Write `doc` as EPS with `opts`. `preview` is the art of [`preview_rect`] when
/// [`EpsOptions::preview`] asks for one, and `thumbnail` a small image of it to embed. Live
/// geometry effects are applied first; template layers, guides and hidden objects are left out.
pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumbnail: Option<&Raster>) -> Result<EpsOutput, String> {
let r = opts.region;
if ![r.x0, r.y0, r.x1, r.y1, opts.origin.x, opts.origin.y].iter().all(|v| v.is_finite()) {
return Err("the export region is not finite".into());
}
let baked = vectorcraft_effects::bake_document(doc);
let doc = baked.as_ref().unwrap_or(doc);
let page = scene::Scene::new(doc, opts).run();
let mut warnings = page.warnings;
let thumb = thumbnail.and_then(|t| match tiff::png(t) {
Ok(png) => Some(png),
Err(e) => {
warnings.push(format!("the thumbnail was left out: {e}"));
None
}
});
let ps = document(opts, &page.setup, &page.body, &page.custom, thumb.as_deref());
let bytes = match (opts.preview, preview) {
(Preview::None, _) => ps.into_bytes(),
(_, None) => {
warnings.push("the preview was left out: nothing was rendered for it".into());
ps.into_bytes()
}
(kind, Some(img)) => {
let tiff = tiff::encode(img, kind == Preview::TiffBlackWhite, opts.transparent_preview && kind == Preview::TiffColor)?;
dos_eps(ps.as_bytes(), &tiff)?
}
};
Ok(EpsOutput { bytes, warnings })
}
/// Comment lines that start a data section the file carries (`%VectorCraft_BeginData: <name>`)
/// and end it; between them, ASCII85 lines after `% `.
const BEGIN_DATA: &str = "%VectorCraft_BeginData: ";
const END_DATA: &str = "%VectorCraft_EndData";
/// The data sections.
const NATIVE: &str = "native";
const THUMBNAIL: &str = "thumbnail";
/// Largest native document [`native`] inflates (bytes).
const MAX_NATIVE: u64 = 1 << 30;
/// The DSC comments every file of ours has after its version line: creator, title, date,
/// language level, data, and the spot colours `custom` with their CMYK equivalents.
pub(crate) fn comments(s: &mut String, title: &str, created: Option<i64>, level: Level, custom: &[(String, [f32; 4])]) {
use std::fmt::Write;
let _ = writeln!(s, "%%Creator: VectorCraft {}", env!("CARGO_PKG_VERSION"));
let _ = writeln!(s, "%%Title: {}", ps::string(title));
if let Some(t) = created {
let [y, mo, d, h, mi, se] = vectorcraft_doc::metadata::civil(t);
let _ = writeln!(s, "%%CreationDate: ({y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{se:02} UTC)");
}
let _ = writeln!(s, "%%LanguageLevel: {}", level.id());
s.push_str("%%DocumentData: Clean7Bit\n");
for (i, (name, cmyk)) in custom.iter().enumerate() {
let _ = writeln!(s, "{}{}", if i == 0 { "%%DocumentCustomColors: " } else { "%%+ " }, ps::string(name));
let [c, m, y, k] = cmyk.map(|v| ps::num(f64::from(v)));
let _ = writeln!(s, "%%CMYKCustomColor: {c} {m} {y} {k} {}", ps::string(name));
}
}
/// The PostScript program: header comments, thumbnail, prolog, setup, the page and the trailer
/// with the native document.
fn document(o: &EpsOptions, setup: &str, body: &str, custom: &[(String, [f32; 4])], thumb: Option<&[u8]>) -> String {
use std::fmt::Write;
let mut s = String::with_capacity(body.len() + setup.len() + 4096);
let bb = bounding_box(o.region, o.origin);
let hi = hires_box(o.region, o.origin);
s.push_str("%!PS-Adobe-3.0 EPSF-3.0\n"); // brand-ok: the DSC version line every EPS file starts with
comments(&mut s, &o.title, o.created, o.level, custom);
let _ = writeln!(s, "%%BoundingBox: {} {} {} {}", bb[0], bb[1], bb[2], bb[3]);
let _ = writeln!(s, "%%HiResBoundingBox: {} {} {} {}", ps::num(hi[0]), ps::num(hi[1]), ps::num(hi[2]), ps::num(hi[3]));
s.push_str("%%Pages: 1\n%%EndComments\n");
if let Some(png) = thumb {
data_section(&mut s, THUMBNAIL, png);
}
s.push_str("%%BeginProlog\n");
s.push_str(ps::PROLOG);
s.push_str("%%EndProlog\n%%BeginSetup\n");
if !setup.is_empty() {
s.push_str("VCdict begin\n");
s.push_str(setup);
s.push_str("end\n");
}
s.push_str("%%EndSetup\n%%Page: 1 1\nVCdict begin\nq\n");
// Document space (y down) onto the page (y up from the origin).
let _ = writeln!(s, "[1 0 0 -1 {} {}] cm", ps::num(-o.origin.x), ps::num(o.origin.y));
s.push_str(body);
s.push_str("Q\nend\nshowpage\n%%PageTrailer\n%%Trailer\n");
if let Some(native) = o.native.as_deref() {
data_section(&mut s, NATIVE, &ps::deflate(native));
}
s.push_str("%%EOF\n");
s
}
/// A data section of comment lines carrying `bytes` (see [`BEGIN_DATA`]).
fn data_section(s: &mut String, name: &str, bytes: &[u8]) {
s.push_str(BEGIN_DATA);
s.push_str(name);
s.push('\n');
for line in ps::ascii85(bytes).lines() {
s.push_str("% ");
s.push_str(line);
s.push('\n');
}
s.push_str(END_DATA);
s.push('\n');
}
/// The bytes of data section `name` in the PostScript `ps`, when it has one that decodes.
fn read_data(ps: &[u8], name: &str) -> Option<Vec<u8>> {
let text = String::from_utf8_lossy(ps);
let mut lines = text.lines();
lines.find(|l| l.strip_prefix(BEGIN_DATA).is_some_and(|n| n.trim() == name))?;
let mut encoded = String::new();
for l in lines {
if l.starts_with(END_DATA) {
return ps::ascii85_decode(&encoded);
}
encoded.push_str(l.strip_prefix('%').unwrap_or(l));
}
None
}
/// The magic number of an EPS file with a binary header (a preview).
const DOS_MAGIC: [u8; 4] = [0xC5, 0xD0, 0xD3, 0xC6];
/// The binary header's size.
const DOS_HEADER: usize = 30;
/// `ps` behind a binary header, followed by the TIFF preview `tiff`.
fn dos_eps(ps: &[u8], tiff: &[u8]) -> Result<Vec<u8>, String> {
let big = || "the file is too large for an EPS preview header (4 GB): choose no preview".to_string();
let ps_len = u32::try_from(ps.len()).map_err(|_| big())?;
let tiff_at = u32::try_from(DOS_HEADER + ps.len()).map_err(|_| big())?;
let tiff_len = u32::try_from(tiff.len()).map_err(|_| big())?;
tiff_at.checked_add(tiff_len).ok_or_else(big)?;
let mut out = Vec::with_capacity(DOS_HEADER + ps.len() + tiff.len());
out.extend(DOS_MAGIC);
out.extend((DOS_HEADER as u32).to_le_bytes());
out.extend(ps_len.to_le_bytes());
// No metafile preview.
out.extend(0u32.to_le_bytes());
out.extend(0u32.to_le_bytes());
out.extend(tiff_at.to_le_bytes());
out.extend(tiff_len.to_le_bytes());
// No checksum.
out.extend(0xFFFFu16.to_le_bytes());
out.extend(ps);
out.extend(tiff);
Ok(out)
}
/// The sections of an EPS file: its PostScript and its TIFF preview (binary header), or the whole
/// file as PostScript. `None` when a binary header points outside the file.
pub fn sections(bytes: &[u8]) -> Option<(&[u8], Option<&[u8]>)> {
if !bytes.starts_with(&DOS_MAGIC) {
return Some((bytes, None));
}
let word = |at: usize| bytes.get(at..at + 4).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(|b| u32::from_le_bytes(b) as usize);
let part = |at: usize| {
let (start, len) = (word(at)?, word(at + 4)?);
bytes.get(start..start.checked_add(len)?)
};
let ps = part(4)?;
let tiff = match word(24) {
Some(0) | None => None,
Some(_) => Some(part(20)?),
};
Some((ps, tiff))
}
/// The Windows metafile preview behind an EPS file's binary header, if it has one.
pub fn metafile_preview(bytes: &[u8]) -> Option<&[u8]> {
if !bytes.starts_with(&DOS_MAGIC) {
return None;
}
let word = |at: usize| bytes.get(at..at + 4).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(|b| u32::from_le_bytes(b) as usize);
let (start, len) = (word(12)?, word(16)?);
if start == 0 || len == 0 {
return None;
}
bytes.get(start..start.checked_add(len)?)
}
/// The native document an EPS file written by [`export`] carries, if any.
pub fn native(bytes: &[u8]) -> Option<Vec<u8>> {
let (ps, _) = sections(bytes)?;
ps::inflate(&read_data(ps, NATIVE)?, MAX_NATIVE)
}
/// Does the EPS file carry a native document (readable or not)?
pub fn has_native(bytes: &[u8]) -> bool {
let marker = format!("{BEGIN_DATA}{NATIVE}");
sections(bytes).is_some_and(|(ps, _)| ps.windows(marker.len()).any(|w| w == marker.as_bytes()))
}
/// The PNG thumbnail an EPS file written by [`export`] carries, if any.
pub fn thumbnail(bytes: &[u8]) -> Option<Vec<u8>> {
read_data(sections(bytes)?.0, THUMBNAIL)
}
#[cfg(test)]
mod tests;