diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -378,6 +378,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
 
 Newest first.
 
+- **2026-10-25** · The interface reads the installed faces it falls back to (Hangul, Traditional Chinese, Indic, Hebrew, Ethiopic…) the first time it shows text in their script, not at start-up, and holds each file once instead of three times: on macOS, 312 MB → 101–110 MB with no document open. The first frame that shows such text already has its glyphs. No readiness percentage change.
 - **2026-10-24** · Story Options rejects malformed or nonexistent explicit story/frame IDs without changing the selected story, including an invalid frame alongside a valid story. Null targets remain absent; omitted targets still use the selection. No readiness percentage change.
 - **2026-10-24** · Text Frame Options explains that a custom baseline-grid increment must be a measure greater than zero when zero, a negative value or a non-measure is entered. Validation is unchanged. No readiness percentage change.
 - **2026-10-24** · Renaming a paragraph or character style, or moving it into a style group, keeps its Export Tagging under the new name, and undo puts it back under the old one. Deleting a tagged style drops its tagging and leaves the replacement style's own tagging as it was. No readiness percentage change.
diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs
--- a/crates/fonts/src/fontdb.rs
+++ b/crates/fonts/src/fontdb.rs
@@ -139,6 +139,20 @@ pub struct FontFace {
     postscript: std::sync::OnceLock<Option<String>>,
 }
 
+/// The code points `font` maps to a glyph, as sorted, merged inclusive ranges.
+fn coverage_of(font: &skrifa::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
+}
+
 /// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of
 /// the process; unlike cloning an `Arc`, copying it touches no shared reference count (which made
 /// parallel composition scale negatively).
@@ -1932,6 +1946,41 @@ impl ScopedFonts<'_> {
         None
     }
 
+    /// The code points `family`'s Regular face (else its first) maps to a glyph, as sorted, merged
+    /// inclusive ranges, without loading the family: a loaded face is asked; an installed family
+    /// that isn't loaded is read from its file, which is then let go (as [`ScopedFonts::family_kind`]
+    /// does). Empty for an unknown family or an unreadable file.
+    pub fn family_coverage(&self, family: &str) -> Vec<(u32, u32)> {
+        let styles = self.styles(family);
+        let Some(style) = styles.iter().find(|s| *s == "Regular").or(styles.first()).cloned() else { return Vec::new() };
+        if self.own().iter().chain(self.db.read_faces().iter()).any(|f| f.family.eq_ignore_ascii_case(family)) {
+            return self.find(family, &style).and_then(|f| f.skrifa().map(|s| coverage_of(&s))).unwrap_or_default();
+        }
+        #[cfg(not(target_arch = "wasm32"))]
+        {
+            let path = {
+                let cat = self.db.read_catalog();
+                let of_family = |c: &&CatalogEntry| c.family.eq_ignore_ascii_case(family);
+                let entry = cat.iter().filter(of_family).find(|c| norm(&c.style) == norm(&style)).or_else(|| cat.iter().find(of_family));
+                entry.map(|c| c.path.clone())
+            };
+            let Some(path) = path else { return Vec::new() };
+            if std::fs::metadata(&path).map_or(true, |m| m.len() > MAX_DOCUMENT_FONT_BYTES) {
+                return Vec::new();
+            }
+            let Ok(data) = std::fs::read(&path) else { return Vec::new() };
+            let found = enumerate_faces(&data);
+            let pick = found
+                .iter()
+                .find(|(_, f, st, _)| f.eq_ignore_ascii_case(family) && norm(st) == norm(&style))
+                .or_else(|| found.iter().find(|(_, f, _, _)| f.eq_ignore_ascii_case(family)));
+            let Some((index, ..)) = pick else { return Vec::new() };
+            skrifa::FontRef::from_index(&data, *index).map(|f| coverage_of(&f)).unwrap_or_default()
+        }
+        #[cfg(target_arch = "wasm32")]
+        Vec::new()
+    }
+
     /// The family and style of the face whose PostScript name is `name`, ignoring case (the
     /// document's, loaded, or installed on the system, not loading it). SVGs exported from
     /// Illustrator name fonts this way (`font-family="'MyriadPro-Regular'"`).
@@ -2254,3 +2303,25 @@ mod tests_heights {
         }
     }
 }
