mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
Setting a tiling pattern went through every pattern made so far to find one with the same PaintProc, cell and color, and went through the document's patterns again to name a new one. Each spot ink was looked up in the list of every ink used. When the reading ended, each ink and each pattern became a swatch after a search of all the swatches, and the second object of each clipping group of two was looked up in the list of every shading painted. For a file that makes n of any of these, the searches did work that grows with n squared: a dev-profile build of vectorcraft-cli info took 99 user CPU seconds on a file that makes 160,000 tiling patterns, 35 on one that uses 160,000 spot inks, and 3.2 on one that paints 160,000 shadings through clips. The patterns are now kept in a hash map, under a hash of the PaintProc's storage, the cell, and the color, tint and swatch that an uncolored pattern is set in. A lookup compares the patterns under the same hash, with the comparison it made before. The inks are kept in a map by name, and the shadings are kept in a set. The swatch names go in a set while the swatches are made. The reader's document gets its patterns only from tiling, so the name search always ended at Pattern n+1 after n patterns, and that name is now made directly. A pattern whose cell has a NaN corner never compared equal to another, and it is no longer kept for lookups. The three files now read in 1.3, 0.6 and 0.9 user CPU seconds, and the documents read are the same as before. Tests: a program that makes 200,000 tiling patterns and uses 200,001 spot inks reads within 20 seconds, with the patterns named in order; a pattern made again from its prototype, and an uncolored pattern set again in a color it was set in, are the same pattern.
273 lines
12 KiB
Rust
273 lines
12 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.
|
||
//! - `native`: the layers of an Illustrator file, from the editing copy of its art that it carries.
|
||
//!
|
||
//! 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. A file in that
|
||
//! format whose program has its layers (`%AI5_BeginLayer`) comes in through them, as `native` reads
|
||
//! an Illustrator EPS's editing copy (#1027).
|
||
//!
|
||
//! 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 from its
|
||
//! layers when its program has them (a `.ai` in PostScript form whose prolog is named but not
|
||
//! included, as other apps write it), else 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 ai;
|
||
mod ate;
|
||
mod data;
|
||
mod graphics;
|
||
mod interp;
|
||
mod lex;
|
||
mod native;
|
||
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;
|
||
#[cfg(test)]
|
||
mod tests_native;
|
||
|
||
use std::collections::HashSet;
|
||
use std::sync::Arc;
|
||
|
||
use vectorcraft_color::Paint;
|
||
use vectorcraft_color::swatch::{REGISTRATION, 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 native::{ai_alone, is_loss, layered_ai};
|
||
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), an Illustrator EPS through the editing copy of
|
||
/// its art when it carries one (see `native`).
|
||
pub fn import(bytes: &[u8]) -> Result<Imported, String> {
|
||
import_with(bytes, true)
|
||
}
|
||
|
||
/// [`import`], an Illustrator EPS through the editing copy of its art only when `editing_data`
|
||
/// (else what its page prints, as any EPS).
|
||
pub fn import_with(bytes: &[u8], editing_data: bool) -> 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) if editing_data => return Ok(native::layered(ps, finish(out))),
|
||
(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()),
|
||
};
|
||
// A file in the legacy Illustrator format whose program can't be run (a `.ai` that names the
|
||
// prolog defining its operators without including it): its layers, which the program has.
|
||
let why = match editing_data.then(|| native::program_alone(ps)).flatten() {
|
||
Some(Ok((document, warnings))) => return Ok(Imported { document, warnings, preview: false }),
|
||
Some(Err(e)) => format!("{why}; its layers couldn't be read either ({e})"),
|
||
None => why,
|
||
};
|
||
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(_) => Err(format!("this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it")),
|
||
}
|
||
}
|
||
|
||
/// 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 layer = Node::layer(out.doc.alloc_id(), "Layer 1", LayerColor::Preset(0));
|
||
if let Some(c) = layer.children_mut() {
|
||
*c = children;
|
||
}
|
||
finish_with(out, vec![Arc::new(layer)])
|
||
}
|
||
|
||
/// The document of what `out` drew, as `layers`: the spot inks as spot swatches, in CMYK when
|
||
/// most process colours were.
|
||
fn finish_with(out: Out, layers: Vec<Arc<Node>>) -> Imported {
|
||
let mut doc = out.doc;
|
||
if out.cmyk > out.rgb {
|
||
doc.color_mode = ColorMode::Cmyk;
|
||
let (swatches, groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model());
|
||
(doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into());
|
||
}
|
||
// `taken` holds the names `Document::swatch` finds: each swatch's name, and Registration.
|
||
let mut taken: HashSet<String> = doc.swatches_iter().map(|s| s.name.clone()).chain([REGISTRATION.to_string()]).collect();
|
||
for (name, color) in out.spots {
|
||
if taken.insert(name.clone()) {
|
||
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 taken.insert(name.clone()) {
|
||
doc.swatches.push(Swatch { paint: Paint::Pattern { pattern: name.clone(), xf: Affine::IDENTITY }, name, global: false, spot: false });
|
||
}
|
||
}
|
||
doc.layers = layers;
|
||
Imported { document: doc, warnings: out.warnings, preview: false }
|
||
}
|