1#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct OutputTemplate {
25 raw: String,
27 token: Option<Token>,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33struct Token {
34 start: usize,
36 end: usize,
38 width: usize,
40 zero_pad: bool,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
46#[non_exhaustive]
47pub enum TemplateError {
48 #[error("output path is empty")]
50 Empty,
51 #[error("output template contains more than one '%d' page-number token")]
54 MultipleTokens,
55 #[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 #[error("output template ends with a lone '%' (write '%%' for a literal '%')")]
65 TrailingPercent,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
70#[non_exhaustive]
71pub enum ExpandError {
72 #[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 path: String,
82 suggestion: String,
84 },
85}
86
87impl OutputTemplate {
88 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 if bytes[j] == b'%' {
114 i = j + 1;
115 continue;
116 }
117 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 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 pub fn has_page_token(&self) -> bool {
163 self.token.is_some()
164 }
165
166 pub fn raw(&self) -> &str {
168 &self.raw
169 }
170
171 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
202fn unescape_percents(segment: &str) -> String {
204 segment.replace("%%", "%")
205}
206
207fn 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
218pub 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 if dot == component_start {
225 return None;
226 }
227 Some((&path[..dot], &path[dot..]))
228}
229
230pub 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 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 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 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 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 let err = OutputTemplate::parse("out-%s.png").unwrap_err();
329 assert_eq!(
331 err,
332 TemplateError::UnsupportedConversion("s".to_string()),
333 "{}",
334 err
335 );
336 assert!(err.to_string().contains("%03d"), "{}", err);
337
338 assert_eq!(
340 OutputTemplate::parse("out-%03x.png").unwrap_err(),
341 TemplateError::UnsupportedConversion("03x".to_string())
342 );
343 assert_eq!(
346 OutputTemplate::parse("out-%03").unwrap_err(),
347 TemplateError::UnsupportedConversion("03".to_string())
348 );
349 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 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 assert_eq!(insert_page_number("f.png", 1, 3), "f-001.png");
389 assert_eq!(insert_page_number("f.png", 12, 4), "f-0012.png");
391 }
392}