From d0438577ed8ffe67ce400e5b56a92007d7204bb5 Mon Sep 17 00:00:00 2001 From: asimfali Date: Tue, 13 Oct 2026 12:12:33 +0300 Subject: [PATCH] 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 Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- crates/fonts/src/ttf.rs | 52 ++++++++++++++++++++++++------- crates/io/src/dxf_read.rs | 10 +++++- crates/io/tests/style_typeface.rs | 27 ++++++++++++++++ 3 files changed, 77 insertions(+), 12 deletions(-) create mode 100644 crates/io/tests/style_typeface.rs diff --git a/crates/fonts/src/ttf.rs b/crates/fonts/src/ttf.rs index d83aaebf..62759c1b 100644 --- a/crates/fonts/src/ttf.rs +++ b/crates/fonts/src/ttf.rs @@ -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 { 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 = 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 { + 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 = 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 = ["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 = (0..100).map(|i| format!("gost{i:03}")).collect(); + assert!(family_candidates(&many, "gosttypea", "gost").len() <= MAX_FAMILY_FILES + MAX_FIRST_WORD_FILES); + } +} diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 10ab2382..51effa6d 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -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()), diff --git a/crates/io/tests/style_typeface.rs b/crates/io/tests/style_typeface.rs new file mode 100644 index 00000000..c1450295 --- /dev/null +++ b/crates/io/tests/style_typeface.rs @@ -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("")); +}