feat(text): read unifont and big-font .shx files; pair a style's big font

`shx.rs` only accepted the classic `AutoCAD-86 shapes 1.x` container, so
every modern Autodesk font (`txt.shx`, `simplex.shx`, `romans.shx`, … are
`unifont 1.0`) and every Asian big font (`chineset.shx`, `hztxt.shx`,
`bigfont.shx` — `bigfont 1.0`) was rejected and text fell back to the LFF
substitutes and TrueType fallback glyphs. Drawings authored with SHX big
fonts (the norm for Chinese / Japanese / Korean sheets) never showed their
real glyphs.

- Parse all three containers. Unifont glyphs are keyed by Unicode; big-font
  glyphs by their double-byte code from a sparse hash table of
  (code, length, offset) entries. Big fonts reference subshapes with a
  two-byte `7, 0, hi, lo`.
- `bigfont_glyph` encodes a character with encoding_rs (Big5 / GBK /
  Shift_JIS / EUC-KR, most likely first for the file name) and takes the
  first code the font has.
- `Face::WithBig` pairs a style's big font with its primary face: full-width
  characters come from the big font, everything else from the primary —
  how a STYLE with a "Big Font" behaves in AutoCAD. `resolve_text_style`
  resolves `big_font_file` like the primary (next to the drawing, then the
  user fonts folder) and rides it along in the font name.
- The missing-fonts dialog lists unresolvable big fonts too.

Unit tests cover a synthetic big font (Big5 lookup, cap-height scaling, no
aliasing of unknown codes), a synthetic unifont, and the pairing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Chris YehandClaude Opus 5 committed 2026-09-21 21:01:05 +08:00
1 parent 0b522ceb86
commit ff4981ffe6
6 files changed
+351 -35

No files matched your search

