#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutputTemplate {
raw: String,
token: Option<Token>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Token {
start: usize,
end: usize,
width: usize,
zero_pad: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum TemplateError {
#[error("output path is empty")]
Empty,
#[error("output template contains more than one '%d' page-number token")]
MultipleTokens,
#[error(
"unsupported conversion '%{0}' in output template \
(only '%d' and '%0Nd', e.g. '%03d', are supported; write '%%' for a literal '%')"
)]
UnsupportedConversion(String),
#[error("output template ends with a lone '%' (write '%%' for a literal '%')")]
TrailingPercent,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ExpandError {
#[error(
"output '{path}' has no '%d' page-number token, but the job produced more than one page\n\
note: page 1 was written to '{path}'; pages 2 and beyond would overwrite it\n\
help: use a template such as '{suggestion}'"
)]
MultiPageNeedsToken {
path: String,
suggestion: String,
},
}
impl OutputTemplate {
pub fn parse(raw: &str) -> Result<Self, TemplateError> {
if raw.is_empty() {
return Err(TemplateError::Empty);
}
let bytes = raw.as_bytes();
let mut token: Option<Token> = None;
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'%' {
i += 1;
continue;
}
let start = i;
let mut j = i + 1;
if j >= bytes.len() {
return Err(TemplateError::TrailingPercent);
}
if bytes[j] == b'%' {
i = j + 1;
continue;
}
let zero_pad = bytes[j] == b'0';
let digits_start = j;
while j < bytes.len() && bytes[j].is_ascii_digit() {
j += 1;
}
let width: usize = if j > digits_start {
raw[digits_start..j].parse().unwrap_or(0)
} else {
0
};
if j >= bytes.len() || bytes[j] != b'd' {
let rest = &raw[start + 1..];
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
let offender: String = rest.chars().skip(digits.chars().count()).take(1).collect();
return Err(TemplateError::UnsupportedConversion(format!(
"{}{}",
digits, offender
)));
}
let end = j + 1;
if token.is_some() {
return Err(TemplateError::MultipleTokens);
}
token = Some(Token {
start,
end,
width,
zero_pad,
});
i = end;
}
Ok(Self {
raw: raw.to_string(),
token,
})
}
pub fn has_page_token(&self) -> bool {
self.token.is_some()
}
pub fn raw(&self) -> &str {
&self.raw
}
pub fn expand(&self, page_number: i32, emitted_index: u32) -> Result<String, ExpandError> {
let Some(tok) = self.token else {
if emitted_index > 1 {
return Err(ExpandError::MultiPageNeedsToken {
path: self.raw.clone(),
suggestion: suggest_token_form(&self.raw),
});
}
return Ok(unescape_percents(&self.raw));
};
let number = if tok.zero_pad {
format!("{:0width$}", page_number, width = tok.width)
} else {
format!("{:width$}", page_number, width = tok.width)
};
let mut out = String::with_capacity(self.raw.len() + number.len());
out.push_str(&unescape_percents(&self.raw[..tok.start]));
out.push_str(&number);
out.push_str(&unescape_percents(&self.raw[tok.end..]));
Ok(out)
}
}
fn unescape_percents(segment: &str) -> String {
segment.replace("%%", "%")
}
fn suggest_token_form(path: &str) -> String {
match split_extension(path) {
Some((stem, ext)) => format!("{}-%03d{}", stem, ext),
None => format!("{}-%03d", path),
}
}
pub fn split_extension(path: &str) -> Option<(&str, &str)> {
let component_start = path.rfind(['/', '\\']).map(|pos| pos + 1).unwrap_or(0);
let dot = path[component_start..].rfind('.')? + component_start;
if dot == component_start {
return None;
}
Some((&path[..dot], &path[dot..]))
}
pub fn insert_page_number(path: &str, page_number: i32, digits: usize) -> String {
match split_extension(path) {
Some((stem, ext)) => format!("{}-{:0width$}{}", stem, page_number, ext, width = digits),
None => format!("{}-{:0width$}", path, page_number, width = digits),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_token_single_page_is_literal() {
let t = OutputTemplate::parse("out.png").unwrap();
assert!(!t.has_page_token());
assert_eq!(t.expand(1, 1).unwrap(), "out.png");
}
#[test]
fn no_token_uses_logical_page_number_without_renaming() {
let t = OutputTemplate::parse("out.png").unwrap();
assert_eq!(t.expand(7, 1).unwrap(), "out.png");
}
#[test]
fn no_token_second_emitted_page_errors() {
let t = OutputTemplate::parse("out.png").unwrap();
let err = t.expand(2, 2).unwrap_err();
match err {
ExpandError::MultiPageNeedsToken { ref suggestion, .. } => {
assert_eq!(suggestion, "out-%03d.png");
}
}
let text = err.to_string();
assert!(text.contains("out.png"), "{}", text);
assert!(text.contains("out-%03d.png"), "{}", text);
}
#[test]
fn plain_token_expands_unpadded() {
let t = OutputTemplate::parse("p-%d.png").unwrap();
assert!(t.has_page_token());
assert_eq!(t.expand(7, 1).unwrap(), "p-7.png");
assert_eq!(t.expand(1234, 4).unwrap(), "p-1234.png");
}
#[test]
fn zero_padded_token_expands_to_width() {
let t = OutputTemplate::parse("p-%03d.png").unwrap();
assert_eq!(t.expand(7, 1).unwrap(), "p-007.png");
assert_eq!(t.expand(12345, 1).unwrap(), "p-12345.png");
}
#[test]
fn token_expands_for_every_page_including_the_first() {
let t = OutputTemplate::parse("s-%03d.png").unwrap();
assert_eq!(t.expand(1, 1).unwrap(), "s-001.png");
}
#[test]
fn token_may_appear_anywhere_including_a_directory() {
let t = OutputTemplate::parse("/tmp/page%03d/img.png").unwrap();
assert_eq!(t.expand(2, 2).unwrap(), "/tmp/page002/img.png");
}
#[test]
fn escaped_percent_is_literal_and_not_a_token() {
let t = OutputTemplate::parse("100%%-scale.png").unwrap();
assert!(!t.has_page_token());
assert_eq!(t.expand(1, 1).unwrap(), "100%-scale.png");
}
#[test]
fn escaped_percent_alongside_a_real_token() {
let t = OutputTemplate::parse("100%%-%02d.png").unwrap();
assert!(t.has_page_token());
assert_eq!(t.expand(3, 3).unwrap(), "100%-03.png");
}
#[test]
fn rejects_multiple_tokens() {
assert_eq!(
OutputTemplate::parse("%d-%d.png").unwrap_err(),
TemplateError::MultipleTokens
);
}
#[test]
fn rejects_unsupported_conversion() {
let err = OutputTemplate::parse("out-%s.png").unwrap_err();
assert_eq!(
err,
TemplateError::UnsupportedConversion("s".to_string()),
"{}",
err
);
assert!(err.to_string().contains("%03d"), "{}", err);
assert_eq!(
OutputTemplate::parse("out-%03x.png").unwrap_err(),
TemplateError::UnsupportedConversion("03x".to_string())
);
assert_eq!(
OutputTemplate::parse("out-%03").unwrap_err(),
TemplateError::UnsupportedConversion("03".to_string())
);
assert_eq!(
OutputTemplate::parse("out-%é.png").unwrap_err(),
TemplateError::UnsupportedConversion("é".to_string())
);
}
#[test]
fn rejects_trailing_percent() {
assert_eq!(
OutputTemplate::parse("out.png%").unwrap_err(),
TemplateError::TrailingPercent
);
}
#[test]
fn rejects_empty() {
assert_eq!(OutputTemplate::parse("").unwrap_err(), TemplateError::Empty);
}
#[test]
fn extension_splitting_handles_paths_and_dotfiles() {
assert_eq!(split_extension("out.png"), Some(("out", ".png")));
assert_eq!(split_extension("/a.b/out.png"), Some(("/a.b/out", ".png")));
assert_eq!(split_extension("/a.b/out"), None);
assert_eq!(split_extension(".hidden"), None);
}
#[test]
fn suggestion_keeps_the_requested_extension() {
assert_eq!(suggest_token_form("out.png"), "out-%03d.png");
assert_eq!(suggest_token_form("/tmp/o.pdf"), "/tmp/o-%03d.pdf");
assert_eq!(suggest_token_form("out"), "out-%03d");
}
#[test]
fn default_numbering_matches_existing_conventions() {
assert_eq!(insert_page_number("f.png", 1, 3), "f-001.png");
assert_eq!(insert_page_number("f.png", 12, 4), "f-0012.png");
}
}