feat(web): unify fonts and refine viewcube

Preload one language-aware font resource across the interface, drawing text, and navigation labels.\n\nRender the navigation cube, ring, outlines, and labels through a 4x MSAA target.
This commit is contained in:
Hakan Seven committed 2026-08-03 15:28:51 +03:00
1 parent 8dc642d40c
commit 53cf31f33d
26 files changed
+1337 -522

No files matched your search

+9 -4
View File
@@ -395,6 +395,10 @@ pub fn tessellate_text_run(
let toks = tokenize_run(text);
let ttf_family = face.ttf_family();
let shaping_family = ttf_family.or_else(|| {
crate::scene::text::web_font::requires_shaping(text)
.then(|| crate::scene::text::web_font::primary_script().family())
});
// Render a stroke directly (LFF glyph) or per shaped contour (TTF), applying
// the run transform at the current pen position.
@@ -420,7 +424,7 @@ pub fn tessellate_text_run(
if seg.is_empty() {
return;
}
let family = ttf_family.unwrap_or("");
let family = shaping_family.unwrap_or("");
if let Some(run) = crate::scene::text::ttf_glyph::shape_run(family, seg) {
for g in &run.glyphs {
emit_glyph(out, &g.strokes, *cursor_x);
@@ -454,12 +458,12 @@ pub fn tessellate_text_run(
// TTF shaping batches consecutive glyphs; any break (space, missing,
// decoration toggle, end of run) flushes the buffer first so pen
// positions stay correct for decorations.
if ttf_family.is_some() && !matches!(tok, Tok::Glyph(_)) {
if shaping_family.is_some() && !matches!(tok, Tok::Glyph(_) | Tok::Space) {
flush_ttf(&mut seg, &mut cursor_x, &mut out, &mut fill_tris);
}
match tok {
Tok::Glyph(c) => {
if ttf_family.is_some() {
if shaping_family.is_some() {
seg.push(*c);
} else {
match face.glyph(*c) {
@@ -475,6 +479,7 @@ pub fn tessellate_text_run(
}
}
}
Tok::Space if shaping_family.is_some() => seg.push(' '),
Tok::Space => cursor_x += face.word_spacing(),
Tok::Missing => cursor_x += 6.0 + face.letter_spacing() * tracking,
Tok::Deco(deco, op) => {
@@ -507,7 +512,7 @@ pub fn tessellate_text_run(
}
}
}
if ttf_family.is_some() {
if shaping_family.is_some() {
flush_ttf(&mut seg, &mut cursor_x, &mut out, &mut fill_tris);
}
+6 -2
View File
@@ -74,8 +74,12 @@ pub fn textfill() -> bool {
/// re-baked tiles.
pub fn set_textfill(on: bool) {
TEXTFILL.store(on, std::sync::atomic::Ordering::Relaxed);
if let Ok(mut a) = text_atlas().lock() {
a.reset();
reset_font_entries();
}
pub fn reset_font_entries() {
if let Ok(mut atlas) = text_atlas().lock() {
atlas.reset();
}
}
+97 -6
View File
@@ -325,6 +325,9 @@ pub fn fallback_glyph(ch: char) -> Option<Arc<Glyph>> {
/// outline. (#141)
pub fn clear_fallback_cache() {
fallback_cache().lock().unwrap().clear();
shape_cache().lock().unwrap().clear();
#[cfg(target_arch = "wasm32")]
crate::scene::text::sdf_atlas::reset_font_entries();
}
/// Web: outline the glyph from the lazily-fetched per-script Noto subset that
@@ -334,8 +337,12 @@ pub fn clear_fallback_cache() {
fn build_fallback(ch: char) -> Option<Arc<Glyph>> {
let script = crate::scene::text::web_font::script_of(ch)?;
let bytes = crate::scene::text::web_font::request(script)?;
let face = ttf_parser::Face::parse(&bytes, 0).ok()?;
let gid = face.glyph_index(ch)?;
let face_count = ttf_parser::fonts_in_collection(&bytes).unwrap_or(1);
let (face, gid) = (0..face_count).find_map(|face_index| {
let face = ttf_parser::Face::parse(&bytes, face_index).ok()?;
let gid = face.glyph_index(ch)?;
Some((face, gid))
})?;
let k = cap_scale(&face);
let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k;
let mut fl = OutlineFlattener::new(k);
@@ -389,11 +396,95 @@ fn build_fallback(ch: char) -> Option<Arc<Glyph>> {
None
}
/// Web: no system fonts → no shaping (TTF faces don't occur on the web anyway,
/// since system-font discovery is empty).
#[cfg(target_arch = "wasm32")]
fn build_shaped(_family: &str, _text: &str) -> Option<ShapedRun> {
None
fn build_shaped(_family: &str, text: &str) -> Option<ShapedRun> {
use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping};
let mut fonts: Vec<Arc<Vec<u8>>> = Vec::new();
let mut waiting = false;
for script in text
.chars()
.filter_map(crate::scene::text::web_font::script_of)
{
match crate::scene::text::web_font::request(script) {
Some(bytes) => {
if !fonts.iter().any(|loaded| Arc::ptr_eq(loaded, &bytes)) {
fonts.push(bytes);
}
}
None => waiting = true,
}
}
if waiting || fonts.is_empty() {
return None;
}
let primary_face = ttf_parser::Face::parse(&fonts[0], 0).ok()?;
let upem = primary_face.units_per_em() as f32;
let cap = primary_face
.capital_height()
.filter(|height| *height > 0)
.map(|height| height as f32)
.unwrap_or(0.7 * upem);
let px_to_9 = CAP_UNITS * upem / (SHAPE_FS * cap);
let mut database = fontdb::Database::new();
for bytes in fonts {
database.load_font_data((*bytes).clone());
}
database.set_sans_serif_family(crate::scene::text::web_font::primary_script().family());
let mut font_system = FontSystem::new_with_locale_and_db(
crate::i18n::active_language_tag(),
database,
);
let attrs = Attrs::new().family(Family::SansSerif);
let mut buffer = Buffer::new(&mut font_system, Metrics::new(SHAPE_FS, SHAPE_FS));
buffer.set_size(&mut font_system, None, None);
buffer.set_text(&mut font_system, text, &attrs, Shaping::Advanced, None);
buffer.shape_until_scroll(&mut font_system, false);
let mut glyphs = Vec::new();
let mut advance = 0.0_f32;
for run in buffer.layout_runs() {
advance = advance.max(run.line_w * px_to_9);
for glyph in run.glyphs.iter() {
let face_index = font_system
.db_mut()
.face(glyph.font_id)
.map(|face| face.index)
.unwrap_or(0);
let Some(font) = font_system.get_font(glyph.font_id, glyph.font_weight) else {
continue;
};
let Ok(face) = ttf_parser::Face::parse(font.data(), face_index) else {
continue;
};
let scale = (SHAPE_FS / face.units_per_em() as f32) * px_to_9;
let pen_x = (glyph.x + SHAPE_FS * glyph.x_offset) * px_to_9;
let pen_y = -(SHAPE_FS * glyph.y_offset) * px_to_9;
let mut flattener = OutlineFlattener::new(scale);
flattener.offset = [pen_x, pen_y];
face.outline_glyph(
ttf_parser::GlyphId(glyph.glyph_id),
&mut flattener,
);
flattener.flush();
if flattener.contours.is_empty() {
continue;
}
let fill_tris = triangulate_contours(&flattener.contours);
glyphs.push(PlacedGlyph {
strokes: flattener.contours,
fill_tris,
});
}
}
if advance <= 0.0 && glyphs.is_empty() {
None
} else {
Some(ShapedRun { glyphs, advance })
}
}
#[cfg(not(target_arch = "wasm32"))]
+164 -17
View File
@@ -1,17 +1,19 @@
//! Per-script web font store + lazy fetch (#141).
//! Shared web font store + per-script fetch (#141).
//!
//! The desktop build outlines glyphs for non-LFF scripts from the user's
//! installed system fonts (see `ttf_glyph::build_fallback`). The web build has
//! no system-font access, so it instead lazily fetches a per-script Noto subset
//! — served from `fonts/<script>.ttf`, one alphabet per file — the first time a
//! drawing uses that script, then outlines glyphs from it. Splitting per script
//! keeps each download small (Latin/Cyrillic/Greek ~50–100 KB; CJK loads only
//! when a CJK drawing is opened).
//! no system-font access, so startup fetches the active UI language's Noto
//! subset and shares those bytes with UI, drawing text, and navigation labels.
//! Every language file also carries the common Latin ranges; other scripts are
//! still fetched on demand when a drawing needs them. CJK remains split by
//! language because the same code point can require a different glyph form.
//!
//! The store and fetch are web-only; the desktop side keeps no-op stubs so the
//! shared call sites (`ttf_glyph`, the app message loop) compile unchanged.
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
#[cfg(not(target_arch = "wasm32"))]
use std::sync::Arc;
/// A script we ship a Noto subset for. [`script_of`] maps a char to one.
///
@@ -51,11 +53,131 @@ impl Script {
Script::Korean => "fonts/korean.ttf",
}
}
pub fn family(self) -> &'static str {
match self {
Script::Chinese => "Noto Sans CJK SC",
Script::Japanese => "Noto Sans CJK JP",
Script::Korean => "Noto Sans CJK KR",
Script::Latin
| Script::Cyrillic
| Script::Greek
| Script::Arabic
| Script::Hebrew
| Script::Thai
| Script::Devanagari => "Noto Sans",
}
}
fn from_u8(value: u8) -> Self {
match value {
1 => Script::Cyrillic,
2 => Script::Greek,
3 => Script::Arabic,
4 => Script::Hebrew,
5 => Script::Thai,
6 => Script::Devanagari,
7 => Script::Chinese,
8 => Script::Japanese,
9 => Script::Korean,
_ => Script::Latin,
}
}
#[cfg(target_arch = "wasm32")]
fn as_u8(self) -> u8 {
match self {
Script::Latin => 0,
Script::Cyrillic => 1,
Script::Greek => 2,
Script::Arabic => 3,
Script::Hebrew => 4,
Script::Thai => 5,
Script::Devanagari => 6,
Script::Chinese => 7,
Script::Japanese => 8,
Script::Korean => 9,
}
}
}
/// Language used to render shared Han ideographs: 0 = Chinese, 1 = Japanese,
/// 2 = Korean. Set from the active document's code page.
static CJK_LANG: AtomicU8 = AtomicU8::new(0);
static PRIMARY_SCRIPT: AtomicU8 = AtomicU8::new(0);
static FONT_GENERATION: AtomicU64 = AtomicU64::new(0);
pub fn generation() -> u64 {
FONT_GENERATION.load(Ordering::Relaxed)
}
fn bump_generation() {
FONT_GENERATION.fetch_add(1, Ordering::Relaxed);
}
#[cfg(target_arch = "wasm32")]
pub fn scripts_for_language_tag(language: &str) -> Vec<Script> {
let language = language
.split(['-', '_'])
.next()
.unwrap_or(language)
.to_ascii_lowercase();
let script = match language.as_str() {
"ar" | "fa" | "ur" => Some(Script::Arabic),
"el" => Some(Script::Greek),
"he" | "yi" => Some(Script::Hebrew),
"hi" | "mr" | "ne" => Some(Script::Devanagari),
"ja" => Some(Script::Japanese),
"ko" => Some(Script::Korean),
"ru" | "uk" | "bg" | "sr" => Some(Script::Cyrillic),
"th" => Some(Script::Thai),
"zh" => Some(Script::Chinese),
_ => None,
};
vec![script.unwrap_or(Script::Latin)]
}
#[cfg(target_arch = "wasm32")]
pub fn preload_language(language: &str) -> Script {
let script = scripts_for_language_tag(language)
.into_iter()
.next()
.unwrap_or(Script::Latin);
PRIMARY_SCRIPT.store(script.as_u8(), Ordering::Relaxed);
bump_generation();
let _ = imp::request(script);
script
}
pub fn primary_script() -> Script {
Script::from_u8(PRIMARY_SCRIPT.load(Ordering::Relaxed))
}
pub fn requires_shaping(text: &str) -> bool {
#[cfg(target_arch = "wasm32")]
{
return text.chars().any(|ch| {
matches!(
ch as u32,
0x0590..=0x05FF
| 0x0600..=0x06FF
| 0x0750..=0x077F
| 0x08A0..=0x08FF
| 0x0900..=0x097F
| 0x0E00..=0x0E7F
| 0xA8E0..=0xA8FF
| 0xFB1D..=0xFB4F
| 0xFB50..=0xFDFF
| 0xFE70..=0xFEFF
)
});
}
#[cfg(not(target_arch = "wasm32"))]
{
let _ = text;
false
}
}
#[cfg(target_arch = "wasm32")]
fn cjk_lang() -> Script {
@@ -94,7 +216,7 @@ pub fn script_of(ch: char) -> Option<Script> {
0x0600..=0x06FF | 0x0750..=0x077F | 0x08A0..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFF => {
Script::Arabic
}
0x0900..=0x097F => Script::Devanagari,
0x0900..=0x097F | 0xA8E0..=0xA8FF => Script::Devanagari,
// Hangul → always Korean; kana → always Japanese.
0x1100..=0x11FF | 0x3130..=0x318F | 0xAC00..=0xD7A3 => Script::Korean,
0x3040..=0x30FF | 0x31F0..=0x31FF => Script::Japanese,
@@ -155,11 +277,32 @@ mod imp {
/// Record a fetch result: `Some(bytes)` on success, `None` on failure.
pub fn insert(script: Script, bytes: Option<Vec<u8>>) {
let st = match bytes {
Some(b) => State::Loaded(Arc::new(b)),
None => State::Failed,
};
store().lock().unwrap().states.insert(script, st);
let mut store = store().lock().unwrap();
match bytes {
Some(bytes) => {
let bytes = Arc::new(bytes);
store.states.insert(script, State::Loaded(bytes.clone()));
if script != Script::Latin {
store
.states
.entry(Script::Latin)
.or_insert_with(|| State::Loaded(bytes));
}
}
None => {
store.states.insert(script, State::Failed);
}
}
drop(store);
super::bump_generation();
}
pub fn loaded(script: Script) -> Option<Arc<Vec<u8>>> {
let s = store().lock().unwrap();
match s.states.get(&script) {
Some(State::Loaded(bytes)) => Some(bytes.clone()),
_ => None,
}
}
/// Fetch a script font over HTTP from the same origin.
@@ -183,8 +326,7 @@ mod imp {
#[cfg(not(target_arch = "wasm32"))]
mod imp {
use super::Script;
use std::sync::Arc;
use super::{Arc, Script};
// Kept for parity with the wasm impl; only the wasm path has a caller.
#[allow(dead_code)]
@@ -194,13 +336,18 @@ mod imp {
pub fn take_pending() -> Vec<Script> {
Vec::new()
}
pub fn insert(_script: Script, _bytes: Option<Vec<u8>>) {}
pub fn insert(_script: Script, _bytes: Option<Vec<u8>>) {
super::bump_generation();
}
pub fn loaded(_script: Script) -> Option<Arc<Vec<u8>>> {
None
}
pub async fn fetch(_script: Script) -> Result<Vec<u8>, String> {
Err("web only".into())
}
}
pub use imp::{fetch, insert, take_pending};
pub use imp::{fetch, insert, loaded, take_pending};
// `request` only has a caller in the wasm font-fallback path.
#[cfg(target_arch = "wasm32")]
pub use imp::request;