From 06bb6b54ff84e57bfb7d688c2624c4dc23705ced Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Sun, 28 Jun 2026 18:44:38 +0100 Subject: [PATCH] fix(text): canonicalise TTF filename font refs in MTEXT inline overrides resolve_font previously returned &str, passing raw stems like "arial" verbatim to Face::resolve. System font lookup requires the canonical family name ("Arial"), so inline \f/\F overrides from block-nested MTEXT were silently unresolvable and fell through to no geometry. Change resolve_font to return Cow<'a, str>: - no override -> Borrowed(base), zero allocation - LFF built-in -> Borrowed(font), zero allocation - system font resolved -> Owned(canonical), one allocation - unresolvable -> Borrowed(base), falls back to style font All 4 call sites updated to &font_name (&Cow derefs to &str). Adds two unit tests covering the fallback and TTF-stem-to-family-name paths. Adds integration test verifying block-nested MTEXT produces geometry and correctly separates outline/fill wires. --- src/entities/text_support.rs | 94 +++++++++++++++++++++++++++++++++--- tests/text_font_rendering.rs | 64 ++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 tests/text_font_rendering.rs diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index f579ffa0..c8943453 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -880,8 +880,18 @@ pub fn run_scale(state: &RunState, entity_h: f32, base_wf: f32) -> f32 { (state.height_mul * entity_h / 9.0) * (state.width_mul * base_wf.abs()) } -pub fn resolve_font<'a>(state: &'a RunState, base: &'a str) -> &'a str { - state.font.as_deref().unwrap_or(base) +pub fn resolve_font<'a>(state: &'a RunState, base: &'a str) -> std::borrow::Cow<'a, str> { + let Some(font) = state.font.as_deref().map(str::trim).filter(|f| !f.is_empty()) else { + return std::borrow::Cow::Borrowed(base); + }; + if lff::is_builtin(font) { + return std::borrow::Cow::Borrowed(font); + } + if let Some(canonical) = crate::scene::text::sysfont::canonical_family_name(font) { + std::borrow::Cow::Owned(canonical) + } else { + std::borrow::Cow::Borrowed(base) + } } pub fn measure_word( @@ -893,7 +903,7 @@ pub fn measure_word( ) -> f32 { let scale = run_scale(state, entity_h, base_wf); let font_name = resolve_font(state, base_font); - let face = Face::resolve(font_name); + let face = Face::resolve(&font_name); let mut w = 0.0_f32; for ch in text.chars() { w += match face.glyph(ch) { @@ -907,7 +917,7 @@ pub fn measure_word( pub fn measure_space(state: &RunState, entity_h: f32, base_wf: f32, base_font: &str) -> f32 { let scale = run_scale(state, entity_h, base_wf); let font_name = resolve_font(state, base_font); - Face::resolve(font_name).word_spacing() * scale + Face::resolve(&font_name).word_spacing() * scale } pub fn atom_width(atom: &LayoutAtom, entity_h: f32, base_wf: f32, base_font: &str) -> f32 { @@ -1431,7 +1441,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { signed_wf, oblique, tracking, - font_name, + &font_name, &body, ); all_strokes.push(TextStroke { @@ -1444,7 +1454,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { // Per-character boxes, advancing exactly as // `measure_word` does so they track the glyphs. let scale = run_scale(&atom.state, entity_h, base_wf); - let face = Face::resolve(font_name); + let face = Face::resolve(&font_name); let mut cx = cursor_x; for ch in text.chars() { let adv = match face.glyph(ch) { @@ -1835,3 +1845,75 @@ pub(crate) fn text_greek_obb_tris( tris } +#[cfg(test)] +mod tests { + use super::*; + + fn style(font_name: &str) -> ResolvedTextStyle { + ResolvedTextStyle { + font_name: font_name.to_string(), + width_factor: 1.0, + oblique_angle: 0.0, + is_backward: false, + is_upside_down: false, + } + } + + fn stroke_point_count(layout: &MTextLayout) -> usize { + layout + .strokes + .iter() + .map(|s| s.strokes.iter().map(Vec::len).sum::() + s.fill_tris.len()) + .sum() + } + + #[test] + fn unresolved_inline_font_falls_back_to_style_font() { + let base = "txt"; + let mut state = RunState::default(); + state.font = Some("__definitely_not_an_installed_font__".to_string()); + + assert_eq!(resolve_font(&state, base), base); + + let layout = layout_mtext(&MTextRenderOpts { + value: "{\\f__definitely_not_an_installed_font__|b0|i0|c0|p34;Storage Units}", + insertion: [0.0, 0.0, 0.0], + height: 2.5, + rect_w: 0.0, + rotation: 0.0, + style: &style(base), + attach_h_anchor: 0.0, + v_anchor: MTextVAnchor::Top, + line_spacing_factor: 1.0, + vertical_text: false, + want_glyph_boxes: false, + }); + + assert!( + stroke_point_count(&layout) > 0, + "unresolvable inline \\f should render through the style font" + ); + } + + #[test] + fn block_style_font_name_from_ttf_file_renders_mtext() { + let layout = layout_mtext(&MTextRenderOpts { + value: "FERRAGAMO", + insertion: [0.0, 0.0, 0.0], + height: 20.0, + rect_w: 0.0, + rotation: 0.0, + style: &style("arial"), + attach_h_anchor: 0.0, + v_anchor: MTextVAnchor::Top, + line_spacing_factor: 1.0, + vertical_text: false, + want_glyph_boxes: false, + }); + + assert!( + stroke_point_count(&layout) > 0, + "style font derived from arial.ttf should produce drawable block text" + ); + } +} diff --git a/tests/text_font_rendering.rs b/tests/text_font_rendering.rs new file mode 100644 index 00000000..968c1e6f --- /dev/null +++ b/tests/text_font_rendering.rs @@ -0,0 +1,64 @@ +use acadrust::entities::{Insert, MText}; +use acadrust::tables::{BlockRecord, TextStyle}; +use acadrust::types::Vector3; +use acadrust::{CadDocument, EntityType, Handle}; +use OpenCADStudio::scene::cache::block_cache::{expand_insert, BlockCache}; + +fn drawable_point_count(wires: &[OpenCADStudio::scene::WireModel]) -> usize { + wires + .iter() + .map(|w| w.points.iter().filter(|p| p[0].is_finite()).count() + w.fill_tris.len()) + .sum() +} + +#[test] +fn block_nested_mtext_uses_its_style_font() { + let mut doc = CadDocument::new(); + + let mut style = TextStyle::new("SHOP"); + style.font_file = "arial.ttf".to_string(); + doc.text_styles.add(style).unwrap(); + + let br_h = Handle::new(doc.next_handle()); + let mut br = BlockRecord::new("LABEL_BLOCK"); + br.handle = br_h; + doc.block_records.add(br).unwrap(); + + let mut mtext = MText::with_value("FERRAGAMO", Vector3::new(0.0, 0.0, 0.0)); + mtext.style = "SHOP".to_string(); + mtext.height = 20.0; + mtext.rectangle_width = 0.0; + let mut sub = EntityType::MText(mtext); + sub.common_mut().owner_handle = br_h; + doc.add_entity(sub).unwrap(); + + let ins = Insert::new("LABEL_BLOCK", Vector3::new(100.0, 50.0, 0.0)); + doc.add_entity(EntityType::Insert(ins.clone())).unwrap(); + let cache = BlockCache::build(&doc, 1.0, [0.0, 0.0, 0.0, 1.0]); + let wires = expand_insert( + &cache, + &ins, + Handle::new(999), + [1.0, 1.0, 1.0, 1.0], + 0.0, + [0.0; 8], + 1.0, + false, + 1.0, + None, + None, + false, + [0.0, 0.0, 0.0, 1.0], + ) + .expect("block defn is cached"); + + assert!( + drawable_point_count(&wires) > 0, + "block-nested MTEXT should render through its text style font" + ); + + assert!( + wires.iter().all(|w| w.points.is_empty() || w.fill_tris.is_empty()), + "outline and fill wires should be separate for correct GPU classification" + ); +}