mlux 1.7.0

A rich Markdown viewer for modern terminals
Documentation
use std::time::Instant;

use log::info;
use typst::diag::FileResult;
use typst::foundations::{Bytes, Datetime};
use typst::syntax::{FileId, Source, VirtualPath};
use typst::text::{Font, FontBook};
use typst::utils::LazyHash;
use typst::{Library, LibraryExt, World};
use typst_kit::fonts::{FontSearcher, FontSlot, Fonts};

// Generated by build.rs: scans fonts/ directory and produces include_bytes! calls
include!(concat!(env!("OUT_DIR"), "/embedded_fonts.rs"));

/// Cached font search results, shared across multiple `MluxWorld` instances.
///
/// `FontSearcher::search()` scans the filesystem (~13ms even in release builds).
/// By caching the results, we avoid repeated scans when creating multiple worlds
/// (e.g., content + sidebar in the viewer, or across resize rebuilds).
pub struct FontCache {
    book: FontBook,
    fonts: Vec<FontSlot>,
    /// Fonts embedded via build.rs (from fonts/ directory).
    custom_fonts: Vec<Font>,
}

impl Default for FontCache {
    fn default() -> Self {
        Self::new()
    }
}

impl FontCache {
    /// Perform a one-time font search and cache the results.
    pub fn new() -> Self {
        let start = Instant::now();
        let Fonts { mut book, fonts } = FontSearcher::new().include_system_fonts(true).search();
        info!(
            "world: font search completed in {:.1}ms",
            start.elapsed().as_secs_f64() * 1000.0
        );

        let custom_fonts = Self::load_embedded_fonts(&mut book);

        // CJK font availability check (once per process)
        let has_cjk = book.contains_family("ipagothic")
            || book.contains_family("noto sans jp")
            || book.contains_family("noto sans cjk jp")
            || book.contains_family("noto serif cjk jp")
            || book.contains_family("ipamincho");
        if !has_cjk {
            eprintln!("warning: no CJK font found. Japanese text may not render correctly.");
            eprintln!("  Install IPAGothic or Noto Sans CJK JP for proper rendering.");
        }

        Self {
            book,
            fonts,
            custom_fonts,
        }
    }

    fn load_embedded_fonts(book: &mut FontBook) -> Vec<Font> {
        let start = Instant::now();
        let mut custom = Vec::new();
        let mut total_compressed = 0usize;
        let mut total_decompressed = 0usize;
        for data in embedded_font_data() {
            total_compressed += data.len();
            let decompressed =
                zstd::decode_all(&data[..]).expect("failed to decompress embedded font");
            total_decompressed += decompressed.len();
            let buffer = Bytes::new(decompressed);
            for font in Font::iter(buffer) {
                book.push(font.info().clone());
                custom.push(font);
            }
        }
        info!(
            "world: decompressed {} embedded fonts ({:.1} MB -> {:.1} MB) in {:.1}ms",
            custom.len(),
            total_compressed as f64 / (1024.0 * 1024.0),
            total_decompressed as f64 / (1024.0 * 1024.0),
            start.elapsed().as_secs_f64() * 1000.0,
        );
        custom
    }

    /// Look up a font by global index (system fonts first, then custom).
    pub fn font(&self, index: usize) -> Option<Font> {
        if index < self.fonts.len() {
            self.fonts[index].get()
        } else {
            self.custom_fonts.get(index - self.fonts.len()).cloned()
        }
    }
}

/// The Typst world for mlux.
///
/// Provides a single virtual file (`/main.typ`) containing the theme
/// set-rules followed by the converted Markdown content.
///
/// Borrows font data from a [`FontCache`] to avoid repeated filesystem scans.
pub struct MluxWorld<'f> {
    library: LazyHash<Library>,
    book: LazyHash<FontBook>,
    font_cache: &'f FontCache,
    main_id: FileId,
    main_source: Source,
    /// Byte offset where content_text begins within main.typ.
    content_offset: usize,
    /// Additional virtual files served by this world (e.g. tmTheme files).
    data_files: crate::theme::DataFiles,
    /// Pre-loaded image files (path → bytes), served via `World::file()`.
    image_files: crate::image::LoadedImages,
}

