Files
OneCAD-Draft/src/entities/text_support.rs
T
Hakan SevenandClaude Opus 4.8 5c15091e74 refactor(text): SDF for all text kinds; remove the legacy stroke text path
SDF is the only text renderer now — the stroke path and its LOD ladder are
gone, and the remaining text kinds render as SDF glyph quads too.

- ATTDEF / ATTRIB: carry a GlyphRun so single-line attribute text renders as
  SDF (multi-line already did via layout_mtext). Tolerance / complex-linetype
  text already SDF.
- Remove `sdf_text_enabled()` (the OCS_TEXT_SDF flag / kill switch) and take
  the SDF path unconditionally everywhere: tessellate Text arm (per-group by
  GlyphRun presence), hit_test fallbacks, render gather, complex_lt, table,
  mleader.
- Delete the text LOD ladder: tess.rs baseline/greek block and the
  block_cache greek/baseline path (emit_greeked_text / emit_text_baseline),
  plus the now-dead helpers text_baseline_points / text_greek_obb_tris /
  text_obb_corners_native / mtext_line_count and the LocalWire
  text_height_local / text_obb_local fields. All text is crisp at every zoom;
  text entities are exempt from the sub-pixel stub cull.
- Fix a latent bug: block-internal SDF text was culled because its LocalWire
  aabb_local was [0,0,0,0] (no points) — fold the glyph-quad extent into
  aabb_local and exempt text_verts-carrying wires from the sub-pixel cull.
- Clear the pre-existing dead-code warnings (TextPlacement.elevation, TabKind,
  MTextRunKind::Tab).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 21:07:00 +03:00

