How to add content to page using the crate in web assembly?

Hello. I haven’t found any examples for using Rust API, other than typst-as-library, which is different from how Typst CLI code looks. So I put together a chunk of Typst CLI code and was able to compile source code with packages to PDF.

Then I looked into compile() function and yanked its code. Then a few abstractions later I got:

fn main() {
    let source_code = r#"
    #import "@preview/codly:1.3.0": *
    #import "@preview/codly-languages:0.1.8": codly-languages
    #show: codly-init
    #codly(languages: codly-languages)
    ```rs
    println!("Hello there.");
    ```
    "#;

    if compile(source_code, "output.pdf").is_err() {
        process::exit(1)
    }

    let text = TextElem::new("Hello".into()).pack();
    let table_children = (0..10)
        .map(|_| TableChild::Item(TableItem::Cell(Packed::new(TableCell::new(text.clone())))))
        .collect();
    let table = TableElem::new(table_children)
        .with_columns(TrackSizings([Sizing::Auto].repeat(2).into()))
        .pack();
    let content = table;

    if compile_content(content, "output2.pdf").is_err() {
        process::exit(1)
    }
}

And the result:

image

image

The full code is about 600 lines long.

Full example
[package]
name = "example"
version = "0.1.0"
edition = "2024"

[dependencies]
chrono = { version = "0.4.40", default-features = false, features = ["clock", "std"] }
comemo = "0.4.0"
typst = "0.13.1"
typst-kit = "0.13.1"
typst-pdf = "0.13.1"

use std::collections::HashMap;
use std::fmt::{Display, Formatter};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::{fmt, fs, io, mem, process};

use chrono::{DateTime, Datelike, FixedOffset, Local, Timelike, Utc};
use comemo::{Track, Tracked, Validate};
use typst::diag::{At, FileError, FileResult, SourceResult, StrResult, Warned, warning};
use typst::ecow::{EcoString, eco_format};
use typst::engine::{Engine, Route, Sink, Traced};
use typst::foundations::{
    Bytes, Content, Datetime, NativeElement, Packed, Smart, StyleChain, TargetElem,
};
use typst::introspection::Introspector;
use typst::layout::{PagedDocument, Sizing, TrackSizings};
use typst::model::{TableCell, TableChild, TableElem, TableItem};
use typst::syntax::{FileId, Source, Span};
use typst::text::{Font, FontBook, TextElem};
use typst::utils::LazyHash;
use typst::{Document, Library, World};
use typst_kit::download::{DownloadState, Downloader, Progress};
use typst_kit::fonts::{FontSlot, Fonts};
use typst_kit::package::PackageStorage;
use typst_pdf::{PdfOptions, PdfStandards, Timestamp};

fn main() {
    let source_code = r#"
    #import "@preview/codly:1.3.0": *
    #import "@preview/codly-languages:0.1.8": codly-languages
    #show: codly-init
    #codly(languages: codly-languages)
    ```rs
    println!("Hello there.");
    ```
    "#;

    if compile(source_code, "output.pdf").is_err() {
        process::exit(1)
    }

    let text = TextElem::new("Hello".into()).pack();
    let table_children = (0..10)
        .map(|_| TableChild::Item(TableItem::Cell(Packed::new(TableCell::new(text.clone())))))
        .collect();
    let table = TableElem::new(table_children)
        .with_columns(TrackSizings([Sizing::Auto].repeat(2).into()))
        .pack();
    let content = table;

    if compile_content(content, "output2.pdf").is_err() {
        process::exit(1)
    }
}

fn compile_content<T: AsRef<Path>>(content: Content, path: T) -> Result<(), ()> {
    let world: &dyn World = &mut SimpleWorld::new("").unwrap();
    let world = world.track();

    let binding = Traced::default();
    let traced = binding.track();

    let document = relayout::<PagedDocument>(world, traced, content).unwrap();

    let output = Output::Path(path.as_ref().to_path_buf());
    export_pdf(&document, &output).unwrap();
    Ok(())
}

