Skip to main content

native/typst_nif/src/lib.rs

use chrono::{DateTime, Datelike, FixedOffset, Local, Utc};
use parking_lot::Mutex;
use rustler::{Atom, Binary, Decoder, Encoder, Env, NewBinary, NifStruct, ResourceArc, Term};
use std::collections::HashMap;
use std::num::NonZeroUsize;
use std::panic::{RefUnwindSafe, UnwindSafe};
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::sync::OnceLock;
use std::{fs, mem};
use typst::diag::{FileError, FileResult, Severity, SourceDiagnostic};
use typst::ecow::EcoVec;
use typst::foundations::{Bytes, Datetime, Dict, Duration, Smart, Str, Value};
use typst::layout::PageRanges;
use typst::syntax::{DiagSpan, FileId, RootedPath, Source, VirtualPath, VirtualRoot};
use typst::text::{Font, FontBook};
use typst::utils::LazyHash;
use typst::{Feature, Features, Library, LibraryExt, World, WorldExt};
use typst_bundle::{export as export_bundle, Bundle, BundleOptions};
use typst_html::{HtmlDocument, HtmlOptions};
use typst_kit::downloader::SystemDownloader;
use typst_kit::files::FsRoot;
use typst_kit::fonts::{embedded, scan, system, FontStore};
use typst_kit::packages::SystemPackages;
use typst_layout::PagedDocument;
use typst_pdf::{PdfOptions, PdfStandard, PdfStandards};
use typst_svg::SvgOptions;
use typst_timing::{timed, TimingScope};

static MARKUP_ID: LazyLock<FileId> = LazyLock::new(|| {
    FileId::unique(RootedPath::new(
        VirtualRoot::Project,
        VirtualPath::new("MARKUP.typ").unwrap(),
    ))
});

/// Stack size for Typst compilation threads (64 MB).
///
/// `typst::compile()` recurses deeply for nested content.  On BEAM dirty-
/// scheduler threads the default stack is ~2 MB (Linux) / ~8 MB (macOS),
/// which overflows at ~27 / ~31 nesting levels.  A stack overflow triggers
/// SIGSEGV — not a Rust panic — and kills the entire BEAM VM.
///
/// Spawning compilation on a dedicated thread with a large stack pushes the
/// overflow threshold to ~800+ levels, far beyond any realistic document.
const COMPILE_STACK_SIZE: usize = 64 * 1024 * 1024;

/// Compiler features enabled for every compilation: HTML export and the
/// multi-file `bundle` export target.
fn enabled_features() -> Features {
    Features::from_iter([Feature::Html, Feature::Bundle])
}

rustler::atoms! {
    ok,
    pdf_1_7,
    pdf_a_2b,
    pdf_a_3b,
    error,
    warning
}

#[derive(NifStruct)]
#[module = "AshTypst.Context.Options"]
pub struct ContextOptionsNif {
    pub root: String,
    pub font_paths: Vec<String>,
    pub ignore_system_fonts: bool,
}

#[derive(NifStruct)]
#[module = "AshTypst.PDFOptions"]
pub struct PdfOptionsNif {
    pub pages: Option<String>,
    pub pdf_standards: Vec<PdfStandardNif>,
    pub document_id: Option<String>,
}

#[derive(NifStruct)]
#[module = "AshTypst.BundleOptions"]
pub struct BundleOptionsNif {
    pub pretty: bool,
    pub render_bleed: bool,
}

#[derive(NifStruct)]
#[module = "AshTypst.BundleResult"]
pub struct BundleResultNif<'a> {
    pub files: HashMap<String, Binary<'a>>,
    pub warnings: Vec<DiagnosticNif>,
}

#[derive(NifStruct)]
#[module = "AshTypst.FontOptions"]
pub struct FontOptionsNif {
    pub font_paths: Vec<String>,
    pub ignore_system_fonts: bool,
}

