forked from 1b-agent/opencadstudio-upstream
feat(fonts): system TrueType support with shaping and fallback
Add a TrueType/OpenType render path alongside the LFF stroke engine so users can pick fonts installed on their machine for text and MText. - Discover system families via fontdb (sysfont) and list them in the text-style picker. - Extract glyph outlines with ttf-parser, flattened into wire strokes normalized to the same 9-unit cap-height space as LFF glyphs. - Shape runs with cosmic-text (ligatures, Arabic joining, kerning, bidi) plus automatic font fallback: a glyph missing from the chosen family is taken from another installed font. - Unify both sources behind a single Face type; a shared run tokenizer drives the stroke (per-glyph) and shaped (TTF) renderers identically. - Stroke fonts now fall back to a system TTF (cosmic-text picks it) for characters no LFF font covers, replacing the unicode/iso3098 chain. Also trim the embedded LFF set from 47 to 26, keeping the AutoCAD-SHX equivalents and dropping LibreCAD-only fonts now covered by TrueType. LFF builtins keep priority over same-named system fonts so DXF text renders identically across machines. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
eff6c7c730
commit
cd048165a9
31 files changed
+1037
-763578
No files matched your search
+1
-1
@@ -1975,7 +1975,7 @@ const MTEXT_FONTS: [&str; 10] = [
|
||||
"ItalicC",
|
||||
"ScriptS",
|
||||
"GothGBT",
|
||||
"Cursive",
|
||||
"RomanC",
|
||||
];
|
||||
/// (label, ACI). 256 = ByLayer.
|
||||
const MTEXT_COLORS: [(&str, u16); 8] = [
|
||||
|
||||
@@ -2,6 +2,7 @@ use acadrust::types::aci_table::aci_to_rgb;
|
||||
use acadrust::{CadDocument, EntityType};
|
||||
|
||||
use crate::scene::acad_to_truck::TextStroke;
|
||||
use crate::scene::font_face::Face;
|
||||
use crate::scene::lff;
|
||||
|
||||
pub struct ResolvedTextStyle {
|
||||
@@ -65,7 +66,7 @@ pub fn text_local_bounds(
|
||||
return None;
|
||||
}
|
||||
|
||||
let font = lff::get_font(font_name);
|
||||
let face = Face::resolve(font_name);
|
||||
let scale = height / 9.0;
|
||||
let wf = width_factor.abs().clamp(0.01, 100.0);
|
||||
let ob = oblique_angle.tan();
|
||||
@@ -77,10 +78,10 @@ pub fn text_local_bounds(
|
||||
|
||||
for ch in text.chars() {
|
||||
if ch == ' ' {
|
||||
cursor_x += font.word_spacing;
|
||||
cursor_x += face.word_spacing();
|
||||
continue;
|
||||
}
|
||||
match font.glyph(ch) {
|
||||
match face.glyph(ch) {
|
||||
Some(glyph) => {
|
||||
for stroke in &glyph.strokes {
|
||||
for &[gx, gy] in stroke {
|
||||
@@ -92,10 +93,10 @@ pub fn text_local_bounds(
|
||||
max_y = max_y.max(sy);
|
||||
}
|
||||
}
|
||||
cursor_x += glyph.advance + font.letter_spacing;
|
||||
cursor_x += glyph.advance + face.letter_spacing();
|
||||
}
|
||||
None => {
|
||||
cursor_x += 6.0 + font.letter_spacing;
|
||||
cursor_x += 6.0 + face.letter_spacing();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -863,12 +864,12 @@ pub fn measure_word(
|
||||
) -> f32 {
|
||||
let scale = run_scale(state, entity_h, base_wf);
|
||||
let font_name = resolve_font(state, base_font);
|
||||
let font = lff::get_font(font_name);
|
||||
let face = Face::resolve(font_name);
|
||||
let mut w = 0.0_f32;
|
||||
for ch in text.chars() {
|
||||
w += match font.glyph(ch) {
|
||||
Some(g) => (g.advance + font.letter_spacing * state.tracking) * scale,
|
||||
None => (6.0 + font.letter_spacing * state.tracking) * scale,
|
||||
w += match face.glyph(ch) {
|
||||
Some(g) => (g.advance + face.letter_spacing() * state.tracking) * scale,
|
||||
None => (6.0 + face.letter_spacing() * state.tracking) * scale,
|
||||
};
|
||||
}
|
||||
w
|
||||
@@ -877,7 +878,7 @@ pub fn measure_word(
|
||||
pub fn measure_space(state: &RunState, entity_h: f32, base_wf: f32, base_font: &str) -> f32 {
|
||||
let scale = run_scale(state, entity_h, base_wf);
|
||||
let font_name = resolve_font(state, base_font);
|
||||
lff::get_font(font_name).word_spacing * scale
|
||||
Face::resolve(font_name).word_spacing() * scale
|
||||
}
|
||||
|
||||
pub fn atom_width(atom: &LayoutAtom, entity_h: f32, base_wf: f32, base_font: &str) -> f32 {
|
||||
@@ -1132,7 +1133,7 @@ pub struct MTextLayout {
|
||||
/// text block.
|
||||
pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
||||
let base_font_name = opts.style.font_name.clone();
|
||||
let base_font = lff::get_font(&base_font_name);
|
||||
let base_font = Face::resolve(&base_font_name);
|
||||
let base_wf_abs = opts.style.width_factor.max(0.01);
|
||||
let base_wf = if opts.style.is_backward { -base_wf_abs } else { base_wf_abs };
|
||||
let base_oblique = opts.style.oblique_angle;
|
||||
@@ -1256,7 +1257,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
||||
1.0
|
||||
};
|
||||
// DXF code 44 — multiplier on the default 5/3-em baseline-to-baseline gap.
|
||||
let line_h = entity_h * ls_factor * (5.0 / 3.0) * base_font.line_spacing;
|
||||
let line_h = entity_h * ls_factor * (5.0 / 3.0) * base_font.line_spacing();
|
||||
let h = entity_h;
|
||||
let v_offset = match opts.v_anchor {
|
||||
MTextVAnchor::Top => -h,
|
||||
@@ -1413,14 +1414,14 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
||||
// Per-character boxes, advancing exactly as
|
||||
// `measure_word` does so they track the glyphs.
|
||||
let scale = run_scale(&atom.state, entity_h, base_wf);
|
||||
let font = lff::get_font(font_name);
|
||||
let face = Face::resolve(font_name);
|
||||
let mut cx = cursor_x;
|
||||
for ch in text.chars() {
|
||||
let adv = match font.glyph(ch) {
|
||||
let adv = match face.glyph(ch) {
|
||||
Some(g) => {
|
||||
(g.advance + font.letter_spacing * tracking) * scale
|
||||
(g.advance + face.letter_spacing() * tracking) * scale
|
||||
}
|
||||
None => (6.0 + font.letter_spacing * tracking) * scale,
|
||||
None => (6.0 + face.letter_spacing() * tracking) * scale,
|
||||
};
|
||||
let (ax, ay) = to_world(line_base_x, line_base_y, cx, ly);
|
||||
let (bx, by) = to_world(line_base_x, line_base_y, cx + adv, ly + run_h);
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// Unified font face over the two glyph sources: embedded LFF stroke fonts and
|
||||
// the user's installed TrueType/OpenType fonts. Both deliver glyphs in the same
|
||||
// 9-unit cap-height space (see `ttf_glyph`), so the text layout code resolves a
|
||||
// `Face` once per run and asks it for glyphs without caring which kind it is.
|
||||
//
|
||||
// Dispatch rule: a name that matches an embedded LFF font wins (so DXF stroke
|
||||
// fonts like `txt`/`romans` keep rendering as before); otherwise, if it matches
|
||||
// an installed system family, the TrueType path is used; otherwise we fall back
|
||||
// to LFF's own `STANDARD` resolution.
|
||||
|
||||
use std::ops::Deref;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::scene::lff::{self, Glyph};
|
||||
use crate::scene::{sysfont, ttf_glyph};
|
||||
|
||||
/// A glyph borrowed from a static LFF font or owned (cached `Arc`) from a TTF
|
||||
/// face. Derefs to [`Glyph`] either way.
|
||||
pub enum GlyphRef<'a> {
|
||||
Borrowed(&'a Glyph),
|
||||
Owned(Arc<Glyph>),
|
||||
}
|
||||
|
||||
impl Deref for GlyphRef<'_> {
|
||||
type Target = Glyph;
|
||||
fn deref(&self) -> &Glyph {
|
||||
match self {
|
||||
GlyphRef::Borrowed(g) => g,
|
||||
GlyphRef::Owned(g) => g,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved font for a text run.
|
||||
pub enum Face {
|
||||
Lff(&'static lff::Font),
|
||||
Ttf {
|
||||
family: String,
|
||||
/// Cached space-glyph advance (9-unit), used for word spacing.
|
||||
word: f32,
|
||||
},
|
||||
}
|
||||
|
||||
impl Face {
|
||||
/// Resolve a style's font name to a concrete face. Embedded stroke fonts
|
||||
/// take priority; only otherwise-unknown names try the system fonts.
|
||||
pub fn resolve(font_name: &str) -> Face {
|
||||
if !lff::is_builtin(font_name) && sysfont::has_family(font_name) {
|
||||
let word = ttf_glyph::glyph(font_name, ' ')
|
||||
.map(|g| g.advance)
|
||||
// Fall back to a sensible blank-width if the font has no space.
|
||||
.filter(|w| *w > 0.0)
|
||||
.unwrap_or(4.5);
|
||||
return Face::Ttf {
|
||||
family: font_name.to_string(),
|
||||
word,
|
||||
};
|
||||
}
|
||||
Face::Lff(lff::get_font(font_name))
|
||||
}
|
||||
|
||||
/// Look up a glyph. A stroke (LFF) font uses its own glyphs; anything it
|
||||
/// lacks falls back to a system TrueType font chosen by cosmic-text (filled
|
||||
/// outline, so covers scripts no stroke font provides). The TTF path mirrors
|
||||
/// this for its single-glyph lookups.
|
||||
pub fn glyph(&self, ch: char) -> Option<GlyphRef<'_>> {
|
||||
match self {
|
||||
Face::Lff(f) => match f.glyph(ch) {
|
||||
Some(g) => Some(GlyphRef::Borrowed(g)),
|
||||
None => ttf_glyph::fallback_glyph(ch).map(GlyphRef::Owned),
|
||||
},
|
||||
Face::Ttf { family, .. } => ttf_glyph::glyph(family, ch)
|
||||
.or_else(|| ttf_glyph::fallback_glyph(ch))
|
||||
.map(GlyphRef::Owned),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extra spacing added after every glyph (9-unit). TTF advances already
|
||||
/// include side bearings, so no extra tracking is added there.
|
||||
pub fn letter_spacing(&self) -> f32 {
|
||||
match self {
|
||||
Face::Lff(f) => f.letter_spacing,
|
||||
Face::Ttf { .. } => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of a space (9-unit).
|
||||
pub fn word_spacing(&self) -> f32 {
|
||||
match self {
|
||||
Face::Lff(f) => f.word_spacing,
|
||||
Face::Ttf { word, .. } => *word,
|
||||
}
|
||||
}
|
||||
|
||||
/// The system family name when this is a TrueType face, else `None`. Lets
|
||||
/// the tessellator shape TTF runs while keeping the per-glyph stroke path
|
||||
/// for LFF.
|
||||
pub fn ttf_family(&self) -> Option<&str> {
|
||||
match self {
|
||||
Face::Ttf { family, .. } => Some(family),
|
||||
Face::Lff(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Line-spacing factor (1.0 = one cap height between baselines as scaled by
|
||||
/// the caller). TTF uses the neutral 1.0; LFF carries its font's value.
|
||||
pub fn line_spacing(&self) -> f32 {
|
||||
match self {
|
||||
Face::Lff(f) => f.line_spacing,
|
||||
Face::Ttf { .. } => 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ttf_dispatches_through_tessellation() {
|
||||
let fams = sysfont::families();
|
||||
if fams.is_empty() {
|
||||
eprintln!("no system fonts; skipping");
|
||||
return;
|
||||
}
|
||||
// Pick a family that actually yields an 'A' outline, then render a run
|
||||
// through the real LFF tessellation entry to prove the Face dispatch.
|
||||
let fam = fams
|
||||
.iter()
|
||||
.find(|f| ttf_glyph::glyph(f, 'A').is_some())
|
||||
.expect("a system family with an 'A'");
|
||||
assert!(matches!(Face::resolve(fam), Face::Ttf { .. }));
|
||||
let strokes =
|
||||
lff::tessellate_text_ex([0.0, 0.0], 10.0, 0.0, 1.0, 0.0, fam, "ABC");
|
||||
assert!(!strokes.is_empty(), "TTF run produced no strokes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shx_name_stays_lff() {
|
||||
assert!(matches!(Face::resolve("txt"), Face::Lff(_)));
|
||||
assert!(matches!(Face::resolve("romans.shx"), Face::Lff(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lff_falls_back_to_ttf_for_uncovered_glyph() {
|
||||
// A CJK ideograph is in no stroke font; with a stroke style selected it
|
||||
// must now resolve through the system-font (cosmic-text) fallback rather
|
||||
// than coming back empty. Tolerated if the machine has no CJK font.
|
||||
let txt = Face::resolve("txt");
|
||||
assert!(matches!(txt, Face::Lff(_)));
|
||||
assert!(txt.glyph('A').is_some(), "primary stroke glyph still works");
|
||||
match txt.glyph('中') {
|
||||
Some(g) => assert!(!g.strokes.is_empty(), "fallback glyph has contours"),
|
||||
None => eprintln!("no CJK system font; fallback skipped"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ttf_run_keeps_decorations() {
|
||||
let fams = sysfont::families();
|
||||
if fams.is_empty() {
|
||||
eprintln!("no system fonts; skipping");
|
||||
return;
|
||||
}
|
||||
let fam = fams
|
||||
.iter()
|
||||
.find(|f| ttf_glyph::glyph(f, 'A').is_some())
|
||||
.expect("a system family with an 'A'");
|
||||
// Underlined shaped text: glyph contours plus exactly one underline
|
||||
// segment (a 2-point polyline) emitted on the \l toggle.
|
||||
let plain = lff::tessellate_text_ex([0.0, 0.0], 10.0, 0.0, 1.0, 0.0, fam, "AB");
|
||||
let deco = lff::tessellate_text_ex([0.0, 0.0], 10.0, 0.0, 1.0, 0.0, fam, "\\LAB\\l");
|
||||
assert!(!plain.is_empty());
|
||||
assert_eq!(
|
||||
deco.len(),
|
||||
plain.len() + 1,
|
||||
"underline should add exactly one segment on the TTF path"
|
||||
);
|
||||
}
|
||||
}
|
||||
+279
-179
@@ -24,38 +24,19 @@ use std::sync::{Mutex, OnceLock};
|
||||
/// Every LibreCAD LFF font, keyed by its file stem (lower-case). Registered
|
||||
/// under the upper-cased stem and the font's `# Name:` header.
|
||||
const FONTS_SRC: &[(&str, &str)] = &[
|
||||
("amiri-regular", include_str!("../../assets/fonts/amiri-regular.lff")),
|
||||
("azomix_i", include_str!("../../assets/fonts/azomix_i.lff")),
|
||||
("azomix", include_str!("../../assets/fonts/azomix.lff")),
|
||||
("cursive", include_str!("../../assets/fonts/cursive.lff")),
|
||||
("cyrillic_ii", include_str!("../../assets/fonts/cyrillic_ii.lff")),
|
||||
("gothgbt", include_str!("../../assets/fonts/gothgbt.lff")),
|
||||
("gothgrt", include_str!("../../assets/fonts/gothgrt.lff")),
|
||||
("gothitt", include_str!("../../assets/fonts/gothitt.lff")),
|
||||
("greekc", include_str!("../../assets/fonts/greekc.lff")),
|
||||
("greekcs", include_str!("../../assets/fonts/greekcs.lff")),
|
||||
("greek_ol", include_str!("../../assets/fonts/greek_ol.lff")),
|
||||
("greekp", include_str!("../../assets/fonts/greekp.lff")),
|
||||
("greeks", include_str!("../../assets/fonts/greeks.lff")),
|
||||
("iso3098_i", include_str!("../../assets/fonts/iso3098_i.lff")),
|
||||
("iso3098", include_str!("../../assets/fonts/iso3098.lff")),
|
||||
("iso", include_str!("../../assets/fonts/iso.lff")),
|
||||
("italicc", include_str!("../../assets/fonts/italicc.lff")),
|
||||
("italiccs", include_str!("../../assets/fonts/italiccs.lff")),
|
||||
("italict", include_str!("../../assets/fonts/italict.lff")),
|
||||
("kochigothic", include_str!("../../assets/fonts/kochigothic.lff")),
|
||||
("kochimincho", include_str!("../../assets/fonts/kochimincho.lff")),
|
||||
("kst32b", include_str!("../../assets/fonts/kst32b.lff")),
|
||||
("ltypeshp", include_str!("../../assets/fonts/ltypeshp.lff")),
|
||||
("lc_opengost-ar", include_str!("../../assets/fonts/lc_opengost-ar.lff")),
|
||||
("lc_opengost-br", include_str!("../../assets/fonts/lc_opengost-br.lff")),
|
||||
("opengosttypea-regular", include_str!("../../assets/fonts/opengosttypea-regular.lff")),
|
||||
("opengosttypeb-regular", include_str!("../../assets/fonts/opengosttypeb-regular.lff")),
|
||||
("romanc", include_str!("../../assets/fonts/romanc.lff")),
|
||||
("romancs", include_str!("../../assets/fonts/romancs.lff")),
|
||||
("romand", include_str!("../../assets/fonts/romand.lff")),
|
||||
("romanp", include_str!("../../assets/fonts/romanp.lff")),
|
||||
("romansi", include_str!("../../assets/fonts/romansi.lff")),
|
||||
("romans", include_str!("../../assets/fonts/romans.lff")),
|
||||
("romant", include_str!("../../assets/fonts/romant.lff")),
|
||||
("scriptc", include_str!("../../assets/fonts/scriptc.lff")),
|
||||
@@ -66,8 +47,6 @@ const FONTS_SRC: &[(&str, &str)] = &[
|
||||
("symap", include_str!("../../assets/fonts/symap.lff")),
|
||||
("symath", include_str!("../../assets/fonts/symath.lff")),
|
||||
("symbol", include_str!("../../assets/fonts/symbol.lff")),
|
||||
("symbol_misc1", include_str!("../../assets/fonts/symbol_misc1.lff")),
|
||||
("symbol_misc2", include_str!("../../assets/fonts/symbol_misc2.lff")),
|
||||
("symeteo", include_str!("../../assets/fonts/symeteo.lff")),
|
||||
("symusic", include_str!("../../assets/fonts/symusic.lff")),
|
||||
("unicode", include_str!("../../assets/fonts/unicode.lff")),
|
||||
@@ -98,9 +77,6 @@ const ALIASES: &[(&str, &str)] = &[
|
||||
("CYRILLIC", "cyrillic_ii"),
|
||||
("CYRILTLC", "cyrillic_ii"),
|
||||
("GREEK", "greekc"),
|
||||
("GOST", "opengosttypeb-regular"),
|
||||
("GOSTA", "opengosttypea-regular"),
|
||||
("GOSTB", "opengosttypeb-regular"),
|
||||
("BIGFONT", "unicode"),
|
||||
("EXTFONT", "unicode"),
|
||||
];
|
||||
@@ -184,23 +160,19 @@ fn fonts_map() -> &'static HashMap<String, Font> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Broad-coverage fallback font for glyphs missing from the selected family.
|
||||
fn unicode_font() -> &'static Font {
|
||||
/// Whether `name` resolves to an actual embedded LFF font (after stripping a
|
||||
/// trailing extension) — i.e. `get_font` would return a real match rather than
|
||||
/// the `STANDARD` fallback. Lets the font dispatcher prefer a system TrueType
|
||||
/// face only for names that are *not* one of our stroke fonts.
|
||||
pub fn is_builtin(name: &str) -> bool {
|
||||
let map = fonts_map();
|
||||
map.get("UNICODE")
|
||||
.or_else(|| map.get("STANDARD"))
|
||||
.or_else(|| map.values().next())
|
||||
.expect("at least one LFF font must be embedded")
|
||||
let key = name.trim().to_ascii_uppercase();
|
||||
map.contains_key(&key)
|
||||
|| key
|
||||
.rsplit_once('.')
|
||||
.is_some_and(|(stem, _)| map.contains_key(stem))
|
||||
}
|
||||
|
||||
/// Secondary fallback covering Latin-extended / Turkish letters (ğ Ş İ …)
|
||||
/// that `unicode` lacks.
|
||||
fn latin_ext_font() -> Option<&'static Font> {
|
||||
fonts_map().get("ISO3098")
|
||||
}
|
||||
|
||||
/// Return a font by name (case-insensitive). `.shx` suffix is stripped.
|
||||
/// Falls back to Standard → Unicode → any embedded font.
|
||||
pub fn get_font(name: &str) -> &'static Font {
|
||||
let map = fonts_map();
|
||||
let key = name.trim().to_ascii_uppercase();
|
||||
@@ -216,6 +188,135 @@ pub fn get_font(name: &str) -> &'static Font {
|
||||
.expect("at least one LFF font must be embedded")
|
||||
}
|
||||
|
||||
// ── Run tokenizer ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Decoration line a token toggles.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Deco {
|
||||
Under,
|
||||
Over,
|
||||
Strike,
|
||||
}
|
||||
|
||||
/// How a decoration token changes state. `\L`/`\O`/`\K` turn on (idempotent),
|
||||
/// `\l`/`\o`/`\k` turn off; `%%u`/`%%o` flip.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Op {
|
||||
On,
|
||||
Off,
|
||||
Toggle,
|
||||
}
|
||||
|
||||
/// One unit of a text run after inline-code resolution. Shared by the stroke
|
||||
/// (LFF) and shaped (TTF) renderers so both interpret the DXF inline grammar
|
||||
/// — `\L…\l` decorations and `%%` specials — identically.
|
||||
enum Tok {
|
||||
/// A renderable character (DXF `%%d`/`%%p`/`%%c`/`%%nnn` already resolved).
|
||||
Glyph(char),
|
||||
/// A literal space.
|
||||
Space,
|
||||
/// A `%%nnn` escape with fewer than three digits — advances like a missing
|
||||
/// glyph without drawing anything.
|
||||
Missing,
|
||||
/// A decoration toggle.
|
||||
Deco(Deco, Op),
|
||||
}
|
||||
|
||||
/// Resolve a run's inline codes into a flat token stream. Mirrors the original
|
||||
/// inline parser in `tessellate_text_run` exactly (verified by the golden test).
|
||||
fn tokenize_run(text: &str) -> Vec<Tok> {
|
||||
let mut toks = Vec::new();
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == '\\' {
|
||||
match chars.peek().copied() {
|
||||
Some('L') => {
|
||||
chars.next();
|
||||
toks.push(Tok::Deco(Deco::Under, Op::On));
|
||||
continue;
|
||||
}
|
||||
Some('l') => {
|
||||
chars.next();
|
||||
toks.push(Tok::Deco(Deco::Under, Op::Off));
|
||||
continue;
|
||||
}
|
||||
Some('O') => {
|
||||
chars.next();
|
||||
toks.push(Tok::Deco(Deco::Over, Op::On));
|
||||
continue;
|
||||
}
|
||||
Some('o') => {
|
||||
chars.next();
|
||||
toks.push(Tok::Deco(Deco::Over, Op::Off));
|
||||
continue;
|
||||
}
|
||||
Some('K') => {
|
||||
chars.next();
|
||||
toks.push(Tok::Deco(Deco::Strike, Op::On));
|
||||
continue;
|
||||
}
|
||||
Some('k') => {
|
||||
chars.next();
|
||||
toks.push(Tok::Deco(Deco::Strike, Op::Off));
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if ch == '%' && chars.peek() == Some(&'%') {
|
||||
chars.next();
|
||||
match chars.peek().map(|c| c.to_ascii_lowercase()) {
|
||||
Some('d') => {
|
||||
chars.next();
|
||||
toks.push(Tok::Glyph('°'));
|
||||
}
|
||||
Some('p') => {
|
||||
chars.next();
|
||||
toks.push(Tok::Glyph('±'));
|
||||
}
|
||||
Some('c') => {
|
||||
chars.next();
|
||||
toks.push(Tok::Glyph('⌀'));
|
||||
}
|
||||
Some('%') => {
|
||||
chars.next();
|
||||
toks.push(Tok::Glyph('%'));
|
||||
}
|
||||
Some('u') => {
|
||||
chars.next();
|
||||
toks.push(Tok::Deco(Deco::Under, Op::Toggle));
|
||||
}
|
||||
Some('o') => {
|
||||
chars.next();
|
||||
toks.push(Tok::Deco(Deco::Over, Op::Toggle));
|
||||
}
|
||||
Some(d) if d.is_ascii_digit() => {
|
||||
let mut digits = String::with_capacity(3);
|
||||
for _ in 0..3 {
|
||||
match chars.peek() {
|
||||
Some(&c) if c.is_ascii_digit() => digits.push(chars.next().unwrap()),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
if digits.len() == 3 {
|
||||
if let Some(c) = digits.parse::<u32>().ok().and_then(char::from_u32) {
|
||||
toks.push(Tok::Glyph(c));
|
||||
}
|
||||
} else {
|
||||
toks.push(Tok::Missing);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
toks.push(if ch == ' ' { Tok::Space } else { Tok::Glyph(ch) });
|
||||
}
|
||||
toks
|
||||
}
|
||||
|
||||
// ── Text tessellation ───────────────────────────────────────────────────────
|
||||
|
||||
/// Tessellate a text run into world-space 2-D strokes. Same semantics as the
|
||||
@@ -256,7 +357,7 @@ pub fn tessellate_text_run(
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let font = get_font(font_name);
|
||||
let face = crate::scene::font_face::Face::resolve(font_name);
|
||||
let scale = height / 9.0;
|
||||
let wf = if width_factor < 0.0 {
|
||||
width_factor.clamp(-100.0, -0.01)
|
||||
@@ -284,153 +385,109 @@ pub fn tessellate_text_run(
|
||||
const OVER_Y: f32 = 10.5;
|
||||
const STRIKE_Y: f32 = 4.5;
|
||||
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == '\\' {
|
||||
match chars.peek().copied() {
|
||||
Some('L') => {
|
||||
chars.next();
|
||||
if underline.is_none() {
|
||||
underline = Some(cursor_x);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Some('l') => {
|
||||
chars.next();
|
||||
if let Some(s) = underline.take() {
|
||||
out.push(vec![xform(s, UNDER_Y, 0.0), xform(cursor_x, UNDER_Y, 0.0)]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Some('O') => {
|
||||
chars.next();
|
||||
if overline.is_none() {
|
||||
overline = Some(cursor_x);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Some('o') => {
|
||||
chars.next();
|
||||
if let Some(s) = overline.take() {
|
||||
out.push(vec![xform(s, OVER_Y, 0.0), xform(cursor_x, OVER_Y, 0.0)]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Some('K') => {
|
||||
chars.next();
|
||||
if strikethrough.is_none() {
|
||||
strikethrough = Some(cursor_x);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Some('k') => {
|
||||
chars.next();
|
||||
if let Some(s) = strikethrough.take() {
|
||||
out.push(vec![
|
||||
xform(s, STRIKE_Y, 0.0),
|
||||
xform(cursor_x, STRIKE_Y, 0.0),
|
||||
]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let toks = tokenize_run(text);
|
||||
let ttf_family = face.ttf_family();
|
||||
|
||||
// DXF %%x special-character sequences.
|
||||
let render_ch: char = if ch == '%' && chars.peek() == Some(&'%') {
|
||||
chars.next();
|
||||
match chars.peek().map(|c| c.to_ascii_lowercase()) {
|
||||
Some('d') => {
|
||||
chars.next();
|
||||
'°'
|
||||
}
|
||||
Some('p') => {
|
||||
chars.next();
|
||||
'±'
|
||||
}
|
||||
Some('c') => {
|
||||
chars.next();
|
||||
'⌀'
|
||||
}
|
||||
Some('%') => {
|
||||
chars.next();
|
||||
'%'
|
||||
}
|
||||
Some('u') => {
|
||||
chars.next();
|
||||
underline = match underline.take() {
|
||||
Some(start) => {
|
||||
out.push(vec![
|
||||
xform(start, UNDER_Y, 0.0),
|
||||
xform(cursor_x, UNDER_Y, 0.0),
|
||||
]);
|
||||
None
|
||||
}
|
||||
None => Some(cursor_x),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
Some('o') => {
|
||||
chars.next();
|
||||
overline = match overline.take() {
|
||||
Some(start) => {
|
||||
out.push(vec![xform(start, OVER_Y, 0.0), xform(cursor_x, OVER_Y, 0.0)]);
|
||||
None
|
||||
}
|
||||
None => Some(cursor_x),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
Some(d) if d.is_ascii_digit() => {
|
||||
let mut digits = String::with_capacity(3);
|
||||
for _ in 0..3 {
|
||||
match chars.peek() {
|
||||
Some(&c) if c.is_ascii_digit() => digits.push(chars.next().unwrap()),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
if digits.len() == 3 {
|
||||
match digits.parse::<u32>().ok().and_then(char::from_u32) {
|
||||
Some(c) => c,
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
cursor_x += (6.0 + font.letter_spacing * tracking) * wf;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
// Render a stroke directly (LFF glyph) or per shaped contour (TTF), applying
|
||||
// the run transform at the current pen position.
|
||||
let emit_glyph = |out: &mut Vec<Vec<[f32; 2]>>, strokes: &[Vec<[f32; 2]>], cx: f32| {
|
||||
for stroke in strokes {
|
||||
if stroke.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
out.push(stroke.iter().map(|&[gx, gy]| xform(gx, gy, cx)).collect());
|
||||
}
|
||||
};
|
||||
|
||||
// Flush a buffered TTF segment: shape it, emit the positioned glyph
|
||||
// contours, and advance the pen by the shaped run width. Falls back to
|
||||
// per-glyph outlines if shaping is unavailable.
|
||||
let flush_ttf = |seg: &mut String, cursor_x: &mut f32, out: &mut Vec<Vec<[f32; 2]>>| {
|
||||
if seg.is_empty() {
|
||||
return;
|
||||
}
|
||||
let family = ttf_family.unwrap_or("");
|
||||
if let Some(run) = crate::scene::ttf_glyph::shape_run(family, seg) {
|
||||
for g in &run.glyphs {
|
||||
emit_glyph(out, &g.strokes, *cursor_x);
|
||||
}
|
||||
*cursor_x += run.advance * wf;
|
||||
} else {
|
||||
ch
|
||||
};
|
||||
|
||||
if render_ch == ' ' {
|
||||
cursor_x += font.word_spacing;
|
||||
continue;
|
||||
}
|
||||
// Selected family first, then the broad Unicode fallback, then
|
||||
// iso3098 for Latin-extended/Turkish letters Unicode omits.
|
||||
let glyph = font
|
||||
.glyph(render_ch)
|
||||
.or_else(|| unicode_font().glyph(render_ch))
|
||||
.or_else(|| latin_ext_font().and_then(|f| f.glyph(render_ch)));
|
||||
match glyph {
|
||||
Some(glyph) => {
|
||||
for stroke in &glyph.strokes {
|
||||
if stroke.len() < 2 {
|
||||
continue;
|
||||
for ch in seg.chars() {
|
||||
match face.glyph(ch) {
|
||||
Some(glyph) => {
|
||||
emit_glyph(out, &glyph.strokes, *cursor_x);
|
||||
*cursor_x += (glyph.advance + face.letter_spacing() * tracking) * wf;
|
||||
}
|
||||
None => {
|
||||
warn_missing_glyph(font_name, ch);
|
||||
*cursor_x += (6.0 + face.letter_spacing() * tracking) * wf;
|
||||
}
|
||||
out.push(stroke.iter().map(|&[gx, gy]| xform(gx, gy, cursor_x)).collect());
|
||||
}
|
||||
cursor_x += (glyph.advance + font.letter_spacing * tracking) * wf;
|
||||
}
|
||||
None => {
|
||||
warn_missing_glyph(font_name, render_ch);
|
||||
cursor_x += (6.0 + font.letter_spacing * tracking) * wf;
|
||||
}
|
||||
}
|
||||
seg.clear();
|
||||
};
|
||||
|
||||
let mut seg = String::new();
|
||||
for tok in &toks {
|
||||
// TTF shaping batches consecutive glyphs; any break (space, missing,
|
||||
// decoration toggle, end of run) flushes the buffer first so pen
|
||||
// positions stay correct for decorations.
|
||||
if ttf_family.is_some() && !matches!(tok, Tok::Glyph(_)) {
|
||||
flush_ttf(&mut seg, &mut cursor_x, &mut out);
|
||||
}
|
||||
match tok {
|
||||
Tok::Glyph(c) => {
|
||||
if ttf_family.is_some() {
|
||||
seg.push(*c);
|
||||
} else {
|
||||
match face.glyph(*c) {
|
||||
Some(glyph) => {
|
||||
emit_glyph(&mut out, &glyph.strokes, cursor_x);
|
||||
cursor_x += (glyph.advance + face.letter_spacing() * tracking) * wf;
|
||||
}
|
||||
None => {
|
||||
warn_missing_glyph(font_name, *c);
|
||||
cursor_x += (6.0 + face.letter_spacing() * tracking) * wf;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Tok::Space => cursor_x += face.word_spacing(),
|
||||
Tok::Missing => cursor_x += (6.0 + face.letter_spacing() * tracking) * wf,
|
||||
Tok::Deco(deco, op) => {
|
||||
let (slot, y) = match deco {
|
||||
Deco::Under => (&mut underline, UNDER_Y),
|
||||
Deco::Over => (&mut overline, OVER_Y),
|
||||
Deco::Strike => (&mut strikethrough, STRIKE_Y),
|
||||
};
|
||||
match op {
|
||||
Op::On => {
|
||||
if slot.is_none() {
|
||||
*slot = Some(cursor_x);
|
||||
}
|
||||
}
|
||||
Op::Off => {
|
||||
if let Some(s) = slot.take() {
|
||||
out.push(vec![xform(s, y, 0.0), xform(cursor_x, y, 0.0)]);
|
||||
}
|
||||
}
|
||||
Op::Toggle => {
|
||||
*slot = match slot.take() {
|
||||
Some(s) => {
|
||||
out.push(vec![xform(s, y, 0.0), xform(cursor_x, y, 0.0)]);
|
||||
None
|
||||
}
|
||||
None => Some(cursor_x),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ttf_family.is_some() {
|
||||
flush_ttf(&mut seg, &mut cursor_x, &mut out);
|
||||
}
|
||||
|
||||
if let Some(start) = underline {
|
||||
@@ -666,6 +723,34 @@ fn bulge_to_points(p0: [f32; 2], p1: [f32; 2], bulge: f32) -> Vec<[f32; 2]> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Locks the LFF `tessellate_text_run` output (segment count, vertex count,
|
||||
/// coordinate checksum) so the shared-tokenizer refactor that adds the TTF
|
||||
/// shaping path cannot silently change stroke-font rendering.
|
||||
#[test]
|
||||
fn lff_tessellation_golden() {
|
||||
let cases: &[(&str, usize, usize, f64)] = &[
|
||||
("ABC 123", 8, 80, 2853.5141),
|
||||
("A\\LB\\lC", 6, 43, 825.5505),
|
||||
("10%%d Ö", 6, 60, 1479.1397),
|
||||
("Hello, World!", 15, 113, 5078.5248),
|
||||
];
|
||||
for &(t, segs, verts, sum_ref) in cases {
|
||||
let st = tessellate_text_ex([0.0, 0.0], 10.0, 0.0, 1.0, 0.0, "txt", t);
|
||||
let nv: usize = st.iter().map(|s| s.len()).sum();
|
||||
let sum: f64 = st
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|p| p[0] as f64 + p[1] as f64)
|
||||
.sum();
|
||||
assert_eq!(st.len(), segs, "segment count drift for {t:?}");
|
||||
assert_eq!(nv, verts, "vertex count drift for {t:?}");
|
||||
assert!(
|
||||
(sum - sum_ref).abs() < 0.01,
|
||||
"coordinate checksum drift for {t:?}: {sum:.4} vs {sum_ref:.4}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -679,6 +764,21 @@ mod tests {
|
||||
// Alias + fallback resolve to a real font.
|
||||
assert!(get_font("txt").glyph('A').is_some());
|
||||
assert!(get_font("RomanS").glyph('B').is_some());
|
||||
// After curation, every embedded stem and AutoCAD SHX alias must still
|
||||
// resolve to a glyph-bearing font (no alias points at a deleted file).
|
||||
for name in [
|
||||
"MONOTXT", "ISOCP", "ISOCP3", "ISOCT", "ISOCT3", "COMPLEX", "ITALIC", "GOTHICE",
|
||||
"GOTHICG", "GOTHICI", "CYRILLIC", "GREEK", "BIGFONT", "Simplex", "ScriptC", "Symbol",
|
||||
] {
|
||||
assert!(
|
||||
get_font(name).glyph('A').is_some() || is_builtin(name),
|
||||
"alias/stem {name} failed to resolve"
|
||||
);
|
||||
}
|
||||
// A name from a deleted LibreCAD-only font is no longer a builtin and
|
||||
// falls back to Standard rather than panicking.
|
||||
assert!(!is_builtin("amiri-regular"));
|
||||
assert!(get_font("kochigothic").glyph('A').is_some());
|
||||
// Unicode fallback covers a non-ASCII letter via the renderer path.
|
||||
let strokes = tessellate_text_run([0.0, 0.0], 2.5, 0.0, 1.0, 0.0, 1.0, "Standard", "Aб");
|
||||
assert!(!strokes.is_empty());
|
||||
|
||||
@@ -4,6 +4,7 @@ mod camera;
|
||||
pub mod complex_lt;
|
||||
pub mod lff;
|
||||
pub mod dispatch;
|
||||
pub mod font_face;
|
||||
pub mod grip;
|
||||
pub mod hatch_model;
|
||||
pub mod hatch_patterns;
|
||||
@@ -28,6 +29,8 @@ mod selection;
|
||||
pub mod acis_to_truck;
|
||||
pub mod solid3d_tess;
|
||||
pub mod spline_tess;
|
||||
pub mod sysfont;
|
||||
pub mod ttf_glyph;
|
||||
pub mod tess_util;
|
||||
pub mod tessellate;
|
||||
pub mod transform;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// System TrueType/OpenType font discovery.
|
||||
//
|
||||
// Wraps a `fontdb` database loaded with the user's installed system fonts so
|
||||
// the rest of the app can (a) list available font families for the text-style
|
||||
// picker and (b) borrow a face's raw bytes to extract glyph outlines (see the
|
||||
// TTF glyph engine). LFF stroke fonts stay separate — this is purely the
|
||||
// TrueType side of the renderer.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
struct SysFonts {
|
||||
db: fontdb::Database,
|
||||
/// Sorted, de-duplicated family names for the picker.
|
||||
families: Vec<String>,
|
||||
}
|
||||
|
||||
static FONTS: OnceLock<SysFonts> = OnceLock::new();
|
||||
|
||||
fn fonts() -> &'static SysFonts {
|
||||
FONTS.get_or_init(|| {
|
||||
let mut db = fontdb::Database::new();
|
||||
db.load_system_fonts();
|
||||
|
||||
let mut families: Vec<String> = db
|
||||
.faces()
|
||||
.filter_map(|face| face.families.first().map(|(name, _)| name.clone()))
|
||||
.collect();
|
||||
families.sort_by_key(|n| n.to_lowercase());
|
||||
families.dedup();
|
||||
|
||||
SysFonts { db, families }
|
||||
})
|
||||
}
|
||||
|
||||
/// All installed system font families, sorted case-insensitively, de-duped.
|
||||
pub fn families() -> &'static [String] {
|
||||
&fonts().families
|
||||
}
|
||||
|
||||
/// Resolve a family name to a concrete face id (regular weight/style).
|
||||
fn face_id(family: &str) -> Option<fontdb::ID> {
|
||||
let db = &fonts().db;
|
||||
let query = fontdb::Query {
|
||||
families: &[fontdb::Family::Name(family)],
|
||||
..Default::default()
|
||||
};
|
||||
db.query(&query)
|
||||
}
|
||||
|
||||
/// Borrow the raw face bytes for `family` and run `f` over them. The byte slice
|
||||
/// is only valid inside the closure, so callers extract everything they need
|
||||
/// (e.g. flattened glyph outlines) before returning. `index` is the face index
|
||||
/// within a TrueType collection. Returns `None` if the family is unknown.
|
||||
pub fn with_face_data<T>(family: &str, f: impl FnOnce(&[u8], u32) -> T) -> Option<T> {
|
||||
let id = face_id(family)?;
|
||||
fonts().db.with_face_data(id, f)
|
||||
}
|
||||
|
||||
/// Whether `family` matches an installed system font (case-insensitive via
|
||||
/// fontdb's own matching).
|
||||
pub fn has_family(family: &str) -> bool {
|
||||
face_id(family).is_some()
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
// 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::lff::Glyph;
|
||||
use crate::scene::sysfont;
|
||||
use rustc_hash::FxHashMap as HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
face.outline_glyph(gid, &mut fl);
|
||||
fl.flush();
|
||||
|
||||
Some(Arc::new(Glyph {
|
||||
strokes: fl.contours,
|
||||
advance,
|
||||
}))
|
||||
})
|
||||
.flatten();
|
||||
|
||||
cache().lock().unwrap().insert(key, built.clone());
|
||||
built
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
// ── 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]>>,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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`.
|
||||
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
|
||||
}
|
||||
|
||||
fn build_fallback(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 k = cap_scale(&face);
|
||||
let gid = ttf_parser::GlyphId(g.glyph_id);
|
||||
let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k;
|
||||
let mut fl = OutlineFlattener::new(k);
|
||||
face.outline_glyph(gid, &mut fl);
|
||||
fl.flush();
|
||||
return Some(Arc::new(Glyph {
|
||||
strokes: fl.contours,
|
||||
advance,
|
||||
}));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn build_shaped(family: &str, text: &str) -> Option<ShapedRun> {
|
||||
use cosmic_text::{Attrs, Buffer, Family, Metrics, Shaping};
|
||||
|
||||
// Cap height / units-per-em of the requested family set the normalization:
|
||||
// its capital letters become CAP_UNITS tall, and every fallback glyph is
|
||||
// scaled into the same pixel-per-unit so sizes stay consistent.
|
||||
let (upem_p, cap_p) = 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()?;
|
||||
// Pixel (at SHAPE_FS) → 9-unit cap-height factor.
|
||||
let px_to_9 = CAP_UNITS * upem_p / (SHAPE_FS * cap_p);
|
||||
|
||||
let mut fs = font_system().lock().unwrap();
|
||||
let attrs = 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);
|
||||
|
||||
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];
|
||||
face.outline_glyph(ttf_parser::GlyphId(g.glyph_id), &mut fl);
|
||||
fl.flush();
|
||||
if !fl.contours.is_empty() {
|
||||
glyphs.push(PlacedGlyph {
|
||||
strokes: fl.contours,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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");
|
||||
}
|
||||
}
|
||||
+51
-2
@@ -71,7 +71,7 @@ const LIST: Color = Color {
|
||||
|
||||
const BUILTIN_FONTS: &[&str] = &[
|
||||
"Standard", "ISO", "Simplex", "RomanS", "RomanD", "RomanC", "RomanT", "ItalicC", "ItalicT",
|
||||
"ScriptS", "ScriptC", "GothGBT", "GothGRT", "GothITT", "Cursive", "GreekC", "Symbol", "ISO",
|
||||
"ScriptS", "ScriptC", "GothGBT", "GothGRT", "GothITT", "GreekC", "Symbol",
|
||||
"ISO3098", "Unicode",
|
||||
];
|
||||
|
||||
@@ -255,7 +255,56 @@ pub fn view_window<'a>(v: TextStyleView<'a>) -> Element<'a, Message> {
|
||||
left: 8.0,
|
||||
});
|
||||
|
||||
let editor = row![font_panel, vsep(), props_panel].height(Fill);
|
||||
// ── System TrueType fonts (read from the user's machine via fontdb) ──
|
||||
let ttf_items: Vec<Element<'_, Message>> = crate::scene::sysfont::families()
|
||||
.iter()
|
||||
.map(|fam| {
|
||||
let is_sel = ttf_buf.eq_ignore_ascii_case(fam);
|
||||
button(text(fam).size(10))
|
||||
.on_press(Message::TextStyleEdit {
|
||||
field: "ttf",
|
||||
value: fam.clone(),
|
||||
})
|
||||
.style(list_item(is_sel))
|
||||
.padding([3, 8])
|
||||
.width(Fill)
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let ttf_panel = container(
|
||||
column![
|
||||
text("TrueType (system)").size(10).color(DIM),
|
||||
container(scrollable(column(ttf_items).spacing(1)).height(Fill))
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(LIST)),
|
||||
border: Border {
|
||||
color: BORDER,
|
||||
width: 1.0,
|
||||
radius: 3.0.into()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.padding(2),
|
||||
text_input("TrueType font…", ttf_buf)
|
||||
.on_input(|v| Message::TextStyleEdit {
|
||||
field: "ttf",
|
||||
value: v
|
||||
})
|
||||
.style(field_style)
|
||||
.size(11)
|
||||
.width(Fill),
|
||||
]
|
||||
.spacing(6)
|
||||
.height(Fill),
|
||||
)
|
||||
.width(190)
|
||||
.height(Fill)
|
||||
.padding([12, 8]);
|
||||
|
||||
let editor = row![font_panel, vsep(), ttf_panel, vsep(), props_panel].height(Fill);
|
||||
|
||||
crate::ui::style_manager::view(crate::ui::style_manager::Scaffold {
|
||||
kind: StyleKind::Text,
|
||||
|
||||
Reference in new issue
Block a user