craft-eps: EPS files as a container (binary header, DSC comments, EPSI preview, ASCII85, sniffing) (#909)

The layer every EPS reader and writer in the apps had its own copy of: where the PostScript
and the previews are behind the binary header (read and write), the header comments with
the bounding boxes (also deferred to the trailer), the EPSI preview bitmap, ASCII85 both
ways, and telling EPS, PostScript and PostScript- or PDF-based Illustrator files apart.
No PostScript interpreter.

From storytold/vectorcraft at 6e873b80 (crates/eps: lib.rs, import.rs, import/preview.rs,
ps.rs) and storytold/designcraft at 513f77b8 (crates/images/src/eps.rs).
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 18:39:42 -05:00
1 parent 9435232e51
commit f2d658d08f
11 files changed
+775

No files matched your search

Generated
+4
View File
@@ -955,6 +955,10 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-eps"
version = "0.1.0"
[[package]]
name = "craft-epub"
version = "0.1.0"
+1
View File
@@ -34,6 +34,7 @@ craft-csv = { path = "crates/csv" }
craft-dialogs = { path = "crates/dialogs" }
craft-dxf = { path = "crates/dxf" }
craft-drawingml = { path = "crates/drawingml" }
craft-eps = { path = "crates/eps" }
craft-doc = { path = "crates/doc" }
craft-docpath = { path = "crates/docpath" }
craft-dpx = { path = "crates/dpx" }
+4
View File
@@ -70,6 +70,10 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
EMU units and `a:xfrm`, colours with theme slots and colour transforms, preset shape outlines
(`a:prstGeom`), custom geometry with guide formulas (`a:custGeom`) and preset text warps
(`a:prstTxWarp`). From DeckCraft. Takes `craft-xml` trees; packages stay with each format.
- [`craft-eps`](crates/eps/README.md): Encapsulated PostScript files as a container: the binary
preview header (read and write), the DSC header comments and bounding boxes (also `(atend)`),
the EPSI preview bitmap, ASCII85 data, and telling EPS, PostScript and Illustrator files
(PostScript- or PDF-based) apart. No PostScript interpreter.
- [`craft-dxf`](crates/dxf/src/lib.rs): DXF tag reader and writer, from CADCraft: ASCII and
binary DXF as (group code, value) tags, split into sections and entities, with pre-2007 code
pages and version detection, and written back. Bounded on hostile input. Mapping tags to a
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "craft-eps"
description = "Encapsulated PostScript files as a container: the binary preview header (read and write), the DSC header comments and bounding boxes, the EPSI preview, ASCII85 data, and telling EPS, PostScript and Illustrator files apart. No PostScript interpreter."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
+68
View File
@@ -0,0 +1,68 @@
# craft-eps
Encapsulated PostScript files as a container: what a file is, where its PostScript and its
previews are, and what its header comments say, without running its program. It depends on
nothing.
| | |
|---|---|
| `sniff`, `is_eps` | EPS (binary header, or `%!PS-Adobe-… EPSF-…`), plain PostScript or PDF, and whether it is an Illustrator file: PostScript-based (`%AI…` comments, or Illustrator as `%%Creator`) or PDF-based (the `AIPrivateData` / `AIPDFPrivateData` keys near the start or end of the PDF, a hint only) |
| `sections` | The parts behind the binary header ("DOS EPS"): the PostScript, the TIFF preview, the Windows metafile preview. A header that points outside the file is an error |
| `wrap` | PostScript and its previews behind a binary header, as `sections` reads them back |
| `Comments::read` | `%%BoundingBox` and `%%HiResBoundingBox` (the first in the header; `(atend)` taken from the trailer, outside embedded documents), `%%Pages`, `%%LanguageLevel`, `%%Creator`, `%%Title` |
| `epsi` | The device-independent preview (`%%BeginPreview`, 1, 2, 4 or 8 bits) as an 8-bit grey bitmap |
| `ascii85::encode`, `ascii85::decode` | The ASCII85 data encoding; lines never start with `%`, decoding is bounded |
```rust
let parts = craft_eps::sections(bytes)?;
let comments = craft_eps::Comments::read(parts.postscript);
if let Some([x0, y0, x1, y1]) = comments.page_box() { /* points */ }
```
## What it is not
There is no PostScript interpreter here, and no writer of drawings. This crate is the layer every
EPS reader and writer in the Craft apps had its own copy of; what the program draws is read and
written elsewhere:
- VectorCraft's `crates/eps` runs the program into its own document and writes EPS, PostScript
print jobs and the legacy Illustrator format from it. That code speaks VectorCraft's document
model throughout and has not moved.
- A PostScript interpreter that returns what a file paints as a PDF is proposed in
craft-libs#159 (`craft-ps`, from DesignCraft).
- Decoding the TIFF or metafile preview is the caller's (`craft-metafile` reads the latter).
- The editing copy of the art inside a PDF-based Illustrator file is in the PDF's `PieceInfo`,
which a PDF reader finds.
## Where it came from
The Craft apps' own code (MIT OR Apache-2.0), merged so that no caller loses a case:
- the binary header reader and writer, the EPSI preview and ASCII85 from VectorCraft
(`crates/eps/src/lib.rs`, `import/preview.rs`, `ps.rs` at `6e873b80`);
- the header comments from DesignCraft (`crates/images/src/eps.rs` at `513f77b8`: bounded header
and trailer windows, `(atend)` boxes outside embedded documents, boxes of a finite size) and
VectorCraft (`import.rs`: `%%Pages`, the Illustrator markers);
- the same header and box reading also existed in EffectCraft (`crates/pdf/src/ps/eps.rs`) and,
as a signature check, in PhotoCraft (`crates/io/src/vector_preview.rs`).
## Clean room
Written from Adobe's published specifications only: the PostScript Language Document Structuring
Conventions 3.0 (Technical Note 5001), the Encapsulated PostScript File Format Specification 3.0
(Technical Note 5002, which defines the binary header and the EPSI preview), and the PostScript
Language Reference, third edition (ASCII85). The Illustrator markers are the header comments the
Adobe Illustrator File Format Specification, version 7.0 (1998), documents (listed by PRONOM at
<https://www.nationalarchives.gov.uk/PRONOM/fmt/423>), and the `PieceInfo` key names PDF-based
files carry in plain sight. No Adobe application was run or used as an oracle, no Adobe prolog or
other Adobe-authored PostScript is included, and no GPL, LGPL or AGPL code (Ghostscript, Inkscape,
Scribus…) was read. The test files are written by hand in the tests.
## Limits
The header is read from the first 64 KiB of the PostScript and a deferred bounding box from the
last 64 KiB (`MAX_HEADER`, `MAX_TRAILER`); a bounding box is four finite numbers that make a box
of a finite size; the binary header's offsets are checked against the file; an EPSI preview is
at most 8192 pixels a side and must have all its samples; `ascii85::decode` takes the most bytes
it may produce; a PDF is scanned for the Illustrator keys in its first and last mebibyte
(`MAX_PDF_SCAN`). Each limit has a test. No `unsafe`, no panics outside tests.
+103
View File
@@ -0,0 +1,103 @@
//! ASCII85 (`ASCII85Encode` / `ASCII85Decode`): four bytes as five characters `!`…`u`, four zero
//! bytes as `z`, ended by `~>`.
/// Characters a line of encoded data has, about.
const LINE: usize = 72;
/// `bytes` in ASCII85 with its `~>` end, in lines of about 72 characters, none starting with `%`
/// (a DSC reader would take such a line for a comment).
pub fn encode(bytes: &[u8]) -> String {
let mut raw = String::with_capacity(bytes.len() * 5 / 4 + 8);
let (chunks, rest) = bytes.as_chunks::<4>();
for c in chunks {
let v = u32::from_be_bytes(*c);
if v == 0 {
raw.push('z');
} else {
push85(&mut raw, v, 5);
}
}
if !rest.is_empty() {
let mut c = [0u8; 4];
for (d, s) in c.iter_mut().zip(rest) {
*d = *s;
}
push85(&mut raw, u32::from_be_bytes(c), rest.len() + 1);
}
raw.push_str("~>");
let mut out = String::with_capacity(raw.len() + raw.len() / LINE + 2);
if raw.starts_with('%') {
// Whitespace is ignored by readers, and keeps the first line from starting with `%`.
out.push(' ');
}
let mut col = 0;
for ch in raw.chars() {
if col >= LINE && ch != '%' {
out.push('\n');
col = 0;
}
out.push(ch);
col += 1;
}
out.push('\n');
out
}
/// The first `n` base-85 digits of `v`.
fn push85(s: &mut String, mut v: u32, n: usize) {
let mut d = [0u8; 5];
for slot in d.iter_mut().rev() {
*slot = (v % 85) as u8 + b'!';
v /= 85;
}
for b in d.iter().take(n) {
s.push(char::from(*b));
}
}
/// ASCII85 `text` (whitespace ignored, up to `~>`) decoded. `None` when it is malformed, has no
/// end, or decodes to more than `max` bytes: a `z` is four bytes for one character, so the length
/// of the text doesn't bound what it becomes.
pub fn decode(text: &[u8], max: usize) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity((text.len() / 5 * 4).min(max));
let mut group = [0u8; 5];
let mut n = 0;
let mut chars = text.iter().copied().filter(|b| !b.is_ascii_whitespace());
while let Some(b) = chars.next() {
if out.len() > max {
return None;
}
match b {
b'~' => {
// A single left-over character can't be a byte.
if chars.next() != Some(b'>') || n == 1 {
return None;
}
if n > 1 {
let mut padded = [b'u'; 5];
for (d, s) in padded.iter_mut().zip(group.iter().take(n)) {
*d = *s;
}
out.extend(value85(&padded)?.to_be_bytes().iter().take(n - 1));
}
return (out.len() <= max).then_some(out);
}
b'z' if n == 0 => out.extend([0; 4]),
b'!'..=b'u' => {
*group.get_mut(n)? = b;
n += 1;
if n == 5 {
out.extend(value85(&group)?.to_be_bytes());
n = 0;
}
}
_ => return None,
}
}
None
}
/// The value of five base-85 digits; `None` past `u32::MAX`.
fn value85(digits: &[u8]) -> Option<u32> {
digits.iter().try_fold(0u32, |acc, d| acc.checked_mul(85)?.checked_add(u32::from(d.checked_sub(b'!')?)))
}
+77
View File
@@ -0,0 +1,77 @@
//! The binary header of an EPS file with a preview ("DOS EPS binary file header", EPSF 3.0
//! section 5.2): 30 bytes, little-endian.
//!
//! | bytes | |
//! |---|---|
//! | 0–3 | `C5 D0 D3 C6` |
//! | 4–7, 8–11 | offset and length of the PostScript |
//! | 12–15, 16–19 | offset and length of the Windows metafile preview (0 for none) |
//! | 20–23, 24–27 | offset and length of the TIFF preview (0 for none) |
//! | 28–29 | checksum of bytes 0–27 (XOR of the 16-bit words), or `FFFF` for none |
use crate::Error;
/// The first bytes of an EPS file with a binary header.
pub const DOS_MAGIC: [u8; 4] = [0xC5, 0xD0, 0xD3, 0xC6];
/// The binary header's size.
pub const DOS_HEADER: usize = 30;
/// The parts of an EPS file.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Sections<'a> {
/// The PostScript program: the whole file when it has no binary header.
pub postscript: &'a [u8],
/// The TIFF preview.
pub tiff: Option<&'a [u8]>,
/// The Windows metafile preview.
pub metafile: Option<&'a [u8]>,
}
/// The sections of an EPS file: its PostScript and previews behind a binary header, or the whole
/// file as PostScript. A header that points outside the file is an error, whichever section it
/// is: such a file is cut short or not an EPS.
pub fn sections(bytes: &[u8]) -> Result<Sections<'_>, Error> {
if !bytes.starts_with(&DOS_MAGIC) {
return Ok(Sections { postscript: bytes, tiff: None, metafile: None });
}
let word = |at: usize| -> Result<usize, Error> {
let b = bytes.get(at..at.saturating_add(4)).and_then(|b| <[u8; 4]>::try_from(b).ok()).ok_or(Error::OutsideFile)?;
usize::try_from(u32::from_le_bytes(b)).map_err(|_| Error::OutsideFile)
};
let part = |at: usize| -> Result<&[u8], Error> {
let (start, len) = (word(at)?, word(at + 4)?);
start.checked_add(len).and_then(|end| bytes.get(start..end)).ok_or(Error::OutsideFile)
};
// A preview with no offset or no length is absent.
let preview = |at: usize| -> Result<Option<&[u8]>, Error> { if word(at)? == 0 || word(at + 4)? == 0 { Ok(None) } else { part(at).map(Some) } };
Ok(Sections { postscript: part(4)?, metafile: preview(12)?, tiff: preview(20)? })
}
/// `postscript` behind a binary header, followed by its previews. With neither preview the
/// PostScript is returned as it is: a header would only hide it from readers of plain EPS.
pub fn wrap(postscript: &[u8], tiff: Option<&[u8]>, metafile: Option<&[u8]>) -> Result<Vec<u8>, Error> {
let (tiff, metafile) = (tiff.filter(|t| !t.is_empty()), metafile.filter(|m| !m.is_empty()));
if tiff.is_none() && metafile.is_none() {
return Ok(postscript.to_vec());
}
let size = |part: Option<&[u8]>| u32::try_from(part.map_or(0, <[u8]>::len)).map_err(|_| Error::TooLarge);
let ps_len = u32::try_from(postscript.len()).map_err(|_| Error::TooLarge)?;
let (metafile_len, tiff_len) = (size(metafile)?, size(tiff)?);
let metafile_at = (DOS_HEADER as u32).checked_add(ps_len).ok_or(Error::TooLarge)?;
let tiff_at = metafile_at.checked_add(metafile_len).ok_or(Error::TooLarge)?;
let total = tiff_at.checked_add(tiff_len).ok_or(Error::TooLarge)?;
let mut out = Vec::with_capacity(usize::try_from(total).map_err(|_| Error::TooLarge)?);
out.extend(DOS_MAGIC);
out.extend((DOS_HEADER as u32).to_le_bytes());
out.extend(ps_len.to_le_bytes());
for (at, len) in [(metafile_at, metafile_len), (tiff_at, tiff_len)] {
out.extend((if len == 0 { 0 } else { at }).to_le_bytes());
out.extend(len.to_le_bytes());
}
// No checksum.
out.extend(0xFFFFu16.to_le_bytes());
out.extend(postscript);
out.extend(metafile.unwrap_or_default());
out.extend(tiff.unwrap_or_default());
Ok(out)
}
+122
View File
@@ -0,0 +1,122 @@
//! The header comments of a PostScript file (Document Structuring Conventions 3.0).
/// Bytes of a file's start read for its header comments.
pub const MAX_HEADER: usize = 64 << 10;
/// Bytes of a file's end read for comments its header defers there with `(atend)`.
pub const MAX_TRAILER: usize = 64 << 10;
/// What the header comments of a PostScript program say.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Comments {
/// `%%BoundingBox`: lower left x and y, upper right x and y, in points.
pub bounding_box: Option<[f64; 4]>,
/// `%%HiResBoundingBox`, the same box with fractions.
pub hires_bounding_box: Option<[f64; 4]>,
/// `%%Pages`.
pub pages: Option<u32>,
/// `%%LanguageLevel`.
pub language_level: Option<u32>,
/// `%%Creator`, without the parentheses some writers put around it.
pub creator: Option<String>,
/// `%%Title`, likewise.
pub title: Option<String>,
/// In the Illustrator format: the header has `%AI…` or `%%AI…` comments, or names Illustrator
/// as its creator.
pub illustrator: bool,
}
/// Comments that end the header, besides the first line that is no comment.
const END_OF_HEADER: [&str; 8] =
["%%EndComments", "%%BeginDocument", "%%BeginProlog", "%%BeginSetup", "%%BeginData", "%%BeginBinary", "%%BeginResource", "%%Page:"];
impl Comments {
/// The header comments of `ps` (the PostScript section of a file, see [`crate::sections`]).
/// A bounding box the header defers with `(atend)` is the last one of the trailer, outside
/// any document the file embeds.
pub fn read(ps: &[u8]) -> Self {
let head = String::from_utf8_lossy(ps.get(..MAX_HEADER).unwrap_or(ps));
let mut c = Comments::default();
let (mut box_atend, mut hires_atend) = (false, false);
// Lines may end in `\r` alone (classic Mac files).
for line in head.split(['\n', '\r']) {
if !(line.starts_with('%') || line.trim().is_empty()) || END_OF_HEADER.iter().any(|k| line.starts_with(k)) {
break;
}
let value = |key: &str| line.strip_prefix(key).map(str::trim);
if line.starts_with("%AI") || line.starts_with("%%AI") {
c.illustrator = true;
}
// The first of a comment given twice counts (DSC 3.0, section 4.6).
if let Some(v) = value("%%HiResBoundingBox:") {
hires_atend |= v.starts_with("(atend)");
c.hires_bounding_box = c.hires_bounding_box.or_else(|| parse_box(v));
} else if let Some(v) = value("%%BoundingBox:") {
box_atend |= v.starts_with("(atend)");
c.bounding_box = c.bounding_box.or_else(|| parse_box(v));
} else if let Some(v) = value("%%Pages:") {
c.pages = c.pages.or_else(|| v.split_whitespace().next().and_then(|n| n.parse().ok()));
} else if let Some(v) = value("%%LanguageLevel:") {
c.language_level = c.language_level.or_else(|| v.split_whitespace().next().and_then(|n| n.parse().ok()));
} else if let Some(v) = value("%%Creator:") {
c.illustrator |= v.contains("Illustrator");
c.creator = c.creator.or_else(|| Some(text(v)));
} else if let Some(v) = value("%%Title:") {
c.title = c.title.or_else(|| Some(text(v)));
}
}
// An outer header box is never replaced by the trailer comments of an embedded document:
// the trailer is read only for what the outer header defers there.
if box_atend || hires_atend {
let tail = String::from_utf8_lossy(ps.get(ps.len().saturating_sub(MAX_TRAILER)..).unwrap_or(ps));
if hires_atend {
c.hires_bounding_box = c.hires_bounding_box.or_else(|| trailer_box(&tail, "%%HiResBoundingBox:"));
}
if box_atend {
c.bounding_box = c.bounding_box.or_else(|| trailer_box(&tail, "%%BoundingBox:"));
}
}
c
}
/// The box the file draws in: the high-resolution one when it has one.
pub fn page_box(&self) -> Option<[f64; 4]> {
self.hires_bounding_box.or(self.bounding_box)
}
}
/// A text value, without the parentheses of a PostScript string around it.
fn text(v: &str) -> String {
v.strip_prefix('(').and_then(|v| v.strip_suffix(')')).unwrap_or(v).to_string()
}
/// The four numbers of a bounding box comment, when they are four finite numbers that make a box
/// of a finite size (subtracting extreme corners can overflow to infinity).
fn parse_box(value: &str) -> Option<[f64; 4]> {
let mut words = value.split_whitespace();
let mut bb = [0.0f64; 4];
for n in &mut bb {
*n = words.next()?.parse().ok()?;
}
let [x0, y0, x1, y1] = bb;
let finite = [x0, y0, x1, y1, x1 - x0, y1 - y0].iter().all(|n| n.is_finite());
(words.next().is_none() && finite && x1 > x0 && y1 > y0).then_some(bb)
}
/// The last `key` box of the trailer that is the outer document's own: the lines are walked
/// backwards from the end of the file, so the nesting is known even when the window starts inside
/// an embedded document. Data and resource blocks are skipped the same way.
fn trailer_box(tail: &str, key: &str) -> Option<[f64; 4]> {
let mut nested = 0usize;
for line in tail.split(['\n', '\r']).rev() {
if ["%%EndDocument", "%%EndData", "%%EndBinary", "%%EndResource"].iter().any(|k| line.starts_with(k)) {
nested = nested.checked_add(1)?;
} else if ["%%BeginDocument", "%%BeginData", "%%BeginBinary", "%%BeginResource"].iter().any(|k| line.starts_with(k)) {
nested = nested.checked_sub(1)?;
} else if nested == 0
&& let Some(bb) = line.strip_prefix(key).and_then(parse_box)
{
return Some(bb);
}
}
None
}
+103
View File
@@ -0,0 +1,103 @@
//! Encapsulated PostScript as a container: what a file is, where its PostScript and its previews
//! are, and what its header comments say, without running its program.
//!
//! - [`sniff`]: EPS, plain PostScript or PDF, and whether it is an Illustrator file (PostScript-
//! or PDF-based).
//! - [`sections`] / [`wrap`]: the binary header ("DOS EPS") that puts a TIFF or Windows metafile
//! preview beside the PostScript, read and written.
//! - [`Comments`]: the DSC header comments, with the bounding box also when it is `(atend)`.
//! - [`epsi`]: the device-independent preview bitmap in `%%BeginPreview` comments.
//! - [`ascii85`]: the ASCII85 data encoding, both ways.
//!
//! Written from the PostScript Language Document Structuring Conventions 3.0 and the
//! Encapsulated PostScript File Format Specification 3.0 (Adobe Technical Notes 5001 and 5002),
//! and the PostScript Language Reference (ASCII85). Input is hostile: every offset is checked
//! against the file, scans are bounded, and nothing panics.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod ascii85;
mod container;
mod dsc;
mod preview;
pub use container::{DOS_HEADER, DOS_MAGIC, Sections, sections, wrap};
pub use dsc::{Comments, MAX_HEADER, MAX_TRAILER};
pub use preview::{Bitmap, MAX_EPSI, epsi};
use std::fmt;
/// Why a file could not be read or written.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Error {
/// The binary header points outside the file, or is cut short.
OutsideFile,
/// A section is too large for the binary header's 32-bit offsets (4 GiB).
TooLarge,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::OutsideFile => f.write_str("the EPS preview header points outside the file"),
Self::TooLarge => f.write_str("the file is too large for an EPS preview header (4 GiB)"),
}
}
}
impl std::error::Error for Error {}
/// The format of a file, by its first bytes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
/// Encapsulated PostScript: a binary preview header, or a first line `%!PS-Adobe-… EPSF-…`.
Eps,
/// A PostScript program that is not encapsulated (`%!`).
PostScript,
/// A PDF (`%PDF-`).
Pdf,
}
/// What [`sniff`] found.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Sniffed {
pub kind: Kind,
/// An Illustrator file. PostScript-based (the legacy `.ai` format, or an Illustrator EPS): its
/// header has `%AI…` comments or names Illustrator as its creator. PDF-based (`.ai` since
/// version 9): the PDF names the `AIPrivateData` or `AIPDFPrivateData` of its editing copy
/// within [`MAX_PDF_SCAN`] bytes of its start or end, which is a hint, not proof: only a PDF
/// reader can look in the first page's `PieceInfo`, and an object stream hides it from here.
pub illustrator: bool,
}
/// Bytes of a PDF's start and of its end scanned for the Illustrator private data keys.
pub const MAX_PDF_SCAN: usize = 1 << 20;
/// What `bytes` are, or `None` for anything but EPS, PostScript and PDF. File names never count:
/// a `.ai` file is told from a `.eps` or `.pdf` by its content.
pub fn sniff(bytes: &[u8]) -> Option<Sniffed> {
if bytes.starts_with(b"%PDF-") {
let head = bytes.get(..MAX_PDF_SCAN).unwrap_or(bytes);
let tail = bytes.get(bytes.len().saturating_sub(MAX_PDF_SCAN)..).unwrap_or(bytes);
let private = |hay: &[u8]| [&b"/AIPrivateData"[..], b"/AIPDFPrivateData"].iter().any(|key| hay.windows(key.len()).any(|w| w == *key));
return Some(Sniffed { kind: Kind::Pdf, illustrator: private(head) || private(tail) });
}
if bytes.starts_with(&DOS_MAGIC) {
// A header that points outside the file is still an EPS file, one that can't be read.
let illustrator = sections(bytes).is_ok_and(|s| Comments::read(s.postscript).illustrator);
return Some(Sniffed { kind: Kind::Eps, illustrator });
}
if !bytes.starts_with(b"%!") {
return None;
}
let first = bytes.split(|b| matches!(b, b'\n' | b'\r')).next().unwrap_or_default();
let encapsulated = first.starts_with(b"%!PS-Adobe") && first.windows(4).any(|w| w == b"EPSF");
Some(Sniffed { kind: if encapsulated { Kind::Eps } else { Kind::PostScript }, illustrator: Comments::read(bytes).illustrator })
}
/// An EPS file (plain, or with a binary header)?
pub fn is_eps(bytes: &[u8]) -> bool {
sniff(bytes).is_some_and(|s| s.kind == Kind::Eps)
}
#[cfg(test)]
mod tests;
+59
View File
@@ -0,0 +1,59 @@
//! The device-independent preview (EPSI): a bitmap in hexadecimal comment lines between
//! `%%BeginPreview: width height depth lines` and `%%EndPreview` (EPSF 3.0, section 6).
/// Largest EPSI bitmap side read (pixels).
pub const MAX_EPSI: usize = 8192;
/// A grey bitmap, 8 bits a pixel, row by row from the top: 0 is black, 255 white.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Bitmap {
pub width: u32,
pub height: u32,
pub gray: Vec<u8>,
}
/// The EPSI preview of `ps`, if it has one of 1, 2, 4 or 8 bits a pixel and all its samples.
/// In the file a sample of 0 is white (EPSF 3.0: "0 is white"); the bitmap is turned around.
pub fn epsi(ps: &[u8]) -> Option<Bitmap> {
let text = String::from_utf8_lossy(ps);
let mut lines = text.split(['\n', '\r']).filter(|l| !l.is_empty());
let head = lines.find_map(|l| l.strip_prefix("%%BeginPreview:"))?;
let mut n = head.split_whitespace().map_while(|w| w.parse::<usize>().ok());
let (w, h, depth) = (n.next()?, n.next()?, n.next()?);
if !(1..=MAX_EPSI).contains(&w) || !(1..=MAX_EPSI).contains(&h) || ![1, 2, 4, 8].contains(&depth) {
return None;
}
let row = (w * depth).div_ceil(8);
let need = row * h;
let mut bytes = Vec::with_capacity(need.min(ps.len() / 2));
let mut high = None;
'lines: for l in lines {
if l.starts_with("%%EndPreview") {
break;
}
for digit in l.strip_prefix('%')?.chars().filter_map(|c| c.to_digit(16)) {
match high.take() {
None => high = Some(digit),
Some(hi) => bytes.push((hi << 4 | digit) as u8),
}
if bytes.len() == need {
break 'lines;
}
}
}
// A preview with its samples missing is none, not a blank image of the size it claims.
if bytes.len() < need {
return None;
}
let max = (1u32 << depth) - 1;
let mut gray = Vec::with_capacity(w * h);
for y in 0..h {
for x in 0..w {
let bit = y * row * 8 + x * depth;
let byte = u32::from(bytes.get(bit / 8).copied().unwrap_or(0));
let v = (byte >> (8 - depth - bit % 8)) & max;
gray.push((255 - v * 255 / max) as u8);
}
}
Some(Bitmap { width: w as u32, height: h as u32, gray })
}
+221
View File
@@ -0,0 +1,221 @@
use super::*;
const EPS: &[u8] = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 50\n%%HiResBoundingBox: 0 0 100.5 50.25\nnewpath\n%%EOF\n";
/// A binary header written by hand, as the specification lays it out.
fn dos(ps: &[u8], metafile: &[u8], tiff: &[u8]) -> Vec<u8> {
let mut out = DOS_MAGIC.to_vec();
let metafile_at = DOS_HEADER + ps.len();
let at = |start: usize, part: &[u8]| if part.is_empty() { 0 } else { start };
for v in [DOS_HEADER, ps.len(), at(metafile_at, metafile), metafile.len(), at(metafile_at + metafile.len(), tiff), tiff.len()] {
out.extend((v as u32).to_le_bytes());
}
out.extend([0xFF, 0xFF]);
for part in [ps, metafile, tiff] {
out.extend_from_slice(part);
}
out
}
#[test]
fn sections_of_plain_and_binary_files() {
assert_eq!(sections(EPS), Ok(Sections { postscript: EPS, tiff: None, metafile: None }));
let file = dos(EPS, b"", b"II*\0tiff");
assert_eq!(sections(&file), Ok(Sections { postscript: EPS, tiff: Some(b"II*\0tiff"), metafile: None }));
// A metafile preview alone is not read as a TIFF preview.
let file = dos(EPS, b"WMFDATA!", b"");
assert_eq!(sections(&file), Ok(Sections { postscript: EPS, tiff: None, metafile: Some(b"WMFDATA!") }));
let file = dos(EPS, b"WMF", b"TIFF");
assert_eq!(sections(&file), Ok(Sections { postscript: EPS, tiff: Some(b"TIFF"), metafile: Some(b"WMF") }));
}
#[test]
fn a_header_that_points_outside_the_file_is_refused() {
let file = dos(EPS, b"WMF", b"TIFF");
// Cut anywhere, the header or a section it names is missing.
for len in 4..file.len() {
assert_eq!(sections(&file[..len]), Err(Error::OutsideFile), "cut at {len}");
}
// Offsets and lengths whose sum overflows 32 bits (and, on 32-bit targets, usize).
for field in [4, 12, 20] {
let mut bad = file.clone();
bad[field..field + 4].copy_from_slice(&u32::MAX.to_le_bytes());
bad[field + 4..field + 8].copy_from_slice(&u32::MAX.to_le_bytes());
assert_eq!(sections(&bad), Err(Error::OutsideFile));
}
assert!(sniff(&file[..10]).is_some_and(|s| s.kind == Kind::Eps && !s.illustrator));
}
#[test]
fn wrap_writes_what_sections_reads() {
assert_eq!(wrap(EPS, None, None).unwrap(), EPS, "no preview, no header");
assert_eq!(wrap(EPS, Some(b""), Some(b"")).unwrap(), EPS);
for (tiff, metafile) in [(Some(&b"TIFF"[..]), None), (None, Some(&b"WMF"[..])), (Some(&b"TIFF"[..]), Some(&b"WMF"[..]))] {
let file = wrap(EPS, tiff, metafile).unwrap();
assert_eq!(file, dos(EPS, metafile.unwrap_or_default(), tiff.unwrap_or_default()));
assert_eq!(sections(&file), Ok(Sections { postscript: EPS, tiff, metafile }));
assert_eq!(&file[28..30], [0xFF, 0xFF], "no checksum");
}
}
fn boxes(ps: &str) -> Option<[f64; 4]> {
Comments::read(ps.as_bytes()).page_box()
}
#[test]
fn bounding_boxes_of_the_header() {
let c = Comments::read(EPS);
assert_eq!((c.bounding_box, c.hires_bounding_box), (Some([0.0, 0.0, 100.0, 50.0]), Some([0.0, 0.0, 100.5, 50.25])));
assert_eq!(c.page_box(), Some([0.0, 0.0, 100.5, 50.25]));
let bb = |line: &str| boxes(&format!("%!PS-Adobe-3.0 EPSF-3.0\n{line}\n"));
assert_eq!(bb("%%BoundingBox: 0 0 100 50"), Some([0.0, 0.0, 100.0, 50.0]));
assert_eq!(bb("%%BoundingBox: 0 0 inf 50"), None);
assert_eq!(bb("%%BoundingBox: -inf 0 100 50"), None);
assert_eq!(bb("%%BoundingBox: -1e308 0 1e308 50"), None, "a size that overflows");
assert_eq!(bb("%%BoundingBox: 100 50 0 0"), None);
assert_eq!(bb("%%BoundingBox: 0 0 100"), None);
assert_eq!(bb("%%BoundingBox: 0 0 100 50 7"), None);
// A bad high-resolution box falls back to the plain one.
assert_eq!(bb("%%HiResBoundingBox: 0 0 inf inf\n%%BoundingBox: 0 0 10 20"), Some([0.0, 0.0, 10.0, 20.0]));
// Lines ending in a carriage return alone, and the first of a comment given twice.
assert_eq!(boxes("%!PS-Adobe-3.0\r%%BoundingBox: 1 2 3 4\r%%BoundingBox: 5 6 7 8\r"), Some([1.0, 2.0, 3.0, 4.0]));
// Comments past the header are the program's, not the file's.
assert_eq!(boxes("%!PS-Adobe-3.0\n%%EndComments\n%%BoundingBox: 1 2 3 4\n"), None);
assert_eq!(boxes("%!PS-Adobe-3.0\nnewpath\n%%BoundingBox: 1 2 3 4\n"), None);
assert_eq!(boxes("%!PS-Adobe-3.0\n%%BeginProlog\n%%BoundingBox: 1 2 3 4\n"), None);
}
#[test]
fn a_deferred_bounding_box_is_the_outer_trailers() {
let ps = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: (atend)\n%%EndComments\nnewpath\n%%Trailer\n%%BoundingBox: 1 2 30 40\n%%EOF\n";
assert_eq!(boxes(ps), Some([1.0, 2.0, 30.0, 40.0]));
// The trailer of an embedded document is not the file's.
let nested = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: (atend)\n%%EndComments\n%%BeginDocument: inner.eps\n%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: (atend)\n%%Trailer\n%%BoundingBox: 0 0 5 5\n%%EOF\n%%EndDocument\n%%Trailer\n%%BoundingBox: 1 2 30 40\n%%EOF\n";
assert_eq!(boxes(nested), Some([1.0, 2.0, 30.0, 40.0]));
let only_inner = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: (atend)\n%%EndComments\n%%BeginDocument: inner.eps\n%%Trailer\n%%BoundingBox: 0 0 5 5\n%%EndDocument\n%%Trailer\n%%EOF\n";
assert_eq!(boxes(only_inner), None);
// A header with its own box never takes one from the trailer.
let own = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 9 9\n%%EndComments\n%%Trailer\n%%BoundingBox: 1 2 30 40\n%%EOF\n";
assert_eq!(boxes(own), Some([0.0, 0.0, 9.0, 9.0]));
// Only the high-resolution box deferred.
let hires = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 9 9\n%%HiResBoundingBox: (atend)\n%%EndComments\n%%Trailer\n%%HiResBoundingBox: 0.5 0.5 8.5 8.5\n%%EOF\n";
assert_eq!(boxes(hires), Some([0.5, 0.5, 8.5, 8.5]));
}
#[test]
fn header_and_trailer_scans_are_bounded() {
// A box past the header window is not looked for.
let far = format!("%!PS-Adobe-3.0\n{}%%BoundingBox: 1 2 3 4\n", "%%DocumentFonts: Helvetica\n".repeat(MAX_HEADER / 27 + 1));
assert_eq!(boxes(&far), None);
let near = format!("%!PS-Adobe-3.0\n{}%%BoundingBox: 1 2 3 4\n", "%%DocumentFonts: Helvetica\n".repeat(100));
assert_eq!(boxes(&near), Some([1.0, 2.0, 3.0, 4.0]));
// A deferred box before the trailer window is not found; inside it, it is.
let filler = "0 0 moveto\n".repeat(MAX_TRAILER / 11 + 1);
let before = format!("%!PS-Adobe-3.0\n%%BoundingBox: (atend)\n%%EndComments\n%%BoundingBox: 1 2 3 4\n{filler}%%EOF\n");
assert_eq!(boxes(&before), None);
let inside = format!("%!PS-Adobe-3.0\n%%BoundingBox: (atend)\n%%EndComments\n{filler}%%Trailer\n%%BoundingBox: 1 2 3 4\n%%EOF\n");
assert_eq!(boxes(&inside), Some([1.0, 2.0, 3.0, 4.0]));
}
#[test]
fn other_header_comments() {
let c = Comments::read(b"%!PS-Adobe-3.0\n%%Creator: (Some App 1.0)\n%%Title: (A drawing)\n%%Pages: 3\n%%LanguageLevel: 2\n%%EndComments\n");
assert_eq!((c.creator.as_deref(), c.title.as_deref(), c.pages, c.language_level), (Some("Some App 1.0"), Some("A drawing"), Some(3), Some(2)));
assert!(!c.illustrator);
assert_eq!(Comments::read(b"%!PS-Adobe-3.0\n%%Title: plain.eps\n").title.as_deref(), Some("plain.eps"));
assert_eq!(Comments::read(b"%!PS-Adobe-3.0\n%%Pages: (atend)\n").pages, None);
}
#[test]
fn files_are_told_apart_by_content() {
let kind = |b: &[u8]| sniff(b).map(|s| (s.kind, s.illustrator));
assert_eq!(kind(EPS), Some((Kind::Eps, false)));
assert!(is_eps(EPS) && is_eps(&dos(EPS, b"", b"TIFF")));
assert_eq!(kind(b"%!PS-Adobe-3.0\nnot encapsulated"), Some((Kind::PostScript, false)));
assert!(!is_eps(b"%!PS-Adobe-3.0\nnot encapsulated"));
assert_eq!(kind(b"%!\nshowpage"), Some((Kind::PostScript, false)));
assert_eq!(kind(b"<svg/>"), None);
assert_eq!(kind(b""), None);
// PostScript-based Illustrator files: by creator, or by the format's own comments.
let by_creator = b"%!PS-Adobe-3.0 EPSF-3.0\n%%Creator: Adobe Illustrator(R) 8.0\n%%BoundingBox: 0 0 1 1\n";
assert_eq!(kind(by_creator), Some((Kind::Eps, true)));
assert_eq!(kind(b"%!PS-Adobe-3.0\n%%BoundingBox: 0 0 1 1\n%AI5_FileFormat 3\n%%EndComments\n"), Some((Kind::PostScript, true)));
assert_eq!(kind(&dos(by_creator, b"", b"TIFF")), Some((Kind::Eps, true)));
// A comment of the format past the header is not the header's.
assert_eq!(kind(b"%!PS-Adobe-3.0\n%%EndComments\n%AI5_BeginLayer\n"), Some((Kind::PostScript, false)));
// PDF-based: the private data keys near the start or the end.
assert_eq!(kind(b"%PDF-1.5\n1 0 obj<</Type/Catalog>>endobj\n"), Some((Kind::Pdf, false)));
assert_eq!(kind(b"%PDF-1.5\n1 0 obj<</Illustrator 2 0 R>>\n2 0 obj<</Private<</AIPrivateData1 3 0 R>>>>"), Some((Kind::Pdf, true)));
assert_eq!(kind(b"%PDF-1.6\n<</AIPDFPrivateData1 3 0 R>>"), Some((Kind::Pdf, true)));
let mut big = b"%PDF-1.5\n".to_vec();
big.extend(std::iter::repeat_n(b' ', MAX_PDF_SCAN));
big.extend_from_slice(b"<</AIPrivateData1 3 0 R>>");
big.extend(std::iter::repeat_n(b' ', MAX_PDF_SCAN));
assert_eq!(kind(&big), Some((Kind::Pdf, false)), "the key is outside both windows");
big.truncate(big.len() - 100);
assert_eq!(kind(&big), Some((Kind::Pdf, true)));
}
#[test]
fn epsi_previews() {
// 4 by 2 pixels, one bit each: 1 is black. Rows are padded to bytes.
let ps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 4 2\n%%BeginPreview: 4 2 1 2\n%A0\n%50\n%%EndPreview\n";
let b = epsi(ps).unwrap();
assert_eq!((b.width, b.height), (4, 2));
assert_eq!(b.gray, [0, 255, 0, 255, 255, 0, 255, 0]);
// Eight bits a pixel: 0 is white, 255 black; hex in either case, split over lines anywhere.
let ps = b"%%BeginPreview: 3 1 8 2\n%00f\n%F80\n%%EndPreview\n";
assert_eq!(epsi(ps).unwrap().gray, [255, 0, 127]);
// Two bits a pixel.
assert_eq!(epsi(b"%%BeginPreview: 4 1 2 1\n%1B\n%%EndPreview\n").unwrap().gray, [255, 170, 85, 0]);
// Missing samples, a line that is no comment, a depth or a size outside the limits: none.
assert_eq!(epsi(b"%%BeginPreview: 4 2 1 2\n%A0\n%%EndPreview\n"), None);
assert_eq!(epsi(b"%%BeginPreview: 4 2 1 2\n%A0\nnewpath\n%50\n%%EndPreview\n"), None);
assert_eq!(epsi(b"%%BeginPreview: 4 2 3 2\n%A0\n%50\n%%EndPreview\n"), None);
assert_eq!(epsi(b"%%BeginPreview: 0 2 1 2\n%A0\n%50\n%%EndPreview\n"), None);
assert_eq!(epsi(format!("%%BeginPreview: {} 1 1 1\n%A0\n%%EndPreview\n", MAX_EPSI + 1).as_bytes()), None);
assert_eq!(epsi(format!("%%BeginPreview: 1 {} 1 1\n%A0\n%%EndPreview\n", MAX_EPSI + 1).as_bytes()), None);
assert_eq!(epsi(b"%%BeginPreview: 99999999999999999999 1 1 1\n%A0\n"), None);
assert_eq!(epsi(EPS), None);
// The largest size claimed without its samples allocates nothing of that size and is none.
assert_eq!(epsi(format!("%%BeginPreview: {MAX_EPSI} {MAX_EPSI} 8 1\n%00\n%%EndPreview\n").as_bytes()), None);
}
#[test]
fn ascii85_round_trips() {
for len in 0..40usize {
let data: Vec<u8> = (0..len).map(|i| (i * 37 + len * 11) as u8).collect();
let text = ascii85::encode(&data);
assert!(text.ends_with("~>\n"));
assert_eq!(ascii85::decode(text.as_bytes(), len), Some(data.clone()), "{len} bytes");
if len > 0 {
assert_eq!(ascii85::decode(text.as_bytes(), len - 1), None, "one byte over the limit");
}
}
// Four zero bytes are one `z`; the published example of the encoding.
assert_eq!(ascii85::encode(&[0; 8]), "zz~>\n");
assert_eq!(ascii85::encode(b"Man "), "9jqo^~>\n");
assert_eq!(ascii85::decode(b"9jqo^~>", 4), Some(b"Man ".to_vec()));
assert_eq!(ascii85::decode(b" 9jq\no^ ~>trailing", 4), Some(b"Man ".to_vec()), "whitespace is ignored, the end ends it");
// Long data is broken into lines, none starting with `%`.
let data: Vec<u8> = (0..4000u32).flat_map(|i| (i.wrapping_mul(2_654_435_761)).to_be_bytes()).collect();
let text = ascii85::encode(&data);
assert!(text.lines().all(|l| !l.starts_with('%') && l.len() <= 80), "{text}");
assert!(ascii85::encode(&[0x0C, 0x80, 0, 0, 1, 2, 3, 4]).lines().all(|l| !l.starts_with('%')));
assert_eq!(ascii85::decode(text.as_bytes(), data.len()), Some(data));
}
#[test]
fn ascii85_refuses_malformed_and_oversized_text() {
assert_eq!(ascii85::decode(b"9jqo^", 100), None, "no end");
assert_eq!(ascii85::decode(b"9jqo^~", 100), None);
assert_eq!(ascii85::decode(b"9~>", 100), None, "one left-over character");
assert_eq!(ascii85::decode(b"9jzo^~>", 100), None, "z inside a group");
assert_eq!(ascii85::decode(b"9jqov~>", 100), None, "a character outside the alphabet");
assert_eq!(ascii85::decode(b"s8W-\"~>", 100), None, "a group past 2^32");
assert_eq!(ascii85::decode(b"s8W-!~>", 100), Some(vec![0xFF; 4]));
// A megabyte of `z` is four megabytes: refused at the limit, not after allocating them all.
let bomb = [vec![b'z'; 1 << 20], b"~>".to_vec()].concat();
assert_eq!(ascii85::decode(&bomb, 1 << 16), None);
assert_eq!(ascii85::decode(&bomb, 4 << 20).map(|v| v.len()), Some(4 << 20));
}