1334 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use acadrust::types::aci_table::aci_to_rgb;
use acadrust::CadDocument;
use crate::scene::convert::acad_to_truck::{GlyphRun, TextStroke};
use crate::scene::text::font_face::Face;
use crate::scene::text::lff;
pub struct ResolvedTextStyle {
pub font_name: String,
pub width_factor: f32,
pub oblique_angle: f32,
pub is_backward: bool,
pub is_upside_down: bool,
}
pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedTextStyle {
let style = document.text_styles.iter().find(|entry| {
entry.name.eq_ignore_ascii_case(style_name)
|| (style_name.trim().is_empty() && entry.name.eq_ignore_ascii_case("Standard"))
});
let mut font_name = if let Some(style) = style {
if !style.true_type_font.trim().is_empty() {
style.true_type_font.trim().to_string()
} else if !style.font_file.trim().is_empty() {
let file = style.font_file.trim();
let basename = file.rsplit(['/', '\\']).next().unwrap_or(file);
let stem = basename.split('.').next().unwrap_or(basename).trim();
if !stem.is_empty() {
stem.to_string()
} else if !style.name.trim().is_empty() {
style.name.trim().to_string()
} else {
"Standard".to_string()
}
} else if !style.name.trim().is_empty() {
style.name.trim().to_string()
} else {
"Standard".to_string()
}
} else if style_name.trim().is_empty() {
"Standard".to_string()
} else {
style_name.trim().to_string()
};
if !lff::is_builtin(&font_name) {
if let Some(canonical) = crate::scene::text::sysfont::canonical_family_name(&font_name) {
font_name = canonical;
}
}
ResolvedTextStyle {
font_name,
width_factor: style.map(|s| s.width_factor as f32).unwrap_or(1.0),
oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0),
is_backward: style.map(|s| s.is_backward()).unwrap_or(false),
is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false),
}
}
pub struct TextLocalBounds {
/// Inked extent (glyph strokes only) — drives vertical alignment, where
/// the cap / baseline geometry is what matters.
pub ink_min: [f32; 2],
pub ink_max: [f32; 2],
/// Pen advance along the baseline, including leading / trailing spaces and
/// inter-glyph spacing. Drives horizontal alignment so spaces in the string
/// keep their width instead of collapsing to the first / last inked glyph.
pub advance: f32,
}
pub fn text_local_bounds(
font_name: &str,
text: &str,
height: f32,
width_factor: f32,
oblique_angle: f32,
) -> Option<TextLocalBounds> {
if text.is_empty() || height <= 0.0 {
return None;
}
let face = Face::resolve(font_name);
let scale = height / 9.0;
let wf = width_factor.abs().clamp(0.01, 100.0);
let ob = oblique_angle.tan();
let mut cursor_x = 0.0_f32;
let mut min_x = f32::INFINITY;
let mut max_x = f32::NEG_INFINITY;
let mut min_y = f32::INFINITY;
let mut max_y = f32::NEG_INFINITY;
for ch in text.chars() {
if ch == ' ' {
cursor_x += face.word_spacing();
continue;
}
match face.glyph(ch) {
Some(glyph) => {
for stroke in &glyph.strokes {
for &[gx, gy] in stroke {
let sx = (cursor_x + gx) * scale * wf + gy * scale * ob;
let sy = gy * scale;
min_x = min_x.min(sx);
max_x = max_x.max(sx);
min_y = min_y.min(sy);
max_y = max_y.max(sy);
}
}
cursor_x += glyph.advance + face.letter_spacing();
}
None => {
cursor_x += 6.0 + face.letter_spacing();
}
}
}
// Pen advance is measured at the baseline, so oblique shear (which skews x
// only by gy) does not enter it. Valid even for an all-space string.
let advance = cursor_x * scale * wf;
if min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite() {
Some(TextLocalBounds {
ink_min: [min_x, min_y],
ink_max: [max_x, max_y],
advance,
})
} else {
None
}
}
/// 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::<u32>() {
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
}
// ──────────────────────────────────────────────────────────────────────────
// Rich MTEXT parser — full inline format-code coverage
//
// Recognised codes (DXF MTEXT inline):
// Escapes: \\ \{ \} \~ \t \P \n \N \U+XXXX \u+XXXX
// Toggles: \L\l \O\o \K\k (underline / overline / strike)
// State: \H<v>[x]; \W<v>[x]; \Q<v>; \T<v>[x]; \A<n>;
// \C<aci>; \c<rgb>;
// \f<name>|b<0/1>|i<0/1>|c<n>|p<n>; \F<file>;
// \M+<n>; \X \S<u><sep><l>;
// Paragraph: \p[xi<v>,l<v>,r<v>,q[lcrjd],t<positions>,s<v>...];
// Scope: { ... } push/pop full state
// ──────────────────────────────────────────────────────────────────────────
/// Paragraph alignment encoded inline via `\p...q[lcrjd]...;`.
/// `Justify` / `Distribute` render as `Left` (full inter-word redistribution
/// is not implemented in the stroke renderer).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ParagraphAlign {
Left,
Center,
Right,
Justify,
Distribute,
}
/// Inline colour override (`\C` ACI or `\c` 24-bit true colour). Resolved to
/// linear RGB at render time via the document's ACI table.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum InlineColor {
Aci(u8),
True([f32; 3]),
}
/// Tab-stop alignment kind (from `\pt<L|C|R><pos>` entries). `Center` / `Right`
/// are DXF-spec kinds the parser does not emit yet (only `Left` is produced).
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TabKind {
Left,
Center,
Right,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TabStop {
pub position: f32,
pub kind: TabKind,
}
/// Per-run formatting state. All fields are multipliers / overrides relative
/// to the entity-level defaults; the renderer composes them with the resolved
/// text style at draw time.
#[derive(Clone, Debug, PartialEq)]
pub struct RunState {
/// Multiplier on entity text height (`\H<v>x;` → ×v; `\H<v>;` → v / entity_h)
pub height_mul: f32,
/// Multiplier on the (signed) style width-factor (`\W<v>;` → set, `\Wx;` → ×)
pub width_mul: f32,
/// Absolute oblique angle override in radians (`\Q<deg>;`)
pub oblique_rad: f32,
/// Tracking multiplier on `font.letter_spacing` (`\T<v>;`)
pub tracking: f32,
/// Vertical alignment of the run within its line box (0=baseline / 1=center
/// / 2=top). Mainly used for fractions and superscript-like layout (`\A`).
pub valign: u8,
/// Font-name override, `None` ⇒ inherit the resolved style font.
pub font: Option<String>,
/// Colour override, `None` ⇒ inherit entity colour.
pub color: Option<InlineColor>,
pub underline: bool,
pub overline: bool,
pub strike: bool,
}
impl Default for RunState {
fn default() -> Self {
Self {
height_mul: 1.0,
width_mul: 1.0,
oblique_rad: 0.0,
tracking: 1.0,
valign: 0,
font: None,
color: None,
underline: false,
overline: false,
strike: false,
}
}
}
#[derive(Clone, Debug)]
pub enum MTextRunKind {
/// Renderable glyph text (DXF specials resolved, decoration markers stripped).
Glyphs(String),
/// `\t` — jump the cursor to the next tab stop (or default tab interval).
/// Handled in the layout matches but not emitted by the parser yet.
#[allow(dead_code)]
Tab,
}
#[derive(Clone, Debug)]
pub struct MTextRun {
pub kind: MTextRunKind,
pub state: RunState,
}
/// One paragraph of MTEXT after parsing. Each line is a sequence of runs that
/// share text content + a snapshot of formatting state, plus paragraph-level
/// layout (alignment, indents, tab stops). `\P` / `\n` / `\N` start a new
/// line; paragraph properties carry forward until the next `\p...;` block.
#[derive(Clone, Debug, Default)]
pub struct MTextLine {
pub runs: Vec<MTextRun>,
pub align: Option<ParagraphAlign>,
pub indent_first: f32,
pub indent_left: f32,
pub indent_right: f32,
pub tab_stops: Vec<TabStop>,
}
impl MTextLine {
pub fn is_blank(&self) -> bool {
self.runs.iter().all(|r| match &r.kind {
MTextRunKind::Glyphs(t) => t.trim().is_empty(),
MTextRunKind::Tab => false,
})
}
}
/// Font-name stem: drop any path and extension, so a `\F` font path like
/// `C:\\fonts\\arial.ttf` and a plain `\f` name `arial` resolve to the same font.
fn font_stem(name: &str) -> String {
name.rsplit(['/', '\\'])
.next()
.unwrap_or(name)
.split('.')
.next()
.unwrap_or(name)
.to_string()
}
/// Parse an MTEXT string into the layout's `Vec<MTextLine>`, using acadrust's
/// structured `mtext_format::parse_mtext` — OCS keeps only the layout engine
/// (`layout_mtext` and callers read `MTextLine`/`RunState`), not a second MTEXT
/// inline parser.
///
/// Representation notes:
/// - DXF `%%d`/`%%p`/`%%c` arrive already resolved to Unicode from acadrust;
/// the stroke tokenizer treats those as ordinary glyphs.
/// - Stacking (`\S`) is flattened inline to `num<sep>den` (`^` for limit, else
/// `/`) since the stroke path has no fraction layout.
/// - `\H`: a relative factor (`\Hx`) applies directly; an absolute height is
/// divided by the entity height. See `acadrust::…::MTextScalar`.
pub fn adapt_mtext_paragraphs(
s: &str,
entity_height: f32,
trim_blank_edges: bool,
) -> Vec<MTextLine> {
use acadrust::entities::mtext_format::{
parse_mtext, MTextColor, MTextLineAlignment, MTextParagraphAlignment, MTextScalar,
SpanProperties, StackingType,
};
let entity_height = entity_height.max(1e-6);
let doc = parse_mtext(s, true);
fn map_align(a: MTextParagraphAlignment) -> Option<ParagraphAlign> {
match a {
MTextParagraphAlignment::Left => Some(ParagraphAlign::Left),
MTextParagraphAlignment::Right => Some(ParagraphAlign::Right),
MTextParagraphAlignment::Center => Some(ParagraphAlign::Center),
MTextParagraphAlignment::Justified => Some(ParagraphAlign::Justify),
MTextParagraphAlignment::Distributed => Some(ParagraphAlign::Distribute),
MTextParagraphAlignment::Default => None,
}
}
fn color_of(p: &SpanProperties) -> Option<InlineColor> {
if let Some((r, g, b)) = p.color_rgb {
return Some(InlineColor::True([
r as f32 / 255.0,
g as f32 / 255.0,
b as f32 / 255.0,
]));
}
match p.color {
Some(MTextColor::Index(n)) => Some(InlineColor::Aci(n.min(255) as u8)),
_ => None,
}
}
let mut lines: Vec<MTextLine> = Vec::new();
for para in &doc.paragraphs {
let props = &para.properties;
let mut line = MTextLine {
align: props.alignment.and_then(map_align),
indent_first: props.first_line_indent.unwrap_or(0.0) as f32,
indent_left: props.left_margin.unwrap_or(0.0) as f32,
indent_right: props.right_margin.unwrap_or(0.0) as f32,
tab_stops: props
.tab_stops
.iter()
.map(|&p| TabStop {
position: p as f32,
kind: TabKind::Left,
})
.collect(),
runs: Vec::new(),
};
for span in &para.spans {
let p = &span.properties;
let state = RunState {
// Relative `\H…x;` is a factor applied directly; absolute `\H…;`
// is a drawing-unit height resolved against the entity height.
height_mul: match p.height {
Some(MTextScalar::Factor(f)) => f as f32,
Some(MTextScalar::Absolute(a)) => a as f32 / entity_height,
None => 1.0,
},
width_mul: p.width_factor.map(|w| w as f32).unwrap_or(1.0),
oblique_rad: p
.oblique_angle
.map(|q| (q as f32).to_radians())
.unwrap_or(0.0),
tracking: p.tracking.map(|t| t as f32).unwrap_or(1.0),
valign: match p.line_align {
Some(MTextLineAlignment::Middle) => 1,
Some(MTextLineAlignment::Top) => 2,
_ => 0,
},
font: p.font.as_ref().map(|f| font_stem(&f.name)),
color: color_of(p),
underline: p.stroke.underline(),
overline: p.stroke.overline(),
strike: p.stroke.strikethrough(),
};
let text = match &span.stacking {
Some(st) => {
let sep = match st.stacking_type {
StackingType::Limit => '^',
_ => '/',
};
let mut t = st.numerator.clone();
if !st.denominator.is_empty() {
t.push(sep);
t.push_str(&st.denominator);
}
t
}
None => span.text.clone(),
};
if text.is_empty() {
continue;
}
line.runs.push(MTextRun {
kind: MTextRunKind::Glyphs(text),
state,
});
}
lines.push(line);
}
if !trim_blank_edges {
return lines;
}
let start = lines.iter().position(|l| !l.is_blank()).unwrap_or(0);
let end = lines
.iter()
.rposition(|l| !l.is_blank())
.map(|i| i + 1)
.unwrap_or(0);
lines[start..end].to_vec()
}
// Legacy MText helpers (`strip_mtext_codes`, `split_mtext_lines`,
// `measure_mtext_chars`, `word_wrap`) were removed when every text-bearing
// entity switched to the run-aware pipeline below. The pipeline now owns
// per-run width measurement and word-wrap; MTEXT inline parsing now comes from
// acadrust via `adapt_mtext_paragraphs`. The supported surface for callers is
// `adapt_mtext_paragraphs`, `layout_mtext`, `mtext_line_count`,
// `text_local_bounds`, and `resolve_dxf_special_chars`.
// ──────────────────────────────────────────────────────────────────────────────
// Shared MText layout / render pipeline
// ──────────────────────────────────────────────────────────────────────────────
//
// `layout_mtext` is the entry point used by every text-bearing entity that
// stores MText-formatted content (MTEXT, MLEADER text content, TABLE cell,
// ATTRIB / ATTDEF with `mtext_flag` set, and DIMENSION `text_override` when
// it carries inline codes).
//
// The pipeline mirrors the MTEXT renderer:
// 1. Parse — via `adapt_mtext_paragraphs` (acadrust `parse_mtext`).
// 2. Atomise — turn each MTextLine.runs into a flat sequence of atoms
// (Word / Space / Tab) so the wrapper operates at break boundaries
// while keeping per-character formatting state.
// 3. Wrap — accumulate atoms into sub-lines using paragraph indents and
// tab stops; each Tab jumps the cursor to the next user-defined stop
// (or a 4-em default grid).
// 4. Render — for each sub-line: pick paragraph alignment + indent, walk
// atoms left → right, emit one TextStroke per Word using the atom's
// RunState (height / width / oblique / tracking / font / colour /
// decorations / valign).
//
// In addition to the strokes, the helper returns enough geometry (line
// widths, line height, v_offset, h_anchor) for the caller to draw a frame /
// background rectangle, run a low-detail LOD path, or compute snap bounds.
#[derive(Clone)]
pub enum AtomKind {
Word(String),
Space,
Tab,
}
#[derive(Clone)]
pub struct LayoutAtom {
pub kind: AtomKind,
pub state: RunState,
}
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) -> 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(
text: &str,
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);
let face = Face::resolve(&font_name);
let mut w = 0.0_f32;
for ch in text.chars() {
w += match face.glyph(ch) {
Some(g) => (g.advance + face.letter_spacing() * state.tracking) * scale,
None => (6.0 + face.letter_spacing() * state.tracking) * scale,
};
}
w
}
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
}
pub fn atom_width(atom: &LayoutAtom, entity_h: f32, base_wf: f32, base_font: &str) -> f32 {
match &atom.kind {
AtomKind::Word(t) => measure_word(t, &atom.state, entity_h, base_wf, base_font),
AtomKind::Space => measure_space(&atom.state, entity_h, base_wf, base_font),
AtomKind::Tab => 0.0,
}
}
/// Cursor position after a `\t` atom: advance to the next user-defined tab
/// stop that lies past `cur_x`, falling back to a 4-em default grid when no
/// stop is reached.
pub fn next_tab_position(
cur_x: f32,
tab_stops: &[TabStop],
indent_left: f32,
entity_h: f32,
) -> f32 {
let local = cur_x - indent_left;
for ts in tab_stops {
if ts.position > local + 1e-4 {
return indent_left + ts.position;
}
}
let default_interval = entity_h * 4.0;
let n = (local / default_interval).floor() + 1.0;
indent_left + n * default_interval
}
/// Break a flat MText paragraph atom stream into wrap-fit sub-lines.
pub fn wrap_paragraph(
atoms: Vec<LayoutAtom>,
rect_w: f32,
indent_first: f32,
indent_left: f32,
indent_right: f32,
tab_stops: &[TabStop],
entity_h: f32,
base_wf: f32,
base_font: &str,
) -> Vec<Vec<LayoutAtom>> {
if rect_w <= 0.0 {
return vec![atoms];
}
let mut sublines: Vec<Vec<LayoutAtom>> = Vec::new();
let mut cur: Vec<LayoutAtom> = Vec::new();
let mut cur_w = 0.0_f32;
let mut subline_idx: usize = 0;
let line_start_x = |idx: usize| if idx == 0 { indent_first } else { indent_left };
let line_max_w = |idx: usize| (rect_w - indent_right - line_start_x(idx)).max(0.0);
for atom in atoms {
match &atom.kind {
AtomKind::Word(_) => {
let w = atom_width(&atom, entity_h, base_wf, base_font);
let max_w = line_max_w(subline_idx);
if !cur.is_empty() && cur_w + w > max_w {
while matches!(cur.last().map(|a| &a.kind), Some(AtomKind::Space)) {
cur.pop();
}
sublines.push(std::mem::take(&mut cur));
cur_w = 0.0;
subline_idx += 1;
}
cur.push(atom);
cur_w += w;
}
AtomKind::Space => {
if cur.is_empty() {
continue;
}
cur_w += atom_width(&atom, entity_h, base_wf, base_font);
cur.push(atom);
}
AtomKind::Tab => {
let start_x = line_start_x(subline_idx);
let new_w = next_tab_position(cur_w + start_x, tab_stops, indent_left, entity_h)
- start_x;
let max_w = line_max_w(subline_idx);
if new_w > max_w && !cur.is_empty() {
sublines.push(std::mem::take(&mut cur));
cur_w = 0.0;
subline_idx += 1;
} else {
cur.push(atom);
cur_w = new_w.min(max_w);
}
}
}
}
if !cur.is_empty() {
sublines.push(cur);
}
if sublines.is_empty() {
sublines.push(Vec::new());
}
sublines
}
pub fn line_total_width(
atoms: &[LayoutAtom],
entity_h: f32,
base_wf: f32,
base_font: &str,
line_start_x: f32,
indent_left: f32,
tab_stops: &[TabStop],
) -> f32 {
let mut x = line_start_x;
for atom in atoms {
match atom.kind {
AtomKind::Tab => {
x = next_tab_position(x, tab_stops, indent_left, entity_h);
}
_ => x += atom_width(atom, entity_h, base_wf, base_font),
}
}
x - line_start_x
}
pub fn resolve_inline_color(c: &InlineColor) -> Option<[f32; 3]> {
match c {
InlineColor::Aci(idx) => aci_to_rgb(*idx).map(|(r, g, b)| {
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]
}),
InlineColor::True(rgb) => Some(*rgb),
}
}
/// Wrap a run's glyph text with MTEXT decoration markers so lff's
/// `tessellate_text_run` emits the underline / overline / strikethrough
/// strokes for us — keeps decoration geometry in one place rather than
/// duplicating the y-position constants.
fn decorated(text: &str, state: &RunState) -> String {
if !(state.underline || state.overline || state.strike) {
return text.to_string();
}
let mut s = String::with_capacity(text.len() + 6);
if state.underline {
s.push_str("\\L");
}
if state.overline {
s.push_str("\\O");
}
if state.strike {
s.push_str("\\K");
}
s.push_str(text);
if state.underline {
s.push_str("\\l");
}
if state.overline {
s.push_str("\\o");
}
if state.strike {
s.push_str("\\k");
}
s
}
#[derive(Clone, Copy, Debug)]
pub enum MTextVAnchor {
/// Block top edge at insertion (first line's cap = insertion.y).
Top,
/// Block midpoint at insertion.
Middle,
/// Block bottom edge at insertion (last line's baseline = insertion.y).
Bottom,
/// MLEADER `MiddleOfTopLine` — first line's vertical centre at insertion.
MiddleOfTopLine,
/// MLEADER `MiddleOfBottomLine` — last line's vertical centre at insertion.
MiddleOfBottomLine,
/// MLEADER `BottomOfTopLineUnderline*` — first line's baseline at insertion.
BottomOfTopLine,
}
/// Render inputs for [`layout_mtext`]. The caller resolves the text style
/// once and feeds the entity's geometry; the helper handles the entire
/// parse → wrap → render pipeline and returns both the rendered strokes and
/// the layout metrics (so callers can also draw frames / fills / LOD
/// substitutes from the same numbers).
pub struct MTextRenderOpts<'a> {
/// Raw MText-formatted value (the string the parser walks).
pub value: &'a str,
/// World-space insertion point — strokes are emitted with this as their
/// origin (after the per-sub-line rotation + cursor offset).
pub insertion: [f64; 3],
/// Entity text height in world units.
pub height: f32,
/// Box width for word-wrap (0 → no wrap; lines flow at the insertion).
pub rect_w: f32,
/// Final rotation in radians (already composed with `is_upside_down`).
pub rotation: f32,
/// Resolved style (font + width factor + oblique). Width factor sign
/// honours `is_backward` (negative → mirror).
pub style: &'a ResolvedTextStyle,
/// Horizontal anchor of the text block at the insertion point:
/// 0.0 = left, 0.5 = center, 1.0 = right.
pub attach_h_anchor: f32,
/// Vertical anchor of the text block at the insertion point.
pub v_anchor: MTextVAnchor,
/// DXF code 44 — multiplier on the default 5/3-em baseline gap.
pub line_spacing_factor: f32,
/// `true` when the entity is laid out top-to-bottom (DXF code 71 = 2).
pub vertical_text: bool,
/// When true, `layout_mtext` also fills `MTextLayout::glyph_boxes` with
/// one world-space box per visible character (used by the MText editor's
/// click-to-select preview). Off in the hot render path.
pub want_glyph_boxes: bool,
}
/// One selectable character in the laid-out text: its world-space AABB plus
/// the running index of visible characters (in reading order) so the editor
/// can map a clicked box back to an offset in the value.
#[derive(Clone, Copy, Debug)]
pub struct GlyphBox {
pub vis: usize,
pub xmin: f32,
pub xmax: f32,
pub ymin: f32,
pub ymax: f32,
}
/// Output of [`layout_mtext`]: stroke groups + the geometry the caller
/// needs for surrounding chrome (frame / fill / LOD baseline-or-rect).
pub struct MTextLayout {
/// One TextStroke per Word atom (Tab / Space contribute only to cursor
/// advance). The `color` field on each stroke carries the inline
/// `\C` / `\c` override when one was set, otherwise `None`.
pub strokes: Vec<TextStroke>,
/// Per-sub-line width in entity-local (pre-rotation) units. Includes
/// any trailing whitespace that survived the trim — kept in sync with
/// the cursor advance so the alignment numbers and the visible glyphs
/// line up.
pub line_widths: Vec<f32>,
/// Sub-line count (≥ 1; an entity with an empty value still reports 1).
pub line_count: usize,
/// Baseline-to-baseline gap used when stepping between sub-lines.
pub line_height: f32,
/// Y of the first sub-line's baseline relative to the insertion point
/// (in the entity-local, pre-rotation frame).
pub v_offset: f32,
/// One world-space AABB per visible character — only populated when
/// `MTextRenderOpts::want_glyph_boxes` is set.
pub glyph_boxes: Vec<GlyphBox>,
}
/// Lay out and render an MText-formatted value, returning the stroke
/// groups plus the layout metrics needed by callers that draw chrome
/// (text frame, background fill, low-detail LOD substitutes) around the
/// text block.
pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
let base_font_name = opts.style.font_name.clone();
let base_font = Face::resolve(&base_font_name);
let base_wf_abs = opts.style.width_factor.max(0.01);
let base_wf = if opts.style.is_backward { -base_wf_abs } else { base_wf_abs };
let base_oblique = opts.style.oblique_angle;
let entity_h = opts.height;
let rect_w = opts.rect_w;
// ── 1. Parse ─────────────────────────────────────────────────────────
// The editor (want_glyph_boxes) keeps blank edges so a freshly typed
// trailing newline yields an empty paragraph the caret can sit on.
let paragraphs = adapt_mtext_paragraphs(opts.value, entity_h, !opts.want_glyph_boxes);
// ── 2. Atomise + wrap each paragraph into sub-lines ──────────────────
struct SubLine {
atoms: Vec<LayoutAtom>,
align: Option<ParagraphAlign>,
indent_first: f32,
indent_left: f32,
indent_right: f32,
tab_stops: Vec<TabStop>,
is_first_in_paragraph: bool,
}
let mut sub_lines: Vec<SubLine> = Vec::new();
for para in &paragraphs {
let mut atoms: Vec<LayoutAtom> = Vec::new();
for run in &para.runs {
match &run.kind {
MTextRunKind::Glyphs(text) => {
let mut word = String::new();
for ch in text.chars() {
if ch == ' ' || ch == '\u{00A0}' {
if !word.is_empty() {
atoms.push(LayoutAtom {
kind: AtomKind::Word(std::mem::take(&mut word)),
state: run.state.clone(),
});
}
atoms.push(LayoutAtom {
kind: AtomKind::Space,
state: run.state.clone(),
});
} else {
word.push(ch);
}
}
if !word.is_empty() {
atoms.push(LayoutAtom {
kind: AtomKind::Word(word),
state: run.state.clone(),
});
}
}
MTextRunKind::Tab => {
atoms.push(LayoutAtom {
kind: AtomKind::Tab,
state: run.state.clone(),
});
}
}
}
// Trim leading + trailing Space atoms so line_w / cursor_start agree
// on the paragraph's visible content. Without this a stray trailing
// space measures wider than it draws and centring / right-alignment
// is off by half a space-width.
//
// Skipped when emitting glyph boxes (the MText editor) so a space the
// user just typed at the end keeps a selectable box and the caret can
// sit after it.
if !opts.want_glyph_boxes {
let first_word = atoms
.iter()
.position(|a| !matches!(a.kind, AtomKind::Space))
.unwrap_or(atoms.len());
atoms.drain(..first_word);
while matches!(atoms.last().map(|a| &a.kind), Some(AtomKind::Space)) {
atoms.pop();
}
}
let wrapped = wrap_paragraph(
atoms,
rect_w,
para.indent_first,
para.indent_left,
para.indent_right,
&para.tab_stops,
entity_h,
base_wf,
&base_font_name,
);
for (idx, atoms) in wrapped.into_iter().enumerate() {
sub_lines.push(SubLine {
atoms,
align: para.align,
indent_first: para.indent_first,
indent_left: para.indent_left,
indent_right: para.indent_right,
tab_stops: para.tab_stops.clone(),
is_first_in_paragraph: idx == 0,
});
}
}
if sub_lines.is_empty() {
sub_lines.push(SubLine {
atoms: Vec::new(),
align: None,
indent_first: 0.0,
indent_left: 0.0,
indent_right: 0.0,
tab_stops: Vec::new(),
is_first_in_paragraph: true,
});
}
// ── 3. Block geometry (line spacing, attachment, rotation) ───────────
let n_lines = sub_lines.len().max(1) as f32;
let ls_factor = if opts.line_spacing_factor > 0.0 {
opts.line_spacing_factor
} else {
1.0
};
// DXF code 44 — multiplier on the default 5/3-em baseline-to-baseline gap.
let line_h = entity_h * ls_factor * (5.0 / 3.0) * base_font.line_spacing();
let h = entity_h;
let v_offset = match opts.v_anchor {
MTextVAnchor::Top => -h,
MTextVAnchor::Middle => ((n_lines - 1.0) * line_h - h) * 0.5,
MTextVAnchor::Bottom => (n_lines - 1.0) * line_h,
MTextVAnchor::MiddleOfTopLine => -h * 0.5,
MTextVAnchor::MiddleOfBottomLine => (n_lines - 1.0) * line_h - h * 0.5,
MTextVAnchor::BottomOfTopLine => 0.0,
};
let attach_h_anchor = opts.attach_h_anchor;
let box_left = -attach_h_anchor * rect_w;
let rot = opts.rotation;
let (cos_r, sin_r) = (rot.cos(), rot.sin());
let ins_x = opts.insertion[0];
let ins_y = opts.insertion[1];
// ── 4. Render each sub-line ──────────────────────────────────────────
let mut all_strokes: Vec<TextStroke> = Vec::new();
let mut line_widths: Vec<f32> = Vec::with_capacity(sub_lines.len());
let mut glyph_boxes: Vec<GlyphBox> = Vec::new();
let mut vis: usize = 0;
// Transform an entity-local point to world space (mirrors the stroke
// origin maths) so glyph boxes line up with the drawn glyphs.
let to_world = |line_base_x: f32, line_base_y: f32, lx: f32, ly: f32| -> (f32, f32) {
let wdx = lx * cos_r - ly * sin_r;
let wdy = lx * sin_r + ly * cos_r;
(
ins_x as f32 + line_base_x + wdx,
ins_y as f32 + line_base_y + wdy,
)
};
for (i, sub) in sub_lines.iter().enumerate() {
let li = i as f32;
let (line_base_x, line_base_y) = if opts.vertical_text {
let col_offset = li * entity_h * 1.2;
(
col_offset * cos_r + v_offset * (-sin_r),
col_offset * sin_r + v_offset * cos_r,
)
} else {
let ly = -(li * line_h) + v_offset;
(ly * (-sin_r), ly * cos_r)
};
let content_left = if rect_w > 0.0 {
box_left
+ if sub.is_first_in_paragraph {
sub.indent_first
} else {
sub.indent_left
}
} else {
0.0
};
let content_right = if rect_w > 0.0 {
box_left + rect_w - sub.indent_right
} else {
0.0
};
let line_anchor: f32 = match sub.align {
Some(ParagraphAlign::Left)
| Some(ParagraphAlign::Justify)
| Some(ParagraphAlign::Distribute) => 0.0,
Some(ParagraphAlign::Center) => 0.5,
Some(ParagraphAlign::Right) => 1.0,
None => attach_h_anchor,
};
let line_w = line_total_width(
&sub.atoms,
entity_h,
base_wf,
&base_font_name,
0.0,
sub.indent_left,
&sub.tab_stops,
);
line_widths.push(line_w);
let cursor_start = if rect_w > 0.0 {
let content_w = (content_right - content_left).max(0.0);
content_left + (content_w - line_w) * line_anchor
} else if line_anchor > 0.0 {
-line_w * line_anchor
} else {
0.0
};
let line_max_h = sub
.atoms
.iter()
.map(|a| a.state.height_mul * entity_h)
.fold(entity_h, f32::max);
// A paragraph break (explicit `\n` / `\P`) that started this line gets
// a zero-width caret slot at the line start, so the MText editor can
// place the caret on a fresh/empty line.
if opts.want_glyph_boxes && i > 0 && sub.is_first_in_paragraph {
let (ax, ay) = to_world(line_base_x, line_base_y, cursor_start, 0.0);
let (_, by) = to_world(line_base_x, line_base_y, cursor_start, entity_h);
glyph_boxes.push(GlyphBox {
vis,
xmin: ax,
xmax: ax,
ymin: ay.min(by),
ymax: ay.max(by),
});
vis += 1;
}
let mut cursor_x = cursor_start;
for atom in &sub.atoms {
match &atom.kind {
AtomKind::Word(text) => {
let run_h = atom.state.height_mul * entity_h;
let signed_wf =
base_wf.signum() * atom.state.width_mul * base_wf.abs();
let oblique = base_oblique + atom.state.oblique_rad;
let font_name = resolve_font(&atom.state, &base_font_name);
let tracking = atom.state.tracking;
let valign_dy = match atom.state.valign {
1 => (line_max_h - run_h) * 0.5,
2 => line_max_h - run_h,
_ => 0.0,
};
let color = atom.state.color.as_ref().and_then(resolve_inline_color);
let body = decorated(text, &atom.state);
let lx = cursor_x;
let ly = valign_dy;
let world_dx = lx * cos_r - ly * sin_r;
let world_dy = lx * sin_r + ly * cos_r;
let origin: [f64; 2] = [
ins_x + (line_base_x + world_dx) as f64,
ins_y + (line_base_y + world_dy) as f64,
];
let (strokes, fill_tris) = lff::tessellate_text_run(
[0.0, 0.0],
run_h,
rot,
signed_wf,
oblique,
tracking,
&font_name,
&body,
);
all_strokes.push(TextStroke {
strokes,
origin,
color,
fill_tris,
// `text` is the plain word (specials already resolved,
// no decoration markers); `run_h` is raw like the strokes.
run: Some(GlyphRun {
text: text.clone(),
font: font_name.to_string(),
height: run_h,
rotation: rot,
width_factor: signed_wf,
oblique,
tracking,
}),
});
if opts.want_glyph_boxes {
// 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 mut cx = cursor_x;
for ch in text.chars() {
let adv = match face.glyph(ch) {
Some(g) => {
(g.advance + face.letter_spacing() * tracking) * scale
}
None => (6.0 + face.letter_spacing() * tracking) * scale,
};
let (ax, ay) = to_world(line_base_x, line_base_y, cx, ly);
let (bx, by) = to_world(line_base_x, line_base_y, cx + adv, ly + run_h);
glyph_boxes.push(GlyphBox {
vis,
xmin: ax.min(bx),
xmax: ax.max(bx),
ymin: ay.min(by),
ymax: ay.max(by),
});
vis += 1;
cx += adv;
}
}
cursor_x +=
measure_word(text, &atom.state, entity_h, base_wf, &base_font_name);
}
AtomKind::Space => {
let adv = measure_space(&atom.state, entity_h, base_wf, &base_font_name);
if opts.want_glyph_boxes {
let run_h = atom.state.height_mul * entity_h;
let (ax, ay) = to_world(line_base_x, line_base_y, cursor_x, 0.0);
let (bx, by) =
to_world(line_base_x, line_base_y, cursor_x + adv, run_h);
glyph_boxes.push(GlyphBox {
vis,
xmin: ax.min(bx),
xmax: ax.max(bx),
ymin: ay.min(by),
ymax: ay.max(by),
});
vis += 1;
}
cursor_x += adv;
}
AtomKind::Tab => {
cursor_x = next_tab_position(
cursor_x,
&sub.tab_stops,
sub.indent_left,
entity_h,
);
}
}
}
}
MTextLayout {
strokes: all_strokes,
line_widths,
line_count: sub_lines.len(),
line_height: line_h,
v_offset,
glyph_boxes,
}
}
#[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::<usize>() + 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"
);
}
}
#[cfg(test)]
mod adapter_tests {
use super::*;
/// Glyph text + state of the first renderable run.
fn first_run(lines: &[MTextLine]) -> (String, RunState) {
for l in lines {
for r in &l.runs {
if let MTextRunKind::Glyphs(t) = &r.kind {
return (t.clone(), r.state.clone());
}
}
}
panic!("no glyph run produced");
}
#[test]
fn plain_text_is_default_state() {
let lines = adapt_mtext_paragraphs("Hello", 2.5, true);
assert_eq!(lines.len(), 1);
let (t, st) = first_run(&lines);
assert_eq!(t, "Hello");
assert_eq!(st, RunState::default());
}
#[test]
fn relative_height_is_a_factor() {
// `\H2x;` multiplies the current height → height_mul 2.0, independent of
// the entity height. (This is the case that needed acadrust's MTextScalar.)
let (_, st) = first_run(&adapt_mtext_paragraphs("\\H2x;big", 2.5, true));
assert!((st.height_mul - 2.0).abs() < 1e-4, "got {}", st.height_mul);
}
#[test]
fn absolute_height_resolves_against_entity_height() {
// `\H5;` is an absolute height → divided by the entity height (2.5) → 2.0.
let (_, st) = first_run(&adapt_mtext_paragraphs("\\H5;abs", 2.5, true));
assert!((st.height_mul - 2.0).abs() < 1e-4, "got {}", st.height_mul);
}
#[test]
fn color_font_oblique_valign_decoration() {
let (_, c) = first_run(&adapt_mtext_paragraphs("\\C1;red", 2.5, true));
assert_eq!(c.color, Some(InlineColor::Aci(1)));
let (_, f) = first_run(&adapt_mtext_paragraphs("\\fArial;x", 2.5, true));
assert_eq!(f.font.as_deref(), Some("Arial"));
let (_, q) = first_run(&adapt_mtext_paragraphs("\\Q15;x", 2.5, true));
assert!((q.oblique_rad - 15f32.to_radians()).abs() < 1e-4);
let (_, w) = first_run(&adapt_mtext_paragraphs("\\W1.5;x", 2.5, true));
assert!((w.width_mul - 1.5).abs() < 1e-4);
let (_, a) = first_run(&adapt_mtext_paragraphs("\\A1;x", 2.5, true));
assert_eq!(a.valign, 1);
let (_, d) = first_run(&adapt_mtext_paragraphs("\\Lunder\\l", 2.5, true));
assert!(d.underline);
}
#[test]
fn stacking_flattens_like_the_legacy_parser() {
let (t, _) = first_run(&adapt_mtext_paragraphs("\\S1/2;", 2.5, true));
assert_eq!(t, "1/2");
let (t, _) = first_run(&adapt_mtext_paragraphs("\\S1^2;", 2.5, true));
assert_eq!(t, "1^2");
// Diagonal `#` renders with a `/` separator.
let (t, _) = first_run(&adapt_mtext_paragraphs("\\S1#2;", 2.5, true));
assert_eq!(t, "1/2");
}
#[test]
fn paragraph_breaks_split_lines() {
let lines = adapt_mtext_paragraphs("a\\Pb\\Pc", 2.5, true);
assert_eq!(lines.len(), 3);
}
}