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)); +}