embedded-typst 0.1.0

A typst::World for embedding the Typst compiler: lazily loaded fonts, a virtual filesystem over several roots, Typst packages and reproducible dates. Native and Wasm.
Documentation
//! The [`World`] implementation.

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock, RwLock};

use typst::diag::{FileError, FileResult};
use typst::foundations::{Bytes, Datetime, Dict, Duration};
use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
use typst::text::{Font, FontBook};
use typst::utils::LazyHash;
use typst::{Library, LibraryExt, World};

use crate::Fonts;

/// The id of a file in the project (not in a package).
pub fn project_file(path: VirtualPath) -> FileId {
    RootedPath::new(VirtualRoot::Project, path).intern()
}

/// Lazy cache entry for a file in the virtual filesystem.
struct FileSlot {
    source: OnceLock<FileResult<Source>>,
    bytes: OnceLock<FileResult<Bytes>>,
}

impl FileSlot {
    fn new() -> Self {
        Self {
            source: OnceLock::new(),
            bytes: OnceLock::new(),
        }
    }
}

/// A file Typst read during a compilation, see [`EmbeddedWorld::dependencies`].
#[cfg(not(target_arch = "wasm32"))]
pub struct Dependency {
    /// The project-relative path, or `@namespace/name:version/path` for a
    /// package file.
    pub name: String,
    /// The file on disk.
    pub path: PathBuf,
    /// The contents Typst saw.
    pub data: Bytes,
}

/// A Typst world for one compilation: the main source, in-memory files and,
/// on native targets, files looked up in a list of root directories and in
/// Typst packages.
///
/// Files are cached for the lifetime of the world, so create a new world for
/// every compilation and share the [`Fonts`] between them.
pub struct EmbeddedWorld {
    main: Source,
    library: LazyHash<Library>,
    fonts: Arc<Fonts>,
    files: RwLock<HashMap<FileId, FileSlot>>,
    roots: Vec<PathBuf>,
    #[cfg(all(feature = "packages", not(target_arch = "wasm32")))]
    packages: Option<crate::Packages>,
    fixed_now: Option<chrono::DateTime<chrono::Utc>>,
    #[cfg(not(target_arch = "wasm32"))]
    now: OnceLock<chrono::DateTime<chrono::Utc>>,
}

impl EmbeddedWorld {
    /// A world whose main file is `main` with the text `source`, without
    /// roots, inputs or packages.
    pub fn new(main: VirtualPath, source: impl Into<String>, fonts: Arc<Fonts>) -> Self {
        Self {
            main: Source::new(project_file(main), source.into()),
            library: LazyHash::new(Library::default()),
            fonts,
            files: RwLock::new(HashMap::new()),
            roots: Vec::new(),
            #[cfg(all(feature = "packages", not(target_arch = "wasm32")))]
            packages: None,
            fixed_now: None,
            #[cfg(not(target_arch = "wasm32"))]
            now: OnceLock::new(),
        }
    }

    /// Look up project files that are not in memory in these directories, in
    /// order. The first root that contains the file wins.
    pub fn with_roots(mut self, roots: Vec<PathBuf>) -> Self {
        self.roots = roots;
        self
    }

    /// Make `inputs` available as `sys.inputs`.
    pub fn with_inputs(mut self, inputs: Dict) -> Self {
        self.library = LazyHash::new(Library::builder().with_inputs(inputs).build());
        self
    }

    /// Answer `datetime.today()` from this Unix timestamp (in UTC) instead of
    /// the system clock, for reproducible output. Without it, Wasm has no
    /// date at all.
    pub fn with_creation_timestamp(mut self, seconds: i64) -> Self {
        self.fixed_now = chrono::DateTime::from_timestamp(seconds, 0);
        self
    }

    /// Resolve `@namespace/name:version` imports from `packages`.
    #[cfg(all(feature = "packages", not(target_arch = "wasm32")))]
    pub fn with_packages(mut self, packages: crate::Packages) -> Self {
        self.packages = Some(packages);
        self
    }

    /// Serve `contents` as the project file `path`, in front of the roots.
    pub fn add_file(&self, path: VirtualPath, contents: impl AsRef<[u8]> + Send + Sync + 'static) {
        let mut files = self
            .files
            .write()
            .expect("virtual filesystem lock poisoned");
        let slot = files
            .entry(project_file(path))
            .or_insert_with(FileSlot::new);
        let _ = slot.bytes.set(Ok(Bytes::new(contents)));
    }

    /// The fonts of this world.
    pub fn fonts(&self) -> &Fonts {
        &self.fonts
    }

