diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index 71cbf515..a8878611 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -67,15 +67,16 @@ pub struct CompileCommand { #[clap(flatten)] pub common: SharedArgs, - /// Path to output file (PDF, PNG, or SVG) - #[clap(required_if_eq("input", "-"))] - pub output: Option, + /// Path to output file (PDF, PNG, or SVG), use `-` to write output to stdout + #[clap(required_if_eq("input", "-"), value_parser = ValueParser::new(output_value_parser))] + pub output: Option, /// The format of the output file, inferred from the extension by default #[arg(long = "format", short = 'f')] pub format: Option, - /// Opens the output file using the default viewer after compilation + /// Opens the output file using the default viewer after compilation. + /// Ignored if output is stdout #[arg(long = "open")] pub open: Option>, @@ -184,6 +185,24 @@ pub enum Input { Path(PathBuf), } +/// An output that is either stdout or a real path. +#[derive(Debug, Clone)] +pub enum Output { + /// Stdout, represented by `-`. + Stdout, + /// A non-empty path. + Path(PathBuf), +} + +impl Display for Output { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Output::Stdout => f.pad("stdout"), + Output::Path(path) => path.display().fmt(f), + } + } +} + /// The clap value parser used by `SharedArgs.input` fn input_value_parser(value: &str) -> Result { if value.is_empty() { @@ -195,6 +214,18 @@ fn input_value_parser(value: &str) -> Result { } } +/// The clap value parser used by `CompileCommand.output` +fn output_value_parser(value: &str) -> Result { + // Empty value also handled by clap for `Option` + if value.is_empty() { + Err(clap::Error::new(clap::error::ErrorKind::InvalidValue)) + } else if value == "-" { + Ok(Output::Stdout) + } else { + Ok(Output::Path(value.into())) + } +} + /// Parses key/value pairs split by the first equal sign. /// /// This function will return an error if the argument contains no equals sign diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index 9a252a53..272ca292 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -1,5 +1,6 @@ use std::fs; -use std::path::{Path, PathBuf}; +use std::io::Write; +use std::path::Path; use chrono::{Datelike, Timelike}; use codespan_reporting::diagnostic::{Diagnostic, Label}; @@ -16,7 +17,7 @@ use typst::syntax::{FileId, Source, Span}; use typst::visualize::Color; use typst::{World, WorldExt}; -use crate::args::{CompileCommand, DiagnosticFormat, Input, OutputFormat}; +use crate::args::{CompileCommand, DiagnosticFormat, Input, Output, OutputFormat}; use crate::timings::Timer; use crate::watch::Status; use crate::world::SystemWorld; @@ -27,18 +28,18 @@ type CodespanError = codespan_reporting::files::Error; impl CompileCommand { /// The output path. - pub fn output(&self) -> PathBuf { + pub fn output(&self) -> Output { self.output.clone().unwrap_or_else(|| { let Input::Path(path) = &self.common.input else { panic!("output must be specified when input is from stdin, as guarded by the CLI"); }; - path.with_extension( + Output::Path(path.with_extension( match self.output_format().unwrap_or(OutputFormat::Pdf) { OutputFormat::Pdf => "pdf", OutputFormat::Png => "png", OutputFormat::Svg => "svg", }, - ) + )) }) } @@ -48,7 +49,7 @@ impl CompileCommand { pub fn output_format(&self) -> StrResult { Ok(if let Some(specified) = self.format { specified - } else if let Some(output) = &self.output { + } else if let Some(Output::Path(output)) = &self.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, @@ -118,7 +119,9 @@ pub fn compile_once( .map_err(|err| eco_format!("failed to print diagnostics ({err})"))?; if let Some(open) = command.open.take() { - open_file(open.as_deref(), &command.output())?; + if let Output::Path(file) = command.output() { + open_file(open.as_deref(), &file)?; + } } } @@ -164,8 +167,9 @@ fn export( /// Export to a PDF. fn export_pdf(document: &Document, command: &CompileCommand) -> StrResult<()> { let buffer = typst_pdf::pdf(document, Smart::Auto, now()); - let output = command.output(); - fs::write(output, buffer) + command + .output() + .write(&buffer) .map_err(|err| eco_format!("failed to write PDF file ({err})"))?; Ok(()) } @@ -184,12 +188,13 @@ fn now() -> Option { } /// An image format to export in. +#[derive(Clone, Copy)] enum ImageExportFormat { Png, Svg, } -/// Export to one or multiple PNGs. +/// Export to one or multiple images. fn export_image( world: &mut SystemWorld, document: &Document, @@ -199,10 +204,16 @@ fn export_image( ) -> StrResult<()> { // Determine whether we have a `{n}` numbering. let output = command.output(); - let string = output.to_str().unwrap_or_default(); - let numbered = string.contains("{n}"); - if !numbered && document.pages.len() > 1 { - bail!("cannot export multiple images without `{{n}}` in output path"); + let can_handle_multiple = match output { + Output::Stdout => false, + Output::Path(ref output) => output.to_str().unwrap_or_default().contains("{n}"), + }; + if !can_handle_multiple && document.pages.len() > 1 { + let s = match output { + Output::Stdout => "to stdout", + Output::Path(_) => "without `{n}` in output path", + }; + bail!("cannot export multiple images {s}"); } // Find a number width that accommodates all pages. For instance, the @@ -218,39 +229,33 @@ fn export_image( .par_iter() .enumerate() .map(|(i, page)| { - let storage; - let path = if numbered { - storage = string.replace("{n}", &format!("{:0width$}", i + 1)); - Path::new(&storage) - } else { - output.as_path() + // Use output with converted path. + let output = match output { + 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)); + Path::new(&storage) + } else { + path + }; + + // If we are not watching, don't use the cache. + // If the frame is in the cache, skip it. + // If the file does not exist, always create it. + if watching && cache.is_cached(i, &page.frame) && path.exists() { + return Ok(()); + } + + Output::Path(path.to_owned()) + } + Output::Stdout => Output::Stdout, }; - // If we are not watching, don't use the cache. - // If the frame is in the cache, skip it. - // If the file does not exist, always create it. - if watching && cache.is_cached(i, &page.frame) && path.exists() { - return Ok(()); - } - - match fmt { - ImageExportFormat::Png => { - let pixmap = typst_render::render( - &page.frame, - command.ppi / 72.0, - Color::WHITE, - ); - pixmap - .save_png(path) - .map_err(|err| eco_format!("failed to write PNG file ({err})"))?; - } - ImageExportFormat::Svg => { - let svg = typst_svg::svg(&page.frame); - fs::write(path, svg.as_bytes()) - .map_err(|err| eco_format!("failed to write SVG file ({err})"))?; - } - } - + export_image_page(command, &page.frame, &output, fmt)?; Ok(()) }) .collect::, EcoString>>()?; @@ -258,6 +263,43 @@ fn export_image( Ok(()) } +/// Export single image. +fn export_image_page( + command: &CompileCommand, + frame: &Frame, + output: &Output, + fmt: ImageExportFormat, +) -> StrResult<()> { + match fmt { + ImageExportFormat::Png => { + let pixmap = typst_render::render(frame, command.ppi / 72.0, Color::WHITE); + let buf = pixmap + .encode_png() + .map_err(|err| eco_format!("failed to encode PNG file ({err})"))?; + output + .write(&buf) + .map_err(|err| eco_format!("failed to write PNG file ({err})"))?; + } + ImageExportFormat::Svg => { + let svg = typst_svg::svg(frame); + output + .write(svg.as_bytes()) + .map_err(|err| eco_format!("failed to write SVG file ({err})"))?; + } + } + Ok(()) +} + +impl Output { + fn write(&self, buffer: &[u8]) -> StrResult<()> { + match self { + Output::Stdout => std::io::stdout().write_all(buffer), + Output::Path(path) => fs::write(path, buffer), + } + .map_err(|err| eco_format!("{err}")) + } +} + /// Caches exported files so that we can avoid re-exporting them if they haven't /// changed. /// diff --git a/crates/typst-cli/src/watch.rs b/crates/typst-cli/src/watch.rs index 28edd434..348cd005 100644 --- a/crates/typst-cli/src/watch.rs +++ b/crates/typst-cli/src/watch.rs @@ -10,9 +10,9 @@ use codespan_reporting::term::{self, termcolor}; use ecow::eco_format; use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher as _}; use same_file::is_same_file; -use typst::diag::StrResult; +use typst::diag::{bail, StrResult}; -use crate::args::{CompileCommand, Input}; +use crate::args::{CompileCommand, Input, Output}; use crate::compile::compile_once; use crate::timings::Timer; use crate::world::{SystemWorld, WorldCreationError}; @@ -20,8 +20,12 @@ use crate::{print_error, terminal}; /// Execute a watching compilation command. pub fn watch(mut timer: Timer, mut command: CompileCommand) -> StrResult<()> { + let Output::Path(output) = command.output() else { + bail!("cannot write document to stdout in watch mode"); + }; + // Create a file system watcher. - let mut watcher = Watcher::new(command.output())?; + let mut watcher = Watcher::new(output)?; // Create the world that serves sources, files, and fonts. // Additionally, if any files do not exist, wait until they do. @@ -281,7 +285,7 @@ impl Status { out.set_color(&color)?; write!(out, "writing to")?; out.reset()?; - writeln!(out, " {}", output.display())?; + writeln!(out, " {output}")?; writeln!(out)?; writeln!(out, "[{timestamp}] {}", self.message())?;