Fonts: use the TrueType typeface from a style's xdata and find families by first word (#894)

AutoCAD stores a TrueType text style's family (e.g. "GOST type A") in the style's ACAD xdata
and leaves the font file name empty. Styles without a file now take that family.

Family-name lookups also look in files whose name starts with the family's first word, so
"GOST type A" finds GOST.TTF (the stem shares only 4 characters with the family name).

Co-authored-by: simanov <[email protected]>
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 05:12:33 -04:00
1 parent 63b448bee9
commit d0438577ed
3 files changed
+77 -12

No files matched your search

+41 -11
View File
@@ -100,6 +100,8 @@ const MAX_FALLBACK_BYTES: usize = 160 << 20;
const MAX_FALLBACK_FACES: usize = 256;
/// Files loaded by one family-name lookup.
const MAX_FAMILY_FILES: usize = 6;
/// Further files a family-name lookup loads: those starting with the name's first word.
const MAX_FIRST_WORD_FILES: usize = 24;
/// Total bytes of system font files loaded from disk: a drawing naming many styles can't pull
/// every installed font into memory.
const MAX_LOADED_BYTES: usize = 256 << 20;
@@ -311,22 +313,15 @@ impl Db {
self.by_family(k)
}
/// A face whose family or full name is `k`, among files with a similar stem.
/// A face whose family or full name is `k`, among files with a similar stem, then among the
/// files whose stem begins with the name's first word (`GOST.TTF` for "GOST type A").
fn by_family(&mut self, k: &str) -> Option<Face> {
let want = norm(k);
if want.len() < 5 {
return None;
}
let mut stems: Vec<(usize, usize, String)> = self
.paths
.keys()
.chain(self.files.keys())
.map(|s| (weight_penalty(s, &want), common_prefix(&norm(s), &want), s.clone()))
.filter(|(_, p, _)| *p >= 5)
.collect();
stems.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)).then(a.2.cmp(&b.2)));
stems.dedup_by(|a, b| a.2 == b.2);
for (_, _, stem) in stems.into_iter().take(MAX_FAMILY_FILES) {
let stems: Vec<String> = self.paths.keys().chain(self.files.keys()).cloned().collect();
for stem in family_candidates(&stems, &want, &norm(k.split_whitespace().next().unwrap_or(""))) {
let Some(bytes) = self.file(&stem) else { continue };
if let Some(face) = faces(&bytes).into_iter().find(|f| f.names().iter().any(|n| *n == want || format!("{n}regular") == want)) {
return Some(face);
@@ -421,6 +416,25 @@ fn norm(s: &str) -> String {
s.chars().filter(|c| !matches!(c, ' ' | '-' | '_')).flat_map(char::to_lowercase).collect()
}
/// The font file stems to check for the family `want` (normalised), best first: those sharing a
/// long prefix with it ([`MAX_FAMILY_FILES`]), then those starting with its `first` word
/// ([`MAX_FIRST_WORD_FILES`]).
fn family_candidates(stems: &[String], want: &str, first: &str) -> Vec<String> {
let mut by_prefix: Vec<(usize, usize, String)> =
stems.iter().map(|s| (weight_penalty(s, want), common_prefix(&norm(s), want), s.clone())).filter(|(_, p, _)| *p >= 5).collect();
by_prefix.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)).then(a.2.cmp(&b.2)));
by_prefix.dedup_by(|a, b| a.2 == b.2);
let mut out: Vec<String> = by_prefix.into_iter().take(MAX_FAMILY_FILES).map(|(_, _, s)| s).collect();
if first.chars().count() >= 4 {
let mut by_word: Vec<(usize, usize, String)> =
stems.iter().filter(|s| norm(s).starts_with(first) && !out.contains(s)).map(|s| (weight_penalty(s, want), s.len(), s.clone())).collect();
by_word.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2)));
by_word.dedup_by(|a, b| a.2 == b.2);
out.extend(by_word.into_iter().take(MAX_FIRST_WORD_FILES).map(|(_, _, s)| s));
}
out
}
fn common_prefix(a: &str, b: &str) -> usize {
a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
}
@@ -848,3 +862,19 @@ pub fn layout_line(face: &Face, s: &str, height: f64, width_factor: f64, oblique
strokes.extend(sh.strokes);
Some(Run { strokes, width: sh.width })
}
#[cfg(test)]
mod family_tests {
use super::*;
#[test]
fn a_family_is_looked_for_in_files_starting_with_its_first_word() {
let stems: Vec<String> = ["gost", "gost2304a", "gost common", "arial", "isocpeur"].map(String::from).to_vec();
let c = family_candidates(&stems, &norm("gost type a"), "gost");
assert!(c.contains(&"gost".to_string()), "GOST.TTF holds \"GOST type A\": {c:?}");
assert!(!c.contains(&"arial".to_string()));
assert!(family_candidates(&stems, &norm("gost type a"), "go").is_empty(), "too short a word matches nothing");
let many: Vec<String> = (0..100).map(|i| format!("gost{i:03}")).collect();
assert!(family_candidates(&many, "gosttypea", "gost").len() <= MAX_FAMILY_FILES + MAX_FIRST_WORD_FILES);
}
}
+9 -1
View File
@@ -1277,8 +1277,16 @@ fn annotative(tags: &[Tag]) -> bool {
}
/// A text style's font: CADCraft's own font name from `CADCRAFT` xdata (`1000 FONT`, name)
/// while the font file is still the `txt` written for other readers, otherwise the file.
/// while the font file is still the `txt` written for other readers; the TrueType family of the
/// style's `ACAD` xdata (`1000` typeface) when it has no font file (how AutoCAD stores TrueType
/// styles); otherwise the file.
fn style_font(tags: &[Tag], file: String) -> String {
if file.trim().is_empty()
&& let Some(face) =
crate::dxf_ext::xdata(tags, "ACAD").iter().find(|t| t.code == 1000).map(|t| t.str().trim().to_string()).filter(|f| !f.is_empty())
{
return face;
}
let x = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP);
let own = x.windows(2).find_map(|w| match w {
[m, n] if m.code == 1000 && m.str() == "FONT" && n.code == 1000 => Some(n.str()),
+27
View File
@@ -0,0 +1,27 @@
//! A TrueType text style without a font file takes its family from the style's `ACAD` xdata.
fn style(name: &str, file: &str, xdata: &[(&str, &str)]) -> String {
let mut s = format!(" 0\nSTYLE\n 2\n{name}\n 70\n0\n 40\n0.0\n 41\n1.0\n 50\n0.0\n 71\n0\n 42\n0.2\n 3\n{file}\n 4\n\n");
if !xdata.is_empty() {
s.push_str("1001\nACAD\n");
for (c, v) in xdata {
s.push_str(&format!("{c}\n{v}\n"));
}
}
s
}
#[test]
fn style_without_a_font_file_uses_the_xdata_typeface() {
let dxf = format!(
" 0\nSECTION\n 2\nTABLES\n 0\nTABLE\n 2\nSTYLE\n 70\n3\n{}{}{} 0\nENDTAB\n 0\nENDSEC\n 0\nEOF\n",
style("TT", "", &[("1000", "GOST type A"), ("1071", "2")]),
style("File", "arial.ttf", &[("1000", "Other"), ("1071", "2")]),
style("Empty", "", &[]),
);
let d = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap();
let font = |n: &str| d.text_styles.iter().find(|s| s.name == n).map(|s| s.font.clone());
assert_eq!(font("TT").as_deref(), Some("GOST type A"));
assert_eq!(font("File").as_deref(), Some("arial.ttf"), "a font file wins over the typeface");
assert_eq!(font("Empty").as_deref(), Some(""));
}