From f2d658d08fed0db02788351390b665b533542450 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 18:39:42 -0500 Subject: [PATCH] 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). --- Cargo.lock | 4 + Cargo.toml | 1 + README.md | 4 + crates/eps/Cargo.toml | 13 +++ crates/eps/README.md | 68 +++++++++++ crates/eps/src/ascii85.rs | 103 +++++++++++++++++ crates/eps/src/container.rs | 77 +++++++++++++ crates/eps/src/dsc.rs | 122 ++++++++++++++++++++ crates/eps/src/lib.rs | 103 +++++++++++++++++ crates/eps/src/preview.rs | 59 ++++++++++ crates/eps/src/tests.rs | 221 ++++++++++++++++++++++++++++++++++++ 11 files changed, 775 insertions(+) create mode 100644 crates/eps/Cargo.toml create mode 100644 crates/eps/README.md create mode 100644 crates/eps/src/ascii85.rs create mode 100644 crates/eps/src/container.rs create mode 100644 crates/eps/src/dsc.rs create mode 100644 crates/eps/src/lib.rs create mode 100644 crates/eps/src/preview.rs create mode 100644 crates/eps/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 013f57763..9b0db1420 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index a0c4d40b5..c5a15be48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/README.md b/README.md index 4167fbfce..0c4247697 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,10 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "), 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. diff --git a/crates/eps/src/ascii85.rs b/crates/eps/src/ascii85.rs new file mode 100644 index 000000000..944c2e0b5 --- /dev/null +++ b/crates/eps/src/ascii85.rs @@ -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> { + 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 { + digits.iter().try_fold(0u32, |acc, d| acc.checked_mul(85)?.checked_add(u32::from(d.checked_sub(b'!')?))) +} diff --git a/crates/eps/src/container.rs b/crates/eps/src/container.rs new file mode 100644 index 000000000..ba721f5c2 --- /dev/null +++ b/crates/eps/src/container.rs @@ -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, Error> { + if !bytes.starts_with(&DOS_MAGIC) { + return Ok(Sections { postscript: bytes, tiff: None, metafile: None }); + } + let word = |at: usize| -> Result { + 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, 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, 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) +} diff --git a/crates/eps/src/dsc.rs b/crates/eps/src/dsc.rs new file mode 100644 index 000000000..835f9b23f --- /dev/null +++ b/crates/eps/src/dsc.rs @@ -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, + /// `%%LanguageLevel`. + pub language_level: Option, + /// `%%Creator`, without the parentheses some writers put around it. + pub creator: Option, + /// `%%Title`, likewise. + pub title: Option, + /// 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 +} diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs new file mode 100644 index 000000000..acffdfcf2 --- /dev/null +++ b/crates/eps/src/lib.rs @@ -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 { + 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; diff --git a/crates/eps/src/preview.rs b/crates/eps/src/preview.rs new file mode 100644 index 000000000..95ed3a79d --- /dev/null +++ b/crates/eps/src/preview.rs @@ -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, +} + +/// 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 { + 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::().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 }) +} diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs new file mode 100644 index 000000000..7ac4c84ca --- /dev/null +++ b/crates/eps/src/tests.rs @@ -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 { + 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""), 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<>endobj\n"), Some((Kind::Pdf, false))); + assert_eq!(kind(b"%PDF-1.5\n1 0 obj<>\n2 0 obj<>>>"), Some((Kind::Pdf, true))); + assert_eq!(kind(b"%PDF-1.6\n<>"), 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"<>"); + 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 = (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 = (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)); +}