#[derive(NifStruct)]
#[module = "AshTypst.CompileResult"]
pub struct CompileResultNif {
    pub page_count: usize,
    pub warnings: Vec<DiagnosticNif>,
}

#[derive(NifStruct)]
#[module = "AshTypst.CompileError"]
pub struct CompileErrorNif {
    pub diagnostics: Vec<DiagnosticNif>,
}

#[derive(NifStruct)]
#[module = "AshTypst.Diagnostic"]
pub struct DiagnosticNif {
    pub severity: SeverityNif,
    pub message: String,
    pub span: Option<SpanNif>,
    pub trace: Vec<TraceItemNif>,
    pub hints: Vec<String>,
}

#[derive(NifStruct)]
#[module = "AshTypst.Span"]
pub struct SpanNif {
    pub start: usize,
    pub end: usize,
    pub line: Option<usize>,
    pub column: Option<usize>,
}

#[derive(NifStruct)]
#[module = "AshTypst.TraceItem"]
pub struct TraceItemNif {
    pub span: Option<SpanNif>,
    pub message: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SeverityNif {
    Error,
    Warning,
}

impl Decoder<'_> for SeverityNif {
    fn decode(term: Term) -> Result<Self, rustler::Error> {
        let atom: Atom = term.decode()?;
        if atom == error() {
            Ok(SeverityNif::Error)
        } else if atom == warning() {
            Ok(SeverityNif::Warning)
        } else {
            Err(rustler::Error::BadArg)
        }
    }
}

impl Encoder for SeverityNif {
    fn encode<'a>(&self, env: Env<'a>) -> Term<'a> {
        match self {
            SeverityNif::Error => error().encode(env),
            SeverityNif::Warning => warning().encode(env),
        }
    }
}

impl From<Severity> for SeverityNif {
    fn from(severity: Severity) -> Self {
        match severity {
            Severity::Error => SeverityNif::Error,
            Severity::Warning => SeverityNif::Warning,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PdfStandardNif {
    Pdf17,
    PdfA2b,
    PdfA3b,
}

impl Decoder<'_> for PdfStandardNif {
    fn decode(term: Term) -> Result<Self, rustler::Error> {
        let atom: Atom = term.decode()?;
        if atom == pdf_1_7() {
            Ok(PdfStandardNif::Pdf17)
        } else if atom == pdf_a_2b() {
            Ok(PdfStandardNif::PdfA2b)
        } else if atom == pdf_a_3b() {
            Ok(PdfStandardNif::PdfA3b)
        } else {
            Err(rustler::Error::BadArg)
        }
    }
}

impl Encoder for PdfStandardNif {
    fn encode<'a>(&self, env: Env<'a>) -> Term<'a> {
        match self {
            PdfStandardNif::Pdf17 => pdf_1_7().encode(env),
            PdfStandardNif::PdfA2b => pdf_a_2b().encode(env),
            PdfStandardNif::PdfA3b => pdf_a_3b().encode(env),
        }
    }
}

impl From<PdfStandardNif> for PdfStandard {
    fn from(standard: PdfStandardNif) -> Self {
        match standard {
            PdfStandardNif::Pdf17 => PdfStandard::V_1_7,
            PdfStandardNif::PdfA2b => PdfStandard::A_2b,
            PdfStandardNif::PdfA3b => PdfStandard::A_3b,
        }
    }
}

impl PdfOptionsNif {
    fn to_pdf_options(&self) -> Result<PdfOptions, String> {
        let mut opts = PdfOptions::default();

        if let Some(ref document_id) = self.document_id {
            opts.ident = Smart::Custom(document_id.clone());
        }

        if !self.pdf_standards.is_empty() {
            let standards: Vec<PdfStandard> =
                self.pdf_standards.iter().map(|&s| s.into()).collect();
            opts.standards = PdfStandards::new(&standards)
                .map_err(|e| format!("Invalid PDF standards: {}", e.message()))?;
        }

        Ok(opts)
    }
}

