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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 117 additions & 9 deletions crates/noa-font/src/face.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,15 +378,15 @@ pub fn load_font_stack_with_primary(
// private glyph) — see `embedded_symbols_nerd_font_face`.
push_some_face(&mut fallbacks, embedded_symbols_nerd_font_face());

for postscript_name in cjk_fallback_postscript_names() {
push_some_face(
&mut fallbacks,
postscript_fallback_face(&source, postscript_name),
);
}
for family_name in cjk_fallback_family_names() {
push_some_face(&mut fallbacks, family_fallback_face(&source, family_name));
}
// CJK fallbacks are NOT loaded here. Resolving the ~18 curated CJK
// families/faces at startup faulted whole `.ttc` files into the page cache
// (Hiragino Sans GB 44.8 MB, ヒラギノ角ゴシック W3 30.0 MB, AppleGothic
// 29.2 MB, …) — ~100 MB of idle RSS even when no CJK glyph is ever drawn.
// Instead a CJK codepoint that misses the stack pulls in exactly the one
// font that covers it, lazily, via [`cjk_fallback_face_for`] (called from
// `FontGrid` on a stack miss, ahead of the generic system cascade). The
// priority order there is identical to the eager list this replaced, so
// which font wins for a given CJK codepoint is unchanged.

Ok(FontStack::new(
primary,
Expand Down Expand Up @@ -881,6 +881,42 @@ pub(crate) fn cascade_fallback_face(_ch: char) -> Option<FontData> {
None
}

/// Lazily resolve the first curated CJK fallback face that covers `ch`, in
/// the exact priority order the eager stack used to load them
/// ([`cjk_fallback_postscript_names`] then [`cjk_fallback_family_names`]) — so
/// which font wins for a given CJK codepoint is unchanged from when the whole
/// CJK list was resolved at startup.
///
/// Called by `FontGrid` on a stack miss, *before* the generic macOS system
/// cascade ([`cascade_fallback_face`]), and its result is pushed into the
/// stack and cached — so no CJK font file is resolved or mapped until a
/// codepoint one of these fonts covers is actually drawn. Each candidate
/// resolves via the no-slurp CoreText→mmap path ([`postscript_fallback_face`]
/// / [`family_fallback_face`]); probing a candidate that does not cover `ch`
/// only faults that font's `cmap`, and the winning font stays memory-mapped so
/// only its touched glyph pages ever become resident.
///
/// Returns `None` when no curated CJK font covers `ch` (the caller then tries
/// the generic system cascade).
pub(crate) fn cjk_fallback_face_for(ch: char) -> Option<FontData> {
let source = SystemSource::new();
cjk_fallback_postscript_names()
.iter()
.find_map(|name| face_covering(postscript_fallback_face(&source, name), ch))
.or_else(|| {
cjk_fallback_family_names()
.iter()
.find_map(|name| face_covering(family_fallback_face(&source, name), ch))
})
}

/// Return `face` only if it maps `ch` to a real (non-notdef) glyph — the
/// coverage gate for the lazy CJK priority walk in [`cjk_fallback_face_for`].
fn face_covering(face: Option<FontData>, ch: char) -> Option<FontData> {
let face = face?;
(face.font_ref().ok()?.charmap().map(ch) != 0).then_some(face)
}

fn cjk_fallback_postscript_names() -> &'static [&'static str] {
#[cfg(target_os = "macos")]
{
Expand Down Expand Up @@ -1613,6 +1649,78 @@ mod tests {
);
}

/// Acceptance (a): no CJK font is resolved/mapped at startup. The default
/// stack is primary (Menlo on macOS) + emoji + Nerd + embedded symbols —
/// none of which carry kanji — so a common kanji must NOT be covered by
/// any face already in the stack. It is only resolved later, lazily, via
/// [`cjk_fallback_face_for`]. (Observable externally as vmmap showing no
/// Hiragino/AppleGothic mapped-file regions before CJK is drawn.)
#[test]
fn startup_stack_does_not_cover_cjk() {
let stack = match load_font_stack(&FontConfig::default()) {
Ok(stack) => stack,
Err(e) => {
eprintln!("skipping: no system monospace font available: {e}");
return;
}
};
// U+65E5 '日' — a common kanji absent from Menlo/emoji/Nerd/symbols.
let covered = stack.faces().iter().any(|face| {
face.font_ref()
.ok()
.is_some_and(|font| font.charmap().map('日') != 0)
});
assert!(
!covered,
"the startup font stack must not cover kanji — CJK fonts load lazily, not eagerly"
);
}

/// The lazy CJK resolver must (1) return a face that actually covers the
/// codepoint and (2) pick the same face the old eager priority list would
/// have — PostScript names first, then families, each in list order — so
/// Japanese text renders with exactly the same fonts as before.
#[test]
fn cjk_fallback_resolves_in_eager_priority_order() {
let Some(face) = cjk_fallback_face_for('日') else {
eprintln!("skipping: no curated CJK font installed to resolve U+65E5");
return;
};
assert!(
face.font_ref()
.expect("resolved face parses")
.charmap()
.map('日')
!= 0,
"cjk_fallback_face_for must return a face that covers the codepoint"
);

// Independently walk the same priority list; the winner must match.
let source = SystemSource::new();
let expected = cjk_fallback_postscript_names()
.iter()
.find_map(|name| face_covering(postscript_fallback_face(&source, name), '日'))
.or_else(|| {
cjk_fallback_family_names()
.iter()
.find_map(|name| face_covering(family_fallback_face(&source, name), '日'))
})
.expect("the same priority walk must also find a covering face");

#[cfg(target_os = "macos")]
assert_eq!(
postscript_name_in(&face.bytes, face.index),
postscript_name_in(&expected.bytes, expected.index),
"lazy CJK resolution must select the same face the eager priority list would"
);
#[cfg(not(target_os = "macos"))]
assert_eq!(
(face.bytes, face.index),
(expected.bytes, expected.index),
"lazy CJK resolution must select the same face the eager priority list would"
);
}

/// `+list-fonts` relies on this shape: at least one family exists on a
/// system with fonts, and the list is strictly sorted (which also proves
/// deduplication).
Expand Down
117 changes: 98 additions & 19 deletions crates/noa-font/src/grid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ use unicode_width::UnicodeWidthChar;

use crate::atlas::Atlas;
use crate::boxdraw::{self, is_builtin_glyph};
use crate::face::{FontStack, FontStyle, Metrics, cascade_fallback_face, load_font_stack};
use crate::face::{
FontStack, FontStyle, Metrics, cascade_fallback_face, cjk_fallback_face_for, load_font_stack,
};
use crate::raster::{GlyphSynthesis, RasterizedGlyph, rasterize_with_variations};
use crate::shape::{self, FaceId, ShapeCell, ShapeRunEntry, ShapedGlyph, StyleKey};
use crate::{FontConfig, FontError, GlyphInfo, GlyphKey};
Expand Down Expand Up @@ -153,11 +155,11 @@ pub struct FontGrid {
/// row caches hold concrete atlas coordinates, so eviction is a semantic
/// invalidation even if the CPU atlas dimensions did not change.
atlas_eviction_generation: u64,
/// Codepoints the static stack could not map and the macOS CoreText
/// cascade could not resolve to a real font either (see
/// [`cascade_fallback_face`]). Cached so a genuinely-uncovered glyph is
/// probed once, not on every segmentation pass, and never re-runs the
/// (relatively costly) `CTFontCreateForString` query.
/// Codepoints the static stack could not map and neither the lazy curated
/// CJK list ([`cjk_fallback_face_for`]) nor the macOS CoreText cascade
/// ([`cascade_fallback_face`]) could resolve to a real font either. Cached
/// so a genuinely-uncovered glyph is probed once, not on every
/// segmentation pass, and never re-runs the (relatively costly) resolution.
cascade_misses: HashSet<char>,
}

Expand Down Expand Up @@ -343,10 +345,11 @@ impl FontGrid {
if let Some(hit) = self.lookup_glyph_in_stack(ch, font_style) {
return hit;
}
// The curated stack (primary + emoji/Nerd/CJK fallbacks, plus any face
// an earlier cascade hit already pulled in) has no glyph for `ch`.
// Defer to the macOS system cascade once, then retry the lookup.
if self.try_cascade_fallback(ch)
// The curated stack (primary + emoji/Nerd fallbacks, plus any face an
// earlier dynamic-fallback hit already pulled in) has no glyph for
// `ch`. Pull in a fallback lazily (curated CJK list first, then the
// macOS system cascade) once, then retry the lookup.
if self.try_dynamic_fallback(ch)
&& let Some(hit) = self.lookup_glyph_in_stack(ch, font_style)
{
return hit;
Expand All @@ -368,12 +371,15 @@ impl FontGrid {
None
}

/// Pull a system fallback face covering `ch` into the stack via the macOS
/// CoreText cascade. Returns whether a new face was added (so the caller
/// retries the lookup). Negative results are cached in `cascade_misses` so
/// a genuinely uncovered codepoint probes CoreText only once, not on every
/// segmentation pass. No-op (always `false`) off macOS.
fn try_cascade_fallback(&mut self, ch: char) -> bool {
/// Pull a fallback face covering `ch` into the stack on demand: the curated
/// CJK priority list first ([`cjk_fallback_face_for`] — lazily mapping only
/// the one CJK font that covers `ch`, in the same priority order the eager
/// stack used), then the macOS CoreText system cascade
/// ([`cascade_fallback_face`]) for anything CJK doesn't cover. Returns
/// whether a new face was added (so the caller retries the lookup).
/// Negative results are cached in `cascade_misses` so a genuinely uncovered
/// codepoint is resolved once, not on every segmentation pass.
fn try_dynamic_fallback(&mut self, ch: char) -> bool {
if self.cascade_misses.contains(&ch) {
return false;
}
Expand All @@ -383,9 +389,9 @@ impl FontGrid {
self.cascade_misses.insert(ch);
return false;
}
match cascade_fallback_face(ch) {
// `cascade_fallback_face` guarantees the returned face maps `ch`,
// so the caller's retry is guaranteed to find it.
// Both resolvers guarantee the returned face maps `ch`, so the caller's
// retry is guaranteed to find it.
match cjk_fallback_face_for(ch).or_else(|| cascade_fallback_face(ch)) {
Some(face) => {
self.font_stack.push_dynamic_fallback(face);
true
Expand Down Expand Up @@ -1006,6 +1012,79 @@ mod tests {
);
}

/// A CJK codepoint absent from the (CJK-free) startup stack is resolved
/// lazily: the first lookup pulls in exactly one fallback face, and the
/// second is served from the now-populated stack without growing it again
/// or re-running resolution.
#[test]
fn cjk_glyph_is_resolved_lazily_then_cached() {
let mut grid = match FontGrid::new(14.0, FontConfig::default()) {
Ok(g) => g,
Err(e) => {
eprintln!("skipping: no system monospace font available: {e}");
return;
}
};
// Precondition for a meaningful test: the startup stack must not
// already cover the kanji (otherwise there is nothing to lazily pull).
if grid
.lookup_glyph_in_stack('日', FontStyle::Regular)
.is_some()
{
eprintln!("skipping: this environment's startup stack already covers kanji");
return;
}

let faces_before = grid.font_stack.faces().len();
let (face_index, glyph_id) = grid.resolve_glyph('日');
if glyph_id == 0 {
eprintln!("skipping: no CJK-capable font installed to resolve U+65E5");
return;
}
assert_eq!(
grid.font_stack.faces().len(),
faces_before + 1,
"a CJK miss must lazily pull in exactly one fallback face"
);
assert!(
grid.font_stack.is_fallback_face(face_index),
"the kanji must resolve to a dynamically-added fallback face, not the primary"
);

// Second lookup: same face + glyph, and the stack does not grow again
// (served from the populated stack, no re-resolution).
let (face_index2, glyph_id2) = grid.resolve_glyph('日');
assert_eq!((face_index, glyph_id), (face_index2, glyph_id2));
assert_eq!(
grid.font_stack.faces().len(),
faces_before + 1,
"a cached CJK lookup must not pull in another fallback face"
);
}

/// The non-CJK path is unaffected: plain ASCII stays on the primary face
/// (index 0) and never pulls a fallback into the stack.
#[test]
fn ascii_resolution_stays_on_primary_and_adds_no_fallback() {
let mut grid = match FontGrid::new(14.0, FontConfig::default()) {
Ok(g) => g,
Err(e) => {
eprintln!("skipping: no system monospace font available: {e}");
return;
}
};
let faces_before = grid.font_stack.faces().len();

let (face_index, glyph_id) = grid.resolve_glyph('A');
assert_eq!(face_index, 0, "ASCII 'A' must resolve to the primary face");
assert_ne!(glyph_id, 0, "the primary face must cover ASCII 'A'");
assert_eq!(
grid.font_stack.faces().len(),
faces_before,
"resolving ASCII must not pull any fallback face into the stack"
);
}

#[test]
fn japanese_glyph_uses_fallback_face_when_available() {
let mut grid = match FontGrid::new(14.0, FontConfig::default()) {
Expand Down