typst_batch/
font.rs

1//! Global shared font management.
2//!
3//! Fonts are expensive to load (~100ms+), so we load them once at startup
4//! and share across all compilations via `OnceLock`.
5//!
6//! # Design Rationale
7//!
8//! Font loading involves:
9//! 1. Scanning system font directories (platform-specific)
10//! 2. Parsing font metadata (family, weight, style, etc.)
11//! 3. Building a searchable font book index
12//!
13//! This is done once and shared via `OnceLock` to ensure:
14//! - Single initialization (first caller wins)
15//! - Zero-cost subsequent access (just a pointer dereference)
16//! - Thread-safe sharing across compilations
17//!
18//! # Font Sources
19//!
20//! Fonts are searched in order:
21//! 1. Custom paths provided at initialization (e.g., project fonts)
22//! 2. System fonts (if enabled)
23
24use std::path::{Path, PathBuf};
25use std::sync::OnceLock;
26
27use typst::text::FontBook;
28use typst::utils::LazyHash;
29use typst_kit::fonts::Fonts;
30
31/// Global shared fonts - initialized once with custom font paths.
32///
33/// Uses `OnceLock` for thread-safe, one-time initialization.
34/// The first call to `get_fonts` determines the font paths for all
35/// subsequent compilations.
36static GLOBAL_FONTS: OnceLock<(Fonts, LazyHash<FontBook>)> = OnceLock::new();
37
38// =============================================================================
39// Font Configuration
40// =============================================================================
41
42/// Options for font initialization.
43///
44/// Use this to customize font loading behavior when calling [`init_fonts_with_options`].
45///
46/// # Example
47///
48/// ```ignore
49/// use typst_batch::{FontOptions, init_fonts_with_options};
50/// use std::path::Path;
51///
52/// let options = FontOptions::new()
53///     .with_system_fonts(true)
54///     .with_custom_paths(&[
55///         Path::new("assets/fonts"),
56///         Path::new("content/fonts"),
57///     ]);
58///
59/// init_fonts_with_options(&options);
60/// ```
61#[derive(Debug, Clone, Default)]
62pub struct FontOptions {
63    /// Whether to include system fonts.
64    pub include_system_fonts: bool,
65    /// Custom font directories to search.
66    pub custom_paths: Vec<PathBuf>,
67}
68
69impl FontOptions {
70    /// Create new font options with default settings.
71    ///
72    /// Default:
73    /// - System fonts: enabled
74    /// - Custom paths: empty
75    pub fn new() -> Self {
76        Self {
77            include_system_fonts: true,
78            custom_paths: Vec::new(),
79        }
80    }
81
82    /// Set whether to include system fonts.
83    ///
84    /// Disabling system fonts can speed up initialization in controlled
85    /// environments where only specific fonts are needed.
86    pub fn with_system_fonts(mut self, include: bool) -> Self {
87        self.include_system_fonts = include;
88        self
89    }
90
91    /// Set custom font paths to search.
92    ///
93    /// These directories are searched for `.ttf`, `.otf`, and other font files.
94    pub fn with_custom_paths(mut self, paths: &[&Path]) -> Self {
95        self.custom_paths = paths.iter().map(|p| p.to_path_buf()).collect();
96        self
97    }
98
99    /// Add a single custom font path.
100    pub fn add_path(mut self, path: impl AsRef<Path>) -> Self {
101        self.custom_paths.push(path.as_ref().to_path_buf());
102        self
103    }
104}
105
106/// Sorting key for deterministic font ordering.
107///
108/// `fontdb` uses `std::fs::read_dir()` which does not guarantee order,
109/// causing non-deterministic font indices across process runs.
110/// This key ensures fonts are always ordered the same way.
111#[allow(dead_code)]
112#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
113struct FontSortKey {
114    path: Option<PathBuf>,
115    index: u32,
116}
117
118// =============================================================================
119// Debug Utilities
120// =============================================================================
121
122/// **DEBUG ONLY**: Write font list to `/tmp/tola_fonts_debug.txt` for debugging.
123///
124/// This function is used to diagnose font loading issues, particularly:
125/// - Non-deterministic font ordering across runs
126/// - Duplicate fonts from different directories (e.g., `assets/` vs `public/`)
127/// - Missing or unexpected fonts
128///
129/// # Output Format
130///
131/// ```text
132/// === Font Debug Output (PID: 12345) ===
133/// Total fonts: 977
134///
135///    0: Maple Mono | /path/to/font.otf | idx=0 | Normal-700-FontStretch(1000)
136///    1: SF Pro | /System/Library/Fonts/SF-Pro.otf | idx=0 | Normal-400-FontStretch(1000)
137/// ...
138/// === End of Debug Output ===
139/// ```
140#[allow(dead_code)]
141fn debug_dump_fonts(fonts: &Fonts) {
142    use std::io::Write;
143    let debug_path = std::path::Path::new("/tmp/tola_fonts_debug.txt");
144    if let Ok(mut file) = std::fs::File::create(debug_path) {
145        let _ = writeln!(
146            file,
147            "=== Font Debug Output (PID: {}) ===",
148            std::process::id()
149        );
150        let _ = writeln!(file, "Total fonts: {}", fonts.fonts.len());
151        let _ = writeln!(file);
152        for (i, slot) in fonts.fonts.iter().enumerate() {
153            let path = slot
154                .path()
155                .map(|p| p.to_string_lossy().to_string())
156                .unwrap_or_else(|| "embedded".to_string());
157            let info = fonts.book.info(i);
158            let family = info.map(|i| i.family.as_str()).unwrap_or("?");
159            let variant = info
160                .map(|i| format!("{:?}", i.variant))
161                .unwrap_or_else(|| "?".to_string());
162            let _ = writeln!(
163                file,
164                "{:4}: {} | {} | idx={} | {}",
165                i,
166                family,
167                path,
168                slot.index(),
169                variant
170            );
171        }
172        let _ = writeln!(file);
173        let _ = writeln!(file, "=== End of Debug Output ===");
174        eprintln!(
175            "[FONT DEBUG] Wrote {} fonts to {:?}",
176            fonts.fonts.len(),
177            debug_path
178        );
179    }
180}
181
182// =============================================================================
183// Font Initialization
184// =============================================================================
185
186/// Initialize fonts with custom font paths (legacy API).
187///
188/// # Arguments
189///
190/// * `font_paths` - Directories to search for fonts (e.g., `[assets/, content/]`).
191///   **Important**: Should NOT include output directory (e.g., `public/`) to avoid
192///   loading duplicate fonts that cause non-deterministic behavior.
193///
194/// # Returns
195///
196/// A tuple of:
197/// - `Fonts`: The font collection with lazy-loaded font data
198/// - `LazyHash<FontBook>`: The font book index wrapped for comemo caching
199fn init_fonts(font_paths: &[&Path]) -> (Fonts, LazyHash<FontBook>) {
200    let options = FontOptions {
201        include_system_fonts: true,
202        custom_paths: font_paths.iter().map(|p| p.to_path_buf()).collect(),
203    };
204    init_fonts_impl(&options)
205}
206
207/// Initialize fonts with detailed options.
208///
209/// This is the implementation used by both [`init_fonts`] and [`init_fonts_with_options`].
210fn init_fonts_impl(options: &FontOptions) -> (Fonts, LazyHash<FontBook>) {
211    let mut searcher = Fonts::searcher();
212    // Include system fonts if enabled
213    searcher.include_system_fonts(options.include_system_fonts);
214
215    // Convert PathBuf to &Path for the API
216    let paths: Vec<&Path> = options.custom_paths.iter().map(|p| p.as_path()).collect();
217
218    // Search custom paths and optionally system fonts
219    let fonts = searcher.search_with(&paths);
220
221    // DEBUG: Uncomment to dump font list for debugging
222    // debug_dump_fonts(&fonts);
223
224    // NOTE: Font sorting is currently disabled.
225    // See `sort_fonts_deterministically` for details on when it's needed.
226    // let fonts = sort_fonts_deterministically(fonts);
227
228    // Wrap font book in LazyHash for comemo caching
229    let book = LazyHash::new(fonts.book.clone());
230    (fonts, book)
231}
232
233/// Initialize fonts with detailed options.
234///
235/// Use this for more control over font loading. Unlike [`get_fonts`], this
236/// allows you to:
237/// - Disable system font loading
238/// - Specify exact font paths
239///
240/// # Arguments
241///
242/// * `options` - Font initialization options
243///
244/// # Returns
245///
246/// A static reference to the shared font collection and book.
247///
248/// # Note
249///
250/// Like [`get_fonts`], this function only initializes fonts once. Subsequent
251/// calls return the already-initialized fonts, ignoring the options parameter.
252pub fn init_fonts_with_options(options: &FontOptions) -> &'static (Fonts, LazyHash<FontBook>) {
253    GLOBAL_FONTS.get_or_init(|| init_fonts_impl(options))
254}
255
256/// Check if fonts have been initialized.
257///
258/// Returns `true` if fonts have already been loaded via [`get_fonts`] or
259/// [`init_fonts_with_options`].
260pub fn fonts_initialized() -> bool {
261    GLOBAL_FONTS.get().is_some()
262}
263
264/// Get the number of loaded fonts.
265///
266/// Returns `None` if fonts have not been initialized yet.
267pub fn font_count() -> Option<usize> {
268    GLOBAL_FONTS.get().map(|(fonts, _)| fonts.fonts.len())
269}
270
271/// Get the number of font families.
272///
273/// Returns `None` if fonts have not been initialized yet.
274pub fn font_family_count() -> Option<usize> {
275    GLOBAL_FONTS.get().map(|(_, book)| book.families().count())
276}
277
278// =============================================================================
279// Font Sorting (Currently Disabled)
280// =============================================================================
281
282/// Sort fonts by (path, index) to ensure deterministic ordering.
283///
284/// # Background: The Non-Determinism Problem
285///
286/// `fontdb` uses `std::fs::read_dir()` to scan font directories, which does NOT
287/// guarantee consistent ordering across runs. This causes font indices to vary:
288///
289/// ```text
290/// Run 1: [SF Pro (idx=0), Helvetica (idx=1), Arial (idx=2)]
291/// Run 2: [Arial (idx=0), SF Pro (idx=1), Helvetica (idx=2)]
292/// ```
293///
294/// Typst uses these indices in SVG output (e.g., `font-family: f0, f1`), so
295/// different indices → different SVG content → non-reproducible builds.
296///
297/// # Why This Is Currently Disabled
298///
299/// The root cause was fixed differently: instead of sorting fonts after loading,
300/// we now only scan `assets/` and `content/` directories for fonts, excluding
301/// the output directory (`public/`). This prevents:
302///
303/// 1. **Duplicate fonts**: `public/fonts/` contains copies of `assets/fonts/`,
304///    causing the same font to be loaded twice with different paths.
305///
306/// 2. **Font count variation**: First build has N fonts, subsequent builds
307///    have N+M fonts (where M = fonts copied to public/), changing all indices.
308#[allow(dead_code)]
309fn sort_fonts_deterministically(fonts: Fonts) -> Fonts {
310    let n = fonts.fonts.len();
311    if n == 0 {
312        return fonts;
313    }
314
315    // Create (original_index, sort_key) pairs
316    let mut indices: Vec<(usize, FontSortKey)> = fonts
317        .fonts
318        .iter()
319        .enumerate()
320        .map(|(i, slot)| {
321            (
322                i,
323                FontSortKey {
324                    path: slot.path().map(|p| p.to_path_buf()),
325                    index: slot.index(),
326                },
327            )
328        })
329        .collect();
330
331    // Sort by (path, index)
332    indices.sort_by(|a, b| a.1.cmp(&b.1));
333
334    // Collect FontInfo in sorted order
335    let sorted_infos: Vec<_> = indices
336        .iter()
337        .filter_map(|(old_idx, _)| fonts.book.info(*old_idx).cloned())
338        .collect();
339
340    // Rebuild FontBook from sorted infos
341    let new_book = FontBook::from_infos(sorted_infos);
342
343    // Reorder fonts Vec to match
344    // We need to move FontSlots, but they're not Clone.
345    // Use a permutation approach with Option<FontSlot>
346    let mut old_fonts: Vec<Option<_>> = fonts.fonts.into_iter().map(Some).collect();
347    let mut new_fonts = Vec::with_capacity(n);
348    for (old_idx, _) in indices {
349        if let Some(slot) = old_fonts[old_idx].take() {
350            new_fonts.push(slot);
351        }
352    }
353
354    Fonts {
355        book: new_book,
356        fonts: new_fonts,
357    }
358}
359
360/// Get or initialize global fonts.
361///
362/// The first call determines the font paths used for all subsequent compilations.
363/// This is intentional: fonts rarely change during a program's lifetime, and
364/// sharing them saves ~100ms per compilation.
365///
366/// # Arguments
367///
368/// * `font_dirs` - Directories to search for fonts (e.g., `[assets/, content/]`).
369///   Pass on the first call to include fonts from these directories.
370///   Should NOT include output directory (e.g., `public/`) to avoid duplicates.
371///
372/// # Returns
373///
374/// A static reference to the shared font collection and book.
375///
376/// # Thread Safety
377///
378/// This function is thread-safe. If called concurrently, only one thread
379/// performs initialization; others wait and receive the shared result.
380pub fn get_fonts(font_dirs: &[&Path]) -> &'static (Fonts, LazyHash<FontBook>) {
381    GLOBAL_FONTS.get_or_init(|| init_fonts(font_dirs))
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn test_global_fonts_initialized() {
390        let fonts = get_fonts(&[]);
391        // Should find at least some system fonts on most systems
392        // Note: This test may fail in minimal container environments
393        assert!(!fonts.0.fonts.is_empty(), "Should find system fonts");
394    }
395
396    #[test]
397    fn test_font_book_not_empty() {
398        let fonts = get_fonts(&[]);
399        // FontBook should have indexed the fonts
400        assert!(
401            fonts.1.families().count() > 0,
402            "Font book should have families"
403        );
404    }
405
406    #[test]
407    fn test_fonts_are_shared() {
408        let fonts1 = get_fonts(&[]);
409        let fonts2 = get_fonts(&[]);
410        // Should return the same static reference
411        assert!(std::ptr::eq(fonts1, fonts2), "Fonts should be shared");
412    }
413
414    #[test]
415    fn test_subsequent_calls_ignore_path() {
416        // First call initializes (may have been done by other tests)
417        let fonts1 = get_fonts(&[]);
418        // Second call with different path should return same fonts
419        let fonts2 = get_fonts(&[Path::new("/nonexistent")]);
420        assert!(
421            std::ptr::eq(fonts1, fonts2),
422            "Path ignored after initialization"
423        );
424    }
425}