collective

Browse / github.com/storytold/filmcraft / patch

Interface fonts: read installed fallback faces when text needs them, and hold each once (−158 MB at idle on macOS)

Interface fonts: read installed fallback faces when text needs them, and hold each once (−158 MB at idle on macOS) ### Problem FilmCraft holds 158 MB of font data it almost never uses, from the first frame, in every session, English interface included. The interface falls back to installed faces for scripts its fonts lack: Hangul in every build, plus Chinese, Arabic and Hebrew in builds without craft-fonts. `theme::install` reads them on the first frame, and each file ends up in memory three times: 1. the font database's copy, which `Face::data()` reads and keeps in the face; 2. the copy `Face::data()` returns, owned by our `FontData` (`cjk::find_system_font`, `rtl::system_fonts`); 3. egui 0.36's copy:

Status
open
Score
0.00
Adopters
0 other operators
Author
ZJHeepfixer (imported from pull request #3309, open)
Base
693c41be5a93241e7c502e24d09a946c8a06944e
Size
+1068 −188 in 8 files
Review
pending
Shared
2026-10-28
Id
2cf0da8076bd4c7e8e1b8895

How it went

No other operator has reported using it yet.

Diff

raw · crates/text/src/fonts.rs crates/ui-egui/src/cjk.rs crates/ui-egui/src/fallback_fonts.rs crates/ui-egui/src/i18n/mod.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/rtl.rs crates/ui-egui/src/theme.rs docs/contributing.md

diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs--- a/crates/text/src/fonts.rs+++ b/crates/text/src/fonts.rs@@ -47,6 +47,11 @@ pub const CRAFT_ORIGIN: &str = "craft-fonts"; /// The default family for new text. pub const DEFAULT_FAMILY: &str = "Inter"; +/// Whether the font at `index` of `data` maps `c` to a glyph (false when it can't be parsed).+pub fn maps_char(data: &[u8], index: u32, c: char) -> bool {+    FontRef::from_index(data, index).ok().and_then(|f| f.charmap().map(c)).is_some_and(|g| g.to_u32() != 0)+}+ /// Index of a face in the database. pub type FaceId = usize; @@ -213,7 +218,11 @@ pub struct VMetrics { }  impl Face {-    fn bytes(&self) -> Option<&[u8]> {+    /// The font file's bytes (None if it is missing or unreadable), read on first use and kept with+    /// the face, which the database holds for the life of the process. Borrow them rather than+    /// copying them with [`Face::data`]: a caller holding the face in a `static` can hand them on+    /// as `&'static [u8]`.+    pub fn bytes(&self) -> Option<&[u8]> {         self.bytes             .get_or_init(|| match &self.info.data {                 FaceData::Static(b) => Some(FaceBytes::Static(b)),@@ -245,6 +254,28 @@ impl Face {     pub fn has_char(&self, c: char) -> bool {         self.glyph(c).is_some()     }+    /// The code points the face maps to a glyph, as sorted, merged inclusive ranges. Like+    /// [`Face::covers_text`], a face whose file is not loaded yet is read into a temporary buffer+    /// that is dropped afterwards.+    pub fn coverage(&self) -> Vec<(u32, u32)> {+        fn ranges(font: &FontRef<'_>) -> Vec<(u32, u32)> {+            let mut cps: Vec<u32> = font.charmap().mappings().filter(|(_, g)| g.to_u32() != 0).map(|(cp, _)| cp).collect();+            cps.sort_unstable();+            let mut out: Vec<(u32, u32)> = Vec::new();+            for cp in cps {+                match out.last_mut() {+                    Some(last) if cp <= last.1.saturating_add(1) => last.1 = last.1.max(cp),+                    _ => out.push((cp, cp)),+                }+            }+            out+        }+        if let (FaceData::File(p), None) = (&self.info.data, self.bytes.get()) {+            let Ok(data) = std::fs::read(p) else { return Vec::new() };+            return FontRef::from_index(&data, self.info.index).map(|f| ranges(&f)).unwrap_or_default();+        }+        self.font().map(|f| ranges(&f)).unwrap_or_default()+    }     /// Whether the face covers every character of `text`. Unlike [`Face::has_char`], a face whose     /// file is not loaded yet is read into a temporary buffer that is dropped afterwards, so     /// probing many system faces does not keep every font file in memory.@@ -628,9 +659,36 @@ mod tests {         let f = Face { id: 0, info, bytes: OnceLock::new(), shaper: OnceLock::new() };         assert!(f.covers_text("A\u{416}"));         assert!(!f.covers_text("A\u{5d0}"));+        let ranges = f.coverage();+        let has = |c: char| ranges.iter().any(|&(a, b)| (a..=b).contains(&u32::from(c)));+        assert!(has('A') && has('\u{416}') && !has('\u{5d0}'), "{ranges:?}");+        assert!(ranges.windows(2).all(|w| w[0].1.saturating_add(1) < w[1].0), "sorted and merged: {ranges:?}");         assert!(f.bytes.get().is_none(), "probing coverage must not cache the file");         assert!(f.data().is_some());         assert!(f.bytes.get().is_some());         let _ = std::fs::remove_file(&path);     }++    /// `bytes` reads the file once and then hands out the same bytes (no copy), even after the file+    /// is gone: callers can borrow them for as long as they hold the face.+    #[test]+    fn bytes_are_read_once_and_shared() {+        let path = std::env::temp_dir().join(format!("filmcraft-face-bytes-{}.ttf", std::process::id()));+        std::fs::write(&path, INTER_REGULAR).expect("write temp font");+        let info = FaceInfo {+            family: "Inter".into(),+            style: "Regular".into(),+            weight: 400,+            italic: false,+            index: 0,+            data: FaceData::File(path.clone()),+            origin: "system",+        };+        let f = Face { id: 0, info, bytes: OnceLock::new(), shaper: OnceLock::new() };+        let first = f.bytes().expect("readable font").as_ptr();+        let _ = std::fs::remove_file(&path);+        assert_eq!(f.bytes().map(<[u8]>::as_ptr), Some(first), "the same bytes, not a second read or a copy");+        assert_eq!(f.bytes().map(<[u8]>::len), Some(INTER_REGULAR.len()));+        assert!(f.has_char('A'), "the kept bytes serve the face after its file is gone");+    } }diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs--- a/crates/ui-egui/src/cjk.rs+++ b/crates/ui-egui/src/cjk.rs@@ -1,10 +1,15 @@ //! Chinese fallback fonts for document text in the interface, independent of UI language.-//! Optional craft-fonts are preferred; native builds without them reuse one installed face.+//! Optional craft-fonts are preferred; native builds without them use one installed face, read+//! the first time the interface paints text that needs it ([`crate::fallback_fonts`]). The+//! installed Hangul face is read the same way. -use std::sync::{Arc, OnceLock};+use std::ops::RangeInclusive;+use std::sync::Arc;  use egui::{FontData, FontDefinitions, FontFamily}; +use crate::fallback_fonts::Fallback;+ const SAMPLE: &str = "中文轨道配乐音效简体汉语繁體漢語车"; const PREFERRED: &[&str] = &[     "Microsoft YaHei UI",@@ -28,55 +33,73 @@ pub(crate) fn craft_fonts() -> impl Iterator<Item = &'static filmcraft_text::fon const KOREAN_SAMPLE: &str = "한국어입력테스트"; const KOREAN_PREFERRED: &[&str] = &["Malgun Gothic", "Apple SD Gothic Neo", "Noto Sans CJK KR", "Noto Sans KR", "Source Han Sans K", "NanumGothic"]; -fn system_font() -> Option<Arc<FontData>> {-    static FONT: OnceLock<Option<Arc<FontData>>> = OnceLock::new();-    FONT.get_or_init(|| find_system_font(SAMPLE, PREFERRED)).clone()-}+/// Han, kana, bopomofo and the CJK symbols, punctuation and full-width forms: what an installed+/// Chinese face draws for the interface.+const CJK_SCRIPT: &[RangeInclusive<char>] = &[+    '\u{2e80}'..='\u{2fdf}',+    '\u{2ff0}'..='\u{303f}',+    '\u{3040}'..='\u{312f}',+    '\u{3190}'..='\u{33ff}',+    '\u{3400}'..='\u{4dbf}',+    '\u{4e00}'..='\u{9fff}',+    '\u{f900}'..='\u{faff}',+    '\u{fe30}'..='\u{fe4f}',+    '\u{ff00}'..='\u{ffef}',+    '\u{1b000}'..='\u{1b16f}',+    '\u{20000}'..='\u{3ffff}',+]; -/// The font-data name of the installed Hangul face [`install`] appends to every family.-pub(crate) const KOREAN_FACE: &str = "system-korean";+/// Hangul: jamo, compatibility jamo, syllables, half-width forms, and the parenthesized and circled+/// Hangul of the enclosed CJK block.+const HANGUL_SCRIPT: &[RangeInclusive<char>] = &[+    '\u{1100}'..='\u{11ff}',+    '\u{3130}'..='\u{318f}',+    '\u{3200}'..='\u{321e}',+    '\u{3260}'..='\u{327e}',+    '\u{a960}'..='\u{a97f}',+    '\u{ac00}'..='\u{d7ff}',+    '\u{ffa0}'..='\u{ffdc}',+]; -fn korean_font() -> Option<Arc<FontData>> {-    static FONT: OnceLock<Option<Arc<FontData>>> = OnceLock::new();-    FONT.get_or_init(|| find_system_font(KOREAN_SAMPLE, KOREAN_PREFERRED)).clone()+fn without_craft_chinese() -> bool {+    craft_fonts().next().is_none() } -/// A font collection's face index matters: Windows CJK UI fonts commonly live in TTC files.-/// Keep the owned bytes behind an Arc so theme reinstalls do not reread or leak a font file.-fn find_system_font(sample: &str, preferred: &[&str]) -> Option<Arc<FontData>> {-    if cfg!(target_arch = "wasm32") {-        return None;-    }-    filmcraft_text::fonts::scan_system();-    let faces: Vec<_> = filmcraft_text::fonts::all_faces().into_iter().filter(|f| f.info.origin == "system" && !f.info.italic).collect();-    let covers = |f: &filmcraft_text::fonts::Face| f.covers_text(sample);-    let by_weight = |f: &&Arc<filmcraft_text::fonts::Face>| f.info.weight.abs_diff(400);-    let found = preferred.iter().find_map(|name| faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(name) && covers(f)).min_by_key(by_weight));-    let face = found.or_else(|| faces.iter().filter(|f| covers(f)).min_by_key(by_weight))?;-    Some(Arc::new(FontData { font: std::borrow::Cow::Owned(face.data()?), index: face.info.index, tweak: Default::default() }))+fn always() -> bool {+    true } +/// The installed Chinese face, for builds without the craft-fonts Hans/Hant faces.+pub(crate) static CHINESE: Fallback = Fallback::new("system-chinese", SAMPLE, PREFERRED, CJK_SCRIPT, without_craft_chinese);++/// The font-data name of the installed Hangul face [`install`] appends to every family.+pub(crate) const KOREAN_FACE: &str = "system-korean";++/// The installed Hangul face (none is bundled).+pub(crate) static KOREAN: Fallback = Fallback::new(KOREAN_FACE, KOREAN_SAMPLE, KOREAN_PREFERRED, HANGUL_SCRIPT, always);+ /// Whether [`install`] has a Hangul face to add (an installed system face; none is bundled). /// The Korean interface needs one. pub(crate) fn korean_available() -> bool {-    korean_font().is_some()+    KOREAN.face.face().is_some() }  /// Whether [`install`] has a Chinese face to add: craft-fonts Hans/Hant faces, or an installed /// system face (scanned once per process). The Simplified Chinese interface needs one. pub(crate) fn available() -> bool {-    craft_fonts().next().is_some() || system_font().is_some()+    craft_fonts().next().is_some() || CHINESE.face.face().is_some() }  /// Append Chinese faces after the Latin UI fonts and the Japanese fallbacks. This keeps the /// interface's Latin typography and Japanese glyph forms, and covers the simplified and-/// traditional hanzi in media and track names that Japanese fonts lack.+/// traditional hanzi in media and track names that Japanese fonts lack. Installed faces are+/// appended once read ([`crate::fallback_fonts`]). pub(crate) fn install(fonts: &mut FontDefinitions) {     install_chinese(fonts);-    if let Some(font) = korean_font() {-        fonts.font_data.insert(KOREAN_FACE.into(), font);+    if let Some(font) = KOREAN.font_data() {+        fonts.font_data.insert(KOREAN.name.into(), font);         for stack in fonts.families.values_mut() {-            stack.push(KOREAN_FACE.into());+            stack.push(KOREAN.name.into());         }     } }@@ -84,10 +107,10 @@ pub(crate) fn install(fonts: &mut FontDefinitions) { fn install_chinese(fonts: &mut FontDefinitions) {     let chinese: Vec<_> = craft_fonts().collect();     if chinese.is_empty() {-        if let Some(font) = system_font() {-            fonts.font_data.insert("system-chinese".into(), font);+        if let Some(font) = CHINESE.font_data() {+            fonts.font_data.insert(CHINESE.name.into(), font);             for stack in fonts.families.values_mut() {-                stack.push("system-chinese".into());+                stack.push(CHINESE.name.into());             }         }         return;diff --git a/crates/ui-egui/src/fallback_fonts.rs b/crates/ui-egui/src/fallback_fonts.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/fallback_fonts.rs@@ -0,0 +1,789 @@+//! Installed faces the interface falls back to: Hangul in every build, and Chinese, Japanese,+//! Arabic and Hebrew when the craft-fonts don't cover them (see [`crate::cjk`], [`crate::rtl`]+//! and [`crate::i18n`]).+//!+//! **Read when text needs them.** These files are large (Apple SD Gothic Neo is 53 MB) and most+//! sessions never draw a character from them, so the faces in [`FALLBACKS`] are not read at+//! start-up. [`Watch`], an egui plugin, looks at the text each pass painted, in each font family.+//! When a character is in the script of a face that hasn't been read, and no face ahead of the+//! fallbacks draws it, the face is read, the fonts are installed again and egui redoes the pass+//! before anything is shown: the first frame that shows the text already has its glyphs.+//!+//! A character in no fallback's script that nothing can draw (a symbol only an installed face+//! has) is looked up in the faces not read yet, whose character maps are read once without+//! keeping the files, and the face that has it is read: so nothing that drew before draws as a+//! box now, and a character no face has (a newer emoji) reads nothing. The interface language+//! needs no special case: its text is painted like any other.+//!+//! **Read once, never copied.** A face's file is read once, into the font database's face+//! ([`Face::bytes`]), which lives for the whole process, and egui is given those bytes borrowed.+//! egui 0.36 copies owned font data each time it builds its fonts, so handing it a copy of the+//! file meant three copies of every face: the database's, ours and egui's.++use std::borrow::Cow;+use std::collections::{HashMap, HashSet};+use std::ops::RangeInclusive;+use std::sync::atomic::{AtomicBool, Ordering};+use std::sync::{Arc, OnceLock};++use egui::epaint::{ClippedShape, Shape};+use egui::{FontData, FontDefinitions, FontFamily, FontId};+use filmcraft_text::fonts::Face;++/// An installed face, found by characters it must cover.+pub(crate) struct SystemFace {+    /// Characters the face must cover.+    sample: &'static str,+    /// Installed families to prefer, best first.+    preferred: &'static [&'static str],+    found: OnceLock<Option<Arc<Face>>>,+}++impl SystemFace {+    pub(crate) const fn new(sample: &'static str, preferred: &'static [&'static str]) -> Self {+        Self { sample, preferred, found: OnceLock::new() }+    }++    /// The installed face (see [`find`]), looked up once per process. `None` on the web and on+    /// systems without one.+    pub(crate) fn face(&self) -> Option<&Arc<Face>> {+        self.found.get_or_init(|| find(self.sample, self.preferred)).as_ref()+    }++    /// Whether the face has been looked for and there is none.+    fn missing(&self) -> bool {+        matches!(self.found.get(), Some(None))+    }++    /// The face for egui, borrowing the font database's bytes (`None` without a face, or when its+    /// file can't be read).+    pub(crate) fn font_data(&'static self) -> Option<Arc<FontData>> {+        borrowed(self.face()?)+    }+}++/// `face` for egui, borrowing the bytes the font database keeps for it: no copy, however often+/// the fonts are installed. `None` when the face's file can't be read.+pub(crate) fn borrowed(face: &'static Face) -> Option<Arc<FontData>> {+    Some(Arc::new(FontData { font: Cow::Borrowed(face.bytes()?), index: face.info.index, tweak: Default::default() }))+}++/// The installed face that covers `sample`: the first family of `preferred` that has one (its+/// style closest to regular), else any installed face that does. A font collection's face index+/// matters: Windows CJK UI fonts commonly live in TTC files. Coverage is probed without keeping+/// the files ([`Face::covers_text`]), and within a family the face closest to regular is probed+/// first, so a collection is read once rather than once per face in it. Scans the system font+/// folders on first use; `None` on the web.+pub(crate) fn find(sample: &str, preferred: &[&str]) -> Option<Arc<Face>> {+    if cfg!(target_arch = "wasm32") {+        return None;+    }+    filmcraft_text::fonts::scan_system();+    let mut faces: Vec<_> = filmcraft_text::fonts::all_faces().into_iter().filter(|f| f.info.origin == "system" && !f.info.italic).collect();+    faces.sort_by_key(|f| f.info.weight.abs_diff(400));+    let covers = |f: &&Arc<Face>| f.covers_text(sample);+    let found = preferred.iter().find_map(|name| faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(name)).find(covers));+    found.or_else(|| faces.iter().find(covers)).cloned()+}++/// An installed face the interface falls back to for a script, read when text needs it.+pub(crate) struct Fallback {+    /// The egui font name.+    pub(crate) name: &'static str,+    pub(crate) face: SystemFace,+    /// The script's code points.+    script: &'static [RangeInclusive<char>],+    /// Whether this build falls back to an installed face for the script at all (false when the+    /// craft-fonts cover it).+    wanted: fn() -> bool,+    read: AtomicBool,+    /// The code points the face maps, while it isn't read (see [`Candidate::might_cover`]).+    coverage: OnceLock<Vec<(u32, u32)>>,+}++impl Fallback {+    pub(crate) const fn new(+        name: &'static str,+        sample: &'static str,+        preferred: &'static [&'static str],+        script: &'static [RangeInclusive<char>],+        wanted: fn() -> bool,+    ) -> Self {+        Self { name, face: SystemFace::new(sample, preferred), script, wanted, read: AtomicBool::new(false), coverage: OnceLock::new() }+    }++    /// The face for egui once [`Watch`] (or [`Fallback::read`]) has read it, else `None`.+    pub(crate) fn font_data(&'static self) -> Option<Arc<FontData>> {+        if !self.is_read() {+            return None;+        }+        self.face.font_data()+    }++    pub(crate) fn is_read(&self) -> bool {+        self.read.load(Ordering::Acquire)+    }++    /// Read the face's file (once), so that installing the fonts includes it. False when this+    /// build doesn't fall back for the script, or nothing installed covers it.+    pub(crate) fn read(&self) -> bool {+        if self.is_read() {+            return true;+        }+        if !(self.wanted)() || self.face.face().and_then(|f| f.bytes()).is_none() {+            return false;+        }+        self.read.store(true, Ordering::Release);+        true+    }++    /// Nothing left to do for this fallback: read, not wanted, or not installed.+    fn settled(&self) -> bool {+        self.is_read() || !(self.wanted)() || self.face.missing()+    }++    /// Use `face` as the installed face (a bundled font standing in for one).+    #[cfg(test)]+    fn use_face(&self, face: Arc<Face>) {+        let _ = self.face.found.set(Some(face));+    }+}++/// What [`plan`] needs to know of a fallback.+trait Candidate {+    /// Whether `c` is in the script the face is the fallback for.+    fn in_script(&self, c: char) -> bool;+    fn is_read(&self) -> bool;+    /// Whether the face, once read, draws `c`.+    fn covers(&self, c: char) -> bool;+    /// Read the face; false when there is nothing to read.+    fn read(&self) -> bool;+    /// Whether the face (read or not) draws `c`. A face not read yet is looked up in its+    /// character map, read once without keeping the file.+    fn might_cover(&self, c: char) -> bool;+}++impl Candidate for Fallback {+    fn in_script(&self, c: char) -> bool {+        self.script.iter().any(|r| r.contains(&c))+    }+    fn is_read(&self) -> bool {+        Fallback::is_read(self)+    }+    fn covers(&self, c: char) -> bool {+        self.is_read() && self.face.face().is_some_and(|f| f.has_char(c))+    }+    fn read(&self) -> bool {+        Fallback::read(self)+    }+    fn might_cover(&self, c: char) -> bool {+        if self.is_read() {+            return self.covers(c);+        }+        let Some(face) = self.face.face().filter(|_| (self.wanted)()) else { return false };+        let cp = u32::from(c);+        let within = |&(a, b): &(u32, u32)| {+            if b < cp {+                std::cmp::Ordering::Less+            } else if a > cp {+                std::cmp::Ordering::Greater+            } else {+                std::cmp::Ordering::Equal+            }+        };+        self.coverage.get_or_init(|| face.coverage()).binary_search_by(within).is_ok()+    }+}++/// How the fonts a pass was laid out with draw a character.+#[derive(Clone, Copy, Debug, PartialEq)]+struct Drawn {+    /// A face ahead of the fallbacks draws it, or egui draws nothing for it.+    early: bool,+    /// Some face draws it.+    any: bool,+}++/// Read the fallbacks (in fallback order) the painted character `c` needs, given how the fonts+/// the pass was laid out with draw it, so that the same face draws `c` as when every fallback was+/// read at start-up. Returns whether a face was read.+///+/// - Nothing is read when a face ahead of the fallbacks draws `c`.+/// - Otherwise the first fallback that has `c` draws it: one already read, or one whose script has+///   `c`, which is read (in order, until one has `c`).+/// - A character no face draws reads the first fallback not read yet whose character map has it+///   (a symbol only an installed face has), and nothing when none has (a newer emoji). One that a+///   face after the fallbacks draws (the system Japanese face) is left to it.+fn plan(c: char, drawn: Drawn, fallbacks: &[&dyn Candidate]) -> bool {+    if drawn.early {+        return false;+    }+    let mut read_any = false;+    for f in fallbacks {+        if f.is_read() {+            if f.covers(c) {+                return read_any;+            }+        } else if f.in_script(c) && f.read() {+            read_any = true;+            if f.covers(c) {+                return true;+            }+        }+    }+    if !drawn.any+        && let Some(f) = fallbacks.iter().find(|f| !f.is_read() && !f.in_script(c) && f.might_cover(c))+    {+        read_any |= f.read();+    }+    read_any+}++/// How `fonts` draws `c` in `family`, `has_glyph` being egui's answer. `late` names the faces that+/// come after the fallbacks' place in a family (the fallbacks themselves, and faces added after+/// them). egui's `has_glyph` answers no for the characters of the face that supplies its+/// replacement glyph (the ◻ of Noto Emoji, or the `?` of the first face in a family without one),+/// so the faces' character maps are asked too.+fn classify(fonts: &FontDefinitions, family: &FontFamily, c: char, has_glyph: bool, late: &[&str]) -> Drawn {+    let Some(stack) = fonts.families.get(family) else { return Drawn { early: true, any: true } };+    let maps = |late_faces: bool| {+        stack+            .iter()+            .filter(|name| late.contains(&name.as_str()) == late_faces)+            .filter_map(|name| fonts.font_data.get(name))+            .any(|data| filmcraft_text::fonts::maps_char(&data.font, data.index, c))+    };+    let (early, later) = (maps(false), maps(true));+    Drawn { early: early || (has_glyph && !later), any: has_glyph || early || later }+}++/// Characters that never need a fallback: ASCII, Latin-1 and Latin Extended-A (which every+/// interface font has), spacing and controls (egui draws nothing for them).+fn plain(c: char) -> bool {+    u32::from(c) < 0x180 || c.is_whitespace() || c.is_control()+}++/// The characters looked at so far, per font family (families differ in their faces): nothing more+/// to read for them. A bit per character of the Basic Multilingual Plane, a set for the others.+#[derive(Default)]+struct Settled(HashMap<FontFamily, (Vec<u64>, HashSet<char>)>);++impl Settled {+    fn insert(&mut self, family: FontFamily, c: char) {+        let (bits, rest) = self.0.entry(family).or_insert_with(|| (vec![0; 1024], HashSet::new()));+        let cp = u32::from(c);+        match bits.get_mut((cp / 64) as usize) {+            Some(word) => *word |= 1 << (cp % 64),+            None => {+                rest.insert(c);+            }+        }+    }++    #[cfg(test)]+    fn contains(&self, family: &FontFamily, c: char) -> bool {+        self.0.get(family).is_some_and(|entry| settled_in(entry, c))+    }+}++fn settled_in((bits, rest): &(Vec<u64>, HashSet<char>), c: char) -> bool {+    let cp = u32::from(c);+    match bits.get((cp / 64) as usize) {+        Some(word) => word & (1 << (cp % 64)) != 0,+        None => rest.contains(&c),+    }+}++/// The characters (with the font family drawing them) in `shapes` that are not `settled`, each+/// once. Bounded: a pass with more new characters leaves the rest to the next pass.+fn new_characters(shapes: &[ClippedShape], settled: &Settled) -> Vec<(char, FontFamily)> {+    const MAX: usize = 4096;+    let mut seen = HashSet::new();+    let mut out = Vec::new();+    let mut stack: Vec<&Shape> = shapes.iter().map(|s| &s.shape).collect();+    while let Some(shape) = stack.pop() {+        match shape {+            Shape::Vec(v) => stack.extend(v.iter()),+            Shape::Text(t) => {+                let job = &t.galley.job;+                if job.text.is_ascii() {+                    continue;+                }+                for section in &job.sections {+                    let Some(text) = job.text.get(section.byte_range.start.0..section.byte_range.end.0) else { continue };+                    let family = &section.format.font_id.family;+                    let done = settled.0.get(family);+                    for c in text.chars().filter(|c| !plain(*c) && !done.is_some_and(|d| settled_in(d, *c))) {+                        if out.len() >= MAX {+                            return out;+                        }+                        if seen.insert((c, family.clone())) {+                            out.push((c, family.clone()));+                        }+                    }+                }+            }+            _ => {}+        }+    }+    out+}++/// The interface's fallbacks for scripts its own fonts lack, in fallback order: the order they+/// come in each font family (`theme::font_definitions`).+pub(crate) static FALLBACKS: [&Fallback; 4] = [&crate::cjk::CHINESE, &crate::cjk::KOREAN, &crate::rtl::ARABIC, &crate::rtl::HEBREW];++/// The egui plugin that reads [`FALLBACKS`] when painted text needs them (see the module docs).+/// `theme::install` adds it.+pub(crate) struct Watch {+    fallbacks: &'static [&'static Fallback],+    /// Installs the fonts again, with the fallbacks read so far.+    install: fn(&egui::Context),+    /// The faces that come after the fallbacks' place in a family (see [`classify`]).+    late: Vec<&'static str>,+    settled: Settled,+    /// When the last read started, to log how long the redone pass took.+    #[cfg(not(target_arch = "wasm32"))]+    started: Option<std::time::Instant>,+}++impl Watch {+    pub(crate) fn new() -> Self {+        let mut watch = Self::with(&FALLBACKS, reinstall);+        // the system Japanese face a Japanese interface adds after the fallbacks+        watch.late.push(crate::i18n::JAPANESE_FONT);+        watch+    }++    fn with(fallbacks: &'static [&'static Fallback], install: fn(&egui::Context)) -> Self {+        Self {+            fallbacks,+            install,+            late: fallbacks.iter().map(|f| f.name).collect(),+            settled: Settled::default(),+            #[cfg(not(target_arch = "wasm32"))]+            started: None,+        }+    }+}++impl egui::Plugin for Watch {+    fn debug_name(&self) -> &'static str {+        "filmcraft fallback fonts"+    }++    /// A pass a `Grid` or an area's sizing already had redone can still be redone once more for a+    /// face read in it, so its text doesn't show boxes for a frame.+    fn setup(&mut self, ctx: &egui::Context) {+        if let Some(three) = std::num::NonZeroUsize::new(3) {+            ctx.options_mut(|o| o.max_passes = o.max_passes.max(three));+        }+    }++    fn output_hook(&mut self, ctx: &egui::Context, output: &mut egui::FullOutput) {+        #[cfg(not(target_arch = "wasm32"))]+        if let Some(started) = self.started.take() {+            log::info!("fonts: the pass redone with the new fallback face took {:.1} ms in all", started.elapsed().as_secs_f64() * 1e3);+        }+        if self.fallbacks.iter().all(|f| f.settled()) {+            return;+        }+        let chars = new_characters(&output.shapes, &self.settled);+        if chars.is_empty() {+            return;+        }+        #[cfg(not(target_arch = "wasm32"))]+        let started = std::time::Instant::now();+        let candidates: Vec<&dyn Candidate> = self.fallbacks.iter().map(|f| *f as &dyn Candidate).collect();+        let mut read = false;+        for (c, family) in chars {+            // the fonts this pass was laid out with (new ones apply from the next pass)+            let late = &self.late;+            let drawn = ctx.fonts_mut(|f| {+                if !f.definitions().families.contains_key(&family) {+                    return Drawn { early: true, any: true }; // (painted text always has a family)+                }+                let has_glyph = f.has_glyph(&FontId::new(12.0, family.clone()), c);+                classify(f.definitions(), &family, c, has_glyph, late)+            });+            read |= plan(c, drawn, &candidates);+            self.settled.insert(family, c);+        }+        if !read {+            return;+        }+        (self.install)(ctx);+        #[cfg(not(target_arch = "wasm32"))]+        {+            let names: Vec<&str> = self.fallbacks.iter().filter(|f| f.is_read()).map(|f| f.name).collect();+            log::info!("fonts: read installed fallback faces {names:?} in {:.1} ms", started.elapsed().as_secs_f64() * 1e3);+            self.started = Some(started);+        }+        // Redo the pass with the new face before anything is shown. egui allows a limited number of+        // passes per frame: when this was the last, the next frame shows the glyphs.+        if output.platform_output.num_completed_passes < ctx.options(|o| o.max_passes.get()) {+            output.platform_output.request_discard_reasons.push(egui::RepaintCause::new_reason("read an installed fallback face"));+        } else {+            ctx.request_repaint();+        }+    }+}++/// Install the theme's fonts again with the fallbacks read so far, keeping faces added on top of+/// them (the system Japanese face, `i18n::install_japanese_font`) where they were: at the end of+/// their families.+fn reinstall(ctx: &egui::Context) {+    let mut fonts = crate::theme::font_definitions();+    let fallbacks: Vec<&str> = FALLBACKS.iter().map(|f| f.name).collect();+    keep_added(&mut fonts, &ctx.fonts_mut(|f| f.definitions().clone()), &fallbacks);+    ctx.set_fonts(fonts);+}++/// Add to `fonts` the faces `current` has that it lacks, at the end of the families they are in,+/// except `fallbacks`: where they go, and whether (a face both right-to-left fallbacks found is+/// installed once), is `fonts`' to say.+fn keep_added(fonts: &mut FontDefinitions, current: &FontDefinitions, fallbacks: &[&str]) {+    for (name, data) in &current.font_data {+        if fonts.font_data.contains_key(name) || fallbacks.contains(&name.as_str()) {+            continue;+        }+        fonts.font_data.insert(name.clone(), data.clone());+        for (family, stack) in &current.families {+            if stack.contains(name) {+                fonts.families.entry(family.clone()).or_default().push(name.clone());+            }+        }+    }+}++#[cfg(test)]+mod tests {+    use super::*;+    use std::cell::Cell;++    /// The installed fallbacks reach egui without a copy: every install hands egui the bytes the+    /// font database keeps, at the same address.+    #[test]+    fn installed_faces_are_borrowed_not_copied() {+        static INSTALLED: SystemFace = SystemFace::new("A", &["Arial", "Helvetica", "DejaVu Sans", "Segoe UI"]);+        let Some(face) = INSTALLED.face() else {+            eprintln!("SKIPPED: no installed Latin font");+            return;+        };+        let (Some(a), Some(b)) = (INSTALLED.font_data(), INSTALLED.font_data()) else {+            panic!("the installed face's file could not be read");+        };+        let Some(kept) = face.bytes() else { panic!("the database keeps no bytes for the face") };+        for data in [&a, &b] {+            assert!(matches!(data.font, Cow::Borrowed(_)), "egui must borrow the bytes, not own a copy");+            assert_eq!(data.font.as_ptr(), kept.as_ptr(), "egui must see the database's own bytes");+            assert_eq!(data.index, face.info.index);+        }+    }++    /// A fallback for [`plan`]: its script, what it covers, whether it is installed, and how often+    /// it was read.+    struct Fake {+        script: RangeInclusive<char>,+        covers: &'static str,+        installed: bool,+        read: Cell<bool>,+        reads: Cell<u32>,+    }++    impl Fake {+        fn new(script: RangeInclusive<char>, covers: &'static str) -> Self {+            Self { script, covers, installed: true, read: Cell::new(false), reads: Cell::new(0) }+        }+        fn read_already(self) -> Self {+            self.read.set(true);+            self+        }+    }++    impl Candidate for Fake {+        fn in_script(&self, c: char) -> bool {+            self.script.contains(&c)+        }+        fn is_read(&self) -> bool {+            self.read.get()+        }+        fn covers(&self, c: char) -> bool {+            self.read.get() && self.covers.contains(c)+        }+        fn read(&self) -> bool {+            if !self.installed {+                return false;+            }+            if !self.read.get() {+                self.reads.set(self.reads.get() + 1);+            }+            self.read.set(true);+            true+        }+        fn might_cover(&self, c: char) -> bool {+            self.installed && self.covers.contains(c)+        }+    }++    const HANGUL: RangeInclusive<char> = '\u{ac00}'..='\u{d7a3}';+    const HAN: RangeInclusive<char> = '\u{4e00}'..='\u{9fff}';++    /// Drawn by a face ahead of the fallbacks; by a face after them only; by none.+    const EARLY: Drawn = Drawn { early: true, any: true };+    const LATE: Drawn = Drawn { early: false, any: true };+    const NONE: Drawn = Drawn { early: false, any: false };++    #[test]+    fn text_the_fonts_draw_reads_nothing() {+        let korean = Fake::new(HANGUL, "한국");+        assert!(!plan('Ж', EARLY, &[&korean]));+        // Hangul the craft-fonts' Chinese face already draws, ahead of the Korean fallback+        assert!(!plan('한', EARLY, &[&korean]));+        assert_eq!(korean.reads.get(), 0);+    }++    #[test]+    fn text_nothing_draws_reads_its_scripts_face() {+        let chinese = Fake::new(HAN, "中文");+        let korean = Fake::new(HANGUL, "한국");+        assert!(plan('한', NONE, &[&chinese, &korean]));+        assert_eq!((chinese.reads.get(), korean.reads.get()), (0, 1), "only the Korean face");+        // once read, the face draws the rest of the script: nothing more to read+        assert!(!plan('국', NONE, &[&chinese, &korean]));+        assert_eq!((chinese.reads.get(), korean.reads.get()), (0, 1));+    }++    #[test]+    fn faces_for_a_script_are_read_in_order_until_one_covers_the_character() {+        let sparse = Fake::new(HAN, "中");+        let full = Fake::new(HAN, "中文");+        let after = Fake::new(HAN, "中文");+        assert!(plan('文', NONE, &[&sparse, &full, &after]));+        assert_eq!((sparse.reads.get(), full.reads.get(), after.reads.get()), (1, 1, 0));+    }++    /// The fallback order decides which face draws a character: when only a face that comes later+    /// draws it (a fallback read for its own script, or the system Japanese face added after the+    /// fallbacks), the earlier one is read so it draws it, as it did when every face was read at+    /// start-up.+    #[test]+    fn an_earlier_face_is_read_when_a_later_one_draws_its_script() {+        let chinese = Fake::new(HAN, "中文");+        let korean = Fake::new(HANGUL, "한국中").read_already();+        assert!(plan('中', LATE, &[&chinese, &korean]));+        assert_eq!(chinese.reads.get(), 1);+        let chinese = Fake::new(HAN, "中文");+        assert!(plan('中', LATE, &[&chinese]), "drawn by a face added after the fallbacks");+        // the other way round, nothing is read: the earlier face already wins+        let chinese = Fake::new(HAN, "中文").read_already();+        let korean = Fake::new(HANGUL, "한국中");+        assert!(!plan('中', LATE, &[&chinese, &korean]));+        assert_eq!(korean.reads.get(), 0);+    }++    /// A character in no fallback's script that nothing draws reads the face that has it (a symbol+    /// only an installed face has), and nothing when no face has it (a newer emoji, Ethiopic) or+    /// when a face after the fallbacks draws it.+    #[test]+    fn a_character_in_no_script_reads_only_the_face_that_has_it() {+        let chinese = Fake::new(HAN, "中");+        let korean = Fake::new(HANGUL, "한⇢↲");+        let hebrew = Fake::new('\u{5d0}'..='\u{5ea}', "א");+        assert!(!plan('↲', LATE, &[&chinese, &korean, &hebrew]));+        assert!(plan('⇢', NONE, &[&chinese, &korean, &hebrew]));+        assert_eq!((chinese.reads.get(), korean.reads.get(), hebrew.reads.get()), (0, 1, 0));+        assert!(!plan('\u{1fae0}', NONE, &[&chinese, &korean, &hebrew]));+        assert!(!plan('\u{1200}', NONE, &[&chinese, &korean, &hebrew]));+        assert_eq!((chinese.reads.get(), korean.reads.get(), hebrew.reads.get()), (0, 1, 0), "nothing has them: nothing read");+    }++    #[test]+    fn a_face_that_is_not_installed_is_skipped() {+        let mut missing = Fake::new(HANGUL, "한");+        missing.installed = false;+        let other = Fake::new(HAN, "中");+        assert!(!plan('한', NONE, &[&missing]));+        assert!(plan('中', NONE, &[&missing, &other]));+        assert_eq!(other.reads.get(), 1);+    }++    /// Characters are settled per font family: one drawn in a family whose faces have it may still+    /// need a fallback in another (a ★ Noto Emoji draws in the proportional family, but not in+    /// the semibold one, which has no Noto Emoji).+    #[test]+    fn characters_are_settled_per_family() {+        let semibold = FontFamily::Name("semibold".into());+        let mut settled = Settled::default();+        settled.insert(FontFamily::Proportional, '★');+        settled.insert(FontFamily::Proportional, '\u{1fae0}');+        assert!(settled.contains(&FontFamily::Proportional, '★'));+        assert!(settled.contains(&FontFamily::Proportional, '\u{1fae0}'));+        assert!(!settled.contains(&semibold, '★'));+        assert!(!settled.contains(&FontFamily::Proportional, '☆'));+    }++    #[test]+    fn only_new_characters_are_collected() {+        let ctx = egui::Context::default();+        ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();+        let font = FontId::proportional(12.0);+        let galley = ctx.fonts_mut(|f| f.layout_no_wrap("Hello DŽ ɕ 한국 한 ñ".into(), font.clone(), egui::Color32::WHITE));+        let text = Shape::galley(egui::Pos2::ZERO, galley, egui::Color32::WHITE);+        let shapes = vec![ClippedShape { clip_rect: egui::Rect::EVERYTHING, shape: Shape::Vec(vec![text]) }];+        let chars: Vec<char> = new_characters(&shapes, &Settled::default()).into_iter().map(|(c, _)| c).collect();+        assert_eq!(chars, ['DŽ', 'ɕ', '한', '국'], "each new character once; ASCII, Latin-1 and spaces skipped");+        let mut settled = Settled::default();+        settled.insert(FontFamily::Proportional, '한');+        let chars: Vec<char> = new_characters(&shapes, &settled).into_iter().map(|(c, _)| c).collect();+        assert_eq!(chars, ['DŽ', 'ɕ', '국']);+    }++    /// egui's `has_glyph` answers no for what Noto Emoji (the face of its replacement glyph)+    /// draws: an emoji counts as drawn anyway, by a face ahead of the fallbacks unless it is a+    /// fallback that draws it.+    #[test]+    fn classify_finds_the_face_that_draws_a_character() {+        let fonts = FontDefinitions::default();+        let p = FontFamily::Proportional;+        assert_eq!(classify(&fonts, &p, '\u{1f3ac}', false, &[]), Drawn { early: true, any: true }, "🎬 is in Noto Emoji");+        let all_late = ["Ubuntu-Light", "NotoEmoji-Regular", "emoji-icon-font"];+        assert_eq!(classify(&fonts, &p, '\u{1f3ac}', false, &all_late), Drawn { early: false, any: true }, "drawn, but only by late faces");+        assert_eq!(classify(&fonts, &p, '한', false, &[]), Drawn { early: false, any: false });+        assert_eq!(classify(&fonts, &p, '\u{200d}', true, &[]), Drawn { early: true, any: true }, "egui draws nothing for a joiner");+        assert_eq!(classify(&fonts, &FontFamily::Name("none".into()), 'A', false, &[]), Drawn { early: true, any: true });+    }++    /// Cyrillic Supplement: the interface fonts lack it and the bundled Noto Serif has it, so Noto+    /// Serif stands in for an installed face.+    #[allow(clippy::single_range_in_vec_init)] // a list of one range+    const SUPPLEMENT: &[RangeInclusive<char>] = &['\u{500}'..='\u{52f}'];++    fn noto_serif() -> Arc<Face> {+        let ids = filmcraft_text::fonts::add_font_data(filmcraft_text::fonts::NOTO_SERIF_REGULAR.to_vec());+        filmcraft_text::fonts::face(ids[0])+    }++    fn yes() -> bool {+        true+    }++    /// Inter and egui's own fonts, then `fallbacks` once read: the theme's fonts without the+    /// installed faces other tests may have read.+    fn install_with(ctx: &egui::Context, fallbacks: &[&'static Fallback]) {+        let mut fonts = FontDefinitions::default();+        fonts.font_data.insert("inter".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::INTER_REGULAR)));+        fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "inter".into());+        for f in fallbacks {+            if let Some(data) = f.font_data() {+                fonts.font_data.insert(f.name.into(), data);+                for stack in fonts.families.values_mut() {+                    stack.push(f.name.into());+                }+            }+        }+        ctx.set_fonts(fonts);+    }++    /// One frame showing `text` in a label: how many passes it took, and whether every glyph of+    /// the label it shows is real (not the missing-glyph box).+    fn frame(ctx: &egui::Context, text: &str) -> (usize, bool) {+        let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {+            ui.label(text);+        });+        out.textures_delta.clear();+        let galley = out.shapes.iter().find_map(|s| match &s.shape {+            Shape::Text(t) if t.galley.job.text == text => Some(t.galley.clone()),+            _ => None,+        });+        let Some(galley) = galley else { panic!("the label is painted") };+        let font = galley.job.sections[0].format.font_id.clone();+        let missing = ctx.fonts_mut(|f| f.layout_no_wrap("\u{10ffff}".into(), font, egui::Color32::WHITE).rows[0].glyphs[0].uv_rect);+        let real = galley.rows.iter().flat_map(|r| &r.glyphs).filter(|g| g.chr != ' ').all(|g| g.uv_rect != missing && !g.uv_rect.is_nothing());+        (out.platform_output.num_completed_passes, real)+    }++    /// The first frame that shows text in a fallback's script already draws it: the face is read+    /// during that frame and egui redoes the pass with it before anything is shown.+    #[test]+    fn the_first_frame_draws_text_whose_face_was_not_read() {+        static FACE: Fallback = Fallback::new("test-supplement", "Ԁ", &[], SUPPLEMENT, yes);+        static ALL: [&Fallback; 1] = [&FACE];+        fn install(ctx: &egui::Context) {+            install_with(ctx, &ALL);+        }+        FACE.use_face(noto_serif());+        let ctx = egui::Context::default();+        install(&ctx);+        ctx.add_plugin(Watch::with(&ALL, install));+        assert!(!FACE.is_read(), "nothing read before text needs it");+        let (passes, real) = frame(&ctx, "Ԁԁ Ԃԃ");+        assert!(FACE.is_read());+        assert_eq!(passes, 2, "the pass is redone with the face");+        assert!(real, "the first frame shows real glyphs");+        assert_eq!(frame(&ctx, "Ԁԁ Ԃԃ"), (1, true), "nothing more to read");+    }++    #[test]+    fn text_the_interface_fonts_draw_reads_no_face() {+        static FACE: Fallback = Fallback::new("test-supplement", "Ԁ", &[], SUPPLEMENT, yes);+        static ALL: [&Fallback; 1] = [&FACE];+        fn install(ctx: &egui::Context) {+            install_with(ctx, &ALL);+        }+        FACE.use_face(noto_serif());+        let ctx = egui::Context::default();+        install(&ctx);+        ctx.add_plugin(Watch::with(&ALL, install));+        assert_eq!(frame(&ctx, "Timeline Привет Ελληνικά 🎬"), (1, true));+        assert!(!FACE.is_read());+    }++    /// A symbol in no fallback's script that only a face not read yet has (Noto Serif's archaic+    /// Greek here; Apple SD Gothic Neo's arrows in the app) still draws on its first frame.+    #[test]+    fn a_character_only_an_unread_face_has_reads_it() {+        static FACE: Fallback = Fallback::new("test-supplement", "Ԁ", &[], SUPPLEMENT, yes);+        static ALL: [&Fallback; 1] = [&FACE];+        fn install(ctx: &egui::Context) {+            install_with(ctx, &ALL);+        }+        FACE.use_face(noto_serif());+        let ctx = egui::Context::default();+        install(&ctx);+        ctx.add_plugin(Watch::with(&ALL, install));+        assert_eq!(frame(&ctx, "Ϙϙ"), (2, true));+        assert!(FACE.is_read());+    }++    /// A character no face has (a newer emoji than Noto Emoji's, Ethiopic here) reads nothing: it+    /// draws as a box as it always did, and the faces stay unread.+    #[test]+    fn a_character_no_face_has_reads_nothing() {+        static FACE: Fallback = Fallback::new("test-supplement", "Ԁ", &[], SUPPLEMENT, yes);+        static ALL: [&Fallback; 1] = [&FACE];+        fn install(ctx: &egui::Context) {+            install_with(ctx, &ALL);+        }+        FACE.use_face(noto_serif());+        let ctx = egui::Context::default();+        install(&ctx);+        ctx.add_plugin(Watch::with(&ALL, install));+        assert_eq!(frame(&ctx, "ሀሁ").0, 1);+        assert!(!FACE.is_read());+    }++    #[test]+    fn faces_added_on_top_of_the_theme_stay() {+        let mut current = FontDefinitions::default();+        current.font_data.insert("system-japanese".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::INTER_REGULAR)));+        current.families.entry(FontFamily::Proportional).or_default().push("system-japanese".into());+        let mut fonts = FontDefinitions::default();+        current.font_data.insert("system-hebr".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::INTER_REGULAR)));+        keep_added(&mut fonts, &current, &["system-hebr"]);+        assert!(!fonts.font_data.contains_key("system-hebr"), "a fallback's place is the theme's to say");+        assert!(fonts.font_data.contains_key("system-japanese"));+        assert_eq!(fonts.families[&FontFamily::Proportional].last().map(String::as_str), Some("system-japanese"));+        assert!(!fonts.families[&FontFamily::Monospace].iter().any(|n| n == "system-japanese"));+    }+}diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs--- a/crates/ui-egui/src/i18n/mod.rs+++ b/crates/ui-egui/src/i18n/mod.rs@@ -17,9 +17,9 @@ //! a font already installed on the system ([`system_japanese_font`]); with neither, switching to //! Japanese is refused with a message. //!-//! Simplified Chinese uses the Chinese fallback `theme::install` always adds (craft-fonts Hans/Hant-//! faces, otherwise one installed system face, see `cjk.rs`); without one, switching to Chinese is-//! refused the same way ([`chinese_font_available`]).+//! Simplified Chinese uses the Chinese fallback of `cjk.rs` (craft-fonts Hans/Hant faces, otherwise+//! one installed system face, read the first time Chinese is painted); without one, switching to+//! Chinese is refused the same way ([`chinese_font_available`]).  mod catalog; @@ -270,28 +270,15 @@ const PREFERRED_JAPANESE: &[&str] = &[     "VL Gothic", ]; -const JAPANESE_FONT: &str = "system-japanese";+pub(crate) const JAPANESE_FONT: &str = "system-japanese";  /// A Japanese font already installed on this system, for the interface (none is bundled). Looked up /// once per process: the system font folders are scanned on first use (name tables only), then the-/// chosen face's file is read. `None` on the web and on systems without a Japanese font.+/// chosen face's file is read into the font database, whose bytes egui borrows (no copy; see+/// `fallback_fonts`). `None` on the web and on systems without a Japanese font. pub fn system_japanese_font() -> Option<Arc<egui::FontData>> {-    static FONT: OnceLock<Option<Arc<egui::FontData>>> = OnceLock::new();-    FONT.get_or_init(|| {-        filmcraft_text::fonts::scan_system();-        let faces: Vec<_> = filmcraft_text::fonts::all_faces().into_iter().filter(|f| f.info.origin == "system" && !f.info.italic).collect();-        let covers = |f: &filmcraft_text::fonts::Face| f.covers_text(JAPANESE_SAMPLE);-        // within a family, the face closest to regular weight-        let by_weight = |f: &&Arc<filmcraft_text::fonts::Face>| f.info.weight.abs_diff(400);-        let preferred =-            PREFERRED_JAPANESE.iter().find_map(|name| faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(name) && covers(f)).min_by_key(by_weight));-        let face = preferred.or_else(|| faces.iter().filter(|f| covers(f)).min_by_key(by_weight))?;-        // kept for the life of the process (one font, read once) so installing it again after a-        // theme change shares the bytes instead of copying the whole file-        let bytes: &'static [u8] = Box::leak(face.data()?.into_boxed_slice());-        Some(Arc::new(egui::FontData { font: std::borrow::Cow::Borrowed(bytes), index: face.info.index, tweak: Default::default() }))-    })-    .clone()+    static FONT: crate::fallback_fonts::SystemFace = crate::fallback_fonts::SystemFace::new(JAPANESE_SAMPLE, PREFERRED_JAPANESE);+    FONT.font_data() }  /// Whether the craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`) supplies a@@ -322,16 +309,17 @@ pub fn install_japanese_font(ctx: &egui::Context) -> bool {     true } -/// Whether the interface can show Simplified Chinese: `theme::install` always adds a Chinese-/// fallback face when there is one (craft-fonts Hans/Hant faces, otherwise an installed system-/// face, see `cjk.rs`), so nothing needs installing here. False on systems without either.+/// Whether the interface can show Simplified Chinese: there is a Chinese fallback face (craft-fonts+/// Hans/Hant faces, which `theme::install` adds, otherwise an installed system face, read the first+/// time Chinese is painted; see `cjk.rs`), so nothing needs installing here. False on systems+/// without either. pub fn chinese_font_available() -> bool {     crate::cjk::available() } -/// Whether the interface can show Korean: `theme::install` adds an installed system Hangul face-/// (Malgun Gothic, Apple SD Gothic Neo, Noto Sans CJK KR…, see `cjk.rs`) when there is one; none-/// is bundled. False on systems (and the web) without one.+/// Whether the interface can show Korean: there is an installed system Hangul face (Malgun Gothic,+/// Apple SD Gothic Neo, Noto Sans CJK KR…, see `cjk.rs`), read the first time Hangul is painted;+/// none is bundled. False on systems (and the web) without one. pub fn korean_font_available() -> bool {     crate::cjk::korean_available() }diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -39,6 +39,7 @@ pub mod credits; pub mod dock; mod dock_shared; mod dock_ui;+mod fallback_fonts; /// Thumbnail textures kept before the least recently used are freed (each is up to 320 px wide). const MAX_THUMB_TEXTURES: usize = 512; diff --git a/crates/ui-egui/src/rtl.rs b/crates/ui-egui/src/rtl.rs--- a/crates/ui-egui/src/rtl.rs+++ b/crates/ui-egui/src/rtl.rs@@ -1,8 +1,9 @@ //! Right-to-left scripts (Arabic, Hebrew) in the interface: fallback fonts and reading order. //! //! Fonts: like [`crate::cjk`], the craft-fonts faces for these scripts (`Arab`, `Hebr`) become the-//! last fallbacks of every UI font family. A build without them looks up one installed face per-//! script once (Noto Sans Arabic, SF Arabic, Segoe UI…) and reuses it across theme changes.+//! last fallbacks of every UI font family. A build without them uses one installed face per script+//! (Noto Sans Arabic, SF Arabic, Segoe UI…), read the first time the interface paints text in it+//! ([`crate::fallback_fonts`]). //! //! Reading order: egui shapes each font run with harfrust, so a word's letters join and run right //! to left, but it places the runs left to right in logical order with no bidi pass: in@@ -13,90 +14,66 @@ //! shaper draws right to left. Text being edited keeps egui's logical order: `TextEdit` maps the //! caret by character index, so a reordered galley would put it in the wrong place. -use std::borrow::Cow; use std::ops::Range;-use std::sync::{Arc, OnceLock};+use std::sync::Arc;  use egui::epaint::text::{ByteIndex, LayoutJob, LayoutSection, TextFormat}; use egui::{Color32, FontData, FontDefinitions, FontId, Galley, Painter, Rect, Vec2, pos2}; use filmcraft_text::fonts::{CraftFont, Face}; use unicode_bidi::{BidiClass, BidiInfo, bidi_class}; -/// A right-to-left script: its ISO 15924 code (as in the craft-fonts manifest), letters a face-/// must cover, and installed families to prefer, best first.-struct Script {-    code: &'static str,-    sample: &'static str,-    preferred: &'static [&'static str],-}+use crate::fallback_fonts::Fallback; -const SCRIPTS: &[Script] = &[-    Script {-        code: "Arab",-        sample: "ابتثجحخدذرزسشصضطظعغفقكلمنهويءآأؤإئةى،؟",-        preferred: &[-            "Noto Sans Arabic UI",-            "Noto Sans Arabic",-            "SF Arabic",-            "Geeza Pro",-            "Segoe UI",-            "Noto Naskh Arabic UI",-            "Noto Naskh Arabic",-            "Tahoma",-            "Arial",-            "DejaVu Sans",-        ],-    },-    Script {-        code: "Hebr",-        sample: "אבגדהוזחטיכךלמםנןסעפףצץקרשת",-        preferred: &["Noto Sans Hebrew", "SF Hebrew", "Arial Hebrew", "Segoe UI", "Arial", "Tahoma", "DejaVu Sans"],-    },-];+/// The right-to-left scripts, by ISO 15924 code (as in the craft-fonts manifest).+const CODES: [&str; 2] = ["Arab", "Hebr"];  /// The craft-fonts faces for right-to-left scripts (empty unless built with `CRAFT_FONTS_DIR`). pub(crate) fn craft_fonts() -> impl Iterator<Item = &'static CraftFont> {-    filmcraft_text::fonts::CRAFT_FONTS.iter().filter(|f| f.scripts.iter().any(|s| SCRIPTS.iter().any(|x| x.code == *s)))+    filmcraft_text::fonts::CRAFT_FONTS.iter().filter(|f| f.scripts.iter().any(|s| CODES.contains(s))) } -/// Installed faces for the scripts craft-fonts does not cover, looked up once per process (native-/// builds only). A face that covers several scripts (Segoe UI) is added once.-fn system_fonts() -> &'static [(String, Arc<FontData>)] {-    static FONTS: OnceLock<Vec<(String, Arc<FontData>)>> = OnceLock::new();-    FONTS.get_or_init(|| {-        if cfg!(target_arch = "wasm32") {-            return Vec::new();-        }-        let missing: Vec<&Script> = SCRIPTS.iter().filter(|s| !craft_fonts().any(|f| f.scripts.contains(&s.code))).collect();-        if missing.is_empty() {-            return Vec::new();-        }-        filmcraft_text::fonts::scan_system();-        let faces: Vec<_> = filmcraft_text::fonts::all_faces().into_iter().filter(|f| f.info.origin == "system" && !f.info.italic).collect();-        let by_weight = |f: &&Arc<Face>| f.info.weight.abs_diff(400);-        let mut chosen: Vec<&Arc<Face>> = Vec::new();-        let mut out = Vec::new();-        for s in missing {-            if chosen.iter().any(|f| f.covers_text(s.sample)) {-                continue;-            }-            let covers = |f: &&Arc<Face>| f.covers_text(s.sample);-            let preferred =-                s.preferred.iter().find_map(|name| faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(name)).filter(covers).min_by_key(by_weight));-            let Some(face) = preferred.or_else(|| faces.iter().filter(covers).min_by_key(by_weight)) else {-                continue;-            };-            let Some(data) = face.data() else {-                continue;-            };-            let font = FontData { font: Cow::Owned(data), index: face.info.index, tweak: Default::default() };-            out.push((format!("system-{}", s.code.to_lowercase()), Arc::new(font)));-            chosen.push(face);-        }-        out-    })+fn without_craft(code: &str) -> bool {+    !craft_fonts().any(|f| f.scripts.contains(&code))+}++fn without_craft_arabic() -> bool {+    without_craft("Arab") } +fn without_craft_hebrew() -> bool {+    without_craft("Hebr")+}++/// The installed Arabic face, for builds without the craft-fonts `Arab` face: Arabic, its+/// supplements and presentation forms.+pub(crate) static ARABIC: Fallback = Fallback::new(+    "system-arab",+    "ابتثجحخدذرزسشصضطظعغفقكلمنهويءآأؤإئةى،؟",+    &[+        "Noto Sans Arabic UI",+        "Noto Sans Arabic",+        "SF Arabic",+        "Geeza Pro",+        "Segoe UI",+        "Noto Naskh Arabic UI",+        "Noto Naskh Arabic",+        "Tahoma",+        "Arial",+        "DejaVu Sans",+    ],+    &['\u{600}'..='\u{6ff}', '\u{750}'..='\u{77f}', '\u{870}'..='\u{8ff}', '\u{fb50}'..='\u{fdff}', '\u{fe70}'..='\u{fefc}', '\u{1ee00}'..='\u{1eeff}'],+    without_craft_arabic,+);++/// The installed Hebrew face (craft-fonts has none).+pub(crate) static HEBREW: Fallback = Fallback::new(+    "system-hebr",+    "אבגדהוזחטיכךלמםנןסעפףצץקרשת",+    &["Noto Sans Hebrew", "SF Hebrew", "Arial Hebrew", "Segoe UI", "Arial", "Tahoma", "DejaVu Sans"],+    &['\u{590}'..='\u{5ff}', '\u{fb1d}'..='\u{fb4f}'],+    without_craft_hebrew,+);+ /// Append the right-to-left faces (craft-fonts, else installed ones) after the other fallbacks of /// every font family. Their letters are in no other UI font, so their place among the fallbacks /// does not matter, and Latin text keeps the interface fonts. Installed faces come last, after@@ -109,9 +86,17 @@ pub(crate) fn install(fonts: &mut FontDefinitions) {         fonts.font_data.insert(name.clone(), Arc::new(FontData::from_static(f.bytes)));         names.push(name);     }-    for (name, font) in system_fonts() {-        fonts.font_data.insert(name.clone(), font.clone());-        names.push(name.clone());+    // installed faces once read ([`crate::fallback_fonts`]); a face that covers both scripts+    // (Segoe UI) is added once+    let mut added: Vec<&Arc<Face>> = Vec::new();+    for fallback in [&ARABIC, &HEBREW] {+        let (Some(font), Some(face)) = (fallback.font_data(), fallback.face.face()) else { continue };+        if added.iter().any(|f| Arc::ptr_eq(f, face)) {+            continue;+        }+        added.push(face);+        fonts.font_data.insert(fallback.name.into(), font);+        names.push(fallback.name.to_string());     }     for stack in fonts.families.values_mut() {         for name in &names {@@ -125,7 +110,7 @@ pub(crate) fn install(fonts: &mut FontDefinitions) { /// Whether `name` is an installed right-to-left face added by [`install`]. #[cfg(test)] pub(crate) fn is_system_face(name: &str) -> bool {-    system_fonts().iter().any(|(n, _)| n == name)+    [&ARABIC, &HEBREW].iter().any(|f| f.name == name) }  /// Whether `text` has a right-to-left letter (Arabic, Hebrew…), the only text [`Lines`] reorders.@@ -369,8 +354,10 @@ mod tests {     fn lines_draw_arabic_in_reading_order() {         let ctx = egui::Context::default();         let mut fonts = FontDefinitions::default();+        // read as when the interface first paints Arabic+        let installed = ARABIC.read();         install(&mut fonts);-        if !craft_fonts().any(|f| f.scripts.contains(&"Arab")) && !system_fonts().iter().any(|(name, _)| name == "system-arab") {+        if !craft_fonts().any(|f| f.scripts.contains(&"Arab")) && !installed {             eprintln!("SKIPPED: no craft-fonts or installed Arabic font");             return;         }diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs--- a/crates/ui-egui/src/theme.rs+++ b/crates/ui-egui/src/theme.rs@@ -416,8 +416,17 @@ pub fn font_families() -> Vec<FontFamily> {  /// Install the Latin UI fonts, Chinese fallback (craft-fonts or an installed face), Japanese /// craft-fonts, Arabic and Hebrew fallbacks (craft-fonts or installed faces, [`crate::rtl`]) and-/// egui visuals. Document names keep their glyphs in every interface language.+/// egui visuals. Document names keep their glyphs in every interface language: installed faces+/// are read the first time the interface paints text that needs them ([`crate::fallback_fonts`]). pub fn install(ctx: &egui::Context, t: &Tokens) {+    ctx.set_fonts(font_definitions());+    ctx.add_plugin(crate::fallback_fonts::Watch::new());+    apply_visuals(ctx, t);+}++/// The interface fonts [`install`] sets: the Latin UI fonts, the craft-fonts, and the installed+/// fallback faces read so far.+pub(crate) fn font_definitions() -> FontDefinitions {     let mut fonts = FontDefinitions::default();     fonts.font_data.insert("inter".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::INTER_REGULAR)));     fonts.font_data.insert("inter-medium".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::INTER_MEDIUM)));@@ -432,8 +441,7 @@ pub fn install(ctx: &egui::Context, t: &Tokens) {     add_craft_fonts(&mut fonts);     crate::cjk::install(&mut fonts);     crate::rtl::install(&mut fonts);-    ctx.set_fonts(fonts);-    apply_visuals(ctx, t);+    fonts }  /// Name of the egui font for a craft-fonts entry.@@ -602,67 +610,88 @@ mod tests {         assert!(v(t.playhead) > v(pink) && v(t.timecode) > v(pink) && v(t.track_target) < v(pink), "lighter and darker as the default blues are");     } -    /// Chinese media and track names must render even when the interface stays in English.-    /// Compare the rasterized glyphs with the missing-glyph box, including custom weight families.+    /// Paint `text` in every font family in one frame and assert the frame shows real glyphs, not+    /// the missing-glyph box: installed fallback faces are read during that frame, so this is the+    /// text's first appearance.+    fn assert_first_frame_draws(ctx: &egui::Context, text: &str, what: &str) {+        let fonts: Vec<FontId> = font_families().into_iter().map(|f| FontId::new(13.0, f)).collect();+        let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {+            for font in &fonts {+                ui.label(egui::RichText::new(text).font(font.clone()));+            }+        });+        out.textures_delta.clear();+        let galleys: Vec<Arc<egui::Galley>> = out+            .shapes+            .iter()+            .filter_map(|s| match &s.shape {+                egui::Shape::Text(t) if t.galley.job.text == text => Some(t.galley.clone()),+                _ => None,+            })+            .collect();+        assert_eq!(galleys.len(), fonts.len(), "{what}: one label per family");+        for galley in galleys {+            let Some(font) = galley.job.sections.first().map(|s| s.format.font_id.clone()) else { continue };+            // U+10FFFF is in no font: the face egui draws missing glyphs with+            let missing = ctx.fonts_mut(|f| f.layout_no_wrap("\u{10ffff}".into(), font.clone(), Color32::WHITE).rows[0].glyphs[0].uv_rect);+            let drawn: Vec<_> = galley.rows.iter().flat_map(|r| &r.glyphs).filter(|g| !g.uv_rect.is_nothing()).collect();+            // right-to-left words are shaped (letters joined) and padded with empty glyphs; every+            // other character draws one glyph of its own+            if !crate::rtl::has_rtl(text) {+                assert_eq!(drawn.len(), text.chars().filter(|c| *c != ' ').count(), "{what}: an empty glyph in {font:?}");+            }+            assert!(!drawn.is_empty(), "{what}: nothing drawn in {font:?}");+            for glyph in drawn {+                assert_ne!(glyph.uv_rect, missing, "{what}: missing {} in {font:?} on the first frame", glyph.chr);+            }+        }+        ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();+    }++    /// Chinese media and track names must render even when the interface stays in English, from the+    /// first frame that shows them, in every theme and font family (custom weights included).     #[test]     fn chinese_names_render_in_every_theme_and_font_family() {         const CHINESE: &str = "中文旁白音乐声轨测试简体汉语繁體漢語车";-        filmcraft_text::fonts::scan_system();-        let available = filmcraft_text::fonts::all_faces()-            .iter()-            .any(|f| matches!(f.info.origin, "system" | filmcraft_text::fonts::CRAFT_ORIGIN) && f.covers_text(CHINESE));-        if !available {+        if !crate::cjk::available() {             eprintln!("SKIPPED: no craft-fonts or installed Chinese font");             return;         }-        let ctx = egui::Context::default();         for kind in [ThemeKind::Dark, ThemeKind::Medium, ThemeKind::Light] {+            let ctx = egui::Context::default();             install(&ctx, &Tokens::for_kind(kind));-            ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();-            ctx.fonts_mut(|fonts| {-                for family in font_families() {-                    let font = FontId::new(13.0, family);-                    let missing = fonts.layout_no_wrap("\u{fffd}".into(), font.clone(), Color32::WHITE);-                    let missing_uv = missing.rows[0].glyphs[0].uv_rect;-                    let names = fonts.layout_no_wrap(CHINESE.into(), font.clone(), Color32::WHITE);-                    assert_eq!(names.rows.iter().map(|r| r.glyphs.len()).sum::<usize>(), CHINESE.chars().count());-                    for glyph in names.rows.iter().flat_map(|r| &r.glyphs) {-                        assert!(!glyph.uv_rect.is_nothing(), "empty {} in {font:?}", glyph.chr);-                        assert_ne!(glyph.uv_rect, missing_uv, "missing {} in {kind:?} / {font:?}", glyph.chr);-                    }-                }-            });+            assert_first_frame_draws(&ctx, CHINESE, &format!("{kind:?}"));         }-        // the layouts above put glyphs in the atlas: take that update so it isn't dropped unapplied-        ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();     } -    /// Typed, pasted or IME-composed Hangul draws real glyphs, not the missing-glyph box (artcraft#2093).+    /// Typed, pasted or IME-composed Hangul draws real glyphs, not the missing-glyph box+    /// (artcraft#2093), from the first frame that shows it.     #[test]     fn korean_text_renders_with_an_installed_korean_font() {         const KOREAN: &str = "한국어 입력 테스트";-        filmcraft_text::fonts::scan_system();-        // `covers_text`, not `has_char`: probing keeps no font file loaded (see `cjk::find_system_font`)-        let available = filmcraft_text::fonts::all_faces().iter().any(|f| f.info.origin == "system" && f.covers_text(KOREAN));-        if !available {+        if !crate::cjk::korean_available() {             eprintln!("SKIPPED: no installed Korean font");             return;         }         let ctx = egui::Context::default();         install(&ctx, &Tokens::for_kind(ThemeKind::Dark));-        ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();-        ctx.fonts_mut(|fonts| {-            for family in font_families() {-                let font = FontId::new(13.0, family);-                let missing = fonts.layout_no_wrap("\u{fffd}".into(), font.clone(), Color32::WHITE);-                let missing_uv = missing.rows[0].glyphs[0].uv_rect;-                let text = fonts.layout_no_wrap(KOREAN.into(), font.clone(), Color32::WHITE);-                for glyph in text.rows.iter().flat_map(|r| &r.glyphs).filter(|g| g.chr != ' ') {-                    assert_ne!(glyph.uv_rect, missing_uv, "missing {} in {font:?}", glyph.chr);-                }+        assert_first_frame_draws(&ctx, KOREAN, "Hangul");+    }++    /// Arabic and Hebrew names draw from the first frame too (craft-fonts' Arabic, or installed faces).+    #[test]+    fn right_to_left_names_render_on_the_first_frame() {+        let arabic = crate::rtl::craft_fonts().any(|f| f.scripts.contains(&"Arab")) || crate::rtl::ARABIC.face.face().is_some();+        let hebrew = crate::rtl::craft_fonts().any(|f| f.scripts.contains(&"Hebr")) || crate::rtl::HEBREW.face.face().is_some();+        for (text, available) in [("طبقةمرحبا", arabic), ("שכבה", hebrew)] {+            if !available {+                eprintln!("SKIPPED: no font for {text}");+                continue;             }-        });-        ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();+            let ctx = egui::Context::default();+            install(&ctx, &Tokens::for_kind(ThemeKind::Dark));+            assert_first_frame_draws(&ctx, text, text);+        }     }      /// Built with craft-fonts: every font family ends with the Japanese faces (BIZ UDPGothic@@ -677,9 +706,9 @@ mod tests {         ctx.fonts_mut(|fonts| {             let defs = fonts.definitions().clone();             for (family, stack) in &defs.families {-                // installed faces come after the craft run: right-to-left ones (Hebrew is not in-                // craft-fonts) and the Hangul face (none is in craft-fonts)-                let stack: Vec<&String> = stack.iter().filter(|n| !crate::rtl::is_system_face(n) && n.as_str() != crate::cjk::KOREAN_FACE).collect();+                // installed faces come after the craft run (Hebrew and Hangul are not in craft-fonts),+                // once read+                let stack: Vec<&String> = stack.iter().filter(|n| !crate::fallback_fonts::FALLBACKS.iter().any(|f| f.name == n.as_str())).collect();                 let first = stack.iter().position(|n| n.starts_with("craft:")).unwrap_or(stack.len());                 assert!(first > 0 && stack[first..].iter().all(|n| n.starts_with("craft:")), "{family:?}: {stack:?}");                 let first_japanese = stack.iter().find(|n| n.starts_with("craft:BIZ") || n.starts_with("craft:Shippori"));diff --git a/docs/contributing.md b/docs/contributing.md--- a/docs/contributing.md+++ b/docs/contributing.md@@ -96,15 +96,20 @@ glyphs skip when it is empty; when you touch fonts, run the gates both with and Chinese media names and track labels use the `Hans` / `Hant` craft-fonts faces after the Japanese fallbacks in every UI font family, regardless of interface language (Japanese and Chinese share code points, so Japanese text keeps Japanese glyph forms; the Chinese faces cover hanzi the Japanese ones lack). Without those build inputs,-native builds look up an installed Chinese face (for example Microsoft YaHei or Noto Sans CJK SC)-once and reuse it across theme changes. No system font files are bundled or copied into the app.+native builds use an installed Chinese face (for example Microsoft YaHei or Noto Sans CJK SC). The+installed faces the interface falls back to (that one, the Hangul face every build uses, and the+Arabic and Hebrew ones below) are read the first time the interface paints a character that needs+them, not at start-up: after each egui pass, `crates/ui-egui/src/fallback_fonts.rs` looks at the+painted text, reads the face, installs the fonts again and has egui redo the pass before the frame+is shown. A face is read once and egui borrows the font database's bytes, so the file is in memory+once. No system font files are bundled or copied into the app. Web builds depend on the font faces selected by `crates/text/build.rs`; they cannot scan system fonts. `theme::tests::chinese_names_render_in_every_theme_and_font_family` verifies Chinese glyphs in the timeline's proportional, monospace, medium and semibold families under all three themes.  Arabic and Hebrew work the same way (`crates/ui-egui/src/rtl.rs`): the `Arab` / `Hebr` craft-fonts-faces, else one installed face per script (for example Noto Sans Arabic, SF Arabic or Segoe UI),-are the last fallbacks of every UI font family. egui shapes each font run (letters join) but lays+faces, else one installed face per script (for example Noto Sans Arabic, SF Arabic or Segoe UI,+read when first needed), are the last fallbacks of every UI font family. egui shapes each font run (letters join) but lays runs out left to right with no bidi pass, so captions on the timeline and in the Text panel are laid out in reading order by `rtl::Lines` (UAX #9 via unicode-bidi, each right-to-left word its own layout section). A caption being edited shows egui's logical order, since `TextEdit` places the caret by