Files
OneCAD-Draft/src/scene/text/ttf_glyph.rs
T
Chris YehandClaude Opus 5.5 5a36ecda74 fix(text): hint TrueType fonts that need the interpreter (DFKai-SB, MingLiU) before outlining
DFKai-SB (kaiu.ttf), MingLiU and other early DynaLab fonts assemble each
ideograph from stroke components that only the TrueType instructions move
into place. ttf-parser's `outline_glyph` returns the raw, unhinted `glyf`
outline, so these fonts rendered with missing or displaced strokes
('自' drawn as '目'; 範, 直, 啟, 動 broken). FreeType calls them "tricky"
and always runs the interpreter for them.

Every non-test `outline_glyph` call site in ttf_glyph.rs (`glyph`,
`outline_char` - shared by both native fallback paths -, the web
`build_fallback`, and both `build_shaped` variants) now goes through
`outline_glyph_into`; `outline_char` takes the font data and face index
for that. When skrifa's `require_interpreter()` says the face
is hint-reliant, the glyph is hinted with the TrueType interpreter
(Target::Mono) at 512 ppem and scaled back to font units (x upem / 512);
every other font keeps the exact ttf-parser outline. The decision and the
HintingInstance are cached once per face, keyed by (byte length, face
index, head.checkSumAdjustment) because fontdb may hand out a new buffer
per call. The hinted path is recorded first and replayed only on success,
so a failed draw falls back to ttf-parser without a half-drawn glyph.

skrifa 0.42.1 is already in the lockfile (via hayro / glifo).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 21:44:52 +08:00