+
+#[cfg(all(test, not(target_arch = "wasm32")))]
+mod family_coverage_tests {
+    use super::*;
+
+    /// An installed family's character map, read without loading the family into the database.
+    #[test]
+    fn family_coverage_reads_an_installed_family_without_loading_it() {
+        let dir = std::env::temp_dir().join(format!("dc-family-coverage-{}", std::process::id()));
+        std::fs::create_dir_all(&dir).unwrap();
+        std::fs::write(dir.join("coverage.ttf"), crate::testing::font_with("DC Coverage Test", &['a', 'b', 'ক']).unwrap()).unwrap();
+        let db = FontDb::with_font_dirs(vec![dir.clone()]);
+        db.load_system_fonts();
+        let ranges = db.scoped(0).family_coverage("DC Coverage Test");
+        let _ = std::fs::remove_dir_all(&dir);
+        let has = |c: char| ranges.iter().any(|&(a, b)| (a..=b).contains(&u32::from(c)));
+        assert!(has('a') && has('b') && has('ক') && !has('c'), "{ranges:?}");
+        assert!(ranges.windows(2).all(|w| w[0].1.saturating_add(1) < w[1].0), "sorted and merged: {ranges:?}");
+        assert!(!db.read_faces().iter().any(|f| f.family == "DC Coverage Test"), "the family stays unloaded");
+        assert!(db.scoped(0).family_coverage("DC No Such Family").is_empty());
+    }
+}
diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs
--- a/crates/fonts/src/lib.rs
+++ b/crates/fonts/src/lib.rs
@@ -96,6 +96,12 @@ pub fn line_metrics(data: &[u8], index: u32) -> Option<(f32, f32, f32, f32)> {
     Some((f32::from(m.units_per_em.max(1)), m.ascent, m.descent, m.leading))
 }
 
