From ff4981ffe6a649a7edfcc63b2e12aa19bc080f7f Mon Sep 17 00:00:00 2001 From: Chris Yeh Date: Mon, 21 Sep 2026 21:01:05 +0800 Subject: [PATCH] feat(text): read unifont and big-font .shx files; pair a style's big font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- Cargo.lock | 1 + Cargo.toml | 3 + src/entities/text_support.rs | 21 +++ src/io/font_repo.rs | 27 ++-- src/scene/text/font_face.rs | 64 ++++++++- src/scene/text/shx.rs | 270 ++++++++++++++++++++++++++++++++--- 6 files changed, 351 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4da766abd..e50a11fdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,7 @@ dependencies = [ "console_error_panic_hook", "cosmic-text 0.15.0", "dwg-thumbnailer", + "encoding_rs", "env_logger", "flate2", "fluent-syntax", diff --git a/Cargo.toml b/Cargo.toml index 6e78631dc..b8d1a54fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,9 @@ ttf-parser = "0.25" cosmic-text = "0.15" unicode-bidi = "0.3" lyon_tessellation = "1.0.20" +# Big-font (.shx) lookups: encode a char to the font's Big5 / GBK / Shift_JIS +# / EUC-KR double-byte code. Already in the tree through other deps. +encoding_rs = "0.8" pollster = "0.4" [dev-dependencies] diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index 743fe85ef..d4adac03f 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -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), diff --git a/src/io/font_repo.rs b/src/io/font_repo.rs index 8df774c66..5b53e8ec7 100644 --- a/src/io/font_repo.rs +++ b/src/io/font_repo.rs @@ -70,18 +70,21 @@ pub fn missing_shx_fonts(doc: &CadDocument) -> Vec { .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()); diff --git a/src/scene/text/font_face.rs b/src/scene/text/font_face.rs index 832e1eaf4..42d29cbdb 100644 --- a/src/scene/text/font_face.rs +++ b/src/scene/text/font_face.rs @@ -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, + /// 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 { + // `primarybig` 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()); + } } diff --git a/src/scene/text/shx.rs b/src/scene/text/shx.rs index 51828b495..997e48c30 100644 --- a/src/scene/text/shx.rs +++ b/src/scene/text/shx.rs @@ -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)>, } @@ -81,39 +94,158 @@ fn load_file(path: &str) -> Option> { fn parse_file(path: &str) -> Option { 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 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 { 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 { + 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) { 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> { + 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 { + 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 = [b"BIG\x00".as_slice(), &[48u8, 0, 2]].concat(); + let glyph_data: Vec = [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 { + 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 = [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 = [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()); + } +}