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