+/// Whether the font at `index` of `data` maps `c` to a glyph (false when it can't be read).
+pub fn maps_char(data: &[u8], index: u32, c: char) -> bool {
+    use skrifa::MetadataProvider;
+    skrifa::FontRef::from_index(data, index).ok().and_then(|f| f.charmap().map(c)).is_some_and(|g| g.to_u32() != 0)
+}
+
 /// An OpenType feature setting: `"liga"`, `"-kern"`, `"ss01"`.
 pub fn feature(tag: &str) -> Option<Feature> {
     let (on, t) = match tag.strip_prefix('-') {
diff --git a/crates/ui-egui/src/fallback_fonts.rs b/crates/ui-egui/src/fallback_fonts.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/fallback_fonts.rs
@@ -0,0 +1,455 @@
+//! Installed fallback faces, read when the interface first paints text that needs them.
+//!
+//! The interface draws scripts its own fonts and the craft-fonts lack (Hangul, Traditional
+//! Chinese, Indic, Southeast Asian, Hebrew, Armenian, Ethiopic…) with installed faces
+//! (`theme::SYSTEM_UI_FONTS`). Those files are large (Apple SD Gothic Neo alone is 53 MB) and most
+//! sessions never draw a character from most of them, so only the interface language's own CJK
+//! face is read when the fonts are installed. [`Watch`], an egui plugin, looks at the text each
+//! pass painted: when a character is in the script of a face that hasn't been read, and the fonts
+//! installed so far can't draw it (or draw it only with a face that comes later in the fallback
+//! order), 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 (an arrow or a math sign only an
+//! installed face has) is looked up in the faces not read yet, whose character maps are read once
+//! without loading the families, 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.
+
+use std::collections::{HashMap, HashSet};
+
+use egui::epaint::{ClippedShape, Shape};
+use egui::{FontDefinitions, FontFamily, FontId};
+
+/// What [`plan`] needs to know of a fallback face.
+pub(crate) 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;
+}
+
+/// 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).
+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| designcraft_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 egui plugin that reads the installed fallbacks painted text needs (see the module docs).
+/// `theme::install_fonts` adds it and keeps its interface language current.
+pub(crate) struct Watch {
+    /// The interface language the fonts were installed for (it orders the CJK faces).
+    pub(crate) lang: String,
+    /// The faces read when painted text needs them, in fallback order, for the language named.
+    fallbacks: (String, Vec<crate::theme::LazyFallback>),
+    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(lang: &str) -> Self {
+        Self {
+            lang: lang.to_owned(),
+            fallbacks: (lang.to_owned(), crate::theme::lazy_fallbacks(lang)),
+            settled: Settled::default(),
+            #[cfg(not(target_arch = "wasm32"))]
+            started: None,
+        }
+    }
+}
+
+impl egui::Plugin for Watch {
+    fn debug_name(&self) -> &'static str {
+        "designcraft 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);
+        }
+        // Until the installed fonts are cataloged nothing can be read: look again once they are
+        // (the app installs the fonts again then, and keeps painting).
+        if !crate::theme::system_fonts_ready() {
+            return;
+        }
+        if self.fallbacks.0 != self.lang {
+            // another interface language: other faces, in another order
+            self.fallbacks = (self.lang.clone(), crate::theme::lazy_fallbacks(&self.lang));
+            self.settled = Settled::default();
+        }
+        let fallbacks = &self.fallbacks.1;
+        if fallbacks.iter().all(|f| f.is_read()) {
+            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> = fallbacks.iter().map(|f| f as &dyn Candidate).collect();
+        let mut read = false;
+        let late: Vec<String> = fallbacks.iter().filter_map(crate::theme::LazyFallback::font_name).collect();
+        let late: Vec<&str> = late.iter().map(String::as_str).collect();
+        for (c, family) in chars {
+            // the fonts this pass was laid out with (new ones apply from the next pass)
+            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;
+        }
+        crate::theme::set_fonts(ctx, &self.lang);
+        #[cfg(not(target_arch = "wasm32"))]
+        {
+            let names: Vec<&str> = fallbacks.iter().filter(|f| f.is_read()).map(|f| f.script()).collect();
+            log::info!("fonts: read installed fallback faces for {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();
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::cell::Cell;
+    use std::ops::RangeInclusive;
+
+    /// 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}';
+    const TAMIL: RangeInclusive<char> = '\u{b80}'..='\u{bff}';
+    const DEVANAGARI: RangeInclusive<char> = '\u{900}'..='\u{97f}';
+
+    /// Drawn by a face ahead of the fallbacks; by a fallback (or 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 hant = Fake::new(HAN, "中文");
+        let korean = Fake::new(HANGUL, "한국");
+        assert!(plan('한', NONE, &[&hant, &korean]));
+        assert_eq!((hant.reads.get(), korean.reads.get()), (0, 1), "only the Korean face");
+        assert!(!plan('국', NONE, &[&hant, &korean]), "the face read draws the rest of its script");
+    }
+
+    /// One face for several scripts (Nirmala UI): read for Tamil, it draws Devanagari too, so the
+    /// Devanagari face isn't read.
+    #[test]
+    fn a_face_read_for_one_script_serves_the_next() {
+        let tamil = Fake::new(TAMIL, "தக");
+        let devanagari = Fake::new(DEVANAGARI, "क");
+        assert!(plan('த', NONE, &[&tamil, &devanagari]));
+        let tamil = Fake { covers: "தகक", ..tamil };
+        assert!(!plan('क', LATE, &[&tamil, &devanagari]));
+        assert_eq!(devanagari.reads.get(), 0);
+    }
+
+    #[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, 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 hant = Fake::new(HAN, "中文");
+        let korean = Fake::new(HANGUL, "한국中").read_already();
+        assert!(plan('中', LATE, &[&hant, &korean]));
+        assert_eq!(hant.reads.get(), 1);
+        let hant = Fake::new(HAN, "中文").read_already();
+        let korean = Fake::new(HANGUL, "한국中");
+        assert!(!plan('中', LATE, &[&hant, &korean]));
+        assert_eq!(korean.reads.get(), 0);
+    }
+
+    /// A character in no fallback's script that nothing draws reads the face whose character map
+    /// has it (a symbol only an installed face has), and nothing when no face has it (a newer
+    /// emoji, Ethiopic, a math sign nobody has).
+    #[test]
+    fn a_character_in_no_script_reads_only_the_face_that_has_it() {
+        let hant = Fake::new(HAN, "中");
+        let korean = Fake::new(HANGUL, "한⇢");
+        let tamil = Fake::new(TAMIL, "த");
+        assert!(plan('⇢', NONE, &[&hant, &korean, &tamil]));
+        assert_eq!((hant.reads.get(), korean.reads.get(), tamil.reads.get()), (0, 1, 0), "only the face that has it");
+        for c in ['\u{1fae0}', '\u{1200}', '\u{2a00}', '\u{f8ff}'] {
+            assert!(!plan(c, NONE, &[&hant, &korean, &tamil]), "{c:?}: no face has it");
+        }
+        assert_eq!((hant.reads.get(), korean.reads.get(), tamil.reads.get()), (0, 1, 0));
+    }
+
+    #[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 a
+    /// named family without 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 Ǆ ɕ 한국 한 ñ".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, ['Ǆ', 'ɕ', '한', '국'], "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, ['Ǆ', 'ɕ', '국']);
+    }
+
+    /// 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 only
+    /// fallbacks draw 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 });
+    }
+}
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
@@ -15,6 +15,7 @@ pub mod cursors;
 pub mod dialogs;
 pub mod dock;
 pub mod effects_dialog;
