mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 11:16:25 +00:00
Hidden instances, instances in hidden groups, on template layers, and on hidden layers or layers that do not print when the exporter leaves those out are no longer placed for PDF, print, EPS and DXF. Their art was baked and counted toward the 16,000,000-object limit for placed symbol art, so it could leave a visible instance written without its effects. bake_document_placing_symbols takes a WrittenLayers value: PDF passes the layers its settings write, EPS writes layers that do not print but not hidden ones, and DXF writes both. Print removes the layers that do not print before baking.
394 lines
16 KiB
Rust
394 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.
|
|
//! - `ai_editing`: the editing copy of the art a PDF-compatible `.ai` file carries
|
|
//! ([`export_ai_editing`], [`ai_private_data`]).
|
|
//! - `ai_ops`: the path and line style operators those two write.
|
|
//! - [`dcs`]: DCS files, an EPS file with one program per printing plate, read for placing.
|
|
|
|
mod ai_editing;
|
|
mod ai_ops;
|
|
pub mod dcs;
|
|
mod import;
|
|
mod latin;
|
|
mod legacy_ai;
|
|
mod print;
|
|
mod ps;
|
|
mod scene;
|
|
mod tiff;
|
|
|
|
pub use ai_editing::{AiEditingOptions, AiEditingOutput, CREATOR_VERSION, export_ai_editing, private_data as ai_private_data};
|
|
pub use import::{
|
|
AiLayers, Imported, NameEncoding, POSTSCRIPT_ERRORS, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard,
|
|
family_style, import, import_names, import_trusting, import_with, is_loss, layered_ai, layered_ai_from, page,
|
|
};
|
|
pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai, writes_image_mask};
|
|
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>,
|
|
/// Notes on the options: the preview or thumbnail asked for that was left out.
|
|
pub setting_notes: 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());
|
|
}
|
|
// Type in the document's composite fonts is drawn in them, whether or not an app has the document open.
|
|
let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts);
|
|
let baked =
|
|
vectorcraft_effects::bake_document_placing_symbols(doc, true, vectorcraft_effects::WrittenLayers { hidden: false, non_printing: true });
|
|
let doc = baked.as_ref().unwrap_or(doc);
|
|
let page = scene::Scene::new(doc, opts).run();
|
|
let mut setting_notes = vec![];
|
|
let thumb = thumbnail.and_then(|t| match tiff::png(t) {
|
|
Ok(png) => Some(png),
|
|
Err(e) => {
|
|
setting_notes.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) => {
|
|
setting_notes.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: page.warnings, setting_notes })
|
|
}
|
|
|
|
/// 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");
|
|
// Each `%%+` line continues the comment right above it.
|
|
for (i, (name, _)) in custom.iter().enumerate() {
|
|
let _ = writeln!(s, "{}{}", if i == 0 { "%%DocumentCustomColors: " } else { "%%+ " }, ps::string(name));
|
|
}
|
|
for (i, (name, cmyk)) in custom.iter().enumerate() {
|
|
let [c, m, y, k] = cmyk.map(|v| ps::num(f64::from(v)));
|
|
let _ = writeln!(s, "{}{c} {m} {y} {k} {}", if i == 0 { "%%CMYKCustomColor: " } else { "%%+ " }, 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
|
|
}
|
|
|
|
pub use craft_eps::{DOS_HEADER, DOS_MAGIC, Sections, is_eps};
|
|
|
|
/// `ps` behind a binary header, followed by the TIFF preview `tiff`.
|
|
fn dos_eps(ps: &[u8], tiff: &[u8]) -> Result<Vec<u8>, String> {
|
|
craft_eps::wrap(ps, Some(tiff), None).map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// 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]>)> {
|
|
let s = craft_eps::sections(bytes).ok()?;
|
|
Some((s.postscript, s.tiff))
|
|
}
|
|
|
|
/// The Windows metafile preview behind an EPS file's binary header, if it has one.
|
|
pub fn metafile_preview(bytes: &[u8]) -> Option<&[u8]> {
|
|
craft_eps::sections(bytes).ok()?.metafile
|
|
}
|
|
|
|
/// 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;
|