Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions crates/rdocx-layout/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
290 changes: 289 additions & 1 deletion crates/rdocx-layout/src/font.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -68,8 +68,56 @@ pub struct FontManager {
fonts: Vec<LoadedFont>,
/// 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<usize>>,
/// Characters already searched for and not found in any available font, so
/// the scan is not repeated for every occurrence.
coverage_misses: HashSet<char>,
}

/// 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()
Expand Down Expand Up @@ -97,6 +145,8 @@ impl FontManager {
cache: HashMap::new(),
fonts: Vec::new(),
next_id: 0,
coverage_fallbacks: HashMap::new(),
coverage_misses: HashSet::new(),
}
}

Expand All @@ -119,6 +169,8 @@ impl FontManager {
cache: HashMap::new(),
fonts: Vec::new(),
next_id: 0,
coverage_fallbacks: HashMap::new(),
coverage_misses: HashSet::new(),
})
}

Expand Down Expand Up @@ -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<FontId> {
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<char> {
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<FontId> {
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<String> = 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<usize> {
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(
Expand Down Expand Up @@ -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"
);
}
}