fn relayout<D: Document>(
    world: Tracked<dyn World + '_>,
    traced: Tracked<Traced>,
    content: Content,
) -> SourceResult<D> {
    let library = world.library();
    let base = StyleChain::new(&library.styles);
    let target = TargetElem::set_target(D::TARGET).wrap();
    let styles = base.chain(&target);
    let empty_introspector = Introspector::default();

    let mut iter = 0;
    let mut subsink;
    let mut introspector = &empty_introspector;
    let mut document: D;

    // Relayout until all introspections stabilize.
    // If that doesn't happen within five attempts, we give up.
    loop {
        subsink = Sink::new();

        let constraint = <Introspector as Validate>::Constraint::new();
        let mut engine = Engine {
            world,
            introspector: introspector.track_with(&constraint),
            traced,
            sink: subsink.track_mut(),
            route: Route::default(),
            routines: &typst::ROUTINES,
        };

        // Layout!
        document = D::create(&mut engine, &content, styles)?;
        introspector = document.introspector();
        iter += 1;

        if introspector.validate(&constraint) {
            break;
        }

        if iter >= 5 {
            subsink.warn(warning!(
                Span::detached(), "layout did not converge within 5 attempts";
                hint: "check if any states or queries are updating themselves"
            ));
            break;
        }
    }

    Ok(document)
}

/// A very simple in-memory world.
struct SimpleWorld {
    /// The root relative to which absolute paths are resolved.
    root: PathBuf,
    /// Main file source code.
    source: Source,
    /// Typst's standard library.
    library: LazyHash<Library>,
    /// Metadata about discovered fonts.
    book: LazyHash<FontBook>,
    /// Locations of and storage for lazily loaded fonts.
    fonts: Vec<FontSlot>,
    /// Maps file ids to source files and buffers.
    slots: Mutex<HashMap<FileId, FileSlot>>,
    /// Holds information about where packages are stored.
    package_storage: PackageStorage,
    /// The current datetime if requested. This is stored here to ensure it is
    /// always the same within one compilation.
    /// Reset between compilations if not [`Now::Fixed`].
    now: Now,
}

impl SimpleWorld {
    fn new(source_code: &str) -> Result<Self, WorldCreationError> {
        // Resolve the system-global root directory.
        let root = {
            let path = Path::new(".");
            path.canonicalize().map_err(|err| match err.kind() {
                io::ErrorKind::NotFound => WorldCreationError::RootNotFound(path.to_path_buf()),
                _ => WorldCreationError::Io(err),
            })?
        };

        let library = {
            Library::builder()
                .with_inputs([].into_iter().collect())
                .with_features([].into_iter().collect())
                .build()
        };

        let fonts = Fonts::searcher()
            .include_system_fonts(true)
            .search_with([""]);

        let now = Now::System(OnceLock::new());

        Ok(Self {
            source: Source::detached(source_code),
            root,
            library: LazyHash::new(library),
            book: LazyHash::new(fonts.book),
            fonts: fonts.fonts,
            slots: Mutex::new(HashMap::new()),
            package_storage: storage(),
            now,
        })
    }
}

/// Returns a new package storage for the given args.
pub fn storage() -> PackageStorage {
    PackageStorage::new(None, None, downloader())
}

impl World for SimpleWorld {
    fn library(&self) -> &LazyHash<Library> {
        &self.library
    }

    fn book(&self) -> &LazyHash<FontBook> {
        &self.book
    }

    fn main(&self) -> FileId {
        self.source.id()
    }

    /// Accessing a specified source file (based on `FileId`).
    fn source(&self, id: FileId) -> FileResult<Source> {
        if id == self.source.id() {
            Ok(self.source.clone())
        } else {
            self.slot(id, |slot| slot.source(&self.root, &self.package_storage))
        }
    }

    fn file(&self, id: FileId) -> FileResult<Bytes> {
        self.slot(id, |slot| slot.file(&self.root, &self.package_storage))
    }

    fn font(&self, index: usize) -> Option<Font> {
        self.fonts.get(index)?.get()
    }

    fn today(&self, offset: Option<i64>) -> Option<Datetime> {
        let now = match &self.now {
            Now::System(time) => time.get_or_init(Utc::now),
        };

        // The time with the specified UTC offset, or within the local time zone.
        let with_offset = match offset {
            None => now.with_timezone(&Local).fixed_offset(),
            Some(hours) => {
                let seconds = i32::try_from(hours).ok()?.checked_mul(3600)?;
                now.with_timezone(&FixedOffset::east_opt(seconds)?)
            }
        };

        Datetime::from_ymd(
            with_offset.year(),
            with_offset.month().try_into().ok()?,
            with_offset.day().try_into().ok()?,
        )
    }
}

