Skip to main content

rdocx_layout/
font.rs

1//! Font loading, resolution, shaping, and metrics.
2//!
3//! Uses fontdb for system font discovery, ttf-parser for metrics,
4//! and HarfRust for text shaping.
5
6use std::collections::HashMap;
7use std::sync::Arc;
8
9use crate::error::{LayoutError, Result};
10use crate::output::FontId;
11
12/// Key for caching resolved fonts.
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14struct FontKey {
15    family: String,
16    bold: bool,
17    italic: bool,
18}
19
20/// Metrics for a font at a given size.
21#[derive(Debug, Clone, Copy)]
22pub struct FontMetrics {
23    /// Ascent in points (positive, above baseline).
24    pub ascent: f64,
25    /// Descent in points (positive, below baseline).
26    pub descent: f64,
27    /// Line gap in points.
28    pub line_gap: f64,
29    /// Units per em.
30    pub units_per_em: u16,
31}
32
33/// Result of shaping a text string.
34#[derive(Debug, Clone)]
35pub struct ShapedText {
36    /// Glyph IDs from shaping.
37    pub glyph_ids: Vec<u16>,
38    /// Per-glyph advances in points.
39    pub advances: Vec<f64>,
40    /// Total width in points.
41    pub width: f64,
42}
43
44/// Internal record for a loaded font face.
45struct LoadedFont {
46    id: FontId,
47    family: String,
48    bold: bool,
49    italic: bool,
50    data: Arc<Vec<u8>>,
51    face_index: u32,
52    units_per_em: u16,
53    /// Vertical metrics in design units, read once when the face is loaded.
54    ascender: i16,
55    descender: i16,
56    line_gap: i16,
57    /// HarfRust's per-face shaping caches. Building these is the expensive
58    /// part of shaping, so it happens once per face instead of once per run.
59    shaper_data: harfrust::ShaperData,
60}
61
62/// Manages font discovery, loading, shaping, and metrics.
63pub struct FontManager {
64    db: fontdb::Database,
65    /// Map from FontKey to loaded font info.
66    cache: HashMap<FontKey, usize>,
67    /// All loaded fonts.
68    fonts: Vec<LoadedFont>,
69    /// Next font ID counter.
70    next_id: u32,
71}
72
73impl Default for FontManager {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl FontManager {
80    /// Create a new FontManager and load system fonts.
81    ///
82    /// When the `bundled-fonts` feature is enabled, bundled fonts (Carlito,
83    /// Caladea, Liberation) are loaded as fallbacks.
84    pub fn new() -> Self {
85        let mut db = fontdb::Database::new();
86
87        // Load bundled fonts first (lowest priority fallbacks)
88        for (_family, data) in crate::bundled_fonts::bundled_font_data() {
89            db.load_font_data(data.to_vec());
90        }
91
92        // Then load system fonts
93        db.load_system_fonts();
94
95        FontManager {
96            db,
97            cache: HashMap::new(),
98            fonts: Vec::new(),
99            next_id: 0,
100        }
101    }
102
103    /// Load additional font files (user-provided or extracted from DOCX).
104    ///
105    /// These fonts are loaded AFTER system fonts, so they take the highest
106    /// priority in font resolution (fontdb returns the last-loaded match).
107    pub fn load_additional_fonts(&mut self, font_files: &[crate::input::FontFile]) {
108        for font_file in font_files {
109            self.db.load_font_data(font_file.data.clone());
110        }
111        // Clear the cache since new fonts may affect resolution
112        self.cache.clear();
113    }
114
115    /// Create a FontManager with user-provided fonts (no system font loading).
116    ///
117    /// Each entry is `(family_name, font_bytes)`. This is useful in environments
118    /// where system fonts are not available, such as WASM.
119    pub fn new_with_fonts(fonts: Vec<(String, Vec<u8>)>) -> Self {
120        let mut db = fontdb::Database::new();
121        for (_name, data) in &fonts {
122            db.load_font_data(data.clone());
123        }
124        FontManager {
125            db,
126            cache: HashMap::new(),
127            fonts: Vec::new(),
128            next_id: 0,
129        }
130    }
131
132    /// Resolve a font by family name, bold, and italic flags.
133    /// Returns a FontId. Uses fallback chain if the requested font is not found.
134    pub fn resolve_font(
135        &mut self,
136        family: Option<&str>,
137        bold: bool,
138        italic: bool,
139    ) -> Result<FontId> {
140        let family_name = family.unwrap_or("Arial");
141
142        let key = FontKey {
143            family: family_name.to_string(),
144            bold,
145            italic,
146        };
147
148        if let Some(&idx) = self.cache.get(&key) {
149            return Ok(self.fonts[idx].id);
150        }
151
152        // Map common Word font names to metric-compatible alternatives
153        let mapped = map_font_name(family_name);
154
155        // Try the requested font, mapped alternatives, then generic fallbacks
156        let mut fallbacks: Vec<&str> = Vec::with_capacity(10);
157        fallbacks.push(family_name);
158        for alt in mapped {
159            if *alt != family_name {
160                fallbacks.push(alt);
161            }
162        }
163        for generic in &[
164            "Carlito",
165            "Arial",
166            "Liberation Sans",
167            "Helvetica",
168            "DejaVu Sans",
169            "Noto Sans",
170        ] {
171            if !fallbacks.contains(generic) {
172                fallbacks.push(generic);
173            }
174        }
175
176        let style = if italic {
177            fontdb::Style::Italic
178        } else {
179            fontdb::Style::Normal
180        };
181        let weight = if bold {
182            fontdb::Weight::BOLD
183        } else {
184            fontdb::Weight::NORMAL
185        };
186
187        let mut found_id = None;
188        for fallback in &fallbacks {
189            let query = fontdb::Query {
190                families: &[fontdb::Family::Name(fallback)],
191                weight,
192                style,
193                stretch: fontdb::Stretch::Normal,
194            };
195
196            if let Some(id) = self.db.query(&query) {
197                found_id = Some(id);
198                break;
199            }
200        }
201
202        // Last resort: try generic families
203        if found_id.is_none() {
204            for generic_family in &[
205                fontdb::Family::SansSerif,
206                fontdb::Family::Serif,
207                fontdb::Family::Monospace,
208            ] {
209                let query = fontdb::Query {
210                    families: &[*generic_family],
211                    weight,
212                    style,
213                    stretch: fontdb::Stretch::Normal,
214                };
215                if let Some(id) = self.db.query(&query) {
216                    found_id = Some(id);
217                    break;
218                }
219            }
220        }
221
222        let db_id = found_id.ok_or_else(|| {
223            LayoutError::FontNotFound(format!("No font found for family '{family_name}'"))
224        })?;
225
226        let font_id = FontId(self.next_id);
227        self.next_id += 1;
228
229        // Load the font data
230        let (data, face_index) = self
231            .db
232            .with_face_data(db_id, |data, idx| (Arc::new(data.to_vec()), idx))
233            .ok_or_else(|| LayoutError::FontParse("Failed to load font data".into()))?;
234
235        let (units_per_em, ascender, descender, line_gap) = {
236            let face = ttf_parser::Face::parse(&data, face_index)
237                .map_err(|e| LayoutError::FontParse(format!("ttf-parser error: {e}")))?;
238            (
239                face.units_per_em(),
240                face.ascender(),
241                face.descender(),
242                face.line_gap(),
243            )
244        };
245
246        // Every metric and advance is scaled by size/upem, so a zero here would
247        // turn the whole layout into infinities.
248        if units_per_em == 0 {
249            return Err(LayoutError::FontParse(format!(
250                "font '{family_name}' declares zero units per em"
251            )));
252        }
253
254        let shaper_data = {
255            let face = harfrust::FontRef::from_index(&data, face_index)
256                .map_err(|e| LayoutError::FontParse(format!("failed to read font face: {e}")))?;
257            harfrust::ShaperData::new(&face)
258        };
259
260        let actual_family = self
261            .db
262            .face(db_id)
263            .map(|f| {
264                f.families
265                    .first()
266                    .map(|(name, _)| name.clone())
267                    .unwrap_or_else(|| family_name.to_string())
268            })
269            .unwrap_or_else(|| family_name.to_string());
270
271        let idx = self.fonts.len();
272        self.fonts.push(LoadedFont {
273            id: font_id,
274            family: actual_family,
275            bold,
276            italic,
277            data,
278            face_index,
279            units_per_em,
280            ascender,
281            descender,
282            line_gap,
283            shaper_data,
284        });
285        self.cache.insert(key, idx);
286
287        Ok(font_id)
288    }
289
290    /// Get font metrics at a given size in points.
291    pub fn metrics(&self, font_id: FontId, size_pt: f64) -> Result<FontMetrics> {
292        let font = self.get_font(font_id)?;
293        let scale = size_pt / font.units_per_em as f64;
294
295        Ok(FontMetrics {
296            ascent: font.ascender as f64 * scale,
297            descent: -(font.descender as f64) * scale, // make positive
298            line_gap: font.line_gap as f64 * scale,
299            units_per_em: font.units_per_em,
300        })
301    }
302
303    /// Shape a text string using HarfRust. Returns glyph IDs and advances.
304    pub fn shape_text(&self, font_id: FontId, text: &str, size_pt: f64) -> Result<ShapedText> {
305        // HarfRust cannot derive segment properties from an empty buffer, and
306        // there is nothing to shape anyway.
307        if text.is_empty() {
308            return Ok(ShapedText {
309                glyph_ids: Vec::new(),
310                advances: Vec::new(),
311                width: 0.0,
312            });
313        }
314
315        let font = self.get_font(font_id)?;
316
317        let face = harfrust::FontRef::from_index(&font.data, font.face_index)
318            .map_err(|e| LayoutError::Shaping(format!("failed to read font face: {e}")))?;
319
320        let shaper = font.shaper_data.shaper(&face).build();
321
322        let mut buffer = harfrust::UnicodeBuffer::new();
323        buffer.push_str(text);
324        // Infer direction, script and language from the text. Unlike rustybuzz,
325        // HarfRust does not do this implicitly and panics on an unset direction.
326        buffer.guess_segment_properties();
327
328        let output = shaper.shape(buffer, harfrust::ShapeOptions::default());
329        let infos = output.glyph_infos();
330        let positions = output.glyph_positions();
331
332        let upem = font.units_per_em as f64;
333        let scale = size_pt / upem;
334
335        let mut glyph_ids = Vec::with_capacity(infos.len());
336        let mut advances = Vec::with_capacity(positions.len());
337        let mut total_width = 0.0;
338
339        for (info, pos) in infos.iter().zip(positions.iter()) {
340            glyph_ids.push(info.glyph_id as u16);
341            let advance = pos.x_advance as f64 * scale;
342            advances.push(advance);
343            total_width += advance;
344        }
345
346        Ok(ShapedText {
347            glyph_ids,
348            advances,
349            width: total_width,
350        })
351    }
352
353    /// Get font data for PDF embedding.
354    pub fn font_data(&self, font_id: FontId) -> Result<crate::output::FontData> {
355        let font = self.get_font(font_id)?;
356        Ok(crate::output::FontData {
357            id: font.id,
358            family: font.family.clone(),
359            data: (*font.data).clone(),
360            face_index: font.face_index,
361            bold: font.bold,
362            italic: font.italic,
363        })
364    }
365
366    /// Get all used font data.
367    pub fn all_font_data(&self) -> Vec<crate::output::FontData> {
368        self.fonts
369            .iter()
370            .map(|f| crate::output::FontData {
371                id: f.id,
372                family: f.family.clone(),
373                data: (*f.data).clone(),
374                face_index: f.face_index,
375                bold: f.bold,
376                italic: f.italic,
377            })
378            .collect()
379    }
380
381    fn get_font(&self, font_id: FontId) -> Result<&LoadedFont> {
382        self.fonts
383            .iter()
384            .find(|f| f.id == font_id)
385            .ok_or_else(|| LayoutError::FontNotFound(format!("FontId({}) not loaded", font_id.0)))
386    }
387}
388
389/// Map common Word font names to metric-compatible alternatives.
390/// Returns a list of candidate names to try (including the original).
391///
392/// Priority: original font → metric-compatible open-source clone → generic fallback.
393/// Carlito is metric-compatible with Calibri, Caladea with Cambria,
394/// Liberation Sans/Serif/Mono with Arial/Times New Roman/Courier New.
395fn map_font_name(name: &str) -> &[&str] {
396    match name {
397        "Calibri" => &["Calibri", "Carlito"],
398        "Calibri Light" => &["Calibri Light", "Carlito"],
399        "Cambria" => &["Cambria", "Caladea"],
400        "Cambria Math" => &["Cambria Math", "Cambria", "Caladea"],
401        "Arial" => &["Arial", "Liberation Sans", "Helvetica"],
402        "Times New Roman" => &["Times New Roman", "Liberation Serif", "Times"],
403        "Courier New" => &["Courier New", "Liberation Mono", "Courier"],
404        "Consolas" => &["Consolas", "Liberation Mono", "DejaVu Sans Mono"],
405        "Segoe UI" => &["Segoe UI", "Carlito", "Liberation Sans"],
406        "Tahoma" => &["Tahoma", "Liberation Sans", "Helvetica"],
407        "Verdana" => &["Verdana", "Liberation Sans", "DejaVu Sans"],
408        "Georgia" => &["Georgia", "Caladea", "Liberation Serif"],
409        "Palatino Linotype" => &["Palatino Linotype", "Palatino", "Liberation Serif"],
410        "Book Antiqua" => &["Book Antiqua", "Palatino", "Liberation Serif"],
411        "Garamond" => &["Garamond", "Caladea", "Liberation Serif"],
412        "Trebuchet MS" => &["Trebuchet MS", "Liberation Sans", "DejaVu Sans"],
413        "Impact" => &["Impact", "Liberation Sans", "Arial"],
414        "Comic Sans MS" => &["Comic Sans MS", "Liberation Sans", "DejaVu Sans"],
415        "Symbol" => &["Symbol", "DejaVu Sans"],
416        "Wingdings" => &["Wingdings", "Symbol"],
417        _ => &[],
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn load_system_font() {
427        let mut fm = FontManager::new();
428        // Should be able to resolve at least one font via fallback
429        let result = fm.resolve_font(None, false, false);
430        // On CI or systems without fonts this might fail, so we just check it doesn't panic
431        if let Ok(id) = result {
432            assert_eq!(id.0, 0);
433        }
434    }
435
436    #[test]
437    fn font_metrics_positive() {
438        let mut fm = FontManager::new();
439        if let Ok(id) = fm.resolve_font(None, false, false) {
440            let metrics = fm.metrics(id, 12.0).unwrap();
441            assert!(metrics.ascent > 0.0);
442            assert!(metrics.descent > 0.0);
443            assert!(metrics.units_per_em > 0);
444        }
445    }
446
447    #[test]
448    fn shape_hello_world() {
449        let mut fm = FontManager::new();
450        if let Ok(id) = fm.resolve_font(None, false, false) {
451            let shaped = fm.shape_text(id, "Hello World", 12.0).unwrap();
452            assert!(!shaped.glyph_ids.is_empty());
453            assert_eq!(shaped.glyph_ids.len(), shaped.advances.len());
454            assert!(shaped.width > 0.0);
455        }
456    }
457
458    #[test]
459    fn font_caching() {
460        let mut fm = FontManager::new();
461        if let Ok(id1) = fm.resolve_font(Some("Arial"), false, false) {
462            let id2 = fm.resolve_font(Some("Arial"), false, false).unwrap();
463            assert_eq!(id1, id2);
464        }
465    }
466
467    #[test]
468    fn bold_italic_variants() {
469        let mut fm = FontManager::new();
470        let regular = fm.resolve_font(None, false, false);
471        let bold = fm.resolve_font(None, true, false);
472        if let (Ok(r), Ok(b)) = (regular, bold) {
473            // Bold should get a different font ID (different variant)
474            assert_ne!(r, b);
475        }
476    }
477}