typst-bake 0.1.9

Bake Typst templates, fonts, and packages into your Rust binary — use Typst as a self-contained, embedded library
Documentation
//! Embedded file resolver for templates and packages
//!
//! Uses lazy decompression - files are decompressed only when accessed.

use crate::util::decompress;
use include_dir::Dir;
use std::borrow::Cow;
use std::collections::HashMap;
use typst::diag::{FileError, FileResult};
use typst::foundations::Bytes;
use typst::syntax::{FileId, Source};

pub use typst_as_lib::file_resolver::FileResolver;

/// Resolver for embedded templates and packages.
///
/// Files are stored in compressed form and decompressed lazily on access.
/// Typst's internal caching prevents redundant decompression.
pub struct EmbeddedResolver {
    files: HashMap<String, &'static [u8]>,
    runtime_files: HashMap<String, Vec<u8>>,
}

impl EmbeddedResolver {
    /// Create a new resolver from embedded directories.
    pub fn new(templates: &'static Dir<'static>, packages: &'static Dir<'static>) -> Self {
        let mut files = HashMap::new();

        collect_files(templates, "", &mut files);
        collect_files(packages, "", &mut files);

        Self {
            files,
            runtime_files: HashMap::new(),
        }
    }

    /// Get the file path string from a `FileId`.
    fn get_path(&self, id: FileId) -> String {
        if let Some(pkg) = id.package() {
            // Package file: namespace/name/version/vpath
            format!(
                "{}/{}/{}/{}",
                pkg.namespace,
                pkg.name,
                pkg.version,
                normalize_path(id.vpath().as_rootless_path())
            )
        } else {
            // Template file: just vpath
            normalize_path(id.vpath().as_rootless_path())
        }
    }

    /// Insert a runtime file (uncompressed).
    pub(crate) fn insert_runtime_file(&mut self, path: String, data: Vec<u8>) {
        self.runtime_files.insert(path, data);
    }

    /// Look up and decompress a file by its FileId.
    /// Runtime files take priority over embedded files.
    fn decompress_file(&self, id: FileId) -> FileResult<Vec<u8>> {
        let path = self.get_path(id);

        // Runtime files are stored uncompressed — return directly.
        if let Some(data) = self.runtime_files.get(&path) {
            return Ok(data.clone());
        }

        let compressed = self
            .files
            .get(&path)
            .copied()
            .ok_or_else(|| not_found(id))?;
        decompress(compressed).map_err(|e| {
            FileError::Other(Some(format!("Decompression failed for {path}: {e}").into()))
        })
    }
}

impl FileResolver for EmbeddedResolver {
    fn resolve_binary(&self, id: FileId) -> FileResult<Cow<'_, Bytes>> {
        let data = self.decompress_file(id)?;
        Ok(Cow::Owned(Bytes::new(data)))
    }

    fn resolve_source(&self, id: FileId) -> FileResult<Cow<'_, Source>> {
        let bytes = self.decompress_file(id)?;
        let source = bytes_to_source(id, &bytes)?;
        Ok(Cow::Owned(source))
    }
}

/// Convert a Path to a forward-slash string.
fn normalize_path(path: &std::path::Path) -> String {
    path.display().to_string().replace('\\', "/")
}

/// Normalize a string file path: strip leading `./` and convert `\` to `/`.
pub(crate) fn normalize_file_path(path: &str) -> String {
    path.trim_start_matches("./").replace('\\', "/")
}

/// Join a prefix and name with `/`, or return name alone if prefix is empty.
fn join_path(prefix: &str, name: &str) -> String {
    if prefix.is_empty() {
        name.to_owned()
    } else {
        format!("{prefix}/{name}")
    }
}

/// Recursively collect files from include_dir with path prefix tracking.
fn collect_files(
    dir: &'static Dir<'static>,
    prefix: &str,
    map: &mut HashMap<String, &'static [u8]>,
) {
    for file in dir.files() {
        let full_path = join_path(prefix, &normalize_path(file.path()));
        map.insert(full_path, file.contents());
    }

    for subdir in dir.dirs() {
        let new_prefix = join_path(prefix, &normalize_path(subdir.path()));
        collect_files(subdir, &new_prefix, map);
    }
}

/// Create a "not found" error.
fn not_found(id: FileId) -> FileError {
    FileError::NotFound(id.vpath().as_rootless_path().into())
}

/// Convert bytes to Source, handling UTF-8 BOM.
fn bytes_to_source(id: FileId, bytes: &[u8]) -> FileResult<Source> {
    // Handle UTF-8 BOM
    let text = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
        std::str::from_utf8(&bytes[3..])
    } else {
        std::str::from_utf8(bytes)
    };

    let text = text.map_err(|_| FileError::InvalidUtf8)?;
    Ok(Source::new(id, text.to_string()))
}