feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
use acadrust::types::aci_table::aci_to_rgb;
|
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 <noreply@anthropic.com>
2026-07-07 21:07:00 +03:00
|
|
|
|
use acadrust::CadDocument;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
|
2026-07-07 00:53:19 +03:00
|
|
|
|
use crate::scene::convert::acad_to_truck::{GlyphRun, TextStroke};
|
2026-06-16 11:33:46 +03:00
|
|
|
|
use crate::scene::text::font_face::Face;
|
|
|
|
|
|
use crate::scene::text::lff;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
|
|
|
|
|
|
pub struct ResolvedTextStyle {
|
|
|
|
|
|
pub font_name: String,
|
|
|
|
|
|
pub width_factor: f32,
|
|
|
|
|
|
pub oblique_angle: f32,
|
2026-04-26 22:10:32 +03:00
|
|
|
|
pub is_backward: bool,
|
|
|
|
|
|
pub is_upside_down: bool,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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"))
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-25 18:17:01 +01:00
|
|
|
|
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() {
|
2026-03-22 14:56:27 +03:00
|
|
|
|
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()
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-25 18:17:01 +01:00
|
|
|
|
if !lff::is_builtin(&font_name) {
|
|
|
|
|
|
if let Some(canonical) = crate::scene::text::sysfont::canonical_family_name(&font_name) {
|
|
|
|
|
|
font_name = canonical;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
ResolvedTextStyle {
|
2026-06-26 00:27:40 +03:00
|
|
|
|
font_name,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
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),
|
2026-04-26 22:10:32 +03:00
|
|
|
|
is_backward: style.map(|s| s.is_backward()).unwrap_or(false),
|
|
|
|
|
|
is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false),
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 18:47:45 +03:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
pub fn text_local_bounds(
|
|
|
|
|
|
font_name: &str,
|
|
|
|
|
|
text: &str,
|
|
|
|
|
|
height: f32,
|
|
|
|
|
|
width_factor: f32,
|
|
|
|
|
|
oblique_angle: f32,
|
2026-06-22 18:47:45 +03:00
|
|
|
|
) -> Option<TextLocalBounds> {
|
2026-03-22 14:56:27 +03:00
|
|
|
|
if text.is_empty() || height <= 0.0 {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 11:18:17 +03:00
|
|
|
|
let face = Face::resolve(font_name);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
let scale = height / 9.0;
|
2026-04-26 22:10:32 +03:00
|
|
|
|
let wf = width_factor.abs().clamp(0.01, 100.0);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
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 == ' ' {
|
2026-06-16 11:18:17 +03:00
|
|
|
|
cursor_x += face.word_spacing();
|
2026-03-22 14:56:27 +03:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-06-16 11:18:17 +03:00
|
|
|
|
match face.glyph(ch) {
|
2026-03-22 14:56:27 +03:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-16 11:18:17 +03:00
|
|
|
|
cursor_x += glyph.advance + face.letter_spacing();
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
None => {
|
2026-06-16 11:18:17 +03:00
|
|
|
|
cursor_x += 6.0 + face.letter_spacing();
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 18:47:45 +03:00
|
|
|
|
// 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;
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
if min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite() {
|
2026-06-22 18:47:45 +03:00
|
|
|
|
Some(TextLocalBounds {
|
|
|
|
|
|
ink_min: [min_x, min_y],
|
|
|
|
|
|
ink_max: [max_x, max_y],
|
|
|
|
|
|
advance,
|
|
|
|
|
|
})
|
2026-03-22 14:56:27 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 21:57:51 +03:00
|
|
|
|
/// 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()) {
|
2026-05-12 10:36:15 +03:00
|
|
|
|
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('%');
|
|
|
|
|
|
}
|
2026-04-26 21:57:51 +03:00
|
|
|
|
Some(d) if d.is_ascii_digit() => {
|
|
|
|
|
|
let mut digits = String::with_capacity(3);
|
|
|
|
|
|
for _ in 0..3 {
|
|
|
|
|
|
match chars.peek() {
|
2026-05-12 10:36:15 +03:00
|
|
|
|
Some(&ch) if ch.is_ascii_digit() => {
|
|
|
|
|
|
digits.push(chars.next().unwrap());
|
|
|
|
|
|
}
|
2026-04-26 21:57:51 +03:00
|
|
|
|
_ => 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);
|
|
|
|
|
|
}
|
2026-05-12 10:36:15 +03:00
|
|
|
|
_ => {
|
|
|
|
|
|
out.push('%');
|
|
|
|
|
|
out.push('%');
|
|
|
|
|
|
}
|
2026-04-26 21:57:51 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:42:22 +03:00
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 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]),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 <noreply@anthropic.com>
2026-07-07 21:07:00 +03:00
|
|
|
|
/// 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)]
|
2026-05-21 23:42:22 +03:00
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
|
|
|
|
pub enum TabKind {
|
|
|
|
|
|
Left,
|
|
|
|
|
|
Center,
|
|
|
|
|
|
Right,
|
2026-07-19 00:45:26 +03:00
|
|
|
|
/// Dot-aligned: the text after the tab places its decimal point on the stop.
|
|
|
|
|
|
Decimal,
|
2026-05-21 23:42:22 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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,
|
feat(mtext): structured editor with selection, copy, and visible bold
Rewrite the in-app MTEXT editor to run on acadrust's structured
MTextDocument model end-to-end: load via parse_mtext, edit a flat Cell
model, serialize via to_mtext_string. Retires the hand-rolled format-code
string handling, the parallel visible_spans parser, the composed_value
prefix, and the Edit-raw-code tab.
Selection and copy:
- double-click selects the word, triple-click selects all (click-counting)
- Ctrl+A selects all text
- Ctrl+C copies the selected text to the clipboard
Formatting:
- per-selection Bold/Italic/Underline/Overline/Strike/Case/Alignment
- Bold renders as a visibly thicker SDF pen, keyed (family, char, bold)
through the whole text pipeline; the resolved style font name is stamped
so the \f...|b1; code survives serialization
- per-selection font and color changes
- editor color dropdown reuses the Properties panel color selector
Global toolbar defaults (font/color/oblique/width/tracking) fold onto the
document as real span properties instead of a leading code prefix; a
trailing empty paragraph is preserved so Enter at the end keeps its line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:18:50 +03:00
|
|
|
|
/// Bold run — rendered with a wider SDF pen (thicker strokes).
|
|
|
|
|
|
pub bold: bool,
|
2026-05-21 23:42:22 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
feat(mtext): structured editor with selection, copy, and visible bold
Rewrite the in-app MTEXT editor to run on acadrust's structured
MTextDocument model end-to-end: load via parse_mtext, edit a flat Cell
model, serialize via to_mtext_string. Retires the hand-rolled format-code
string handling, the parallel visible_spans parser, the composed_value
prefix, and the Edit-raw-code tab.
Selection and copy:
- double-click selects the word, triple-click selects all (click-counting)
- Ctrl+A selects all text
- Ctrl+C copies the selected text to the clipboard
Formatting:
- per-selection Bold/Italic/Underline/Overline/Strike/Case/Alignment
- Bold renders as a visibly thicker SDF pen, keyed (family, char, bold)
through the whole text pipeline; the resolved style font name is stamped
so the \f...|b1; code survives serialization
- per-selection font and color changes
- editor color dropdown reuses the Properties panel color selector
Global toolbar defaults (font/color/oblique/width/tracking) fold onto the
document as real span properties instead of a leading code prefix; a
trailing empty paragraph is preserved so Enter at the end keeps its line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:18:50 +03:00
|
|
|
|
bold: false,
|
2026-05-21 23:42:22 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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).
|
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 <noreply@anthropic.com>
2026-07-07 21:07:00 +03:00
|
|
|
|
/// Handled in the layout matches but not emitted by the parser yet.
|
|
|
|
|
|
#[allow(dead_code)]
|
2026-05-21 23:42:22 +03:00
|
|
|
|
Tab,
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
/// A `\S` stack: `numerator` over `denominator`, sharing one horizontal
|
|
|
|
|
|
/// slot. `bar` draws the fraction rule between them (`\S1/2;`); without it
|
|
|
|
|
|
/// the two just sit one above the other (`\S1^2;`, a limit).
|
|
|
|
|
|
Stack {
|
|
|
|
|
|
numerator: String,
|
|
|
|
|
|
denominator: String,
|
|
|
|
|
|
bar: bool,
|
|
|
|
|
|
},
|
2026-05-21 23:42:22 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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>,
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
/// Set when this line opened a new column (`\N`, not `\P`). Layout starts a
|
|
|
|
|
|
/// fresh column here when the entity defines any; with no columns it reads
|
|
|
|
|
|
/// as a plain line break, which is what `\N` degrades to anyway.
|
|
|
|
|
|
pub starts_column: bool,
|
2026-07-18 23:19:01 +03:00
|
|
|
|
/// Blank space above the paragraph, in drawing units (`\p…b#;`). Widens the
|
|
|
|
|
|
/// gap before the paragraph's first line so stacked paragraphs read apart.
|
|
|
|
|
|
pub space_before: f32,
|
|
|
|
|
|
/// Blank space below the paragraph, in drawing units (`\p…a#;`).
|
|
|
|
|
|
pub space_after: f32,
|
|
|
|
|
|
/// Per-paragraph line spacing (`\psm#` multiple / `\pse#` exact), overriding
|
|
|
|
|
|
/// the entity-level factor for this paragraph's own lines.
|
|
|
|
|
|
pub line_spacing: Option<ParaLineSpacing>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A paragraph's own line-spacing rule from `\psm#` / `\pse#`, overriding the
|
|
|
|
|
|
/// entity's DXF-44 factor for that paragraph.
|
|
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
|
|
|
|
pub enum ParaLineSpacing {
|
|
|
|
|
|
/// Multiple of the default baseline gap (`sm#`).
|
|
|
|
|
|
Multiple(f32),
|
|
|
|
|
|
/// Exact baseline-to-baseline distance in drawing units (`se#`).
|
|
|
|
|
|
Exact(f32),
|
2026-05-21 23:42:22 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
MTextRunKind::Stack { .. } => false,
|
2026-05-21 23:42:22 +03:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-07 00:04:11 +03:00
|
|
|
|
/// 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()
|
2026-05-21 23:42:22 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 00:04:11 +03:00
|
|
|
|
/// 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.
|
2026-05-21 23:42:22 +03:00
|
|
|
|
///
|
2026-07-07 00:04:11 +03:00
|
|
|
|
/// 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(
|
2026-05-31 13:14:35 +03:00
|
|
|
|
s: &str,
|
|
|
|
|
|
entity_height: f32,
|
|
|
|
|
|
trim_blank_edges: bool,
|
|
|
|
|
|
) -> Vec<MTextLine> {
|
2026-07-07 00:04:11 +03:00
|
|
|
|
use acadrust::entities::mtext_format::{
|
2026-07-18 23:19:01 +03:00
|
|
|
|
parse_mtext, MTextColor, MTextLineAlignment, MTextLineSpacing, MTextParagraphAlignment,
|
|
|
|
|
|
MTextScalar, ParagraphProperties, SpanProperties, StackingType,
|
2026-07-07 00:04:11 +03:00
|
|
|
|
};
|
2026-05-21 23:42:22 +03:00
|
|
|
|
|
2026-07-07 00:04:11 +03:00
|
|
|
|
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,
|
2026-05-21 23:42:22 +03:00
|
|
|
|
}
|
2026-07-07 00:04:11 +03:00
|
|
|
|
}
|
|
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-21 23:42:22 +03:00
|
|
|
|
|
2026-07-17 18:26:29 +03:00
|
|
|
|
// A `\p...;` block sets the paragraph's alignment/indents/tabs and stays in
|
|
|
|
|
|
// force until the next `\p` — a paragraph opened by a bare `\P` / `\N`
|
|
|
|
|
|
// inherits it. The parser records only the literal codes (a paragraph with
|
|
|
|
|
|
// no `\p` carries none), so persistence is applied here: without it, the
|
|
|
|
|
|
// second line of a centred or right column (`\pxqc;col2\P≡`) would fall back
|
|
|
|
|
|
// to the left edge while its heading stayed aligned.
|
|
|
|
|
|
let has_own_props = |p: &ParagraphProperties| {
|
|
|
|
|
|
p.alignment
|
|
|
|
|
|
.as_ref()
|
|
|
|
|
|
.is_some_and(|a| !matches!(a, MTextParagraphAlignment::Default))
|
|
|
|
|
|
|| p.first_line_indent.is_some()
|
|
|
|
|
|
|| p.left_margin.is_some()
|
|
|
|
|
|
|| p.right_margin.is_some()
|
|
|
|
|
|
|| !p.tab_stops.is_empty()
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-07 00:04:11 +03:00
|
|
|
|
let mut lines: Vec<MTextLine> = Vec::new();
|
2026-07-17 18:26:29 +03:00
|
|
|
|
let mut carried: Option<(Option<ParagraphAlign>, f32, f32, f32, Vec<TabStop>)> = None;
|
2026-07-18 23:19:01 +03:00
|
|
|
|
// Paragraph spacing (`b`/`a`) and line spacing (`sm`/`se`) are sticky
|
|
|
|
|
|
// per-field: a `\p…;` block that names some codes leaves the rest at their
|
|
|
|
|
|
// previous value, so spacing set once (`\pxb0.25;`) applies to every later
|
|
|
|
|
|
// paragraph until re-specified. The alignment/indent carry above is
|
|
|
|
|
|
// all-or-nothing, which would drop spacing at the first `\p` that only
|
|
|
|
|
|
// touches alignment — so spacing is tracked independently here.
|
|
|
|
|
|
let mut carried_sb = 0.0_f32;
|
|
|
|
|
|
let mut carried_sa = 0.0_f32;
|
|
|
|
|
|
let mut carried_ls: Option<ParaLineSpacing> = None;
|
2026-07-07 00:04:11 +03:00
|
|
|
|
for para in &doc.paragraphs {
|
|
|
|
|
|
let props = ¶.properties;
|
2026-07-18 23:19:01 +03:00
|
|
|
|
if let Some(v) = props.spacing_before {
|
|
|
|
|
|
carried_sb = v as f32;
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(v) = props.spacing_after {
|
|
|
|
|
|
carried_sa = v as f32;
|
|
|
|
|
|
}
|
|
|
|
|
|
match props.line_spacing {
|
|
|
|
|
|
Some(MTextLineSpacing::Multiple(m)) => {
|
|
|
|
|
|
carried_ls = Some(ParaLineSpacing::Multiple(m as f32))
|
|
|
|
|
|
}
|
|
|
|
|
|
Some(MTextLineSpacing::Exact(e)) => carried_ls = Some(ParaLineSpacing::Exact(e as f32)),
|
|
|
|
|
|
Some(MTextLineSpacing::Default) => carried_ls = None,
|
|
|
|
|
|
None => {}
|
|
|
|
|
|
}
|
2026-07-17 18:26:29 +03:00
|
|
|
|
let (align, indent_first, indent_left, indent_right, tab_stops) =
|
|
|
|
|
|
if has_own_props(props) || carried.is_none() {
|
|
|
|
|
|
let v = (
|
|
|
|
|
|
props.alignment.and_then(map_align),
|
|
|
|
|
|
props.first_line_indent.unwrap_or(0.0) as f32,
|
|
|
|
|
|
props.left_margin.unwrap_or(0.0) as f32,
|
|
|
|
|
|
props.right_margin.unwrap_or(0.0) as f32,
|
|
|
|
|
|
props
|
|
|
|
|
|
.tab_stops
|
|
|
|
|
|
.iter()
|
2026-07-19 00:45:26 +03:00
|
|
|
|
.map(|ts| {
|
|
|
|
|
|
use acadrust::entities::mtext_format::TabStop as ATab;
|
|
|
|
|
|
let kind = match ts {
|
|
|
|
|
|
ATab::Left(_) => TabKind::Left,
|
|
|
|
|
|
ATab::Center(_) => TabKind::Center,
|
|
|
|
|
|
ATab::Right(_) => TabKind::Right,
|
|
|
|
|
|
ATab::Decimal(_) => TabKind::Decimal,
|
|
|
|
|
|
};
|
|
|
|
|
|
TabStop {
|
|
|
|
|
|
position: ts.position() as f32,
|
|
|
|
|
|
kind,
|
|
|
|
|
|
}
|
2026-07-17 18:26:29 +03:00
|
|
|
|
})
|
|
|
|
|
|
.collect::<Vec<_>>(),
|
|
|
|
|
|
);
|
|
|
|
|
|
carried = Some(v.clone());
|
|
|
|
|
|
v
|
|
|
|
|
|
} else {
|
|
|
|
|
|
carried.clone().unwrap()
|
|
|
|
|
|
};
|
2026-07-07 00:04:11 +03:00
|
|
|
|
let mut line = MTextLine {
|
2026-07-17 18:26:29 +03:00
|
|
|
|
align,
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
starts_column: para.starts_column,
|
2026-07-17 18:26:29 +03:00
|
|
|
|
indent_first,
|
|
|
|
|
|
indent_left,
|
|
|
|
|
|
indent_right,
|
|
|
|
|
|
tab_stops,
|
2026-07-07 00:04:11 +03:00
|
|
|
|
runs: Vec::new(),
|
2026-07-18 23:19:01 +03:00
|
|
|
|
space_before: carried_sb,
|
|
|
|
|
|
space_after: carried_sa,
|
|
|
|
|
|
line_spacing: carried_ls,
|
2026-07-07 00:04:11 +03:00
|
|
|
|
};
|
|
|
|
|
|
for span in ¶.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),
|
2026-07-19 17:16:41 +03:00
|
|
|
|
// Explicit `\Q` oblique wins; otherwise the font italic flag
|
|
|
|
|
|
// (`\f…|i1;`) renders as the same ~15° slant the editor's Italic
|
|
|
|
|
|
// button applies, so imported italic text isn't shown upright.
|
|
|
|
|
|
oblique_rad: match p.oblique_angle {
|
|
|
|
|
|
Some(q) => (q as f32).to_radians(),
|
|
|
|
|
|
None if p.font.as_ref().is_some_and(|f| f.italic) => {
|
|
|
|
|
|
15.0_f32.to_radians()
|
|
|
|
|
|
}
|
|
|
|
|
|
None => 0.0,
|
|
|
|
|
|
},
|
2026-07-07 00:04:11 +03:00
|
|
|
|
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)),
|
feat(mtext): structured editor with selection, copy, and visible bold
Rewrite the in-app MTEXT editor to run on acadrust's structured
MTextDocument model end-to-end: load via parse_mtext, edit a flat Cell
model, serialize via to_mtext_string. Retires the hand-rolled format-code
string handling, the parallel visible_spans parser, the composed_value
prefix, and the Edit-raw-code tab.
Selection and copy:
- double-click selects the word, triple-click selects all (click-counting)
- Ctrl+A selects all text
- Ctrl+C copies the selected text to the clipboard
Formatting:
- per-selection Bold/Italic/Underline/Overline/Strike/Case/Alignment
- Bold renders as a visibly thicker SDF pen, keyed (family, char, bold)
through the whole text pipeline; the resolved style font name is stamped
so the \f...|b1; code survives serialization
- per-selection font and color changes
- editor color dropdown reuses the Properties panel color selector
Global toolbar defaults (font/color/oblique/width/tracking) fold onto the
document as real span properties instead of a leading code prefix; a
trailing empty paragraph is preserved so Enter at the end keeps its line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:18:50 +03:00
|
|
|
|
bold: p.font.as_ref().map(|f| f.bold).unwrap_or(false),
|
2026-07-07 00:04:11 +03:00
|
|
|
|
color: color_of(p),
|
|
|
|
|
|
underline: p.stroke.underline(),
|
|
|
|
|
|
overline: p.stroke.overline(),
|
|
|
|
|
|
strike: p.stroke.strikethrough(),
|
|
|
|
|
|
};
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
if let Some(st) = &span.stacking {
|
|
|
|
|
|
// `\S` stacks the two halves in one slot. A diagonal stack
|
|
|
|
|
|
// (`\S1#2;`) is the exception — it reads as an inline `num/den`
|
|
|
|
|
|
// and is drawn that way rather than as a slanted rule.
|
|
|
|
|
|
if matches!(st.stacking_type, StackingType::Diagonal) {
|
2026-07-07 00:04:11 +03:00
|
|
|
|
let mut t = st.numerator.clone();
|
|
|
|
|
|
if !st.denominator.is_empty() {
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
t.push('/');
|
2026-07-07 00:04:11 +03:00
|
|
|
|
t.push_str(&st.denominator);
|
2026-05-21 23:42:22 +03:00
|
|
|
|
}
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
if !t.is_empty() {
|
|
|
|
|
|
line.runs.push(MTextRun {
|
|
|
|
|
|
kind: MTextRunKind::Glyphs(t),
|
|
|
|
|
|
state,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if !st.numerator.is_empty() || !st.denominator.is_empty() {
|
|
|
|
|
|
line.runs.push(MTextRun {
|
|
|
|
|
|
kind: MTextRunKind::Stack {
|
|
|
|
|
|
numerator: st.numerator.clone(),
|
|
|
|
|
|
denominator: st.denominator.clone(),
|
|
|
|
|
|
bar: matches!(st.stacking_type, StackingType::Horizontal),
|
|
|
|
|
|
},
|
|
|
|
|
|
state,
|
|
|
|
|
|
});
|
2026-05-21 23:42:22 +03:00
|
|
|
|
}
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
let text = span.text.clone();
|
2026-07-07 00:04:11 +03:00
|
|
|
|
if text.is_empty() {
|
|
|
|
|
|
continue;
|
2026-05-21 23:42:22 +03:00
|
|
|
|
}
|
2026-07-07 00:04:11 +03:00
|
|
|
|
line.runs.push(MTextRun {
|
|
|
|
|
|
kind: MTextRunKind::Glyphs(text),
|
|
|
|
|
|
state,
|
|
|
|
|
|
});
|
2026-05-21 23:42:22 +03:00
|
|
|
|
}
|
2026-07-07 00:04:11 +03:00
|
|
|
|
lines.push(line);
|
2026-05-21 23:42:22 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-31 13:14:35 +03:00
|
|
|
|
if !trim_blank_edges {
|
|
|
|
|
|
return lines;
|
|
|
|
|
|
}
|
2026-05-21 23:42:22 +03:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
// 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
|
2026-07-07 00:04:11 +03:00
|
|
|
|
// 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`.
|
2026-03-22 14:56:27 +03:00
|
|
|
|
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// 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:
|
2026-07-07 00:04:11 +03:00
|
|
|
|
// 1. Parse — via `adapt_mtext_paragraphs` (acadrust `parse_mtext`).
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
// 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,
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
/// A `\S` stack — both halves share this one slot, so it measures as wide
|
|
|
|
|
|
/// as the wider of them rather than as the two laid end to end.
|
|
|
|
|
|
Stack {
|
|
|
|
|
|
numerator: String,
|
|
|
|
|
|
denominator: String,
|
|
|
|
|
|
bar: bool,
|
|
|
|
|
|
},
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-17 18:26:29 +03:00
|
|
|
|
// A `\S` stack is measured against the height of the run it sits in, and that
|
|
|
|
|
|
// run has usually already been dropped for the purpose — `\H0.7x;\S1/2;\H1.429x;`
|
|
|
|
|
|
// is how a 70 %-scaled fraction is spelled, so the 0.7 *is* the fraction's size.
|
|
|
|
|
|
// Shrinking again on top of it is what buries the halves.
|
|
|
|
|
|
//
|
|
|
|
|
|
// So a half is near the full run height and the stack ends up about twice that:
|
|
|
|
|
|
// it is meant to grow past the line, numerator above the cap and denominator on
|
|
|
|
|
|
// the baseline — not to be squeezed into one line's worth of height.
|
|
|
|
|
|
//
|
|
|
|
|
|
// The three must stay ordered — denominator cap, then rule, then numerator
|
|
|
|
|
|
// baseline — or the rule cuts through a half.
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
/// Each half of a `\S` stack, as a fraction of the run's own height.
|
2026-07-17 18:26:29 +03:00
|
|
|
|
pub const STACK_HALF_SCALE: f32 = 0.85;
|
|
|
|
|
|
/// Height of the fraction rule above the denominator's baseline, as a fraction
|
|
|
|
|
|
/// of the run's height. Sits just clear of the denominator's cap.
|
|
|
|
|
|
pub const STACK_BAR_Y: f32 = 0.95;
|
|
|
|
|
|
/// Baseline of the numerator above the denominator's, as a fraction of the run's
|
|
|
|
|
|
/// height. Clears the rule.
|
|
|
|
|
|
pub const STACK_RAISE: f32 = 1.05;
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
#[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())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-28 18:44:38 +01:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-06-28 18:44:38 +01:00
|
|
|
|
let face = Face::resolve(&font_name);
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
let mut w = 0.0_f32;
|
|
|
|
|
|
for ch in text.chars() {
|
2026-06-16 11:18:17 +03:00
|
|
|
|
w += match face.glyph(ch) {
|
|
|
|
|
|
Some(g) => (g.advance + face.letter_spacing() * state.tracking) * scale,
|
|
|
|
|
|
None => (6.0 + face.letter_spacing() * state.tracking) * scale,
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
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);
|
2026-06-28 18:44:38 +01:00
|
|
|
|
Face::resolve(&font_name).word_spacing() * scale
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
AtomKind::Stack {
|
|
|
|
|
|
numerator,
|
|
|
|
|
|
denominator,
|
|
|
|
|
|
..
|
|
|
|
|
|
} => {
|
|
|
|
|
|
// Both halves start at the slot's left edge, so the slot is as wide
|
|
|
|
|
|
// as the wider one — measured at the shrunk height they draw at.
|
|
|
|
|
|
let mut half = atom.state.clone();
|
|
|
|
|
|
half.height_mul *= STACK_HALF_SCALE;
|
|
|
|
|
|
let n = measure_word(numerator, &half, entity_h, base_wf, base_font);
|
|
|
|
|
|
let d = measure_word(denominator, &half, entity_h, base_wf, base_font);
|
|
|
|
|
|
n.max(d)
|
|
|
|
|
|
}
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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;
|
2026-07-19 01:10:56 +03:00
|
|
|
|
// The field after a center/right/decimal tab is aligned on the stop, so it
|
|
|
|
|
|
// reaches only part of its width to the right — don't wrap it off the line
|
|
|
|
|
|
// by its full width as a left-flushed word would be.
|
|
|
|
|
|
let mut after_align_tab = false;
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
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 {
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
// A stack is one unbreakable slot — it wraps exactly like a word.
|
|
|
|
|
|
AtomKind::Word(_) | AtomKind::Stack { .. } => {
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
let w = atom_width(&atom, entity_h, base_wf, base_font);
|
|
|
|
|
|
let max_w = line_max_w(subline_idx);
|
2026-07-19 01:10:56 +03:00
|
|
|
|
if !cur.is_empty() && cur_w + w > max_w && !after_align_tab {
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
while matches!(cur.last().map(|a| &a.kind), Some(AtomKind::Space)) {
|
|
|
|
|
|
cur.pop();
|
2026-05-01 11:51:45 +03:00
|
|
|
|
}
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
sublines.push(std::mem::take(&mut cur));
|
|
|
|
|
|
cur_w = 0.0;
|
|
|
|
|
|
subline_idx += 1;
|
2026-05-01 11:51:45 +03:00
|
|
|
|
}
|
2026-07-19 01:10:56 +03:00
|
|
|
|
after_align_tab = false;
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
cur.push(atom);
|
|
|
|
|
|
cur_w += w;
|
|
|
|
|
|
}
|
|
|
|
|
|
AtomKind::Space => {
|
2026-07-19 01:10:56 +03:00
|
|
|
|
after_align_tab = false;
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
if cur.is_empty() {
|
|
|
|
|
|
continue;
|
2026-05-12 10:36:15 +03:00
|
|
|
|
}
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
cur_w += atom_width(&atom, entity_h, base_wf, base_font);
|
|
|
|
|
|
cur.push(atom);
|
|
|
|
|
|
}
|
|
|
|
|
|
AtomKind::Tab => {
|
|
|
|
|
|
let start_x = line_start_x(subline_idx);
|
2026-07-19 01:10:56 +03:00
|
|
|
|
let local = cur_w + start_x - indent_left;
|
|
|
|
|
|
let is_align = tab_stops
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|ts| ts.position > local + 1e-4)
|
|
|
|
|
|
.map(|ts| !matches!(ts.kind, TabKind::Left))
|
|
|
|
|
|
.unwrap_or(false);
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-07-19 01:10:56 +03:00
|
|
|
|
after_align_tab = is_align;
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
if !cur.is_empty() {
|
|
|
|
|
|
sublines.push(cur);
|
|
|
|
|
|
}
|
|
|
|
|
|
if sublines.is_empty() {
|
|
|
|
|
|
sublines.push(Vec::new());
|
|
|
|
|
|
}
|
|
|
|
|
|
sublines
|
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
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
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
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),
|
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
feat(fonts): switch the text engine from CXF to LibreCAD LFF fonts
Replace the small QCAD CXF stroke-font set with the full LibreCAD LFF font
library (46 fonts) and a new LFF parser/engine in src/scene/lff.rs. The
parser reads the `[hex] char` glyph blocks, tessellates `,A<bulge>` arcs, and
resolves `C<hex>` glyph references. Per LibreCAD's RS_Polyline convention a
vertex's bulge curves the segment ending at that vertex — getting this wrong
turned round letters like O into sideways capsules.
Font name resolution mirrors LibreCAD: match by file stem, map common
AutoCAD/DXF SHX names (txt, isocp, complex, italic, gothic*, gost, …) to the
nearest LFF family, and fall back to `standard`. A missing glyph in the chosen
family falls back to the broad `unicode` font so accented/Cyrillic/Greek text
still renders. Cap height stays 9 units, so the text scale is unchanged.
All text consumers move from cxf:: to lff::; complex linetypes still render
embedded text but no longer draw embedded shapes (LibreCAD ships no shape
file). The CXF module and every *.cxf asset are removed, and the MText / text
style font pickers list the LFF families.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 03:31:20 +03:00
|
|
|
|
/// Wrap a run's glyph text with MTEXT decoration markers so lff's
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
/// `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();
|
2026-04-26 21:57:51 +03:00
|
|
|
|
}
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
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
|
2026-04-26 21:57:51 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
#[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,
|
2026-05-31 02:33:47 +03:00
|
|
|
|
/// 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,
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
/// The entity's column layout. `Default` (count 0) = a single column, which
|
|
|
|
|
|
/// is what everything except a multi-column MTEXT wants.
|
|
|
|
|
|
pub columns: MTextColumns,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// An MTEXT's column layout, flattened from its `column_data`.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Content moves to the next column at a `\N` break. Filling a column and
|
|
|
|
|
|
/// spilling into the next on its own is not modelled: `heights` / `auto_height`
|
|
|
|
|
|
/// are not read, so a column runs as long as its content does.
|
|
|
|
|
|
#[derive(Clone, Copy, Debug, Default)]
|
|
|
|
|
|
pub struct MTextColumns {
|
|
|
|
|
|
/// Column count. 0 or 1 both mean "no column layout".
|
|
|
|
|
|
pub count: usize,
|
|
|
|
|
|
/// Width of one column, world units.
|
|
|
|
|
|
pub width: f32,
|
|
|
|
|
|
/// Gap between two adjacent columns, world units.
|
|
|
|
|
|
pub gutter: f32,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl MTextColumns {
|
|
|
|
|
|
/// Whether this describes a real multi-column layout.
|
|
|
|
|
|
pub fn active(&self) -> bool {
|
|
|
|
|
|
self.count > 1 && self.width > 1e-6
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Left edge of column `i`, relative to the text block's own left edge.
|
|
|
|
|
|
pub fn offset_of(&self, i: usize) -> f32 {
|
|
|
|
|
|
i as f32 * (self.width + self.gutter)
|
|
|
|
|
|
}
|
2026-05-31 02:33:47 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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,
|
2026-05-15 17:41:56 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
/// 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,
|
2026-05-31 02:33:47 +03:00
|
|
|
|
/// One world-space AABB per visible character — only populated when
|
|
|
|
|
|
/// `MTextRenderOpts::want_glyph_boxes` is set.
|
|
|
|
|
|
pub glyph_boxes: Vec<GlyphBox>,
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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();
|
2026-06-16 11:18:17 +03:00
|
|
|
|
let base_font = Face::resolve(&base_font_name);
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
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 ─────────────────────────────────────────────────────────
|
2026-05-31 13:14:35 +03:00
|
|
|
|
// The editor (want_glyph_boxes) keeps blank edges so a freshly typed
|
|
|
|
|
|
// trailing newline yields an empty paragraph the caret can sit on.
|
2026-07-07 00:04:11 +03:00
|
|
|
|
let paragraphs = adapt_mtext_paragraphs(opts.value, entity_h, !opts.want_glyph_boxes);
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
|
|
|
|
|
|
// ── 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,
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
/// Which column this line lives in; always 0 without a column layout.
|
|
|
|
|
|
column: usize,
|
2026-07-18 23:19:01 +03:00
|
|
|
|
/// Extra gap above (paragraph's first wrapped line only) / below (last
|
|
|
|
|
|
/// line only), in drawing units, from `\p…b#/a#;`.
|
|
|
|
|
|
space_before: f32,
|
|
|
|
|
|
space_after: f32,
|
|
|
|
|
|
/// Paragraph line-spacing override applied to this line's advance.
|
|
|
|
|
|
line_spacing: Option<ParaLineSpacing>,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
let cols = opts.columns;
|
|
|
|
|
|
// A `\N` past the last column has nowhere to go — keep those lines in the
|
|
|
|
|
|
// last one rather than laying them out beyond the block.
|
|
|
|
|
|
let last_col = cols.count.saturating_sub(1);
|
|
|
|
|
|
let mut column = 0usize;
|
|
|
|
|
|
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
let mut sub_lines: Vec<SubLine> = Vec::new();
|
|
|
|
|
|
for para in ¶graphs {
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
if cols.active() && para.starts_column {
|
|
|
|
|
|
column = (column + 1).min(last_col);
|
|
|
|
|
|
}
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
let mut atoms: Vec<LayoutAtom> = Vec::new();
|
|
|
|
|
|
for run in ¶.runs {
|
|
|
|
|
|
match &run.kind {
|
|
|
|
|
|
MTextRunKind::Glyphs(text) => {
|
|
|
|
|
|
let mut word = String::new();
|
|
|
|
|
|
for ch in text.chars() {
|
2026-07-19 17:16:41 +03:00
|
|
|
|
if ch == '\u{00A0}' {
|
|
|
|
|
|
// Non-breaking space (`\~`): keep it inside the word
|
|
|
|
|
|
// so the line can't wrap here. It still renders as a
|
|
|
|
|
|
// space — the font advances over the ' ' — but the
|
|
|
|
|
|
// words it joins stay together.
|
|
|
|
|
|
word.push(' ');
|
|
|
|
|
|
} else if ch == ' ' || ch == '\t' {
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
if !word.is_empty() {
|
|
|
|
|
|
atoms.push(LayoutAtom {
|
|
|
|
|
|
kind: AtomKind::Word(std::mem::take(&mut word)),
|
|
|
|
|
|
state: run.state.clone(),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-07-19 00:45:26 +03:00
|
|
|
|
// A literal tab (acadrust keeps `^I` / `\t` as a tab
|
|
|
|
|
|
// char in the span) advances to the paragraph's next
|
|
|
|
|
|
// tab stop, aligning the field that follows it.
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
atoms.push(LayoutAtom {
|
2026-07-19 00:45:26 +03:00
|
|
|
|
kind: if ch == '\t' {
|
|
|
|
|
|
AtomKind::Tab
|
|
|
|
|
|
} else {
|
|
|
|
|
|
AtomKind::Space
|
|
|
|
|
|
},
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
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(),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
MTextRunKind::Stack {
|
|
|
|
|
|
numerator,
|
|
|
|
|
|
denominator,
|
|
|
|
|
|
bar,
|
|
|
|
|
|
} => {
|
|
|
|
|
|
atoms.push(LayoutAtom {
|
|
|
|
|
|
kind: AtomKind::Stack {
|
|
|
|
|
|
numerator: numerator.clone(),
|
|
|
|
|
|
denominator: denominator.clone(),
|
|
|
|
|
|
bar: *bar,
|
|
|
|
|
|
},
|
|
|
|
|
|
state: run.state.clone(),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
// 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.
|
2026-05-31 13:14:35 +03:00
|
|
|
|
//
|
|
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
// Wrap to the column the text actually flows down, not to the block:
|
|
|
|
|
|
// measuring against the full width would let a line run across the
|
|
|
|
|
|
// gutter and over its neighbour.
|
|
|
|
|
|
let wrap_w = if cols.active() { cols.width } else { rect_w };
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
let wrapped = wrap_paragraph(
|
|
|
|
|
|
atoms,
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
wrap_w,
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
para.indent_first,
|
|
|
|
|
|
para.indent_left,
|
|
|
|
|
|
para.indent_right,
|
|
|
|
|
|
¶.tab_stops,
|
|
|
|
|
|
entity_h,
|
|
|
|
|
|
base_wf,
|
|
|
|
|
|
&base_font_name,
|
|
|
|
|
|
);
|
2026-07-18 23:19:01 +03:00
|
|
|
|
let n_wrapped = wrapped.len();
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
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,
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
column,
|
2026-07-18 23:19:01 +03:00
|
|
|
|
// The gap above rides the first wrapped line; the gap below the
|
|
|
|
|
|
// last, so an interior wrap keeps normal single spacing.
|
|
|
|
|
|
space_before: if idx == 0 { para.space_before } else { 0.0 },
|
|
|
|
|
|
space_after: if idx + 1 == n_wrapped {
|
|
|
|
|
|
para.space_after
|
|
|
|
|
|
} else {
|
|
|
|
|
|
0.0
|
|
|
|
|
|
},
|
|
|
|
|
|
line_spacing: para.line_spacing,
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
});
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
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,
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
column: 0,
|
2026-07-18 23:19:01 +03:00
|
|
|
|
space_before: 0.0,
|
|
|
|
|
|
space_after: 0.0,
|
|
|
|
|
|
line_spacing: None,
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 3. Block geometry (line spacing, attachment, rotation) ───────────
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
// Row of each sub-line *within its own column* — the flat index only tracks
|
|
|
|
|
|
// depth down the page when there is one column. Columns sit side by side, so
|
|
|
|
|
|
// each restarts at the top and the block is as deep as its longest one.
|
|
|
|
|
|
let mut col_rows = vec![0usize; cols.count.max(1)];
|
|
|
|
|
|
let row_in_column: Vec<f32> = sub_lines
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|s| {
|
|
|
|
|
|
let r = col_rows[s.column];
|
|
|
|
|
|
col_rows[s.column] = r + 1;
|
|
|
|
|
|
r as f32
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
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.
|
2026-06-16 11:18:17 +03:00
|
|
|
|
let line_h = entity_h * ls_factor * (5.0 / 3.0) * base_font.line_spacing();
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
let h = entity_h;
|
2026-07-15 22:55:51 +03:00
|
|
|
|
// How far the first line's glyphs actually reach above the baseline, when
|
2026-07-18 23:19:01 +03:00
|
|
|
|
// that is further than the line's own cap height.
|
2026-07-15 22:55:51 +03:00
|
|
|
|
//
|
2026-07-18 23:19:01 +03:00
|
|
|
|
// Ordinary letters stop at the cap, so this is the first line's nominal cap
|
|
|
|
|
|
// and nothing below moves. Two exceptions push it: a symbol font whose
|
|
|
|
|
|
// glyphs straddle the cap box (the diameter sign's stroke overhangs it top
|
|
|
|
|
|
// and bottom by design), and — the reason the floor is the *line's* cap, not
|
|
|
|
|
|
// the entity height — a small heading (`\H0.2x;First column`): anchoring by
|
|
|
|
|
|
// the full entity height opened a whole blank line above it.
|
|
|
|
|
|
let first_line_cap = sub_lines
|
|
|
|
|
|
.first()
|
|
|
|
|
|
.map(|line| {
|
|
|
|
|
|
line.atoms
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter_map(|a| match &a.kind {
|
|
|
|
|
|
AtomKind::Word(_) => Some(a.state.height_mul * entity_h),
|
|
|
|
|
|
_ => None,
|
|
|
|
|
|
})
|
|
|
|
|
|
.fold(0.0_f32, f32::max)
|
|
|
|
|
|
})
|
|
|
|
|
|
.filter(|c| *c > 0.0)
|
|
|
|
|
|
.unwrap_or(h);
|
2026-07-15 22:55:51 +03:00
|
|
|
|
let first_line_ascent = sub_lines
|
|
|
|
|
|
.first()
|
|
|
|
|
|
.map(|line| {
|
|
|
|
|
|
line.atoms
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter_map(|atom| {
|
|
|
|
|
|
let AtomKind::Word(text) = &atom.kind else {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
};
|
|
|
|
|
|
let font = resolve_font(&atom.state, &base_font_name);
|
|
|
|
|
|
text_local_bounds(
|
|
|
|
|
|
&font,
|
|
|
|
|
|
text,
|
|
|
|
|
|
atom.state.height_mul * entity_h,
|
|
|
|
|
|
atom.state.width_mul * base_wf.abs(),
|
|
|
|
|
|
atom.state.oblique_rad,
|
|
|
|
|
|
)
|
|
|
|
|
|
.map(|b| b.ink_max[1])
|
|
|
|
|
|
})
|
2026-07-18 23:19:01 +03:00
|
|
|
|
.fold(first_line_cap, f32::max)
|
2026-07-15 22:55:51 +03:00
|
|
|
|
})
|
|
|
|
|
|
.unwrap_or(h);
|
2026-07-18 23:19:01 +03:00
|
|
|
|
// Per-line vertical advance: a varying-height MText (inline `\H`) must
|
|
|
|
|
|
// space each line by its own content height, not the entity height —
|
|
|
|
|
|
// otherwise a small line still leaves a full-height gap and the block
|
|
|
|
|
|
// grows several times too tall. Accumulated per column (columns stack
|
|
|
|
|
|
// independently). Uniform-height text reduces to the constant `line_h`.
|
2026-07-19 02:39:42 +03:00
|
|
|
|
// A blank line (an empty `\P` paragraph) carries no glyph to size it, so it
|
|
|
|
|
|
// takes the height of the text around it — the last non-empty line — not the
|
|
|
|
|
|
// full entity height, which would blow a small-text blank line up several
|
|
|
|
|
|
// times too tall and shove everything below it down the page.
|
|
|
|
|
|
let mut last_line_h = entity_h;
|
2026-07-18 23:19:01 +03:00
|
|
|
|
let per_line_h: Vec<f32> = sub_lines
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|s| {
|
|
|
|
|
|
let mh = s
|
|
|
|
|
|
.atoms
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter_map(|a| match &a.kind {
|
|
|
|
|
|
AtomKind::Word(_) => Some(a.state.height_mul * entity_h),
|
|
|
|
|
|
_ => None,
|
|
|
|
|
|
})
|
|
|
|
|
|
.fold(0.0_f32, f32::max);
|
2026-07-19 02:39:42 +03:00
|
|
|
|
let mh = if mh > 0.0 {
|
|
|
|
|
|
last_line_h = mh;
|
|
|
|
|
|
mh
|
|
|
|
|
|
} else {
|
|
|
|
|
|
last_line_h
|
|
|
|
|
|
};
|
2026-07-18 23:19:01 +03:00
|
|
|
|
// A paragraph's own `\psm#`/`\pse#` overrides the entity factor for
|
|
|
|
|
|
// its lines; `Exact` fixes the baseline gap outright.
|
|
|
|
|
|
match s.line_spacing {
|
|
|
|
|
|
Some(ParaLineSpacing::Exact(e)) if e > 0.0 => e,
|
|
|
|
|
|
Some(ParaLineSpacing::Multiple(m)) if m > 0.0 => {
|
|
|
|
|
|
mh * m * (5.0 / 3.0) * base_font.line_spacing()
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => mh * ls_factor * (5.0 / 3.0) * base_font.line_spacing(),
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
2026-07-19 17:24:37 +03:00
|
|
|
|
// Per-line text height — the tallest Word on the line, an empty line
|
|
|
|
|
|
// inheriting the previous line's height (same rule as `per_line_h`). Unlike
|
|
|
|
|
|
// `line_max_h` it has no `entity_h` floor, so it reflects the ACTUAL text
|
|
|
|
|
|
// size; used to size the empty-line caret slot to the surrounding text
|
|
|
|
|
|
// rather than the raw entity height (which can be many times the visible
|
|
|
|
|
|
// text when `\H…x;` runs shrink it).
|
|
|
|
|
|
let line_text_h: Vec<f32> = {
|
|
|
|
|
|
let mut last = entity_h;
|
|
|
|
|
|
sub_lines
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|s| {
|
|
|
|
|
|
let mh = s
|
|
|
|
|
|
.atoms
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter_map(|a| match &a.kind {
|
|
|
|
|
|
AtomKind::Word(_) => Some(a.state.height_mul * entity_h),
|
|
|
|
|
|
_ => None,
|
|
|
|
|
|
})
|
|
|
|
|
|
.fold(0.0_f32, f32::max);
|
|
|
|
|
|
if mh > 0.0 {
|
|
|
|
|
|
last = mh;
|
|
|
|
|
|
mh
|
|
|
|
|
|
} else {
|
|
|
|
|
|
last
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
};
|
2026-07-18 23:19:01 +03:00
|
|
|
|
let line_y_offset: Vec<f32> = {
|
|
|
|
|
|
let mut col_y = vec![0.0_f32; cols.count.max(1)];
|
|
|
|
|
|
let mut started = vec![false; cols.count.max(1)];
|
|
|
|
|
|
// Space owed below the previous paragraph in each column, paid into the
|
|
|
|
|
|
// gap at the next paragraph's first line (alongside its space-before).
|
|
|
|
|
|
let mut pending_after = vec![0.0_f32; cols.count.max(1)];
|
|
|
|
|
|
sub_lines
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.enumerate()
|
|
|
|
|
|
.map(|(i, s)| {
|
|
|
|
|
|
let c = s.column.min(col_y.len().saturating_sub(1));
|
|
|
|
|
|
if started[c] {
|
|
|
|
|
|
col_y[c] -= per_line_h[i];
|
|
|
|
|
|
if s.is_first_in_paragraph {
|
|
|
|
|
|
col_y[c] -= s.space_before + pending_after[c];
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
started[c] = true;
|
|
|
|
|
|
// The column's first paragraph still opens its space-before as
|
|
|
|
|
|
// a top margin — that gap is the entity's own `\p…b#;`, not a
|
|
|
|
|
|
// heuristic, so the heading sits where the drawing places it.
|
|
|
|
|
|
col_y[c] -= s.space_before;
|
|
|
|
|
|
}
|
|
|
|
|
|
pending_after[c] = s.space_after;
|
|
|
|
|
|
col_y[c]
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
};
|
|
|
|
|
|
let total_depth = -line_y_offset.iter().copied().fold(0.0_f32, f32::min);
|
|
|
|
|
|
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
let v_offset = match opts.v_anchor {
|
2026-07-15 22:55:51 +03:00
|
|
|
|
MTextVAnchor::Top => -first_line_ascent,
|
2026-07-18 23:19:01 +03:00
|
|
|
|
MTextVAnchor::Middle => (total_depth - h) * 0.5,
|
|
|
|
|
|
MTextVAnchor::Bottom => total_depth,
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
MTextVAnchor::MiddleOfTopLine => -h * 0.5,
|
2026-07-18 23:19:01 +03:00
|
|
|
|
MTextVAnchor::MiddleOfBottomLine => total_depth - h * 0.5,
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
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());
|
2026-05-31 02:33:47 +03:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
};
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
for (i, sub) in sub_lines.iter().enumerate() {
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
let li = row_in_column[i];
|
|
|
|
|
|
// The block anchors on its full width; each column then sits at its own
|
|
|
|
|
|
// offset inside it and wraps to its own width, not the block's.
|
|
|
|
|
|
let (box_left, rect_w) = if cols.active() {
|
|
|
|
|
|
(box_left + cols.offset_of(sub.column), cols.width)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
(box_left, rect_w)
|
|
|
|
|
|
};
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
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 {
|
2026-07-18 23:19:01 +03:00
|
|
|
|
let ly = line_y_offset[i] + v_offset;
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
(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);
|
|
|
|
|
|
|
2026-05-31 13:14:35 +03:00
|
|
|
|
// 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
|
2026-07-19 17:24:37 +03:00
|
|
|
|
// place the caret on a fresh/empty line. Size it to the line's own text
|
|
|
|
|
|
// height (the surrounding text for an empty line), not the raw entity
|
|
|
|
|
|
// height — otherwise a blank line's caret is entity-tall, which is many
|
|
|
|
|
|
// times the visible text when `\H…x;` runs shrink it.
|
2026-05-31 13:14:35 +03:00
|
|
|
|
if opts.want_glyph_boxes && i > 0 && sub.is_first_in_paragraph {
|
2026-07-19 17:24:37 +03:00
|
|
|
|
let caret_h = line_text_h.get(i).copied().unwrap_or(entity_h);
|
2026-05-31 13:14:35 +03:00
|
|
|
|
let (ax, ay) = to_world(line_base_x, line_base_y, cursor_start, 0.0);
|
2026-07-19 17:24:37 +03:00
|
|
|
|
let (_, by) = to_world(line_base_x, line_base_y, cursor_start, caret_h);
|
2026-05-31 13:14:35 +03:00
|
|
|
|
glyph_boxes.push(GlyphBox {
|
|
|
|
|
|
vis,
|
|
|
|
|
|
xmin: ax,
|
|
|
|
|
|
xmax: ax,
|
|
|
|
|
|
ymin: ay.min(by),
|
|
|
|
|
|
ymax: ay.max(by),
|
|
|
|
|
|
});
|
|
|
|
|
|
vis += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-19 00:00:24 +03:00
|
|
|
|
// Justify / distribute fill the line to the content width. `qj` spreads
|
|
|
|
|
|
// only the word gaps and leaves the paragraph's last line ragged; `qd`
|
|
|
|
|
|
// spreads every glyph gap (letters and words) on every line. The slack
|
|
|
|
|
|
// is shared as one gap `δ`: added to each space, and — for distribute —
|
|
|
|
|
|
// to each glyph via letter tracking, so `δ·(chars+spaces) = slack`.
|
|
|
|
|
|
let is_last_in_para = sub_lines
|
|
|
|
|
|
.get(i + 1)
|
|
|
|
|
|
.is_none_or(|n| n.is_first_in_paragraph || n.column != sub.column);
|
|
|
|
|
|
let (spread_letters, gap) = {
|
|
|
|
|
|
let justify =
|
|
|
|
|
|
matches!(sub.align, Some(ParagraphAlign::Justify)) && !is_last_in_para;
|
|
|
|
|
|
let distribute = matches!(sub.align, Some(ParagraphAlign::Distribute));
|
|
|
|
|
|
let slack = (content_right - content_left).max(0.0) - line_w;
|
|
|
|
|
|
if rect_w > 0.0 && (justify || distribute) && slack > 1e-6 {
|
|
|
|
|
|
let spaces = sub
|
|
|
|
|
|
.atoms
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|a| matches!(a.kind, AtomKind::Space))
|
|
|
|
|
|
.count();
|
|
|
|
|
|
if distribute {
|
|
|
|
|
|
let chars: usize = sub
|
|
|
|
|
|
.atoms
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter_map(|a| match &a.kind {
|
|
|
|
|
|
AtomKind::Word(t) => Some(t.chars().count()),
|
|
|
|
|
|
_ => None,
|
|
|
|
|
|
})
|
|
|
|
|
|
.sum();
|
|
|
|
|
|
let n = chars + spaces;
|
|
|
|
|
|
(true, if n > 0 { slack / n as f32 } else { 0.0 })
|
|
|
|
|
|
} else if spaces > 0 {
|
|
|
|
|
|
(false, slack / spaces as f32)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
(false, 0.0)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
(false, 0.0)
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
let mut cursor_x = cursor_start;
|
2026-07-19 00:45:26 +03:00
|
|
|
|
for (ai, atom) in sub.atoms.iter().enumerate() {
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
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);
|
2026-07-19 00:00:24 +03:00
|
|
|
|
// Distribute widens letter tracking so each glyph advances an
|
|
|
|
|
|
// extra `gap`; `tracking` scales the font's letter_spacing, so
|
|
|
|
|
|
// invert that to land the exact world gap. A zero-spacing font
|
|
|
|
|
|
// (TTF) can't take letter tracking — its words stay solid and
|
|
|
|
|
|
// only the word gaps spread.
|
|
|
|
|
|
let tracking = if spread_letters {
|
|
|
|
|
|
let l = Face::resolve(&font_name).letter_spacing();
|
|
|
|
|
|
let wf = signed_wf.abs().max(1e-6);
|
|
|
|
|
|
let scale = run_h / 9.0;
|
|
|
|
|
|
if l.abs() > 1e-6 && scale > 0.0 {
|
|
|
|
|
|
atom.state.tracking + gap / (l * scale * wf)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
atom.state.tracking
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
atom.state.tracking
|
|
|
|
|
|
};
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
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 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,
|
|
|
|
|
|
];
|
2026-07-18 23:19:01 +03:00
|
|
|
|
// Glyphs from the plain word — the SDF run and the stroke
|
|
|
|
|
|
// fallback both draw clean characters.
|
2026-06-25 18:17:01 +01:00
|
|
|
|
let (strokes, fill_tris) = lff::tessellate_text_run(
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
[0.0, 0.0],
|
|
|
|
|
|
run_h,
|
|
|
|
|
|
rot,
|
|
|
|
|
|
signed_wf,
|
|
|
|
|
|
oblique,
|
|
|
|
|
|
tracking,
|
2026-06-28 18:44:38 +01:00
|
|
|
|
&font_name,
|
2026-07-18 23:19:01 +03:00
|
|
|
|
text,
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
);
|
2026-07-18 23:19:01 +03:00
|
|
|
|
let glyph_n = strokes.len();
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
all_strokes.push(TextStroke {
|
|
|
|
|
|
strokes,
|
|
|
|
|
|
origin,
|
|
|
|
|
|
color,
|
2026-06-25 18:17:01 +01:00
|
|
|
|
fill_tris,
|
2026-07-07 00:53:19 +03:00
|
|
|
|
run: Some(GlyphRun {
|
|
|
|
|
|
text: text.clone(),
|
|
|
|
|
|
font: font_name.to_string(),
|
|
|
|
|
|
height: run_h,
|
|
|
|
|
|
rotation: rot,
|
|
|
|
|
|
width_factor: signed_wf,
|
|
|
|
|
|
oblique,
|
|
|
|
|
|
tracking,
|
feat(mtext): structured editor with selection, copy, and visible bold
Rewrite the in-app MTEXT editor to run on acadrust's structured
MTextDocument model end-to-end: load via parse_mtext, edit a flat Cell
model, serialize via to_mtext_string. Retires the hand-rolled format-code
string handling, the parallel visible_spans parser, the composed_value
prefix, and the Edit-raw-code tab.
Selection and copy:
- double-click selects the word, triple-click selects all (click-counting)
- Ctrl+A selects all text
- Ctrl+C copies the selected text to the clipboard
Formatting:
- per-selection Bold/Italic/Underline/Overline/Strike/Case/Alignment
- Bold renders as a visibly thicker SDF pen, keyed (family, char, bold)
through the whole text pipeline; the resolved style font name is stamped
so the \f...|b1; code survives serialization
- per-selection font and color changes
- editor color dropdown reuses the Properties panel color selector
Global toolbar defaults (font/color/oblique/width/tracking) fold onto the
document as real span properties instead of a leading code prefix; a
trailing empty paragraph is preserved so Enter at the end keeps its line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:18:50 +03:00
|
|
|
|
bold: atom.state.bold,
|
2026-07-07 00:53:19 +03:00
|
|
|
|
}),
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
});
|
2026-07-18 23:19:01 +03:00
|
|
|
|
// Underline / overline / strike are lines, not glyphs; a
|
|
|
|
|
|
// run-group's strokes are suppressed by the SDF path, so
|
|
|
|
|
|
// emit the decorations in their own RUN-LESS group. Reuse
|
|
|
|
|
|
// lff's exact positions by tessellating the decorated word
|
|
|
|
|
|
// and taking the strokes it appends after the glyphs.
|
|
|
|
|
|
if atom.state.underline || atom.state.overline || atom.state.strike {
|
|
|
|
|
|
let body = decorated(text, &atom.state);
|
|
|
|
|
|
let (deco, _) = lff::tessellate_text_run(
|
|
|
|
|
|
[0.0, 0.0],
|
|
|
|
|
|
run_h,
|
|
|
|
|
|
rot,
|
|
|
|
|
|
signed_wf,
|
|
|
|
|
|
oblique,
|
|
|
|
|
|
tracking,
|
|
|
|
|
|
&font_name,
|
|
|
|
|
|
&body,
|
|
|
|
|
|
);
|
|
|
|
|
|
if deco.len() > glyph_n {
|
|
|
|
|
|
all_strokes.push(TextStroke {
|
|
|
|
|
|
strokes: deco[glyph_n..].to_vec(),
|
|
|
|
|
|
origin,
|
|
|
|
|
|
color,
|
|
|
|
|
|
fill_tris: vec![],
|
|
|
|
|
|
run: None,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-31 02:33:47 +03:00
|
|
|
|
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);
|
2026-06-28 18:44:38 +01:00
|
|
|
|
let face = Face::resolve(&font_name);
|
2026-05-31 02:33:47 +03:00
|
|
|
|
let mut cx = cursor_x;
|
|
|
|
|
|
for ch in text.chars() {
|
2026-06-16 11:18:17 +03:00
|
|
|
|
let adv = match face.glyph(ch) {
|
2026-05-31 02:33:47 +03:00
|
|
|
|
Some(g) => {
|
2026-06-16 11:18:17 +03:00
|
|
|
|
(g.advance + face.letter_spacing() * tracking) * scale
|
2026-05-31 02:33:47 +03:00
|
|
|
|
}
|
2026-06-16 11:18:17 +03:00
|
|
|
|
None => (6.0 + face.letter_spacing() * tracking) * scale,
|
2026-05-31 02:33:47 +03:00
|
|
|
|
};
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-19 00:00:24 +03:00
|
|
|
|
// Advance by the same tracking the glyphs drew with, so a
|
|
|
|
|
|
// distributed word's letters and the atoms after it stay put.
|
|
|
|
|
|
if tracking != atom.state.tracking {
|
|
|
|
|
|
let mut st = atom.state.clone();
|
|
|
|
|
|
st.tracking = tracking;
|
|
|
|
|
|
cursor_x += measure_word(text, &st, entity_h, base_wf, &base_font_name);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
cursor_x +=
|
|
|
|
|
|
measure_word(text, &atom.state, entity_h, base_wf, &base_font_name);
|
|
|
|
|
|
}
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
}
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
AtomKind::Stack {
|
|
|
|
|
|
numerator,
|
|
|
|
|
|
denominator,
|
|
|
|
|
|
bar,
|
|
|
|
|
|
} => {
|
|
|
|
|
|
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 color = atom.state.color.as_ref().and_then(resolve_inline_color);
|
|
|
|
|
|
let valign_dy = match atom.state.valign {
|
|
|
|
|
|
1 => (line_max_h - run_h) * 0.5,
|
|
|
|
|
|
2 => line_max_h - run_h,
|
|
|
|
|
|
_ => 0.0,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Both halves draw at the shrunk height; the numerator sits
|
|
|
|
|
|
// a fixed lift above the denominator's baseline, so the two
|
|
|
|
|
|
// occupy one slot instead of running on horizontally.
|
|
|
|
|
|
let mut half_state = atom.state.clone();
|
|
|
|
|
|
half_state.height_mul *= STACK_HALF_SCALE;
|
|
|
|
|
|
let half_h = run_h * STACK_HALF_SCALE;
|
|
|
|
|
|
|
|
|
|
|
|
let slot_w = atom_width(atom, entity_h, base_wf, &base_font_name);
|
|
|
|
|
|
|
|
|
|
|
|
let mut emit = |text: &str, ly: f32| {
|
|
|
|
|
|
if text.is_empty() {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let lx = cursor_x;
|
|
|
|
|
|
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],
|
|
|
|
|
|
half_h,
|
|
|
|
|
|
rot,
|
|
|
|
|
|
signed_wf,
|
|
|
|
|
|
oblique,
|
|
|
|
|
|
tracking,
|
|
|
|
|
|
&font_name,
|
|
|
|
|
|
text,
|
|
|
|
|
|
);
|
|
|
|
|
|
all_strokes.push(TextStroke {
|
|
|
|
|
|
strokes,
|
|
|
|
|
|
origin,
|
|
|
|
|
|
color,
|
|
|
|
|
|
fill_tris,
|
|
|
|
|
|
run: Some(GlyphRun {
|
|
|
|
|
|
text: text.to_string(),
|
|
|
|
|
|
font: font_name.to_string(),
|
|
|
|
|
|
height: half_h,
|
|
|
|
|
|
rotation: rot,
|
|
|
|
|
|
width_factor: signed_wf,
|
|
|
|
|
|
oblique,
|
|
|
|
|
|
tracking,
|
|
|
|
|
|
bold: atom.state.bold,
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
emit(denominator, valign_dy);
|
|
|
|
|
|
emit(numerator, valign_dy + run_h * STACK_RAISE);
|
|
|
|
|
|
|
|
|
|
|
|
if *bar && slot_w > 0.0 {
|
|
|
|
|
|
// The rule is plain geometry, not a glyph, so it rides a
|
|
|
|
|
|
// run-less group — those keep their strokes, while a
|
|
|
|
|
|
// group carrying a `GlyphRun` renders as SDF quads and
|
|
|
|
|
|
// has its strokes suppressed.
|
|
|
|
|
|
let by = valign_dy + run_h * STACK_BAR_Y;
|
|
|
|
|
|
let p = |lx: f32, ly: f32| -> [f32; 2] {
|
|
|
|
|
|
[lx * cos_r - ly * sin_r, lx * sin_r + ly * cos_r]
|
|
|
|
|
|
};
|
|
|
|
|
|
let (ox, oy) = (
|
|
|
|
|
|
ins_x + (line_base_x + p(cursor_x, by)[0]) as f64,
|
|
|
|
|
|
ins_y + (line_base_y + p(cursor_x, by)[1]) as f64,
|
|
|
|
|
|
);
|
|
|
|
|
|
let end = p(slot_w, 0.0);
|
|
|
|
|
|
all_strokes.push(TextStroke {
|
|
|
|
|
|
strokes: vec![vec![[0.0, 0.0], end]],
|
|
|
|
|
|
origin: [ox, oy],
|
|
|
|
|
|
color,
|
|
|
|
|
|
fill_tris: Vec::new(),
|
|
|
|
|
|
run: None,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
cursor_x += slot_w;
|
|
|
|
|
|
}
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
AtomKind::Space => {
|
2026-07-19 00:00:24 +03:00
|
|
|
|
// Justify / distribute widen every word gap by `gap`.
|
|
|
|
|
|
let adv =
|
|
|
|
|
|
measure_space(&atom.state, entity_h, base_wf, &base_font_name) + gap;
|
2026-07-18 23:19:01 +03:00
|
|
|
|
// A space inside a decorated span carries the underline /
|
|
|
|
|
|
// overline / strike state but draws no glyph, so without this
|
|
|
|
|
|
// the rule breaks at every inter-word gap. Emit the decoration
|
|
|
|
|
|
// for the blank the same way words do — lff advances a space by
|
|
|
|
|
|
// exactly `measure_space`, so the rule meets the neighbours'.
|
|
|
|
|
|
if atom.state.underline || atom.state.overline || atom.state.strike {
|
|
|
|
|
|
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 lx = cursor_x;
|
|
|
|
|
|
let ly = valign_dy;
|
|
|
|
|
|
let origin: [f64; 2] = [
|
|
|
|
|
|
ins_x + (line_base_x + lx * cos_r - ly * sin_r) as f64,
|
|
|
|
|
|
ins_y + (line_base_y + lx * sin_r + ly * cos_r) as f64,
|
|
|
|
|
|
];
|
|
|
|
|
|
let body = decorated(" ", &atom.state);
|
|
|
|
|
|
let (deco, _) = lff::tessellate_text_run(
|
|
|
|
|
|
[0.0, 0.0],
|
|
|
|
|
|
run_h,
|
|
|
|
|
|
rot,
|
|
|
|
|
|
signed_wf,
|
|
|
|
|
|
oblique,
|
|
|
|
|
|
tracking,
|
|
|
|
|
|
&font_name,
|
|
|
|
|
|
&body,
|
|
|
|
|
|
);
|
|
|
|
|
|
if !deco.is_empty() {
|
|
|
|
|
|
all_strokes.push(TextStroke {
|
|
|
|
|
|
strokes: deco,
|
|
|
|
|
|
origin,
|
|
|
|
|
|
color,
|
|
|
|
|
|
fill_tris: vec![],
|
|
|
|
|
|
run: None,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-31 02:33:47 +03:00
|
|
|
|
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;
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
}
|
|
|
|
|
|
AtomKind::Tab => {
|
2026-07-19 00:45:26 +03:00
|
|
|
|
// A center/right/decimal stop aligns the field that follows
|
|
|
|
|
|
// the tab (the run of words up to the next space/tab) rather
|
|
|
|
|
|
// than just advancing the pen. Decimal places the first `.`
|
|
|
|
|
|
// on the stop; right the field's end; center its middle.
|
2026-07-19 01:10:56 +03:00
|
|
|
|
//
|
|
|
|
|
|
// Stop positions are relative to the paragraph's own left
|
|
|
|
|
|
// edge, so measure against `box_left` (the column's left,
|
|
|
|
|
|
// which `cursor_x` already carries) — not the raw indent, or
|
|
|
|
|
|
// a second column's tabs miss every stop and shoot off right.
|
|
|
|
|
|
let tab_base = box_left + sub.indent_left;
|
|
|
|
|
|
let local = cursor_x - tab_base;
|
2026-07-19 00:45:26 +03:00
|
|
|
|
let stop = sub
|
|
|
|
|
|
.tab_stops
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|ts| ts.position > local + 1e-4)
|
|
|
|
|
|
.copied();
|
|
|
|
|
|
match stop.map(|s| s.kind) {
|
|
|
|
|
|
Some(TabKind::Center | TabKind::Right | TabKind::Decimal) => {
|
2026-07-19 01:10:56 +03:00
|
|
|
|
let sx = tab_base + stop.unwrap().position;
|
2026-07-19 00:45:26 +03:00
|
|
|
|
let mut field_w = 0.0_f32;
|
|
|
|
|
|
let mut before_dot: Option<f32> = None;
|
|
|
|
|
|
for next in &sub.atoms[ai + 1..] {
|
|
|
|
|
|
match &next.kind {
|
|
|
|
|
|
AtomKind::Word(t) => {
|
|
|
|
|
|
if before_dot.is_none() {
|
|
|
|
|
|
if let Some(dp) = t.find('.') {
|
|
|
|
|
|
let pre: String = t[..dp].to_string();
|
|
|
|
|
|
before_dot = Some(
|
|
|
|
|
|
field_w
|
|
|
|
|
|
+ measure_word(
|
|
|
|
|
|
&pre,
|
|
|
|
|
|
&next.state,
|
|
|
|
|
|
entity_h,
|
|
|
|
|
|
base_wf,
|
|
|
|
|
|
&base_font_name,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
field_w += measure_word(
|
|
|
|
|
|
t,
|
|
|
|
|
|
&next.state,
|
|
|
|
|
|
entity_h,
|
|
|
|
|
|
base_wf,
|
|
|
|
|
|
&base_font_name,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
AtomKind::Stack { .. } => {
|
|
|
|
|
|
field_w += atom_width(
|
|
|
|
|
|
next,
|
|
|
|
|
|
entity_h,
|
|
|
|
|
|
base_wf,
|
|
|
|
|
|
&base_font_name,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => break,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let offset = match stop.unwrap().kind {
|
|
|
|
|
|
TabKind::Right => field_w,
|
|
|
|
|
|
TabKind::Center => field_w * 0.5,
|
|
|
|
|
|
// No dot in the field → align its end, as AutoCAD
|
|
|
|
|
|
// does for a decimal tab with no decimal point.
|
|
|
|
|
|
TabKind::Decimal => before_dot.unwrap_or(field_w),
|
|
|
|
|
|
TabKind::Left => 0.0,
|
|
|
|
|
|
};
|
|
|
|
|
|
// The stop wins even when the field is wider than the
|
|
|
|
|
|
// room before it — the dot/edge lands on the stop and
|
|
|
|
|
|
// the field overruns to the left, matching AutoCAD.
|
|
|
|
|
|
cursor_x = sx - offset;
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => {
|
|
|
|
|
|
cursor_x = next_tab_position(
|
|
|
|
|
|
cursor_x,
|
|
|
|
|
|
&sub.tab_stops,
|
2026-07-19 01:10:56 +03:00
|
|
|
|
tab_base,
|
2026-07-19 00:45:26 +03:00
|
|
|
|
entity_h,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.
Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
`layout_mtext`; `\C7;Red`-style codes no longer render as literal
glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
and the LOD-aware path in [scene/tessellate.rs]) — drops the
`strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
the value goes through the MText pipeline (SingleLine stays on the
Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
MText (instead of Text) whenever the value contains MText escapes;
the LOD ladder via `tessellate_entity_dim_text` handles both kinds.
Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
`BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
callers that draw chrome (frames, fills, baselines, greek rects)
share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
`measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
`v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
deleted — all callers migrated.
Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
MTextLayout {
|
|
|
|
|
|
strokes: all_strokes,
|
|
|
|
|
|
line_widths,
|
|
|
|
|
|
line_count: sub_lines.len(),
|
|
|
|
|
|
line_height: line_h,
|
|
|
|
|
|
v_offset,
|
2026-05-31 02:33:47 +03:00
|
|
|
|
glyph_boxes,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-23 23:26:14 +03:00
|
|
|
|
|
2026-06-28 18:44:38 +01:00
|
|
|
|
#[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 {
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
columns: Default::default(),
|
2026-06-28 18:44:38 +01:00
|
|
|
|
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 {
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
columns: Default::default(),
|
2026-06-28 18:44:38 +01:00
|
|
|
|
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"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-07 00:04:11 +03:00
|
|
|
|
|
|
|
|
|
|
#[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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-19 17:16:41 +03:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn font_italic_flag_renders_as_oblique() {
|
|
|
|
|
|
// A `\f…|i1;` italic font has no true-italic glyphs in the stroke/SDF
|
|
|
|
|
|
// faces, so it must render as the same slant the editor's Italic button
|
|
|
|
|
|
// applies (~15°) — not upright.
|
|
|
|
|
|
let (_, it) = first_run(&adapt_mtext_paragraphs("\\fArial|i1;x", 2.5, true));
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
(it.oblique_rad - 15f32.to_radians()).abs() < 1e-4,
|
|
|
|
|
|
"italic flag should slant, got {}",
|
|
|
|
|
|
it.oblique_rad
|
|
|
|
|
|
);
|
|
|
|
|
|
// A non-italic font stays upright.
|
|
|
|
|
|
let (_, up) = first_run(&adapt_mtext_paragraphs("\\fArial|i0;x", 2.5, true));
|
|
|
|
|
|
assert!(up.oblique_rad.abs() < 1e-4, "got {}", up.oblique_rad);
|
|
|
|
|
|
// An explicit `\Q` wins over the italic flag (no double slant).
|
|
|
|
|
|
let (_, q) = first_run(&adapt_mtext_paragraphs("\\Q30;\\fArial|i1;x", 2.5, true));
|
|
|
|
|
|
assert!((q.oblique_rad - 30f32.to_radians()).abs() < 1e-4, "got {}", q.oblique_rad);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 00:04:11 +03:00
|
|
|
|
#[test]
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
fn stacking_keeps_its_halves_apart() {
|
|
|
|
|
|
// `\S` reaches layout as a stack, not as flattened text: the halves
|
|
|
|
|
|
// share one slot, which is what keeps a fraction from widening the line.
|
|
|
|
|
|
let lines = adapt_mtext_paragraphs("\\S1/2;", 2.5, true);
|
|
|
|
|
|
match &lines[0].runs[0].kind {
|
|
|
|
|
|
MTextRunKind::Stack {
|
|
|
|
|
|
numerator,
|
|
|
|
|
|
denominator,
|
|
|
|
|
|
bar,
|
|
|
|
|
|
} => {
|
|
|
|
|
|
assert_eq!((numerator.as_str(), denominator.as_str()), ("1", "2"));
|
|
|
|
|
|
assert!(*bar, "`/` draws a fraction rule");
|
|
|
|
|
|
}
|
|
|
|
|
|
other => panic!("expected a stack run, got {other:?}"),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// `^` is the same stack without the rule.
|
|
|
|
|
|
let lines = adapt_mtext_paragraphs("\\S1^2;", 2.5, true);
|
|
|
|
|
|
match &lines[0].runs[0].kind {
|
|
|
|
|
|
MTextRunKind::Stack { bar, .. } => assert!(!*bar, "`^` is a limit, no rule"),
|
|
|
|
|
|
other => panic!("expected a stack run, got {other:?}"),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Diagonal stays inline `num/den` — that is what a slanted fraction
|
|
|
|
|
|
// reads as, and it needs no second baseline.
|
2026-07-07 00:04:11 +03:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-07-18 23:19:01 +03:00
|
|
|
|
|
2026-07-07 00:04:11 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 22:55:51 +03:00
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod v_anchor_tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
|
|
/// The highest point the layout actually DRAWS, in entity-local units.
|
|
|
|
|
|
/// `glyph_boxes` are the cap box the editor's caret rides on, not the ink,
|
|
|
|
|
|
/// so they cannot answer this.
|
|
|
|
|
|
fn ink_top(l: &MTextLayout) -> f32 {
|
|
|
|
|
|
l.strokes
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.flat_map(|s| {
|
|
|
|
|
|
s.strokes
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.flatten()
|
|
|
|
|
|
.map(move |p| s.origin[1] as f32 + p[1])
|
|
|
|
|
|
})
|
|
|
|
|
|
.fold(f32::MIN, f32::max)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn layout_at_top(value: &str, height: f32) -> MTextLayout {
|
|
|
|
|
|
let style = ResolvedTextStyle {
|
|
|
|
|
|
font_name: "txt".to_string(),
|
|
|
|
|
|
width_factor: 1.0,
|
|
|
|
|
|
oblique_angle: 0.0,
|
|
|
|
|
|
is_backward: false,
|
|
|
|
|
|
is_upside_down: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
layout_mtext(&MTextRenderOpts {
|
feat(mtext): lay out `\N` columns and stack `\S` fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
|
|
|
|
columns: Default::default(),
|
2026-07-15 22:55:51 +03:00
|
|
|
|
value,
|
|
|
|
|
|
insertion: [0.0, 0.0, 0.0],
|
|
|
|
|
|
height,
|
|
|
|
|
|
rect_w: 0.0,
|
|
|
|
|
|
rotation: 0.0,
|
|
|
|
|
|
style: &style,
|
|
|
|
|
|
attach_h_anchor: 0.0,
|
|
|
|
|
|
v_anchor: MTextVAnchor::Top,
|
|
|
|
|
|
line_spacing_factor: 1.0,
|
|
|
|
|
|
vertical_text: false,
|
|
|
|
|
|
want_glyph_boxes: true,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The top of what a line draws sits at the insertion point.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// For ordinary letters that is the cap height and nothing moves. A symbol
|
|
|
|
|
|
/// font's glyphs straddle the cap box on purpose — the diameter sign's
|
|
|
|
|
|
/// stroke overhangs it — and anchoring by the nominal cap hung the glyph
|
|
|
|
|
|
/// above where the drawing expects it, by the size of the overhang.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn a_glyph_that_overhangs_the_cap_still_starts_at_the_insertion_point() {
|
|
|
|
|
|
// The benchmark's own case: a diameter sign at height 3, whose stroke
|
|
|
|
|
|
// reaches 11.2117 of 9 cap units — 3.7372 in world units, not 3.
|
|
|
|
|
|
let top = ink_top(&layout_at_top("{\\Fgdt.shx;n}", 3.0));
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
top.abs() < 0.05,
|
|
|
|
|
|
"the glyph draws down from {top}, not from the insertion point"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// …and plain text must not move: its letters stop at the cap, so the top
|
|
|
|
|
|
/// anchor stays the cap height exactly as before.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn plain_text_still_hangs_from_its_cap_height() {
|
|
|
|
|
|
for s in ["ABC", "abc", "Hello world"] {
|
|
|
|
|
|
let top = ink_top(&layout_at_top(s, 3.0));
|
|
|
|
|
|
assert!(top <= 0.05, "{s:?} drew above the insertion point ({top})");
|
|
|
|
|
|
// An all-lowercase line stops at its x-height and must NOT be
|
|
|
|
|
|
// pulled up to meet the insertion point.
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
top > -3.0,
|
|
|
|
|
|
"{s:?} was pushed down to {top}; the cap should still anchor it"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|