Skip to main content

stet_core/
eps.rs

1// stet - A PostScript Interpreter
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! EPS file support: DOS binary header stripping and BoundingBox parsing.
6
7/// DOS EPS binary header magic bytes.
8const DOS_EPS_MAGIC: [u8; 4] = [0xC5, 0xD0, 0xD3, 0xC6];
9
10/// Strip a DOS EPS binary header if present, returning the PostScript portion.
11///
12/// DOS EPS files start with a 30-byte header containing magic bytes `C5 D0 D3 C6`,
13/// followed by a little-endian u32 offset and u32 length pointing to the embedded
14/// PostScript section. If the magic is not found, the data is returned unchanged.
15pub fn strip_dos_eps_header(data: &[u8]) -> &[u8] {
16    if data.len() < 12 || data[..4] != DOS_EPS_MAGIC {
17        return data;
18    }
19
20    let ps_offset = u32::from_le_bytes([data[4], data[5], data[6], data[7]]) as usize;
21    let ps_length = u32::from_le_bytes([data[8], data[9], data[10], data[11]]) as usize;
22
23    let start = ps_offset.min(data.len());
24    let end = (ps_offset + ps_length).min(data.len());
25    &data[start..end]
26}
27
28/// Detect EPS by content: checks if the first line contains `EPSF` (e.g. `%!PS-Adobe-2.0 EPSF-1.2`).
29///
30/// This catches EPS files with `.ps` extensions that GhostScript would still treat as EPS.
31pub fn content_is_epsf(data: &[u8]) -> bool {
32    // Check just the first line (up to first newline or 256 bytes)
33    let end = data
34        .iter()
35        .position(|&b| b == b'\n' || b == b'\r')
36        .unwrap_or(data.len().min(256));
37    let first_line = &data[..end];
38    first_line.windows(4).any(|w| w == b"EPSF")
39}
40
41/// Parse a `%%BoundingBox` or `%%HiResBoundingBox` DSC comment from EPS data.
42///
43/// Scans the first 4096 bytes for the bounding box comment. Prefers
44/// `%%HiResBoundingBox` (float values) over `%%BoundingBox` (integer values).
45/// Handles `%%BoundingBox: (atend)` by also scanning the last 4096 bytes.
46///
47/// Returns `Some((llx, lly, urx, ury))` or `None` if not found.
48pub fn read_eps_bounding_box(data: &[u8]) -> Option<(f64, f64, f64, f64)> {
49    // Scan the header portion first
50    let header_end = data.len().min(4096);
51    let header = &data[..header_end];
52
53    let mut bbox = None;
54    let mut hires_bbox = None;
55    let mut need_atend = false;
56
57    scan_for_bbox(header, &mut bbox, &mut hires_bbox, &mut need_atend);
58
59    // If %%BoundingBox: (atend), scan the trailer too
60    if need_atend && data.len() > 4096 {
61        let trailer_start = data.len().saturating_sub(4096);
62        let trailer = &data[trailer_start..];
63        scan_for_bbox(trailer, &mut bbox, &mut hires_bbox, &mut need_atend);
64    }
65
66    hires_bbox.or(bbox)
67}
68
69/// Scan a byte slice for BoundingBox and HiResBoundingBox comments.
70fn scan_for_bbox(
71    data: &[u8],
72    bbox: &mut Option<(f64, f64, f64, f64)>,
73    hires_bbox: &mut Option<(f64, f64, f64, f64)>,
74    need_atend: &mut bool,
75) {
76    for line in data.split(|&b| b == b'\n' || b == b'\r') {
77        if line.is_empty() {
78            continue;
79        }
80
81        if line.starts_with(b"%%HiResBoundingBox:") {
82            let rest = &line[b"%%HiResBoundingBox:".len()..];
83            if let Some(values) = parse_four_numbers(rest) {
84                *hires_bbox = Some(values);
85            }
86        } else if line.starts_with(b"%%BoundingBox:") {
87            let rest = &line[b"%%BoundingBox:".len()..];
88            let trimmed = trim_ascii(rest);
89            if trimmed == b"(atend)" {
90                *need_atend = true;
91            } else if let Some(values) = parse_four_numbers(rest) {
92                *bbox = Some(values);
93            }
94        }
95    }
96}
97
98/// Parse four whitespace-separated numbers from a byte slice.
99fn parse_four_numbers(data: &[u8]) -> Option<(f64, f64, f64, f64)> {
100    let s = std::str::from_utf8(data).ok()?;
101    let mut nums = s.split_whitespace().filter_map(|w| w.parse::<f64>().ok());
102    let llx = nums.next()?;
103    let lly = nums.next()?;
104    let urx = nums.next()?;
105    let ury = nums.next()?;
106    Some((llx, lly, urx, ury))
107}
108
109/// Trim leading and trailing ASCII whitespace from a byte slice.
110fn trim_ascii(data: &[u8]) -> &[u8] {
111    let start = data
112        .iter()
113        .position(|b| !b.is_ascii_whitespace())
114        .unwrap_or(data.len());
115    let end = data
116        .iter()
117        .rposition(|b| !b.is_ascii_whitespace())
118        .map_or(start, |p| p + 1);
119    &data[start..end]
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn test_strip_dos_header() {
128        // Build a synthetic DOS EPS file
129        let ps_content = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n";
130        let ps_offset: u32 = 30; // header is 30 bytes
131        let ps_length = ps_content.len() as u32;
132
133        let mut data = Vec::new();
134        data.extend_from_slice(&DOS_EPS_MAGIC);
135        data.extend_from_slice(&ps_offset.to_le_bytes());
136        data.extend_from_slice(&ps_length.to_le_bytes());
137        // Pad to 30 bytes (TIFF preview offset, length, and checksum)
138        data.resize(30, 0xFF);
139        data.extend_from_slice(ps_content);
140
141        let result = strip_dos_eps_header(&data);
142        assert_eq!(result, ps_content);
143    }
144
145    #[test]
146    fn test_strip_no_header() {
147        let ps_content = b"%!PS-Adobe-3.0\n%%BoundingBox: 0 0 200 300\n";
148        let result = strip_dos_eps_header(ps_content);
149        assert_eq!(result, ps_content);
150    }
151
152    #[test]
153    fn test_content_is_epsf() {
154        assert!(content_is_epsf(
155            b"%!PS-Adobe-2.0 EPSF-1.2\n%%BoundingBox: 0 0 100 100\n"
156        ));
157        assert!(content_is_epsf(
158            b"%!PS-Adobe-3.0 EPSF-3.0\r%%BoundingBox: 0 0 100 100\r"
159        ));
160        assert!(!content_is_epsf(
161            b"%!PS-Adobe-3.0\n%%BoundingBox: 0 0 100 100\n"
162        ));
163        assert!(!content_is_epsf(b"/Helvetica findfont\n"));
164    }
165
166    #[test]
167    fn test_bbox_integer() {
168        let data = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 300\n";
169        let bbox = read_eps_bounding_box(data);
170        assert_eq!(bbox, Some((0.0, 0.0, 200.0, 300.0)));
171    }
172
173    #[test]
174    fn test_bbox_hires_preferred() {
175        let data = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 300\n%%HiResBoundingBox: 0.5 1.5 199.75 299.25\n";
176        let bbox = read_eps_bounding_box(data);
177        assert_eq!(bbox, Some((0.5, 1.5, 199.75, 299.25)));
178    }
179
180    #[test]
181    fn test_bbox_not_found() {
182        let data = b"%!PS-Adobe-3.0\n/Helvetica findfont 12 scalefont setfont\n";
183        let bbox = read_eps_bounding_box(data);
184        assert_eq!(bbox, None);
185    }
186
187    #[test]
188    fn test_bbox_negative_coords() {
189        let data = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: -50 -25 150 275\n";
190        let bbox = read_eps_bounding_box(data);
191        assert_eq!(bbox, Some((-50.0, -25.0, 150.0, 275.0)));
192    }
193
194    #[test]
195    fn test_bbox_cr_line_endings() {
196        // Old Mac-style CR-only line endings (used by some Adobe Illustrator files)
197        let data = b"%!PS-Adobe-3.0 EPSF-3.0\r%%BoundingBox: 0 9 360 135\r%%HiResBoundingBox: 0 9.001 360 135\r";
198        let bbox = read_eps_bounding_box(data);
199        assert_eq!(bbox, Some((0.0, 9.001, 360.0, 135.0)));
200    }
201
202    #[test]
203    fn test_bbox_atend() {
204        // Build data larger than 4096 bytes with (atend) in header and bbox in trailer
205        let mut data = Vec::new();
206        data.extend_from_slice(b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: (atend)\n");
207        // Pad to > 4096 bytes
208        data.resize(5000, b' ');
209        data.extend_from_slice(b"\n%%BoundingBox: 10 20 300 400\n%%EOF\n");
210
211        let bbox = read_eps_bounding_box(&data);
212        assert_eq!(bbox, Some((10.0, 20.0, 300.0, 400.0)));
213    }
214}