vectorcraft/crates/eps/src/lib.rs

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, ai_alone, family_style, import, import_with, is_loss, layered_ai};
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;