Skip to main content

document_svg/document/
eps.rs

1//! Bounded ASCII PostScript/EPS vector preview.
2//!
3//! This reader supports a safe subset of path construction, colors, line
4//! widths, fill/stroke, and showpage. It never executes PostScript procedures,
5//! file operators, external resources, or embedded code.
6
7use std::path::Path;
8
9use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
10use crate::error::{Error, Result};
11use crate::ir::{IDENTITY, Node, Page, Paint, SourceMeta, Stroke};
12
13const MAX_EPS_INPUT_BYTES: u64 = 64 * 1024 * 1024;
14const MAX_EPS_LINES: usize = 1_000_000;
15const MAX_EPS_TOKENS: usize = 5_000_000;
16const MAX_EPS_PATHS: usize = 200_000;
17const MAX_EPS_COORDINATE: f64 = 1_000_000.0;
18const MAX_EPS_PAGE_WIDTH: f64 = 20_000.0;
19const MAX_EPS_PAGE_HEIGHT: f64 = 20_000.0;
20
21#[derive(Clone)]
22struct PathState {
23    d: String,
24    has_path: bool,
25}
26
27pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
28    let text = String::from_utf8_lossy(bytes);
29    text.lines()
30        .take(32)
31        .any(|line| line.trim_start().starts_with("%!PS") || line.contains("%%BoundingBox:"))
32}
33
34pub(crate) fn convert(
35    path: &Path,
36    options: &ConvertOptions,
37    sink: &mut dyn PageConsumer,
38) -> Result<Vec<String>> {
39    let bytes = read_limited_file(
40        path,
41        options.max_input_bytes.min(MAX_EPS_INPUT_BYTES),
42        "PostScript input",
43    )?;
44    let text = std::str::from_utf8(&bytes).map_err(|error| {
45        Error::InvalidInput(format!("PostScript input is not ASCII/UTF-8: {error}"))
46    })?;
47    let (pages, warnings) = parse_document(text, options.max_pages)?;
48    for page in pages {
49        sink.consume(page)?;
50    }
51    Ok(warnings)
52}
53
54fn parse_document(text: &str, max_pages: usize) -> Result<(Vec<Page>, Vec<String>)> {
55    let (bbox, mut warnings) = parse_bbox(text)?;
56    let width = (bbox[2] - bbox[0]).clamp(1.0, MAX_EPS_PAGE_WIDTH);
57    let height = (bbox[3] - bbox[1]).clamp(1.0, MAX_EPS_PAGE_HEIGHT);
58    let mut pages = Vec::new();
59    let mut page = Page::new(1, width, height, "eps");
60    page.title = "PostScript page 1".into();
61    let mut stack = Vec::<f64>::new();
62    let mut path = PathState {
63        d: String::new(),
64        has_path: false,
65    };
66    let mut fill = Paint::None;
67    let mut stroke = Paint::solid("#000000");
68    let mut line_width = 1.0;
69    let mut paths = 0usize;
70    let mut tokens = 0usize;
71    let mut line_count = 0usize;
72    for raw_line in text.lines() {
73        line_count = line_count.saturating_add(1);
74        if line_count > MAX_EPS_LINES {
75            return Err(Error::LimitExceeded(format!(
76                "PostScript exceeds {MAX_EPS_LINES} lines"
77            )));
78        }
79        let line = raw_line.trim();
80        if line.starts_with('%') {
81            continue;
82        }
83        for token in line.split_whitespace() {
84            tokens = tokens.saturating_add(1);
85            if tokens > MAX_EPS_TOKENS {
86                return Err(Error::LimitExceeded(format!(
87                    "PostScript exceeds {MAX_EPS_TOKENS} tokens"
88                )));
89            }
90            if let Ok(value) = token.parse::<f64>() {
91                if !value.is_finite() || value.abs() > MAX_EPS_COORDINATE {
92                    return Err(Error::InvalidInput(
93                        "PostScript coordinate/value is out of range".into(),
94                    ));
95                }
96                stack.push(value);
97                continue;
98            }
99            match token {
100                "newpath" => {
101                    path = PathState {
102                        d: String::new(),
103                        has_path: false,
104                    }
105                }
106                "moveto" => {
107                    let (x, y) = pop_pair(&mut stack)?;
108                    path.d.push_str(&format!(
109                        "M {:.2},{:.2} ",
110                        x - bbox[0],
111                        height - (y - bbox[1])
112                    ));
113                    path.has_path = true;
114                }
115                "lineto" => {
116                    let (x, y) = pop_pair(&mut stack)?;
117                    path.d.push_str(&format!(
118                        "L {:.2},{:.2} ",
119                        x - bbox[0],
120                        height - (y - bbox[1])
121                    ));
122                    path.has_path = true;
123                }
124                "curveto" => {
125                    let (x3, y3) = pop_pair(&mut stack)?;
126                    let (x2, y2) = pop_pair(&mut stack)?;
127                    let (x1, y1) = pop_pair(&mut stack)?;
128                    path.d.push_str(&format!(
129                        "C {:.2},{:.2} {:.2},{:.2} {:.2},{:.2} ",
130                        x1 - bbox[0],
131                        height - (y1 - bbox[1]),
132                        x2 - bbox[0],
133                        height - (y2 - bbox[1]),
134                        x3 - bbox[0],
135                        height - (y3 - bbox[1])
136                    ));
137                    path.has_path = true;
138                }
139                "closepath" => path.d.push_str("Z "),
140                "setrgbcolor" => {
141                    let b = stack.pop().ok_or_else(|| {
142                        Error::InvalidInput("PostScript setrgbcolor is missing blue".into())
143                    })?;
144                    let g = stack.pop().ok_or_else(|| {
145                        Error::InvalidInput("PostScript setrgbcolor is missing green".into())
146                    })?;
147                    let r = stack.pop().ok_or_else(|| {
148                        Error::InvalidInput("PostScript setrgbcolor is missing red".into())
149                    })?;
150                    let color = format!(
151                        "#{:02X}{:02X}{:02X}",
152                        (r.clamp(0.0, 1.0) * 255.0) as u8,
153                        (g.clamp(0.0, 1.0) * 255.0) as u8,
154                        (b.clamp(0.0, 1.0) * 255.0) as u8
155                    );
156                    fill = Paint::solid(&color);
157                    stroke = Paint::solid(color);
158                }
159                "setgray" => {
160                    let gray = stack.pop().ok_or_else(|| {
161                        Error::InvalidInput("PostScript setgray is missing value".into())
162                    })?;
163                    let channel = (gray.clamp(0.0, 1.0) * 255.0) as u8;
164                    let color = format!("#{channel:02X}{channel:02X}{channel:02X}");
165                    fill = Paint::solid(&color);
166                    stroke = Paint::solid(color);
167                }
168                "setlinewidth" => line_width = stack.pop().unwrap_or(1.0).clamp(0.1, 100.0),
169                "fill" | "eofill" => emit_path(
170                    &mut page,
171                    &mut path,
172                    fill.clone(),
173                    Paint::None,
174                    line_width,
175                    &mut paths,
176                    &mut warnings,
177                )?,
178                "stroke" => emit_path(
179                    &mut page,
180                    &mut path,
181                    Paint::None,
182                    stroke.clone(),
183                    line_width,
184                    &mut paths,
185                    &mut warnings,
186                )?,
187                "showpage" => {
188                    if !page.nodes.is_empty() {
189                        if pages.len() + 1 >= max_pages {
190                            return Err(Error::LimitExceeded(
191                                "PostScript exceeded max_pages".into(),
192                            ));
193                        }
194                        pages.push(page);
195                        let number = pages.len() + 1;
196                        page = Page::new(number, width, height, "eps");
197                        page.title = format!("PostScript page {number}");
198                    }
199                }
200                "gsave" | "grestore" => push_warning_once(
201                    &mut warnings,
202                    "PostScript graphics state save/restore is approximated",
203                ),
204                "show" | "ashow" | "stringwidth" => {
205                    push_warning_once(&mut warnings, "PostScript text operators are omitted")
206                }
207                "run" | "exec" | "file" | "system" | "deletefile" => push_warning_once(
208                    &mut warnings,
209                    "PostScript execution/file operators were blocked",
210                ),
211                _ if token
212                    .chars()
213                    .any(|character| character.is_ascii_alphabetic()) => {}
214                _ => {}
215            }
216        }
217    }
218    if !page.nodes.is_empty() {
219        pages.push(page);
220    }
221    if pages.is_empty() {
222        return Err(Error::InvalidInput(
223            "PostScript contains no renderable paths".into(),
224        ));
225    }
226    Ok((pages, warnings))
227}
228
229fn parse_bbox(text: &str) -> Result<([f64; 4], Vec<String>)> {
230    for line in text.lines() {
231        if let Some(rest) = line
232            .strip_prefix("%%BoundingBox:")
233            .or_else(|| line.strip_prefix("%%HiResBoundingBox:"))
234        {
235            let values = rest
236                .split_whitespace()
237                .filter_map(|value| value.parse::<f64>().ok())
238                .collect::<Vec<_>>();
239            if values.len() == 4
240                && values.iter().all(|value| value.is_finite())
241                && values[2] > values[0]
242                && values[3] > values[1]
243            {
244                return Ok(([values[0], values[1], values[2], values[3]], Vec::new()));
245            }
246        }
247    }
248    Err(Error::InvalidInput(
249        "PostScript input has no valid %%BoundingBox".into(),
250    ))
251}
252
253fn pop_pair(stack: &mut Vec<f64>) -> Result<(f64, f64)> {
254    let y = stack
255        .pop()
256        .ok_or_else(|| Error::InvalidInput("PostScript path operator is missing y".into()))?;
257    let x = stack
258        .pop()
259        .ok_or_else(|| Error::InvalidInput("PostScript path operator is missing x".into()))?;
260    Ok((x, y))
261}
262
263fn emit_path(
264    page: &mut Page,
265    path: &mut PathState,
266    fill: Paint,
267    stroke: Paint,
268    width: f64,
269    paths: &mut usize,
270    warnings: &mut Vec<String>,
271) -> Result<()> {
272    if !path.has_path || path.d.trim().is_empty() {
273        return Ok(());
274    }
275    *paths = paths.saturating_add(1);
276    if *paths > MAX_EPS_PATHS {
277        return Err(Error::LimitExceeded(format!(
278            "PostScript exceeds {MAX_EPS_PATHS} paths"
279        )));
280    }
281    page.nodes.push(Node::Path {
282        id: format!("eps-path-{}", *paths),
283        d: path.d.clone(),
284        fill_rule: "evenodd".into(),
285        fill,
286        stroke: Stroke {
287            paint: stroke,
288            width,
289            ..Default::default()
290        },
291        transform: IDENTITY,
292        clip_id: None,
293        meta: SourceMeta {
294            semantic_role: "eps:path".into(),
295            ..Default::default()
296        },
297    });
298    path.d.clear();
299    path.has_path = false;
300    let _ = warnings;
301    Ok(())
302}
303
304fn push_warning_once(warnings: &mut Vec<String>, warning: &str) {
305    if !warnings.iter().any(|existing| existing == warning) {
306        warnings.push(warning.to_owned());
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::{parse_bbox, parse_document};
313
314    #[test]
315    fn accepts_hires_bounding_box() {
316        let (bbox, _) =
317            parse_bbox("%!PS-Adobe-3.0\n%%HiResBoundingBox: 0.5 1.0 20.5 30.0\n").unwrap();
318        assert_eq!(bbox, [0.5, 1.0, 20.5, 30.0]);
319    }
320
321    #[test]
322    fn blocks_postscript_execution_operators() {
323        let source = "%!PS-Adobe-3.0\n%%BoundingBox: 0 0 10 10\n(ignored) run\nnewpath 0 0 moveto 10 10 lineto stroke\n";
324        let (pages, warnings) = parse_document(source, 2).unwrap();
325        assert_eq!(pages.len(), 1);
326        assert!(
327            warnings
328                .iter()
329                .any(|warning| warning.contains("execution/file"))
330        );
331    }
332}