From a41f821fa68647ca1b9a8a1fa86b8bc91d6d7c09 Mon Sep 17 00:00:00 2001 From: Jon Heinritz Date: Fri, 20 Jun 2025 08:00:29 +0200 Subject: [PATCH 1/3] feat(cli): allow specifying output directory --- crates/typst-cli/src/compile.rs | 52 +++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index 207bb7d09..86e39f484 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -111,19 +111,43 @@ impl CompileConfig { OutputFormat::Pdf }; - let output = args.output.clone().unwrap_or_else(|| { - let Input::Path(path) = &input else { - panic!("output must be specified when input is from stdin, as guarded by the CLI"); - }; - Output::Path(path.with_extension( - match output_format { + let output = match args.output.clone() { + None => { + let Input::Path(path) = &input else { + panic!("output must be specified when input is from stdin, as guarded by the CLI"); + }; + Output::Path(path.with_extension(match output_format { OutputFormat::Pdf => "pdf", OutputFormat::Png => "png", OutputFormat::Svg => "svg", OutputFormat::Html => "html", - }, - )) - }); + })) + } + // Check if a [`Path`] has a trailing `/` (or on `windows` a `\`) character, + // indicating that the output should be written to `{output}/{input_file_name}.{ext}` + Some(Output::Path(mut path)) if has_trailing_path_separator(&path) => { + let Input::Path(input) = &input else { + bail!( + "can't infer output file when input is from stdin\n\ + consider providing a full path to write to, or get input from file", + ); + }; + + let Some(file_name) = input.file_name() else { + panic!("input path must be non-empty, as guarded by the CLI"); + }; + + path.push(file_name); + path.set_extension(match output_format { + OutputFormat::Pdf => "pdf", + OutputFormat::Png => "png", + OutputFormat::Svg => "svg", + OutputFormat::Html => "html", + }); + Output::Path(path) + } + Some(output) => output, + }; let pages = args.pages.as_ref().map(|export_ranges| { PageRanges::new(export_ranges.iter().map(|r| r.0.clone()).collect()) @@ -162,6 +186,16 @@ impl CompileConfig { } } +fn has_trailing_path_separator(path: &Path) -> bool { + path.as_os_str() + .as_encoded_bytes() + .last() + .copied() + .map(Into::::into) + .map(std::path::is_separator) + .unwrap_or(false) +} + /// Compile a single time. /// /// Returns whether it compiled without errors. From c4f5ba84d184011dc9d238ce13abefd52eaa99c4 Mon Sep 17 00:00:00 2001 From: Jon Heinritz Date: Fri, 20 Jun 2025 08:30:39 +0200 Subject: [PATCH 2/3] feat(cli): also create output directory if it doesn't already exist --- crates/typst-cli/src/compile.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index 86e39f484..afc6863d0 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -137,6 +137,14 @@ impl CompileConfig { panic!("input path must be non-empty, as guarded by the CLI"); }; + // create directory if doesn't exist yet + std::fs::create_dir_all(&path).map_err(|err| { + eco_format!( + "failed to create output directory at {path}: {err}", + path = path.display() + ) + })?; + path.push(file_name); path.set_extension(match output_format { OutputFormat::Pdf => "pdf", From 0369b830132221b01a7c3119f7bdbbb14b57f5cc Mon Sep 17 00:00:00 2001 From: Jon Heinritz Date: Tue, 24 Jun 2025 16:26:41 +0200 Subject: [PATCH 3/3] refactor: extract methods for converting output format from/to file extension --- crates/typst-cli/src/args.rs | 22 ++++++++++++++++++++++ crates/typst-cli/src/compile.rs | 23 +++++------------------ 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index fd0eb5f05..d5544e997 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -1,3 +1,4 @@ +use std::ffi::OsStr; use std::fmt::{self, Display, Formatter}; use std::num::NonZeroUsize; use std::ops::RangeInclusive; @@ -443,6 +444,27 @@ pub enum OutputFormat { Html, } +impl OutputFormat { + pub fn from_file_ext(ext: &OsStr) -> Option { + match ext { + ext if ext.eq_ignore_ascii_case("pdf") => Some(OutputFormat::Pdf), + ext if ext.eq_ignore_ascii_case("png") => Some(OutputFormat::Png), + ext if ext.eq_ignore_ascii_case("svg") => Some(OutputFormat::Svg), + ext if ext.eq_ignore_ascii_case("html") => Some(OutputFormat::Html), + _ => None, + } + } + + pub fn as_file_ext(&self) -> &'static str { + match self { + Self::Pdf => "pdf", + Self::Png => "png", + Self::Svg => "svg", + Self::Html => "html", + } + } +} + display_possible_values!(OutputFormat); /// Which format to use for diagnostics. diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index afc6863d0..d8a189191 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -96,12 +96,9 @@ impl CompileConfig { let output_format = if let Some(specified) = args.format { specified } else if let Some(Output::Path(output)) = &args.output { - match output.extension() { - Some(ext) if ext.eq_ignore_ascii_case("pdf") => OutputFormat::Pdf, - Some(ext) if ext.eq_ignore_ascii_case("png") => OutputFormat::Png, - Some(ext) if ext.eq_ignore_ascii_case("svg") => OutputFormat::Svg, - Some(ext) if ext.eq_ignore_ascii_case("html") => OutputFormat::Html, - _ => bail!( + match output.extension().and_then(OutputFormat::from_file_ext) { + Some(format) => format, + None => bail!( "could not infer output format for path {}.\n\ consider providing the format manually with `--format/-f`", output.display() @@ -116,12 +113,7 @@ impl CompileConfig { let Input::Path(path) = &input else { panic!("output must be specified when input is from stdin, as guarded by the CLI"); }; - Output::Path(path.with_extension(match output_format { - OutputFormat::Pdf => "pdf", - OutputFormat::Png => "png", - OutputFormat::Svg => "svg", - OutputFormat::Html => "html", - })) + Output::Path(path.with_extension(output_format.as_file_ext())) } // Check if a [`Path`] has a trailing `/` (or on `windows` a `\`) character, // indicating that the output should be written to `{output}/{input_file_name}.{ext}` @@ -146,12 +138,7 @@ impl CompileConfig { })?; path.push(file_name); - path.set_extension(match output_format { - OutputFormat::Pdf => "pdf", - OutputFormat::Png => "png", - OutputFormat::Svg => "svg", - OutputFormat::Html => "html", - }); + path.set_extension(output_format.as_file_ext()); Output::Path(path) } Some(output) => output,