Merge pull request #1432 from swit33:fix/text-fallback-empty-outline

This commit is contained in:
Hakan Seven committed 2026-09-22 12:27:59 +03:00
commit 4a8f8af36c
3 files changed
+241 -65

No files matched your search

+35 -14
View File
@@ -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<String> {
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),
}
}
+87 -5
View File
@@ -13,6 +13,10 @@ struct SysFonts {
db: fontdb::Database,
/// Sorted, de-duplicated family names for the picker.
families: Vec<String>,
/// 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<String, String>,
}
static FONTS: OnceLock<SysFonts> = OnceLock::new();
@@ -22,14 +26,31 @@ fn fonts() -> &'static SysFonts {
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();
let mut families: Vec<String> = Vec::new();
let mut by_file: HashMap<String, String> = 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<T>(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<String> {
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<String> {
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);
}
}
+119 -46
View File
@@ -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<Arc<Glyph>> {
outline_from_fallback_face(ch).or_else(|| installed_family_glyph(ch))
}
/// Outline `ch` from a parsed face, in the 9-unit text space.
///
/// A fallback glyph stands in for a stroke / SHX font that lacks the character.
/// For an ideograph that font would have been a big font, whose glyphs fill the
/// text height — so size the substitute by its em box. Everything else keeps the
/// cap-height normalisation that lines it up with the Latin stroke glyphs.
#[cfg(not(target_arch = "wasm32"))]
fn outline_char(face: &ttf_parser::Face, ch: char) -> Option<Glyph> {
let gid = face.glyph_index(ch)?;
let k = if is_full_width(ch) {
em_scale(face)
} else {
cap_scale(face)
};
let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k;
let mut fl = OutlineFlattener::new(k);
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<Arc<Glyph>> {
use cosmic_text::{Attrs, Buffer, Metrics, Shaping};
let mut fs = font_system().lock().unwrap();
// Default family → cosmic's own fallback search chooses a covering font.
let attrs = Attrs::new();
let mut buf = Buffer::new(&mut fs, Metrics::new(SHAPE_FS, SHAPE_FS));
buf.set_size(&mut fs, None, None);
let s = ch.to_string();
buf.set_text(&mut fs, &s, &attrs, Shaping::Advanced, None);
buf.shape_until_scroll(&mut fs, false);
for run in buf.layout_runs() {
for g in run.glyphs.iter() {
if g.glyph_id == 0 {
continue; // .notdef — this font doesn't really cover it
}
let face_index = fs.db_mut().face(g.font_id).map(|f| f.index).unwrap_or(0);
let font = fs.get_font(g.font_id, g.font_weight)?;
let face = ttf_parser::Face::parse(font.data(), face_index).ok()?;
let Some(glyph) = outline_char(&face, ch) else {
continue;
};
if glyph.strokes.is_empty() && glyph.fill_tris.is_empty() {
continue;
}
return Some(Arc::new(glyph));
}
}
None
}
/// The first installed family that actually draws `ch` — the net under
/// cosmic-text, whose fallback list is the platform's own handful of names (on
/// macOS: `.SF NS`, `Menlo`, `Apple Color Emoji`, `Geneva`, `Arial Unicode MS`)
/// and covers neither Cyrillic nor Greek, however many fonts the machine has
/// that do.
#[cfg(not(target_arch = "wasm32"))]
fn installed_family_glyph(ch: char) -> Option<Arc<Glyph>> {
crate::scene::text::sysfont::families()
.iter()
.find_map(|family| {
let glyph = sysfont::with_face_data(family, |data, index| {
let face = ttf_parser::Face::parse(data, index).ok()?;
outline_char(&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<Arc<Glyph>> {
}))
}
#[cfg(not(target_arch = "wasm32"))]
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()?;
// 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<ShapedRun> {
use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping};