Browse / github.com/storytold/designcraft / patch
Fonts: read the interface's installed fallback faces when text needs them, and hold each once (312 → 101–110 MB at idle on macOS)
Fonts: read the interface's installed fallback faces when text needs them, and hold each once (312 → 101–110 MB at idle on macOS) ### Problem Once the font catalog is done, DesignCraft holds about 200 MB of installed font data for its interface, whatever the interface shows. The interface falls back to installed faces for every script its own fonts and the craft-fonts lack (`SYSTEM_UI_FONTS`). On macOS that is 18 faces (PingFang TC, Apple SD Gothic Neo, Tamil Sangam MN, Kohinoor Devanagari … Kefa). `install_fonts` reads all of them, and each file ends up in memory three times: 1. the font database's copy (`FontDb::read_installed`, when `installed_ui_fonts` resolves the family); 2. `face.data().to_vec()`, owned by the
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- ZJHeepfixer (imported from pull request #1826, open)
- Base
7ca39c6c10f123d0cd0176eda1511e59667c89d0- Size
- +852 −23 in 6 files
- Review
- pending
- Shared
- 2026-10-28
- Id
673bd8afd88fec724efea5ff
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/fonts/src/fontdb.rs crates/fonts/src/lib.rs crates/ui-egui/src/fallback_fonts.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/theme.rs
@@ -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.@@ -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());+ }+}@@ -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('-') {new file mode 100644@@ -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 = §ion.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 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 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 });+ }+}@@ -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;@@ -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(&[]);