+mod fallback_fonts;
 pub mod i18n;
 pub mod icons;
 pub mod menus;
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
@@ -1,5 +1,6 @@
 //! Design tokens (InDesign's four interface brightness levels), fonts and egui style.
 
+use std::ops::RangeInclusive;
 use std::sync::Arc;
 
 use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals};
@@ -264,16 +265,27 @@ impl Tokens {
 /// Install the UI fonts for the interface language `lang` (it orders the CJK fallbacks).
 /// Returns false while the installed fonts are still being cataloged: the built-in faces are
 /// installed without waiting, and the caller installs again once they are (see
-/// [`system_fonts_ready`]) to add the installed faces for the scripts they lack.
+/// [`system_fonts_ready`]) to add the installed faces for the scripts they lack. Of those, only
+/// the interface language's own CJK face is read here; the others are read the first time the
+/// interface paints text that needs them ([`crate::fallback_fonts`]).
 pub fn install_fonts(ctx: &egui::Context, lang: &str) -> bool {
+    let cataloged = set_fonts(ctx, lang);
+    ctx.add_plugin(crate::fallback_fonts::Watch::new(lang));
+    ctx.with_plugin(|w: &mut crate::fallback_fonts::Watch| lang.clone_into(&mut w.lang));
+    cataloged
+}
+
+/// [`install_fonts`] without the fallback watch (which installs the fonts again itself).
+pub(crate) fn set_fonts(ctx: &egui::Context, lang: &str) -> bool {
     let craft = designcraft_fonts::CRAFT_FONTS;
     let mut defs = font_definitions(craft, lang);
-    let system = system_ui_fonts(craft);
-    if let Some(system) = system {
+    let system = system_ui_fonts(craft, lang);
+    let cataloged = system.is_some();
+    if let Some(system) = &system {
         add_system_fallbacks(&mut defs, system, lang);
     }
     ctx.set_fonts(defs);
-    system.is_some()
+    cataloged
 }
 
 /// Whether [`install_fonts`] would find the installed fonts cataloged.
@@ -282,6 +294,7 @@ pub fn system_fonts_ready() -> bool {
 }
 
 /// An installed font the UI falls back to for a script the built-in faces lack.
+#[derive(Clone)]
 pub(crate) struct SystemFont {
     /// The ISO 15924 code of the script it is for ("Jpan", "Kore", "Taml"…).
     script: &'static str,
@@ -292,10 +305,22 @@ pub(crate) struct SystemFont {
 }
 
 impl SystemFont {
+    /// A face from font file `bytes` (tests: synthetic faces standing in for installed ones).
+    #[cfg(test)]
     pub(crate) fn new(script: &'static str, family: String, bytes: Vec<u8>, index: u32) -> Self {
         let tweak = baseline_tweak(&bytes, index);
         SystemFont { script, family, data: Arc::new(FontData { index, tweak, ..FontData::from_owned(bytes) }) }
     }
+
+    /// An installed face of the font database, which egui borrows: the database never unloads a
+    /// face ([`designcraft_fonts::FaceRef`]), and egui 0.36 copies owned font data each time it
+    /// builds its fonts, so a copy here meant three copies of the file (the database's, this one
+    /// and egui's).
+    fn installed(script: &'static str, family: String, face: &Arc<designcraft_fonts::FontFace>) -> Self {
+        let face = designcraft_fonts::FaceRef::of(face).get();
+        let font = FontData { index: face.index(), tweak: baseline_tweak(face.data(), face.index()), ..FontData::from_static(face.data()) };
+        SystemFont { script, family, data: Arc::new(font) }
+    }
 }
 
 /// Installed UI faces (sans serif) per script, with a character every face of the script has:
@@ -350,8 +375,11 @@ const SYSTEM_UI_FONTS: &[(&str, char, &[&str])] = &[
 /// Traditional Chinese (font names in the font menus), Indic, Southeast Asian and other scripts
 /// in any build. egui can't find system fonts itself; the document font scan (not on the web)
 /// does.
-fn system_ui_fonts(craft: &[designcraft_fonts::CraftFont]) -> Option<&'static [SystemFont]> {
-    static FACES: std::sync::OnceLock<Vec<SystemFont>> = std::sync::OnceLock::new();
+///
+/// The faces are read when needed: the interface language's own CJK face here, the others the
+/// first time the interface paints text in their script ([`crate::fallback_fonts`]). Returns the
+/// faces read so far.
+fn system_ui_fonts(craft: &[designcraft_fonts::CraftFont], lang: &str) -> Option<Vec<SystemFont>> {
     let db = designcraft_fonts::FontDb::global();
     // Never wait for the font scan here (the first frame): it runs in the background, and the
     // faces are read once, when it is done.
@@ -360,32 +388,241 @@ fn system_ui_fonts(craft: &[designcraft_fonts::CraftFont]) -> Option<&'static [S
         db.scan_in_background();
         return None;
     }
-    Some(FACES.get_or_init(|| installed_ui_fonts(db, craft)))
+    // The interface language's CJK face draws its Han characters ahead of the built-in faces.
+    if let Some(i) = SYSTEM_UI_FONTS.iter().position(|(script, ..)| *script == cjk_script(lang)) {
+        read_system_font(db, craft, i);
+    }
+    Some(read_system_fonts())
 }
 
-/// The faces of [`SYSTEM_UI_FONTS`] that `db` has, for the scripts `craft` doesn't cover.
-fn installed_ui_fonts(db: &designcraft_fonts::FontDb, craft: &[designcraft_fonts::CraftFont]) -> Vec<SystemFont> {
+/// The installed face for each script of [`SYSTEM_UI_FONTS`], once read (`None`: none installed,
+/// or the craft-fonts cover the script).
+static SYSTEM_FONTS: [std::sync::OnceLock<Option<SystemFont>>; SYSTEM_UI_FONTS.len()] = [const { std::sync::OnceLock::new() }; SYSTEM_UI_FONTS.len()];
+
+/// Read the installed face for script `i` of [`SYSTEM_UI_FONTS`] (once); whether there is one.
+fn read_system_font(db: &designcraft_fonts::FontDb, craft: &[designcraft_fonts::CraftFont], i: usize) -> bool {
+    SYSTEM_FONTS.get(i).is_some_and(|slot| slot.get_or_init(|| installed_ui_font(db, craft, i)).is_some())
+}
+
+/// The faces read so far, in [`SYSTEM_UI_FONTS`] order; a family that serves several scripts is
+/// added once.
+fn read_system_fonts() -> Vec<SystemFont> {
     let mut faces: Vec<SystemFont> = Vec::new();
-    for (script, sample, families) in SYSTEM_UI_FONTS {
-        if craft.iter().any(|f| f.scripts.contains(script)) {
-            continue;
+    for face in SYSTEM_FONTS.iter().filter_map(|slot| slot.get()).flatten() {
+        if !faces.iter().any(|f| f.family.eq_ignore_ascii_case(&face.family)) {
+            faces.push(face.clone());
         }
-        let found = families.iter().find_map(|family| {
-            if !db.has_family(family) {
-                return None;
-            }
-            let face = db.face(family, "Regular");
-            // `face` falls back to another family when this one can't be read: keep only the real one.
-            (face.family.eq_ignore_ascii_case(family) && !face.data().is_empty() && face.covers(*sample)).then_some((family, face))
-        });
-        let Some((family, face)) = found else { continue };
-        if !faces.iter().any(|f| f.family.eq_ignore_ascii_case(family)) {
-            faces.push(SystemFont::new(script, (*family).to_string(), face.data().to_vec(), face.index()));
+    }
+    faces
+}
+
+/// The faces of [`SYSTEM_UI_FONTS`] that `db` has, for the scripts `craft` doesn't cover; a family
+/// that serves several scripts is added once.
+#[cfg(test)]
+fn installed_ui_fonts(db: &designcraft_fonts::FontDb, craft: &[designcraft_fonts::CraftFont]) -> Vec<SystemFont> {
+    let mut faces: Vec<SystemFont> = Vec::new();
+    for face in (0..SYSTEM_UI_FONTS.len()).filter_map(|i| installed_ui_font(db, craft, i)) {
+        if !faces.iter().any(|f| f.family.eq_ignore_ascii_case(&face.family)) {
+            faces.push(face);
         }
     }
     faces
 }
 
+/// The face of `db` for script `i` of [`SYSTEM_UI_FONTS`]: the first listed family that is
+/// installed and covers the script's character. `None` when the craft-fonts cover the script or
+/// nothing installed does.
+fn installed_ui_font(db: &designcraft_fonts::FontDb, craft: &[designcraft_fonts::CraftFont], i: usize) -> Option<SystemFont> {
+    let (script, sample, families) = SYSTEM_UI_FONTS.get(i)?;
+    if craft.iter().any(|f| f.scripts.contains(script)) {
+        return None;
+    }
+    families.iter().find_map(|family| {
+        if !db.has_family(family) {
+            return None;
+        }
+        let face = db.face(family, "Regular");
+        // `face` falls back to another family when this one can't be read: keep only the real one.
+        (face.family.eq_ignore_ascii_case(family) && !face.data().is_empty() && face.covers(*sample))
+            .then(|| SystemFont::installed(script, (*family).to_string(), &face))
+    })
+}
+
+/// The code points of each script of [`SYSTEM_UI_FONTS`]: the text its face is read for (Han,
+/// kana and bopomofo with the CJK symbols, punctuation and full-width forms; Hangul with its
+/// enclosed and half-width forms; each other script with its extensions).
+#[allow(clippy::single_range_in_vec_init)] // one range for some scripts
+const SCRIPT_RANGES: &[(&str, &[RangeInclusive<char>])] = &[
+    (
+        "Jpan",
+        &[
+            '\u{2e80}'..='\u{2fdf}',
+            '\u{2ff0}'..='\u{303f}',
+            '\u{3190}'..='\u{319f}',
+            '\u{31c0}'..='\u{31ef}',
+            '\u{3200}'..='\u{33ff}',
+            '\u{3400}'..='\u{4dbf}',
+            '\u{4e00}'..='\u{9fff}',
+            '\u{f900}'..='\u{faff}',
+            '\u{fe30}'..='\u{fe4f}',
+            '\u{ff00}'..='\u{ffef}',
+            '\u{20000}'..='\u{3ffff}',
+            '\u{3040}'..='\u{30ff}',
+            '\u{31f0}'..='\u{31ff}',
+            '\u{1b000}'..='\u{1b16f}',
+        ],
+    ),
+    (
+        "Hans",
+        &[
+            '\u{2e80}'..='\u{2fdf}',
+            '\u{2ff0}'..='\u{303f}',
+            '\u{3190}'..='\u{319f}',
+            '\u{31c0}'..='\u{31ef}',
+            '\u{3200}'..='\u{33ff}',
+            '\u{3400}'..='\u{4dbf}',
+            '\u{4e00}'..='\u{9fff}',
+            '\u{f900}'..='\u{faff}',
+            '\u{fe30}'..='\u{fe4f}',
+            '\u{ff00}'..='\u{ffef}',
+            '\u{20000}'..='\u{3ffff}',
+            '\u{3100}'..='\u{312f}',
+            '\u{31a0}'..='\u{31bf}',
+        ],
+    ),
+    (
+        "Hant",
+        &[
+            '\u{2e80}'..='\u{2fdf}',
+            '\u{2ff0}'..='\u{303f}',
+            '\u{3190}'..='\u{319f}',
+            '\u{31c0}'..='\u{31ef}',
+            '\u{3200}'..='\u{33ff}',
+            '\u{3400}'..='\u{4dbf}',
+            '\u{4e00}'..='\u{9fff}',
+            '\u{f900}'..='\u{faff}',
+            '\u{fe30}'..='\u{fe4f}',
+            '\u{ff00}'..='\u{ffef}',
+            '\u{20000}'..='\u{3ffff}',
+            '\u{3100}'..='\u{312f}',
+            '\u{31a0}'..='\u{31bf}',
+        ],
+    ),
+    (
+        "Kore",
+        &[
+            '\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}',
+        ],
+    ),
+    ("Taml", &['\u{b80}'..='\u{bff}', '\u{11fc0}'..='\u{11fff}']),
+    ("Deva", &['\u{900}'..='\u{97f}', '\u{a8e0}'..='\u{a8ff}', '\u{1cd0}'..='\u{1cff}']),
+    ("Beng", &['\u{980}'..='\u{9ff}']),
+    ("Gujr", &['\u{a80}'..='\u{aff}']),
+    ("Guru", &['\u{a00}'..='\u{a7f}']),
+    ("Knda", &['\u{c80}'..='\u{cff}']),
+    ("Mlym", &['\u{d00}'..='\u{d7f}']),
+    ("Telu", &['\u{c00}'..='\u{c7f}']),
+    ("Orya", &['\u{b00}'..='\u{b7f}']),
+    ("Sinh", &['\u{d80}'..='\u{dff}', '\u{111e0}'..='\u{111ff}']),
+    ("Thai", &['\u{e00}'..='\u{e7f}']),
+    ("Khmr", &['\u{1780}'..='\u{17ff}', '\u{19e0}'..='\u{19ff}']),
+    ("Laoo", &['\u{e80}'..='\u{eff}']),
+    ("Mymr", &['\u{1000}'..='\u{109f}', '\u{a9e0}'..='\u{a9ff}', '\u{aa60}'..='\u{aa7f}']),
+    ("Hebr", &['\u{590}'..='\u{5ff}', '\u{fb1d}'..='\u{fb4f}']),
+    ("Armn", &['\u{530}'..='\u{58f}', '\u{fb13}'..='\u{fb17}']),
+    ("Geor", &['\u{10a0}'..='\u{10ff}', '\u{1c90}'..='\u{1cbf}', '\u{2d00}'..='\u{2d2f}']),
+    ("Ethi", &['\u{1200}'..='\u{139f}', '\u{2d80}'..='\u{2ddf}', '\u{ab00}'..='\u{ab2f}', '\u{1e7e0}'..='\u{1e7ff}']),
+];
+
+fn script_ranges(script: &str) -> &'static [RangeInclusive<char>] {
+    SCRIPT_RANGES.iter().find(|(s, _)| *s == script).map_or(&[], |(_, ranges)| ranges)
+}
+
+/// An installed fallback face [`crate::fallback_fonts::Watch`] reads when painted text needs it:
+/// script `0` of [`SYSTEM_UI_FONTS`].
+pub(crate) struct LazyFallback(usize);
+
+impl LazyFallback {
+    /// The script's ISO 15924 code.
+    pub(crate) fn script(&self) -> &'static str {
+        SYSTEM_UI_FONTS.get(self.0).map_or("", |(script, ..)| script)
+    }
+
+    fn face(&self) -> Option<&'static SystemFont> {
+        SYSTEM_FONTS.get(self.0)?.get()?.as_ref()
+    }
+
+    /// The egui font name of the face once read.
+    pub(crate) fn font_name(&self) -> Option<String> {
+        self.face().map(|f| format!("system-{}", f.family))
+    }
+
+    /// The code points of the face this fallback would read, without reading it: the first listed
+    /// family that is installed. Looked up once.
+    fn coverage(&self) -> &'static [(u32, u32)] {
+        static COVERAGE: [std::sync::OnceLock<Vec<(u32, u32)>>; SYSTEM_UI_FONTS.len()] =
+            [const { std::sync::OnceLock::new() }; SYSTEM_UI_FONTS.len()];
+        let db = designcraft_fonts::FontDb::global();
+        let Some((slot, (_, _, families))) = COVERAGE.get(self.0).zip(SYSTEM_UI_FONTS.get(self.0)) else { return &[] };
+        if !db.system_fonts_cataloged() {
+            return &[];
+        }
+        slot.get_or_init(|| families.iter().find(|f| db.has_family(f)).map(|f| db.scoped(0).family_coverage(f)).unwrap_or_default())
+    }
+}
+
+impl crate::fallback_fonts::Candidate for LazyFallback {
+    fn in_script(&self, c: char) -> bool {
+        script_ranges(self.script()).iter().any(|r| r.contains(&c))
+    }
+    fn is_read(&self) -> bool {
+        self.face().is_some()
+    }
+    fn covers(&self, c: char) -> bool {
+        self.face().is_some_and(|f| designcraft_fonts::maps_char(&f.data.font, f.data.index, c))
+    }
+    fn read(&self) -> bool {
+        let db = designcraft_fonts::FontDb::global();
+        db.system_fonts_cataloged() && read_system_font(db, designcraft_fonts::CRAFT_FONTS, self.0)
+    }
+    fn might_cover(&self, c: char) -> bool {
+        if self.is_read() {
+            return self.covers(c);
+        }
+        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().binary_search_by(within).is_ok()
+    }
+}
+
+/// The installed faces for the interface language `lang` that are read when painted text needs
+/// them, in the order [`add_system_fallbacks`] puts them in: the CJK scripts other than the
+/// language's own (which is read with the fonts), then the other scripts.
+pub(crate) fn lazy_fallbacks(lang: &str) -> Vec<LazyFallback> {
+    const CJK: [&str; 4] = ["Jpan", "Hans", "Hant", "Kore"];
+    let first = cjk_script(lang);
+    let craft = designcraft_fonts::CRAFT_FONTS;
+    let wanted = |script: &str| script != first && !craft.iter().any(|f| f.scripts.contains(&script));
+    let index = |script: &str| SYSTEM_UI_FONTS.iter().position(|(s, ..)| *s == script);
+    let cjk = CJK.iter().filter(|s| wanted(s)).filter_map(|s| index(s));
+    let rest = SYSTEM_UI_FONTS.iter().enumerate().filter(|(_, (s, ..))| !CJK.contains(s) && wanted(s)).map(|(i, _)| i);
+    cjk.chain(rest).map(LazyFallback).collect()
+}
+
 /// The UI face every family starts with (Source Sans 3; the semibold shares its line metrics).
 const UI_FONT: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
 
@@ -708,6 +945,11 @@ mod japanese_font_tests {
         let _ = std::fs::remove_dir_all(&dir);
         let picked: Vec<(&str, &str)> = system.iter().map(|f| (f.script, f.family.as_str())).collect();
         assert_eq!(picked, [("Jpan", "Noto Sans CJK JP"), ("Taml", "Nirmala UI"), ("Thai", "Noto Sans Thai")]);
+        // egui borrows the database's bytes: the font file is in memory once, not copied per install
+        for f in &system {
+            let face = db.face(&f.family, "Regular");
+            assert_eq!(f.data.font.as_ptr(), face.data().as_ptr(), "{}: egui must borrow the database's own bytes, not a copy", f.family);
+        }
 
         let mut defs = super::font_definitions(&[], "ja");
         defs.families.entry(egui::FontFamily::Proportional).or_default().push("craft-Stand-in".into());
@@ -729,6 +971,59 @@ mod japanese_font_tests {
         });
     }
 
+    /// The installed faces read when painted text needs them come in the order the fonts list them:
+    /// the other CJK scripts, then the rest; not the interface language's own CJK face (read with
+    /// the fonts) nor a script the craft-fonts cover.
+    #[test]
+    fn lazy_fallbacks_follow_the_fallback_order() {
+        let scripts = |lang: &str| super::lazy_fallbacks(lang).iter().map(super::LazyFallback::script).collect::<Vec<_>>();
+        let craft = |script: &str| designcraft_fonts::CRAFT_FONTS.iter().any(|f| f.scripts.contains(&script));
+        for (lang, own) in [("en", "Jpan"), ("zh-hant", "Hant"), ("ko", "Kore")] {
+            let list = scripts(lang);
+            assert!(!list.contains(&own), "{lang}: {list:?}");
+            assert!(list.iter().all(|s| !craft(s)), "{lang}: {list:?}");
+            let cjk = list.iter().take_while(|s| matches!(**s, "Jpan" | "Hans" | "Hant" | "Kore")).count();
+            assert!(list[cjk..].iter().all(|s| !matches!(*s, "Jpan" | "Hans" | "Hant" | "Kore")), "{lang}: CJK first: {list:?}");
+            assert!(list.contains(&"Taml") && list.contains(&"Ethi"), "{lang}: {list:?}");
+        }
+    }
+
+    /// Text in a script whose installed face hasn't been read draws real glyphs on the first frame
+    /// that paints it: the face is read during that frame and the pass redone with it.
+    #[cfg(not(target_arch = "wasm32"))]
+    #[test]
+    fn the_first_frame_draws_text_in_an_installed_script() {
+        let db = designcraft_fonts::FontDb::global();
+        let _ = db.load_system_fonts();
+        for (script, text) in [("Taml", "தமிழ் ஆவணம்"), ("Kore", "한국어 문서"), ("Hebr", "מסמך עברי")] {
+            let Some(i) = super::SYSTEM_UI_FONTS.iter().position(|(s, ..)| *s == script) else { panic!("{script}") };
+            let craft = designcraft_fonts::CRAFT_FONTS.iter().any(|f| f.scripts.contains(&script));
+            if !craft && super::installed_ui_font(db, designcraft_fonts::CRAFT_FONTS, i).is_none() {
+                eprintln!("SKIPPED: no installed {script} face");
+                continue;
+            }
+            let ctx = egui::Context::default();
+            super::install_fonts(&ctx, "en");
+            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 {
+                egui::Shape::Text(t) if t.galley.job.text == text => Some(t.galley.clone()),
+                _ => None,
+            });
+            let Some(galley) = galley else { panic!("{script}: 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 drawn: Vec<_> = galley.rows.iter().flat_map(|r| &r.glyphs).filter(|g| !g.uv_rect.is_nothing()).collect();
+            assert!(!drawn.is_empty(), "{script}: nothing drawn");
+            for g in drawn {
+                assert_ne!(g.uv_rect, missing, "{script}: missing {} on the first frame", g.chr);
+            }
+            ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();
+        }
+    }
+
     #[test]
     fn ui_works_without_craft_fonts() {
         let ctx = ctx_with(&[]);
