diff --git a/src/entities/mtext.rs b/src/entities/mtext.rs index 8e0a9db66..dd2b1fffc 100644 --- a/src/entities/mtext.rs +++ b/src/entities/mtext.rs @@ -4,7 +4,7 @@ use glam::Vec3; use crate::command::EntityTransform; use crate::entities::common::{edit_prop as edit, ro_prop as ro, square_grip, triangle_grip}; use crate::entities::text_support::{ - resolve_text_style, split_mtext_lines, strip_mtext_codes, word_wrap, + measure_mtext_chars, resolve_text_style, split_mtext_lines, strip_mtext_codes, word_wrap, }; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible}; use crate::scene::acad_to_truck::{TruckEntity, TruckObject}; @@ -145,16 +145,7 @@ fn to_truck(t: &MText, document: &acadrust::CadDocument) -> TruckEntity { }; let line_w = if h_anchor > 0.0 { let scale = t.height as f32 / 9.0 * style_width_factor; - line.chars() - .map(|c| { - if c == ' ' { - return font.word_spacing * scale; - } - font.glyph(c) - .map(|g| (g.advance + font.letter_spacing) * scale) - .unwrap_or(t.height as f32 * 0.6) - }) - .sum() + measure_mtext_chars(line, scale, font) } else { 0.0 }; diff --git a/src/entities/text.rs b/src/entities/text.rs index 7011d47a3..47c94d958 100644 --- a/src/entities/text.rs +++ b/src/entities/text.rs @@ -3,7 +3,7 @@ use glam::Vec3; use crate::command::EntityTransform; use crate::entities::common::{edit_prop as edit, parse_f64, square_grip}; -use crate::entities::text_support::{resolve_text_style, text_local_bounds}; +use crate::entities::text_support::{resolve_dxf_special_chars, resolve_text_style, text_local_bounds}; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible}; use crate::scene::acad_to_truck::{TruckEntity, TruckObject}; use crate::scene::cxf; @@ -71,9 +71,11 @@ fn to_truck(t: &Text, document: &acadrust::CadDocument) -> TruckEntity { (_, VA::Bottom | VA::Middle | VA::Top, Some(a)) => [a.x as f32, a.y as f32], _ => [t.insertion_point.x as f32, t.insertion_point.y as f32], }; + // Strip %%u/%%o for bounds (they add no width); resolve %%d/%%c/%%p for correct advance. + let value_for_bounds = resolve_dxf_special_chars(&t.value); let bounds = text_local_bounds( &font_name, - &t.value, + &value_for_bounds, t.height as f32, width_factor, oblique_angle, @@ -99,6 +101,7 @@ fn to_truck(t: &Text, document: &acadrust::CadDocument) -> TruckEntity { anchor[0] - (anchor_local_x * cos_r - anchor_local_y * sin_r), anchor[1] - (anchor_local_x * sin_r + anchor_local_y * cos_r), ]; + // Pass raw value — tessellate_text_ex resolves %%x codes and emits decoration strokes. let strokes_2d = cxf::tessellate_text_ex( origin, t.height as f32, diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index c1adf5e84..078bdc04c 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -101,6 +101,58 @@ pub fn text_local_bounds( } } +/// Expand DXF `%%x` special-character sequences that appear in both TEXT and MTEXT values: +/// - `%%d` / `%%D` → `°` +/// - `%%p` / `%%P` → `±` +/// - `%%c` / `%%C` → `⌀` +/// - `%%u` / `%%U` → underline toggle (stripped — not renderable with stroke fonts) +/// - `%%o` / `%%O` → overline toggle (stripped) +/// - `%%%%` → `%` +/// - `%%nnn` (3 decimal digits) → Unicode scalar `nnn` +/// Any unrecognised `%%x` is passed through unchanged. +pub fn resolve_dxf_special_chars(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + + while let Some(c) = chars.next() { + if c != '%' || chars.peek() != Some(&'%') { + out.push(c); + continue; + } + chars.next(); // consume second '%' + match chars.peek().map(|c| c.to_ascii_lowercase()) { + Some('d') => { chars.next(); out.push('°'); } + Some('p') => { chars.next(); out.push('±'); } + Some('c') => { chars.next(); out.push('⌀'); } + Some('u') | Some('o') => { chars.next(); } // toggle codes — strip silently + Some('%') => { chars.next(); out.push('%'); } + Some(d) if d.is_ascii_digit() => { + let mut digits = String::with_capacity(3); + for _ in 0..3 { + match chars.peek() { + Some(&ch) if ch.is_ascii_digit() => { digits.push(chars.next().unwrap()); } + _ => break, + } + } + if digits.len() == 3 { + if let Ok(n) = digits.parse::() { + if let Some(ch) = char::from_u32(n) { + out.push(ch); + continue; + } + } + } + out.push('%'); + out.push('%'); + out.push_str(&digits); + } + _ => { out.push('%'); out.push('%'); } + } + } + + out +} + pub fn strip_mtext_codes(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut chars = s.chars().peekable(); @@ -116,7 +168,14 @@ pub fn strip_mtext_codes(s: &str) -> String { chars.next(); out.push(' '); } - Some(c) if "pHWQTACcLOKlfFUu".contains(c) => { + // Decoration toggles — standalone (no args, no ';'). Keep as \X markers + // so tessellate_text_ex can emit underline/overline/strikethrough strokes. + Some('L') | Some('l') | Some('O') | Some('o') | Some('K') | Some('k') => { + out.push('\\'); + out.push(chars.next().unwrap()); + } + // Codes with semicolon-terminated arguments — strip entirely. + Some(c) if "pHWQTACcfFUu".contains(c) => { chars.next(); for c in chars.by_ref() { if c == ';' { @@ -139,7 +198,7 @@ pub fn strip_mtext_codes(s: &str) -> String { } } - out + resolve_dxf_special_chars(&out) } pub fn split_mtext_lines(s: &str) -> Vec { @@ -149,32 +208,42 @@ pub fn split_mtext_lines(s: &str) -> Vec { .collect() } +/// Measure the advance width of an MText line (after strip_mtext_codes), correctly +/// skipping decoration toggle markers (`\L`, `\l`, `\O`, `\o`, `\K`, `\k`). +pub fn measure_mtext_chars(text: &str, scale: f32, font: &cxf::CxfFile) -> f32 { + let mut width = 0.0_f32; + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\\' && matches!(chars.peek(), Some('L' | 'l' | 'O' | 'o' | 'K' | 'k')) { + chars.next(); + continue; + } + if c == ' ' { + width += font.word_spacing * scale; + } else { + width += font + .glyph(c) + .map(|g| (g.advance + font.letter_spacing) * scale) + .unwrap_or(scale * 6.0); + } + } + width +} + pub fn word_wrap(text: &str, max_w: f32, scale: f32, font: &'static cxf::CxfFile) -> Vec { if max_w <= 0.0 || text.is_empty() { return vec![text.to_string()]; } - let glyph_w = |c: char| -> f32 { - if c == ' ' { - return font.word_spacing * scale; - } - font.glyph(c) - .map(|g| (g.advance + font.letter_spacing) * scale) - .unwrap_or(scale * 6.0) - }; - + let space_w = font.word_spacing * scale; let mut lines = Vec::new(); let mut current = String::new(); let mut current_w = 0.0_f32; for word in text.split(' ') { - let word_w: f32 = word.chars().map(glyph_w).sum(); - let space_w = if current.is_empty() { - 0.0 - } else { - glyph_w(' ') - }; - if !current.is_empty() && current_w + space_w + word_w > max_w { + let word_w = measure_mtext_chars(word, scale, font); + let gap = if current.is_empty() { 0.0 } else { space_w }; + if !current.is_empty() && current_w + gap + word_w > max_w { lines.push(std::mem::take(&mut current)); current_w = 0.0; } diff --git a/src/scene/cxf.rs b/src/scene/cxf.rs index ea8ceae84..81b5400d2 100644 --- a/src/scene/cxf.rs +++ b/src/scene/cxf.rs @@ -216,11 +216,12 @@ pub fn tessellate_text_ex( 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 ob = oblique_angle.tan(); 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; + // Transform from glyph-space (cursor_x + gx, gy) to world space. + let xform = |gx: f32, gy: f32, cx: f32| -> [f32; 2] { + let sx = (cx + gx) * scale * wf + gy * scale * ob; let sy = gy * scale; [ origin[0] + sx * cos_r - sy * sin_r, @@ -230,13 +231,122 @@ pub fn tessellate_text_ex( let mut out: Vec> = Vec::new(); let mut cursor_x: f32 = 0.0; + // Decoration toggle state: Some(start_cursor_x) when active. + // Y positions in 9-unit em space (baseline=0, cap=9). + let mut underline: Option = None; + let mut overline: Option = None; + let mut strikethrough: Option = None; + const UNDER_Y: f32 = -1.5; + const OVER_Y: f32 = 10.5; + const STRIKE_Y: f32 = 4.5; - for ch in text.chars() { - if ch == ' ' { + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + // MText decoration toggles: \L on, \l off, \O on, \o off, \K on, \k off. + if ch == '\\' { + match chars.peek().copied() { + Some('L') => { + chars.next(); + if underline.is_none() { underline = Some(cursor_x); } + continue; + } + Some('l') => { + chars.next(); + if let Some(s) = underline.take() { + out.push(vec![xform(s, UNDER_Y, 0.0), xform(cursor_x, UNDER_Y, 0.0)]); + } + continue; + } + Some('O') => { + chars.next(); + if overline.is_none() { overline = Some(cursor_x); } + continue; + } + Some('o') => { + chars.next(); + if let Some(s) = overline.take() { + out.push(vec![xform(s, OVER_Y, 0.0), xform(cursor_x, OVER_Y, 0.0)]); + } + continue; + } + Some('K') => { + chars.next(); + if strikethrough.is_none() { strikethrough = Some(cursor_x); } + continue; + } + Some('k') => { + chars.next(); + if let Some(s) = strikethrough.take() { + out.push(vec![xform(s, STRIKE_Y, 0.0), xform(cursor_x, STRIKE_Y, 0.0)]); + } + continue; + } + _ => {} // not a decoration code — fall through and render as backslash glyph + } + } + + // Resolve DXF %%x special-character sequences inline. + let render_ch: char = if ch == '%' && chars.peek() == Some(&'%') { + chars.next(); // consume second '%' + match chars.peek().map(|c| c.to_ascii_lowercase()) { + Some('d') => { chars.next(); '°' } + Some('p') => { chars.next(); '±' } + Some('c') => { chars.next(); '⌀' } + Some('%') => { chars.next(); '%' } + Some('u') => { + chars.next(); + underline = match underline.take() { + Some(start) => { + out.push(vec![xform(start, UNDER_Y, 0.0), xform(cursor_x, UNDER_Y, 0.0)]); + None + } + None => Some(cursor_x), + }; + continue; + } + Some('o') => { + chars.next(); + overline = match overline.take() { + Some(start) => { + out.push(vec![xform(start, OVER_Y, 0.0), xform(cursor_x, OVER_Y, 0.0)]); + None + } + None => Some(cursor_x), + }; + continue; + } + Some(d) if d.is_ascii_digit() => { + // %%nnn — 3-digit decimal Unicode scalar + let mut digits = String::with_capacity(3); + for _ in 0..3 { + match chars.peek() { + Some(&c) if c.is_ascii_digit() => { digits.push(chars.next().unwrap()); } + _ => break, + } + } + if digits.len() == 3 { + if let Ok(n) = digits.parse::() { + if let Some(c) = char::from_u32(n) { + c + } else { continue; } + } else { continue; } + } else { + // Partial digit sequence — advance as unknown glyph and move on + cursor_x += (6.0 + font.letter_spacing) * wf; + continue; + } + } + _ => { continue; } // unknown %%x — skip silently + } + } else { + ch + }; + + if render_ch == ' ' { cursor_x += font.word_spacing; continue; } - match font.glyph(ch) { + match font.glyph(render_ch) { Some(glyph) => { for stroke in &glyph.strokes { if stroke.len() < 2 { @@ -256,6 +366,18 @@ pub fn tessellate_text_ex( } } } + + // Close any decoration spans that weren't explicitly closed. + if let Some(start) = underline { + out.push(vec![xform(start, UNDER_Y, 0.0), xform(cursor_x, UNDER_Y, 0.0)]); + } + if let Some(start) = overline { + out.push(vec![xform(start, OVER_Y, 0.0), xform(cursor_x, OVER_Y, 0.0)]); + } + if let Some(start) = strikethrough { + out.push(vec![xform(start, STRIKE_Y, 0.0), xform(cursor_x, STRIKE_Y, 0.0)]); + } + out }