From f954c7f4952d9cd41f48586784db50d30eaa374f Mon Sep 17 00:00:00 2001 From: Alexey Bagno Date: Tue, 22 Sep 2026 09:39:43 +0300 Subject: [PATCH 1/2] fix(text): resolve a style's font file name against installed faces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A DWG text style stores its TrueType font as a *file* name — `GOST2304_TypeA_italic.ttf`, sometimes with a directory — which is what AutoCAD resolves. The family lives inside the file and is usually a different string (`GOST2304 Type A`), so looking the reference up as a family missed an installed font and fell through to the embedded stroke fonts. Those carry no Cyrillic at all (`simplex`, `standard`, `romans`, `iso3098` hold no U+04xx glyph), so a Russian drawing came out with every word missing: the letters went on to the system-font fallback, which returns nothing on macOS. Installed faces are now indexed by file name, and a reference — a style's font file, or an inline `\f` code, which carries either form — resolves by family first and by file name second. An embedded stroke font still wins, so `romans.shx` keeps rendering as it did. `cargo test --lib face_files_resolve_to_their_family` --- src/entities/text_support.rs | 49 +++++++++++++------ src/scene/text/sysfont.rs | 92 ++++++++++++++++++++++++++++++++++-- 2 files changed, 122 insertions(+), 19 deletions(-) diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index 57e865093..c41269990 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -5,6 +5,20 @@ use crate::scene::convert::acad_to_render::{GlyphRun, TextStroke}; use crate::scene::text::font_face::Face; use crate::scene::text::lff; +/// A font reference a drawing holds — a style's font file, or an inline `\f` +/// code — turned into an installed family. +/// +/// An embedded stroke font wins over anything installed, so `romans.shx` keeps +/// rendering as it did even where a font of that name happens to be installed; +/// only a name no stroke font answers to is looked up among the system faces, +/// first by family and then by file name (see `sysfont::family_for_reference`). +fn resolve_font_reference(reference: &str) -> Option { + if lff::is_builtin(reference) { + return None; + } + crate::scene::text::sysfont::family_for_reference(reference) +} + pub struct ResolvedTextStyle { pub font_name: String, pub width_factor: f32, @@ -21,14 +35,20 @@ pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedT }); let mut font_name = if let Some(style) = style { - if !style.true_type_font.trim().is_empty() { - style.true_type_font.trim().to_string() - } else if !style.font_file.trim().is_empty() { - let file = style.font_file.trim(); + // A style names a font *file* — `GOST2304_TypeA_italic.ttf`, + // `romans.shx`, sometimes with a directory; `true_type_font` carries the + // same thing for the styles that set it. + let file = if !style.true_type_font.trim().is_empty() { + style.true_type_font.trim() + } else { + style.font_file.trim() + }; + if !file.is_empty() { // A .shx font that resolves on disk (as stored, or next to the // drawing) renders its REAL stroke glyphs — pass the absolute - // path through so `Face::resolve` picks the SHX face. Only an - // unresolvable file falls back to the stem's LFF substitute. + // path through so `Face::resolve` picks the SHX face. Fonts fetched + // from the community repository live in the per-user fonts folder, + // so that is searched last, after the drawing folder. let shx_path = file .to_ascii_lowercase() .ends_with(".shx") @@ -38,10 +58,7 @@ pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedT .as_deref() .map(std::path::Path::new) .and_then(|p| p.parent()); - crate::io::resolve_image_file(file, base) - // Fonts fetched from the community repository live in - // the per-user fonts folder — search it last. - .or_else(|| { + crate::io::resolve_image_file(file, base).or_else(|| { crate::io::font_repo::local_font_file(file) .map(|path| path.to_string_lossy().into_owned()) }) @@ -49,6 +66,11 @@ pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedT .flatten(); if let Some(p) = shx_path { p + } else if let Some(family) = resolve_font_reference(file) { + // The file is installed, so its family is the face to draw with. + // Substituting a stroke font instead drops every letter that + // font does not have — for a Cyrillic drawing, all of them. + family } else { let basename = file.rsplit(['/', '\\']).next().unwrap_or(file); let stem = basename.split('.').next().unwrap_or(basename).trim(); @@ -900,10 +922,9 @@ pub fn resolve_font<'a>(state: &'a RunState, base: &'a str) -> std::borrow::Cow< if lff::is_builtin(font) { return std::borrow::Cow::Borrowed(font); } - if let Some(canonical) = crate::scene::text::sysfont::canonical_family_name(font) { - std::borrow::Cow::Owned(canonical) - } else { - std::borrow::Cow::Borrowed(base) + match resolve_font_reference(font) { + Some(canonical) => std::borrow::Cow::Owned(canonical), + None => std::borrow::Cow::Borrowed(base), } } diff --git a/src/scene/text/sysfont.rs b/src/scene/text/sysfont.rs index 7e24c4d2a..09e2427fb 100644 --- a/src/scene/text/sysfont.rs +++ b/src/scene/text/sysfont.rs @@ -13,6 +13,10 @@ struct SysFonts { db: fontdb::Database, /// Sorted, de-duplicated family names for the picker. families: Vec, + /// Installed face file name (lower-case) → the family that face holds. + /// A drawing references a TrueType style by *file* name, so this is what + /// makes such a style resolve — see [`family_for_reference`]. + by_file: HashMap, } static FONTS: OnceLock = OnceLock::new(); @@ -22,14 +26,31 @@ fn fonts() -> &'static SysFonts { let mut db = fontdb::Database::new(); db.load_system_fonts(); - let mut families: Vec = db - .faces() - .filter_map(|face| face.families.first().map(|(name, _)| name.clone())) - .collect(); + let mut families: Vec = Vec::new(); + let mut by_file: HashMap = HashMap::new(); + for face in db.faces() { + let Some((family, _)) = face.families.first() else { + continue; + }; + families.push(family.clone()); + // Remember every face file: a DWG style names the file, and the + // family inside it is usually a different string. + if let fontdb::Source::File(path) = &face.source { + if let Some(name) = path.file_name() { + by_file + .entry(name.to_string_lossy().to_ascii_lowercase()) + .or_insert_with(|| family.clone()); + } + } + } families.sort_by_key(|n| n.to_lowercase()); families.dedup(); - SysFonts { db, families } + SysFonts { + db, + families, + by_file, + } }) } @@ -136,9 +157,70 @@ pub fn with_face_data(family: &str, f: impl FnOnce(&[u8], u32) -> T) -> Optio fonts().db.with_face_data(id, f) } +/// The family of the installed face whose file is named `name` — a bare file +/// name or any path to one, matched case-insensitively. +fn family_for_file(name: &str) -> Option { + let file = name.rsplit(['/', '\\']).next()?.trim(); + if file.is_empty() { + return None; + } + fonts().by_file.get(&file.to_ascii_lowercase()).cloned() +} + +/// The installed family a drawing's font reference names, or `None` when +/// nothing installed answers to it. +/// +/// A DWG text style stores the TrueType font *file* (`GOST2304_TypeA_italic.ttf`, +/// sometimes with a directory), which is what AutoCAD resolves; the family name +/// lives inside the file and is usually not the same string. Inline `\f` codes +/// carry either form, so both are tried. +pub fn family_for_reference(reference: &str) -> Option { + let reference = reference.trim(); + if reference.is_empty() { + return None; + } + canonical_family_name(reference).or_else(|| family_for_file(reference)) +} + /// 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() } +#[cfg(test)] +mod tests { + use super::*; + + /// A DWG stores a style's TrueType font as a file name, so every installed + /// face has to be reachable by the name of its file — case-insensitively, + /// and whether it comes as a bare name or as a path. + #[test] + fn face_files_resolve_to_their_family() { + let found = fonts().db.faces().find_map(|face| match &face.source { + fontdb::Source::File(path) => { + let file = path.file_name()?.to_string_lossy().into_owned(); + let family = face.families.first().map(|(name, _)| name.clone())?; + Some((file, family)) + } + _ => None, + }); + let Some((file, family)) = found else { + eprintln!("no installed face files on this host; skipping"); + return; + }; + + assert_eq!(family_for_file(&file).as_deref(), Some(family.as_str())); + assert_eq!( + family_for_file(&file.to_ascii_uppercase()).as_deref(), + Some(family.as_str()) + ); + assert_eq!( + family_for_reference(&format!("/somewhere/else/{file}")).as_deref(), + Some(family.as_str()) + ); + assert_eq!(family_for_file("no-such-face-9f3.ttf"), None); + assert_eq!(family_for_reference(" "), None); + } +} + From c0dccff14a8dfbd4f8718cb091cc81acc3dc4dac Mon Sep 17 00:00:00 2001 From: Alexey Bagno Date: Tue, 22 Sep 2026 09:39:43 +0300 Subject: [PATCH 2/2] fix(text): never accept a fallback glyph that has no outline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cosmic-text's fallback search can land on a face that claims the character and outlines to nothing: macOS's system font does that for every glyph, because ttf-parser reads no contours out of it. The code took the first non-.notdef glyph id and stopped, so the character drew nothing while believing it drew something — invisible text, and no warning either. An empty outline is now treated as no answer: the search moves on to the next face, and when cosmic-text's own list has nothing that draws the character, the installed families are walked for one that does. That list is the platform's own handful of names (on macOS `.SF NS`, `Menlo`, `Apple Color Emoji`, `Geneva`, `Arial Unicode MS`) and covers neither Cyrillic nor Greek, however many fonts the machine has that do. `cargo test --lib a_fallback_glyph_is_never_empty`, and the Turkish letters in `lff::tests::fonts_parse_and_resolve` render on macOS now instead of failing. --- src/scene/text/ttf_glyph.rs | 165 ++++++++++++++++++++++++++---------- 1 file changed, 119 insertions(+), 46 deletions(-) diff --git a/src/scene/text/ttf_glyph.rs b/src/scene/text/ttf_glyph.rs index 0024034b2..8e324b037 100644 --- a/src/scene/text/ttf_glyph.rs +++ b/src/scene/text/ttf_glyph.rs @@ -366,6 +366,125 @@ pub fn clear_fallback_cache() { crate::scene::text::sdf_atlas::reset_font_entries(); } +#[cfg(test)] +mod fallback_tests { + use super::*; + + /// A fallback that comes back with an id but no outline draws nothing while + /// believing it drew something: the stroke fonts hand every letter they + /// lack to this path, so on macOS a Cyrillic drawing came out blank. + #[test] + fn a_fallback_glyph_is_never_empty() { + for ch in ['Ğ', 'ş', 'б', 'Я', 'Ω', '中', 'A'] { + let Some(glyph) = fallback_glyph(ch) else { + continue; // no font on this host covers it at all + }; + assert!( + !glyph.strokes.is_empty() || !glyph.fill_tris.is_empty(), + "'{ch}' resolved to a glyph with no outline" + ); + } + } +} + +/// Last-resort glyph for a character missing from a stroke (LFF) font: whatever +/// this machine can draw it with, normalized to 9-unit cap height, or `None` +/// when nothing installed has the character. Cached per character. +/// +/// The result is a filled-outline glyph, so it visually differs from the +/// surrounding single-stroke text — accepted as the price of covering scripts +/// no stroke font provides. +#[cfg(not(target_arch = "wasm32"))] +fn build_fallback(ch: char) -> Option> { + outline_from_fallback_face(ch).or_else(|| installed_family_glyph(ch)) +} + +/// Outline `ch` from a parsed face, in the 9-unit text space. +/// +/// A fallback glyph stands in for a stroke / SHX font that lacks the character. +/// For an ideograph that font would have been a big font, whose glyphs fill the +/// text height — so size the substitute by its em box. Everything else keeps the +/// cap-height normalisation that lines it up with the Latin stroke glyphs. +#[cfg(not(target_arch = "wasm32"))] +fn outline_char(face: &ttf_parser::Face, ch: char) -> Option { + let gid = face.glyph_index(ch)?; + let k = if is_full_width(ch) { + em_scale(face) + } else { + cap_scale(face) + }; + let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k; + let mut fl = OutlineFlattener::new(k); + face.outline_glyph(gid, &mut fl); + fl.flush(); + let fill_tris = triangulate_contours(&fl.contours); + Some(Glyph { + strokes: fl.contours, + advance, + fill_tris, + }) +} + +/// Outline `ch` from the face cosmic-text picks for it: it knows the platform's +/// fallback order and shapes its way through ligatures and joining. +/// +/// A face can answer with a glyph id and no outline at all — macOS's system font +/// does, for every character, because ttf-parser reads no contours out of it — +/// so an empty outline is not an answer and the caller has to keep looking. +#[cfg(not(target_arch = "wasm32"))] +fn outline_from_fallback_face(ch: char) -> Option> { + use cosmic_text::{Attrs, Buffer, Metrics, Shaping}; + let mut fs = font_system().lock().unwrap(); + // Default family → cosmic's own fallback search chooses a covering font. + let attrs = Attrs::new(); + let mut buf = Buffer::new(&mut fs, Metrics::new(SHAPE_FS, SHAPE_FS)); + buf.set_size(&mut fs, None, None); + let s = ch.to_string(); + buf.set_text(&mut fs, &s, &attrs, Shaping::Advanced, None); + buf.shape_until_scroll(&mut fs, false); + + for run in buf.layout_runs() { + for g in run.glyphs.iter() { + if g.glyph_id == 0 { + continue; // .notdef — this font doesn't really cover it + } + let face_index = fs.db_mut().face(g.font_id).map(|f| f.index).unwrap_or(0); + let font = fs.get_font(g.font_id, g.font_weight)?; + let face = ttf_parser::Face::parse(font.data(), face_index).ok()?; + let Some(glyph) = outline_char(&face, ch) else { + continue; + }; + if glyph.strokes.is_empty() && glyph.fill_tris.is_empty() { + continue; + } + return Some(Arc::new(glyph)); + } + } + None +} + +/// The first installed family that actually draws `ch` — the net under +/// cosmic-text, whose fallback list is the platform's own handful of names (on +/// macOS: `.SF NS`, `Menlo`, `Apple Color Emoji`, `Geneva`, `Arial Unicode MS`) +/// and covers neither Cyrillic nor Greek, however many fonts the machine has +/// that do. +#[cfg(not(target_arch = "wasm32"))] +fn installed_family_glyph(ch: char) -> Option> { + crate::scene::text::sysfont::families() + .iter() + .find_map(|family| { + let glyph = sysfont::with_face_data(family, |data, index| { + let face = ttf_parser::Face::parse(data, index).ok()?; + outline_char(&face, ch) + }) + .flatten()?; + // A face that yields no outline (the system font, a space) is not a + // font that draws this character. + (!glyph.strokes.is_empty() || !glyph.fill_tris.is_empty()) + .then(|| Arc::new(glyph)) + }) +} + /// Web: outline the glyph from the lazily-fetched per-script Noto subset that /// covers it. Returns `None` while that font is still loading (the char renders /// once it arrives and the fallback cache is cleared). (#141) @@ -395,52 +514,6 @@ fn build_fallback(ch: char) -> Option> { })) } -#[cfg(not(target_arch = "wasm32"))] -fn build_fallback(ch: char) -> Option> { - 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()?; - // A fallback glyph stands in for a stroke / SHX font that lacks - // the character. For an ideograph that font would have been a - // big font, whose glyphs fill the text height — so size the - // substitute by its em box. Everything else keeps the cap-height - // normalisation that lines it up with the Latin stroke glyphs. - let k = if is_full_width(ch) { - em_scale(&face) - } else { - 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(); - let fill_tris = triangulate_contours(&fl.contours); - return Some(Arc::new(Glyph { - strokes: fl.contours, - advance, - fill_tris, - })); - } - } - None -} - #[cfg(target_arch = "wasm32")] fn build_shaped(_family: &str, text: &str) -> Option { use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping};