forked from 1b-agent/opencadstudio-upstream
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:
1 parent
8dc642d40c
commit
53cf31f33d
26 files changed
+1337
-522
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
Reference in new issue
Block a user