// 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::text::lff::{self, Glyph}; use crate::scene::text::{shx, 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), } 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, }, /// A real compiled .SHX stroke font on disk (glyphs keyed by char code). Shx { path: String, /// (above + below) / above — baseline step relative to cap height. line: f32, /// Space advance (9-unit). word: f32, }, /// A face paired with an Asian big font (`chineset.shx`, `hztxt.shx`, …): /// double-byte characters come from the big font, everything else from /// `primary` — exactly how a STYLE with a "Big Font" behaves in AutoCAD. WithBig { primary: Box, /// Absolute path of the `AutoCAD-86 bigfont 1.0` file. big: String, }, } /// Separator between a style's primary font name and its big-font path in /// the single `font_name` string the text pipeline passes around (see /// [`Face::with_big_font`]); a control character no font name contains. pub const BIG_FONT_SEP: char = '\u{1}'; 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 { // `primarybig` pairs a big font with the primary face. if let Some((primary, big)) = font_name.split_once(BIG_FONT_SEP) { let face = Face::resolve(primary); return if !big.is_empty() && shx::is_bigfont(big) { Face::WithBig { primary: Box::new(face), big: big.to_string(), } } else { face }; } // A resolvable on-disk .SHX font renders its real stroke glyphs; the // LFF substitutes only cover names we can't load. if font_name.to_ascii_lowercase().ends_with(".shx") && std::path::Path::new(font_name).is_file() { if let Some((above, below)) = shx::font_metrics(font_name) { let word = shx::font_glyph(font_name, ' ' as u16) .map(|g| g.advance) .filter(|a| *a > 0.0) .unwrap_or(4.5); return Face::Shx { path: font_name.to_string(), line: ((above + below) / above) as f32, word, }; } } let is_builtin = lff::is_builtin(font_name); let has_sys = sysfont::has_family(font_name); if !is_builtin && has_sys { let canonical = sysfont::canonical_family_name(font_name).unwrap_or_else(|| font_name.to_string()); let word = ttf_glyph::glyph(&canonical, ' ') .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: canonical, word, }; } Face::Lff(lff::get_font(font_name)) } /// The `font_name` string that pairs `primary` with the big font at /// `big_path` for [`Face::resolve`]. Every text entry point takes a font /// name, so the pairing rides along in that string. pub fn with_big_font(primary: &str, big_path: &str) -> String { format!("{primary}{BIG_FONT_SEP}{big_path}") } /// 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> { 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), Face::Shx { path, .. } => shx::font_glyph(path, ch as u16) .map(GlyphRef::Owned) .or_else(|| ttf_glyph::fallback_glyph(ch).map(GlyphRef::Owned)), Face::WithBig { primary, big } => { if ttf_glyph::is_full_width(ch) { if let Some(g) = shx::bigfont_glyph(big, ch) { return Some(GlyphRef::Owned(g)); } } primary.glyph(ch) } } } /// 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, // SHX advances already include the trailing gap by design. Face::Shx { .. } => 0.0, Face::WithBig { primary, .. } => primary.letter_spacing(), } } /// Width of a space (9-unit). pub fn word_spacing(&self) -> f32 { match self { Face::Lff(f) => f.word_spacing, Face::Ttf { word, .. } | Face::Shx { word, .. } => *word, Face::WithBig { primary, .. } => primary.word_spacing(), } } /// 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(_) | Face::Shx { .. } => None, Face::WithBig { primary, .. } => primary.ttf_family(), } } /// 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, Face::Shx { line, .. } => *line, Face::WithBig { primary, .. } => primary.line_spacing(), } } } #[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" ); } #[test] fn case_insensitive_ttf_resolution() { let fams = sysfont::families(); if fams.is_empty() { eprintln!("no system fonts; skipping"); return; } // Arial and Cambria are typical on Windows/Mac/Linux for test_name in &["arial", "ARIAL", "ARIALN", "cambria"] { let resolved = Face::resolve(test_name); match resolved { Face::Ttf { family, .. } => { assert!( family == "Arial" || family == "Arial Narrow" || family == "Cambria", "Resolved to unexpected family name: {}", family ); } Face::Lff(_) => { // It's possible some test environments don't have Arial or Cambria, // but if they are present in sysfont::families() (or we can fallback), // they should resolve to Ttf. If they aren't installed, Lff fallback is accepted. eprintln!("Font {} resolved to Lff (probably not installed)", test_name); } Face::Shx { .. } | Face::WithBig { .. } => { // A system TTF name never resolves to an SHX shape font // (nor gains a big font without the pairing separator). eprintln!("Font {} resolved to Shx (unexpected)", test_name); } } } } #[test] fn big_font_pairing_routes_ideographs_and_leaves_latin_alone() { // Without a real big-font file the pairing is dropped: the primary // face is used as-is. assert!(matches!( Face::resolve(&Face::with_big_font("txt", "C:/nowhere/chineset.shx")), Face::Lff(_) )); // A stroke face still renders Latin from its own glyphs when paired. let face = Face::resolve("txt"); assert!(face.glyph('A').is_some()); } }