forked from 1b-agent/opencadstudio-upstream
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>
1165 lines
44 KiB
Rust
1165 lines
44 KiB
Rust
// 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"
|
||
);
|
||
}
|
||
}
|
||
}
|