impl SimpleWorld {
    /// Access the canonical slot for the given file id.
    fn slot<F, T>(&self, id: FileId, f: F) -> T
    where
        F: FnOnce(&mut FileSlot) -> T,
    {
        let mut map = self.slots.lock().unwrap();
        f(map.entry(id).or_insert_with(|| FileSlot::new(id)))
    }
}

/// Holds the processed data for a file ID.
///
/// Both fields can be populated if the file is both imported and read().
struct FileSlot {
    /// The slot's file id.
    id: FileId,
    /// The lazily loaded and incrementally updated source file.
    source: SlotCell<Source>,
    /// The lazily loaded raw byte buffer.
    file: SlotCell<Bytes>,
}

impl FileSlot {
    /// Create a new file slot.
    fn new(id: FileId) -> Self {
        Self {
            id,
            file: SlotCell::new(),
            source: SlotCell::new(),
        }
    }

    /// Retrieve the source for this file.
    fn source(
        &mut self,
        project_root: &Path,
        package_storage: &PackageStorage,
    ) -> FileResult<Source> {
        self.source.get_or_init(
            || read(self.id, project_root, package_storage),
            |data, prev| {
                let text = decode_utf8(&data)?;
                if let Some(mut prev) = prev {
                    prev.replace(text);
                    Ok(prev)
                } else {
                    Ok(Source::new(self.id, text.into()))
                }
            },
        )
    }

    /// Retrieve the file's bytes.
    fn file(&mut self, project_root: &Path, package_storage: &PackageStorage) -> FileResult<Bytes> {
        self.file.get_or_init(
            || read(self.id, project_root, package_storage),
            |data, _| Ok(Bytes::new(data)),
        )
    }
}

/// Lazily processes data for a file.
struct SlotCell<T> {
    /// The processed data.
    data: Option<FileResult<T>>,
    /// A hash of the raw file contents / access error.
    fingerprint: u128,
    /// Whether the slot has been accessed in the current compilation.
    accessed: bool,
}

impl<T: Clone> SlotCell<T> {
    /// Creates a new, empty cell.
    fn new() -> Self {
        Self {
            data: None,
            fingerprint: 0,
            accessed: false,
        }
    }

    /// Gets the contents of the cell or initialize them.
    fn get_or_init(
        &mut self,
        load: impl FnOnce() -> FileResult<Vec<u8>>,
        f: impl FnOnce(Vec<u8>, Option<T>) -> FileResult<T>,
    ) -> FileResult<T> {
        // If we accessed the file already in this compilation, retrieve it.
        if mem::replace(&mut self.accessed, true) {
            if let Some(data) = &self.data {
                return data.clone();
            }
        }

        // Read and hash the file.
        let result = load();
        let fingerprint = typst::utils::hash128(&result);

        // If the file contents didn't change, yield the old processed data.
        if mem::replace(&mut self.fingerprint, fingerprint) == fingerprint {
            if let Some(data) = &self.data {
                return data.clone();
            }
        }

        let prev = self.data.take().and_then(Result::ok);
        let value = result.and_then(|data| f(data, prev));
        self.data = Some(value.clone());

        value
    }
}

/// Resolves the path of a file id on the system, downloading a package if
/// necessary.
fn system_path(
    project_root: &Path,
    id: FileId,
    package_storage: &PackageStorage,
) -> FileResult<PathBuf> {
    // Determine the root path relative to which the file path
    // will be resolved.
    let buf;
    let mut root = project_root;
    if let Some(spec) = id.package() {
        buf = package_storage.prepare_package(spec, &mut PrintDownload(&spec))?;
        root = &buf;
    }

    // Join the path to the root. If it tries to escape, deny
    // access. Note: It can still escape via symlinks.
    id.vpath().resolve(root).ok_or(FileError::AccessDenied)
}

/// Reads a file from a `FileId`.
///
/// If the ID represents stdin it will read from standard input,
/// otherwise it gets the file path of the ID and reads the file from disk.
fn read(id: FileId, project_root: &Path, package_storage: &PackageStorage) -> FileResult<Vec<u8>> {
    read_from_disk(&system_path(project_root, id, package_storage)?)
}

/// Read a file from disk.
fn read_from_disk(path: &Path) -> FileResult<Vec<u8>> {
    let f = |e| FileError::from_io(e, path);
    if fs::metadata(path).map_err(f)?.is_dir() {
        Err(FileError::IsDirectory)
    } else {
        fs::read(path).map_err(f)
    }
}

