diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index bed5f49a2..c115d5a9f 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -68,7 +68,11 @@ pub struct CompileCommand { #[clap(flatten)] pub common: SharedArgs, - /// Path to output file (PDF, PNG, or SVG), use `-` to write output to stdout + /// Path to output file (PDF, PNG, or SVG). + /// Use `-` to write output to stdout; For output formats emitting one file per page, + /// a page number template must be present if the source document renders to multiple pages. + /// Use `{p}` for page numbers, `{0p}` for zero padded page numbers, `{t}` for page count. + /// For example, `doc-page-{0p}-of-{t}.png` creates `doc-page-01-of-10.png` and so on. #[clap(required_if_eq("input", "-"), value_parser = ValueParser::new(output_value_parser))] pub output: Option, diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index ce03fd455..abe8768f9 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -206,25 +206,24 @@ fn export_image( watching: bool, fmt: ImageExportFormat, ) -> StrResult<()> { - // Determine whether we have a `{n}` numbering. let output = command.output(); + // Determine whether we have indexable templates in output let can_handle_multiple = match output { Output::Stdout => false, - Output::Path(ref output) => output.to_str().unwrap_or_default().contains("{n}"), + Output::Path(ref output) => { + output_template::has_indexable_template(output.to_str().unwrap_or_default()) + } }; if !can_handle_multiple && document.pages.len() > 1 { - let s = match output { + let err = match output { Output::Stdout => "to stdout", - Output::Path(_) => "without `{n}` in output path", + Output::Path(_) => { + "without a page number template ({p}, {0p}) in the output path" + } }; - bail!("cannot export multiple images {s}"); + bail!("cannot export multiple images {err}"); } - // Find a number width that accommodates all pages. For instance, the - // first page should be numbered "001" if there are between 100 and - // 999 pages. - let width = 1 + document.pages.len().checked_ilog10().unwrap_or(0) as usize; - let cache = world.export_cache(); // The results are collected in a `Vec<()>` which does not allocate. @@ -238,10 +237,11 @@ fn export_image( Output::Path(ref path) => { let storage; let path = if can_handle_multiple { - storage = path - .to_str() - .unwrap_or_default() - .replace("{n}", &format!("{:0width$}", i + 1)); + storage = output_template::format( + path.to_str().unwrap_or_default(), + i + 1, + document.pages.len(), + ); Path::new(&storage) } else { path @@ -267,6 +267,35 @@ fn export_image( Ok(()) } +mod output_template { + const INDEXABLE: [&str; 3] = ["{p}", "{0p}", "{n}"]; + + pub fn has_indexable_template(output: &str) -> bool { + INDEXABLE.iter().any(|template| output.contains(template)) + } + + pub fn format(output: &str, this_page: usize, total_pages: usize) -> String { + // Find the base 10 width of number `i` + fn width(i: usize) -> usize { + 1 + i.checked_ilog10().unwrap_or(0) as usize + } + + let other_templates = ["{t}"]; + INDEXABLE.iter().chain(other_templates.iter()).fold( + output.to_string(), + |out, template| { + let replacement = match *template { + "{p}" => format!("{this_page}"), + "{0p}" | "{n}" => format!("{:01$}", this_page, width(total_pages)), + "{t}" => format!("{total_pages}"), + _ => unreachable!("unhandled template placeholder {template}"), + }; + out.replace(template, replacement.as_str()) + }, + ) + } +} + /// Export single image. fn export_image_page( command: &CompileCommand,