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)
416 lines
16 KiB
Rust
416 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`.
|
|
//! - `legacy_ai`: legacy `.ai` files of version 7 ([`export_legacy_ai`]), with their layers and groups.
|
|
|
|
mod import;
|
|
mod latin;
|
|
mod legacy_ai;
|
|
mod print;
|
|
mod ps;
|
|
mod scene;
|
|
mod tiff;
|
|
|
|
pub use import::{Imported, POSTSCRIPT_ERRORS, ai_alone, family_style, import, import_trusting, import_with, is_loss, layered_ai, page};
|
|
pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_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;
|