From 0de6ec8706917e218b475545394aa79b48f0fe37 Mon Sep 17 00:00:00 2001 From: Leonardo Pires Batista Date: Tue, 13 Oct 2026 16:41:59 -0300 Subject: [PATCH] fonts: read user SHX text fonts and keep Latin accents single-stroke Co-Authored-By: Codex --- README.md | 13 +- crates/fonts/src/lib.rs | 29 ++- crates/fonts/src/shx.rs | 385 ++++++++++++++++++++++++++++ crates/fonts/src/shx/program.rs | 242 +++++++++++++++++ crates/fonts/src/shx/tests.rs | 211 +++++++++++++++ crates/fonts/src/stroke.rs | 55 ++++ crates/fonts/src/ttf.rs | 2 +- crates/fonts/tests/latin_accents.rs | 54 ++++ crates/io/tests/shx_text.rs | 35 +++ docs/file-format-parity.md | 5 +- docs/gaps.md | 5 +- docs/shx-fonts.md | 75 ++++++ 12 files changed, 1096 insertions(+), 15 deletions(-) create mode 100644 crates/fonts/src/shx.rs create mode 100644 crates/fonts/src/shx/program.rs create mode 100644 crates/fonts/src/shx/tests.rs create mode 100644 crates/fonts/tests/latin_accents.rs create mode 100644 crates/io/tests/shx_text.rs create mode 100644 docs/shx-fonts.md diff --git a/README.md b/README.md index a259af4f3..978a0ab4d 100644 --- a/README.md +++ b/README.md @@ -144,9 +144,11 @@ input (`CRAFT_FONTS_DIR= cargo build --release`) al fonts: BIZ UDPGothic, BIZ UDMincho and Shippori Mincho (Japanese) and Noto Sans Arabic (the web build, which has no system fonts, only BIZ UDPGothic Regular and Noto Sans Arabic). A text style can use them by family name, and they draw characters a text's font lacks when no installed -wide-coverage CJK font has them. Without `CRAFT_FONTS_DIR` nothing is embedded. SHX -fonts (including big fonts such as `chineset.shx`) aren't read yet: text in an SHX style is drawn -with our own stroke font, and any character a text's font lacks (CJK, for example) is drawn from +wide-coverage CJK font has them. Without `CRAFT_FONTS_DIR` nothing is embedded. User-supplied +SHX text fonts (`shapes 1.0/1.1`, Unicode `unifont 1.0`) load from the directories in +`CADCRAFT_SHXPATH`, with their own strokes and advances. Big fonts aren't read yet. Missing +SHX fonts use FONTALT or our own stroke font (including Latin accents); any character a text's +font lacks (CJK, for example) is drawn from an installed font that has it, preferring wide-coverage fonts such as Noto Sans CJK, Source Han Sans, WenQuanYi, PingFang or Microsoft JhengHei. Two settings, set with `SETVAR` or (for every run) an environment variable, steer this: @@ -165,6 +167,11 @@ Linux and the BSDs `/usr/share/fonts`, `/usr/local/share/fonts`, `~/.local/share by file name (`Vazirmatn-Regular.ttf`) or family name (`Vazirmatn`), or inline in MTEXT with `\fVazirmatn;`. Restart CADCraft after installing a font: the font folders are scanned once. +**Your own SHX fonts.** Set `CADCRAFT_SHXPATH` to the folders containing fonts you are entitled +to use (semicolon-separated on Windows, colon-separated on Unix), then name the file or stem in +STYLE or inline MTEXT. CADCraft does not bundle proprietary fonts or scan CAD installations. +See [SHX fonts](docs/shx-fonts.md) for configuration, supported programs and limits. + **Right-to-left text (Persian, Arabic, Urdu, Hebrew).** TEXT and MTEXT lines are laid out with the Unicode Bidirectional Algorithm (the paragraph direction comes from its first letter, so numbers and Latin words inside Persian text keep their own order), and Arabic-script text is shaped with diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 9ccc69cac..badb2bd9c 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -1,8 +1,8 @@ //! CADCraft text layout. //! //! Turns TEXT and MTEXT into stroke polylines with the built-in single-stroke font -//! ("CADCraft Stroke") or glyph outlines of installed TrueType fonts ([`ttf`]), with a -//! per-character fallback to installed fonts for characters the chosen font lacks. Handles the +//! ("CADCraft Stroke"), user-supplied SHX fonts ([`shx`]) or glyph outlines of installed +//! TrueType fonts ([`ttf`]), with a per-character fallback for characters the chosen font lacks. Handles the //! `%%` control codes, alignment modes, width factor, obliquing, MTEXT inline formatting //! (paragraphs, stacking, height changes), word wrap and attachment points. //! @@ -16,6 +16,7 @@ pub mod bidi; mod complex; mod mtext; +pub mod shx; mod stroke; pub mod subset; pub mod ttf; @@ -263,6 +264,9 @@ pub fn line_width(s: &str, height: f64, width_factor: f64) -> f64 { /// Width of the gap MTEXT leaves between two words. Words are measured without their trailing /// letter gap, so the stroke font's gap is added on both sides of the space, as TEXT lays it out. pub fn space_width(font: &TextFont, height: f64, width_factor: f64) -> f64 { + if let TextFont::Shx(face) = font { + return face.width(" ", height, width_factor); + } if let TextFont::Outline(face) = font && let Some(w) = ttf::width(face, " ", height, width_factor) { @@ -271,28 +275,33 @@ pub fn space_width(font: &TextFont, height: f64, width_factor: f64) -> f64 { stroke_advance(' ', height, width_factor) + stroke::GAP / stroke::CAP * height * width_factor } -/// The font a piece of text is set in: the built-in stroke font or an installed TrueType / -/// OpenType face (outlines). Either way, characters the font lacks come from a fallback font. +/// The font a piece of text is set in: the built-in stroke font, a user SHX font or an installed +/// TrueType / OpenType face (outlines). Characters the font lacks come from a fallback font. #[derive(Clone, Debug, Default)] pub enum TextFont { #[default] Stroke, Outline(ttf::Face), + Shx(std::sync::Arc), } impl TextFont { - /// Resolve a text style font name: an installed TTF/OTF/TTC when found; else, for a font we - /// can't find (an SHX file, a missing TTF), the FONTALT font ([`ttf::font_alt`]) when set; + /// Resolve a text style font name: an installed TTF/OTF/TTC or user SHX when found; else, + /// for a font we can't find, the FONTALT font ([`ttf::font_alt`]) when set; /// else the stroke font. pub fn resolve(name: &str) -> TextFont { if let Some(f) = ttf::find(name) { return TextFont::Outline(f); } + if let Some(f) = shx::find(name) { + return TextFont::Shx(f); + } let n = name.trim(); if n.is_empty() || n.eq_ignore_ascii_case(BUILTIN_FONT) { return TextFont::Stroke; } - ttf::find(&ttf::font_alt()).map(TextFont::Outline).unwrap_or(TextFont::Stroke) + let alt = ttf::font_alt(); + ttf::find(&alt).map(TextFont::Outline).or_else(|| shx::find(&alt).map(TextFont::Shx)).unwrap_or(TextFont::Stroke) } pub fn is_outline(&self) -> bool { matches!(self, TextFont::Outline(_)) @@ -410,6 +419,9 @@ pub(crate) fn decorations(spans: &[(f64, f64, bool, bool)], h: f64) -> Vec Shaped { + if let TextFont::Shx(face) = font { + return face.shape(s, height, width_factor, oblique); + } if let TextFont::Outline(face) = font && let Some(sh) = ttf::shape(face, s, height, width_factor, oblique) { @@ -420,6 +432,9 @@ pub fn shape_line(font: &TextFont, s: &str, height: f64, width_factor: f64, obli /// Width of one line in `font`. pub fn text_width(font: &TextFont, s: &str, height: f64, width_factor: f64) -> f64 { + if let TextFont::Shx(face) = font { + return face.width(s, height, width_factor); + } if let TextFont::Outline(face) = font && let Some(w) = ttf::width(face, s, height, width_factor) { diff --git a/crates/fonts/src/shx.rs b/crates/fonts/src/shx.rs new file mode 100644 index 000000000..be9cac18f --- /dev/null +++ b/crates/fonts/src/shx.rs @@ -0,0 +1,385 @@ +//! User-supplied compiled stroke fonts. No font files are bundled or searched in CAD bundles. +//! +//! Shape programs follow Autodesk's public Shape Descriptions / Special Codes Reference. +//! The binary record layouts are documented in `docs/shx-fonts.md`. All fixtures are synthetic. + +mod program; +#[cfg(test)] +mod tests; + +use std::collections::HashMap; +use std::io::Read; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, OnceLock, PoisonError, RwLock}; + +use cadcraft_geom::Vec2; + +use crate::{Shaped, bidi}; + +const MAX_FILE_BYTES: usize = 8 << 20; +const MAX_PROGRAM_BYTES: usize = 8192; +const MAX_FONTS: usize = 32; +const MAX_LOADED_BYTES: usize = 64 << 20; +#[cfg(not(target_arch = "wasm32"))] +const MAX_FILES: usize = 8192; +const MAX_CACHED_GLYPHS: usize = 4096; +const MAX_CACHED_POINTS: usize = 262_144; + +/// Malformed, unsupported or excessively complex font data. Resolution keeps the fallback. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Error { + Invalid, + Unsupported, + Limit, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Invalid => "invalid SHX font", + Self::Unsupported => "unsupported SHX font format or encoding", + Self::Limit => "SHX font exceeds a complexity or memory limit", + }) + } +} + +impl std::error::Error for Error {} + +#[derive(Debug)] +struct Glyph { + strokes: Vec>, + advance: f64, +} + +#[derive(Debug, Default)] +struct GlyphCache { + glyphs: HashMap>>, + points: usize, +} + +/// Parsed font programs, with bounded, lazy decoding shared by every placement. +#[derive(Debug)] +pub struct Font { + cap: f64, + unicode: bool, + dual: bool, + programs: HashMap>, + cache: RwLock, + bytes: usize, +} + +/// Checked cursor shared by the record reader and program interpreter. +struct Cursor<'a> { + bytes: &'a [u8], + pos: usize, +} + +impl<'a> Cursor<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, pos: 0 } + } + fn take(&mut self, n: usize) -> Result<&'a [u8], Error> { + let end = self.pos.checked_add(n).ok_or(Error::Limit)?; + let result = self.bytes.get(self.pos..end).ok_or(Error::Invalid)?; + self.pos = end; + Ok(result) + } + fn byte(&mut self) -> Result { + self.take(1)?.first().copied().ok_or(Error::Invalid) + } + fn word(&mut self) -> Result { + Ok(u16::from(self.byte()?) | (u16::from(self.byte()?) << 8)) + } + fn name(&mut self) -> Result<(), Error> { + let n = self.bytes.get(self.pos..).and_then(|s| s.iter().position(|b| *b == 0)).ok_or(Error::Invalid)?; + self.take(n.checked_add(1).ok_or(Error::Limit)?)?; + Ok(()) + } + fn done(&self) -> bool { + self.pos == self.bytes.len() + } +} + +impl Font { + /// Parse shapes 1.0/1.1 and Unicode unifont 1.0. Big fonts and packed encodings are rejected. + pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() > MAX_FILE_BYTES { + return Err(Error::Limit); + } + let mut r = Cursor::new(bytes); + let mut font = Self { cap: 0.0, unicode: false, dual: false, programs: HashMap::new(), cache: RwLock::default(), bytes: bytes.len() }; + if bytes.starts_with(b"AutoCAD-86 unifont 1.0\r\n\x1a") { + r.take(25)?; + let count = r.word()?; + if count == 0 || r.word()? != 0 { + return Err(Error::Invalid); + } + let size = usize::from(r.word()?); + let mut definition = Cursor::new(r.take(size)?); + definition.name()?; + font.metrics(&mut definition)?; + if definition.byte()? != 0 { + return Err(Error::Unsupported); + } + let _embedding_permission = definition.byte()?; + if definition.byte()? != 0 || !definition.done() { + return Err(Error::Invalid); + } + font.unicode = true; + for _ in 1..count { + let id = r.word()?; + let size = usize::from(r.word()?); + font.record(id, r.take(size)?)?; + } + if !r.done() { + return Err(Error::Invalid); + } + } else if bytes.starts_with(b"AutoCAD-86 shapes 1.0\r\n\x1a") || bytes.starts_with(b"AutoCAD-86 shapes 1.1\r\n\x1a") { + r.take(24)?; + let first = r.word()?; + let last = r.word()?; + let count = usize::from(r.word()?); + if count == 0 { + return Err(Error::Invalid); + } + // Check the entire index exists before allocating from its input-derived count. + let mut index = Cursor::new(r.take(count.checked_mul(4).ok_or(Error::Limit)?)?); + let mut entries = Vec::with_capacity(count); + for _ in 0..count { + entries.push((index.word()?, usize::from(index.word()?))); + } + if entries.first().map(|e| e.0) != Some(first) || entries.last().map(|e| e.0) != Some(last) { + return Err(Error::Invalid); + } + for (id, size) in entries { + let record = r.take(size)?; + if id == 0 { + let mut definition = Cursor::new(record); + definition.name()?; + font.metrics(&mut definition)?; + if definition.byte()? != 0 || !definition.done() { + return Err(Error::Invalid); + } + } else { + font.record(id, record)?; + } + } + if r.take(3)? != b"EOF" || !r.done() { + return Err(Error::Invalid); + } + } else { + return Err(Error::Unsupported); + } + if font.cap <= 0.0 || font.programs.is_empty() { + return Err(Error::Invalid); + } + Ok(font) + } + + fn metrics(&mut self, r: &mut Cursor<'_>) -> Result<(), Error> { + self.cap = f64::from(r.byte()?); + let _descent = r.byte()?; + self.dual = match r.byte()? { + 0 => false, + 2 => true, + _ => return Err(Error::Unsupported), + }; + Ok(()) + } + + fn record(&mut self, id: u16, bytes: &[u8]) -> Result<(), Error> { + let mut r = Cursor::new(bytes); + r.name()?; + let data = bytes.get(r.pos..).ok_or(Error::Invalid)?; + if data.is_empty() || data.last() != Some(&0) || data.len() > MAX_PROGRAM_BYTES || self.programs.contains_key(&id) { + return Err(Error::Invalid); + } + self.programs.insert(id, data.to_vec()); + Ok(()) + } + + fn id(&self, c: char) -> Option { + if !self.unicode { + match c { + '°' if self.programs.contains_key(&256) => return Some(256), + '±' if self.programs.contains_key(&257) => return Some(257), + '⌀' | 'Ø' if self.programs.contains_key(&258) => return Some(258), + _ => {} + } + } + u16::try_from(u32::from(c)).ok() + } + + fn glyph(&self, c: char) -> Option> { + let id = self.id(c)?; + if let Some(g) = self.cache.read().unwrap_or_else(PoisonError::into_inner).glyphs.get(&id) { + return g.clone(); + } + let result = program::decode(self, id).ok().map(Arc::new); + let points = result.as_ref().map_or(0, |g| g.strokes.iter().map(Vec::len).sum()); + let mut cache = self.cache.write().unwrap_or_else(PoisonError::into_inner); + if let Some(g) = cache.glyphs.get(&id) { + return g.clone(); + } + if cache.glyphs.len() < MAX_CACHED_GLYPHS && cache.points.saturating_add(points) <= MAX_CACHED_POINTS { + cache.points += points; + cache.glyphs.insert(id, result.clone()); + } + result + } + + /// Horizontal layout uses the font's cap height and pen-end advances (no added stroke gap). + pub(crate) fn shape(&self, text: &str, height: f64, width_factor: f64, oblique: f64) -> Shaped { + let h = if height.is_finite() && height > 0.0 { height } else { 1.0 }; + let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 }; + let oblique = if oblique.is_finite() { oblique } else { 0.0 }; + let shear = oblique.tan().clamp(-10.0, 10.0); + let scale = h / self.cap; + let chars = crate::decode_controls(text); + let logical: Vec = chars.iter().map(|v| v.0).collect(); + let mut out = Shaped::default(); + let mut spans = Vec::new(); + let mut x = 0.0; + for (run, rtl) in bidi::visual_runs(&logical, None) { + let indices: Box> = if rtl { Box::new(run.rev()) } else { Box::new(run) }; + for i in indices { + let Some(&(c, under, over)) = chars.get(i) else { continue }; + if bidi::is_format_control(c) { + continue; + } + let c = if rtl { bidi::mirror(c) } else { c }; + let advance = if let Some(g) = self.glyph(c) { + for stroke in &g.strokes { + out.strokes.push(stroke.iter().map(|p| Vec2::new(x + p.x * scale * wf + p.y * scale * shear, p.y * scale)).collect()); + } + g.advance * scale * wf + } else { + let mut fallback = crate::shape_stroke(&c.to_string(), h, wf, oblique); + let advance = fallback.width + crate::stroke::GAP / crate::stroke::CAP * h * wf; + fallback.map(|p| p + Vec2::new(x, 0.0)); + out.extend(fallback); + advance + }; + spans.push((x, x + advance, under, over)); + x += advance; + } + } + out.strokes.extend(crate::decorations(&spans, h)); + out.width = x.max(0.0); + out + } + + pub(crate) fn width(&self, text: &str, height: f64, width_factor: f64) -> f64 { + if bidi::has_rtl(text) { + // Mirrored punctuation can have a different advance in a supplied font. + return self.shape(text, height, width_factor, 0.0).width; + } + let h = if height.is_finite() && height > 0.0 { height } else { 1.0 }; + let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 }; + crate::decode_controls(text) + .into_iter() + .filter(|(c, _, _)| !bidi::is_format_control(*c)) + .map(|(c, _, _)| { + self.glyph(c).map_or_else( + || crate::line_width(&c.to_string(), h, wf) + crate::stroke::GAP / crate::stroke::CAP * h * wf, + |g| g.advance * h / self.cap * wf, + ) + }) + .sum::() + .max(0.0) + } +} + +#[derive(Default)] +struct Registry { + paths: HashMap, + fonts: HashMap>>, + bytes: usize, +} + +fn key(name: &str) -> Option { + let name = name.trim().rsplit(['/', '\\']).next()?; + if name.is_empty() || name.len() > 255 { + return None; + } + let lower = name.to_ascii_lowercase(); + if let Some(stem) = lower.strip_suffix(".shx") { + (!stem.is_empty() && !stem.contains(':')).then(|| stem.to_owned()) + } else if lower.contains('.') || lower.contains(':') { + None + } else { + Some(lower) + } +} + +fn registry() -> &'static Mutex { + static REGISTRY: OnceLock> = OnceLock::new(); + REGISTRY.get_or_init(|| Mutex::new(scan())) +} + +fn scan() -> Registry { + #[cfg(target_arch = "wasm32")] + return Registry::default(); + #[cfg(not(target_arch = "wasm32"))] + { + let mut registry = Registry::default(); + if let Some(paths) = std::env::var_os("CADCRAFT_SHXPATH") { + let mut files = 0usize; + for dir in std::env::split_paths(&paths).take(32) { + let Ok(entries) = std::fs::read_dir(dir) else { continue }; + for entry in entries.take(MAX_FILES.saturating_sub(files)) { + files += 1; + let Ok(entry) = entry else { continue }; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("shx")) + && let Some(name) = path.file_name().and_then(|n| n.to_str()).and_then(key) + { + registry.paths.entry(name).or_insert(path); + } + } + } + } + registry + } +} + +/// Register user-provided bytes (also works on WASM); never writes or embeds a font file. +pub fn register(name: &str, bytes: &[u8]) -> Result<(), Error> { + let name = key(name).ok_or(Error::Invalid)?; + let font = Arc::new(Font::parse(bytes)?); + let mut registry = registry().lock().unwrap_or_else(PoisonError::into_inner); + let old = registry.fonts.get(&name).and_then(Option::as_ref).map_or(0, |f| f.bytes); + let total = registry.bytes.saturating_sub(old).saturating_add(font.bytes); + if total > MAX_LOADED_BYTES || (!registry.fonts.contains_key(&name) && registry.fonts.len() >= MAX_FONTS) { + return Err(Error::Limit); + } + registry.bytes = total; + registry.fonts.insert(name, Some(font)); + Ok(()) +} + +/// Resolve by file name or stem from the configured directories or registered font bytes. +pub fn find(name: &str) -> Option> { + let name = key(name)?; + let mut registry = registry().lock().unwrap_or_else(PoisonError::into_inner); + if let Some(font) = registry.fonts.get(&name) { + return font.clone(); + } + let path = registry.paths.get(&name)?; + if registry.fonts.len() >= MAX_FONTS { + return None; + } + let mut bytes = Vec::new(); + let result = std::fs::File::open(path).ok().and_then(|file| { + file.take((MAX_FILE_BYTES + 1) as u64).read_to_end(&mut bytes).ok()?; + if registry.bytes.saturating_add(bytes.len()) > MAX_LOADED_BYTES { + return None; + } + Font::parse(&bytes).ok().map(Arc::new) + }); + if let Some(font) = &result { + registry.bytes += font.bytes; + } + registry.fonts.insert(name, result.clone()); + result +} diff --git a/crates/fonts/src/shx/program.rs b/crates/fonts/src/shx/program.rs new file mode 100644 index 000000000..92ae813cc --- /dev/null +++ b/crates/fonts/src/shx/program.rs @@ -0,0 +1,242 @@ +//! Bounded interpreter of the publicly documented SHP/SHX pen commands. + +use std::f64::consts::{FRAC_PI_4, TAU}; + +use cadcraft_geom::Vec2; + +use super::{Cursor, Error, Font, Glyph}; + +const MAX_DEPTH: usize = 32; +const MAX_STACK: usize = 32; +const MAX_STEPS: usize = 65_536; +const MAX_POINTS: usize = 8192; +const MAX_COORD: f64 = 1.0e6; + +struct Pen<'a> { + font: &'a Font, + pos: Vec2, + down: bool, + scale: f64, + stack: Vec, + strokes: Vec>, + active: Vec, + steps: usize, + points: usize, +} + +pub(super) fn decode(font: &Font, id: u16) -> Result { + let mut pen = + Pen { font, pos: Vec2::ZERO, down: true, scale: 1.0, stack: Vec::new(), strokes: Vec::new(), active: Vec::new(), steps: 0, points: 0 }; + pen.run(id, 0)?; + pen.flush(); + if !pen.stack.is_empty() { + return Err(Error::Invalid); + } + Ok(Glyph { strokes: pen.strokes, advance: pen.pos.x }) +} + +impl Pen<'_> { + fn step(&mut self) -> Result<(), Error> { + self.steps += 1; + if self.steps > MAX_STEPS { Err(Error::Limit) } else { Ok(()) } + } + + fn flush(&mut self) { + if self.active.len() >= 2 { + self.strokes.push(std::mem::take(&mut self.active)); + } else { + self.active.clear(); + } + } + + fn move_to(&mut self, target: Vec2) -> Result<(), Error> { + if !target.is_finite() || target.x.abs().max(target.y.abs()) > MAX_COORD { + return Err(Error::Limit); + } + if self.down { + let added = if self.active.is_empty() { 2 } else { 1 }; + self.points += added; + if self.points > MAX_POINTS { + return Err(Error::Limit); + } + if self.active.is_empty() { + self.active.push(self.pos); + } + self.active.push(target); + } else { + self.flush(); + } + self.pos = target; + Ok(()) + } + + fn displacement(&mut self, dx: f64, dy: f64) -> Result<(), Error> { + self.move_to(self.pos + Vec2::new(dx, dy) * self.scale) + } + + fn arc(&mut self, radius: f64, start: f64, sweep: f64) -> Result<(), Error> { + if !radius.is_finite() || radius <= 0.0 || radius > MAX_COORD || !sweep.is_finite() || sweep.abs() > TAU + 1e-8 { + return Err(Error::Invalid); + } + let center = self.pos - Vec2::new(start.cos(), start.sin()) * radius; + let count = (sweep.abs() / (TAU / 128.0)).ceil().clamp(1.0, 128.0) as usize; + let count = if self.down { count } else { 1 }; + for i in 1..=count { + let angle = start + sweep * i as f64 / count as f64; + self.move_to(center + Vec2::new(angle.cos(), angle.sin()) * radius)?; + } + Ok(()) + } + + fn bulge(&mut self, dx: f64, dy: f64, bulge: f64) -> Result<(), Error> { + let delta = Vec2::new(dx, dy) * self.scale; + if bulge == 0.0 || !self.down { + return self.move_to(self.pos + delta); + } + if bulge.abs() > 127.0 || delta.len2() == 0.0 { + return Err(Error::Invalid); + } + let b = bulge / 127.0; + let center = self.pos + delta * 0.5 + Vec2::new(-delta.y, delta.x) * ((1.0 - b * b) / (4.0 * b)); + let relative = self.pos - center; + self.arc(relative.len(), relative.y.atan2(relative.x), 4.0 * b.atan()) + } + + fn run(&mut self, id: u16, depth: usize) -> Result<(), Error> { + if depth >= MAX_DEPTH { + return Err(Error::Limit); + } + let program = self.font.programs.get(&id).ok_or(Error::Invalid)?; + let mut r = Cursor::new(program); + let mut skip = false; + loop { + self.step()?; + let code = r.byte()?; + let execute = !skip; + skip = false; + match code { + 0 => return if r.done() { Ok(()) } else { Err(Error::Invalid) }, + 1 => { + if execute { + self.down = true; + } + } + 2 => { + if execute { + self.down = false; + self.flush(); + } + } + 3 | 4 => { + let factor = f64::from(r.byte()?); + if factor == 0.0 { + return Err(Error::Invalid); + } + if execute { + self.scale = if code == 3 { self.scale / factor } else { self.scale * factor }; + if !(1e-6..=MAX_COORD).contains(&self.scale) { + return Err(Error::Limit); + } + } + } + 5 => { + if execute { + if self.stack.len() >= MAX_STACK { + return Err(Error::Limit); + } + self.stack.push(self.pos); + } + } + 6 => { + if execute { + self.flush(); + self.pos = self.stack.pop().ok_or(Error::Invalid)?; + } + } + 7 => { + let id = if self.font.unicode { + // Unicode subshape operands are high byte first; record ids are LE. + (u16::from(r.byte()?) << 8) | u16::from(r.byte()?) + } else { + u16::from(r.byte()?) + }; + if execute { + self.run(id, depth + 1)?; + } + } + 8 | 9 | 12 | 13 => loop { + self.step()?; + let dx = f64::from(i8::from_ne_bytes([r.byte()?])); + let dy = f64::from(i8::from_ne_bytes([r.byte()?])); + if matches!(code, 9 | 13) && dx == 0.0 && dy == 0.0 { + break; + } + let bulge = if matches!(code, 12 | 13) { f64::from(i8::from_ne_bytes([r.byte()?])) } else { 0.0 }; + if execute { + self.bulge(dx, dy, bulge)?; + } + if matches!(code, 8 | 12) { + break; + } + }, + 10 | 11 => { + let (offset, end, radius) = if code == 11 { + let offset = f64::from(r.byte()?) / 256.0; + let end = r.byte()?; + let end = if end == 0 { 1.0 } else { f64::from(end) / 256.0 }; + let radius = (u16::from(r.byte()?) << 8) | u16::from(r.byte()?); + (offset, end, f64::from(radius)) + } else { + (0.0, 1.0, f64::from(r.byte()?)) + }; + // The sign is sign-magnitude for arc octants, not two's-complement. + let spec = r.byte()?; + let sign = if spec & 128 == 0 { 1.0 } else { -1.0 }; + let octant = (spec & 127) >> 4; + let span = spec & 15; + if octant > 7 || span > 7 { + return Err(Error::Invalid); + } + let span = if span == 0 { 8.0 } else { f64::from(span) }; + let start = (f64::from(octant) + sign * offset) * FRAC_PI_4; + let sweep = sign * (span - 1.0 + end - offset) * FRAC_PI_4; + if execute { + self.arc(radius * self.scale, start, sweep)?; + } + } + 14 => { + if self.font.dual && execute { + skip = true; + } + } + 15 => return Err(Error::Unsupported), + _ => { + let length = f64::from(code >> 4); + let direction = code & 15; + // Directions at 22.5-degree steps use half-grid intermediate vectors. + let (dx, dy) = match direction { + 0 => (1.0, 0.0), + 1 => (1.0, 0.5), + 2 => (1.0, 1.0), + 3 => (0.5, 1.0), + 4 => (0.0, 1.0), + 5 => (-0.5, 1.0), + 6 => (-1.0, 1.0), + 7 => (-1.0, 0.5), + 8 => (-1.0, 0.0), + 9 => (-1.0, -0.5), + 10 => (-1.0, -1.0), + 11 => (-0.5, -1.0), + 12 => (0.0, -1.0), + 13 => (0.5, -1.0), + 14 => (1.0, -1.0), + _ => (1.0, -0.5), + }; + if execute { + self.displacement(dx * length, dy * length)?; + } + } + } + } + } +} diff --git a/crates/fonts/src/shx/tests.rs b/crates/fonts/src/shx/tests.rs new file mode 100644 index 000000000..fa06f5636 --- /dev/null +++ b/crates/fonts/src/shx/tests.rs @@ -0,0 +1,211 @@ +use super::*; +use crate::{MTextParams, TextFont, layout_mtext_with, shape_line, text_width}; + +// Original synthetic glyph programs. These bytes are assembled here, never compiled in AutoCAD. +fn unifont(rows: &[(u16, &[u8])], mode: u8) -> Vec { + let mut data = b"AutoCAD-86 unifont 1.0\r\n\x1a".to_vec(); + data.extend_from_slice(&(rows.len() as u16 + 1).to_le_bytes()); + data.extend_from_slice(&0u16.to_le_bytes()); + let definition = [b't', b'e', b's', b't', 0, 6, 2, mode, 0, 0, 0]; + data.extend_from_slice(&(definition.len() as u16).to_le_bytes()); + data.extend_from_slice(&definition); + for (id, program) in rows { + data.extend_from_slice(&id.to_le_bytes()); + data.extend_from_slice(&(program.len() as u16 + 1).to_le_bytes()); + data.push(0); + data.extend_from_slice(program); + } + data +} + +fn legacy(version: &str, rows: &[(u16, &[u8])]) -> Vec { + let mut data = format!("AutoCAD-86 shapes {version}\r\n\x1a").into_bytes(); + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(&rows.last().unwrap().0.to_le_bytes()); + data.extend_from_slice(&(rows.len() as u16 + 1).to_le_bytes()); + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(&5u16.to_le_bytes()); + for (id, program) in rows { + data.extend_from_slice(&id.to_le_bytes()); + data.extend_from_slice(&(program.len() as u16 + 1).to_le_bytes()); + } + data.extend_from_slice(&[0, 6, 2, 2, 0]); + for (_, program) in rows { + data.push(0); + data.extend_from_slice(program); + } + data.extend_from_slice(b"EOF"); + data +} + +const A: &[u8] = &[2, 8, 1, 0, 1, 8, 0, 6, 8, 4, 250, 2, 8, 2, 0, 0]; +const ACUTE: &[u8] = &[7, 0, 65, 5, 2, 8, 251, 7, 1, 8, 2, 1, 6, 0]; +const SPACE: &[u8] = &[2, 8, 3, 0, 0]; + +#[test] +fn unicode_subshapes_use_high_byte_first_and_preserve_advance() { + let font = Font::parse(&unifont(&[(65, A), (193, ACUTE), (32, SPACE)], 2)).unwrap(); + let a = font.glyph('A').unwrap(); + let acute = font.glyph('\u{00C1}').unwrap(); + assert_eq!(a.advance, 7.0); + assert_eq!(acute.advance, a.advance); + assert_eq!(acute.strokes.first(), a.strokes.first()); + assert!(acute.strokes.iter().flatten().any(|p| p.y == 8.0)); + let face = TextFont::Shx(Arc::new(font)); + let shaped = shape_line(&face, "A\u{00C1} A", 3.0, 0.8, 0.2); + assert!(shaped.glyphs.is_empty(), "all glyphs stay single-stroke"); + assert!((shaped.width - 24.0 * 3.0 / 6.0 * 0.8).abs() < 1e-9); + assert!((text_width(&face, "A\u{00C1} A", 3.0, 0.8) - shaped.width).abs() < 1e-9); + assert!((crate::space_width(&face, 3.0, 0.8) - 1.2).abs() < 1e-9); + assert!(shaped.strokes.iter().flatten().all(|p| p.is_finite())); +} + +#[test] +fn legacy_versions_map_dimension_symbols_and_latin_characters() { + for version in ["1.0", "1.1"] { + let bytes = legacy(version, &[(65, A), (193, &[7, 65, 0]), (256, A), (257, A), (258, A)]); + let font = Font::parse(&bytes).unwrap(); + for c in ['A', '\u{00C1}', '°', '±', '⌀'] { + assert_eq!(font.glyph(c).unwrap().advance, 7.0); + } + assert_eq!(font.shape("%%d%%p%%c", 6.0, 1.0, 0.0).width, 21.0); + } +} + +#[test] +fn pen_commands_have_known_geometry() { + let cases: &[(u16, &[u8], Vec2)] = &[ + (65, &[0x24, 0x20, 2, 0x2c, 0], Vec2::new(2.0, 0.0)), + (66, &[4, 2, 8, 3, 2, 3, 2, 8, 1, 0, 0], Vec2::new(7.0, 4.0)), + (67, &[9, 3, 1, 3, 255, 0, 0, 0], Vec2::new(6.0, 0.0)), + (68, &[10, 3, 0x02, 0], Vec2::new(-3.0, 3.0)), + (69, &[10, 3, 0x82, 0], Vec2::new(-3.0, -3.0)), + (70, &[11, 0, 0, 0, 3, 0x02, 0], Vec2::new(-3.0, 3.0)), + (71, &[12, 6, 0, 127, 0], Vec2::new(6.0, 0.0)), + (72, &[13, 6, 0, 127, 6, 0, 129, 0, 0, 0], Vec2::new(12.0, 0.0)), + (73, &[5, 8, 0, 6, 6, 2, 8, 7, 0, 0], Vec2::new(7.0, 0.0)), + (74, &[14, 9, 50, 50, 50, 50, 0, 0, 8, 3, 0, 0], Vec2::new(3.0, 0.0)), + (75, &[14, 7, 0, 65, 8, 3, 0, 0], Vec2::new(3.0, 0.0)), + ]; + let rows: Vec<_> = cases.iter().map(|(id, bytes, _)| (*id, *bytes)).collect(); + let font = Font::parse(&unifont(&rows, 2)).unwrap(); + for (id, _, end) in cases { + let glyph = program::decode(&font, *id).unwrap(); + assert!((glyph.advance - end.x).abs() < 1e-8, "glyph {id}"); + if !matches!(*id, 65 | 73) { + let last = glyph.strokes.last().unwrap().last().unwrap(); + assert!(last.dist(*end) < 1e-8, "glyph {id}: {last:?}"); + } + } + let semicircle = font.glyph('G').unwrap(); + assert!(semicircle.strokes.iter().flatten().any(|p| (p.y + 3.0).abs() < 1e-8)); + let cw = font.glyph('E').unwrap(); + assert!(cw.strokes.iter().flatten().all(|p| p.y <= 1e-8)); +} + +#[test] +fn resolution_mtext_wrapping_and_inline_font_use_shx_metrics() { + register("cadcraft-synthetic-unifont.shx", &unifont(&[(65, A), (193, ACUTE), (32, SPACE)], 2)).unwrap(); + let font = TextFont::resolve("CADCRAFT-SYNTHETIC-UNIFONT.SHX"); + assert!(matches!(font, TextFont::Shx(_))); + assert!(matches!(TextFont::resolve("cadcraft-synthetic-unifont"), TextFont::Shx(_))); + let mut params = MTextParams::new(Vec2::ZERO, 6.0); + params.font = font; + params.width = 18.0; + let layout = layout_mtext_with("A\u{00C1} A\u{00C1}", ¶ms); + assert_eq!(layout.lines, ["A\u{00C1}", "A\u{00C1}"]); + assert!(layout.pieces.iter().all(|p| p.shaped.glyphs.is_empty())); + params.width = 0.0; + params.font = TextFont::Stroke; + let inline = layout_mtext_with("\\Fcadcraft-synthetic-unifont.shx;A\u{00C1}", ¶ms); + assert_eq!(inline.lines, ["A\u{00C1}"]); + assert!(inline.pieces.iter().all(|p| p.shaped.glyphs.is_empty())); + assert!(inline.strokes.iter().flatten().any(|p| (p.x - 12.0).abs() < 1e-9), "native pen-end spacing"); +} + +#[test] +fn configured_directory_loads_only_named_user_fonts() { + // A child process isolates startup environment and the once-scanned registry, without + // unsafe process-wide environment mutation in parallel Rust tests. + if std::env::var_os("CADCRAFT_SHX_TEST_CHILD").is_some() { + let font = find("CADCRAFT-SYNTHETIC-PATH.SHX").unwrap(); + assert_eq!(font.glyph('A').unwrap().advance, 7.0); + assert!(matches!(TextFont::resolve("cadcraft-synthetic-path"), TextFont::Shx(_))); + assert!(find("cadcraft-broken-path.shx").is_none()); + assert!(find("cadcraft-nonexistent-path.shx").is_none()); + assert!(matches!(TextFont::resolve("cadcraft-nonexistent-path.shx"), TextFont::Shx(_)), "SHX FONTALT"); + assert!(matches!(TextFont::resolve(crate::BUILTIN_FONT), TextFont::Stroke)); + assert!(find("../cadcraft-synthetic-path.ttf").is_none()); + return; + } + let dir = std::env::temp_dir().join(format!("cadcraft-shx-path-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("cadcraft-synthetic-path.shx"), unifont(&[(65, A)], 2)).unwrap(); + std::fs::write(dir.join("cadcraft-broken-path.shx"), b"not a font").unwrap(); + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "shx::tests::configured_directory_loads_only_named_user_fonts", "--nocapture"]) + .env("CADCRAFT_SHX_TEST_CHILD", "1") + .env("CADCRAFT_SHXPATH", &dir) + .env("CADCRAFT_FONTALT", "cadcraft-synthetic-path.shx") + .output() + .unwrap(); + std::fs::remove_file(dir.join("cadcraft-synthetic-path.shx")).unwrap(); + std::fs::remove_file(dir.join("cadcraft-broken-path.shx")).unwrap(); + std::fs::remove_dir(&dir).unwrap(); + assert!(result.status.success(), "{}\n{}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr)); +} + +#[test] +fn malformed_and_hostile_fonts_never_panic() { + let valid = unifont(&[(65, A), (193, ACUTE)], 2); + for end in 0..valid.len() { + assert!(Font::parse(&valid[..end]).is_err(), "truncated at {end}"); + } + let malformed: &[&[u8]] = &[ + &[7, 0, 65, 0], // cycle + &[6, 0], // stack underflow + &[5, 0], // unbalanced stack + &[3, 0, 0], // division by zero + &[4, 255, 4, 255, 4, 255, 0], // excessive scale + &[9, 1, 1, 0], // missing displacement terminator + &[11, 0], // truncated arc + &[10, 1, 0x79, 0], // invalid octant span + &[15, 0], // unsupported opcode + ]; + for program in malformed { + let font = Font::parse(&unifont(&[(65, program)], 2)).unwrap(); + assert!(program::decode(&font, 65).is_err()); + let fallback = font.shape("A\u{00C1}", 1.0, 1.0, 0.0); + assert!(!fallback.strokes.is_empty() && fallback.glyphs.is_empty()); + } + // Deep acyclic calls, excessive points, random mutation, duplicates and bogus lengths. + let mut chain: Vec> = (65u16..110).map(|id| vec![7, 0, (id + 1) as u8, 0]).collect(); + chain.push(vec![0]); + let rows: Vec<_> = chain.iter().enumerate().map(|(i, p)| (65 + i as u16, p.as_slice())).collect(); + assert!(matches!(program::decode(&Font::parse(&unifont(&rows, 2)).unwrap(), 65), Err(Error::Limit))); + let mut many_points = vec![0x10; 4096]; + many_points.push(0); + let font = Font::parse(&unifont(&[(65, &[7, 0, 66, 7, 0, 66, 0]), (66, &many_points)], 2)).unwrap(); + assert!(matches!(program::decode(&font, 65), Err(Error::Limit))); + let mut state = 0x1234_5678u32; + for _ in 0..500 { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + let mut bytes = valid.clone(); + let i = state as usize % bytes.len(); + bytes[i] ^= (state >> 24) as u8; + if let Ok(font) = Font::parse(&bytes) { + let _ = font.shape("A\u{00C1}", 1.0, 1.0, 0.0); + } + } + assert!(Font::parse(&unifont(&[(65, A), (65, A)], 2)).is_err()); + assert!(matches!(Font::parse(b"AutoCAD-86 bigfont 1.0\r\n\x1a"), Err(Error::Unsupported))); + assert!(matches!(Font::parse(&vec![0; MAX_FILE_BYTES + 1]), Err(Error::Limit))); + let font = Font::parse(&valid).unwrap(); + for n in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 0.0] { + let shaped = font.shape("A\u{00C1}B", n, n, n); + assert!(shaped.width.is_finite()); + assert!(shaped.strokes.iter().flatten().all(|p| p.is_finite())); + } +} diff --git a/crates/fonts/src/stroke.rs b/crates/fonts/src/stroke.rs index e6d38b7db..43aa2dd3f 100644 --- a/crates/fonts/src/stroke.rs +++ b/crates/fonts/src/stroke.rs @@ -110,6 +110,61 @@ pub(crate) fn glyph(c: char) -> Option<(f64, &'static str)> { 'Ø' | 'ø' | '⌀' => (4.0, "1c0d0h1i3i4h4d3c1c 0c4i"), // List bullet: a small crossed diamond at mid x-height. '•' => (2.0, "1d0e1f2e1d 0e2e 1d1f"), + // Original Latin diacritics, composed from our own letter bodies and marks. + '\u{00C0}' => (4.0, concat!("0c2i4c 1f3f", " 1k3j")), + '\u{00C1}' => (4.0, concat!("0c2i4c 1f3f", " 1j3k")), + '\u{00C2}' => (4.0, concat!("0c2i4c 1f3f", " 0j2k4j")), + '\u{00C3}' => (4.0, concat!("0c2i4c 1f3f", " 0j1k3j4k")), + '\u{00C4}' => (4.0, concat!("0c2i4c 1f3f", " 1j1k 3j3k")), + '\u{00C5}' => (4.0, concat!("0c2i4c 1f3f", " 2j1k2l3k2j")), + '\u{00C7}' => (4.0, concat!("4h3i1i0h0d1c3c4d", " 2c3b2a1a")), + '\u{00C8}' => (4.0, concat!("4i0i0c4c 0f3f", " 1k3j")), + '\u{00C9}' => (4.0, concat!("4i0i0c4c 0f3f", " 1j3k")), + '\u{00CA}' => (4.0, concat!("4i0i0c4c 0f3f", " 0j2k4j")), + '\u{00CB}' => (4.0, concat!("4i0i0c4c 0f3f", " 1j1k 3j3k")), + '\u{00CC}' => (2.0, concat!("0i2i 1i1c 0c2c", " 0k2j")), + '\u{00CD}' => (2.0, concat!("0i2i 1i1c 0c2c", " 0j2k")), + '\u{00CE}' => (2.0, concat!("0i2i 1i1c 0c2c", " 0j1k2j")), + '\u{00CF}' => (2.0, concat!("0i2i 1i1c 0c2c", " 0j0k 2j2k")), + '\u{00D1}' => (4.0, concat!("0c0i4c4i", " 0j1k3j4k")), + '\u{00D2}' => (4.0, concat!("1c0d0h1i3i4h4d3c1c", " 1k3j")), + '\u{00D3}' => (4.0, concat!("1c0d0h1i3i4h4d3c1c", " 1j3k")), + '\u{00D4}' => (4.0, concat!("1c0d0h1i3i4h4d3c1c", " 0j2k4j")), + '\u{00D5}' => (4.0, concat!("1c0d0h1i3i4h4d3c1c", " 0j1k3j4k")), + '\u{00D6}' => (4.0, concat!("1c0d0h1i3i4h4d3c1c", " 1j1k 3j3k")), + '\u{00D9}' => (4.0, concat!("0i0d1c3c4d4i", " 1k3j")), + '\u{00DA}' => (4.0, concat!("0i0d1c3c4d4i", " 1j3k")), + '\u{00DB}' => (4.0, concat!("0i0d1c3c4d4i", " 0j2k4j")), + '\u{00DC}' => (4.0, concat!("0i0d1c3c4d4i", " 1j1k 3j3k")), + '\u{00DD}' => (4.0, concat!("0i2f4i 2f2c", " 1j3k")), + '\u{00E0}' => (4.0, concat!("4g4c 4f3g1g0f0d1c3c4d", " 1i3h")), + '\u{00E1}' => (4.0, concat!("4g4c 4f3g1g0f0d1c3c4d", " 1h3i")), + '\u{00E2}' => (4.0, concat!("4g4c 4f3g1g0f0d1c3c4d", " 0h2i4h")), + '\u{00E3}' => (4.0, concat!("4g4c 4f3g1g0f0d1c3c4d", " 0h1i3h4i")), + '\u{00E4}' => (4.0, concat!("4g4c 4f3g1g0f0d1c3c4d", " 1h1i 3h3i")), + '\u{00E5}' => (4.0, concat!("4g4c 4f3g1g0f0d1c3c4d", " 2h1i2j3i2h")), + '\u{00E7}' => (4.0, concat!("4f3g1g0f0d1c3c4d", " 2c3b2a1a")), + '\u{00E8}' => (4.0, concat!("0e4e4f3g1g0f0d1c3c4d", " 1i3h")), + '\u{00E9}' => (4.0, concat!("0e4e4f3g1g0f0d1c3c4d", " 1h3i")), + '\u{00EA}' => (4.0, concat!("0e4e4f3g1g0f0d1c3c4d", " 0h2i4h")), + '\u{00EB}' => (4.0, concat!("0e4e4f3g1g0f0d1c3c4d", " 1h1i 3h3i")), + '\u{00EC}' => (1.0, concat!("0c0g", " 0i1h")), + '\u{00ED}' => (1.0, concat!("0c0g", " 0h1i")), + '\u{00EE}' => (1.0, concat!("0c0g", " 0h0i1h")), + '\u{00EF}' => (1.0, concat!("0c0g", " 0h0i 1h1i")), + '\u{00F1}' => (4.0, concat!("0c0g 0f1g3g4f4c", " 0h1i3h4i")), + '\u{00F2}' => (4.0, concat!("1c0d0f1g3g4f4d3c1c", " 1i3h")), + '\u{00F3}' => (4.0, concat!("1c0d0f1g3g4f4d3c1c", " 1h3i")), + '\u{00F4}' => (4.0, concat!("1c0d0f1g3g4f4d3c1c", " 0h2i4h")), + '\u{00F5}' => (4.0, concat!("1c0d0f1g3g4f4d3c1c", " 0h1i3h4i")), + '\u{00F6}' => (4.0, concat!("1c0d0f1g3g4f4d3c1c", " 1h1i 3h3i")), + '\u{00F9}' => (4.0, concat!("0g0d1c3c4d 4g4c", " 1i3h")), + '\u{00FA}' => (4.0, concat!("0g0d1c3c4d 4g4c", " 1h3i")), + '\u{00FB}' => (4.0, concat!("0g0d1c3c4d 4g4c", " 0h2i4h")), + '\u{00FC}' => (4.0, concat!("0g0d1c3c4d 4g4c", " 1h1i 3h3i")), + '\u{00FD}' => (4.0, concat!("0g2c 4g1a0a", " 1h3i")), + '\u{00FF}' => (4.0, concat!("0g2c 4g1a0a", " 1h1i 3h3i")), + '\u{0178}' => (4.0, concat!("0i2f4i 2f2c", " 1j1k 3j3k")), _ => return None, }; Some(g) diff --git a/crates/fonts/src/ttf.rs b/crates/fonts/src/ttf.rs index 62759c1b0..583746fec 100644 --- a/crates/fonts/src/ttf.rs +++ b/crates/fonts/src/ttf.rs @@ -7,7 +7,7 @@ //! //! Two process-wide settings (like AutoCAD's profile variables) control substitution: //! - [`font_alt`] (`FONTALT`): the font used for a text style font that can't be found (an SHX -//! file we don't read, a missing TTF). Empty = the built-in stroke font. +//! file that isn't available, a missing TTF). Empty = the built-in stroke font. //! - [`fallback_fonts`] (`FONTFALLBACK`): fonts tried first for characters the text's font //! lacks (CJK in an SHX big font, for example), before the automatic system font search. //! diff --git a/crates/fonts/tests/latin_accents.rs b/crates/fonts/tests/latin_accents.rs new file mode 100644 index 000000000..79d4a37c1 --- /dev/null +++ b/crates/fonts/tests/latin_accents.rs @@ -0,0 +1,54 @@ +use cadcraft_fonts::{MTextParams, TextFont, char_advance, layout_mtext_with, line_width, shape_stroke}; +use cadcraft_geom::Vec2; + +#[test] +fn latin_accents_keep_single_strokes_and_the_base_letter_spacing() { + let groups = [ + ('A', "ÀÁÂÃÄÅ"), + ('C', "Ç"), + ('E', "ÈÉÊË"), + ('I', "ÌÍÎÏ"), + ('N', "Ñ"), + ('O', "ÒÓÔÕÖ"), + ('U', "ÙÚÛÜ"), + ('Y', "ÝŸ"), + ('a', "àáâãäå"), + ('c', "ç"), + ('e', "èéêë"), + ('i', "ìíîï"), + ('n', "ñ"), + ('o', "òóôõö"), + ('u', "ùúûü"), + ('y', "ýÿ"), + ]; + for (base, accents) in groups { + for c in accents.chars() { + let shaped = shape_stroke(&c.to_string(), 6.0, 0.8, 0.2); + assert!(shaped.glyphs.is_empty(), "{c} must not become a filled system-font outline"); + assert!(!shaped.strokes.is_empty()); + assert_eq!(char_advance(c), char_advance(base)); + assert!((line_width(&c.to_string(), 6.0, 0.8) - shaped.width).abs() < 1e-9); + assert!(shaped.strokes.iter().flatten().all(|p| p.is_finite())); + let next = shape_stroke(&format!("{c}B"), 6.0, 0.8, 0.2); + let plain = shape_stroke(&format!("{base}B"), 6.0, 0.8, 0.2); + assert_eq!(next.width, plain.width, "accent does not shift the next letter"); + } + } + for (accent, base) in [('Ç', 'C'), ('Ã', 'A'), ('Á', 'A'), ('Ó', 'O')] { + let plain = shape_stroke(&base.to_string(), 6.0, 1.0, 0.0); + let marked = shape_stroke(&accent.to_string(), 6.0, 1.0, 0.0); + assert!(marked.strokes.starts_with(&plain.strokes), "same letter body for {accent}"); + assert!(marked.strokes.len() > plain.strokes.len()); + } +} + +#[test] +fn portuguese_mtext_wraps_with_consistent_stroke_metrics() { + let text = "A CONCEPÇÃO SERÁ APROVADA APÓS O PROCESSAMENTO DA ESTRUTURA"; + let params = MTextParams { width: 32.0, font: TextFont::Stroke, ..MTextParams::new(Vec2::ZERO, 1.0) }; + let layout = layout_mtext_with(text, ¶ms); + assert!(layout.lines.len() >= 2); + assert_eq!(layout.lines.join(" "), text); + assert!(layout.pieces.iter().all(|p| p.shaped.glyphs.is_empty())); + assert!(layout.strokes.iter().flatten().all(|p| p.is_finite())); +} diff --git a/crates/io/tests/shx_text.rs b/crates/io/tests/shx_text.rs new file mode 100644 index 000000000..bd04a031b --- /dev/null +++ b/crates/io/tests/shx_text.rs @@ -0,0 +1,35 @@ +//! Synthetic DXF STYLE -> TEXT/MTEXT -> display list, without external font fixtures. + +use cadcraft_doc::Space; + +fn font() -> Vec { + let mut bytes = b"AutoCAD-86 unifont 1.0\r\n\x1a".to_vec(); + bytes.extend_from_slice(&[4, 0, 0, 0, 8, 0]); + bytes.extend_from_slice(&[b's', 0, 6, 2, 2, 0, 0, 0]); + for (id, program) in + [(32u16, vec![2, 8, 3, 0, 0]), (65, vec![8, 0, 6, 8, 4, 250, 2, 8, 3, 0, 0]), (193, vec![7, 0, 65, 5, 2, 8, 252, 7, 1, 8, 2, 1, 6, 0])] + { + bytes.extend_from_slice(&id.to_le_bytes()); + bytes.extend_from_slice(&(program.len() as u16 + 1).to_le_bytes()); + bytes.push(0); + bytes.extend(program); + } + bytes +} + +#[test] +fn shx_style_and_unicode_escapes_render_as_native_strokes_and_roundtrip() { + cadcraft_fonts::shx::register("cadcraft-io-synthetic.shx", &font()).unwrap(); + let dxf = "0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nSTYLE\n0\nSTYLE\n2\nR80\n3\ncadcraft-io-synthetic.shx\n0\nENDTAB\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nTEXT\n7\nR80\n10\n0\n20\n0\n40\n6\n1\nA\\U+00C1\n0\nMTEXT\n7\nR80\n10\n0\n20\n-20\n40\n6\n41\n18\n1\nA\\U+00C1 A\\U+00C1\n0\nENDSEC\n0\nEOF\n"; + let drawing = cadcraft_io::read(dxf.as_bytes(), "synthetic.dxf").unwrap(); + let display = cadcraft_render::build(&drawing, &Space::Model, &cadcraft_render::Options::default()); + assert!(display.tris.is_empty(), "SHX letters and accents are strokes, not filled TrueType outlines"); + assert!(display.verts.iter().any(|p| (p.x - 11.0).abs() < 1e-9), "native advance of 7, not the built-in advance"); + assert!(display.verts.iter().any(|p| p.y > 6.0), "accent above the cap height"); + assert!(display.verts.iter().any(|p| p.y < -27.0), "MTEXT wraps its second word"); + let saved = cadcraft_io::write_dxf(&drawing); + let reopened = cadcraft_io::read(saved.as_bytes(), "synthetic.dxf").unwrap(); + let again = cadcraft_render::build(&reopened, &Space::Model, &cadcraft_render::Options::default()); + assert_eq!(display.verts, again.verts); + assert!(again.tris.is_empty()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5b3a94ce8..f17732ed2 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (multiline attributes read, drawn and written) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (user SHX text fonts read; Latin stroke accents) · **Target:** Autodesk AutoCAD 2027 Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work items are in [gaps.md](gaps.md#file-formats); the weighted score feeds @@ -112,7 +112,7 @@ early (#175), but the throughput problem remains. **DWG ≈ 30% read, ≈ 15% wr | DWF/DWFx export, DWF underlay | yes | no | 10–15 | | DGN import/export/underlay | yes | no (needs a public spec; owner decision) | 25–40 | | WMF, EPS, ACIS SAT, STL, FBX, IGES/STEP (Windows), 3DS | various | no; most are 3D and wait for M11 | 20–40 | -| SHX fonts (`.shx`, big fonts) | read | **not parsed**: our stroke font or FONTALT stands in, so text metrics differ from AutoCAD | 8–12 | +| SHX fonts (`.shx`, big fonts) | read | user `shapes 1.0/1.1` and Unicode `unifont 1.0` text fonts read via `CADCRAFT_SHXPATH`, native strokes/cap/advances; original Latin fallback accents. Big fonts, packed encodings and vertical text not read; separate SHX MTEXT attachment rules remain ([details](shx-fonts.md), part of #801) | 8–12 (not re-estimated) | | Linetype (`.lin`) and hatch pattern (`.pat`) files | load user files | user files load (#552); linetype shape (SHX) elements are skipped | 1–2 | | Plot styles (`.ctb`, `.stb`), plotter configs (`.pc3`), `.pmp` | yes | plot style tables in our own JSON format ([plot-styles.md](plot-styles.md)) and kept in the drawing; plotter devices with papers and printable areas in our own JSON format ([plotters.md](plotters.md), #564); binary `.ctb`/`.stb`/`.pc3`/`.pmp` not read (no public spec) | 4–8 | | Scripts (`.scr`) | SCRIPT | script text through the CLI, MCP and control channel; no SCRIPT command reading a `.scr` file | 1–2 | @@ -135,6 +135,7 @@ our output in AutoCAD black-box. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | SHX text fonts: bounded, lazy pen-program reader; style resolution and inline MTEXT use native cap/advance metrics; missing-font Latin accents draw as original single strokes. Big fonts and separate SHX MTEXT attachment rules remain; no proprietary fonts bundled | | 2026-10-12 | minor | PDF output: object transparency plotted as PDF alpha (`/ExtGState` `/ca` `/CA`) when the page setup's or the plot's "Plot transparency" is on; PNG plots blend it, SVG uses `opacity`; plots without it are unchanged byte for byte | | 2026-10-12 | minor | DXF/DWG save: the Model layout's plot settings are written as CADCraft plots model space, drawing extents scaled to fit (PLOTSETTINGS 74 = 1, 75 = 0), instead of plot type 5 "Layout information", which other programs' Plot dialogs can't use for model space | | 2026-10-12 | minor | VIEWPORT frozen layers also written and read as AutoCAD's group 341 layer handles, so third-party viewers honour per-viewport freezing in DXF (DWG converter gap noted) | diff --git a/docs/gaps.md b/docs/gaps.md index 0adb80f12..37bd5d034 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (multiline attributes read, drawn and saved: F17 narrowed) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (FF8 narrowed: user SHX text fonts read; Latin stroke accents) · **Target:** Autodesk AutoCAD 2027 Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR @@ -75,7 +75,7 @@ Detail: [file-format-parity.md](file-format-parity.md). | FF5 | **[alpha blocker]** Real-file corpus (`storytold/cadcraft-corpus`), open → save → reopen tests, black-box check in AutoCAD | High | 10–15 + human | | FF6 | **[alpha blocker]** DWG throughput: a 10 MB DWG took ~2 min to fail (#57) | High for real projects | 6–10 | | FF7 | Table record and object handles from other programs' files are renumbered on the first save (entity handles are kept; `*D` blocks no longer duplicate and once reopened, CADCraft's own files keep every handle across saves, #495) | Low | 4–6 | -| FF8 | SHX fonts (shape and big fonts) not read | Medium-high (text metrics differ) | 8–12 | +| FF8 | SHX big fonts and packed encodings not read; vertical text and SHX-specific MTEXT attachment offsets remain. User shape fonts 1.0/1.1 and Unicode unifont 1.0 load via `CADCRAFT_SHXPATH`, with native strokes/advances; missing-font Latin accents stay single-stroke ([details](shx-fonts.md), part of #801) | Medium-high (unsupported fonts and some layout metrics differ) | 8–12 (not re-estimated) | | FF9 | `.pc3` plotter configuration files; linetype shape (SHX) elements are skipped (templates and `.dwt`, user `.lin`/`.pat` and `.pgp` alias files landed, #552; `.ctb`/`.stb`, #410) | Low-medium | 5–10 | | FF10 | PDF import and underlays (PDFATTACH); raster image attach (IMAGEATTACH). PDF export has real text in embedded TrueType subsets, PDF layers, several sheets per file and hyperlinks (#567, #571) | Medium | 20–35 | | FF11 | DWF/DWFx, DGN, WMF, EPS, ACIS/STL and other exchange formats | Low-medium | 50–100 | @@ -145,6 +145,7 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | FF8 narrowed: user SHX text fonts read with bounded pen-program decoding, native cap/advance metrics and original Latin stroke accents; big fonts, alternate encodings and separate SHX MTEXT layout rules remain | | 2026-10-12 | minor | Object transparency (entity, ByLayer, ByBlock) is drawn: on screen (TRANSPARENCYDISPLAY, on by default), in PNG/SVG exports, and in plots when the page setup's "Plot transparency" is on (PDF alpha, PNG blending, SVG opacity; PLOTTRANSPARENCYOVERRIDE): F14 narrowed | | 2026-10-12 | minor | Hatch and Gradient dialog (HATCH, GRADIENT; HPDLGMODE) and Hatch Edit dialog (HATCHEDIT, right-click, double-click a hatch): F13 narrowed to Preview, Inherit Properties and boundary add/remove in edit mode | | 2026-10-12 | minor | A bare system variable name typed at the command line (ORTHOMODE, GRIDMODE…) runs SETVAR on that variable, as in AutoCAD; U10 narrowed | diff --git a/docs/shx-fonts.md b/docs/shx-fonts.md new file mode 100644 index 000000000..b341d6f83 --- /dev/null +++ b/docs/shx-fonts.md @@ -0,0 +1,75 @@ +# SHX text fonts + +CADCraft reads user-supplied compiled stroke fonts in the `AutoCAD-86 shapes 1.0`, +`shapes 1.1` and Unicode `unifont 1.0` formats. It draws their pen programs as strokes, +using their cap height and pen-end advances. TEXT, MTEXT (including inline font switches, +wrapping and paragraph formatting), attributes, dimensions and table text use the same resolver. +Font names in DXF styles are preserved on save. + +## Supplying fonts + +Put fonts you are entitled to use in a separate directory and set `CADCRAFT_SHXPATH` before +starting CADCraft. It is an OS path list: semicolons on Windows, colons on Unix. For example, +in PowerShell: + +```powershell +$env:CADCRAFT_SHXPATH = 'C:\Users\you\CADFonts' +cadcraft drawing.dxf +``` + +The resolver searches those directories once, without recursion, by case-insensitive file name +or stem (`romans.shx` and `romans`). It does not search CAD installation bundles. Font files +are loaded only when used. Restart after changing the directories or installing a font. +Available SHX fonts take precedence over FONTALT. Missing, invalid or unsupported fonts keep +the existing FONTALT / CADCraft Stroke fallback. The original built-in stroke font now covers +the precomposed Latin accents used in Portuguese, instead of mixing thin letters with filled +TrueType accents. Characters absent from an SHX font retain the per-character fallback. + +The Rust `cadcraft_fonts::shx::register(name, bytes)` API accepts user-supplied bytes on native +and WASM builds. There is no web font-upload UI yet; the web build cannot search local folders. +No proprietary font is embedded, redistributed or included as a test fixture. + +## Supported programs and limits + +The interpreter supports pen up/down, the 16 compact vector directions, scaling, location +push/pop, subshapes, displacements, octant arcs, fractional arcs, bulge arcs and skipping +vertical-only commands during horizontal layout. Arcs are tessellated with up to 128 segments +per revolution. Glyphs are decoded lazily and shared across placements. + +Big fonts, packed multibyte encodings, vertical layout, SHP source loading and linetype shape +elements remain unsupported. Legacy shape fonts map Latin character numbers and the three +dimension symbols; alternate legacy character encodings are not inferred from the drawing. +Unicode SHX text uses bidi display order; OpenType joining is available through TrueType fonts, +not SHX programs. MTEXT still uses the existing attachment and line-pitch rules: this change +does not claim to fix the separate SHX layout offsets reported in #801. + +Hostile-input limits: 8 MiB per file; 8 KiB per glyph program; 32 loaded fonts / 64 MiB of +program bytes; 32 subshape levels and location-stack entries; 65,536 interpreter steps and +8,192 points per decoded glyph; finite coordinates within ±1,000,000 font units. Each font +caches at most 4,096 glyphs / 262,144 points. Directory scans stop after 8,192 entries across +at most 32 directories. Unsupported or over-limit programs fall back without panicking. + +## Format references and verification + +The pen language and metrics come from Autodesk's public +[Shape Descriptions](https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-MAC-Customization/files/GUID-DE941DB5-7044-433C-AA68-2A9AE98A5713.htm), +[Special Codes Reference](https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-MAC-Customization/files/GUID-06832147-16BE-4A66-A6D0-3ADF98DC8228.htm) +and [Text Font Descriptions](https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-MAC-Customization/files/GUID-9BBE5B28-DF02-4EC5-863A-BA04AB6F5EF1.htm). +The compiled container layout was checked against the public MIT-licensed +[ezdxf format reader](https://github.com/mozman/ezdxf/blob/master/src/ezdxf/fonts/shapefile.py), +as a format reference; no library code or font definitions are incorporated. + +Binary headers end in CR/LF and `0x1A`. Shapes files have a little-endian first/last/count +header, an `(id, record length)` index, named records and an `EOF` marker. Unifont has a +little-endian record count, definition id/length and named definition with cap/descent/mode/ +encoding/embedding fields, followed by `(id, length, named program)` records. Unicode subshape +operands use high-byte-first ordering (distinct from little-endian record ids), as reported +in [#801](https://github.com/storytold/cadcraft/issues/801). + +Tests assemble original programs directly in Rust and cover both containers, native advances, +accent subshapes, arcs, pen/stack operations, inline MTEXT, wrapping, DXF Unicode escapes and +round trips. Truncation at every byte, deterministic mutations, cycles and invalid operands +exercise fallback and bounds. Implementation uses the public format references above; automated +tests use only original synthetic fixtures. At the reporter's explicit request, a manual local +check also loaded their installed `romans.shx` while rendering their DXF. Neither that font nor +the drawing is included in the repository or automated tests.