use std::ffi::OsStr;
use std::path::Path;
use chrono::{DateTime, Datelike, Timelike, Utc};
use ecow::eco_format;
use parking_lot::RwLock;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use typst::diag::{
At, HintedStrResult, HintedString, SourceDiagnostic, SourceResult, StrResult, Warned,
bail,
};
use typst::foundations::{Datetime, Smart};
use typst::layout::PageRanges;
use typst::syntax::Span;
use typst_bundle::{Bundle, BundleOptions, VirtualFs};
use typst_html::{HtmlDocument, HtmlOptions};
use typst_kit::timer::Timer;
use typst_layout::{Page, PagedDocument};
use typst_pdf::{PdfOptions, PdfStandards, Timestamp};
use typst_render::RenderOptions;
use typst_svg::SvgOptions;
use typst_utils::Scalar;
use crate::args::{
CompileArgs, CompileCommand, DepsFormat, DiagnosticFormat, Input, Output,
OutputFormat, PdfStandard, WatchCommand,
};
use crate::deps::write_deps;
use crate::watch::Status;
use crate::world::SystemWorld;
use crate::{set_failed, terminal};
#[cfg(feature = "http-server")]
use typst_kit::server::HttpServer;
pub fn compile(command: &'static CompileCommand) -> HintedStrResult<()> {
let mut timer = Timer::new_or_placeholder(command.args.timings.clone());
let mut config = CompileConfig::new(command)?;
let mut world = SystemWorld::new(
Some(&command.args.input),
&command.args.world,
&command.args.process,
)
.map_err(|err| eco_format!("{err}"))?;
timer.record(&mut world, |world| compile_once(world, &mut config))?
}
pub struct CompileConfig {
pub warnings: Vec<HintedString>,
pub watching: bool,
pub input: Input,
pub output: Output,
pub output_format: OutputFormat,
pub pretty: bool,
pub pages: Option<PageRanges>,
pub creation_timestamp: Option<DateTime<Utc>>,
pub diagnostic_format: DiagnosticFormat,
pub open: Option<Option<String>>,
pub pdf_standards: PdfStandards,
pub tagged: bool,
pub deps: Option<Output>,
pub deps_format: DepsFormat,
pub ppi: f64,
pub export_cache: ExportCache,
#[cfg(feature = "http-server")]
pub server: Option<HttpServer>,
}
impl CompileConfig {
pub fn new(command: &CompileCommand) -> HintedStrResult<Self> {
Self::new_impl(&command.args, None)
}
pub fn watching(command: &WatchCommand) -> HintedStrResult<Self> {
Self::new_impl(&command.args, Some(command))
}
fn new_impl(
args: &CompileArgs,
watch: Option<&WatchCommand>,
) -> HintedStrResult<Self> {
let mut warnings = Vec::new();
let input = args.input.clone();
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!(
"could not infer output format for path {}.\n\
consider providing the format manually with `--format/-f`",
output.display(),
),
}
} else {
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 {
OutputFormat::Pdf => "pdf",
OutputFormat::Png => "png",
OutputFormat::Svg => "svg",
OutputFormat::Html => "html",
OutputFormat::Bundle => "",
},
))
});
let pages = args.pages.as_ref().map(|export_ranges| {
PageRanges::new(export_ranges.iter().map(|r| r.0.clone()).collect())
});
let tagged = !args.no_pdf_tags && pages.is_none();
if output_format == OutputFormat::Pdf && pages.is_some() && !args.no_pdf_tags {
warnings.push(
HintedString::from("using --pages implies --no-pdf-tags").with_hints([
"the resulting PDF will be inaccessible".into(),
"add --no-pdf-tags to silence this warning".into(),
]),
);
}
if !tagged {
const ACCESSIBLE: &[(PdfStandard, &str)] = &[
(PdfStandard::A_1a, "PDF/A-1a"),
(PdfStandard::A_2a, "PDF/A-2a"),
(PdfStandard::A_3a, "PDF/A-3a"),
(PdfStandard::UA_1, "PDF/UA-1"),
];
for (standard, name) in ACCESSIBLE {
if args.pdf_standard.contains(standard) {
if args.no_pdf_tags {
bail!("cannot disable PDF tags when exporting a {name} document");
} else {
bail!(
"cannot disable PDF tags when exporting a {name} document";
hint: "using --pages implies --no-pdf-tags";
);
}
}
}
}
let pdf_standards = PdfStandards::new(
&args.pdf_standard.iter().copied().map(Into::into).collect::<Vec<_>>(),
)?;
#[cfg(feature = "http-server")]
let server = if let Some(command) = watch
&& !command.server.no_serve
&& matches!(output_format, OutputFormat::Html | OutputFormat::Bundle)
{
Some(HttpServer::new(
&eco_format!("{input}"),
command.server.port,
!command.server.no_reload,
)?)
} else {
None
};
let mut deps = args.deps.clone();
let mut deps_format = args.deps_format;
if let Some(path) = &args.make_deps
&& deps.is_none()
{
deps = Some(Output::Path(path.clone()));
deps_format = DepsFormat::Make;
warnings.push(
"--make-deps is deprecated, use --deps and --deps-format instead".into(),
);
}
match (&output, &deps, watch) {
(Output::Stdout, _, Some(_)) => {
bail!("cannot write document to stdout in watch mode");
}
(_, Some(Output::Stdout), Some(_)) => {
bail!("cannot write dependencies to stdout in watch mode")
}
(Output::Stdout, Some(Output::Stdout), _) => {
bail!("cannot write both output and dependencies to stdout")
}
_ => {}
}
Ok(Self {
warnings,
watching: watch.is_some(),
input,
output,
output_format,
pretty: args.pretty,
pages,
pdf_standards,
tagged,
creation_timestamp: args
.world
.creation_timestamp
.map(|time| {
chrono::DateTime::from_timestamp(time, 0)
.ok_or("creation timestamp is out of range")
})
.transpose()?,
ppi: args.ppi,
diagnostic_format: args.process.diagnostic_format,
open: args.open.clone(),
export_cache: ExportCache::new(),
deps,
deps_format,
#[cfg(feature = "http-server")]
server,
})
}
}
#[typst_macros::time(name = "compile once")]
pub fn compile_once(
world: &mut SystemWorld,
config: &mut CompileConfig,
) -> HintedStrResult<()> {
let start = std::time::Instant::now();
if config.watching {
Status::Compiling.print(config).unwrap();
}
let Warned { output, mut warnings } = compile_and_export(world, config);
for warning in config.warnings.iter() {
warnings.push(
SourceDiagnostic::warning(Span::detached(), warning.message())
.with_hints(warning.hints().iter().map(Into::into)),
);
}
match &output {
Ok(_) => {
let duration = start.elapsed();
if config.watching {
if warnings.is_empty() {
Status::Success(duration).print(config).unwrap();
} else {
Status::PartialSuccess(duration).print(config).unwrap();
}
}
print_diagnostics(world, &[], &warnings, config.diagnostic_format)
.map_err(|err| eco_format!("failed to print diagnostics ({err})"))?;
open_output(config)?;
}
Err(errors) => {
set_failed();
if config.watching {
Status::Error.print(config).unwrap();
}
print_diagnostics(world, errors, &warnings, config.diagnostic_format)
.map_err(|err| eco_format!("failed to print diagnostics ({err})"))?;
}
}
if let Some(dest) = &config.deps {
write_deps(world, dest, config.deps_format, output.as_deref().ok())
.map_err(|err| eco_format!("failed to create dependency file ({err})"))?;
}
Ok(())
}
fn compile_and_export(
world: &mut SystemWorld,
config: &mut CompileConfig,
) -> Warned<SourceResult<Vec<Output>>> {
match config.output_format {
OutputFormat::Pdf | OutputFormat::Png | OutputFormat::Svg => {
let Warned { output, warnings } = typst::compile::<PagedDocument>(world);
let result = output.and_then(|document| export_paged(&document, config));
Warned { output: result, warnings }
}
OutputFormat::Html => {
let Warned { output, warnings } = typst::compile::<HtmlDocument>(world);
let result = output.and_then(|document| export_html(&document, config));
Warned {
output: result.map(|()| vec![config.output.clone()]),
warnings,
}
}
OutputFormat::Bundle => {
let Warned { output, warnings } = typst::compile::<Bundle>(world);
let result = output.and_then(|bundle| export_bundle(bundle, config));
Warned { output: result, warnings }
}
}
}
fn export_html(document: &HtmlDocument, config: &CompileConfig) -> SourceResult<()> {
let options = HtmlOptions { pretty: config.pretty };
let html = typst_html::html(document, &options)?;
let result = config.output.write(html.as_bytes());
#[cfg(feature = "http-server")]
if let Some(server) = &config.server {
server.set_html(html);
}
result
.map_err(|err| eco_format!("failed to write HTML file ({err})"))
.at(Span::detached())
}
fn export_paged(
document: &PagedDocument,
config: &CompileConfig,
) -> SourceResult<Vec<Output>> {
match config.output_format {
OutputFormat::Pdf => {
export_pdf(document, config).map(|()| vec![config.output.clone()])
}
OutputFormat::Png => {
export_image(document, config, ImageExportFormat::Png).at(Span::detached())
}
OutputFormat::Svg => {
export_image(document, config, ImageExportFormat::Svg).at(Span::detached())
}
OutputFormat::Html | OutputFormat::Bundle => unreachable!(),
}
}
fn export_pdf(document: &PagedDocument, config: &CompileConfig) -> SourceResult<()> {
let options = pdf_options(config);
let buffer = typst_pdf::pdf(document, &options)?;
config
.output
.write(&buffer)
.map_err(|err| eco_format!("failed to write PDF file ({err})"))
.at(Span::detached())?;
Ok(())
}
fn export_bundle(bundle: Bundle, config: &CompileConfig) -> SourceResult<Vec<Output>> {
let options = BundleOptions {
html: html_options(config),
pdf: pdf_options(config),
png: png_options(config),
svg: svg_options(config),
};
let fs = typst_bundle::export(&bundle, &options)?;
let root = match &config.output {
Output::Path(path) => path,
Output::Stdout => {
bail!(Span::detached(), "cannot write bundle to standard output")
}
};
let outputs = write_virtual_fs(root, &fs).at(Span::detached())?;
#[cfg(feature = "http-server")]
if let Some(server) = &config.server {
server.set_bundle(bundle, fs);
}
Ok(outputs)
}
fn write_virtual_fs(root: &Path, fs: &VirtualFs) -> StrResult<Vec<Output>> {
std::fs::create_dir_all(root)
.map_err(|err| eco_format!("failed to create output directory ({err})"))?;
fs.par_iter()
.map(|(path, data)| {
let realized = path.realize(root);
if let Some(parent) = realized.parent() {
std::fs::create_dir_all(parent)
.map_err(|err| eco_format!("failed to create directory ({err})"))?;
}
std::fs::write(&realized, data)
.map_err(|err| eco_format!("failed to write file ({err})"))?;
Ok(Output::Path(realized))
})
.collect()
}
fn convert_datetime<Tz: chrono::TimeZone>(
date_time: chrono::DateTime<Tz>,
) -> Option<Datetime> {
Datetime::from_ymd_hms(
date_time.year(),
date_time.month().try_into().ok()?,
date_time.day().try_into().ok()?,
date_time.hour().try_into().ok()?,
date_time.minute().try_into().ok()?,
date_time.second().try_into().ok()?,
)
}
#[derive(Copy, Clone)]
enum ImageExportFormat {
Png,
Svg,
}
fn export_image(
document: &PagedDocument,
config: &CompileConfig,
fmt: ImageExportFormat,
) -> StrResult<Vec<Output>> {
let can_handle_multiple = match config.output {
Output::Stdout => false,
Output::Path(ref output) => {
output_template::has_indexable_template(output.to_str().unwrap_or_default())
}
};
let exported_pages = document
.pages()
.iter()
.enumerate()
.filter(|(i, _)| {
config.pages.as_ref().is_none_or(|exported_page_ranges| {
exported_page_ranges.includes_page_index(*i)
})
})
.collect::<Vec<_>>();
if !can_handle_multiple && exported_pages.len() > 1 {
let err = match config.output {
Output::Stdout => "to stdout",
Output::Path(_) => {
"without a page number template ({p}, {0p}) in the output path"
}
};
bail!("cannot export multiple images {err}");
}
exported_pages
.par_iter()
.map(|(i, page)| {
let output = match &config.output {
Output::Path(path) => {
let storage;
let path = if can_handle_multiple {
storage = output_template::format(
path.to_str().unwrap_or_default(),
i + 1,
document.pages().len(),
);
Path::new(&storage)
} else {
path
};
if config.watching
&& config.export_cache.is_cached(*i, page)
&& path.exists()
{
return Ok(Output::Path(path.to_path_buf()));
}
Output::Path(path.to_owned())
}
Output::Stdout => Output::Stdout,
};
export_image_page(config, page, &output, fmt)?;
Ok(output)
})
.collect::<StrResult<Vec<Output>>>()
}
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 {
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())
},
)
}
}
fn export_image_page(
config: &CompileConfig,
page: &Page,
output: &Output,
fmt: ImageExportFormat,
) -> StrResult<()> {
match fmt {
ImageExportFormat::Png => {
let options = png_options(config);
let pixmap = typst_render::render(page, &options);
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 options = svg_options(config);
let svg = typst_svg::svg(page, &options);
output
.write(svg.as_bytes())
.map_err(|err| eco_format!("failed to write SVG file ({err})"))?;
}
}
Ok(())
}
fn html_options(config: &CompileConfig) -> HtmlOptions {
HtmlOptions { pretty: config.pretty }
}
fn pdf_options(config: &CompileConfig) -> PdfOptions {
let timestamp = match config.creation_timestamp {
Some(timestamp) => convert_datetime(timestamp).map(Timestamp::new_utc),
None => {
let local_datetime = chrono::Local::now();
convert_datetime(local_datetime).and_then(|datetime| {
Timestamp::new_local(
datetime,
local_datetime.offset().local_minus_utc() / 60,
)
})
}
};
PdfOptions {
ident: Smart::Auto,
creator: Smart::Auto,
timestamp,
page_ranges: config.pages.clone(),
standards: config.pdf_standards.clone(),
tagged: config.tagged,
pretty: config.pretty,
}
}
fn svg_options(config: &CompileConfig) -> SvgOptions {
SvgOptions { render_bleed: false, pretty: config.pretty }
}
fn png_options(config: &CompileConfig) -> RenderOptions {
RenderOptions {
pixel_per_pt: Scalar::new(config.ppi / 72.0),
render_bleed: false,
}
}
pub struct ExportCache {
pub cache: RwLock<Vec<u128>>,
}
impl ExportCache {
pub fn new() -> Self {
Self { cache: RwLock::new(Vec::with_capacity(32)) }
}
pub fn is_cached(&self, i: usize, page: &Page) -> bool {
let hash = typst::utils::hash128(page);
let mut cache = self.cache.upgradable_read();
if i >= cache.len() {
cache.with_upgraded(|cache| cache.push(hash));
return false;
}
cache.with_upgraded(|cache| std::mem::replace(&mut cache[i], hash) == hash)
}
}
fn open_output(config: &mut CompileConfig) -> StrResult<()> {
let Some(viewer) = config.open.take() else { return Ok(()) };
#[cfg(feature = "http-server")]
if let Some(server) = &config.server {
let url = format!("http://{}", server.addr());
return open_path(OsStr::new(&url), viewer.as_deref());
}
let Output::Path(path) = &config.output else { return Ok(()) };
let path = path
.canonicalize()
.map_err(|err| eco_format!("failed to canonicalize path ({err})"))?;
open_path(path.as_os_str(), viewer.as_deref())
}
fn open_path(path: &OsStr, viewer: Option<&str>) -> StrResult<()> {
if let Some(viewer) = viewer {
open::with_detached(path, viewer)
.map_err(|err| eco_format!("failed to open file with {viewer} ({err})"))
} else {
open::that_detached(path).map_err(|err| {
let openers = open::commands(path)
.iter()
.map(|command| command.get_program().to_string_lossy())
.collect::<Vec<_>>()
.join(", ");
eco_format!(
"failed to open file with any of these resource openers: {openers} \
({err})",
)
})
}
}
pub fn print_diagnostics(
world: &SystemWorld,
errors: &[SourceDiagnostic],
warnings: &[SourceDiagnostic],
format: DiagnosticFormat,
) -> Result<(), codespan_reporting::files::Error> {
typst_kit::diagnostics::emit(
&mut terminal::out(),
world,
errors.iter().chain(warnings),
match format {
DiagnosticFormat::Human => typst_kit::diagnostics::DiagnosticFormat::Human,
DiagnosticFormat::Short => typst_kit::diagnostics::DiagnosticFormat::Short,
},
)
}
impl From<PdfStandard> for typst_pdf::PdfStandard {
fn from(standard: PdfStandard) -> Self {
match standard {
PdfStandard::V_1_4 => typst_pdf::PdfStandard::V_1_4,
PdfStandard::V_1_5 => typst_pdf::PdfStandard::V_1_5,
PdfStandard::V_1_6 => typst_pdf::PdfStandard::V_1_6,
PdfStandard::V_1_7 => typst_pdf::PdfStandard::V_1_7,
PdfStandard::V_2_0 => typst_pdf::PdfStandard::V_2_0,
PdfStandard::A_1b => typst_pdf::PdfStandard::A_1b,
PdfStandard::A_1a => typst_pdf::PdfStandard::A_1a,
PdfStandard::A_2b => typst_pdf::PdfStandard::A_2b,
PdfStandard::A_2u => typst_pdf::PdfStandard::A_2u,
PdfStandard::A_2a => typst_pdf::PdfStandard::A_2a,
PdfStandard::A_3b => typst_pdf::PdfStandard::A_3b,
PdfStandard::A_3u => typst_pdf::PdfStandard::A_3u,
PdfStandard::A_3a => typst_pdf::PdfStandard::A_3a,
PdfStandard::A_4 => typst_pdf::PdfStandard::A_4,
PdfStandard::A_4f => typst_pdf::PdfStandard::A_4f,
PdfStandard::A_4e => typst_pdf::PdfStandard::A_4e,
PdfStandard::UA_1 => typst_pdf::PdfStandard::Ua_1,
}
}
}