Skip to main content

stet_core/
output_template.rs

1// stet - A PostScript Interpreter
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Output-path templates for the `-o` / `--output` CLI flag.
6//!
7//! The model is Ghostscript's: the path the user supplies is a *literal
8//! template*, and the decision about per-page naming is made from the string
9//! alone rather than from the number of pages. A template containing a `%d`
10//! conversion expands once per page; one without it names a single file.
11//!
12//! Deciding from the string matters because PostScript page counts are not
13//! knowable in advance — pages arrive as `showpage` executes, so the name of
14//! page 1 has to be committed before anyone knows whether a page 2 exists.
15//! Ghostscript resolves this by opening the literal path once and streaming
16//! every page into it, which silently produces a file holding several
17//! concatenated images (measured against gs 10.05.1: three PNG signatures and
18//! three `IEND` chunks in one `.png`, exit 0, no warning). stet accepts the
19//! same templates but raises [`ExpandError::MultiPageNeedsToken`] on the
20//! second page instead, leaving page 1 on disk intact.
21
22/// A parsed `-o` / `--output` template.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct OutputTemplate {
25    /// The template exactly as the user wrote it.
26    raw: String,
27    /// Position and shape of the `%d` conversion, if the template has one.
28    token: Option<Token>,
29}
30
31/// A `%d` / `%0Nd` conversion inside a template.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33struct Token {
34    /// Byte offset of the leading `%`.
35    start: usize,
36    /// Byte offset one past the trailing `d`.
37    end: usize,
38    /// Minimum field width; `0` when unspecified.
39    width: usize,
40    /// Whether the width is zero-padded (`%03d`) rather than space-padded.
41    zero_pad: bool,
42}
43
44/// Why a template could not be parsed.
45#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
46#[non_exhaustive]
47pub enum TemplateError {
48    /// The template was the empty string.
49    #[error("output path is empty")]
50    Empty,
51    /// More than one `%d` conversion. Which one numbers the page is
52    /// ambiguous, so refuse rather than pick.
53    #[error("output template contains more than one '%d' page-number token")]
54    MultipleTokens,
55    /// A `%` conversion stet does not implement. Only `%d` and `%0Nd` are
56    /// supported; anything else is rejected rather than passed through to a
57    /// formatter that might interpret it differently.
58    #[error(
59        "unsupported conversion '%{0}' in output template \
60         (only '%d' and '%0Nd', e.g. '%03d', are supported; write '%%' for a literal '%')"
61    )]
62    UnsupportedConversion(String),
63    /// A trailing `%` with nothing after it.
64    #[error("output template ends with a lone '%' (write '%%' for a literal '%')")]
65    TrailingPercent,
66}
67
68/// Why a template could not be expanded for a given page.
69#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
70#[non_exhaustive]
71pub enum ExpandError {
72    /// A job produced a second page under a template with no `%d`, so every
73    /// page would land on the same path.
74    #[error(
75        "output '{path}' has no '%d' page-number token, but the job produced more than one page\n\
76         note: page 1 was written to '{path}'; pages 2 and beyond would overwrite it\n\
77         help: use a template such as '{suggestion}'"
78    )]
79    MultiPageNeedsToken {
80        /// The literal path the template names.
81        path: String,
82        /// A ready-to-paste template derived from `path`.
83        suggestion: String,
84    },
85}
86
87impl OutputTemplate {
88    /// Parse a user-supplied `-o` value.
89    ///
90    /// Recognises `%d` and `%0Nd` as the page-number conversion and `%%` as an
91    /// escaped literal `%`. Every other `%` sequence is an error, so a typo
92    /// like `%s` is reported rather than silently written to disk.
93    pub fn parse(raw: &str) -> Result<Self, TemplateError> {
94        if raw.is_empty() {
95            return Err(TemplateError::Empty);
96        }
97
98        let bytes = raw.as_bytes();
99        let mut token: Option<Token> = None;
100        let mut i = 0;
101
102        while i < bytes.len() {
103            if bytes[i] != b'%' {
104                i += 1;
105                continue;
106            }
107            let start = i;
108            let mut j = i + 1;
109            if j >= bytes.len() {
110                return Err(TemplateError::TrailingPercent);
111            }
112            // `%%` — an escaped literal percent, not a conversion.
113            if bytes[j] == b'%' {
114                i = j + 1;
115                continue;
116            }
117            // Optional zero-pad flag, then an optional decimal width.
118            let zero_pad = bytes[j] == b'0';
119            let digits_start = j;
120            while j < bytes.len() && bytes[j].is_ascii_digit() {
121                j += 1;
122            }
123            let width: usize = if j > digits_start {
124                raw[digits_start..j].parse().unwrap_or(0)
125            } else {
126                0
127            };
128            if j >= bytes.len() || bytes[j] != b'd' {
129                // Quote the conversion itself — its width digits plus the one
130                // character that broke it — rather than a fixed-length slice
131                // of whatever follows, which would drag in unrelated path
132                // text ("%s.p" for "out-%s.png"). Stepping by chars keeps a
133                // multi-byte offender from splitting mid-character.
134                let rest = &raw[start + 1..];
135                let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
136                let offender: String = rest.chars().skip(digits.chars().count()).take(1).collect();
137                return Err(TemplateError::UnsupportedConversion(format!(
138                    "{}{}",
139                    digits, offender
140                )));
141            }
142            let end = j + 1;
143            if token.is_some() {
144                return Err(TemplateError::MultipleTokens);
145            }
146            token = Some(Token {
147                start,
148                end,
149                width,
150                zero_pad,
151            });
152            i = end;
153        }
154
155        Ok(Self {
156            raw: raw.to_string(),
157            token,
158        })
159    }
160
161    /// Whether this template carries a `%d` page-number conversion.
162    pub fn has_page_token(&self) -> bool {
163        self.token.is_some()
164    }
165
166    /// The template as the user wrote it.
167    pub fn raw(&self) -> &str {
168        &self.raw
169    }
170
171    /// Expand the template for a 1-based page number.
172    ///
173    /// `emitted_index` is the 1-based count of pages *actually written* so far
174    /// including this one — not the logical page number, which `--pages 3`
175    /// would make 3 for a job that emits a single file. A template without a
176    /// `%d` is only valid while that count is 1.
177    pub fn expand(&self, page_number: i32, emitted_index: u32) -> Result<String, ExpandError> {
178        let Some(tok) = self.token else {
179            if emitted_index > 1 {
180                return Err(ExpandError::MultiPageNeedsToken {
181                    path: self.raw.clone(),
182                    suggestion: suggest_token_form(&self.raw),
183                });
184            }
185            return Ok(unescape_percents(&self.raw));
186        };
187
188        let number = if tok.zero_pad {
189            format!("{:0width$}", page_number, width = tok.width)
190        } else {
191            format!("{:width$}", page_number, width = tok.width)
192        };
193
194        let mut out = String::with_capacity(self.raw.len() + number.len());
195        out.push_str(&unescape_percents(&self.raw[..tok.start]));
196        out.push_str(&number);
197        out.push_str(&unescape_percents(&self.raw[tok.end..]));
198        Ok(out)
199    }
200}
201
202/// Collapse `%%` to `%` in the literal segments of a template.
203fn unescape_percents(segment: &str) -> String {
204    segment.replace("%%", "%")
205}
206
207/// Build a `%03d` form of a path, for the "use a template like this" hint.
208///
209/// Inserts the token before the final extension so the suggestion keeps the
210/// file type the user asked for: `out.png` becomes `out-%03d.png`.
211fn suggest_token_form(path: &str) -> String {
212    match split_extension(path) {
213        Some((stem, ext)) => format!("{}-%03d{}", stem, ext),
214        None => format!("{}-%03d", path),
215    }
216}
217
218/// Split a path into (stem, extension-with-dot) at the final `.` of the last
219/// path component. Returns `None` when that component has no extension.
220pub fn split_extension(path: &str) -> Option<(&str, &str)> {
221    let component_start = path.rfind(['/', '\\']).map(|pos| pos + 1).unwrap_or(0);
222    let dot = path[component_start..].rfind('.')? + component_start;
223    // A leading dot is a hidden file, not an extension.
224    if dot == component_start {
225        return None;
226    }
227    Some((&path[..dot], &path[dot..]))
228}
229
230/// Insert a `-NNN` page number before a path's extension.
231///
232/// This is the default multi-page naming used when no `-o` template is given.
233pub fn insert_page_number(path: &str, page_number: i32, digits: usize) -> String {
234    match split_extension(path) {
235        Some((stem, ext)) => format!("{}-{:0width$}{}", stem, page_number, ext, width = digits),
236        None => format!("{}-{:0width$}", path, page_number, width = digits),
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn no_token_single_page_is_literal() {
246        let t = OutputTemplate::parse("out.png").unwrap();
247        assert!(!t.has_page_token());
248        assert_eq!(t.expand(1, 1).unwrap(), "out.png");
249    }
250
251    #[test]
252    fn no_token_uses_logical_page_number_without_renaming() {
253        // `--pages 7` emits one file; the logical number is 7 but it is still
254        // the first emitted page, so the literal path stands.
255        let t = OutputTemplate::parse("out.png").unwrap();
256        assert_eq!(t.expand(7, 1).unwrap(), "out.png");
257    }
258
259    #[test]
260    fn no_token_second_emitted_page_errors() {
261        let t = OutputTemplate::parse("out.png").unwrap();
262        let err = t.expand(2, 2).unwrap_err();
263        match err {
264            ExpandError::MultiPageNeedsToken { ref suggestion, .. } => {
265                assert_eq!(suggestion, "out-%03d.png");
266            }
267        }
268        // The message names the flag value and a usable replacement.
269        let text = err.to_string();
270        assert!(text.contains("out.png"), "{}", text);
271        assert!(text.contains("out-%03d.png"), "{}", text);
272    }
273
274    #[test]
275    fn plain_token_expands_unpadded() {
276        let t = OutputTemplate::parse("p-%d.png").unwrap();
277        assert!(t.has_page_token());
278        assert_eq!(t.expand(7, 1).unwrap(), "p-7.png");
279        assert_eq!(t.expand(1234, 4).unwrap(), "p-1234.png");
280    }
281
282    #[test]
283    fn zero_padded_token_expands_to_width() {
284        let t = OutputTemplate::parse("p-%03d.png").unwrap();
285        assert_eq!(t.expand(7, 1).unwrap(), "p-007.png");
286        // Numbers wider than the field are not truncated.
287        assert_eq!(t.expand(12345, 1).unwrap(), "p-12345.png");
288    }
289
290    #[test]
291    fn token_expands_for_every_page_including_the_first() {
292        // gs expands the token even when only one page is produced.
293        let t = OutputTemplate::parse("s-%03d.png").unwrap();
294        assert_eq!(t.expand(1, 1).unwrap(), "s-001.png");
295    }
296
297    #[test]
298    fn token_may_appear_anywhere_including_a_directory() {
299        let t = OutputTemplate::parse("/tmp/page%03d/img.png").unwrap();
300        assert_eq!(t.expand(2, 2).unwrap(), "/tmp/page002/img.png");
301    }
302
303    #[test]
304    fn escaped_percent_is_literal_and_not_a_token() {
305        let t = OutputTemplate::parse("100%%-scale.png").unwrap();
306        assert!(!t.has_page_token());
307        assert_eq!(t.expand(1, 1).unwrap(), "100%-scale.png");
308    }
309
310    #[test]
311    fn escaped_percent_alongside_a_real_token() {
312        let t = OutputTemplate::parse("100%%-%02d.png").unwrap();
313        assert!(t.has_page_token());
314        assert_eq!(t.expand(3, 3).unwrap(), "100%-03.png");
315    }
316
317    #[test]
318    fn rejects_multiple_tokens() {
319        assert_eq!(
320            OutputTemplate::parse("%d-%d.png").unwrap_err(),
321            TemplateError::MultipleTokens
322        );
323    }
324
325    #[test]
326    fn rejects_unsupported_conversion() {
327        // A typo must not reach the filesystem as a literal name.
328        let err = OutputTemplate::parse("out-%s.png").unwrap_err();
329        // The message quotes the conversion alone, not the path text after it.
330        assert_eq!(
331            err,
332            TemplateError::UnsupportedConversion("s".to_string()),
333            "{}",
334            err
335        );
336        assert!(err.to_string().contains("%03d"), "{}", err);
337
338        // A width with the wrong terminator reports the width too.
339        assert_eq!(
340            OutputTemplate::parse("out-%03x.png").unwrap_err(),
341            TemplateError::UnsupportedConversion("03x".to_string())
342        );
343        // A conversion cut off by end of string is still a conversion error,
344        // not a "lone %".
345        assert_eq!(
346            OutputTemplate::parse("out-%03").unwrap_err(),
347            TemplateError::UnsupportedConversion("03".to_string())
348        );
349        // A multi-byte offender must not panic on a byte-boundary slice.
350        assert_eq!(
351            OutputTemplate::parse("out-%é.png").unwrap_err(),
352            TemplateError::UnsupportedConversion("é".to_string())
353        );
354    }
355
356    #[test]
357    fn rejects_trailing_percent() {
358        assert_eq!(
359            OutputTemplate::parse("out.png%").unwrap_err(),
360            TemplateError::TrailingPercent
361        );
362    }
363
364    #[test]
365    fn rejects_empty() {
366        assert_eq!(OutputTemplate::parse("").unwrap_err(), TemplateError::Empty);
367    }
368
369    #[test]
370    fn extension_splitting_handles_paths_and_dotfiles() {
371        assert_eq!(split_extension("out.png"), Some(("out", ".png")));
372        assert_eq!(split_extension("/a.b/out.png"), Some(("/a.b/out", ".png")));
373        // No extension on the final component, despite a dot in a parent dir.
374        assert_eq!(split_extension("/a.b/out"), None);
375        assert_eq!(split_extension(".hidden"), None);
376    }
377
378    #[test]
379    fn suggestion_keeps_the_requested_extension() {
380        assert_eq!(suggest_token_form("out.png"), "out-%03d.png");
381        assert_eq!(suggest_token_form("/tmp/o.pdf"), "/tmp/o-%03d.pdf");
382        assert_eq!(suggest_token_form("out"), "out-%03d");
383    }
384
385    #[test]
386    fn default_numbering_matches_existing_conventions() {
387        // PDF raster default: three digits.
388        assert_eq!(insert_page_number("f.png", 1, 3), "f-001.png");
389        // PostScript default: four digits.
390        assert_eq!(insert_page_number("f.png", 12, 4), "f-0012.png");
391    }
392}