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}