Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,13 @@ craft-affinity = { path = "crates/affinity" }
craft-ai-core = { path = "crates/ai-core" }
craft-anicraft-format = { path = "crates/anicraft-format" }
craft-pdf = { path = "crates/pdf", version = "=0.1.0" }
craft-ps = { path = "crates/ps", version = "=0.1.0" }
craft-pdn = { path = "crates/pdn" }
craft-plate-test = { path = "crates/plate-test" }
pdf-writer = "0.15"
miniz_oxide = "0.8"
weezl = "0.1"
skrifa = "0.47"
krilla = { version = "=0.8.2", default-features = false, features = ["raster-images"] }
hayro = { version = "=0.7.1", default-features = false }
hayro-interpret = { version = "=0.7.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] }
Expand Down
2 changes: 2 additions & 0 deletions crates/eps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ 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 |
| `postscript_section` | The PostScript program alone; a damaged preview does not prevent a reader from using a valid program section |
| `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` |
| `Comments::read_with_header_limit` | The same comments with a caller-selected header scan limit capped at 256 KiB; `read` defaults to 64 KiB |
| `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 |

Expand Down
36 changes: 26 additions & 10 deletions crates/eps/src/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,24 +27,40 @@ pub struct Sections<'a> {
pub metafile: Option<&'a [u8]>,
}

fn word(bytes: &[u8], at: usize) -> Result<usize, Error> {
let end = at.checked_add(4).ok_or(Error::OutsideFile)?;
let b = bytes.get(at..end).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)
}

fn part(bytes: &[u8], at: usize) -> Result<&[u8], Error> {
let len_at = at.checked_add(4).ok_or(Error::OutsideFile)?;
let (start, len) = (word(bytes, at)?, word(bytes, len_at)?);
start.checked_add(len).and_then(|end| bytes.get(start..end)).ok_or(Error::OutsideFile)
}

/// The PostScript program in an EPS, ignoring preview ranges in its binary header.
///
/// This is useful to readers that execute only PostScript and historically accepted a valid
/// program section even when a separate preview range was damaged. Use [`sections`] when every
/// declared section must be validated.
pub fn postscript_section(bytes: &[u8]) -> Result<&[u8], Error> {
if bytes.starts_with(&DOS_MAGIC) { part(bytes, 4) } else { Ok(bytes) }
}

/// 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)? })
let preview = |at: usize| -> Result<Option<&[u8]>, Error> {
let len_at = at.checked_add(4).ok_or(Error::OutsideFile)?;
if word(bytes, at)? == 0 || word(bytes, len_at)? == 0 { Ok(None) } else { part(bytes, at).map(Some) }
};
Ok(Sections { postscript: postscript_section(bytes)?, metafile: preview(12)?, tiff: preview(20)? })
}

/// `postscript` behind a binary header, followed by its previews. With neither preview the
Expand Down
14 changes: 13 additions & 1 deletion crates/eps/src/dsc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

/// Bytes of a file's start read for its header comments.
pub const MAX_HEADER: usize = 64 << 10;
/// Largest header scan available for readers preserving a wider historical bound.
pub const MAX_EXTENDED_HEADER: usize = 256 << 10;
/// Bytes of a file's end read for comments its header defers there with `(atend)`.
pub const MAX_TRAILER: usize = 64 << 10;

Expand Down Expand Up @@ -34,7 +36,17 @@ impl Comments {
/// 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));
Self::read_with_header_limit(ps, MAX_HEADER)
}

/// Read comments with a caller-selected scan limit for the header.
///
/// The limit is capped at [`MAX_EXTENDED_HEADER`], and the trailer remains capped at
/// [`MAX_TRAILER`]. This lets readers preserve a previously supported wider scan without
/// making header or trailer work unbounded.
pub fn read_with_header_limit(ps: &[u8], header_limit: usize) -> Self {
let header_limit = header_limit.min(MAX_EXTENDED_HEADER).min(ps.len());
let head = String::from_utf8_lossy(&ps[..header_limit]);
let mut c = Comments::default();
let (mut box_atend, mut hires_atend) = (false, false);
// Lines may end in `\r` alone (classic Mac files).
Expand Down
4 changes: 2 additions & 2 deletions crates/eps/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ pub mod postscript;
mod preview;
mod rectangles;

pub use container::{DOS_HEADER, DOS_MAGIC, Sections, sections, wrap};
pub use dsc::{Comments, MAX_HEADER, MAX_TRAILER};
pub use container::{DOS_HEADER, DOS_MAGIC, Sections, postscript_section, sections, wrap};
pub use dsc::{Comments, MAX_EXTENDED_HEADER, MAX_HEADER, MAX_TRAILER};
pub use embedded::{EmbeddedFontProgram, embedded_font_programs};
pub use preview::{Bitmap, MAX_EPSI, epsi};
pub use rectangles::{MAX_SIMPLE_RECTANGLE_SAVE_DEPTH, MAX_SIMPLE_RECTANGLE_TOKENS, MAX_SIMPLE_RECTANGLES, Rectangle, simple_rectangles};
Expand Down
18 changes: 18 additions & 0 deletions crates/eps/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,24 @@ fn sections_of_plain_and_binary_files() {
assert_eq!(sections(&file), Ok(Sections { postscript: EPS, tiff: Some(b"TIFF"), metafile: Some(b"WMF") }));
}

#[test]
fn postscript_section_survives_a_damaged_preview_range() {
let ps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n";
let mut bytes = wrap(ps, Some(b"tiff"), None).unwrap();
bytes[20..24].copy_from_slice(&u32::MAX.to_le_bytes());
bytes[24..28].copy_from_slice(&1u32.to_le_bytes());
assert_eq!(postscript_section(&bytes), Ok(ps.as_slice()));
assert_eq!(sections(&bytes), Err(Error::OutsideFile));
}

#[test]
fn extended_comment_scan_keeps_its_upper_bound() {
let mut ps = b"%!PS-Adobe-3.0 EPSF-3.0\n".to_vec();
ps.resize(ps.len() + MAX_EXTENDED_HEADER + 1, b' ');
ps.extend_from_slice(b"\n%%BoundingBox: 0 0 20 10\n%%EndComments\n");
assert_eq!(Comments::read_with_header_limit(&ps, usize::MAX).page_box(), None);
}

#[test]
fn a_header_that_points_outside_the_file_is_refused() {
let file = dos(EPS, b"WMF", b"TIFF");
Expand Down
27 changes: 27 additions & 0 deletions crates/ps/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
[package]
name = "craft-ps"
description = "PostScript and EPS for Craft apps: an EPS interpreter that paints into PDF"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true

[lints]
workspace = true

[features]
default = []
read = ["dep:craft-eps", "dep:pdf-writer", "dep:miniz_oxide", "dep:weezl", "dep:skrifa", "dep:kurbo"]

[dependencies]
craft-eps = { workspace = true, optional = true }
pdf-writer = { workspace = true, optional = true }
miniz_oxide = { workspace = true, optional = true }
weezl = { workspace = true, optional = true }
skrifa = { workspace = true, optional = true }
kurbo = { workspace = true, optional = true }

[[example]]
name = "eps2pdf"
required-features = ["read"]
50 changes: 50 additions & 0 deletions crates/ps/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# craft-ps

PostScript and EPS for the Craft apps. No feature is enabled by default.

| Feature | Provides |
| --- | --- |
| `read` | An EPS interpreter: runs the program and returns what it paints as a one-page PDF |

Writing PostScript and EPS (craft-libs #115) is planned as a separate feature next to `read`.

## Reading EPS

`read::eps_to_pdf(bytes, limits)` runs an EPS (plain, or DOS EPS with a binary header) and
returns a one-page PDF 1.4 the size of its bounding box: vectors stay vectors, CMYK and spot
colours stay as they are, so an app can draw and export the result as a placed PDF.
`read::cached_pdf` converts with the default limits and remembers the result by content.
`is_eps`, `bounding_box` and `postscript_section` read the file without running it.
EPS binary sections and DSC comments are read by `craft-eps`.

It is written from the PostScript Language Reference (third edition), the EPS specification,
the Type 1 Font Format and Adobe Technical Notes 5012 (Type 42), 5176 (CFF) and 5177 (Type 2
charstrings); no GPL code was used. It covers what EPS files use:

- the language: Level 3 operators, an explicit execution stack (no Rust recursion for
PostScript control flow), `stopped`, resources, `eexec`;
- graphics: paths, painting, clipping, `strokepath`, user paths and forms;
- colour: device spaces, Separation and DeviceN, Indexed, ICCBased, Lab;
- images, with filters decoded as they are read (ASCIIHex, ASCII85, RunLength, LZW, Flate with
predictors, SubFile, ReusableStream; JPEG is passed through);
- smooth shadings (types 1–7, meshes included), shading and tiling patterns;
- text in Type 1, Type 3, Type 42 and CFF fonts (`StartData`), CIDFonts and composite fonts
through CMaps, horizontal and vertical.

Not covered: Level 2 binary tokens, CIDFontType 0 with Type 1 charstrings, CIDFontType 1 and
the old composite FMapTypes other than 2 and 9.

## Limits

`Limits { steps, memory, stack_overflow_noop, skip_images, keep_unpainted, zero_division_zero, fallback_font }` bounds one conversion. The optional font callback supplies host stand-ins for missing fonts; booleans let a host retain prior operand-overflow, image, partial-page, and numeric-coercion behavior. Every operator charges the work it does in
proportion to its operands (elements copied or searched, path segments, glyphs, samples)
against `steps` as it goes, and every byte read or decoded at any filter level counts too, so
the step limit bounds run time. `memory` bounds everything a conversion makes, copies
included. The defaults are 50 million steps and 1 GiB, or 20 million and 256 MiB on wasm32;
real EPS files use at most a few million steps and tens of megabytes, and a program that runs
into the limits ends within seconds. By default a program that fails or exceeds them before
painting anything gives `Err`; setting `keep_unpainted` returns a blank page with the warning.
One that fails later keeps what it painted (with a warning).

`cargo run -p craft-ps --features read --example eps2pdf -- in.eps out.pdf` converts one file
and prints the steps and memory it used.
41 changes: 41 additions & 0 deletions crates/ps/examples/eps2pdf.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//! `cargo run -p craft-ps --features read --example eps2pdf -- in.eps out.pdf`: convert one EPS.

fn main() -> std::process::ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let (Some(input), Some(output)) = (args.first(), args.get(1)) else {
eprintln!("usage: eps2pdf in.eps out.pdf");
return std::process::ExitCode::FAILURE;
};
let bytes = match std::fs::read(input) {
Ok(b) => b,
Err(e) => {
eprintln!("{input}: {e}");
return std::process::ExitCode::FAILURE;
}
};
let t = std::time::Instant::now();
match craft_ps::read::eps_to_pdf(&bytes, craft_ps::read::Limits::default()) {
Ok(c) => {
eprintln!(
"{input}: {:.0} ms, bbox {:?}, {} warnings, {} steps, {} KiB",
t.elapsed().as_secs_f64() * 1000.0,
c.bbox,
c.warnings.len(),
c.steps,
c.memory / 1024
);
for w in c.warnings.iter().take(20) {
eprintln!(" {w}");
}
if let Err(e) = std::fs::write(output, &c.pdf) {
eprintln!("{output}: {e}");
return std::process::ExitCode::FAILURE;
}
std::process::ExitCode::SUCCESS
}
Err(e) => {
eprintln!("{input}: {e} ({:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
std::process::ExitCode::FAILURE
}
}
}
9 changes: 9 additions & 0 deletions crates/ps/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//! PostScript and EPS for the Craft apps.
//!
//! - [`read`] (feature `read`): runs an EPS and gives what it paints as a one-page vector PDF,
//! for apps that place EPS files as graphics.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]

#[cfg(feature = "read")]
pub mod read;
Loading
Loading