    /// The file on disk for `id`: a project file is looked up in every root,
    /// a package file in its package directory.
    pub fn path_for(&self, id: FileId) -> FileResult<PathBuf> {
        let not_found = || FileError::NotFound(id.vpath().get_with_slash().into());
        match id.root() {
            VirtualRoot::Project => self
                .roots
                .iter()
                .filter_map(|root| id.vpath().realize(root).ok())
                .find(|path| path.is_file())
                .ok_or_else(not_found),
            #[cfg(all(feature = "packages", not(target_arch = "wasm32")))]
            VirtualRoot::Package(spec) => {
                let packages = self.packages.as_ref().ok_or_else(not_found)?;
                let root = packages.root(spec)?;
                id.vpath().realize(&root).map_err(|_| not_found())
            }
            #[cfg(not(all(feature = "packages", not(target_arch = "wasm32"))))]
            VirtualRoot::Package(_) => Err(not_found()),
        }
    }

    /// The path to show in diagnostics: the real file, relative to the
    /// working directory where possible, otherwise the virtual path.
    pub fn display_path(&self, id: FileId) -> String {
        let Ok(path) = self.path_for(id) else {
            return id.vpath().get_without_slash().to_owned();
        };
        std::env::current_dir()
            .ok()
            .and_then(|cwd| cwd.canonicalize().ok())
            .and_then(|cwd| path.strip_prefix(cwd).ok().map(Path::to_path_buf))
            .unwrap_or(path)
            .display()
            .to_string()
    }

    /// Every file Typst read from disk so far, except the main file and
    /// in-memory files.
    #[cfg(not(target_arch = "wasm32"))]
    pub fn dependencies(&self) -> Vec<Dependency> {
        let files = self.files.read().expect("virtual filesystem lock poisoned");
        let mut dependencies = Vec::new();
        for (id, slot) in files.iter() {
            let data = match (slot.bytes.get(), slot.source.get()) {
                (Some(Ok(bytes)), _) => bytes.clone(),
                (_, Some(Ok(source))) => Bytes::from_string(source.text().to_owned()),
                _ => continue,
            };
            let Ok(path) = self.path_for(*id) else {
                continue;
            };
            let name = match id.root() {
                VirtualRoot::Project => id.vpath().get_without_slash().to_owned(),
                VirtualRoot::Package(spec) => format!("{spec}/{}", id.vpath().get_without_slash()),
            };
            dependencies.push(Dependency { name, path, data });
        }
        dependencies
    }

    fn cached_or_load<T: Clone>(
        &self,
        id: FileId,
        cell: impl Fn(&FileSlot) -> &OnceLock<FileResult<T>>,
        load: impl FnOnce(&Path) -> FileResult<T>,
    ) -> FileResult<T> {
        {
            let files = self.files.read().expect("virtual filesystem lock poisoned");
            if let Some(result) = files.get(&id).and_then(|slot| cell(slot).get()) {
                return result.clone();
            }
        }

        let result = self.path_for(id).and_then(|path| load(&path));

        let mut files = self
            .files
            .write()
            .expect("virtual filesystem lock poisoned");
        let slot = files.entry(id).or_insert_with(FileSlot::new);
        cell(slot).get_or_init(|| result.clone()).clone()
    }
}

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

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

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

    fn source(&self, id: FileId) -> FileResult<Source> {
        if id == self.main.id() {
            return Ok(self.main.clone());
        }

        {
            let files = self.files.read().expect("virtual filesystem lock poisoned");
            if let Some(Ok(bytes)) = files.get(&id).and_then(|slot| slot.bytes.get()) {
                let text =
                    std::str::from_utf8(bytes.as_slice()).map_err(|_| FileError::InvalidUtf8)?;
                return Ok(Source::new(id, text.to_owned()));
            }
        }

        self.cached_or_load(
            id,
            |slot| &slot.source,
            |path| {
                std::fs::read_to_string(path)
                    .map_err(|error| FileError::from_io(error, path))
                    .map(|text| Source::new(id, text))
            },
        )
    }

    fn file(&self, id: FileId) -> FileResult<Bytes> {
        self.cached_or_load(
            id,
            |slot| &slot.bytes,
            |path| {
                std::fs::read(path)
                    .map_err(|error| FileError::from_io(error, path))
                    .map(Bytes::new)
            },
        )
    }

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

    fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
        use chrono::Datelike;

        // A fixed creation timestamp is interpreted as UTC so the result does
        // not depend on the machine's time zone. The system clock is read
        // once per world, so every call agrees.
        let (now, local) = match self.fixed_now {
            Some(now) => (now, false),
            #[cfg(not(target_arch = "wasm32"))]
            None => (*self.now.get_or_init(chrono::Utc::now), true),
            #[cfg(target_arch = "wasm32")]
            None => return None,
        };
        let date = match offset {
            #[cfg(not(target_arch = "wasm32"))]
            None if local => now.with_timezone(&chrono::Local).date_naive(),
            None => now.date_naive(),
            Some(offset) => {
                (now + chrono::Duration::try_seconds(offset.seconds() as i64)?).date_naive()
            }
        };
        #[cfg(target_arch = "wasm32")]
        let _ = local;
        Datetime::from_ymd(
            date.year(),
            date.month().try_into().ok()?,
            date.day().try_into().ok()?,
        )
    }
}