An image can take one data source per colour component (MultipleDataSources, or `colorimage` with multi true). The PostScript Language Reference (3rd ed., §4.10 "Images" and `image`/`colorimage` in chapter 8) has the operator read the sources in turn, the same amount from each. The reader drained each source on its own instead: when the sources are procedures reading one file a row at a time, the first plane took the rows of every component and the image came out in sheared horizontal bands, as in #498. - Data sources are read in turn by one reader, `read_sources`: a procedure once a round, a file or string a row a round, until each plane is full or its source ends. Single sources, procedure filters and masked images use it too; a string source resumes where it left off. - Tests (`tests_images.rs`, programs written from the reference): procedures reading one file in the dictionary and `colorimage` operand forms (fails without the fix), separate string sources, rows padded to a byte at 1, 2, 4, 12 and 16 bits with an odd width (one and several sources), Flate PNG predictors with every row filter at an odd width. The import fuzz vocabulary has several sources reading one file and sources of uneven lengths. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
237 lines
9.9 KiB
Rust
237 lines
9.9 KiB
Rust
//! 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 a file in the legacy Illustrator format (versions
|
||
//! 3–8, with the prolog that defines its operators), the group operators `u` … `U` (nested) come in
|
||
//! as groups. The format's published specification documents its header comments (`%AI…`,
|
||
//! `%%Creator`) and these operators: Adobe Illustrator File Format Specification, version 7.0
|
||
//! (1998), listed by PRONOM at https://www.nationalarchives.gov.uk/PRONOM/fmt/423.
|
||
//!
|
||
//! 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;
|
||
#[cfg(test)]
|
||
mod tests_images;
|
||
|
||
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>,
|
||
/// In the legacy Illustrator format: its header has `%AI…` comments or names the app as the
|
||
/// creator (see the module docs for the specification).
|
||
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 }
|
||
}
|