pub struct SystemWorld {
    root: PathBuf,
    main: FileId,
    markup: String,
    library: LazyHash<Library>,
    fonts: FontStore,
    slots: Mutex<HashMap<FileId, FileSlot>>,
    packages: SystemPackages,
    now: Now,
    virtual_files: HashMap<String, Vec<u8>>,
    inputs: HashMap<String, String>,
}

impl SystemWorld {
    pub fn new(root: PathBuf, font_paths: Vec<PathBuf>, ignore_system_fonts: bool) -> Self {
        let filtered_paths: Vec<PathBuf> = font_paths
            .into_iter()
            .filter(|p| p.exists() && p.is_dir())
            .collect();

        let include_system_fonts = !ignore_system_fonts;

        let mut fonts = FontStore::new();
        for path in &filtered_paths {
            fonts.extend(scan(path));
        }
        if include_system_fonts {
            fonts.extend(system());
        }
        fonts.extend(embedded());

        let user_agent = concat!("typst/", env!("CARGO_PKG_VERSION"));
        Self {
            root,
            main: *MARKUP_ID,
            markup: String::new(),
            library: LazyHash::new(Library::builder().with_features(enabled_features()).build()),
            fonts,
            slots: Mutex::new(HashMap::new()),
            packages: SystemPackages::new(SystemDownloader::new(user_agent)),
            now: Now::System(OnceLock::new()),
            virtual_files: HashMap::new(),
            inputs: HashMap::new(),
        }
    }

    pub fn reset(&mut self) {
        for slot in self.slots.get_mut().values_mut() {
            slot.reset();
        }
        if let Now::System(time_lock) = &mut self.now {
            time_lock.take();
        }
    }

