1use 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}