//! CXF vector font and shape parser. //! //! Handles both linetype shape files (ltypeshp.cxf) and full font files //! (Standard.cxf, NormalLatin2.cxf, etc.) with a single parser. //! //! Supported CXF geometry commands: //! L x0,y0,x1,y1 — single line segment //! PL x,y,b,... — open polyline (x, y, bulge triplets) //! PLC x,y,b,... — closed polyline //! A cx,cy,r,start,end — arc CCW, angles in degrees //! AR cx,cy,r,start,end — arc CW, angles in degrees //! //! Entry type decision rule: //! [XXXX] LABEL //! - If LABEL is exactly one character AND equals char::from_u32(hex) //! -> stored as a Unicode glyph (font use-case, e.g. [0041] A) //! - Otherwise -> stored as a named shape (ltypeshp, e.g. [0042] BAT) //! //! Glyph coordinate space: //! Origin = left baseline corner. //! Cap height = 9 glyph units. //! Scale factor = text_height / 9.0. use std::collections::HashMap; use std::f32::consts::TAU; use std::sync::OnceLock; // ── Embedded assets ─────────────────────────────────────────────────────── const SRC_LTYPESHP: &str = include_str!("../../assets/fonts/ltypeshp.cxf"); const SRC_STANDARD: &str = include_str!("../../assets/fonts/Standard.cxf"); const SRC_NORMAL: &str = include_str!("../../assets/fonts/NormalLatin2.cxf"); const SRC_COURIER: &str = include_str!("../../assets/fonts/CourierCad.cxf"); const SRC_SANS: &str = include_str!("../../assets/fonts/SansNS.cxf"); const SRC_ITALIC: &str = include_str!("../../assets/fonts/ItalicT.cxf"); const SRC_GOTHIC: &str = include_str!("../../assets/fonts/GothITT.cxf"); const SRC_CURSIVE: &str = include_str!("../../assets/fonts/Cursive.cxf"); const SRC_SCRIPTC: &str = include_str!("../../assets/fonts/ScriptS.cxf"); // ── Public types ────────────────────────────────────────────────────────── /// One glyph or shape: a list of open 2-D polyline strokes in local units. #[derive(Clone, Default)] pub struct CxfGlyph { /// Each stroke is a list of [x, y] points forming a connected polyline. pub strokes: Vec>, /// Advance width in glyph units (rightmost X of all strokes). pub advance: f32, } /// A parsed CXF file. #[derive(Clone)] pub struct CxfFile { /// Font/file name from the `# Name:` header. pub name: String, /// Extra space between characters in glyph units (`# LetterSpacing:`). pub letter_spacing: f32, /// Space character width in glyph units (`# WordSpacing:`). pub word_spacing: f32, /// Line height multiplier relative to text height (`# LineSpacingFactor:`). pub line_spacing: f32, /// Named shapes keyed by uppercase name — used for ltypeshp. shapes: HashMap, /// Unicode glyphs keyed by character — used for font files. glyphs: HashMap, } impl CxfFile { /// Look up a linetype shape by name (case-insensitive). pub fn shape(&self, name: &str) -> Option<&CxfGlyph> { self.shapes.get(&name.to_ascii_uppercase()) } /// Look up a font glyph by Unicode character. pub fn glyph(&self, c: char) -> Option<&CxfGlyph> { self.glyphs.get(&c) } } // ── Static registries ───────────────────────────────────────────────────── static SHAPES: OnceLock = OnceLock::new(); static FONTS: OnceLock> = OnceLock::new(); fn shapes_file() -> &'static CxfFile { SHAPES.get_or_init(|| parse(SRC_LTYPESHP)) } fn fonts_map() -> &'static HashMap { FONTS.get_or_init(|| { let mut map = HashMap::new(); let mut insert = |src: &str, aliases: &[&str]| { let f = parse(src); map.insert(f.name.to_ascii_uppercase(), f.clone()); for alias in aliases { map.insert(alias.to_ascii_uppercase(), f.clone()); } }; insert(SRC_STANDARD, &["STANDARD", "TXT", "TXT.SHX", "SIMPLEX"]); insert( SRC_NORMAL, &["NORMAL", "NORMALLATIN2", "ROMANS", "ROMANS.SHX"], ); insert(SRC_COURIER, &["COURIER", "COURIERCAD", "COURIERCAD"]); insert(SRC_SANS, &["SANS", "SANSNS"]); insert(SRC_ITALIC, &["ITALIC", "ITALICT"]); insert(SRC_GOTHIC, &["GOTHIC", "GOTHITT"]); insert(SRC_CURSIVE, &["CURSIVE"]); insert(SRC_SCRIPTC, &["SCRIPTS", "SCRIPTC", "SCRIPT"]); map }) } // ── Public API — linetype shapes ────────────────────────────────────────── /// Look up a linetype shape by name (case-insensitive). Used by `complex_lt.rs`. pub fn get(name: &str) -> Option<&'static CxfGlyph> { shapes_file().shape(name) } // ── Public API — text tessellation ──────────────────────────────────────── /// Return a font by name (case-insensitive). /// Falls back to Standard → Normal → any available font. pub fn get_font(name: &str) -> &'static CxfFile { let map = fonts_map(); let key = name.to_ascii_uppercase(); map.get(&key) .or_else(|| map.get("STANDARD")) .or_else(|| map.get("NORMAL")) .or_else(|| map.values().next()) .expect("at least one CXF font must be embedded") } /// Tessellate a text string into world-space 2-D strokes. /// /// - `origin` — insertion point [world_x, world_y] /// - `height` — text height in world units (cap height = 9 glyph units) /// - `rotation` — rotation angle in radians (CCW positive) /// - `font_name` — e.g. "Standard", "Romans", "Courier" (case-insensitive) /// - `text` — the string to render /// /// Returns one Vec<[f32;2]> per stroke. Strokes from different glyphs are /// separate entries. #[allow(dead_code)] pub fn tessellate_text( origin: [f32; 2], height: f32, rotation: f32, font_name: &str, text: &str, ) -> Vec> { if text.is_empty() || height <= 0.0 { return vec![]; } let font = get_font(font_name); let scale = height / 9.0; // CXF cap height = 9 glyph units let (cos_r, sin_r) = (rotation.cos(), rotation.sin()); let xform = |gx: f32, gy: f32, cursor_x: f32| -> [f32; 2] { let lx = (cursor_x + gx) * scale; let ly = gy * scale; [ origin[0] + lx * cos_r - ly * sin_r, origin[1] + lx * sin_r + ly * cos_r, ] }; let mut out: Vec> = Vec::new(); let mut cursor_x: f32 = 0.0; for ch in text.chars() { if ch == ' ' { cursor_x += font.word_spacing; continue; } match font.glyph(ch) { Some(glyph) => { for stroke in &glyph.strokes { if stroke.len() < 2 { continue; } out.push( stroke .iter() .map(|&[gx, gy]| xform(gx, gy, cursor_x)) .collect(), ); } cursor_x += glyph.advance + font.letter_spacing; } None => { cursor_x += 6.0 + font.letter_spacing; } } } out } pub fn tessellate_text_ex( origin: [f32; 2], height: f32, rotation: f32, width_factor: f32, oblique_angle: f32, font_name: &str, text: &str, ) -> Vec> { if text.is_empty() || height <= 0.0 { return vec![]; } let font = get_font(font_name); let scale = height / 9.0; let wf = width_factor.clamp(0.01, 100.0); let ob = oblique_angle.tan(); // shear multiplier let (cos_r, sin_r) = (rotation.cos(), rotation.sin()); let xform = |gx: f32, gy: f32, cursor_x: f32| -> [f32; 2] { let sx = (cursor_x + gx) * scale * wf + gy * scale * ob; let sy = gy * scale; [ origin[0] + sx * cos_r - sy * sin_r, origin[1] + sx * sin_r + sy * cos_r, ] }; let mut out: Vec> = Vec::new(); let mut cursor_x: f32 = 0.0; for ch in text.chars() { if ch == ' ' { cursor_x += font.word_spacing; continue; } match font.glyph(ch) { Some(glyph) => { for stroke in &glyph.strokes { if stroke.len() < 2 { continue; } out.push( stroke .iter() .map(|&[gx, gy]| xform(gx, gy, cursor_x)) .collect(), ); } cursor_x += (glyph.advance + font.letter_spacing) * wf; } None => { cursor_x += (6.0 + font.letter_spacing) * wf; } } } out } /// Measure the width of a rendered text string in font glyph units × scale. /// Returns the total advance width in world units (height-independent: divide by height for /// a normalised ratio, or use directly when `height` is already the desired em size). pub fn measure_text(text: &str, height: f32, width_factor: f32, font_name: &str) -> f32 { if text.is_empty() || height <= 0.0 { return 0.0; } let font = get_font(font_name); let scale = height / 9.0; let wf = width_factor.clamp(0.01, 100.0); let mut cursor_x: f32 = 0.0; for ch in text.chars() { if ch == ' ' { cursor_x += font.word_spacing; } else if let Some(glyph) = font.glyph(ch) { cursor_x += glyph.advance + font.letter_spacing; } else { cursor_x += 6.0 + font.letter_spacing; } } cursor_x * scale * wf } // ── Parser ──────────────────────────────────────────────────────────────── fn parse(src: &str) -> CxfFile { let mut file = CxfFile { name: String::from("Unknown"), letter_spacing: 3.0, word_spacing: 6.75, line_spacing: 1.0, shapes: HashMap::new(), glyphs: HashMap::new(), }; let mut cur_char: Option = None; let mut cur_name: Option = None; let mut cur_strokes: Vec> = Vec::new(); let mut cur_max_x: f32 = 0.0; for raw in src.lines() { let line = raw.trim(); if line.is_empty() { continue; } // ── Header ──────────────────────────────────────────────────────── if line.starts_with('#') { if let Some(rest) = line.strip_prefix("# Name:") { file.name = rest.trim().to_string(); } else if let Some(rest) = line.strip_prefix("# LetterSpacing:") { if let Ok(v) = rest.trim().parse::() { file.letter_spacing = v; } } else if let Some(rest) = line.strip_prefix("# WordSpacing:") { if let Ok(v) = rest.trim().parse::() { file.word_spacing = v; } } else if let Some(rest) = line.strip_prefix("# LineSpacingFactor:") { if let Ok(v) = rest.trim().parse::() { file.line_spacing = v; } } continue; } // ── Entry header: `[XXXX] LABEL` ───────────────────────────────── if line.starts_with('[') { flush_entry( &mut file, &mut cur_char, &mut cur_name, &mut cur_strokes, &mut cur_max_x, ); let hex_end = match line.find(']') { Some(i) => i, None => continue, }; let hex_str = line[1..hex_end].trim(); let label = line[hex_end + 1..].trim(); if let Ok(cp) = u32::from_str_radix(hex_str, 16) { let cp_char = char::from_u32(cp); // Glyph: label is exactly the one character that the codepoint maps to. // Shape: label is a word (different from the codepoint char) or empty // with a non-printable / non-matching codepoint. let is_glyph = match cp_char { Some(ch) => label.chars().count() == 1 && label.chars().next() == Some(ch), None => false, }; if is_glyph { cur_char = cp_char; cur_name = None; } else if !label.is_empty() { cur_name = Some(label.to_string()); cur_char = None; } else if let Some(ch) = cp_char { // No label but valid codepoint — treat as glyph. cur_char = Some(ch); cur_name = None; } } continue; } // ── Geometry (only inside a valid entry) ────────────────────────── if cur_char.is_none() && cur_name.is_none() { continue; } let up = line.to_ascii_uppercase(); if up.starts_with("PLC ") { if let Some(s) = parse_triplet_poly(&line[4..], true) { update_max_x(&s, &mut cur_max_x); if !s.is_empty() { cur_strokes.push(s); } } } else if up.starts_with("PL ") { if let Some(s) = parse_triplet_poly(&line[3..], false) { update_max_x(&s, &mut cur_max_x); if !s.is_empty() { cur_strokes.push(s); } } } else if up.starts_with("AR ") { if let Some(s) = parse_arc(&line[3..], true) { update_max_x(&s, &mut cur_max_x); cur_strokes.push(s); } } else if up.starts_with("A ") { if let Some(s) = parse_arc(&line[2..], false) { update_max_x(&s, &mut cur_max_x); cur_strokes.push(s); } } else if up.starts_with("L ") { let vals: Vec = line[2..] .split(',') .filter_map(|s| s.trim().parse().ok()) .collect(); if vals.len() >= 4 { let s = vec![[vals[0], vals[1]], [vals[2], vals[3]]]; update_max_x(&s, &mut cur_max_x); cur_strokes.push(s); } } } flush_entry( &mut file, &mut cur_char, &mut cur_name, &mut cur_strokes, &mut cur_max_x, ); file } fn flush_entry( file: &mut CxfFile, cur_char: &mut Option, cur_name: &mut Option, cur_strokes: &mut Vec>, cur_max_x: &mut f32, ) { let strokes = std::mem::take(cur_strokes); let advance = *cur_max_x; *cur_max_x = 0.0; let glyph = CxfGlyph { strokes, advance }; if let Some(ch) = cur_char.take() { file.glyphs.insert(ch, glyph); } else if let Some(name) = cur_name.take() { file.shapes.insert(name.to_ascii_uppercase(), glyph); } } fn update_max_x(stroke: &[[f32; 2]], max_x: &mut f32) { for &[x, _] in stroke { if x > *max_x { *max_x = x; } } } // ── Arc tessellation ────────────────────────────────────────────────────── /// Parse `cx,cy,radius,start_deg,end_deg` and tessellate into a polyline. /// `reversed = true` → CW sweep (AR), `false` → CCW (A). fn parse_arc(data: &str, reversed: bool) -> Option> { let vals: Vec = data .split(',') .filter_map(|s| s.trim().parse().ok()) .collect(); if vals.len() < 5 { return None; } let (cx, cy, r) = (vals[0], vals[1], vals[2]); let sa = vals[3].to_radians(); let ea = vals[4].to_radians(); if r <= 0.0 { return None; } let sweep = if reversed { let mut s = ea - sa; if s > 0.0 { s -= TAU; } if s.abs() < 1e-6 { s = -TAU; } s } else { let mut s = ea - sa; if s < 0.0 { s += TAU; } if s.abs() < 1e-6 { s = TAU; } s }; let segs = ((sweep.abs() / TAU) * 64.0).ceil().clamp(4.0, 64.0) as u32; let mut pts = Vec::with_capacity(segs as usize + 1); for i in 0..=segs { let t = sa + sweep * (i as f32 / segs as f32); pts.push([cx + r * t.cos(), cy + r * t.sin()]); } Some(pts) } // ── Polyline (bulge) tessellation ───────────────────────────────────────── /// Parse `(x, y, bulge)` triplets from a PL / PLC command. fn parse_triplet_poly(data: &str, closed: bool) -> Option> { let vals: Vec = data .split(',') .filter_map(|s| s.trim().parse().ok()) .collect(); let n = vals.len(); if n < 6 || n % 3 != 0 { return None; } let triplets: Vec<([f32; 2], f32)> = (0..n) .step_by(3) .map(|i| ([vals[i], vals[i + 1]], vals[i + 2])) .collect(); let tc = triplets.len(); let mut pts: Vec<[f32; 2]> = Vec::with_capacity(tc * 6); for (idx, &(start, bulge)) in triplets.iter().enumerate() { let end = if idx + 1 < tc { triplets[idx + 1].0 } else if closed && tc > 0 { triplets[0].0 } else { if pts.last() != Some(&start) { pts.push(start); } break; }; if pts.last() != Some(&start) { pts.push(start); } if bulge.abs() < 1e-7 { pts.push(end); } else { bulge_arc(&mut pts, start, end, bulge, 16); } } if closed { if let Some(&first) = pts.first() { if pts.last() != Some(&first) { pts.push(first); } } } Some(pts) } /// Approximate a bulge arc from `p0` to `p1`. /// `bulge = tan(included_angle / 4)`: positive = CCW, negative = CW. fn bulge_arc(out: &mut Vec<[f32; 2]>, p0: [f32; 2], p1: [f32; 2], bulge: f32, n: usize) { let (x1, y1) = (p0[0], p0[1]); let (x2, y2) = (p1[0], p1[1]); let theta = 4.0 * bulge.atan(); let dx = x2 - x1; let dy = y2 - y1; let d = (dx * dx + dy * dy).sqrt(); if d < 1e-10 { out.push(p1); return; } let half_theta = theta * 0.5; let r = (d * 0.5) / half_theta.sin().abs(); let px = -dy / d; let py = dx / d; let d_mc = r * half_theta.cos(); let sign = if bulge > 0.0 { 1.0_f32 } else { -1.0_f32 }; // Center lies to the LEFT of chord for CCW (bulge>0) and to the RIGHT for CW (bulge<0). // px = -dy/d is the left-perpendicular unit vector of the chord. // CCW (sign=+1): center = mid + perp * d_mc (left) // CW (sign=-1): center = mid - perp * d_mc (right) let cx = (x1 + x2) * 0.5 + sign * px * d_mc; let cy = (y1 + y2) * 0.5 + sign * py * d_mc; let a1 = (y1 - cy).atan2(x1 - cx); let a2 = (y2 - cy).atan2(x2 - cx); // With the corrected center, raw sweep (a2-a1) is already in the right // range for most arcs. A single clamp is sufficient — no loop needed. let mut sweep = a2 - a1; if bulge > 0.0 { // CCW: sweep must be in (0, TAU]. if sweep <= 0.0 { sweep += TAU; } } else { // CW: sweep must be in [-TAU, 0). if sweep >= 0.0 { sweep -= TAU; } } if sweep.abs() < 1e-7 { sweep = if bulge > 0.0 { TAU } else { -TAU }; } for i in 1..=n { let t = i as f32 / n as f32; let a = a1 + sweep * t; out.push([cx + r * a.cos(), cy + r * a.sin()]); } }