+21
View File
@@ -77,6 +77,27 @@ pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedT
}
}
// A style's "Big Font" (`chineset.shx`, `hztxt.shx`, …) supplies the
// double-byte characters. Resolve it like the primary font (next to the
// drawing, then the per-user fonts folder) and pair it with the face; an
// unresolvable big font just leaves those characters to the fallback.
if let Some(big) = style
.map(|s| s.big_font_file.trim())
.filter(|big| !big.is_empty() && big.to_ascii_lowercase().ends_with(".shx"))
{
let base = document
.source_path
.as_deref()
.map(std::path::Path::new)
.and_then(|p| p.parent());
let big_path = crate::io::resolve_image_file(big, base).or_else(|| {
crate::io::font_repo::local_font_file(big).map(|p| p.to_string_lossy().into_owned())
});
if let Some(big_path) = big_path {
font_name = Face::with_big_font(&font_name, &big_path);
}
}
ResolvedTextStyle {
font_name,
width_factor: style.map(|s| s.width_factor as f32).unwrap_or(1.0),
+15 -12
View File
@@ -70,18 +70,21 @@ pub fn missing_shx_fonts(doc: &CadDocument) -> Vec<String> {
.map(Path::new)
.and_then(|p| p.parent());
for style in doc.text_styles.iter() {
let file = style.font_file.trim();
if file.is_empty() || !file.to_ascii_lowercase().ends_with(".shx") {
continue;
}
let resolved = crate::io::resolve_image_file(file, base).is_some()
|| local_font_file(file).is_some();
if resolved {
continue;
}
let name = file.rsplit(['/', '\\']).next().unwrap_or(file).to_string();
if !missing.iter().any(|m| m.eq_ignore_ascii_case(&name)) {
missing.push(name);
// The primary font and the big font (Asian double-byte glyphs) are
// both plain .shx files the style depends on.
for file in [style.font_file.trim(), style.big_font_file.trim()] {
if file.is_empty() || !file.to_ascii_lowercase().ends_with(".shx") {
continue;
}
let resolved = crate::io::resolve_image_file(file, base).is_some()
|| local_font_file(file).is_some();
if resolved {
continue;
}
let name = file.rsplit(['/', '\\']).next().unwrap_or(file).to_string();
if !missing.iter().any(|m| m.eq_ignore_ascii_case(&name)) {
missing.push(name);
}
}
}
missing.sort_by_key(|name| name.to_ascii_lowercase());
+62 -2
View File
@@ -47,12 +47,37 @@ pub enum Face {
/// 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<Face>,
/// 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 {
// `primary<SEP>big` 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")
@@ -87,6 +112,13 @@ impl Face {
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
@@ -103,6 +135,14 @@ impl Face {
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)
}
}
}
@@ -114,6 +154,7 @@ impl Face {
Face::Ttf { .. } => 0.0,
// SHX advances already include the trailing gap by design.
Face::Shx { .. } => 0.0,
Face::WithBig { primary, .. } => primary.letter_spacing(),
}
}
@@ -122,6 +163,7 @@ impl Face {
match self {
Face::Lff(f) => f.word_spacing,
Face::Ttf { word, .. } | Face::Shx { word, .. } => *word,
Face::WithBig { primary, .. } => primary.word_spacing(),
}
}
@@ -132,6 +174,7 @@ impl Face {
match self {
Face::Ttf { family, .. } => Some(family),
Face::Lff(_) | Face::Shx { .. } => None,
Face::WithBig { primary, .. } => primary.ttf_family(),
}
}
@@ -142,6 +185,7 @@ impl Face {
Face::Lff(f) => f.line_spacing,
Face::Ttf { .. } => 1.0,
Face::Shx { line, .. } => *line,
Face::WithBig { primary, .. } => primary.line_spacing(),
}
}
}
@@ -236,11 +280,27 @@ mod tests {
// 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 { .. } => {
// A system TTF name never resolves to an SHX shape font.
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());
}
}
+249 -21
View File
@@ -50,7 +50,20 @@ const DIRS: [[f64; 2]; 16] = [
[1.0, -0.5],
];
/// Which `AutoCAD-86` container a file is; decides how glyphs are keyed and
/// how a `7` (subshape) opcode is encoded.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ShxKind {
/// `shapes 1.0/1.1`: keyed by 8-bit code (or shape number).
Shapes,
/// `unifont 1.0`: keyed by Unicode code point.
Unifont,
/// `bigfont 1.0`: keyed by a double-byte legacy code (Big5, GBK, …).
Bigfont,
}
struct ShxFile {
kind: ShxKind,
/// shape number → (name, spec bytes)
shapes: HashMap<u16, (String, Vec<u8>)>,
}
@@ -81,39 +94,158 @@ fn load_file(path: &str) -> Option<Arc<ShxFile>> {
fn parse_file(path: &str) -> Option<ShxFile> {
let bytes = std::fs::read(path).ok()?;
// Header line: `AutoCAD-86 shapes 1.0`/`1.1` + CR LF SUB. Reject fonts.
// Header line: `AutoCAD-86 <kind> 1.x` + CR LF SUB.
let head_end = bytes.iter().position(|&b| b == 0x1A)?;
let header = String::from_utf8_lossy(&bytes[..head_end]);
if !header.contains("shapes") {
let kind = if header.contains("shapes") {
ShxKind::Shapes
} else if header.contains("unifont") {
ShxKind::Unifont
} else if header.contains("bigfont") {
ShxKind::Bigfont
} else {
return None;
}
};
let mut p = head_end + 1;
let rd_u16 = |p: &mut usize| -> Option<u16> {
let v = u16::from_le_bytes([*bytes.get(*p)?, *bytes.get(*p + 1)?]);
*p += 2;
Some(v)
};
let _first = rd_u16(&mut p)?;
let _last = rd_u16(&mut p)?;
let count = rd_u16(&mut p)? as usize;
let mut dir: Vec<(u16, usize)> = Vec::with_capacity(count);
for _ in 0..count {
let num = rd_u16(&mut p)?;
let len = rd_u16(&mut p)? as usize;
dir.push((num, len));
}
let mut shapes = HashMap::new();
for (num, len) in dir {
let end = p.checked_add(len)?.min(bytes.len());
let blob = &bytes[p..end];
p = end;
// Name = leading NUL-terminated ASCII, spec follows.
let rd_u32 = |p: &mut usize| -> Option<u32> {
let v = u32::from_le_bytes([
*bytes.get(*p)?,
*bytes.get(*p + 1)?,
*bytes.get(*p + 2)?,
*bytes.get(*p + 3)?,
]);
*p += 4;
Some(v)
};
// Glyph data = leading NUL-terminated ASCII name, spec follows.
let split_named = |blob: &[u8]| -> (String, Vec<u8>) {
let name_end = blob.iter().position(|&b| b == 0).unwrap_or(0);
let name = String::from_utf8_lossy(&blob[..name_end]).into_owned();
let spec = blob.get(name_end + 1..).unwrap_or(&[]).to_vec();
shapes.insert(num, (name, spec));
(name, spec)
};
let mut shapes = HashMap::new();
match kind {
ShxKind::Shapes => {
let _first = rd_u16(&mut p)?;
let _last = rd_u16(&mut p)?;
let count = rd_u16(&mut p)? as usize;
let mut dir: Vec<(u16, usize)> = Vec::with_capacity(count);
for _ in 0..count {
let num = rd_u16(&mut p)?;
let len = rd_u16(&mut p)? as usize;
dir.push((num, len));
}
for (num, len) in dir {
let end = p.checked_add(len)?.min(bytes.len());
let blob = &bytes[p..end];
p = end;
shapes.insert(num, split_named(blob));
}
}
ShxKind::Unifont => {
// u32 glyph count (including the font header), then the header
// entry (u16 length + name NUL above below modes encoding
// embedded 0), then `count - 1` × (u16 code, u16 length, data).
let count = rd_u32(&mut p)? as usize;
let head_len = rd_u16(&mut p)? as usize;
let end = p.checked_add(head_len)?.min(bytes.len());
shapes.insert(0, split_named(&bytes[p..end]));
p = end;
for _ in 1..count {
let Some(code) = rd_u16(&mut p) else { break };
let Some(len) = rd_u16(&mut p) else { break };
let end = p.checked_add(len as usize)?.min(bytes.len());
shapes.insert(code, split_named(&bytes[p..end]));
p = end;
}
}
ShxKind::Bigfont => {
// u16 bytes per table entry (8), u16 table entries, u16 range
// count, ranges × (u16 lo, u16 hi) of lead bytes, then the entry
// table — a sparse hash table of (u16 code, u16 length, u32 file
// offset); empty slots are all zero. Shape 0 (name NUL above
// below modes) is the font header.
let entry_size = rd_u16(&mut p)? as usize;
let entries = rd_u16(&mut p)? as usize;
let ranges = rd_u16(&mut p)? as usize;
p = p.checked_add(ranges * 4)?;
if entry_size < 8 {
return None;
}
for i in 0..entries {
let mut q = p.checked_add(i * entry_size)?;
let Some(code) = rd_u16(&mut q) else { break };
let Some(len) = rd_u16(&mut q) else { break };
let Some(off) = rd_u32(&mut q) else { break };
if off == 0 && len == 0 {
continue;
}
let start = off as usize;
let end = start.saturating_add(len as usize).min(bytes.len());
if start >= end {
continue;
}
shapes.insert(code, split_named(&bytes[start..end]));
}
}
}
Some(ShxFile { shapes })
Some(ShxFile { kind, shapes })
}
/// True when the file is an Asian big font (`AutoCAD-86 bigfont 1.0`).
pub fn is_bigfont(path: &str) -> bool {
load_file(path).is_some_and(|f| f.kind == ShxKind::Bigfont)
}
/// The double-byte legacy encodings a big font may be keyed by, most likely
/// first for this file name. The font itself does not say; try them in turn
/// and take the first code the font actually has.
fn bigfont_encodings(path: &str) -> [&'static encoding_rs::Encoding; 4] {
use encoding_rs::{BIG5, EUC_KR, GBK, SHIFT_JIS};
let name = path
.rsplit(['/', '\\'])
.next()
.unwrap_or(path)
.to_ascii_lowercase();
if name.contains("gb") || name.contains("hz") || name.contains("china") {
[GBK, BIG5, SHIFT_JIS, EUC_KR]
} else if name.contains("bigfont") || name.contains("extfont") || name.contains("jp") {
[SHIFT_JIS, BIG5, GBK, EUC_KR]
} else if name.contains("whg") || name.contains("kor") || name.contains("han") {
[EUC_KR, BIG5, GBK, SHIFT_JIS]
} else {
// `chineset.shx` and most Taiwanese fonts.
[BIG5, GBK, SHIFT_JIS, EUC_KR]
}
}
/// Look up `ch` in a big font: encode it to the font's double-byte code and
/// fetch that shape, normalised like [`font_glyph`]. `None` when the file is
/// not a big font or has no glyph for the character.
pub fn bigfont_glyph(path: &str, ch: char) -> Option<Arc<crate::scene::text::lff::Glyph>> {
let file = load_file(path)?;
if file.kind != ShxKind::Bigfont {
return None;
}
let mut buf = [0u8; 4];
let s = ch.encode_utf8(&mut buf);
for enc in bigfont_encodings(path) {
let (bytes, _, unmappable) = enc.encode(s);
if unmappable || bytes.len() != 2 {
continue;
}
let code = u16::from_be_bytes([bytes[0], bytes[1]]);
if file.shapes.contains_key(&code) {
return font_glyph(path, code);
}
}
None
}
/// Tessellate the shape named `name` (case-insensitive) from the SHX at
@@ -410,8 +542,16 @@ fn interpret(
}
}
7 => {
let sub = *b.get(i).unwrap_or(&0) as u16;
let mut sub = *b.get(i).unwrap_or(&0) as u16;
i += 1;
// A big font references subshapes by a two-byte code:
// `7, 0, hi, lo`.
if file.kind == ShxKind::Bigfont && sub == 0 {
let hi = *b.get(i).unwrap_or(&0) as u16;
let lo = *b.get(i + 1).unwrap_or(&0) as u16;
i += 2;
sub = (hi << 8) | lo;
}
if !skipping && sub != 0 && sub != shape_number {
interpret(file, sub, st, cur, out, depth + 1);
}
@@ -510,3 +650,91 @@ fn interpret(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Shape bytecode for a glyph: a horizontal 6-unit stroke, then end.
/// (`0x60` = length 6, direction 0.)
const BAR: [u8; 2] = [0x60, 0x00];
fn tmp(name: &str, bytes: &[u8]) -> String {
let dir = std::env::temp_dir().join(format!("ocs-shx-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
path.to_string_lossy().into_owned()
}
/// A one-glyph `bigfont 1.0` keyed by the Big5 code of `中` (0xA4A4),
/// laid out like `chineset.shx`: header, 8-byte entries in a sparse
/// table, font header shape 0 (`name NUL above below modes`).
fn synthetic_bigfont() -> Vec<u8> {
let mut b = b"AutoCAD-86 bigfont 1.0\r\n\x1a".to_vec();
let entries: u16 = 4;
b.extend_from_slice(&8u16.to_le_bytes());
b.extend_from_slice(&entries.to_le_bytes());
b.extend_from_slice(&1u16.to_le_bytes()); // one lead-byte range
b.extend_from_slice(&0x80u16.to_le_bytes());
b.extend_from_slice(&0xFFu16.to_le_bytes());
let table_at = b.len();
b.resize(table_at + 8 * entries as usize, 0);
let head_data: Vec<u8> = [b"BIG\x00".as_slice(), &[48u8, 0, 2]].concat();
let glyph_data: Vec<u8> = [b"\x00".as_slice(), &BAR].concat();
let head_off = b.len() as u32;
b.extend_from_slice(&head_data);
let glyph_off = b.len() as u32;
b.extend_from_slice(&glyph_data);
// Slot 0 = shape 0; slot 2 = the glyph (slots 1 and 3 stay empty).
let mut entry = |slot: usize, code: u16, len: u16, off: u32| {
let at = table_at + slot * 8;
b[at..at + 2].copy_from_slice(&code.to_le_bytes());
b[at + 2..at + 4].copy_from_slice(&len.to_le_bytes());
b[at + 4..at + 8].copy_from_slice(&off.to_le_bytes());
};
entry(0, 0, head_data.len() as u16, head_off);
entry(2, 0xA4A4, glyph_data.len() as u16, glyph_off);
b
}
/// A `unifont 1.0` with the font header and one glyph for `A` (U+0041).
fn synthetic_unifont() -> Vec<u8> {
let mut b = b"AutoCAD-86 unifont 1.0\r\n\x1a".to_vec();
b.extend_from_slice(&2u32.to_le_bytes()); // header + 1 glyph
let head: Vec<u8> = [b"UNI\x00".as_slice(), &[6u8, 2, 2, 0, 0, 0]].concat();
b.extend_from_slice(&(head.len() as u16).to_le_bytes());
b.extend_from_slice(&head);
let glyph: Vec<u8> = [b"\x00".as_slice(), &BAR].concat();
b.extend_from_slice(&0x41u16.to_le_bytes());
b.extend_from_slice(&(glyph.len() as u16).to_le_bytes());
b.extend_from_slice(&glyph);
b
}
#[test]
fn bigfont_glyph_is_found_by_big5_code_and_scaled_to_cap_height() {
let path = tmp("cjk.shx", &synthetic_bigfont());
assert!(is_bigfont(&path));
let (above, below) = font_metrics(&path).expect("font header");
assert_eq!((above, below), (48.0, 0.0));
// `中` → Big5 A4A4 → the glyph; `A` is not in a big font.
let g = bigfont_glyph(&path, '中').expect("glyph for 中");
// 6 font units at above = 48 → 6 * 9 / 48 = 1.125 text units.
assert!((g.advance - 1.125).abs() < 1e-4, "advance {}", g.advance);
assert_eq!(g.strokes.len(), 1);
assert!(bigfont_glyph(&path, 'A').is_none());
assert!(bigfont_glyph(&path, '國').is_none(), "unknown code must miss, not alias");
}
#[test]
fn unifont_glyph_is_keyed_by_unicode() {
let path = tmp("uni.shx", &synthetic_unifont());
assert!(!is_bigfont(&path));
assert_eq!(font_metrics(&path), Some((6.0, 2.0)));
let g = font_glyph(&path, 'A' as u16).expect("glyph for A");
assert!((g.advance - 9.0).abs() < 1e-4, "6 units at above 6 → 9: {}", g.advance);
assert!(font_glyph(&path, 'B' as u16).is_none());
}
}