/// Decode UTF-8 with an optional BOM.
fn decode_utf8(buf: &[u8]) -> FileResult<&str> {
    // Remove UTF-8 BOM.
    Ok(std::str::from_utf8(
        buf.strip_prefix(b"\xef\xbb\xbf").unwrap_or(buf),
    )?)
}

/// The current date and time.
enum Now {
    /// The current date and time if the time is not externally fixed.
    System(OnceLock<DateTime<Utc>>),
}

/// An error that occurs during world construction.
#[derive(Debug)]
pub enum WorldCreationError {
    /// The input file does not appear to exist.
    InputNotFound(PathBuf),
    /// The input file is not contained within the root folder.
    InputOutsideRoot,
    /// The root directory does not appear to exist.
    RootNotFound(PathBuf),
    /// Another type of I/O error.
    Io(io::Error),
}

impl fmt::Display for WorldCreationError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            WorldCreationError::InputNotFound(path) => {
                write!(f, "input file not found (searched at {})", path.display())
            }
            WorldCreationError::InputOutsideRoot => {
                write!(f, "source file must be contained in project root")
            }
            WorldCreationError::RootNotFound(path) => {
                write!(
                    f,
                    "root directory not found (searched at {})",
                    path.display()
                )
            }
            WorldCreationError::Io(err) => write!(f, "{err}"),
        }
    }
}

impl From<WorldCreationError> for EcoString {
    fn from(err: WorldCreationError) -> Self {
        eco_format!("{err}")
    }
}

fn compile<T: AsRef<Path>, U: AsRef<str>>(source_code: U, path: T) -> Result<(), ()> {
    let mut world = SimpleWorld::new(source_code.as_ref()).unwrap();
    let output = Output::Path(path.as_ref().to_path_buf());
    let Warned { output, warnings } = compile_and_export(&mut world, &output);
    match output {
        Ok(_) => {
            for diagnostic in warnings {
                eprintln!("{}", diagnostic.message);
            }
            Ok(())
        }
        Err(errors) => {
            for diagnostic in warnings.into_iter().chain(errors) {
                eprintln!("{}", diagnostic.message);
            }
            Err(())
        }
    }
}

/// Compile and then export the document.
fn compile_and_export(
    world: &mut SimpleWorld,
    output: &Output,
) -> Warned<SourceResult<Vec<Output>>> {
    let Warned {
        output: document,
        warnings,
    } = typst::compile::<PagedDocument>(world);
    let result =
        document.and_then(|document| export_pdf(&document, output).map(|()| vec![output.clone()]));
    Warned {
        output: result,
        warnings,
    }
}

/// Export to a PDF.
fn export_pdf(document: &PagedDocument, output: &Output) -> SourceResult<()> {
    // If the timestamp is provided through the CLI, use UTC suffix,
    // else, use the current local time and timezone.
    let timestamp = {
        let local_datetime = chrono::Local::now();
        convert_datetime(local_datetime).and_then(|datetime| {
            Timestamp::new_local(datetime, local_datetime.offset().local_minus_utc() / 60)
        })
    };

    let options = PdfOptions {
        ident: Smart::Auto,
        timestamp,
        page_ranges: None,
        standards: PdfStandards::default(),
    };
    let buffer = typst_pdf::pdf(document, &options)?;

    output
        .write(&buffer)
        .map_err(|err| eco_format!("failed to write PDF file ({err})"))
        .at(Span::detached())?;
    Ok(())
}

/// 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 Output {
    fn write(&self, buffer: &[u8]) -> StrResult<()> {
        match self {
            Output::Stdout => io::Write::write_all(&mut std::io::stdout(), buffer),
            Output::Path(path) => fs::write(path, buffer),
        }
        .map_err(|err| eco_format!("{err}"))
    }
}

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),
        }
    }
}

/// Convert [`chrono::DateTime`] to [`Datetime`]
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()?,
    )
}

/// Prints download progress by writing `downloading {0}` followed by repeatedly
/// updating the last terminal line.
pub struct PrintDownload<T>(pub T);

impl<T: Display> Progress for PrintDownload<T> {
    fn print_start(&mut self) {}
    fn print_progress(&mut self, _state: &DownloadState) {}
    fn print_finish(&mut self, _state: &DownloadState) {}
}

/// Returns a new downloader.
pub fn downloader() -> Downloader {
    let user_agent = concat!("typst/", env!("CARGO_PKG_VERSION"));
    Downloader::new(user_agent)
}