1165 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// TrueType/OpenType glyph → CAD wire strokes.
//
// Pulls a system font's bytes from `sysfont`, extracts a glyph's outline with
// `ttf-parser`, and flattens the quadratic/cubic Bézier contours into polyline
// strokes — the same `lff::Glyph` shape the LFF stroke engine produces, so the
// text layout code can consume either without caring which font kind it is.
//
// Coordinates are normalized to the **same 9-unit cap-height space the LFF
// engine uses**: a capital letter is 9 units tall with the baseline at y = 0.
// That makes a TTF glyph a drop-in for an LFF one — the existing text layout
// scales both by `height / 9.0`, so TTF and stroke text share one pipeline.
// Cap height comes from the font's OS/2 table; if absent we approximate it as
// 0.7 × units-per-em.
//
// Unlike LFF single-stroke glyphs, TTF contours are closed outlines (the glyph
// boundary). Counters — the hole in "O" or "A" — come through as separate
// closed contours; the wire renderer simply draws every contour.
use crate::scene::text::lff::Glyph;
use crate::scene::text::sysfont;
use rustc_hash::FxHashMap as HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use lyon_tessellation::math::point;
use lyon_tessellation::path::Path;
use lyon_tessellation::{
BuffersBuilder, FillOptions, FillRule, FillTessellator, FillVertex, VertexBuffers,
};
/// Bézier flattening step counts. Outlines are small on screen most of the
/// time; these are a fixed budget that keeps curves smooth without exploding
/// vertex counts. Cubic gets more steps because OTF/CFF curves swing wider.
const QUAD_STEPS: usize = 8;
const CUBIC_STEPS: usize = 12;
/// Glyph-unit cap height the layout scales by `height / 9.0`.
const CAP_UNITS: f32 = 9.0;
/// Collects `ttf-parser` outline callbacks into closed contours, normalized so
/// the font's cap height equals [`CAP_UNITS`].
struct OutlineFlattener {
/// Font-unit → 9-unit-cap-height scale factor.
k: f32,
/// Pen position (9-unit) added to every vertex — lets a shaped glyph carry
/// its run offset. Zero for a standalone glyph.
offset: [f32; 2],
contours: Vec<Vec<[f32; 2]>>,
cur: Vec<[f32; 2]>,
start: [f32; 2],
pos: [f32; 2],
}
impl OutlineFlattener {
fn new(k: f32) -> Self {
OutlineFlattener {
k,
offset: [0.0, 0.0],
contours: Vec::new(),
cur: Vec::new(),
start: [0.0, 0.0],
pos: [0.0, 0.0],
}
}
fn n(&self, x: f32, y: f32) -> [f32; 2] {
[x * self.k + self.offset[0], y * self.k + self.offset[1]]
}
fn flush(&mut self) {
if self.cur.len() >= 2 {
self.contours.push(std::mem::take(&mut self.cur));
} else {
self.cur.clear();
}
}
}
impl ttf_parser::OutlineBuilder for OutlineFlattener {
fn move_to(&mut self, x: f32, y: f32) {
self.flush();
let p = self.n(x, y);
self.start = p;
self.pos = p;
self.cur.push(p);
}
fn line_to(&mut self, x: f32, y: f32) {
let p = self.n(x, y);
self.pos = p;
self.cur.push(p);
}
fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
let p0 = self.pos;
let c = self.n(cx, cy);
let p = self.n(x, y);
for i in 1..=QUAD_STEPS {
let t = i as f32 / QUAD_STEPS as f32;
let u = 1.0 - t;
let bx = u * u * p0[0] + 2.0 * u * t * c[0] + t * t * p[0];
let by = u * u * p0[1] + 2.0 * u * t * c[1] + t * t * p[1];
self.cur.push([bx, by]);
}
self.pos = p;
}
fn curve_to(&mut self, c1x: f32, c1y: f32, c2x: f32, c2y: f32, x: f32, y: f32) {
let p0 = self.pos;
let c1 = self.n(c1x, c1y);
let c2 = self.n(c2x, c2y);
let p = self.n(x, y);
for i in 1..=CUBIC_STEPS {
let t = i as f32 / CUBIC_STEPS as f32;
let u = 1.0 - t;
let bx = u * u * u * p0[0]
+ 3.0 * u * u * t * c1[0]
+ 3.0 * u * t * t * c2[0]
+ t * t * t * p[0];
let by = u * u * u * p0[1]
+ 3.0 * u * u * t * c1[1]
+ 3.0 * u * t * t * c2[1]
+ t * t * t * p[1];
self.cur.push([bx, by]);
}
self.pos = p;
}
fn close(&mut self) {
// Close the ring back to its start so the wire forms a loop.
if self.cur.first().map_or(false, |f| *f != self.pos) {
self.cur.push(self.start);
}
self.flush();
}
}
// ── Hint-reliant ("tricky") fonts ───────────────────────────────────────────
//
// 標楷體(DFKai-SB)、細明體(MingLiU)等早期華康字型是「筆畫拼字」:每個字由
// 筆畫部件組成,部件要靠 TrueType 指令才會移到正確位置。直接讀 `glyf` 原始輪廓
// 會缺筆畫(「自」變「目」、範/直/啟/動缺筆)。FreeType 稱這類字型為 tricky。
// 對這些字型改用 skrifa 的 TrueType 解譯器在大字級 hinting,再換回字型單位。
//
// DFKai-SB, MingLiU and other early DynaLab fonts build each ideograph from
// stroke components that only the TrueType instructions move into place, so
// the raw `glyf` outline is missing strokes. FreeType calls them "tricky".
// For those fonts we run skrifa's TrueType interpreter at a large ppem and
// scale the result back to font units; every other font keeps ttf-parser.
/// ppem the interpreter hints at: large enough that grid rounding is only
/// 1/512 em, so the hinted outline stays close to the design.
/// 解譯器 hinting 用的字級:夠大,格點捨入只有 1/512 em,輪廓貼近原設計。
const HINT_PPEM: f32 = 512.0;
/// Identifies one face without hashing its bytes. fontdb may hand out a fresh
/// buffer on every `with_face_data`, so the slice address is not stable.
/// 不雜湊整個字型就能辨識一個字面;fontdb 每次可能給新緩衝區,指標不可靠。
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
struct FaceKey {
len: usize,
index: u32,
checksum_adjustment: u32,
}
/// `Some(instance)` for a font that needs the interpreter, `None` otherwise.
/// 需要解譯器的字型存 `Some(hinting 實例)`,其他存 `None`。
type HintCache = HashMap<FaceKey, Option<Arc<skrifa::outline::HintingInstance>>>;
fn hint_cache() -> &'static Mutex<HintCache> {
static CACHE: OnceLock<Mutex<HintCache>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::default()))
}
/// Hinting instance for `font`, or `None` when it renders correctly unhinted.
/// `require_interpreter` reads the name table and may checksum tables, so the
/// answer (and the instance) is cached per face.
/// 取得該字型的 hinting 實例;不需要解譯器時回 `None`。
/// `require_interpreter` 會讀 name 表、可能算 checksum,很慢,所以每個字面快取一次。
fn hinter_for(
font: &skrifa::FontRef,
key: FaceKey,
) -> Option<Arc<skrifa::outline::HintingInstance>> {
use skrifa::instance::{LocationRef, Size};
use skrifa::outline::{Engine, HintingInstance, HintingOptions, Target};
use skrifa::MetadataProvider;
if let Some(hit) = hint_cache().lock().unwrap().get(&key) {
return hit.clone();
}
let outlines = font.outline_glyphs();
let built = if outlines.require_interpreter() {
// FreeType's recipe for tricky fonts: the bytecode interpreter in
// monochrome mode. / FreeType 對 tricky 字型的做法:解譯器+單色模式。
let options = HintingOptions {
engine: Engine::Interpreter,
target: Target::Mono,
};
match HintingInstance::new(
&outlines,
Size::new(HINT_PPEM),
LocationRef::default(),
options,
) {
Ok(instance) => Some(Arc::new(instance)),
Err(e) => {
// Logged once per face (the `None` is cached): the font falls
// back to its raw outlines, which may be missing strokes.
// 每個字面只記一次;退回原始輪廓,可能缺筆畫。
log::warn!(
"TrueType hinting failed for a hint-reliant font; using raw outlines: {e:?}"
);
None
}
}
} else {
None
};
hint_cache().lock().unwrap().insert(key, built.clone());
built
}
/// One recorded path command, already scaled to font units.
/// 一筆已換回字型單位的路徑指令。
enum PathCmd {
Move(f32, f32),
Line(f32, f32),
Quad(f32, f32, f32, f32),
Cubic(f32, f32, f32, f32, f32, f32),
Close,
}
/// skrifa pen that records hinted pixel coordinates as font units, so a draw
/// that fails halfway never leaves a partial glyph in the real builder.
/// 把 hinting 後的像素座標換回字型單位先記下來;畫到一半失敗也不會污染真正的 builder。
struct RecordingPen {
/// Pixel → font-unit factor (`upem / HINT_PPEM`). / 像素→字型單位係數。
k: f32,
cmds: Vec<PathCmd>,
}
impl skrifa::outline::OutlinePen for RecordingPen {
fn move_to(&mut self, x: f32, y: f32) {
self.cmds.push(PathCmd::Move(x * self.k, y * self.k));
}
fn line_to(&mut self, x: f32, y: f32) {
self.cmds.push(PathCmd::Line(x * self.k, y * self.k));
}
fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
let k = self.k;
self.cmds.push(PathCmd::Quad(cx * k, cy * k, x * k, y * k));
}
fn curve_to(&mut self, c1x: f32, c1y: f32, c2x: f32, c2y: f32, x: f32, y: f32) {
let k = self.k;
self.cmds.push(PathCmd::Cubic(
c1x * k,
c1y * k,
c2x * k,
c2y * k,
x * k,
y * k,
));
}
fn close(&mut self) {
self.cmds.push(PathCmd::Close);
}
}
/// Hinted outline of `gid` in font units, or `None` when the font needs no
/// interpreter (or hinting failed) and the caller should use ttf-parser.
/// 回傳字型單位的 hinting 輪廓;字型不需要解譯器(或 hinting 失敗)時回 `None`。
fn hinted_outline(data: &[u8], index: u32, gid: ttf_parser::GlyphId) -> Option<Vec<PathCmd>> {
use skrifa::outline::DrawSettings;
use skrifa::raw::TableProvider;
use skrifa::MetadataProvider;
let font = skrifa::FontRef::from_index(data, index).ok()?;
let head = font.head().ok()?;
let key = FaceKey {
len: data.len(),
index,
checksum_adjustment: head.checksum_adjustment(),
};
let hinter = hinter_for(&font, key)?;
let glyph = font
.outline_glyphs()
.get(skrifa::GlyphId::new(gid.0 as u32))?;
let mut pen = RecordingPen {
k: head.units_per_em().max(1) as f32 / HINT_PPEM,
cmds: Vec::new(),
};
glyph
.draw(DrawSettings::hinted(&hinter, false), &mut pen)
.ok()?;
Some(pen.cmds)
}
/// Outline glyph `gid` of face `index` in `data` into `builder` (font units,
/// y-up) — the drop-in replacement for `face.outline_glyph` used everywhere in
/// this file. Hint-reliant fonts go through the TrueType interpreter first.
/// 把字形輪廓畫進 `builder`(字型單位、y 向上),取代本檔所有 `face.outline_glyph`;
/// 筆畫拼字字型先經 TrueType 解譯器 hinting。
fn outline_glyph_into(
data: &[u8],
index: u32,
face: &ttf_parser::Face,
gid: ttf_parser::GlyphId,
builder: &mut impl ttf_parser::OutlineBuilder,
) {
let Some(cmds) = hinted_outline(data, index, gid) else {
face.outline_glyph(gid, builder);
return;
};
for cmd in cmds {
match cmd {
PathCmd::Move(x, y) => builder.move_to(x, y),
PathCmd::Line(x, y) => builder.line_to(x, y),
PathCmd::Quad(cx, cy, x, y) => builder.quad_to(cx, cy, x, y),
PathCmd::Cubic(c1x, c1y, c2x, c2y, x, y) => builder.curve_to(c1x, c1y, c2x, c2y, x, y),
PathCmd::Close => builder.close(),
}
}
}
// ── Cache ────────────────────────────────────────────────────────────────────
type GlyphCache = HashMap<(String, char), Option<Arc<Glyph>>>;
fn cache() -> &'static Mutex<GlyphCache> {
static CACHE: OnceLock<Mutex<GlyphCache>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::default()))
}
/// Em-normalized outline for one character of a system font family. Returns
/// `None` if the family is not installed or the glyph is missing. Result is
/// cached per `(family, char)`.
pub fn glyph(family: &str, ch: char) -> Option<Arc<Glyph>> {
let key = (family.to_string(), ch);
if let Some(hit) = cache().lock().unwrap().get(&key) {
return hit.clone();
}
let built = sysfont::with_face_data(family, |data, index| {
let face = ttf_parser::Face::parse(data, index).ok()?;
let gid = face.glyph_index(ch)?;
let k = cap_scale(&face);
let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k;
let mut fl = OutlineFlattener::new(k);
// A glyph with no outline (e.g. space) still has a valid advance.
outline_glyph_into(data, index, &face, gid, &mut fl);
fl.flush();
let fill_tris = triangulate_contours(&fl.contours);
Some(Arc::new(Glyph {
strokes: fl.contours,
advance,
fill_tris,
}))
})
.flatten();
cache().lock().unwrap().insert(key, built.clone());
built
}
/// Font-unit → 9-unit-em-box factor: the whole em square maps onto the text
/// height. This is how an SHX big font (`chineset.shx`, `hztxt.shx`, …)
/// sizes its ideographs — a CJK glyph is as tall as the text height and one
/// text height wide — so a TrueType glyph standing in for a missing big-font
/// glyph must use the same box, not the Latin cap height. Cap-height scaling
/// makes ideographs ~1.3–1.5× too big (1 em ≈ 1.3–1.5 cap heights in CJK
/// fonts), so every line of substituted Chinese text ran past its frame.
fn em_scale(face: &ttf_parser::Face) -> f32 {
CAP_UNITS / face.units_per_em().max(1) as f32
}
/// Ideographic / full-width characters: the ones a big font would supply and
/// that sit on an em box rather than the Latin cap height.
pub(crate) fn is_full_width(ch: char) -> bool {
matches!(
ch as u32,
0x1100..=0x11FF // Hangul Jamo
| 0x2E80..=0x2FDF // CJK / Kangxi radicals
| 0x2FF0..=0x303F // ideographic description, CJK symbols & punctuation
| 0x3040..=0x30FF // Hiragana, Katakana
| 0x3100..=0x312F // Bopomofo
| 0x3130..=0x318F // Hangul compatibility Jamo
| 0x3190..=0x31FF // Kanbun, Bopomofo ext., CJK strokes, Katakana ext.
| 0x3200..=0x33FF // enclosed CJK, CJK compatibility
| 0x3400..=0x4DBF // CJK ext. A
| 0x4E00..=0x9FFF // CJK unified ideographs
| 0xA960..=0xA97F // Hangul Jamo ext. A
| 0xAC00..=0xD7FF // Hangul syllables, Jamo ext. B
| 0xF900..=0xFAFF // CJK compatibility ideographs
| 0xFE30..=0xFE4F // CJK compatibility forms
| 0xFF01..=0xFF60 // full-width ASCII variants
| 0xFFE0..=0xFFE6 // full-width symbols
| 0x20000..=0x3FFFF // CJK ext. B–H
)
}
/// Font-unit → 9-unit-cap-height factor for a parsed face. Cap height comes
/// from the OS/2 table; absent, we approximate it as 0.7 × units-per-em.
fn cap_scale(face: &ttf_parser::Face) -> f32 {
let upem = face.units_per_em() as f32;
let cap = face
.capital_height()
.filter(|&c| c > 0)
.map(|c| c as f32)
.unwrap_or(0.7 * upem);
CAP_UNITS / cap
}
fn triangulate_contours(contours: &[Vec<[f32; 2]>]) -> Vec<[f32; 2]> {
if contours.is_empty() {
return Vec::new();
}
let mut builder = Path::builder();
for contour in contours {
if contour.len() < 3 {
continue;
}
builder.begin(point(contour[0][0], contour[0][1]));
for p in &contour[1..] {
builder.line_to(point(p[0], p[1]));
}
builder.end(true);
}
let path = builder.build();
let mut geometry: VertexBuffers<[f32; 2], u32> = VertexBuffers::new();
let mut tessellator = FillTessellator::new();
// TrueType outlines use the nonzero winding rule; lyon's default even-odd
// rule mis-fills glyphs whose contours overlap. On a tessellation failure
// the glyph falls back to an empty fill silently — per-glyph logging would
// otherwise spam stderr once per character for a malformed font.
if tessellator
.tessellate_path(
&path,
&FillOptions::default().with_fill_rule(FillRule::NonZero),
&mut BuffersBuilder::new(&mut geometry, |vertex: FillVertex| {
vertex.position().to_array()
}),
)
.is_err()
{
return Vec::new();
}
let mut tris = Vec::with_capacity(geometry.indices.len());
for &idx in &geometry.indices {
if let Some(&p) = geometry.vertices.get(idx as usize) {
tris.push(p);
}
}
tris
}
// ── Shaping ────────────────────────────────────────────────────────────────
/// One shaped glyph, positioned within its run. Strokes are in 9-unit space and
/// already carry the glyph's pen position (cumulative advance + shaping offset),
/// so the caller only applies the run's own transform.
pub struct PlacedGlyph {
pub strokes: Vec<Vec<[f32; 2]>>,
pub fill_tris: Vec<[f32; 2]>,
}
/// A fully shaped run.
pub struct ShapedRun {
pub glyphs: Vec<PlacedGlyph>,
/// Total pen advance of the run (9-unit).
pub advance: f32,
}
type ShapeCache = HashMap<(String, String), Option<Arc<ShapedRun>>>;
fn shape_cache() -> &'static Mutex<ShapeCache> {
static CACHE: OnceLock<Mutex<ShapeCache>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::default()))
}
/// Pixel font size the layout runs at. Positions and outlines are normalized
/// out of pixel space afterwards, so the exact value only affects rounding.
#[allow(dead_code)]
const SHAPE_FS: f32 = 1000.0;
/// Shared cosmic-text layout engine, loaded once with the system fonts. Held
/// behind a mutex because `set_text`/`shape` need `&mut FontSystem`.
///
/// Native only: on the web there are no system fonts, so cosmic-text would
/// panic with "no default font found" — the shaping / fallback paths are
/// disabled there instead (LFF stroke fonts still render).
#[cfg(not(target_arch = "wasm32"))]
fn font_system() -> &'static Mutex<cosmic_text::FontSystem> {
static FS: OnceLock<Mutex<cosmic_text::FontSystem>> = OnceLock::new();
FS.get_or_init(|| Mutex::new(cosmic_text::FontSystem::new()))
}
/// Lay out and shape `text` in `family` with cosmic-text — ligatures, Arabic
/// joining, kerning, bidi reordering, and automatic font fallback (a glyph the
/// chosen family lacks is taken from another installed font). Each resulting
/// glyph is outlined from its *resolved* font and normalized into 9-unit
/// cap-height space (cap height of the primary family). Cached per
/// `(family, text)`; `None` for a non-system family.
pub fn shape_run(family: &str, text: &str) -> Option<Arc<ShapedRun>> {
if text.is_empty() {
return None;
}
let key = (family.to_string(), text.to_string());
if let Some(hit) = shape_cache().lock().unwrap().get(&key) {
return hit.clone();
}
let built = build_shaped(family, text).map(Arc::new);
shape_cache().lock().unwrap().insert(key, built.clone());
built
}
type FallbackCache = HashMap<char, Option<Arc<Glyph>>>;
fn fallback_cache() -> &'static Mutex<FallbackCache> {
static CACHE: OnceLock<Mutex<FallbackCache>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::default()))
}
/// Last-resort glyph for a character missing from a stroke (LFF) font: let
/// cosmic-text pick whichever installed font covers it, then outline it from
/// that font normalized to 9-unit cap height. Returns `None` if no system font
/// has the character. Cached per character.
///
/// The result is a filled-outline glyph, so it visually differs from the
/// surrounding single-stroke text — accepted as the price of covering scripts
/// no stroke font provides.
pub fn fallback_glyph(ch: char) -> Option<Arc<Glyph>> {
if let Some(hit) = fallback_cache().lock().unwrap().get(&ch) {
return hit.clone();
}
let built = build_fallback(ch);
fallback_cache().lock().unwrap().insert(ch, built.clone());
built
}
/// Drop the cached fallback glyphs so the next lookup re-resolves. The web
/// build calls this when a per-script font finishes loading: glyphs that
/// resolved to `None` while the font was still in flight then get a real
/// 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();
}
#[cfg(test)]
mod fallback_tests {
use super::*;
/// A fallback that comes back with an id but no outline draws nothing while
/// believing it drew something: the stroke fonts hand every letter they
/// lack to this path, so on macOS a Cyrillic drawing came out blank.
#[test]
fn a_fallback_glyph_is_never_empty() {
for ch in ['Ğ', 'ş', 'б', 'Я', 'Ω', '中', 'A'] {
let Some(glyph) = fallback_glyph(ch) else {
continue; // no font on this host covers it at all
};
assert!(
!glyph.strokes.is_empty() || !glyph.fill_tris.is_empty(),
"'{ch}' resolved to a glyph with no outline"
);
}
}
}
/// Last-resort glyph for a character missing from a stroke (LFF) font: whatever
/// this machine can draw it with, normalized to 9-unit cap height, or `None`
/// when nothing installed has the character. Cached per character.
///
/// The result is a filled-outline glyph, so it visually differs from the
/// surrounding single-stroke text — accepted as the price of covering scripts
/// no stroke font provides.
#[cfg(not(target_arch = "wasm32"))]
fn build_fallback(ch: char) -> Option<Arc<Glyph>> {
outline_from_fallback_face(ch).or_else(|| installed_family_glyph(ch))
}
/// Outline `ch` from a parsed face, in the 9-unit text space.
///
/// A fallback glyph stands in for a stroke / SHX font that lacks the character.
/// For an ideograph that font would have been a big font, whose glyphs fill the
/// text height — so size the substitute by its em box. Everything else keeps the
/// cap-height normalisation that lines it up with the Latin stroke glyphs.
#[cfg(not(target_arch = "wasm32"))]
fn outline_char(data: &[u8], index: u32, face: &ttf_parser::Face, ch: char) -> Option<Glyph> {
let gid = face.glyph_index(ch)?;
let k = if is_full_width(ch) {
em_scale(face)
} else {
cap_scale(face)
};
let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k;
let mut fl = OutlineFlattener::new(k);
outline_glyph_into(data, index, face, gid, &mut fl);
fl.flush();
let fill_tris = triangulate_contours(&fl.contours);
Some(Glyph {
strokes: fl.contours,
advance,
fill_tris,
})
}
/// Outline `ch` from the face cosmic-text picks for it: it knows the platform's
/// fallback order and shapes its way through ligatures and joining.
///
/// A face can answer with a glyph id and no outline at all — macOS's system font
/// does, for every character, because ttf-parser reads no contours out of it —
/// so an empty outline is not an answer and the caller has to keep looking.
#[cfg(not(target_arch = "wasm32"))]
fn outline_from_fallback_face(ch: char) -> Option<Arc<Glyph>> {
use cosmic_text::{Attrs, Buffer, Metrics, Shaping};
let mut fs = font_system().lock().unwrap();
// Default family → cosmic's own fallback search chooses a covering font.
let attrs = Attrs::new();
let mut buf = Buffer::new(&mut fs, Metrics::new(SHAPE_FS, SHAPE_FS));
buf.set_size(&mut fs, None, None);
let s = ch.to_string();
buf.set_text(&mut fs, &s, &attrs, Shaping::Advanced, None);
buf.shape_until_scroll(&mut fs, false);
for run in buf.layout_runs() {
for g in run.glyphs.iter() {
if g.glyph_id == 0 {
continue; // .notdef — this font doesn't really cover it
}
let face_index = fs.db_mut().face(g.font_id).map(|f| f.index).unwrap_or(0);
let font = fs.get_font(g.font_id, g.font_weight)?;
let face = ttf_parser::Face::parse(font.data(), face_index).ok()?;
let Some(glyph) = outline_char(font.data(), face_index, &face, ch) else {
continue;
};
if glyph.strokes.is_empty() && glyph.fill_tris.is_empty() {
continue;
}
return Some(Arc::new(glyph));
}
}
None
}
/// The first installed family that actually draws `ch` — the net under
/// cosmic-text, whose fallback list is the platform's own handful of names (on
/// macOS: `.SF NS`, `Menlo`, `Apple Color Emoji`, `Geneva`, `Arial Unicode MS`)
/// and covers neither Cyrillic nor Greek, however many fonts the machine has
/// that do.
#[cfg(not(target_arch = "wasm32"))]
fn installed_family_glyph(ch: char) -> Option<Arc<Glyph>> {
crate::scene::text::sysfont::families()
.iter()
.find_map(|family| {
let glyph = sysfont::with_face_data(family, |data, index| {
let face = ttf_parser::Face::parse(data, index).ok()?;
outline_char(data, index, &face, ch)
})
.flatten()?;
// A face that yields no outline (the system font, a space) is not a
// font that draws this character.
(!glyph.strokes.is_empty() || !glyph.fill_tris.is_empty())
.then(|| Arc::new(glyph))
})
}
/// Web: outline the glyph from the lazily-fetched per-script Noto subset that
/// covers it. Returns `None` while that font is still loading (the char renders
/// once it arrives and the fallback cache is cleared). (#141)
#[cfg(target_arch = "wasm32")]
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_count = ttf_parser::fonts_in_collection(&bytes).unwrap_or(1);
let (face_index, 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_index, 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);
outline_glyph_into(&bytes, face_index, &face, gid, &mut fl);
fl.flush();
if fl.contours.is_empty() {
return None;
}
let fill_tris = triangulate_contours(&fl.contours);
Some(Arc::new(Glyph {
strokes: fl.contours,
advance,
fill_tris,
}))
}
#[cfg(target_arch = "wasm32")]
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];
outline_glyph_into(
font.data(),
face_index,
&face,
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"))]
fn build_shaped(family: &str, text: &str) -> Option<ShapedRun> {
use cosmic_text::{Attrs, Buffer, Family, Metrics, Shaping};
let mut primary_metrics = sysfont::with_face_data(family, |data, idx| {
let f = ttf_parser::Face::parse(data, idx).ok()?;
let upem = f.units_per_em() as f32;
let cap = f
.capital_height()
.filter(|&c| c > 0)
.map(|c| c as f32)
.unwrap_or(0.7 * upem);
Some((upem, cap))
})
.flatten();
let mut fs = font_system().lock().unwrap();
let attrs = if family.is_empty() {
Attrs::new()
} else {
Attrs::new().family(Family::Name(family))
};
let mut buf = Buffer::new(&mut fs, Metrics::new(SHAPE_FS, SHAPE_FS));
// No wrapping: a run is a single line.
buf.set_size(&mut fs, None, None);
buf.set_text(&mut fs, text, &attrs, Shaping::Advanced, None);
buf.shape_until_scroll(&mut fs, false);
if primary_metrics.is_none() {
for run in buf.layout_runs() {
for g in run.glyphs.iter() {
if g.glyph_id == 0 {
continue;
}
let face_index = fs.db_mut().face(g.font_id).map(|f| f.index).unwrap_or(0);
if let Some(font) = fs.get_font(g.font_id, g.font_weight) {
if let Ok(face) = ttf_parser::Face::parse(font.data(), face_index) {
let upem = face.units_per_em() as f32;
let cap = face
.capital_height()
.filter(|&c| c > 0)
.map(|c| c as f32)
.unwrap_or(0.7 * upem);
primary_metrics = Some((upem, cap));
break;
}
}
}
if primary_metrics.is_some() {
break;
}
}
}
let (upem_p, cap_p) = primary_metrics.unwrap_or((1000.0, 700.0));
// Pixel (at SHAPE_FS) → 9-unit cap-height factor.
let px_to_9 = CAP_UNITS * upem_p / (SHAPE_FS * cap_p);
let mut glyphs: Vec<PlacedGlyph> = Vec::new();
let mut advance = 0.0_f32;
for run in buf.layout_runs() {
advance = advance.max(run.line_w * px_to_9);
for g in run.glyphs.iter() {
let face_index = fs.db_mut().face(g.font_id).map(|f| f.index).unwrap_or(0);
let Some(font) = fs.get_font(g.font_id, g.font_weight) else {
continue;
};
let Ok(face) = ttf_parser::Face::parse(font.data(), face_index) else {
continue;
};
let upem_g = face.units_per_em() as f32;
// Glyph font-units → 9-unit: to pixels (at SHAPE_FS) then to units.
let scale_g = (SHAPE_FS / upem_g) * px_to_9;
// Absolute pen position (px) → 9-unit. `physical()` shows x_offset /
// y_offset are fractions of font size.
let pen_x = (g.x + SHAPE_FS * g.x_offset) * px_to_9;
let pen_y = -(SHAPE_FS * g.y_offset) * px_to_9; // outlines are y-up
let mut fl = OutlineFlattener::new(scale_g);
fl.offset = [pen_x, pen_y];
outline_glyph_into(
font.data(),
face_index,
&face,
ttf_parser::GlyphId(g.glyph_id),
&mut fl,
);
fl.flush();
if !fl.contours.is_empty() {
let fill_tris = triangulate_contours(&fl.contours);
glyphs.push(PlacedGlyph {
strokes: fl.contours,
fill_tris,
});
}
}
}
if advance <= 0.0 && glyphs.is_empty() {
return None;
}
Some(ShapedRun { glyphs, advance })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shape_run_falls_back_for_missing_script() {
let fams = sysfont::families();
if fams.is_empty() {
eprintln!("no system fonts; skipping");
return;
}
// A typical Latin family lacks CJK; cosmic-text should still resolve the
// ideograph from a fallback font. If the machine has no CJK font at all
// the count stays 1 — tolerated, but never a panic or empty run.
let fam = fams
.iter()
.find(|f| glyph(f, 'A').is_some())
.expect("family");
let run = shape_run(fam, "A中").expect("shaped");
eprintln!("fallback run glyphs={}", run.glyphs.len());
assert!(!run.glyphs.is_empty());
}
#[test]
fn fallback_ideograph_fills_the_text_height_like_a_big_font() {
// An SHX big font draws an ideograph one text height tall and one
// text height wide. The TrueType stand-in must match that box (9
// units), not the ~12–14 units the Latin cap-height normalisation
// gives a CJK em square — that overrun pushed every substituted
// Chinese line past its frame. Tolerated when the machine has no
// CJK font at all.
let Some(g) = fallback_glyph('中') else {
eprintln!("no CJK system font; skipping");
return;
};
assert!(
(g.advance - CAP_UNITS).abs() < 0.6,
"ideograph advance must be about one text height: got {}",
g.advance
);
let ink_h = g
.strokes
.iter()
.flatten()
.map(|p| p[1])
.fold((f32::MAX, f32::MIN), |(lo, hi), y| (lo.min(y), hi.max(y)));
assert!(
ink_h.1 - ink_h.0 <= CAP_UNITS * 1.05,
"ideograph ink must fit the text height: {:?}",
ink_h
);
// Latin fallback keeps the cap-height convention (shares a baseline
// with the stroke glyphs around it).
assert!(!is_full_width('A') && is_full_width('中') && is_full_width(','));
}
#[test]
fn shape_run_positions_glyphs() {
let fams = sysfont::families();
if fams.is_empty() { eprintln!("no fonts; skip"); return; }
let fam = fams.iter().find(|f| glyph(f, 'A').is_some()).expect("family");
let run = shape_run(fam, "AVA").expect("shaped");
eprintln!("glyphs={} advance={:.3}", run.glyphs.len(), run.advance);
assert_eq!(run.glyphs.len(), 3);
assert!(run.advance > 0.0);
// glyphs must be laid out left-to-right: each glyph's strokes sit at a
// greater x than the previous glyph's start.
let xs: Vec<f32> = run.glyphs.iter()
.filter_map(|g| g.strokes.iter().flatten().map(|p| p[0]).fold(None, |m,x| Some(m.map_or(x, |mm:f32| mm.min(x)))))
.collect();
assert!(xs.windows(2).all(|w| w[1] >= w[0] - 1.0), "glyphs not L->R: {:?}", xs);
}
#[test]
fn smoke_outline_first_family() {
let fams = sysfont::families();
if fams.is_empty() {
eprintln!("no system fonts; skipping");
return;
}
// Find any family that yields an 'A' outline.
let mut ok = false;
for fam in fams.iter().take(20) {
if let Some(g) = glyph(fam, 'A') {
eprintln!(
"{}: contours={} advance={:.4} verts={}",
fam,
g.strokes.len(),
g.advance,
g.strokes.iter().map(|s| s.len()).sum::<usize>()
);
assert!(g.advance > 0.0);
assert!(!g.strokes.is_empty());
ok = true;
break;
}
}
assert!(ok, "no family produced an 'A' outline");
}
// ── Hint-reliant fonts / 筆畫拼字字型 ─────────────────────────────────
/// Bytes of a font in the Windows font folder; `None` (→ skip) elsewhere.
/// 讀 Windows 字型資料夾的字型;沒有就回 `None`(測試跳過)。
fn windows_font(file: &str) -> Option<Vec<u8>> {
let dir = std::env::var_os("WINDIR")?;
std::fs::read(std::path::Path::new(&dir).join("Fonts").join(file)).ok()
}
/// Contours of `ch` in font units (flattener scale 1), either straight from
/// ttf-parser or through `outline_glyph_into`.
/// 取字型單位輪廓:直接 ttf-parser,或走 `outline_glyph_into`。
fn contours(data: &[u8], index: u32, ch: char, new_path: bool) -> Vec<Vec<[f32; 2]>> {
let face = ttf_parser::Face::parse(data, index).expect("face");
let gid = face.glyph_index(ch).expect("glyph");
let mut fl = OutlineFlattener::new(1.0);
if new_path {
outline_glyph_into(data, index, &face, gid, &mut fl);
} else {
face.outline_glyph(gid, &mut fl);
}
fl.flush();
fl.contours
}
fn require_interpreter(data: &[u8], index: u32) -> bool {
use skrifa::MetadataProvider;
skrifa::FontRef::from_index(data, index)
.expect("font")
.outline_glyphs()
.require_interpreter()
}
#[test]
fn tricky_font_detection_kaiu_yes_msjh_no() {
// DFKai-SB is on FreeType's / skrifa's tricky list; Microsoft JhengHei
// is not. The cached decision (`hinted_outline`) must agree.
// 標楷體在 tricky 名單上、微軟正黑體不在;快取後的判斷也要一致。
if let Some(kaiu) = windows_font("kaiu.ttf") {
assert!(
require_interpreter(&kaiu, 0),
"kaiu.ttf must need the interpreter"
);
let gid = ttf_parser::Face::parse(&kaiu, 0)
.unwrap()
.glyph_index('自')
.unwrap();
assert!(
hinted_outline(&kaiu, 0, gid).is_some(),
"kaiu must take the hinted path"
);
} else {
eprintln!("kaiu.ttf not installed; skipping");
}
if let Some(msjh) = windows_font("msjh.ttc") {
assert!(
!require_interpreter(&msjh, 0),
"msjh.ttc must not need the interpreter"
);
let gid = ttf_parser::Face::parse(&msjh, 0)
.unwrap()
.glyph_index('自')
.unwrap();
assert!(
hinted_outline(&msjh, 0, gid).is_none(),
"msjh must keep ttf-parser"
);
} else {
eprintln!("msjh.ttc not installed; skipping");
}
if let Some(mingliu) = windows_font("mingliu.ttc") {
// MingLiU / PMingLiU are on the same list (informational coverage).
// 細明體/新細明體也在名單上。
assert!(
require_interpreter(&mingliu, 0),
"mingliu.ttc must need the interpreter"
);
}
}
#[test]
fn kaiu_zi_goes_through_the_interpreter_and_stays_in_the_em() {
// Raw DFKai-SB outlines drop strokes ('自' renders as '目'); the hinted
// path must produce a different outline, in font units (not 512-px
// pixels), filling most of the em square.
// 標楷體原始輪廓缺筆(自→目);新路徑輸出要不同,且是字型單位、落在 em 內。
let Some(kaiu) = windows_font("kaiu.ttf") else {
eprintln!("kaiu.ttf not installed; skipping");
return;
};
let raw = contours(&kaiu, 0, '自', false);
let hinted = contours(&kaiu, 0, '自', true);
assert!(!hinted.is_empty(), "hinted '自' has no contours");
assert_ne!(raw, hinted, "hinted '自' must differ from the raw outline");
let face = ttf_parser::Face::parse(&kaiu, 0).unwrap();
let upem = face.units_per_em() as f32;
let (asc, desc) = (face.ascender() as f32, face.descender() as f32);
let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for p in hinted.iter().flatten() {
x0 = x0.min(p[0]);
y0 = y0.min(p[1]);
x1 = x1.max(p[0]);
y1 = y1.max(p[1]);
}
eprintln!(
"kaiu '自': upem={upem} asc={asc} desc={desc} raw contours={} hinted contours={} bbox=({x0},{y0})-({x1},{y1})",
raw.len(),
hinted.len()
);
let tol = 0.02 * upem;
assert!(
x0 >= -tol && x1 <= upem + tol,
"x outside the em: {x0}..{x1}"
);
assert!(
y0 >= desc - tol && y1 <= asc + tol,
"y outside the em: {y0}..{y1}"
);
// A forgotten pixel → font-unit conversion would halve the ideograph
// (512 px vs 1024 upem). '自' is narrow (~0.47 em) but ~0.8 em tall.
// 忘了像素→字型單位換算的話字會縮一半;「自」窄(約 0.47 em)但高約 0.8 em。
assert!(y1 - y0 > 0.6 * upem, "ideograph too short: {}", y1 - y0);
assert!(x1 - x0 > 0.3 * upem, "ideograph too narrow: {}", x1 - x0);
// The public entry point must use the hinted path too.
// 對外的 `glyph()` 也要走 hinting 路徑。
if let Some(g) = glyph("DFKai-SB", '自') {
let gid = face.glyph_index('自').unwrap();
let mut fl = OutlineFlattener::new(cap_scale(&face));
face.outline_glyph(gid, &mut fl);
fl.flush();
assert_ne!(
g.strokes, fl.contours,
"glyph() still returns the raw outline"
);
} else {
eprintln!("DFKai-SB family not resolvable via sysfont; public-path check skipped");
}
}
#[test]
fn non_tricky_font_outlines_are_unchanged() {
// Fonts that don't need the interpreter keep the exact ttf-parser outline.
// 不需要解譯器的字型,輸出與 ttf-parser 完全相同。
let Some(msjh) = windows_font("msjh.ttc") else {
eprintln!("msjh.ttc not installed; skipping");
return;
};
for ch in "自保持接觸器線圈AgQ1".chars() {
assert_eq!(
contours(&msjh, 0, ch, true),
contours(&msjh, 0, ch, false),
"msjh '{ch}' changed"
);
}
}
}