// The `compile` command (upstream `typst-cli/src/compile.rs`).
///|
/// An application-level error with hints (upstream `HintedString` as the
/// error of a `HintedStrResult`).
priv suberror HintedFailure {
HintedFailure(@library.HintedString)
}
///|
/// Bails with a message (upstream `bail!` in a `StrResult`).
fn bail(message : String) -> HintedFailure {
HintedFailure(@library.HintedString::new(message))
}
///|
/// Execute a compilation command.
async fn compile(args : CompileArgs) -> Unit raise HintedFailure {
let config = CompileConfig::new(args)
let world = SystemWorld::new(Some(args.input), args.world, args.process) catch {
err => raise bail("\{err}")
}
compile_once(world, config)
}
///|
/// A preprocessed `CompileCommand`.
priv struct CompileConfig {
/// Static warnings to emit after compilation.
warnings : Array[@library.HintedString]
/// Whether we are watching.
watching : Bool
/// Whether to clear the terminal on recompilation (only while watching).
fullscreen : Bool
/// Path to input Typst file or stdin.
input : Input
/// Path to output file (PDF, PNG, SVG, or HTML).
output : Output
/// The format of the output file.
output_format : OutputFormat
/// Whether to make the serialized document pretty.
pretty : Bool?
/// Which pages to export.
pages : @library.PageRanges?
/// The document's creation date formatted as a UNIX timestamp, with UTC
/// suffix.
creation_timestamp : @kit.UtcDateTime?
/// The format to emit diagnostics in.
diagnostic_format : @kit.DiagnosticFormat
/// Opens the output file with the default viewer or a specific program
/// after compilation.
open : String??
/// A list of standards the PDF should conform to.
pdf_standards : @library.PdfStandards?
/// Whether to write PDF (accessibility) tags.
tagged : Bool?
/// A destination to write a list of dependencies to.
deps : Output?
/// The format to use for dependencies.
deps_format : DepsFormat
/// The PPI (pixels per inch) to use for PNG export.
ppi : Double?
/// The export cache for images, used for caching output files in `typst
/// watch` sessions with images.
export_cache : ExportCache
}
///|
/// Rust's `Path::extension`.
fn path_extension(path : String) -> String? {
guard @platform.file_name(path) is Some(name) else { return None }
guard name.rev_find(".") is Some(i) else { return None }
if i == 0 {
None
} else {
Some(name[i + 1:].to_owned())
}
}
///|
/// Rust's `Path::with_extension`.
fn with_extension(path : String, ext : String) -> String {
guard @platform.file_name(path) is Some(name) else { return path }
let stem_len = match name.rev_find(".") {
Some(i) if i > 0 => i
_ => name.length()
}
// The path up to the end of the file stem.
let trimmed = trim_trailing_slashes(path)
let prefix = trimmed[:trimmed.length() - name.length() + stem_len].to_owned()
if ext == "" {
prefix
} else {
prefix + "." + ext
}
}
///|
fn trim_trailing_slashes(path : String) -> String {
let mut end = path.length()
while end > 1 && path.code_unit_at(end - 1) == '/' {
end -= 1
}
path[:end].to_owned()
}
///|
/// Preprocess a `CompileCommand`, producing a compilation config.
fn CompileConfig::new(args : CompileArgs) -> CompileConfig raise HintedFailure {
CompileConfig::new_impl(args, None)
}
///|
/// Preprocess a `WatchCommand`, producing a compilation config.
// Only `watch` calls it (watch.rs:21), which not every target has
// (`watch_unsupported.mbt`).
#warnings("-unused_value")
fn CompileConfig::watching(
command : WatchCommand,
) -> CompileConfig raise HintedFailure {
CompileConfig::new_impl(command.args, Some(command))
}
///|
/// The shared implementation of [`CompileConfig::new`] and
/// [`CompileConfig::watching`].
fn CompileConfig::new_impl(
args : CompileArgs,
watch : WatchCommand?,
) -> CompileConfig raise HintedFailure {
let warnings = []
let input = args.input
let output_format = match args.format {
Some(specified) => specified
None =>
match args.output {
Some(Path(output)) =>
match path_extension(output).map(e => e.to_lower()) {
Some("pdf") => Pdf
Some("png") => Png
Some("svg") => Svg
Some("html") => Html
_ =>
raise bail(
"could not infer output format for path \{output}.\nconsider providing the format manually with `--format/-f`",
)
}
_ => Pdf
}
}
let output = match args.output {
Some(output) => output
None => {
guard input is Path(path) else {
panic() // output must be specified when input is from stdin, as guarded by the CLI
}
Path(
with_extension(
path,
match output_format {
Pdf => "pdf"
Png => "png"
Svg => "svg"
Html => "html"
Bundle => ""
},
),
)
}
}
let pages = args.pages.map(ranges => @library.PageRanges(ranges))
if args.no_pdf_tags {
warnings.push(
@library.HintedString::new(
"`--no-pdf-tags` is deprecated, use `--pdf-tagged=false` instead",
),
)
}
let tagged = match args.pdf_tagged {
Some(tagged) => {
if tagged && pages is Some(_) {
raise bail("cannot enable PDF tags when exporting a page range")
}
Some(tagged)
}
None => {
if output_format == Pdf && pages is Some(_) && !args.no_pdf_tags {
warnings.push(
@library.HintedString::new(
"using `--pages` implies `--pdf-tagged=false`",
)
.with_hint("the resulting PDF will be inaccessible")
.with_hint("add `--pdf-tagged=false` to silence this warning"),
)
}
if args.no_pdf_tags || pages is Some(_) {
Some(false)
} else {
None
}
}
}
if tagged == Some(false) {
let accessible : Array[(@library.PdfStandard, String)] = [
(A_1a, "PDF/A-1a"),
(A_2a, "PDF/A-2a"),
(A_3a, "PDF/A-3a"),
(UA_1, "PDF/UA-1"),
]
for entry in accessible {
let (standard, name) = entry
if args.pdf_standard.contains(standard) {
if args.no_pdf_tags {
raise bail(
"cannot disable PDF tags when exporting a \{name} document",
)
} else {
let err = @library.HintedString::new(
"cannot disable PDF tags when exporting a \{name} document",
)
if args.pages is Some(_) {
err.hint("using --pages implies --pdf-tagged=false")
}
raise HintedFailure(err)
}
}
}
}
let pdf_standards = if args.pdf_standard.is_empty() {
None
} else {
Some(
@library.PdfStandards::new(args.pdf_standard[:]) catch {
@library.HintedError(err) => raise HintedFailure(err)
},
)
}
let fullscreen = match watch {
Some(command) => !command.no_fullscreen
None => true
}
let mut deps = args.deps
let mut deps_format = args.deps_format
if args.make_deps is Some(path) && deps is None {
deps = Some(Path(path))
deps_format = Make
warnings.push(
@library.HintedString::new(
"--make-deps is deprecated, use --deps and --deps-format instead",
),
)
}
match (output, deps, watch) {
(Stdout, _, Some(_)) =>
raise bail("cannot write document to stdout in watch mode")
(_, Some(Stdout), Some(_)) =>
raise bail("cannot write dependencies to stdout in watch mode")
(Stdout, Some(Stdout), _) =>
raise bail("cannot write both output and dependencies to stdout")
_ => ()
}
// Not ported: upstream serves the HTML or the bundle over HTTP while
// watching (compile.rs:217, its `http-server` feature; the server is
// created before the checks above).
if watch is Some(command) &&
!command.server.no_serve &&
output_format is (Html | Bundle) {
raise bail(
"the built-in HTTP server for HTML export is not supported by this build; pass `--no-serve`",
)
}
let creation_timestamp = match args.world.creation_timestamp {
Some(time) =>
match @kit.UtcDateTime::from_timestamp(time) {
Some(dt) => Some(dt)
None => raise bail("creation timestamp is out of range")
}
None => None
}
// Not possible on all platforms of this port (wasm can only write text).
if output is Stdout &&
output_format is (Pdf | Png) &&
!@platform.supports_binary_stdout() {
raise bail(
"cannot write binary output to stdout on this platform; specify an output file",
)
}
// Not supported by this port (upstream writes a JSON trace).
if args.timings is Some(_) {
raise bail(
"performance timings (`--timings`) are not supported by this build",
)
}
{
warnings,
watching: watch is Some(_),
input,
output,
output_format,
pretty: args.pretty,
pages,
pdf_standards,
tagged,
creation_timestamp,
ppi: args.ppi,
diagnostic_format: args.process.diagnostic_format,
open: args.open,
export_cache: ExportCache::new(),
deps,
deps_format,
fullscreen,
}
}
///|
/// Compile a single time.
///
/// Returns whether it compiled without errors.
fn compile_once(
world : SystemWorld,
config : CompileConfig,
) -> Unit raise HintedFailure {
let start = @platform.monotonic_nanos()
if config.watching {
Status::Compiling.print(config)
}
let warned = compile_and_export(world, config)
let warnings : Array[@library.SourceDiagnostic] = warned.warnings.copy()
// Add static warnings (for deprecated CLI flags and such).
for warning in config.warnings {
warnings.push(warning.into_warning_at(@syntax.Span::detached()))
}
match warned.output {
// Print success message and possibly warnings.
Ok(_) => {
// (Not negative: the clock of the wasm targets is the wall clock.)
let elapsed = @platform.monotonic_nanos() - start
let duration = if elapsed < 0L { 0L } else { elapsed }
if config.watching {
if warnings.is_empty() {
Status::Success(duration).print(config)
} else {
Status::PartialSuccess(duration).print(config)
}
}
print_diagnostics(world, [], warnings, config.diagnostic_format) catch {
err => raise bail("failed to print diagnostics (\{err})")
}
open_output(config)
}
// Print failure message and diagnostics.
Err(errors) => {
set_failed()
if config.watching {
Status::Error.print(config)
}
print_diagnostics(world, errors, warnings, config.diagnostic_format) catch {
err => raise bail("failed to print diagnostics (\{err})")
}
}
}
if config.deps is Some(dest) {
let outputs = match warned.output {
Ok(outputs) => Some(outputs)
Err(_) => None
}
write_deps(world, dest, config.deps_format, outputs) catch {
err => raise bail("failed to create dependency file (\{err})")
}
}
}
///|
/// `Warned::and_then`: chains a computation, collecting warnings.
fn[T] and_then(
warned : @library.Warned[Result[T, Array[@library.SourceDiagnostic]]],
f : (T) -> @library.Warned[
Result[Array[Output], Array[@library.SourceDiagnostic]],
],
) -> @library.Warned[Result[Array[Output], Array[@library.SourceDiagnostic]]] {
match warned.output {
Ok(value) => {
let next = f(value)
{ output: next.output, warnings: [..warned.warnings, ..next.warnings], }
}
Err(errors) => { output: Err(errors), warnings: warned.warnings, }
}
}
///|
/// A plain result as a warned result.
fn plain(
result : Result[Array[Output], Array[@library.SourceDiagnostic]],
) -> @library.Warned[Result[Array[Output], Array[@library.SourceDiagnostic]]] {
{ output: result, warnings: [], }
}
///|
/// An error at a detached span (`.at(Span::detached())`).
fn detached_error(message : String) -> Array[@library.SourceDiagnostic] {
[@library.SourceDiagnostic::error(@syntax.DiagSpan::detached(), message)]
}
///|
/// Compile and then export the document.
fn compile_and_export(
world : SystemWorld,
config : CompileConfig,
) -> @library.Warned[Result[Array[Output], Array[@library.SourceDiagnostic]]] {
match config.output_format {
Pdf | Png | Svg => {
let compiled : @library.Warned[
Result[@layout.PagedDocument, Array[@library.SourceDiagnostic]],
] = @typst.compile(world)
and_then(compiled, document => export_paged(document, config))
}
Html => {
let compiled : @library.Warned[
Result[@html.HtmlDocument, Array[@library.SourceDiagnostic]],
] = @typst.compile(world)
and_then(compiled, document => {
plain(
try export_html(document, config) catch {
@library.SourceError(errors) => Err(errors)
} noraise {
_ => Ok([config.output])
},
)
})
}
Bundle => {
let compiled : @library.Warned[
Result[@bundle.Bundle, Array[@library.SourceDiagnostic]],
] = @typst.compile(world)
and_then(compiled, bundle => export_bundle(bundle, config))
}
}
}
///|
/// Export to HTML.
fn export_html(
document : @html.HtmlDocument,
config : CompileConfig,
) -> Unit raise @library.SourceError {
let options = html_options(config)
let html = @html.html(document, options)
config.output.write(@utf8.encode(html)) catch {
err =>
raise @library.SourceError(
detached_error("failed to write HTML file (\{err})"),
)
}
}
///|
/// Export to a paged target format.
fn export_paged(
document : @layout.PagedDocument,
config : CompileConfig,
) -> @library.Warned[Result[Array[Output], Array[@library.SourceDiagnostic]]] {
match config.output_format {
Pdf => {
let exported = export_pdf(document, config)
{
output: exported.output.map(_ => [config.output]),
warnings: exported.warnings,
}
}
Png =>
plain(
export_image(document, config, Png) catch {
@kit.StrError(msg) => Err(detached_error(msg))
},
)
Svg =>
plain(
export_image(document, config, Svg) catch {
@kit.StrError(msg) => Err(detached_error(msg))
},
)
Html | Bundle => panic()
}
}
///|
/// Export to a PDF.
fn export_pdf(
document : @layout.PagedDocument,
config : CompileConfig,
) -> @library.Warned[Result[Unit, Array[@library.SourceDiagnostic]]] {
let options = pdf_options(config)
let result = @pdf.pdf(document, options)
match result.output {
Ok(buffer) => {
let output : Result[Unit, Array[@library.SourceDiagnostic]] = try
config.output.write(buffer)
catch {
err => Err(detached_error("failed to write PDF file (\{err})"))
} noraise {
_ => Ok(())
}
{ output, warnings: result.warnings, }
}
Err(errors) => { output: Err(errors), warnings: result.warnings, }
}
}
///|
/// Export to a bundle, a collection of files in a directory.
fn export_bundle(
bundle : @bundle.Bundle,
config : CompileConfig,
) -> @library.Warned[Result[Array[Output], Array[@library.SourceDiagnostic]]] {
let pdf_opts = pdf_options(config)
let png_opts = png_options(config)
let options : @bundle.BundleOptions = {
html: html_options(config),
pdf: Some((doc, anchors, resolver) => {
@pdf.pdf_in_bundle(doc, pdf_opts, anchors, resolver)
}),
png: Some(doc => bundle_png(doc, png_opts)),
svg: svg_options(config),
}
let exported = @bundle.export_bundle(bundle, options)
let output = match exported.output {
Err(errors) => Err(errors)
Ok(fs) =>
match config.output {
Stdout => Err(detached_error("cannot write bundle to standard output"))
Path(root) =>
write_virtual_fs(root, fs) catch {
@kit.StrError(msg) => Err(detached_error(msg))
}
}
}
{ output, warnings: exported.warnings, }
}
///|
/// Exports a PNG document of a bundle (`typst-bundle`'s `export_png`).
fn bundle_png(
doc : @layout.PagedDocument,
options : @render.RenderOptions,
) -> Bytes raise @library.SourceError {
let options = options.resolve(@render.PngFormatOptions::get(doc.options()))
let pixmap = @render.render(doc.pages()[0], options) catch {
@render.RenderPanic(msg) => raise @library.SourceError(detached_error(msg))
}
pixmap.encode_png()
}
///|
/// Writes a bundle's files to disk.
fn write_virtual_fs(
root : String,
fs : @bundle.VirtualFs,
) -> Result[Array[Output], Array[@library.SourceDiagnostic]] raise @kit.StrError {
@platform.create_dir_all(root) catch {
err => raise @kit.StrError("failed to create output directory (\{err})")
}
let outputs = []
for path, data in fs {
let realized = path.realize(root) catch {
err => raise @kit.StrError("failed to realize path (\{err})")
}
if @platform.parent(realized) is Some(parent) {
@platform.create_dir_all(parent) catch {
err => raise @kit.StrError("failed to create directory (\{err})")
}
}
@platform.write_file(realized, data.as_slice()) catch {
err => raise @kit.StrError("failed to write file (\{err})")
}
outputs.push(Output::Path(realized))
}
Ok(outputs)
}
///|
/// Convert a UTC date and time to a `Datetime`.
fn convert_datetime(dt : @kit.UtcDateTime) -> @library.Datetime? {
@library.Datetime::from_ymd_hms(
dt.year,
dt.month,
dt.day,
dt.hour,
dt.minute,
dt.second,
)
}
///|
/// An image format to export in.
priv enum ImageExportFormat {
Png
Svg
}
///|
/// Export to one or multiple images.
fn export_image(
document : @layout.PagedDocument,
config : CompileConfig,
fmt : ImageExportFormat,
) -> Result[Array[Output], Array[@library.SourceDiagnostic]] raise @kit.StrError {
match fmt {
Png => {
let options = png_options(config).resolve(
@render.PngFormatOptions::get(document.options()),
)
export_image_pages(config, document, (page, output) => {
let pixmap = @render.render(page, options) catch {
@render.RenderPanic(msg) => raise @kit.StrError(msg)
}
let buf = pixmap.encode_png()
output.write(buf) catch {
err => raise @kit.StrError("failed to write PNG file (\{err})")
}
})
}
Svg => {
let options = svg_options(config).resolve(
@svg.SvgFormatOptions::get(document.options()),
)
export_image_pages(config, document, (page, output) => {
let svg = @svg.svg(page, options)
output.write(@utf8.encode(svg)) catch {
err => raise @kit.StrError("failed to write SVG file (\{err})")
}
})
}
}
}
///|
/// Exports the document's pages as separate images, each exported with a
/// format-specific `export_image_page` function.
fn export_image_pages(
config : CompileConfig,
document : @layout.PagedDocument,
export_image_page : (@layout.Page, Output) -> Unit raise @kit.StrError,
) -> Result[Array[Output], Array[@library.SourceDiagnostic]] raise @kit.StrError {
// Determine whether we have indexable templates in output
let can_handle_multiple = match config.output {
Stdout => false
Path(output) => has_indexable_template(output)
}
let pages = document.pages()
let exported_pages = []
for i, page in pages {
if config.pages is Some(ranges) && !ranges.includes_page(i + 1) {
continue
}
exported_pages.push((i, page))
}
if !can_handle_multiple && exported_pages.length() > 1 {
let err = match config.output {
Stdout => "to stdout"
Path(_) => "without a page number template ({p}, {0p}) in the output path"
}
raise @kit.StrError("cannot export multiple images \{err}")
}
let outputs = []
for entry in exported_pages {
let (i, page) = entry
// Use output with converted path.
let output = match config.output {
Path(path) => {
let path = if can_handle_multiple {
format_template(path, i + 1, pages.length())
} 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 config.watching &&
config.export_cache.is_cached(i, page) &&
@platform.exists(path) {
outputs.push(Output::Path(path))
continue
}
Output::Path(path)
}
Stdout => Stdout
}
export_image_page(page, output)
outputs.push(output)
}
Ok(outputs)
}
///|
let indexable_templates : ReadOnlyArray[String] = ["{p}", "{0p}", "{n}"]
///|
fn has_indexable_template(output : String) -> Bool {
indexable_templates.any(template => output.contains(template))
}
///|
fn format_template(
output : String,
this_page : Int32,
total_pages : Int32,
) -> String {
// Find the base 10 width of number `i`
let width = (i : Int32) => {
let mut digits = 1
let mut n = i
while n >= 10 {
n /= 10
digits += 1
}
digits
}
let mut out = output
for template in ["{p}", "{0p}", "{n}", "{t}"] {
let replacement = match template {
"{p}" => this_page.to_string()
"{0p}" | "{n}" => {
let s = this_page.to_string()
let w = width(total_pages)
if s.length() < w {
"0".repeat(w - s.length()) + s
} else {
s
}
}
_ => total_pages.to_string()
}
out = out.replace_all(old=template, new=replacement)
}
out
}
///|
/// Creates options for HTML export.
fn html_options(config : CompileConfig) -> @html.HtmlOptions {
{ format: { pretty: config.pretty, }, }
}
///|
/// Creates options for PDF export.
fn pdf_options(config : CompileConfig) -> @pdf.PdfOptions {
// If the timestamp is provided through the CLI, use UTC suffix, else, use
// the current local time and timezone.
let timestamp = match config.creation_timestamp {
Some(timestamp) => convert_datetime(timestamp).map(@pdf.Timestamp::new_utc)
None => {
let secs = @platform.now_ms() / 1000L
let offset = @platform.local_offset(secs)
guard @kit.UtcDateTime::from_timestamp(secs + offset.to_int64())
is Some(local_dt) else {
return pdf_options_with(config, None)
}
convert_datetime(local_dt).bind(datetime => {
@pdf.Timestamp::new_local(datetime, offset / 60)
})
}
}
pdf_options_with(config, timestamp)
}
///|
fn pdf_options_with(
config : CompileConfig,
timestamp : @pdf.Timestamp?,
) -> @pdf.PdfOptions {
{
ident: Auto,
creator: Auto,
timestamp,
format: {
pages: config.pages.map(p => Some(p)),
standard: config.pdf_standards,
tagged: config.tagged.map(t => @library.Smart::Custom(t)),
pretty: config.pretty,
},
}
}
///|
/// Creates options for SVG export.
fn svg_options(config : CompileConfig) -> @svg.SvgOptions {
{ render_bleed: false, format: { pretty: config.pretty, }, }
}
///|
/// Creates options for PNG export.
fn png_options(config : CompileConfig) -> @render.RenderOptions {
{ render_bleed: false, format: { ppi: config.ppi, }, }
}
///|
/// Caches exported files so that we can avoid re-exporting them if they
/// haven't changed.
///
/// This is done by having a list of size `files.len()` that contains the
/// hashes of the last rendered frame in each file. If a new frame is
/// inserted, this will invalidate the rest of the cache, this is deliberate
/// as to decrease the complexity and memory usage of such a cache.
priv struct ExportCache {
/// The hashes of last compilation's frames.
cache : Array[@library.U128]
}
///|
/// Creates a new export cache.
fn ExportCache::new() -> ExportCache {
{ cache: Array(capacity=32), }
}
///|
/// Returns true if the entry is cached and appends the new hash to the
/// cache (for the next compilation).
fn ExportCache::is_cached(
self : ExportCache,
i : Int32,
page : @layout.Page,
) -> Bool {
let hash = @library.hash128(page)
if i >= self.cache.length() {
self.cache.push(hash)
return false
}
let cached = self.cache[i]
self.cache[i] = hash
cached == hash
}
///|
/// Opens the output if desired.
fn open_output(config : CompileConfig) -> Unit raise HintedFailure {
guard config.open is Some(_) else { return }
// Can't open stdout.
guard config.output is Path(_) else { return }
raise bail("opening the output (`--open`) is not supported by this build")
}
///|
/// Print diagnostic messages to the terminal.
fn print_diagnostics(
world : &@kit.DiagnosticWorld,
errors : Array[@library.SourceDiagnostic],
warnings : Array[@library.SourceDiagnostic],
format : @kit.DiagnosticFormat,
) -> Unit raise @kit.FilesError {
let out = terminal_out()
@kit.emit_diagnostics(out, world, [..errors, ..warnings], format)
flush_terminal(out)
}