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

Hello everyone,

(First sorry for my english which is not my native language)
Former Latex user, I’m a big fan of Typst, very very good work!

I have a project in web assembly with rust, I’m using the typst crate to generate a pdf from a json created from the javascript part.
Unfortunately I don’t understand enough the typst crate documentation: I can create a TextElem, and so on, but I don’t understand how to put the elements on pages… I always get an empty pdf, without any of the basic element I’ve created.

For now as a fallback, I use the json, parse it and create a “typst string command”, which is used as source in a World I created.
But when I will have a huge json, I will need to create a huge string in typst language, which will be parsed and used by the typst function internally. I think I can avoid creating the typst string command if I’m able to use the crate’s functions, and have better performances.

Ideally, I would like to profit of the auto-layouting of typst and not make a single calculation, like putting all basic element in a vec, then typst use this vec, do its job, and create a pdf with several pages (so I would like not to define which element are on which page).

If someone could send me some hints, or a simple example of how to put TextElement into pages and then generate a pdf, it would me really nice because for now I’m stuck :slight_smile:

Thank you everyone, have a good day.

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

Wow, that’s a lot!
Thank you very much, I will study this.

1 Like

You should definitely avoid generating a very long piece of Typst source code (see e.g. Why does Typst crash with large amounts of content?), but there is an alternative other than creating the Typst data structures directly in Rust code; you can load the JSON file into Typst and use Typst scripting to create the document.

In LaTeX it may seem natural to use code generation when trying to incorporate external data into a document, but in Typst it’s actually very easy to use scripting for that purpose, and being able to generate large documents from external data is IMO one of the main use cases for Typst. See data-loading for other relevant topics.

I would definitely at least attempt using “regular” Typst here, and only drop down to Rust if it’s measurably too slow, since you’re losing a lot of versatility when making that switch.

1 Like

Thank you, I will search in this direction.