Files
bflatasticandClaude Opus 5.5 1d265951df Fix #498: EPS images with several data sources read them in turn
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>
2026-10-08 23:43:49 +07:00

237 lines
9.9 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 }
}