#![allow(dead_code)]
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use resvg::usvg;
use resvg::usvg::fontdb;
use skrifa::instance::{Location, LocationRef, Size};
use skrifa::raw::tables::gpos::{ExtensionSubtable, Gpos, PairPos, PositionLookup};
use skrifa::raw::tables::kern::{Kern, SubtableKind};
use skrifa::raw::types::GlyphId16;
use skrifa::raw::TableProvider;
use skrifa::{FontRef, GlyphId, MetadataProvider, Tag};
pub const FONT_FAMILY: &str = "sans-serif";
pub const FONT_SIZE: f32 = 14.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FontUnavailable {
NoMatch,
Unreadable,
}
impl std::fmt::Display for FontUnavailable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoMatch => write!(f, "no font matches '{FONT_FAMILY}'"),
Self::Unreadable => write!(f, "the matched font face could not be read"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KernSource {
Gpos(Vec<u16>),
LegacyTable,
None,
}
#[derive(Clone, Copy)]
struct CharAdvance {
gid: Option<u32>,
units: f32,
}
#[derive(Default)]
struct Cache {
chars: HashMap<(char, u32), CharAdvance>,
kern: HashMap<(u32, u32), i32>,
}
pub struct TextMetrics {
db: Arc<fontdb::Database>,
face: fontdb::ID,
upem: f32,
kern_source: KernSource,
has_opsz: bool,
cache: Mutex<Cache>,
face_opens: AtomicUsize,
fallback_probes: AtomicUsize,
}
impl TextMetrics {
pub fn resolve(db: Arc<fontdb::Database>) -> Result<Self, FontUnavailable> {
Self::resolve_families(db, &[fontdb::Family::SansSerif, fontdb::Family::Serif])
}
pub fn resolve_families(
db: Arc<fontdb::Database>,
families: &[fontdb::Family],
) -> Result<Self, FontUnavailable> {
let face = db
.query(&fontdb::Query {
families,
weight: fontdb::Weight::NORMAL,
stretch: fontdb::Stretch::Normal,
style: fontdb::Style::Normal,
})
.ok_or(FontUnavailable::NoMatch)?;
let probed = db
.with_face_data(face, |data, index| {
let font = FontRef::from_index(data, index).ok()?;
let upem = font
.metrics(Size::unscaled(), LocationRef::default())
.units_per_em;
if !(16..=16384).contains(&upem) {
return None;
}
let has_opsz = font.axes().get_by_tag(Tag::new(b"opsz")).is_some();
Some((upem as f32, kern_source(&font), has_opsz))
})
.flatten();
let (upem, kern_source, has_opsz) = probed.ok_or(FontUnavailable::Unreadable)?;
Ok(Self {
db,
face,
upem,
kern_source,
has_opsz,
cache: Mutex::new(Cache::default()),
face_opens: AtomicUsize::new(0),
fallback_probes: AtomicUsize::new(0),
})
}
pub fn face_id(&self) -> fontdb::ID {
self.face
}
pub fn units_per_em(&self) -> f32 {
self.upem
}
pub fn kern_source(&self) -> &KernSource {
&self.kern_source
}
pub fn kerns(&self) -> bool {
!matches!(self.kern_source, KernSource::None)
}
pub fn has_missing_glyph(&self, text: &str) -> bool {
let chars: Vec<char> = text.chars().collect();
let key = self.size_key(FONT_SIZE);
let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
self.fill(&mut cache, &chars, FONT_SIZE, key);
chars
.iter()
.any(|c| cache.chars.get(&(*c, key)).is_none_or(|e| e.gid.is_none()))
}
fn size_key(&self, font_size: f32) -> u32 {
if self.has_opsz {
font_size.to_bits()
} else {
0
}
}
pub fn face_opens(&self) -> usize {
self.face_opens.load(Ordering::Relaxed)
}
pub fn fallback_probes(&self) -> usize {
self.fallback_probes.load(Ordering::Relaxed)
}
pub fn measure(&self, text: &str, font_size: f32) -> f32 {
self.measure_impl(text, font_size, true)
}
pub fn measure_kerning(&self, text: &str, font_size: f32, kerning: bool) -> f32 {
self.measure_impl(text, font_size, kerning)
}
fn measure_impl(&self, text: &str, font_size: f32, kerning: bool) -> f32 {
if text.is_empty() {
return 0.0;
}
let chars: Vec<char> = text.chars().collect();
let key = self.size_key(font_size);
let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
self.fill(&mut cache, &chars, font_size, key);
let mut units = 0.0f32;
let mut prev: Option<u32> = None;
for &c in &chars {
let Some(entry) = cache.chars.get(&(c, key)).copied() else {
units += self.upem;
prev = None;
continue;
};
match entry.gid {
Some(gid) => {
if kerning {
if let Some(p) = prev {
units += cache.kern.get(&(p, gid)).copied().unwrap_or(0) as f32;
}
}
units += entry.units;
prev = Some(gid);
}
None => {
units += entry.units;
prev = None;
}
}
}
units * (font_size / self.upem)
}
fn fill(&self, cache: &mut Cache, chars: &[char], font_size: f32, key: u32) {
let need_chars = chars.iter().any(|c| !cache.chars.contains_key(&(*c, key)));
let need_pairs = need_chars || {
let mut miss = false;
let mut prev: Option<u32> = None;
for c in chars {
let gid = cache.chars.get(&(*c, key)).and_then(|e| e.gid);
if let (Some(p), Some(g)) = (prev, gid) {
miss |= !cache.kern.contains_key(&(p, g));
}
prev = gid;
}
miss
};
if !need_chars && !need_pairs {
return;
}
let mut missing: Vec<char> = Vec::new();
self.face_opens.fetch_add(1, Ordering::Relaxed);
let _ = self.db.with_face_data(self.face, |data, index| {
let Ok(font) = FontRef::from_index(data, index) else {
return;
};
let charmap = font.charmap();
let location: Location = if self.has_opsz {
font.axes().location([(Tag::new(b"opsz"), font_size)])
} else {
font.axes().location::<[(Tag, f32); 0]>([])
};
let metrics = font.glyph_metrics(Size::unscaled(), &location);
let notdef = metrics.advance_width(GlyphId::new(0)).unwrap_or(0.0);
for &c in chars {
if cache.chars.contains_key(&(c, key)) {
continue;
}
let entry = match charmap.map(c) {
Some(gid) if gid.to_u32() != 0 => CharAdvance {
gid: Some(gid.to_u32()),
units: metrics.advance_width(gid).unwrap_or(0.0),
},
_ => {
missing.push(c);
CharAdvance {
gid: None,
units: notdef,
}
}
};
cache.chars.insert((c, key), entry);
}
let legacy = matches!(self.kern_source, KernSource::LegacyTable)
.then(|| font.kern().ok())
.flatten();
let gpos = match &self.kern_source {
KernSource::Gpos(idx) => font.gpos().ok().map(|g| (g, idx.as_slice())),
_ => None,
};
let mut prev: Option<u32> = None;
for c in chars {
let gid = cache.chars.get(&(*c, key)).and_then(|e| e.gid);
if let (Some(p), Some(g)) = (prev, gid) {
cache.kern.entry((p, g)).or_insert_with(|| {
if let Some((gpos, idx)) = &gpos {
gpos_kern_pair(gpos, idx, p, g)
} else {
legacy.as_ref().map_or(0, |k| kern_pair(k, p, g))
}
});
}
prev = gid;
}
});
if !missing.is_empty() {
self.fill_fallbacks(cache, &missing, font_size, key);
}
}
pub fn fallback_face(&self, c: char) -> Option<fontdb::ID> {
self.fallback_probes.fetch_add(1, Ordering::Relaxed);
let mut db = self.db.clone();
usvg::FontResolver::default_fallback_selector()(c, &[self.face], &mut db)
}
fn fill_fallbacks(&self, cache: &mut Cache, missing: &[char], font_size: f32, key: u32) {
let mut by_face: HashMap<fontdb::ID, Vec<char>> = HashMap::new();
for &c in missing {
if let Some(id) = self.fallback_face(c) {
by_face.entry(id).or_default().push(c);
}
}
for (id, chars) in by_face {
let _ = self.db.with_face_data(id, |data, index| {
let Ok(font) = FontRef::from_index(data, index) else {
return;
};
let fb_upem = font
.metrics(Size::unscaled(), LocationRef::default())
.units_per_em;
if !(16..=16384).contains(&fb_upem) {
return;
}
let location: Location = if font.axes().get_by_tag(Tag::new(b"opsz")).is_some() {
font.axes().location([(Tag::new(b"opsz"), font_size)])
} else {
font.axes().location::<[(Tag, f32); 0]>([])
};
let metrics = font.glyph_metrics(Size::unscaled(), &location);
let charmap = font.charmap();
let rescale = self.upem / fb_upem as f32;
for c in chars {
let Some(gid) = charmap.map(c) else { continue };
if gid.to_u32() == 0 {
continue;
}
let Some(advance) = metrics.advance_width(gid) else {
continue;
};
if let Some(entry) = cache.chars.get_mut(&(c, key)) {
entry.units = advance * rescale;
}
}
});
}
}
}
fn kern_pair(kern: &Kern, left: u32, right: u32) -> i32 {
let (l, r) = (GlyphId::new(left), GlyphId::new(right));
for sub in kern.subtables() {
let Ok(sub) = sub else { continue };
if !sub.is_horizontal() || sub.is_cross_stream() {
continue;
}
let Ok(kind) = sub.kind() else { continue };
let v = match kind {
SubtableKind::Format0(t) => t.kerning(l, r),
SubtableKind::Format2(t) => t.kerning(l, r),
SubtableKind::Format3(t) => t.kerning(l, r),
SubtableKind::Format1(_) => None,
};
if let Some(v) = v {
return v;
}
}
0
}
fn kern_source(font: &FontRef) -> KernSource {
let gpos_kern = gpos_kern_lookups(font);
let has_kerx = font.kerx().is_ok();
let full_ot = font.gsub().is_ok() && font.gpos().is_ok() && gpos_kern.is_some();
if has_kerx && !full_ot {
return KernSource::None;
}
if let Some(lookups) = gpos_kern {
return KernSource::Gpos(lookups);
}
let has_legacy = font
.kern()
.ok()
.is_some_and(|k| k.subtables().flatten().any(|s| s.is_horizontal()));
if has_legacy {
KernSource::LegacyTable
} else {
KernSource::None
}
}
fn gpos_kern_lookups(font: &FontRef) -> Option<Vec<u16>> {
let gpos = font.gpos().ok()?;
let features = gpos.feature_list().ok()?;
let scripts = gpos.script_list().ok()?;
let records = scripts.script_records();
let chosen = records
.iter()
.find(|r| r.script_tag() == Tag::new(b"latn"))
.or_else(|| records.iter().find(|r| r.script_tag() == Tag::new(b"DFLT")))?;
let script = chosen.script(scripts.offset_data()).ok()?;
let visible: Vec<u16> = match script.default_lang_sys() {
Some(Ok(lang)) => lang.feature_indices().iter().map(|i| i.get()).collect(),
_ => Vec::new(),
};
if visible.is_empty() {
return None;
}
let feature_records = features.feature_records();
let mut lookups: Vec<u16> = Vec::new();
for idx in visible {
let Some(rec) = feature_records.get(idx as usize) else {
continue;
};
if rec.feature_tag() != Tag::new(b"kern") {
continue;
}
if let Ok(feature) = rec.feature(features.offset_data()) {
lookups.extend(feature.lookup_list_indices().iter().map(|i| i.get()));
}
}
if lookups.is_empty() {
return None;
}
lookups.sort_unstable();
lookups.dedup();
Some(lookups)
}
fn gpos_kern_pair(gpos: &Gpos, lookup_indices: &[u16], left: u32, right: u32) -> i32 {
let Ok(list) = gpos.lookup_list() else {
return 0;
};
let lookups = list.lookups();
let (l, r) = (GlyphId::new(left), GlyphId::new(right));
let mut total = 0;
for &idx in lookup_indices {
let Ok(lookup) = lookups.get(idx as usize) else {
continue;
};
let subtables: Vec<PairPos> = match &lookup {
PositionLookup::Pair(inner) => inner.subtables().iter().flatten().collect(),
PositionLookup::Extension(inner) => inner
.subtables()
.iter()
.flatten()
.filter_map(|ext| match ext {
ExtensionSubtable::Pair(p) => p.extension().ok(),
_ => None,
})
.collect(),
_ => continue,
};
for sub in &subtables {
if let Some(v) = pair_pos_advance(sub, l, r) {
total += v;
break;
}
}
}
total
}
fn pair_pos_advance(pair: &PairPos, left: GlyphId, right: GlyphId) -> Option<i32> {
let x_adv = |v: &skrifa::raw::tables::gpos::ValueRecord| {
v.x_advance.map(|a| a.get() as i32).unwrap_or(0)
};
match pair {
PairPos::Format1(t) => {
let index = t.coverage().ok()?.get(left)? as usize;
let set = t.pair_sets().get(index).ok()?;
let right16: GlyphId16 = right.try_into().ok()?;
for rec in set.pair_value_records().iter() {
let Ok(rec) = rec else { break };
match rec.second_glyph().cmp(&right16) {
std::cmp::Ordering::Less => continue,
std::cmp::Ordering::Greater => break,
std::cmp::Ordering::Equal => {
return Some(x_adv(&rec.value_record1) + x_adv(&rec.value_record2))
}
}
}
None
}
PairPos::Format2(t) => {
t.coverage().ok()?.get(left)?;
let c1 = t.class_def1().ok()?.get(left) as usize;
let c2 = t.class_def2().ok()?.get(right) as usize;
let rec1 = t.class1_records().get(c1).ok()?;
let rec2 = rec1.class2_records().get(c2).ok()?;
Some(x_adv(&rec2.value_record1) + x_adv(&rec2.value_record2))
}
}
}
pub fn shared() -> Result<&'static TextMetrics, FontUnavailable> {
static M: OnceLock<Result<TextMetrics, FontUnavailable>> = OnceLock::new();
M.get_or_init(|| TextMetrics::resolve(crate::preview::svg::shared_fontdb()))
.as_ref()
.map_err(|e| *e)
}
pub fn fonts_available() -> bool {
shared().is_ok()
}
pub fn collapse_spaces(text: &str) -> std::borrow::Cow<'_, str> {
let needs = |t: &str| {
let b = t.as_bytes();
b.first() == Some(&b' ')
|| b.last() == Some(&b' ')
|| t.contains(" ")
|| t.contains(['\r', '\n', '\t'])
};
if !needs(text) {
return std::borrow::Cow::Borrowed(text);
}
let mut out = String::with_capacity(text.len());
let mut prev_space = false;
for c in text.chars() {
let c = match c {
'\r' | '\n' | '\t' => ' ',
_ => c,
};
if c == ' ' {
if prev_space {
continue;
}
prev_space = true;
} else {
prev_space = false;
}
out.push(c);
}
if out.starts_with(' ') {
out.remove(0);
}
if out.ends_with(' ') {
out.pop();
}
std::borrow::Cow::Owned(out)
}
pub fn measure(text: &str, font_size: f32) -> f32 {
let text = collapse_spaces(text);
match shared() {
Ok(m) => m.measure(&text, font_size),
Err(_) => estimate_without_font(&text, font_size),
}
}
pub fn try_measure(text: &str, font_size: f32) -> Result<f32, FontUnavailable> {
let text = collapse_spaces(text);
shared().map(|m| m.measure(&text, font_size))
}
fn estimate_without_font(text: &str, font_size: f32) -> f32 {
use unicode_width::UnicodeWidthChar;
text.chars()
.map(|c| {
if c.width().unwrap_or(0) >= 2 {
1.0
} else {
0.5
}
})
.sum::<f32>()
* font_size
}
#[cfg(test)]
mod tests {
use super::*;
use crate::preview::svg::shared_fontdb;
use resvg::usvg;
use std::sync::Mutex as StdMutex;
const CORPUS: &[(&str, &str)] = &[
("empty", ""),
("single-char", "A"),
("single-cjk", "処"),
("cjk", "処理を実行する"),
("cjk-punct", "開始、確認。「完了」"),
("emoji", "🚀"),
("emoji-in-text", "Deploy 🚀 done"),
("kern-avatar", "AVATAR To Wa"),
("kern-wavy", "Yo, Wavy!"),
("kern-quotes", "AWAY LTV P,"),
("thin-illicit", "illicit"),
("thin-iiiii", "iiiii"),
("thin-lll", "lllllllllllllllllll"),
("spaces-inner", "a b c d"),
("spaces-around", " spaced out "),
(
"long",
"The quick brown fox jumps over the lazy dog, then does it again",
),
("mixed", "混在 mixed ラベル 123"),
(
"mixed-long",
"konoma のプレビュー preview を全画面 fullscreen で描画する",
),
("digits", "0123456789"),
("punct", "(){}[]<>|/\\-_=+*&^%$#@!?.,;:"),
("upper", "ABCDEFGHIJKLMNOPQRSTUVWXYZ"),
("lower", "abcdefghijklmnopqrstuvwxyz"),
];
struct Probe {
width: f32,
primary: Option<fontdb::ID>,
fallbacks: Vec<fontdb::ID>,
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
fn probe(text: &str, font_size: f32) -> Option<Probe> {
probe_with_family(FONT_FAMILY, text, font_size)
}
fn probe_default(text: &str) -> Option<Probe> {
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="6000" height="400"><text x="0" y="200" font-family="{FONT_FAMILY}" font-size="{FONT_SIZE}">{}</text></svg>"#,
xml_escape(text)
);
let opt = usvg::Options {
fontdb: shared_fontdb(),
..usvg::Options::default()
};
let tree = usvg::Tree::from_data(svg.as_bytes(), &opt).ok()?;
Some(Probe {
width: find_text_width(tree.root())?,
primary: None,
fallbacks: Vec::new(),
})
}
fn probe_with_family(family: &str, text: &str, font_size: f32) -> Option<Probe> {
probe_in(shared_fontdb(), family, text, font_size)
}
fn probe_in(
db: Arc<fontdb::Database>,
family: &str,
text: &str,
font_size: f32,
) -> Option<Probe> {
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="6000" height="400"><text x="0" y="200" font-family="{}" font-size="{font_size}" xml:space="preserve">{}</text></svg>"#,
xml_escape(family),
xml_escape(text)
);
let primary: Arc<StdMutex<Option<fontdb::ID>>> = Arc::new(StdMutex::new(None));
let fallbacks: Arc<StdMutex<Vec<fontdb::ID>>> = Arc::new(StdMutex::new(Vec::new()));
let (p2, f2) = (primary.clone(), fallbacks.clone());
let default_select = usvg::FontResolver::default_font_selector();
let default_fallback = usvg::FontResolver::default_fallback_selector();
let opt = usvg::Options {
fontdb: db,
font_resolver: usvg::FontResolver {
select_font: Box::new(move |font, db| {
let id = default_select(font, db);
if let Some(id) = id {
*p2.lock().unwrap() = Some(id);
}
id
}),
select_fallback: Box::new(move |c, exclude, db| {
let id = default_fallback(c, exclude, db);
if let Some(id) = id {
f2.lock().unwrap().push(id);
}
id
}),
},
..usvg::Options::default()
};
let tree = usvg::Tree::from_data(svg.as_bytes(), &opt).ok()?;
let width = find_text_width(tree.root())?;
let primary = *primary.lock().unwrap();
let fallbacks = fallbacks.lock().unwrap().clone();
Some(Probe {
width,
primary,
fallbacks,
})
}
fn find_text_width(group: &usvg::Group) -> Option<f32> {
for node in group.children() {
match node {
usvg::Node::Text(t) => return Some(t.bounding_box().width()),
usvg::Node::Group(g) => {
if let Some(w) = find_text_width(g) {
return Some(w);
}
}
_ => {}
}
}
None
}
fn family_name(id: fontdb::ID) -> String {
shared_fontdb()
.face(id)
.map(|f| f.families[0].0.clone())
.unwrap_or_default()
}
fn metrics() -> Option<&'static TextMetrics> {
match shared() {
Ok(m) => Some(m),
Err(e) => {
eprintln!(
"SKIP: この環境には計測に使えるフォントが無い ({e})。フォント依存のテストを飛ばす。"
);
None
}
}
}
const STRICT_TOLERANCE_PX: f32 = 1e-3;
const FALLBACK_TOLERANCE_RATIO: f32 = 0.02;
const FALLBACK_TOLERANCE_FLOOR_PX: f32 = 0.5;
#[test]
fn measurement_matches_resvg() {
let Some(m) = metrics() else { return };
let mut strict = 0usize;
let mut fallback = 0usize;
let mut worst = (0.0f32, "");
for (name, text) in CORPUS {
let mine = m.measure(text, FONT_SIZE);
let Some(p) = probe(text, FONT_SIZE) else {
assert!(
text.is_empty(),
"{name}: usvg がテキストノードを作らなかったのに空文字列でない"
);
assert_eq!(mine, 0.0, "{name}: 空文字列の幅は 0");
continue;
};
assert_eq!(
p.primary,
Some(m.face_id()),
"{name}: usvg が font-family=\"{FONT_FAMILY}\" を解決した face と計測 face が違う"
);
let delta = (mine - p.width).abs();
let tol = if p.fallbacks.is_empty() {
strict += 1;
STRICT_TOLERANCE_PX
} else {
fallback += 1;
(p.width * FALLBACK_TOLERANCE_RATIO).max(FALLBACK_TOLERANCE_FLOOR_PX)
};
if delta > worst.0 {
worst = (delta, name);
}
assert!(
delta <= tol,
"{name}: konoma={mine:.4} resvg={:.4} delta={delta:.4} > tol={tol:.4} (fallback={})",
p.width,
p.fallbacks.len()
);
}
assert!(
strict >= 10,
"厳密判定に載ったケースが {strict} 件しかない = コーパスが痩せている"
);
assert!(
fallback >= 3,
"フォールバック経路のケースが {fallback} 件しかない = CJK/絵文字が抜けている"
);
eprintln!(
"strict={strict} fallback={fallback} worst={:.6}px ({})",
worst.0, worst.1
);
}
#[test]
fn collapsing_matches_what_usvg_folds_before_shaping() {
let Some(_) = metrics() else { return };
let cases: &[(&str, &str, &str)] = &[
("plain", "one two", "one two"),
("double", "loop Every minute", "loop Every minute"),
("many", "a b c d", "a b c d"),
("edges", " spaced out ", "spaced out"),
("tab", "a\tb", "a b"),
("tab-run", "a\t\tb", "a b"),
("cr", "a\rb", "a b"),
("only-spaces", " ", ""),
("empty", "", ""),
("nbsp", "a\u{a0}\u{a0}b", "a\u{a0}\u{a0}b"),
(
"ideographic",
"全\u{3000}\u{3000}角",
"全\u{3000}\u{3000}角",
),
("nbsp-edge", "\u{a0}x\u{a0}", "\u{a0}x\u{a0}"),
];
for (name, written, folded) in cases {
assert_eq!(
collapse_spaces(written).as_ref(),
*folded,
"{name}: collapse_spaces disagrees with the rule usvg implements"
);
assert_eq!(
collapse_spaces(folded).as_ref(),
*folded,
"{name}: not idempotent"
);
let (Some(raw), Some(pre)) = (probe_default(written), probe_default(folded)) else {
assert!(
folded.is_empty(),
"{name}: usvg drew nothing for a non-empty string"
);
continue;
};
assert!(
(raw.width - pre.width).abs() <= STRICT_TOLERANCE_PX,
"{name}: usvg drew {written:?} at {} and {folded:?} at {} — the fold is not what \
usvg does",
raw.width,
pre.width
);
assert!(
(measure(written, FONT_SIZE) - raw.width).abs() <= STRICT_TOLERANCE_PX,
"{name}: konoma measured {} where resvg drew {}",
measure(written, FONT_SIZE),
raw.width
);
}
}
#[test]
fn corpus_covers_every_required_category() {
let Some(m) = metrics() else { return };
let has = |pred: fn(&str) -> bool| CORPUS.iter().any(|(_, t)| pred(t));
assert!(
has(|t| t.chars().any(|c| ('\u{4e00}'..='\u{9fff}').contains(&c))),
"CJK 漢字のケースが無い"
);
assert!(
has(|t| t.chars().any(|c| ('\u{3040}'..='\u{30ff}').contains(&c))),
"かな のケースが無い"
);
assert!(has(|t| t.contains('\u{1f680}')), "絵文字のケースが無い");
assert!(has(|t| t.contains("AVATAR")), "カーニング対象の欧文が無い");
assert!(has(|t| t.contains("illicit")), "細いグリフのケースが無い");
assert!(has(|t| t.contains(" ")), "連続空白のケースが無い");
assert!(has(|t| t.chars().count() > 50), "長文のケースが無い");
assert!(has(str::is_empty), "空文字列のケースが無い");
assert!(has(|t| t.chars().count() == 1), "1 文字のケースが無い");
assert!(
has(|t| t.contains("mixed") && t.chars().any(|c| c as u32 > 0x2000)),
"欧文と CJK の混在ケースが無い"
);
let mixed = CORPUS.iter().find(|(n, _)| *n == "mixed").unwrap().1;
let p = probe(mixed, FONT_SIZE).expect("mixed は描かれる");
assert!(
!p.fallbacks.is_empty(),
"mixed が計測フォントだけで賄えている = 混在ケースになっていない"
);
assert!(m.measure(mixed, FONT_SIZE) > 0.0);
}
#[test]
fn kerning_is_applied_and_is_what_matches_resvg() {
let Some(m) = metrics() else { return };
if !m.kerns() {
eprintln!("SKIP: 計測フォントがカーニングを持たない環境なのでこの検査は成立しない");
return;
}
let text = "AVATAR To Wa";
let with = m.measure(text, FONT_SIZE);
let without = m.measure_kerning(text, FONT_SIZE, false);
let actual = probe(text, FONT_SIZE).expect("描かれる").width;
assert!(
(without - with) > 1.0,
"kern が効いていない (with={with:.3} without={without:.3})"
);
assert!(
(with - actual).abs() <= STRICT_TOLERANCE_PX,
"kern あり = resvg"
);
assert!(
(without - actual).abs() > 1.0,
"kern 無しでも resvg と一致してしまう = この文字列にはカーニング対象が無い"
);
}
fn katex_db(files: &[&str]) -> Arc<fontdb::Database> {
let mut db = fontdb::Database::new();
for file in files {
let bytes = ratex_katex_fonts::ttf_bytes(file)
.unwrap_or_else(|| panic!("the embedded KaTeX set carries {file}"))
.into_owned();
db.load_font_data(bytes);
}
let first = db
.faces()
.next()
.and_then(|f| f.families.first().map(|(name, _)| name.clone()))
.expect("the first face registers a family");
db.set_sans_serif_family(first);
Arc::new(db)
}
#[test]
fn a_missing_character_takes_the_advance_of_the_face_resvg_falls_back_to() {
let text = "∀";
let expected = 0.556 * FONT_SIZE;
let db = katex_db(&["KaTeX_SansSerif-Regular.ttf", "KaTeX_Main-Regular.ttf"]);
let m = TextMetrics::resolve(db.clone()).expect("the staged sans-serif resolves");
assert!(
m.has_missing_glyph(text),
"setup: the measuring face must be the one *without* the character"
);
let mine = m.measure(text, FONT_SIZE);
assert!(
(mine - expected).abs() <= STRICT_TOLERANCE_PX,
"measured {mine} where the fallback face's advance is {expected}"
);
assert!(
(mine - FONT_SIZE).abs() > 6.0,
"one em would be {FONT_SIZE}, and that is the rule this replaced"
);
assert!(
(mine - 0.25 * FONT_SIZE).abs() > 4.0,
"the measuring face's own .notdef would be {}",
0.25 * FONT_SIZE
);
let p = probe_in(db, FONT_FAMILY, text, FONT_SIZE).expect("resvg draws it");
assert_eq!(
p.fallbacks.len(),
1,
"setup: resvg must actually have fallen back for this character"
);
assert!(
(mine - p.width).abs() <= STRICT_TOLERANCE_PX,
"konoma={mine} resvg={}",
p.width
);
}
#[test]
fn a_character_no_face_can_draw_takes_the_measuring_faces_notdef() {
let text = "∀";
let expected = 0.25 * FONT_SIZE;
let db = katex_db(&["KaTeX_SansSerif-Regular.ttf"]);
let m = TextMetrics::resolve(db.clone()).expect("the staged sans-serif resolves");
assert!(m.has_missing_glyph(text), "setup: the face must lack it");
let mine = m.measure(text, FONT_SIZE);
assert!(
(mine - expected).abs() <= STRICT_TOLERANCE_PX,
"measured {mine} where this face's .notdef is {expected} wide"
);
assert!(
(mine - FONT_SIZE).abs() > 9.0,
"one em would be {FONT_SIZE}, and that is the rule this replaced"
);
let p = probe_in(db, FONT_FAMILY, text, FONT_SIZE).expect("resvg draws it");
assert!(
p.fallbacks.is_empty(),
"setup: there is nothing for resvg to fall back to"
);
assert!(
(mine - p.width).abs() <= STRICT_TOLERANCE_PX,
"konoma={mine} resvg={}",
p.width
);
}
#[test]
fn a_run_the_measuring_face_cannot_draw_matches_resvg_exactly() {
let Some(m) = metrics() else { return };
let mut with_fallback = 0usize;
let mut without = 0usize;
for text in ["処理を実行する", "全画面プレビュー", "🚀"] {
assert!(
m.has_missing_glyph(text),
"{text:?} is expected to be beyond any sans-serif face konoma measures with"
);
let Some(p) = probe(text, FONT_SIZE) else {
continue;
};
if p.fallbacks.is_empty() {
without += 1;
} else {
with_fallback += 1;
}
let mine = m.measure(text, FONT_SIZE);
assert!(
(mine - p.width).abs() <= STRICT_TOLERANCE_PX,
"{text:?}: konoma={mine:.4} resvg={:.4} (fallback faces: {})",
p.width,
p.fallbacks.len()
);
}
assert!(
with_fallback + without >= 3,
"usvg drew nothing for one of these strings, so the test skipped it silently"
);
eprintln!("fell back for {with_fallback}, kept .notdef for {without}");
}
#[test]
fn the_fallback_face_konoma_reads_is_the_one_usvg_uses() {
let Some(m) = metrics() else { return };
let mut chars: Vec<char> = CORPUS
.iter()
.flat_map(|(_, t)| t.chars())
.filter(|c| m.has_missing_glyph(&c.to_string()))
.collect();
chars.sort_unstable();
chars.dedup();
let mut checked = 0usize;
for c in chars {
let text = c.to_string();
let Some(p) = probe(&text, FONT_SIZE) else {
continue;
};
let Some(&used) = p.fallbacks.first() else {
continue;
};
checked += 1;
assert_eq!(
m.fallback_face(c),
Some(used),
"U+{:04X}: konoma would read {:?}, resvg drew it from {:?}",
c as u32,
m.fallback_face(c).map(family_name),
family_name(used)
);
}
assert!(
checked >= 3,
"only {checked} corpus characters made usvg fall back, so this checked almost nothing"
);
}
#[test]
fn a_fallback_face_is_resolved_once_per_character_and_never_again() {
let db = katex_db(&["KaTeX_SansSerif-Regular.ttf", "KaTeX_Main-Regular.ttf"]);
let m = TextMetrics::resolve(db).expect("the staged sans-serif resolves");
assert_eq!(m.fallback_probes(), 0, "resolving alone probes nothing");
let text = "∀ℵ√∀ℵ√";
let _ = m.measure(text, FONT_SIZE);
assert_eq!(
m.fallback_probes(),
3,
"one probe per distinct character, not per occurrence"
);
for _ in 0..500 {
let _ = m.measure(text, FONT_SIZE);
let _ = m.measure(text, FONT_SIZE * 2.0);
}
assert_eq!(
m.fallback_probes(),
3,
"a warm measurement re-ran usvg's font-book walk = the cache is not holding"
);
}
#[test]
fn empty_text_measures_zero_and_scaling_is_linear() {
let Some(m) = metrics() else { return };
assert_eq!(m.measure("", FONT_SIZE), 0.0);
let t = "Node label";
let a = m.measure(t, FONT_SIZE);
let b = m.measure(t, FONT_SIZE * 2.0);
assert!((b - a * 2.0).abs() < 1e-3, "font_size に比例する");
}
#[test]
fn svg_font_family_resolves_to_the_measuring_face() {
let Some(m) = metrics() else { return };
for text in ["Label", "処理", "AVATAR"] {
let p = probe(text, FONT_SIZE).expect("描かれる");
assert_eq!(
p.primary,
Some(m.face_id()),
"font-family=\"{FONT_FAMILY}\" の解決先が計測 face と一致しない"
);
}
assert_eq!(FONT_FAMILY, "sans-serif");
assert!(!FONT_FAMILY.contains(','), "フォントスタックを並べない");
}
#[test]
fn layout_font_is_pinned_and_theme_independent() {
assert_eq!(FONT_SIZE, 14.0);
assert_eq!(FONT_FAMILY, "sans-serif");
}
#[test]
fn missing_fonts_are_detected_rather_than_guessed() {
let empty = Arc::new(fontdb::Database::new());
assert_eq!(
TextMetrics::resolve(empty).err(),
Some(FontUnavailable::NoMatch),
"フォントが 1 つも無ければ解決は Err"
);
}
#[test]
fn usvg_silently_drops_text_when_no_font_resolves() {
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="50"><text x="0" y="30" font-family="{FONT_FAMILY}" font-size="14">Label</text></svg>"#
);
let opt = usvg::Options {
fontdb: Arc::new(fontdb::Database::new()),
..usvg::Options::default()
};
let tree = usvg::Tree::from_data(svg.as_bytes(), &opt).expect("SVG 自体は妥当");
assert!(
find_text_width(tree.root()).is_none(),
"フォント不在でも usvg はエラーにならず、テキストが黙って消える"
);
}
#[test]
fn face_data_is_opened_only_on_a_cache_miss() {
if metrics().is_none() {
return;
}
let m = TextMetrics::resolve(shared_fontdb()).expect("解決できる");
assert_eq!(m.face_opens(), 0, "解決だけでは計測用に開かない");
let (a, b) = ("Cache warm 処理", "warm Cache");
let _ = m.measure(a, FONT_SIZE);
let _ = m.measure(b, FONT_SIZE);
let warm = m.face_opens();
assert!(warm <= 2, "初回の取りこぼしが多すぎる: {warm}");
for _ in 0..200 {
let _ = m.measure(a, FONT_SIZE);
let _ = m.measure(b, FONT_SIZE * 2.0);
}
assert_eq!(
m.face_opens(),
warm,
"既知の文字だけの計測でフォントを開き直している = キャッシュが効いていない"
);
}
#[test]
fn estimate_without_font_is_positive_and_width_aware() {
assert_eq!(estimate_without_font("", 14.0), 0.0);
assert!(estimate_without_font("ab", 14.0) > 0.0);
assert!(
estimate_without_font("処", 14.0) > estimate_without_font("a", 14.0),
"全角は半角より広い"
);
}
#[test]
fn measurement_matches_resvg_across_the_whole_font_book() {
if metrics().is_none() {
return;
}
let db = shared_fontdb();
let mut names: Vec<String> = db
.faces()
.filter(|f| {
f.style == fontdb::Style::Normal
&& f.weight == fontdb::Weight::NORMAL
&& f.stretch == fontdb::Stretch::Normal
})
.map(|f| f.families[0].0.clone())
.collect();
names.sort();
names.dedup();
let samples = ["AVATAR To Wa", "illicit", "Preview label", "Yo, Wavy!"];
let mut mismatches: Vec<String> = Vec::new();
let mut checked = 0usize;
for name in &names {
let fam = [fontdb::Family::Name(name)];
let Ok(m) = TextMetrics::resolve_families(db.clone(), &fam) else {
continue;
};
for t in samples {
let Some(p) = probe_with_family(name, t, FONT_SIZE) else {
continue;
};
if p.primary != Some(m.face_id()) {
continue;
}
if m.has_missing_glyph(t) {
continue;
}
checked += 1;
let mine = m.measure(t, FONT_SIZE);
if (mine - p.width).abs() > STRICT_TOLERANCE_PX {
mismatches.push(format!(
"{name} / {t:?}: konoma={mine:.3} resvg={:.3} d={:.3} kern={:?}",
p.width,
mine - p.width,
m.kern_source()
));
}
}
}
if checked < 40 {
eprintln!("SKIP: 比較できたフォントが {checked} 件しかない環境なので比率を判定しない");
return;
}
eprintln!(
"cross-font: checked={checked} mismatches={} ({:.1}%)",
mismatches.len(),
mismatches.len() as f32 * 100.0 / checked as f32
);
assert!(
mismatches.len() * 20 <= checked,
"{}/{checked} のフォントで計測と描画がずれる (上限 5%):\n{}",
mismatches.len(),
mismatches
.iter()
.take(10)
.cloned()
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn optical_sizing_follows_font_size() {
if metrics().is_none() {
return;
}
let db = shared_fontdb();
let mut names: Vec<String> = db
.faces()
.filter(|f| {
f.style == fontdb::Style::Normal
&& f.weight == fontdb::Weight::NORMAL
&& f.stretch == fontdb::Stretch::Normal
})
.map(|f| f.families[0].0.clone())
.collect();
names.sort();
names.dedup();
let text = "Preview label";
for name in &names {
let fam = [fontdb::Family::Name(name)];
let Ok(m) = TextMetrics::resolve_families(db.clone(), &fam) else {
continue;
};
if !m.has_opsz || m.has_missing_glyph(text) {
continue;
}
let Some(p) = probe_with_family(name, text, FONT_SIZE) else {
continue;
};
if p.primary != Some(m.face_id()) || !p.fallbacks.is_empty() {
continue;
}
let with = (m.measure(text, FONT_SIZE) - p.width).abs();
let mut flat = TextMetrics::resolve_families(db.clone(), &fam).expect("解決できる");
flat.has_opsz = false;
let without = (flat.measure(text, FONT_SIZE) - p.width).abs();
eprintln!("opsz guard on {name}: with={with:.3}px without={without:.3}px");
assert!(
with * 4.0 < without,
"{name}: opsz を適用してもしなくても誤差が変わらない (with={with:.3} without={without:.3})"
);
return;
}
eprintln!("SKIP: opsz 軸を持つフォントがこの環境に無いので可変フォントの検査は飛ばす");
}
#[test]
#[ignore = "reporting only: cargo test -- --ignored --nocapture measurement_report"]
fn measurement_report() {
let Some(m) = metrics() else { return };
eprintln!(
"face upem={} kern={} face={:?}",
m.units_per_em(),
match m.kern_source() {
KernSource::Gpos(l) => format!("gpos({} lookups)", l.len()),
KernSource::LegacyTable => "kern table".to_string(),
KernSource::None => "none".to_string(),
},
m.face_id()
);
eprintln!(
"{:<14} {:>9} {:>9} {:>9} {:>8} {:>7} fallback",
"case", "konoma", "resvg", "delta", "delta/ch", "nokern"
);
for (name, text) in CORPUS {
let mine = m.measure(text, FONT_SIZE);
let nokern = m.measure_kerning(text, FONT_SIZE, false);
match probe(text, FONT_SIZE) {
Some(p) => {
let n = text.chars().count().max(1) as f32;
eprintln!(
"{:<14} {:>9.3} {:>9.3} {:>9.6} {:>8.3} {:>7.3} {}",
name,
mine,
p.width,
mine - p.width,
(mine - p.width) / n,
nokern - p.width,
if p.fallbacks.is_empty() {
format!("primary_same={}", p.primary == Some(m.face_id()))
} else {
format!(
"primary_same={} fallback={:?}",
p.primary == Some(m.face_id()),
p.fallbacks
.iter()
.map(|id| family_name(*id))
.collect::<Vec<_>>()
)
}
);
}
None => eprintln!("{name:<14} {mine:>9.3} {:>9} (usvg: no text node)", "-"),
}
}
for (name, text) in [
("latin", "Preview label"),
("cjk", "処理を実行する"),
("emoji", "build 🚀 ship"),
] {
let cold = TextMetrics::resolve(shared_fontdb()).expect("解決できる");
let t0 = std::time::Instant::now();
let _ = cold.measure(text, FONT_SIZE);
let us = t0.elapsed().as_micros();
eprintln!(
"cold measure({name}) = {us} us total, {} fallback probes, {} face opens",
cold.fallback_probes(),
cold.face_opens()
);
}
for (_, t) in CORPUS {
let _ = m.measure(t, FONT_SIZE);
}
for (name, text) in [("short label", "Preview"), ("cjk label", "処理を実行する")] {
let n = 100_000;
let t0 = std::time::Instant::now();
let mut acc = 0.0f32;
for _ in 0..n {
acc += m.measure(text, FONT_SIZE);
}
let ns = t0.elapsed().as_nanos() as f64 / n as f64;
eprintln!("warm measure({name}) = {ns:.0} ns/call (acc={acc:.0})");
}
}
}