diff --git a/crates/rdocx-layout/src/engine.rs b/crates/rdocx-layout/src/engine.rs index 62d896a3..e61e8dc9 100644 --- a/crates/rdocx-layout/src/engine.rs +++ b/crates/rdocx-layout/src/engine.rs @@ -565,8 +565,14 @@ pub fn layout_paragraph( let marker_italic = marker_rpr.italic.unwrap_or(false); let marker_font_family = marker_rpr.font_ascii.as_deref(); - if let Ok(font_id) = fm.resolve_font(marker_font_family, marker_bold, marker_italic) - && let Ok(shaped) = fm.shape_text(font_id, &marker.marker_text, marker_font_size) + // Bullet glyphs are not in every font either, so the marker gets + // the same coverage check as body text. + if let Ok(font_id) = fm.resolve_font_for_text( + marker_font_family, + marker_bold, + marker_italic, + &marker.marker_text, + ) && let Ok(shaped) = fm.shape_text(font_id, &marker.marker_text, marker_font_size) { let metrics = fm.metrics(font_id, marker_font_size)?; let color = marker_rpr @@ -677,7 +683,10 @@ pub fn layout_paragraph( baseline_offset += pos as f64 / 2.0; // half-points to points } - let font_id = fm.resolve_font(font_family.as_deref(), bold, italic)?; + // Resolved against the run's own text, so a family without glyphs for + // this script is replaced by one that has them. + let font_id = + fm.resolve_font_for_text(font_family.as_deref(), bold, italic, &run.text())?; let metrics = fm.metrics(font_id, font_size)?; for content in &run.content { diff --git a/crates/rdocx-layout/src/font.rs b/crates/rdocx-layout/src/font.rs index 0feeac9f..1c7467b9 100644 --- a/crates/rdocx-layout/src/font.rs +++ b/crates/rdocx-layout/src/font.rs @@ -3,7 +3,7 @@ //! Uses fontdb for system font discovery, ttf-parser for metrics, //! and HarfRust for text shaping. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use crate::error::{LayoutError, Result}; @@ -68,8 +68,56 @@ pub struct FontManager { fonts: Vec, /// Next font ID counter. next_id: u32, + /// Fonts already discovered as covering something the requested family + /// could not, keyed by (bold, italic). + /// + /// Finding a font that covers a character means loading and inspecting + /// faces, which is far too slow to repeat per character. Once a CJK face + /// has been found for one character it almost always covers the rest of + /// the run, so it is tried first next time. + coverage_fallbacks: HashMap<(bool, bool), Vec>, + /// Characters already searched for and not found in any available font, so + /// the scan is not repeated for every occurrence. + coverage_misses: HashSet, } +/// Families with broad non-Latin coverage, tried before scanning everything. +/// +/// Ordered roughly by how likely each is to be installed. This is only a fast +/// path: if none of them is present the full font database is still searched. +const BROAD_COVERAGE_FAMILIES: &[&str] = &[ + // Bundled with or shipped alongside many Linux distributions + "Noto Sans CJK SC", + "Noto Sans CJK JP", + "Noto Sans CJK KR", + "Noto Sans CJK TC", + "Noto Serif CJK SC", + "Source Han Sans SC", + "WenQuanYi Zen Hei", + "WenQuanYi Micro Hei", + // macOS + "PingFang SC", + "PingFang TC", + "Hiragino Sans", + "Hiragino Kaku Gothic ProN", + "Apple SD Gothic Neo", + "Songti SC", + "STHeiti", + // Windows + "Microsoft YaHei", + "Microsoft JhengHei", + "SimSun", + "SimHei", + "NSimSun", + "Yu Gothic", + "MS Gothic", + "Meiryo", + "Malgun Gothic", + // Wide-coverage generalists + "Arial Unicode MS", + "DejaVu Sans", +]; + impl Default for FontManager { fn default() -> Self { Self::new() @@ -97,6 +145,8 @@ impl FontManager { cache: HashMap::new(), fonts: Vec::new(), next_id: 0, + coverage_fallbacks: HashMap::new(), + coverage_misses: HashSet::new(), } } @@ -119,6 +169,8 @@ impl FontManager { cache: HashMap::new(), fonts: Vec::new(), next_id: 0, + coverage_fallbacks: HashMap::new(), + coverage_misses: HashSet::new(), }) } @@ -156,9 +208,164 @@ impl FontManager { cache: HashMap::new(), fonts: Vec::new(), next_id: 0, + coverage_fallbacks: HashMap::new(), + coverage_misses: HashSet::new(), } } + /// Resolve a font for `text`, falling back on glyph coverage. + /// + /// `resolve_font` picks by family name alone. That is enough for Latin + /// text, but a run asking for a Chinese family on a machine without it + /// falls down the name chain and lands on a Latin font, which has no CJK + /// glyphs, so every character renders as a missing-glyph box. Name + /// matching cannot detect that, because the font it chose exists and is + /// perfectly valid, it simply cannot draw this text. + /// + /// So the resolved font is checked against the text, and when a character + /// is missing another font that can draw it is looked for. + /// + /// This is per run rather than per character: the font that covers the + /// first missing character is used for the whole run. Text that mixes + /// scripts inside one run is therefore still imperfect, but it is a large + /// improvement on drawing boxes. + pub fn resolve_font_for_text( + &mut self, + family: Option<&str>, + bold: bool, + italic: bool, + text: &str, + ) -> Result { + let primary = self.resolve_font(family, bold, italic)?; + + let Some(idx) = self.index_of(primary) else { + return Ok(primary); + }; + let missing = self.uncovered(idx, text); + if missing.is_empty() { + return Ok(primary); + } + + match self.font_covering(&missing, bold, italic) { + // Nothing installed can draw it. Keep the original font so the + // text still occupies the right space. + None => Ok(primary), + Some(id) => Ok(id), + } + } + + /// The characters in `text` that the font at `idx` cannot draw. + /// + /// Whitespace and control characters are skipped: a font without a glyph + /// for a space is not a reason to go looking for another one. + fn uncovered(&self, idx: usize, text: &str) -> Vec { + let font = &self.fonts[idx]; + let Ok(face) = ttf_parser::Face::parse(&font.data, font.face_index) else { + return Vec::new(); + }; + let mut seen = HashSet::new(); + text.chars() + .filter(|&ch| !ch.is_whitespace() && !ch.is_control()) + .filter(|&ch| face.glyph_index(ch).is_none()) + .filter(|&ch| seen.insert(ch)) + .collect() + } + + /// Whether the font at `idx` has a glyph for `ch`. + fn covers(&self, idx: usize, ch: char) -> bool { + let font = &self.fonts[idx]; + ttf_parser::Face::parse(&font.data, font.face_index) + .map(|face| face.glyph_index(ch).is_some()) + .unwrap_or(false) + } + + /// Find a font that can draw `missing`. + /// + /// A font covering every missing character wins. Failing that the one + /// covering the most is used, because a single run gets a single font and + /// partial coverage still beats a row of boxes. Picking on the first + /// missing character alone is not enough: a Japanese face may have the + /// characters shared with Chinese and not the simplified-only ones, so it + /// would look like a fix and still leave gaps. + fn font_covering(&mut self, missing: &[char], bold: bool, italic: bool) -> Option { + if missing.iter().all(|ch| self.coverage_misses.contains(ch)) { + return None; + } + + let mut best: Option<(usize, usize)> = None; // (covered count, font index) + let consider = |this: &Self, idx: usize, best: &mut Option<(usize, usize)>| -> bool { + let covered = missing.iter().filter(|&&ch| this.covers(idx, ch)).count(); + if covered == 0 { + return false; + } + if best.map(|(n, _)| covered > n).unwrap_or(true) { + *best = Some((covered, idx)); + } + covered == missing.len() + }; + + // Fonts that already rescued an earlier run, which for a document in + // one script is almost always the answer again. + if let Some(known) = self.coverage_fallbacks.get(&(bold, italic)).cloned() { + for idx in known { + if consider(self, idx, &mut best) { + return Some(self.fonts[idx].id); + } + } + } + + // Families with broad coverage, then everything else the database + // knows about. Both go through resolve_font so loading and caching + // stay in one place. + let candidates: Vec = BROAD_COVERAGE_FAMILIES + .iter() + .map(|s| s.to_string()) + .chain( + self.db + .faces() + .filter_map(|f| f.families.first().map(|(name, _)| name.clone())), + ) + .collect(); + + for name in candidates { + let Ok(id) = self.resolve_font(Some(&name), bold, italic) else { + continue; + }; + let Some(idx) = self.index_of(id) else { + continue; + }; + let complete = consider(self, idx, &mut best); + if complete { + self.coverage_fallbacks + .entry((bold, italic)) + .or_default() + .push(idx); + return Some(id); + } + } + + match best { + Some((_, idx)) => { + self.coverage_fallbacks + .entry((bold, italic)) + .or_default() + .push(idx); + Some(self.fonts[idx].id) + } + None => { + for &ch in missing { + self.coverage_misses.insert(ch); + } + None + } + } + } + + /// Index into `fonts` for a FontId. + fn index_of(&self, id: FontId) -> Option { + self.fonts.iter().position(|f| f.id == id) + } + /// Resolve a font by family name, bold, and italic flags. /// Returns a FontId. Uses fallback chain if the requested font is not found. pub fn resolve_font( @@ -527,4 +734,85 @@ mod tests { assert_ne!(r, b); } } + + /// Latin text must resolve exactly as it did before, so the coverage check + /// cannot disturb the overwhelmingly common case. + #[test] + fn latin_text_resolves_the_same_as_by_name() { + let mut fm = FontManager::new(); + let Ok(by_name) = fm.resolve_font(Some("Arial"), false, false) else { + return; + }; + let for_text = fm + .resolve_font_for_text(Some("Arial"), false, false, "Hello world") + .unwrap(); + assert_eq!(by_name, for_text); + } + + /// Text nothing can draw must keep the requested font rather than failing. + /// + /// The bundled fonts have no CJK coverage, so in deterministic mode the + /// search is guaranteed to come up empty. The text still needs a font so + /// it occupies the right space. + #[test] + fn text_no_font_can_draw_keeps_the_requested_font() { + let Ok(mut fm) = FontManager::new_deterministic() else { + return; // needs the bundled-fonts feature + }; + let primary = fm.resolve_font(Some("Carlito"), false, false).unwrap(); + let resolved = fm + .resolve_font_for_text(Some("Carlito"), false, false, "这是中文") + .unwrap(); + assert_eq!( + primary, resolved, + "with no covering font available the original must be kept" + ); + } + + /// Whitespace absent from a font is not a reason to go hunting for another. + #[test] + fn whitespace_does_not_trigger_a_fallback() { + let Ok(mut fm) = FontManager::new_deterministic() else { + return; + }; + let by_name = fm.resolve_font(Some("Carlito"), false, false).unwrap(); + let idx = fm.index_of(by_name).unwrap(); + // A non-breaking space and a tab, neither of which every face carries. + assert!( + fm.uncovered(idx, "a\u{00a0}b\tc") + .iter() + .all(|c| *c != '\t'), + "control and whitespace characters must be ignored" + ); + } + + /// When the machine does have a CJK font, CJK text must not keep a Latin + /// font that cannot draw it. + /// + /// Skipped where no such font is installed, which is why it asserts + /// nothing about which font is chosen. + #[test] + fn cjk_text_moves_off_a_latin_font_when_possible() { + let mut fm = FontManager::new(); + let Ok(latin) = fm.resolve_font(Some("Liberation Serif"), false, false) else { + return; + }; + let Some(idx) = fm.index_of(latin) else { + return; + }; + if fm.uncovered(idx, "这是中文").is_empty() { + return; // that font somehow covers it, nothing to prove + } + let resolved = fm + .resolve_font_for_text(Some("Liberation Serif"), false, false, "这是中文") + .unwrap(); + if resolved == latin { + return; // no covering font installed on this machine + } + let new_idx = fm.index_of(resolved).unwrap(); + assert!( + fm.uncovered(new_idx, "这是中文").len() < fm.uncovered(idx, "这是中文").len(), + "the replacement must cover more of the text than the original" + ); + } }