mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 11:07:01 -06:00
cascade's last fallback searched the loaded faces and, finding none that draws the cluster, called `first` while the search's read guard was still alive (a temporary in the match arm lives to the end of the `let`). `first` looks up the default family, which loads it through add_fonts, and add_fonts takes the faces' write lock even when there is nothing to add: the thread waited on itself, and every other thread then waited on the loading lock it held. It shows when a run's chain is empty and nothing loaded draws the character before the default family has been looked up, so it depends on which test (or document) gets there first: deckcraft-text's tests hung in 4 of 10 parallel runs here (all threads parked in system_fallback / try_family, one in add_fonts' write), and in 0 of 15 with this change. The search now finishes before `first` runs. New test falling_back_to_the_default_face_does_not_deadlock reaches that path on a fresh database; it times out on main and passes with the fix.
310 lines
14 KiB
Rust
310 lines
14 KiB
Rust
//! Font fallback per grapheme cluster, and ligature caret positions.
|
|
//!
|
|
//! The cascade for a cluster (a base character with its marks, never split across faces) is:
|
|
//! 1. the faces the document asks for, in order (the run's slot typeface, then the theme's script
|
|
//! font, `a:font[@script="Arab"]`);
|
|
//! 2. for scripts with a platform cascade (Arabic, Hebrew, Thai, Devanagari): the craft-fonts face
|
|
//! bundled for the script (Noto Naskh Arabic for Arabic), so every machine draws the same; then
|
|
//! openly licensed or OS-native families found by name among the installed fonts (no platform
|
|
//! API is called); then any installed face covering the cluster;
|
|
//! 3. any loaded face (bundled first: Japanese keeps its craft-fonts faces), then the first
|
|
//! requested face (drawn as .notdef).
|
|
|
|
use std::sync::Arc;
|
|
|
|
use skrifa::raw::TableProvider;
|
|
|
|
use crate::script::{ScriptTag, script_of};
|
|
use crate::{CRAFT_FONTS, FontDb, FontFace};
|
|
|
|
/// Variation selector 15: text presentation.
|
|
const TEXT_SELECTOR: char = '\u{FE0E}';
|
|
/// Combining enclosing keycap.
|
|
const KEYCAP: char = '\u{20E3}';
|
|
|
|
/// Characters that never decide a face: they're drawn by whatever face the cluster gets.
|
|
fn ignorable(c: char) -> bool {
|
|
c.is_whitespace()
|
|
|| c.is_control()
|
|
|| matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2069 | 0xFE00..=0xFE0F | 0xFEFF | 0xE0100..=0xE01EF)
|
|
}
|
|
|
|
/// Is the cluster an emoji drawn in colour: a pictograph that defaults to emoji presentation
|
|
/// (Unicode's Emoji_Presentation, including BMP ones such as ⭐ ✅ ☔ ⌚ ⏩), or a sequence with an
|
|
/// emoji presentation selector (U+FE0F), a zero-width joiner between pictographs, a skin tone
|
|
/// modifier or regional indicators (flags)? Keycaps ("1️⃣") are not: PowerPoint draws them as text
|
|
/// (an outlined key), as it draws ♥ ✔ ☎ ⬆ ▶ without a selector.
|
|
pub fn is_emoji_cluster(cluster: &[char]) -> bool {
|
|
if cluster.contains(&'\u{FE0E}') || cluster.contains(&'\u{20E3}') {
|
|
return false;
|
|
}
|
|
let pictographic = |c: char| matches!(c as u32, 0x1F000..=0x1FAFF | 0x2600..=0x27BF | 0x2B00..=0x2BFF | 0x2190..=0x21FF | 0x2300..=0x23FF);
|
|
let default_emoji = |c: char| {
|
|
matches!(
|
|
c as u32,
|
|
0x1F300..=0x1F64F
|
|
| 0x1F680..=0x1F6FF
|
|
| 0x1F900..=0x1F9FF
|
|
| 0x1FA70..=0x1FAFF
|
|
| 0x1F1E6..=0x1F1FF
|
|
| 0x1F004
|
|
| 0x1F0CF
|
|
| 0x1F18E
|
|
| 0x1F191..=0x1F19A
|
|
| 0x1F201
|
|
| 0x1F21A
|
|
| 0x1F22F
|
|
| 0x1F232..=0x1F236
|
|
| 0x1F238..=0x1F23A
|
|
| 0x1F250..=0x1F251
|
|
| 0x231A..=0x231B
|
|
| 0x23E9..=0x23EC
|
|
| 0x23F0
|
|
| 0x23F3
|
|
| 0x25FD..=0x25FE
|
|
| 0x2614..=0x2615
|
|
| 0x2648..=0x2653
|
|
| 0x267F
|
|
| 0x2693
|
|
| 0x26A1
|
|
| 0x26AA..=0x26AB
|
|
| 0x26BD..=0x26BE
|
|
| 0x26C4..=0x26C5
|
|
| 0x26CE
|
|
| 0x26D4
|
|
| 0x26EA
|
|
| 0x26F2..=0x26F3
|
|
| 0x26F5
|
|
| 0x26FA
|
|
| 0x26FD
|
|
| 0x2705
|
|
| 0x270A..=0x270B
|
|
| 0x2728
|
|
| 0x274C
|
|
| 0x274E
|
|
| 0x2753..=0x2755
|
|
| 0x2757
|
|
| 0x2795..=0x2797
|
|
| 0x27B0
|
|
| 0x27BF
|
|
| 0x2B1B..=0x2B1C
|
|
| 0x2B50
|
|
| 0x2B55
|
|
)
|
|
};
|
|
cluster.iter().any(|&c| default_emoji(c))
|
|
|| (cluster.contains(&'\u{FE0F}') && cluster.iter().any(|&c| pictographic(c)))
|
|
|| (cluster.contains(&'\u{200D}') && cluster.iter().any(|&c| pictographic(c)))
|
|
}
|
|
|
|
/// Does `face` draw every character of the cluster?
|
|
pub fn covers_cluster(face: &FontFace, cluster: &[char]) -> bool {
|
|
cluster.iter().all(|c| ignorable(*c) || face.covers(*c))
|
|
}
|
|
|
|
/// Installed families tried by name for a script after the bundled face, before scanning every
|
|
/// installed font. Microsoft font names are not listed (AGENTS.md §1.1); an installed one is
|
|
/// still used by the scan when nothing earlier covers the cluster.
|
|
fn os_families(script: ScriptTag) -> &'static [&'static str] {
|
|
match &script {
|
|
b"Arab" => &["Noto Naskh Arabic", "Noto Sans Arabic", "Geeza Pro", "SF Arabic", "DejaVu Sans"],
|
|
b"Hebr" => &["Noto Sans Hebrew", "Arial Hebrew", "SF Hebrew", "DejaVu Sans"],
|
|
b"Thai" => &["Noto Sans Thai", "Thonburi"],
|
|
b"Deva" => &["Noto Sans Devanagari", "Kohinoor Devanagari", "Devanagari Sangam MN"],
|
|
_ => &[],
|
|
}
|
|
}
|
|
|
|
/// The craft-fonts families for a script (manifest order), e.g. Noto Naskh Arabic for `Arab`.
|
|
pub fn bundled_families(script: ScriptTag) -> Vec<&'static str> {
|
|
let tag = std::str::from_utf8(&script).unwrap_or("");
|
|
let mut v: Vec<&'static str> = CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&tag)).map(|f| f.family).collect();
|
|
v.dedup();
|
|
v
|
|
}
|
|
|
|
impl FontDb {
|
|
/// The face for one grapheme cluster: the first of `chain` that draws all of it, else the
|
|
/// bundled face for its script, else an installed face for the script, else any loaded
|
|
/// face that draws it, else `chain[0]` (or the default face if `chain` is empty).
|
|
pub fn cascade(&self, cluster: &[char], chain: &[Arc<FontFace>], style: &str) -> Arc<FontFace> {
|
|
// Emoji take a colour face (with its ZWJ sequences, skin tones and flags) before the
|
|
// document's fonts, as PowerPoint draws them, even when a text font has a plain glyph.
|
|
if is_emoji_cluster(cluster) {
|
|
if let Some(f) = chain.iter().find(|f| f.is_color() && covers_cluster(f, cluster)) {
|
|
return f.clone();
|
|
}
|
|
if let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c))
|
|
&& let Some(f) = self.color_face_for(key, cluster)
|
|
{
|
|
return f;
|
|
}
|
|
}
|
|
if let Some(f) = chain.iter().find(|f| covers_cluster(f, cluster)) {
|
|
return f.clone();
|
|
}
|
|
// Text presentation (U+FE0E) and keycaps: a plain face, not a colour emoji one.
|
|
let text_style = cluster.contains(&TEXT_SELECTOR) || cluster.contains(&KEYCAP);
|
|
if text_style
|
|
&& let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c))
|
|
&& let Some(f) = self.plain_face_for(key, cluster)
|
|
{
|
|
return f;
|
|
}
|
|
let first = || chain.first().cloned().unwrap_or_else(|| self.face(crate::DEFAULT_FAMILY, style));
|
|
let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c)) else { return first() };
|
|
let script = cluster.iter().find_map(|c| script_of(*c));
|
|
// Scripts with a platform cascade (Arabic, Hebrew, Thai, Devanagari): the bundled face,
|
|
// then installed ones; the others keep the loaded-faces-first order (Japanese documents
|
|
// prefer the bundled craft-fonts faces).
|
|
if let Some(s) = script.filter(|s| !os_families(*s).is_empty()) {
|
|
let bundled = bundled_families(s);
|
|
for fam in &bundled {
|
|
let f = self.face(fam, style);
|
|
if f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {
|
|
return f;
|
|
}
|
|
}
|
|
for fam in os_families(s) {
|
|
if bundled.contains(fam) {
|
|
continue;
|
|
}
|
|
if self.is_loaded(fam) || self.has_family(fam) {
|
|
let f = self.face(fam, style);
|
|
if f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {
|
|
return f;
|
|
}
|
|
}
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
if let Some(f) = self.system_face_for(key, cluster, &bundled) {
|
|
return f;
|
|
}
|
|
}
|
|
let exclude = chain.first().map(|f| f.id()).unwrap_or(u32::MAX);
|
|
let f = match self.fallback_for(key, exclude) {
|
|
Some(f) if covers_cluster(&f, cluster) => f,
|
|
Some(f) if chain.iter().all(|c| !c.covers(key)) => f,
|
|
// A letter with marks the run's font lacks (stacked combining marks): a loaded plain
|
|
// face that draws the whole cluster, as PowerPoint draws the marks, before the run's
|
|
// font with missing-glyph boxes.
|
|
_ => {
|
|
// Searched before `first` runs: it may load the default family, which needs the
|
|
// faces' write lock, so the read lock must not be held across it.
|
|
let found = self.read_faces().iter().find(|f| !f.is_color() && covers_cluster(f, cluster)).cloned();
|
|
found.unwrap_or_else(first)
|
|
}
|
|
};
|
|
// A pictograph that defaults to text presentation (⚠ ♥ ⬆ ▶) whose first fallback is the
|
|
// colour emoji font: PowerPoint draws it from a plain symbol font.
|
|
if f.is_color()
|
|
&& !is_emoji_cluster(cluster)
|
|
&& let Some(plain) = self.plain_face_for(key, cluster)
|
|
{
|
|
return plain;
|
|
}
|
|
f
|
|
}
|
|
|
|
/// An installed (non-bundled) face covering the cluster, loading one from the system catalog
|
|
/// if no loaded face does.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn system_face_for(&self, key: char, cluster: &[char], bundled: &[&str]) -> Option<Arc<FontFace>> {
|
|
let pick = |db: &FontDb| {
|
|
let faces = db.read_faces();
|
|
let mut v: Vec<&Arc<FontFace>> = faces
|
|
.iter()
|
|
.filter(|f| !bundled.iter().any(|b| f.family.eq_ignore_ascii_case(b)) && !CRAFT_FONTS.iter().any(|c| c.family == f.family))
|
|
.filter(|f| covers_cluster(f, cluster))
|
|
.collect();
|
|
v.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32));
|
|
v.first().map(|f| (*f).clone())
|
|
};
|
|
if let Some(f) = pick(self) {
|
|
return Some(f);
|
|
}
|
|
if self.system_fallback(key) { pick(self) } else { None }
|
|
}
|
|
}
|
|
|
|
/// Caret positions inside a ligature glyph from GDEF `LigCaretList`, in font units from the
|
|
/// glyph's origin (sorted ascending). Empty when the font has none for `gid`.
|
|
pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
|
|
let Some(font) = face.skrifa() else { return Vec::new() };
|
|
let Ok(gdef) = font.gdef() else { return Vec::new() };
|
|
let Some(Ok(list)) = gdef.lig_caret_list() else { return Vec::new() };
|
|
let Ok(cov) = list.coverage() else { return Vec::new() };
|
|
let Some(idx) = cov.get(skrifa::GlyphId::new(gid)) else { return Vec::new() };
|
|
let Ok(lig) = list.lig_glyphs().get(idx as usize) else { return Vec::new() };
|
|
let mut out: Vec<i32> = lig
|
|
.caret_values()
|
|
.iter()
|
|
.filter_map(|cv| cv.ok())
|
|
.filter_map(|cv| match cv {
|
|
skrifa::raw::tables::gdef::CaretValue::Format1(f) => Some(f.coordinate() as i32),
|
|
skrifa::raw::tables::gdef::CaretValue::Format3(f) => Some(f.coordinate() as i32),
|
|
// Format 2 is a contour point index; outlines aren't consulted here.
|
|
skrifa::raw::tables::gdef::CaretValue::Format2(_) => None,
|
|
})
|
|
.take(64)
|
|
.collect();
|
|
out.sort_unstable();
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn requested_face_wins_when_it_covers() {
|
|
let db = FontDb::global();
|
|
let inter = db.face(crate::DEFAULT_FAMILY, "Regular");
|
|
let f = db.cascade(&['a'], std::slice::from_ref(&inter), "Regular");
|
|
assert!(Arc::ptr_eq(&f, &inter));
|
|
// Spaces and joiners never force a fallback.
|
|
let f = db.cascade(&[' ', '\u{200D}'], std::slice::from_ref(&inter), "Regular");
|
|
assert!(Arc::ptr_eq(&f, &inter));
|
|
// Nothing to go on: the default face.
|
|
assert!(!db.cascade(&[], &[], "Regular").family.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn arabic_cluster_falls_back_to_an_arabic_face() {
|
|
let db = FontDb::global();
|
|
let inter = db.face(crate::DEFAULT_FAMILY, "Regular");
|
|
let cluster = ['\u{0628}', '\u{064E}']; // beh + fatha: one cluster, one face.
|
|
let f = db.cascade(&cluster, std::slice::from_ref(&inter), "Regular");
|
|
if covers_cluster(&f, &cluster) {
|
|
assert!(f.covers('\u{0628}'));
|
|
} else {
|
|
eprintln!("skipped: no installed or bundled face covers Arabic");
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
#[test]
|
|
fn falling_back_to_the_default_face_does_not_deadlock() {
|
|
// An empty chain and a character no loaded face draws: the loaded faces are searched,
|
|
// then the default family is looked up, which loads it (taking the faces' write lock).
|
|
// Holding the search's read lock across that lookup hung the thread.
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
std::thread::spawn(move || {
|
|
let db = FontDb::with_font_dirs(Vec::new());
|
|
db.set_system_fallback(false);
|
|
let _ = tx.send(db.cascade(&['\u{10FFFD}'], &[], "Regular").family.clone());
|
|
});
|
|
let family = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("cascade did not return");
|
|
assert!(!family.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn ligature_carets_never_fail() {
|
|
let face = FontDb::global().face(crate::DEFAULT_FAMILY, "Regular");
|
|
for gid in [0, 1, 100, u32::MAX] {
|
|
let c = ligature_carets(&face, gid);
|
|
assert!(c.windows(2).all(|w| w[0] <= w[1]));
|
|
}
|
|
}
|
|
}
|