Support writing document to stdout (#3632)

This commit is contained in:
Ilia 2024-04-01 22:36:21 +03:00 committed by GitHub
parent 82717b2869
commit e91baaca82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 130 additions and 53 deletions

View File

@ -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<PathBuf>,
/// 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<Output>,
/// The format of the output file, inferred from the extension by default
#[arg(long = "format", short = 'f')]
pub format: Option<OutputFormat>,
/// 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<Option<String>>,
@ -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<Input, clap::error::Error> {
if value.is_empty() {
@ -195,6 +214,18 @@ fn input_value_parser(value: &str) -> Result<Input, clap::error::Error> {
}
}
/// The clap value parser used by `CompileCommand.output`
fn output_value_parser(value: &str) -> Result<Output, clap::error::Error> {
// Empty value also handled by clap for `Option<Output>`
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

View File

@ -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<OutputFormat> {
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<Datetime> {
}
/// 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::<Result<Vec<()>, 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.
///

View File

@ -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())?;