impl<'f> MluxWorld<'f> {
    /// Create a new MluxWorld.
    ///
    /// - `theme_text`: contents of the theme.typ file
    /// - `data_files`: additional virtual files for the theme (e.g. tmTheme)
    /// - `content_text`: Typst markup converted from Markdown
    /// - `width`: page width in pt
    /// - `fonts`: cached font search results
    pub fn new(
        theme_text: &str,
        data_files: crate::theme::DataFiles,
        content_text: &str,
        width: f64,
        fonts: &'f FontCache,
        image_files: crate::image::LoadedImages,
    ) -> Self {
        let start = Instant::now();
        // Inline theme + mitex compat shims + width override + content into a single source
        let mitex_compat = include_str!("../../themes/mitex-compat.typ");
        let prefix = format!("{theme_text}\n{mitex_compat}\n#set page(width: {width}pt)\n");
        let content_offset = prefix.len();
        let main_text = format!("{prefix}{content_text}\n");

        let mut world = Self::from_source(&main_text, fonts);
        world.data_files = data_files;
        world.content_offset = content_offset;
        world.image_files = image_files;
        info!(
            "world: new() completed in {:.1}ms",
            start.elapsed().as_secs_f64() * 1000.0
        );
        world
    }

    /// Create a MluxWorld from raw Typst source (no theme injection or width override).
    pub fn new_raw(source: &str, fonts: &'f FontCache) -> Self {
        Self::from_source(source, fonts)
    }

    /// Get a reference to the main Source (for Span resolution).
    pub fn main_source(&self) -> &Source {
        &self.main_source
    }

    /// Byte offset where content_text begins within main.typ.
    pub fn content_offset(&self) -> usize {
        self.content_offset
    }

    fn from_source(main_text: &str, fonts: &'f FontCache) -> Self {
        let vpath = VirtualPath::new("main.typ");
        let main_id = FileId::new(None, vpath);
        let main_source = Source::new(main_id, main_text.to_string());

        Self {
            library: LazyHash::new(Library::default()),
            book: LazyHash::new(fonts.book.clone()),
            font_cache: fonts,
            main_id,
            main_source,
            content_offset: 0,
            data_files: &[],
            image_files: crate::image::LoadedImages::default(),
        }
    }
}

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

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

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

    fn source(&self, id: FileId) -> FileResult<Source> {
        if id == self.main_id {
            Ok(self.main_source.clone())
        } else {
            Err(typst::diag::FileError::NotFound(
                id.vpath().as_rootless_path().into(),
            ))
        }
    }

    fn file(&self, id: FileId) -> FileResult<Bytes> {
        let path = id.vpath().as_rootless_path();
        for &(name, data) in self.data_files {
            if path == std::path::Path::new(name) {
                return Ok(Bytes::new(data.to_vec()));
            }
        }
        // Check pre-loaded image files
        let path_str = path.to_str().unwrap_or("");
        if let Some(bytes) = self.image_files.get(path_str) {
            return Ok(bytes.clone());
        }
        // VirtualPath normalizes "://" to ":/" (treats paths as filesystem).
        // Restore the double slash for URL scheme lookup.
        if let Some(bytes) = restore_url_scheme(path_str)
            .as_deref()
            .and_then(|restored| self.image_files.get(restored))
        {
            return Ok(bytes.clone());
        }
        if id == self.main_id {
            Ok(Bytes::from_string(self.main_source.clone()))
        } else {
            Err(typst::diag::FileError::NotFound(path.into()))
        }
    }

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

    fn today(&self, _offset: Option<i64>) -> Option<Datetime> {
        None
    }
}

/// Restore URL double-slash that VirtualPath normalizes away.
///
/// `VirtualPath::new("https://example.com/...")` normalizes to `"https:/example.com/..."`.
/// This function detects that pattern and restores `"://"`.
fn restore_url_scheme(path: &str) -> Option<String> {
    for scheme in &["https:/", "http:/"] {
        if let Some(rest) = path.strip_prefix(scheme)
            && !rest.starts_with('/')
        {
            return Some(format!("{scheme}/{rest}"));
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn restore_url_scheme_https() {
        assert_eq!(
            restore_url_scheme("https:/example.com/img.png"),
            Some("https://example.com/img.png".to_string()),
        );
    }

    #[test]
    fn restore_url_scheme_http() {
        assert_eq!(
            restore_url_scheme("http:/example.com/img.png"),
            Some("http://example.com/img.png".to_string()),
        );
    }

    #[test]
    fn restore_url_scheme_already_double_slash() {
        // Already has "://" → after VirtualPath it would be ":/", but if somehow
        // it's already correct, return None (no restoration needed).
        assert_eq!(restore_url_scheme("https://example.com/img.png"), None);
    }

    #[test]
    fn restore_url_scheme_local_path() {
        assert_eq!(restore_url_scheme("images/photo.png"), None);
    }
}