    fn rebuild_library(&mut self) {
        let mut dict = Dict::new();
        for (key, value) in &self.inputs {
            dict.insert(
                Str::from(key.as_str()),
                Value::Str(Str::from(value.as_str())),
            );
        }
        self.library = LazyHash::new(
            Library::builder()
                .with_inputs(dict)
                .with_features(enabled_features())
                .build(),
        );
    }
}

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

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

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

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

        if let Some(content) = self.virtual_files.get(id.vpath().get_without_slash()) {
            let text = decode_utf8(content)?;
            return Ok(Source::new(id, text.into()));
        }

        self.slot(id, |slot| slot.source(&self.root, &self.packages))
    }

    fn file(&self, id: FileId) -> FileResult<Bytes> {
        if let Some(content) = self.virtual_files.get(id.vpath().get_without_slash()) {
            return Ok(Bytes::new(content.clone()));
        }

        self.slot(id, |slot| slot.file(&self.root, &self.packages))
    }

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

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

        let with_offset = match offset {
            None => now.with_timezone(&Local).fixed_offset(),
            Some(duration) => {
                let seconds = i32::try_from(duration.seconds() as i64).ok()?;
                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 SystemWorld {
    fn slot<F, T>(&self, id: FileId, f: F) -> T
    where
        F: FnOnce(&mut FileSlot) -> T,
    {
        let mut map = self.slots.lock();
        f(map.entry(id).or_insert_with(|| FileSlot::new(id)))
    }
}

struct FileSlot {
    id: FileId,
    source: SlotCell<Source>,
    file: SlotCell<Bytes>,
}

impl FileSlot {
    fn new(id: FileId) -> Self {
        Self {
            id,
            file: SlotCell::new(),
            source: SlotCell::new(),
        }
    }

    fn reset(&mut self) {
        self.source.reset();
        self.file.reset();
    }

    fn source(&mut self, project_root: &Path, packages: &SystemPackages) -> FileResult<Source> {
        self.source.get_or_init(
            || read(self.id, project_root, packages),
            |data, prev| {
                let name = if prev.is_some() {
                    "reparsing file"
                } else {
                    "parsing file"
                };
                let _scope = TimingScope::new(name);
                let text = decode_utf8(&data)?;
                if let Some(mut prev) = prev {
                    prev.replace(text);
                    Ok(prev)
                } else {
                    Ok(Source::new(self.id, text.into()))
                }
            },
        )
    }

    fn file(&mut self, project_root: &Path, packages: &SystemPackages) -> FileResult<Bytes> {
        self.file.get_or_init(
            || read(self.id, project_root, packages),
            |data, _| Ok(Bytes::new(data)),
        )
    }
}

struct SlotCell<T> {
    data: Option<FileResult<T>>,
    fingerprint: u128,
    accessed: bool,
}

impl<T: Clone> SlotCell<T> {
    fn new() -> Self {
        Self {
            data: None,
            fingerprint: 0,
            accessed: false,
        }
    }

    fn reset(&mut self) {
        self.accessed = false;
    }

    fn get_or_init(
        &mut self,
        load: impl FnOnce() -> FileResult<Vec<u8>>,
        f: impl FnOnce(Vec<u8>, Option<T>) -> FileResult<T>,
    ) -> FileResult<T> {
        if mem::replace(&mut self.accessed, true) {
            if let Some(data) = &self.data {
                return data.clone();
            }
        }

        let result = timed!("loading file", load());
        let fingerprint = timed!("hashing file", typst::utils::hash128(&result));

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

fn system_path(project_root: &Path, id: FileId, packages: &SystemPackages) -> FileResult<PathBuf> {
    let root = match id.root() {
        VirtualRoot::Package(spec) => packages.obtain(spec)?,
        VirtualRoot::Project => FsRoot::new(project_root.to_path_buf()),
    };

    root.resolve(id.vpath())
}

fn read(id: FileId, project_root: &Path, packages: &SystemPackages) -> FileResult<Vec<u8>> {
    read_from_disk(&system_path(project_root, id, packages)?)
}

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

fn decode_utf8(buf: &[u8]) -> FileResult<&str> {
    Ok(std::str::from_utf8(
        buf.strip_prefix(b"\xef\xbb\xbf").unwrap_or(buf),
    )?)
}

enum Now {
    #[allow(dead_code)]
    Fixed(DateTime<Utc>),
    System(OnceLock<DateTime<Utc>>),
}

pub struct TypstContext {
    world: Mutex<SystemWorld>,
    document: Mutex<Option<PagedDocument>>,
}

impl UnwindSafe for TypstContext {}
impl RefUnwindSafe for TypstContext {}

#[rustler::resource_impl]
impl rustler::Resource for TypstContext {}

fn span_to_nif(span: impl Into<DiagSpan>, world: &SystemWorld) -> Option<SpanNif> {
    let span = span.into();
    let range = world.range(span)?;

    let (line, column) = span
        .id()
        .and_then(|id| world.source(id).ok())
        .and_then(|source| source.lines().byte_to_line_column(range.start))
        .map(|(line, column)| (Some(line + 1), Some(column + 1)))
        .unwrap_or((None, None));

    Some(SpanNif {
        start: range.start,
        end: range.end,
        line,
        column,
    })
}

fn diagnostic_to_nif(d: &SourceDiagnostic, world: &SystemWorld) -> DiagnosticNif {
    DiagnosticNif {
        severity: d.severity.into(),
        message: d.message.to_string(),
        span: span_to_nif(d.span, world),
        trace: d
            .trace
            .iter()
            .map(|item| TraceItemNif {
                span: span_to_nif(item.span, world),
                message: item.v.to_string(),
            })
            .collect(),
        hints: d.hints.iter().map(|h| h.v.to_string()).collect(),
    }
}

fn diagnostics_to_vec(
    diagnostics: EcoVec<SourceDiagnostic>,
    world: &SystemWorld,
) -> Vec<DiagnosticNif> {
    diagnostics
        .iter()
        .map(|d| diagnostic_to_nif(d, world))
        .collect()
}

fn simple_error(message: &str) -> CompileErrorNif {
    CompileErrorNif {
        diagnostics: vec![DiagnosticNif {
            severity: SeverityNif::Error,
            message: message.to_string(),
            span: None,
            trace: vec![],
            hints: vec![],
        }],
    }
}

/// Parse "1-3,5,7-9" into PageRanges (1-indexed inclusive ranges using NonZeroUsize).
fn parse_page_ranges(pages: &str, total: usize) -> Result<PageRanges, String> {
    use std::ops::RangeInclusive;

    let mut ranges: Vec<RangeInclusive<Option<NonZeroUsize>>> = Vec::new();
    for part in pages.split(',') {
        let part = part.trim();
        if part.contains('-') {
            let mut iter = part.splitn(2, '-');
            let start: usize = iter
                .next()
                .unwrap()
                .trim()
                .parse()
                .map_err(|_| format!("Invalid page number in range: {}", part))?;
            let end: usize = iter
                .next()
                .unwrap()
                .trim()
                .parse()
                .map_err(|_| format!("Invalid page number in range: {}", part))?;
            if start < 1 || end < 1 || start > total || end > total || start > end {
                return Err(format!("Page range out of bounds: {}", part));
            }
            ranges.push(NonZeroUsize::new(start)..=NonZeroUsize::new(end));
        } else {
            let page: usize = part
                .parse()
                .map_err(|_| format!("Invalid page number: {}", part))?;
            if page < 1 || page > total {
                return Err(format!("Page number out of bounds: {}", page));
            }
            let nz = NonZeroUsize::new(page);
            ranges.push(nz..=nz);
        }
    }
    Ok(PageRanges::new(ranges))
}

#[rustler::nif(schedule = "DirtyIo")]
fn context_new(opts: ContextOptionsNif) -> ResourceArc<TypstContext> {
    let root = PathBuf::from(&opts.root);
    let font_paths: Vec<PathBuf> = opts.font_paths.iter().map(PathBuf::from).collect();
    let world = SystemWorld::new(root, font_paths, opts.ignore_system_fonts);
    ResourceArc::new(TypstContext {
        world: Mutex::new(world),
        document: Mutex::new(None),
    })
}

#[rustler::nif]
fn context_set_markup(ctx: ResourceArc<TypstContext>, markup: String) -> Atom {
    let mut world = ctx.world.lock();
    world.markup = markup;
    world.reset();
    *ctx.document.lock() = None;
    ok()
}

#[rustler::nif(schedule = "DirtyCpu")]
fn context_compile(ctx: ResourceArc<TypstContext>) -> Result<CompileResultNif, CompileErrorNif> {
    std::thread::Builder::new()
        .name("typst-compile".into())
        .stack_size(COMPILE_STACK_SIZE)
        .spawn(move || {
            let mut world_guard = ctx.world.lock();
            world_guard.reset();
            let result = typst::compile::<PagedDocument>(&*world_guard);
            match result.output {
                Ok(document) => {
                    let page_count = document.pages().len();
                    let warnings = diagnostics_to_vec(result.warnings, &world_guard);
                    *ctx.document.lock() = Some(document);
                    Ok(CompileResultNif {
                        page_count,
                        warnings,
                    })
                }
                Err(errors) => {
                    let diagnostics = diagnostics_to_vec(errors, &world_guard);
                    *ctx.document.lock() = None;
                    Err(CompileErrorNif { diagnostics })
                }
            }
        })
        .map_err(|e| simple_error(&format!("Failed to spawn compile thread: {e}")))?
        .join()
        .unwrap_or_else(|_| Err(simple_error("Compilation panicked")))
}

#[rustler::nif(schedule = "DirtyCpu")]
fn context_render_svg(
    ctx: ResourceArc<TypstContext>,
    page: usize,
    pretty: bool,
    render_bleed: bool,
) -> Result<String, CompileErrorNif> {
    let doc_guard = ctx.document.lock();
    let document = doc_guard
        .as_ref()
        .ok_or_else(|| simple_error("No compiled document. Call compile() first."))?;

    if page >= document.pages().len() {
        return Err(simple_error(&format!(
            "Page index {} out of bounds (document has {} pages)",
            page,
            document.pages().len()
        )));
    }

    let opts = SvgOptions {
        pretty,
        render_bleed,
    };
    Ok(typst_svg::svg(&document.pages()[page], &opts))
}

#[rustler::nif(schedule = "DirtyCpu")]
fn context_export_pdf<'a>(
    env: Env<'a>,
    ctx: ResourceArc<TypstContext>,
    opts: PdfOptionsNif,
) -> Result<Binary<'a>, CompileErrorNif> {
    let doc_guard = ctx.document.lock();
    let document = doc_guard
        .as_ref()
        .ok_or_else(|| simple_error("No compiled document. Call compile() first."))?;

    let mut pdf_opts = opts.to_pdf_options().map_err(|e| simple_error(&e))?;

    if let Some(ref pages_str) = opts.pages {
        pdf_opts.page_ranges = Some(
            parse_page_ranges(pages_str, document.pages().len()).map_err(|e| simple_error(&e))?,
        );
    }

    let result = typst_pdf::pdf(document, &pdf_opts);
    drop(doc_guard);

    let pdf_bytes = result.map_err(|e| {
        let world = ctx.world.lock();
        CompileErrorNif {
            diagnostics: diagnostics_to_vec(e, &world),
        }
    })?;

    let mut binary = NewBinary::new(env, pdf_bytes.len());
    binary.as_mut_slice().copy_from_slice(&pdf_bytes);
    Ok(binary.into())
}

#[rustler::nif]
fn context_font_families(ctx: ResourceArc<TypstContext>) -> Vec<String> {
    let world = ctx.world.lock();
    world
        .fonts
        .book()
        .families()
        .map(|(name, _)| name.to_string())
        .collect()
}

#[rustler::nif]
fn context_set_virtual_file(ctx: ResourceArc<TypstContext>, path: String, content: String) -> Atom {
    let mut world = ctx.world.lock();
    world.virtual_files.insert(path, content.into_bytes());
    *ctx.document.lock() = None;
    ok()
}

#[rustler::nif]
fn context_set_virtual_file_binary<'a>(
    ctx: ResourceArc<TypstContext>,
    path: String,
    content: Binary<'a>,
) -> Atom {
    let mut world = ctx.world.lock();
    world
        .virtual_files
        .insert(path, content.as_slice().to_vec());
    *ctx.document.lock() = None;
    ok()
}

#[rustler::nif]
fn context_append_virtual_file(
    ctx: ResourceArc<TypstContext>,
    path: String,
    chunk: String,
) -> Atom {
    let mut world = ctx.world.lock();
    world
        .virtual_files
        .entry(path)
        .or_default()
        .extend_from_slice(chunk.as_bytes());
    ok()
}

#[rustler::nif]
fn context_clear_virtual_file(ctx: ResourceArc<TypstContext>, path: String) -> Atom {
    let mut world = ctx.world.lock();
    world.virtual_files.remove(&path);
    *ctx.document.lock() = None;
    ok()
}

#[rustler::nif]
fn context_set_input(ctx: ResourceArc<TypstContext>, key: String, value: String) -> Atom {
    let mut world = ctx.world.lock();
    world.inputs.insert(key, value);
    world.rebuild_library();
    ok()
}

#[rustler::nif]
fn context_set_inputs(ctx: ResourceArc<TypstContext>, inputs: HashMap<String, String>) -> Atom {
    let mut world = ctx.world.lock();
    world.inputs = inputs;
    world.rebuild_library();
    ok()
}

#[rustler::nif(schedule = "DirtyCpu")]
fn context_export_html(
    ctx: ResourceArc<TypstContext>,
    pretty: bool,
) -> Result<String, CompileErrorNif> {
    std::thread::Builder::new()
        .name("typst-html".into())
        .stack_size(COMPILE_STACK_SIZE)
        .spawn(move || {
            let mut world_guard = ctx.world.lock();
            world_guard.reset();
            let result = typst::compile::<HtmlDocument>(&*world_guard);
            let opts = HtmlOptions { pretty };
            match result.output {
                Ok(html_doc) => match typst_html::html(&html_doc, &opts) {
                    Ok(html_string) => Ok(html_string),
                    Err(errors) => Err(CompileErrorNif {
                        diagnostics: diagnostics_to_vec(errors, &world_guard),
                    }),
                },
                Err(errors) => Err(CompileErrorNif {
                    diagnostics: diagnostics_to_vec(errors, &world_guard),
                }),
            }
        })
        .map_err(|e| simple_error(&format!("Failed to spawn HTML compile thread: {e}")))?
        .join()
        .unwrap_or_else(|_| Err(simple_error("HTML compilation panicked")))
}

#[rustler::nif(schedule = "DirtyCpu")]
fn context_export_bundle<'a>(
    env: Env<'a>,
    ctx: ResourceArc<TypstContext>,
    opts: BundleOptionsNif,
) -> Result<BundleResultNif<'a>, CompileErrorNif> {
    let (files, warnings) = std::thread::Builder::new()
        .name("typst-bundle".into())
        .stack_size(COMPILE_STACK_SIZE)
        .spawn(move || {
            let mut world_guard = ctx.world.lock();
            world_guard.reset();
            let result = typst::compile::<Bundle>(&*world_guard);
            let bundle = match result.output {
                Ok(bundle) => bundle,
                Err(errors) => {
                    return Err(CompileErrorNif {
                        diagnostics: diagnostics_to_vec(errors, &world_guard),
                    })
                }
            };

            let bundle_opts = BundleOptions {
                html: HtmlOptions {
                    pretty: opts.pretty,
                },
                svg: SvgOptions {
                    pretty: opts.pretty,
                    render_bleed: opts.render_bleed,
                },
                ..Default::default()
            };

            match export_bundle(&bundle, &bundle_opts) {
                Ok(vfs) => {
                    let files = vfs
                        .into_iter()
                        .map(|(path, bytes)| (path.get_without_slash().to_string(), bytes.to_vec()))
                        .collect::<Vec<(String, Vec<u8>)>>();
                    let warnings = diagnostics_to_vec(result.warnings, &world_guard);
                    Ok((files, warnings))
                }
                Err(errors) => Err(CompileErrorNif {
                    diagnostics: diagnostics_to_vec(errors, &world_guard),
                }),
            }
        })
        .map_err(|e| simple_error(&format!("Failed to spawn bundle compile thread: {e}")))?
        .join()
        .unwrap_or_else(|_| Err(simple_error("Bundle compilation panicked")))?;

    let mut map = HashMap::with_capacity(files.len());
    for (path, bytes) in files {
        let mut binary = NewBinary::new(env, bytes.len());
        binary.as_mut_slice().copy_from_slice(&bytes);
        map.insert(path, binary.into());
    }
    Ok(BundleResultNif {
        files: map,
        warnings,
    })
}

#[rustler::nif(schedule = "DirtyIo")]
fn font_families(opts: FontOptionsNif) -> Vec<String> {
    let include_system_fonts = !opts.ignore_system_fonts;

    let font_paths_vec: Vec<PathBuf> = opts
        .font_paths
        .iter()
        .map(PathBuf::from)
        .filter(|p| p.exists() && p.is_dir())
        .collect();

    let mut fonts = FontStore::new();
    for path in &font_paths_vec {
        fonts.extend(scan(path));
    }
    if include_system_fonts {
        fonts.extend(system());
    }
    fonts.extend(embedded());

    fonts
        .book()
        .families()
        .map(|(name, _info)| name.to_string())
        .collect()
}

rustler::init!("Elixir.AshTypst.NIF");