Files
OneCAD-Draft/src/entities/text_support.rs
T
Hakan SevenandClaude Opus 5.5 f537aff75c feat: node graph overlay; move to opencadcodec/opencadkernel/opencadgraph
The codec and kernel repositories and crates are renamed opencadcodec and
opencadkernel. Inside OCS they are depended on under short keys, so code
reads `codec::`, `kernel::` and the new `graph::` (opencadgraph, the node
graph engine). File-format and protocol spellings stay as they were: the
ACIS product id `acadrust`, the ACADRUST_* switches and the plugin
version keys `acadrust_version` / `acadrust_source`.

A button left of MCP on the command line opens a translucent node graph
over the viewport, with a palette of object, input, math, logic, list,
text and geometry nodes added by click or drag. An object node owns
drawing entities and its rows are the Properties panel's own rows for
them: the single-object page is extracted from refresh_properties as
entity_property_sections, values use the control protocol's
property_json, and writes go through set_property_value with a property
target override, so the panel, MCP and the graph share one read/write
path. A list reaching an object row makes one entity per element. Each
graph action is one undo step; the canvas pans and zooms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:34:23 +03:00

3205 lines
131 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use codec::types::aci_table::aci_to_rgb;
use codec::CadDocument;
use crate::scene::convert::acad_to_render::{GlyphRun, TextStroke};
use crate::scene::text::font_face::Face;
use crate::scene::text::lff;
/// A font reference a drawing holds — a style's font file, or an inline `\f`
/// code — turned into an installed family.
///
/// An embedded stroke font wins over anything installed, so `romans.shx` keeps
/// rendering as it did even where a font of that name happens to be installed;
/// only a name no stroke font answers to is looked up among the system faces,
/// first by family and then by file name (see `sysfont::family_for_reference`).
fn resolve_font_reference(reference: &str) -> Option<String> {
if lff::is_builtin(reference) {
return None;
}
crate::scene::text::sysfont::family_for_reference(reference)
}
pub struct ResolvedTextStyle {
pub font_name: String,
pub width_factor: f32,
pub oblique_angle: f32,
pub is_backward: bool,
pub is_upside_down: bool,
pub is_vertical: bool,
}
pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedTextStyle {
let style = document.text_styles.iter().find(|entry| {
entry.name.eq_ignore_ascii_case(style_name)
|| (style_name.trim().is_empty() && entry.name.eq_ignore_ascii_case("Standard"))
});
let mut font_name = if let Some(style) = style {
// A style names a font *file* — `GOST2304_TypeA_italic.ttf`,
// `romans.shx`, sometimes with a directory; `true_type_font` carries the
// same thing for the styles that set it.
let file = if !style.true_type_font.trim().is_empty() {
style.true_type_font.trim()
} else {
style.font_file.trim()
};
if !file.is_empty() {
// A .shx font that resolves on disk (as stored, or next to the
// drawing) renders its REAL stroke glyphs — pass the absolute
// path through so `Face::resolve` picks the SHX face. Fonts fetched
// from the community repository live in the per-user fonts folder,
// so that is searched last, after the drawing folder.
let shx_path = file
.to_ascii_lowercase()
.ends_with(".shx")
.then(|| {
let base = document
.source_path
.as_deref()
.map(std::path::Path::new)
.and_then(|p| p.parent());
crate::io::resolve_image_file(file, base).or_else(|| {
crate::io::font_repo::local_font_file(file)
.map(|path| path.to_string_lossy().into_owned())
})
})
.flatten();
if let Some(p) = shx_path {
p
} else if let Some(family) = resolve_font_reference(file) {
// The file is installed, so its family is the face to draw with.
// Substituting a stroke font instead drops every letter that
// font does not have — for a Cyrillic drawing, all of them.
family
} else {
let basename = file.rsplit(['/', '\\']).next().unwrap_or(file);
let stem = basename.split('.').next().unwrap_or(basename).trim();
if !stem.is_empty() {
stem.to_string()
} else if !style.name.trim().is_empty() {
style.name.trim().to_string()
} else {
"Standard".to_string()
}
}
} else if !style.name.trim().is_empty() {
style.name.trim().to_string()
} else {
"Standard".to_string()
}
} else if style_name.trim().is_empty() {
"Standard".to_string()
} else {
style_name.trim().to_string()
};
if !lff::is_builtin(&font_name) {
if let Some(canonical) = crate::scene::text::sysfont::canonical_family_name(&font_name) {
font_name = canonical;
}
}
// A style's "Big Font" (`chineset.shx`, `hztxt.shx`, …) supplies the
// double-byte characters. Resolve it like the primary font (next to the
// drawing, then the per-user fonts folder) and pair it with the face; an
// unresolvable big font just leaves those characters to the fallback.
if let Some(big) = style
.map(|s| s.big_font_file.trim())
.filter(|big| !big.is_empty() && big.to_ascii_lowercase().ends_with(".shx"))
{
let base = document
.source_path
.as_deref()
.map(std::path::Path::new)
.and_then(|p| p.parent());
let big_path = crate::io::resolve_image_file(big, base).or_else(|| {
crate::io::font_repo::local_font_file(big).map(|p| p.to_string_lossy().into_owned())
});
if let Some(big_path) = big_path {
font_name = Face::with_big_font(&font_name, &big_path);
}
}
ResolvedTextStyle {
font_name,
width_factor: style.map(|s| s.width_factor as f32).unwrap_or(1.0),
oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0),
is_backward: style.map(|s| s.is_backward()).unwrap_or(false),
is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false),
is_vertical: style.map(|s| s.is_vertical).unwrap_or(false),
}
}
pub struct TextLocalBounds {
/// Inked extent (glyph strokes only) — drives vertical alignment, where
/// the cap / baseline geometry is what matters.
pub ink_min: [f32; 2],
pub ink_max: [f32; 2],
/// Pen advance along the baseline, including leading / trailing spaces and
/// inter-glyph spacing. Drives horizontal alignment so spaces in the string
/// keep their width instead of collapsing to the first / last inked glyph.
pub advance: f32,
}
pub fn text_local_bounds(
font_name: &str,
text: &str,
height: f32,
width_factor: f32,
oblique_angle: f32,
) -> Option<TextLocalBounds> {
if text.is_empty() || height <= 0.0 {
return None;
}
let face = Face::resolve(font_name);
let scale = height / 9.0;
let wf = width_factor.abs().clamp(0.01, 100.0);
let ob = oblique_angle.tan();
let mut cursor_x = 0.0_f32;
let mut min_x = f32::INFINITY;
let mut max_x = f32::NEG_INFINITY;
let mut min_y = f32::INFINITY;
let mut max_y = f32::NEG_INFINITY;
let shaping_family = face.ttf_family().or_else(|| {
crate::scene::text::web_font::requires_shaping(text)
.then(|| crate::scene::text::web_font::primary_script().family())
});
if let Some(family) = shaping_family {
if let Some(run) = crate::scene::text::ttf_glyph::shape_run(family, text) {
let advance = run.advance * scale * wf;
for g in &run.glyphs {
for stroke in &g.strokes {
for &[gx, gy] in stroke {
let sx = 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);
}
}
}
if min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite() {
return Some(TextLocalBounds {
ink_min: [min_x, min_y],
ink_max: [max_x, max_y],
advance,
});
} else if advance > 0.0 {
return Some(TextLocalBounds {
ink_min: [0.0, 0.0],
ink_max: [advance, height],
advance,
});
}
}
}
for ch in text.chars() {
if ch == ' ' {
cursor_x += face.word_spacing();
continue;
}
match face.glyph(ch) {
Some(glyph) => {
for stroke in &glyph.strokes {
for &[gx, gy] in stroke {
let sx = (cursor_x + gx) * scale * wf + gy * scale * ob;
let sy = gy * scale;
min_x = min_x.min(sx);
max_x = max_x.max(sx);
min_y = min_y.min(sy);
max_y = max_y.max(sy);
}
}
cursor_x += glyph.advance + face.letter_spacing();
}
None => {
cursor_x += 6.0 + face.letter_spacing();
}
}
}
// Pen advance is measured at the baseline, so oblique shear (which skews x
// only by gy) does not enter it. Valid even for an all-space string.
let advance = cursor_x * scale * wf;
if min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite() {
Some(TextLocalBounds {
ink_min: [min_x, min_y],
ink_max: [max_x, max_y],
advance,
})
} else {
None
}
}
/// Expand DXF `%%x` special-character sequences that appear in both TEXT and MTEXT values:
/// - `%%d` / `%%D` → `°`
/// - `%%p` / `%%P` → `±`
/// - `%%c` / `%%C` → `∅` (U+2205, the native diameter sign)
/// - `%%u` / `%%U` → underline toggle (stripped — not renderable with stroke fonts)
/// - `%%o` / `%%O` → overline toggle (stripped)
/// - `%%%%` → `%`
/// - `%%nnn` (3 decimal digits) → Unicode scalar `nnn`
/// Any unrecognised `%%x` is passed through unchanged.
pub fn resolve_dxf_special_chars(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c != '%' || chars.peek() != Some(&'%') {
out.push(c);
continue;
}
chars.next(); // consume second '%'
match chars.peek().map(|c| c.to_ascii_lowercase()) {
Some('d') => {
chars.next();
out.push('°');
}
Some('p') => {
chars.next();
out.push('±');
}
Some('c') => {
chars.next();
out.push('∅');
}
Some('u') | Some('o') => {
chars.next();
} // toggle codes — strip silently
Some('%') => {
chars.next();
out.push('%');
}
Some(d) if d.is_ascii_digit() => {
let mut digits = String::with_capacity(3);
for _ in 0..3 {
match chars.peek() {
Some(&ch) if ch.is_ascii_digit() => {
digits.push(chars.next().unwrap());
}
_ => break,
}
}
if digits.len() == 3 {
if let Ok(n) = digits.parse::<u32>() {
if let Some(ch) = char::from_u32(n) {
out.push(ch);
continue;
}
}
}
out.push('%');
out.push('%');
out.push_str(&digits);
}
_ => {
out.push('%');
out.push('%');
}
}
}
out
}
// ──────────────────────────────────────────────────────────────────────────
// Rich MTEXT parser — full inline format-code coverage
//
// Recognised codes (DXF MTEXT inline):
// Escapes: \\ \{ \} \~ \t \P \n \N \U+XXXX \u+XXXX
// Toggles: \L\l \O\o \K\k (underline / overline / strike)
// State: \H<v>[x]; \W<v>[x]; \Q<v>; \T<v>[x]; \A<n>;
// \C<aci>; \c<rgb>;
// \f<name>|b<0/1>|i<0/1>|c<n>|p<n>; \F<file>;
// \M+<n>; \X \S<u><sep><l>;
// Paragraph: \p[xi<v>,l<v>,r<v>,q[lcrjd],t<positions>,s<v>...];
// Scope: { ... } push/pop full state
// ──────────────────────────────────────────────────────────────────────────
/// Paragraph alignment encoded inline via `\p...q[lcrjd]...;`.
/// `Justify` / `Distribute` render as `Left` (full inter-word redistribution
/// is not implemented in the stroke renderer).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ParagraphAlign {
Left,
Center,
Right,
Justify,
Distribute,
}
/// Inline colour override (`\C` ACI or `\c` 24-bit true colour). Resolved to
/// linear RGB at render time via the document's ACI table.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum InlineColor {
Aci(u8),
True([f32; 3]),
}
/// Tab-stop alignment kind (from `\pt<L|C|R><pos>` entries). `Center` / `Right`
/// are DXF-spec kinds the parser does not emit yet (only `Left` is produced).
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TabKind {
Left,
Center,
Right,
/// Dot-aligned: the text after the tab places its decimal point on the stop.
Decimal,
}
#[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,
/// Bold run — rendered with a wider SDF pen (thicker strokes).
pub bold: bool,
}
impl Default for RunState {
fn default() -> Self {
Self {
height_mul: 1.0,
width_mul: 1.0,
oblique_rad: 0.0,
tracking: 1.0,
valign: 0,
font: None,
color: None,
underline: false,
overline: false,
strike: false,
bold: false,
}
}
}
#[derive(Clone, Debug)]
pub enum MTextRunKind {
/// Renderable glyph text (DXF specials resolved, decoration markers stripped).
Glyphs(String),
/// `\t` — jump the cursor to the next tab stop (or default tab interval).
/// Handled in the layout matches but not emitted by the parser yet.
#[allow(dead_code)]
Tab,
/// 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,
},
}
#[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>,
/// 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,
/// 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),
}
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,
MTextRunKind::Stack { .. } => false,
})
}
}
/// Font-name stem: drop any path and extension, so a `\F` font path like
/// `C:\\fonts\\arial.ttf` and a plain `\f` name `arial` resolve to the same font.
fn font_stem(name: &str) -> String {
name.rsplit(['/', '\\'])
.next()
.unwrap_or(name)
.split('.')
.next()
.unwrap_or(name)
.to_string()
}
/// Parse an MTEXT string into the layout's `Vec<MTextLine>`, using opencadcodec's
/// structured `mtext_format::parse_mtext` — OCS keeps only the layout engine
/// (`layout_mtext` and callers read `MTextLine`/`RunState`), not a second MTEXT
/// inline parser.
///
/// Representation notes:
/// - DXF `%%d`/`%%p`/`%%c` arrive already resolved to Unicode from opencadcodec;
/// 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 `codec::…::MTextScalar`.
pub fn adapt_mtext_paragraphs(
s: &str,
entity_height: f32,
trim_blank_edges: bool,
) -> Vec<MTextLine> {
use codec::entities::mtext_format::{
parse_mtext, MTextColor, MTextLineAlignment, MTextLineSpacing, MTextParagraphAlignment,
MTextScalar, ParagraphProperties, SpanProperties, StackingType,
};
let entity_height = entity_height.max(1e-6);
let doc = parse_mtext(s, true);
fn map_align(a: MTextParagraphAlignment) -> Option<ParagraphAlign> {
match a {
MTextParagraphAlignment::Left => Some(ParagraphAlign::Left),
MTextParagraphAlignment::Right => Some(ParagraphAlign::Right),
MTextParagraphAlignment::Center => Some(ParagraphAlign::Center),
MTextParagraphAlignment::Justified => Some(ParagraphAlign::Justify),
MTextParagraphAlignment::Distributed => Some(ParagraphAlign::Distribute),
MTextParagraphAlignment::Default => None,
}
}
fn color_of(p: &SpanProperties) -> Option<InlineColor> {
if let Some((r, g, b)) = p.color_rgb {
return Some(InlineColor::True([
r as f32 / 255.0,
g as f32 / 255.0,
b as f32 / 255.0,
]));
}
match p.color {
Some(MTextColor::Index(n)) => Some(InlineColor::Aci(n.min(255) as u8)),
_ => None,
}
}
// 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()
};
let mut lines: Vec<MTextLine> = Vec::new();
let mut carried: Option<(Option<ParagraphAlign>, f32, f32, f32, Vec<TabStop>)> = None;
// 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;
for para in &doc.paragraphs {
let props = &para.properties;
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 => {}
}
let (align, indent_first, indent_left, indent_right, tab_stops) =
if has_own_props(props) || carried.is_none() {
// `\pxi…,l…,r…,t…;` values are multiples of the text height,
// not drawing units, and the first-line indent is relative to
// the left indent (a numbered list's hanging indent is
// `i-1.5,l1.5`: number at the margin, wrapped lines 1.5 h in).
// Taken raw they were a fraction of a unit — no indent at all —
// so every wrapped list line snapped back to the margin.
let left = props.left_margin.unwrap_or(0.0) as f32 * entity_height;
let v = (
props.alignment.and_then(map_align),
left + props.first_line_indent.unwrap_or(0.0) as f32 * entity_height,
left,
props.right_margin.unwrap_or(0.0) as f32 * entity_height,
props
.tab_stops
.iter()
.map(|ts| {
use codec::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 * entity_height,
kind,
}
})
.collect::<Vec<_>>(),
);
carried = Some(v.clone());
v
} else {
carried.clone().unwrap()
};
let mut line = MTextLine {
align,
starts_column: para.starts_column,
indent_first,
indent_left,
indent_right,
tab_stops,
runs: Vec::new(),
space_before: carried_sb,
space_after: carried_sa,
line_spacing: carried_ls,
};
for span in &para.spans {
let p = &span.properties;
let state = RunState {
// Relative `\H…x;` is a factor applied directly; absolute `\H…;`
// is a drawing-unit height resolved against the entity height.
height_mul: match p.height {
Some(MTextScalar::Factor(f)) => f as f32,
Some(MTextScalar::Absolute(a)) => a as f32 / entity_height,
None => 1.0,
},
width_mul: p.width_factor.map(|w| w as f32).unwrap_or(1.0),
// 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,
},
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)),
bold: p.font.as_ref().map(|f| f.bold).unwrap_or(false),
color: color_of(p),
underline: p.stroke.underline(),
overline: p.stroke.overline(),
strike: p.stroke.strikethrough(),
};
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) {
let mut t = st.numerator.clone();
if !st.denominator.is_empty() {
t.push('/');
t.push_str(&st.denominator);
}
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,
});
}
continue;
}
let text = span.text.clone();
if text.is_empty() {
continue;
}
line.runs.push(MTextRun {
kind: MTextRunKind::Glyphs(text),
state,
});
}
lines.push(line);
}
if !trim_blank_edges {
return lines;
}
let start = lines.iter().position(|l| !l.is_blank()).unwrap_or(0);
let end = lines
.iter()
.rposition(|l| !l.is_blank())
.map(|i| i + 1)
.unwrap_or(0);
lines[start..end].to_vec()
}
// Legacy MText helpers (`strip_mtext_codes`, `split_mtext_lines`,
// `measure_mtext_chars`, `word_wrap`) were removed when every text-bearing
// entity switched to the run-aware pipeline below. The pipeline now owns
// per-run width measurement and word-wrap; MTEXT inline parsing now comes from
// opencadcodec via `adapt_mtext_paragraphs`. The supported surface for callers is
// `adapt_mtext_paragraphs`, `layout_mtext`, `mtext_line_count`,
// `text_local_bounds`, and `resolve_dxf_special_chars`.
// ──────────────────────────────────────────────────────────────────────────────
// Shared MText layout / render pipeline
// ──────────────────────────────────────────────────────────────────────────────
//
// `layout_mtext` is the entry point used by every text-bearing entity that
// stores MText-formatted content (MTEXT, MLEADER text content, TABLE cell,
// ATTRIB / ATTDEF with `mtext_flag` set, and DIMENSION `text_override` when
// it carries inline codes).
//
// The pipeline mirrors the MTEXT renderer:
// 1. Parse — via `adapt_mtext_paragraphs` (opencadcodec `parse_mtext`).
// 2. Atomise — turn each MTextLine.runs into a flat sequence of atoms
// (Word / Space / Tab) so the wrapper operates at break boundaries
// while keeping per-character formatting state.
// 3. Wrap — accumulate atoms into sub-lines using paragraph indents and
// tab stops; each Tab jumps the cursor to the next user-defined stop
// (or a 4-em default grid).
// 4. Render — for each sub-line: pick paragraph alignment + indent, walk
// atoms left → right, emit one TextStroke per Word using the atom's
// RunState (height / width / oblique / tracking / font / colour /
// decorations / valign).
//
// In addition to the strokes, the helper returns enough geometry (line
// widths, line height, v_offset, h_anchor) for the caller to draw a frame /
// background rectangle, run a low-detail LOD path, or compute snap bounds.
#[derive(Clone)]
pub enum AtomKind {
Word(String),
Space,
Tab,
/// 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,
},
}
// 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.
/// Each half of a `\S` stack, as a fraction of the run's own height.
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;
#[derive(Clone)]
pub struct LayoutAtom {
pub kind: AtomKind,
pub state: RunState,
pub char_offset: usize,
}
pub fn is_rtl_char(c: char) -> bool {
matches!(
unicode_bidi::bidi_class(c),
unicode_bidi::BidiClass::R
| unicode_bidi::BidiClass::AL
| unicode_bidi::BidiClass::RLE
| unicode_bidi::BidiClass::RLO
| unicode_bidi::BidiClass::RLI
)
}
/// Reorder a line's atoms into visual order (left to right) using the Unicode
/// Bidirectional Algorithm (UBA) Rule L2. If the line contains no RTL characters
/// and is not an RTL paragraph, returns the atoms unchanged.
pub fn reorder_line_atoms(atoms: Vec<LayoutAtom>, is_rtl: bool) -> Vec<LayoutAtom> {
if atoms.len() <= 1 && !is_rtl {
return atoms;
}
let has_rtl = atoms.iter().any(|atom| match &atom.kind {
AtomKind::Word(w) => w.chars().any(is_rtl_char),
_ => false,
});
if !has_rtl && !is_rtl {
return atoms;
}
let mut line_text = String::new();
let mut ranges = Vec::with_capacity(atoms.len());
for atom in &atoms {
let start = line_text.len();
match &atom.kind {
AtomKind::Word(w) => line_text.push_str(w),
AtomKind::Space => line_text.push(' '),
AtomKind::Tab => line_text.push('\t'),
AtomKind::Stack { numerator, denominator, .. } => {
line_text.push_str(numerator);
line_text.push('/');
line_text.push_str(denominator);
}
}
let end = line_text.len();
ranges.push(start..end);
}
let base_level = if is_rtl {
Some(unicode_bidi::Level::rtl())
} else {
Some(unicode_bidi::Level::ltr())
};
let bidi = unicode_bidi::BidiInfo::new(&line_text, base_level);
if bidi.paragraphs.is_empty() {
return atoms;
}
let atom_levels: Vec<u8> = ranges
.iter()
.map(|r| {
let mut lvl = None;
// `bidi.levels` is indexed by byte, so walk characters rather than
// bytes to avoid slicing inside a multi-byte character.
for (offset, c) in line_text[r.clone()].char_indices() {
let i = r.start + offset;
if i < bidi.levels.len() {
match unicode_bidi::bidi_class(c) {
unicode_bidi::BidiClass::L
| unicode_bidi::BidiClass::R
| unicode_bidi::BidiClass::AL => {
lvl = Some(bidi.levels[i].number());
break;
}
_ => {}
}
}
}
lvl.unwrap_or_else(|| {
if r.start < bidi.levels.len() {
bidi.levels[r.start].number()
} else if is_rtl {
1
} else {
0
}
})
})
.collect();
let max_level = atom_levels.iter().copied().max().unwrap_or(0);
let min_odd_level = atom_levels
.iter()
.copied()
.filter(|&l| l % 2 != 0)
.min()
.unwrap_or(1);
let mut order: Vec<usize> = (0..atoms.len()).collect();
for level in (min_odd_level..=max_level).rev() {
let mut start = 0;
while start < order.len() {
if atom_levels[order[start]] >= level {
let mut end = start + 1;
while end < order.len() && atom_levels[order[end]] >= level {
end += 1;
}
order[start..end].reverse();
start = end;
} else {
start += 1;
}
}
}
let mut opt_atoms: Vec<Option<LayoutAtom>> = atoms.into_iter().map(Some).collect();
let mut reordered = Vec::with_capacity(opt_atoms.len());
for idx in order {
if let Some(atom) = opt_atoms[idx].take() {
reordered.push(atom);
}
}
reordered
}
pub fn run_scale(state: &RunState, entity_h: f32, base_wf: f32) -> f32 {
(state.height_mul * entity_h / 9.0) * (state.width_mul * base_wf.abs())
}
pub fn resolve_font<'a>(state: &'a RunState, base: &'a str) -> std::borrow::Cow<'a, str> {
let Some(font) = state.font.as_deref().map(str::trim).filter(|f| !f.is_empty()) else {
return std::borrow::Cow::Borrowed(base);
};
if lff::is_builtin(font) {
return std::borrow::Cow::Borrowed(font);
}
match resolve_font_reference(font) {
Some(canonical) => std::borrow::Cow::Owned(canonical),
None => std::borrow::Cow::Borrowed(base),
}
}
/// Whether a line may break between `prev` and `next`. Ideographic text has
/// no spaces, so a break is allowed on either side of a full-width character,
/// except before a closing mark (`,。、)」…`) or after an opening one
/// (`(「《…`) — the basic kinsoku rule.
pub(crate) fn cjk_break_between(prev: char, next: char) -> bool {
use crate::scene::text::ttf_glyph::is_full_width;
const CLOSING: &str = ",。、;:?!)」』】〕》〉』﹚﹞.~…‥、〞”’,.;:!?)]}%";
const OPENING: &str = "(「『【〔《〈﹙﹝“‘([{";
if !(is_full_width(prev) || is_full_width(next)) {
return false;
}
!CLOSING.contains(next) && !OPENING.contains(prev)
}
pub fn measure_word(
text: &str,
state: &RunState,
entity_h: f32,
base_wf: f32,
base_font: &str,
) -> f32 {
let scale = run_scale(state, entity_h, base_wf);
let font_name = resolve_font(state, base_font);
let face = Face::resolve(&font_name);
let shaping_family = face.ttf_family().or_else(|| {
crate::scene::text::web_font::requires_shaping(text)
.then(|| crate::scene::text::web_font::primary_script().family())
});
if let Some(fam) = shaping_family {
if let Some(run) = crate::scene::text::ttf_glyph::shape_run(fam, text) {
return run.advance * scale;
}
}
let mut w = 0.0_f32;
for ch in text.chars() {
w += match face.glyph(ch) {
Some(g) => (g.advance + face.letter_spacing() * state.tracking) * scale,
None => (6.0 + face.letter_spacing() * state.tracking) * scale,
};
}
w
}
pub fn measure_space(state: &RunState, entity_h: f32, base_wf: f32, base_font: &str) -> f32 {
let scale = run_scale(state, entity_h, base_wf);
let font_name = resolve_font(state, base_font);
Face::resolve(&font_name).word_spacing() * scale
}
pub fn atom_width(atom: &LayoutAtom, entity_h: f32, base_wf: f32, base_font: &str) -> f32 {
match &atom.kind {
AtomKind::Word(t) => measure_word(t, &atom.state, entity_h, base_wf, base_font),
AtomKind::Space => measure_space(&atom.state, entity_h, base_wf, base_font),
AtomKind::Tab => 0.0,
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)
}
}
}
/// 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;
// 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;
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 {
// A stack is one unbreakable slot — it wraps exactly like a word.
AtomKind::Word(_) | AtomKind::Stack { .. } => {
let w = atom_width(&atom, entity_h, base_wf, base_font);
let max_w = line_max_w(subline_idx);
if !cur.is_empty() && cur_w + w > max_w && !after_align_tab {
while matches!(cur.last().map(|a| &a.kind), Some(AtomKind::Space)) {
cur.pop();
}
sublines.push(std::mem::take(&mut cur));
cur_w = 0.0;
subline_idx += 1;
}
after_align_tab = false;
cur.push(atom);
cur_w += w;
}
AtomKind::Space => {
after_align_tab = false;
if cur.is_empty() {
continue;
}
cur_w += atom_width(&atom, entity_h, base_wf, base_font);
cur.push(atom);
}
AtomKind::Tab => {
let start_x = line_start_x(subline_idx);
let 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);
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);
}
after_align_tab = is_align;
}
}
}
if !cur.is_empty() {
sublines.push(cur);
}
if sublines.is_empty() {
sublines.push(Vec::new());
}
sublines
}
pub fn line_total_width(
atoms: &[LayoutAtom],
entity_h: f32,
base_wf: f32,
base_font: &str,
line_start_x: f32,
indent_left: f32,
tab_stops: &[TabStop],
) -> f32 {
let mut x = line_start_x;
for atom in atoms {
match atom.kind {
AtomKind::Tab => {
x = next_tab_position(x, tab_stops, indent_left, entity_h);
}
_ => x += atom_width(atom, entity_h, base_wf, base_font),
}
}
x - line_start_x
}
pub fn resolve_inline_color(c: &InlineColor) -> Option<[f32; 3]> {
match c {
InlineColor::Aci(idx) => aci_to_rgb(*idx).map(|(r, g, b)| {
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]
}),
InlineColor::True(rgb) => Some(*rgb),
}
}
/// Wrap a run's glyph text with MTEXT decoration markers so lff's
/// `tessellate_text_run` emits the underline / overline / strikethrough
/// strokes for us — keeps decoration geometry in one place rather than
/// duplicating the y-position constants.
fn decorated(text: &str, state: &RunState) -> String {
if !(state.underline || state.overline || state.strike) {
return text.to_string();
}
let mut s = String::with_capacity(text.len() + 6);
if state.underline {
s.push_str("\\L");
}
if state.overline {
s.push_str("\\O");
}
if state.strike {
s.push_str("\\K");
}
s.push_str(text);
if state.underline {
s.push_str("\\l");
}
if state.overline {
s.push_str("\\o");
}
if state.strike {
s.push_str("\\k");
}
s
}
#[derive(Clone, Copy, Debug)]
pub enum MTextVAnchor {
/// Block top edge at insertion (first line's cap = insertion.y).
Top,
/// Block midpoint at insertion.
Middle,
/// Block bottom edge at insertion (last line's baseline = insertion.y).
Bottom,
/// MLEADER `MiddleOfTopLine` — first line's vertical centre at insertion.
MiddleOfTopLine,
/// MLEADER `MiddleOfBottomLine` — last line's vertical centre at insertion.
MiddleOfBottomLine,
/// MLEADER `BottomOfTopLineUnderline*` — first line's baseline at insertion.
BottomOfTopLine,
}
/// Flow axis + signed attachment-side factor for a text box's width/wrap
/// grip. ONE place for every text-bearing entity (MTEXT, MLEADER, …):
///
/// - `dir`: the flow axis — the rotated baseline for horizontal text, DOWN
/// the column for top-to-bottom text.
/// - `k`: which side of the insertion the box actually forms on, from the
/// attachment that governs the flow axis: start-attached `+1`, centre
/// `±half`, end-attached `-1` (a right-attached horizontal text forms LEFT
/// of its insertion; a bottom-anchored vertical text forms UP).
///
/// Grip position = `insertion + dir · (k · width)`; a drag re-derives
/// `width = proj(dir) / k`, so pulling the grip on either side widens the
/// same box.
pub fn flow_grip_axis(
rotation: f64,
vertical: bool,
h_anchor: f32,
v_anchor: MTextVAnchor,
) -> (glam::DVec3, f64) {
let (c, s) = (rotation.cos(), rotation.sin());
let dir = if vertical {
glam::DVec3::new(s, -c, 0.0)
} else {
glam::DVec3::new(c, s, 0.0)
};
let k = if vertical {
match v_anchor {
MTextVAnchor::Top
| MTextVAnchor::MiddleOfTopLine
| MTextVAnchor::BottomOfTopLine => 1.0,
MTextVAnchor::Middle => 0.5,
MTextVAnchor::Bottom | MTextVAnchor::MiddleOfBottomLine => -1.0,
}
} else if h_anchor >= 0.75 {
-1.0
} else if h_anchor >= 0.25 {
0.5
} else {
1.0
};
(dir, k)
}
/// 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` fixes every baseline advance to the entity spacing. `false`
/// treats it as a minimum and expands for taller inline runs.
pub exact_line_spacing: bool,
/// User-defined text-box/column height. Zero means content-driven.
pub rectangle_height: f32,
/// `true` when the entity is laid out top-to-bottom (DXF code 71 = 2).
pub vertical_text: bool,
/// When true, `layout_mtext` also fills `MTextLayout::glyph_boxes` with
/// one world-space box per visible character (used by the MText editor's
/// click-to-select preview). Off in the hot render path.
pub want_glyph_boxes: bool,
/// 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 an explicit `\N` break or when the
/// configured manual/automatic column height is exhausted.
pub(crate) const MAX_MTEXT_COLUMNS: i32 = 256;
pub(crate) fn clamp_mtext_column_count(count: i32) -> i32 {
count.clamp(1, MAX_MTEXT_COLUMNS)
}
#[derive(Clone, 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,
/// Whether logical column flow starts at the opposite side.
pub flow_reversed: bool,
/// Dynamic-column height is balanced from content when true.
pub auto_height: bool,
/// Optional manual height for each logical column.
pub heights: Vec<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 {
let physical = if self.flow_reversed {
self.count.saturating_sub(1).saturating_sub(i)
} else {
i
};
physical as f32 * (self.width + self.gutter)
}
fn total_width(&self) -> f32 {
self.width * self.count as f32
+ self.gutter.max(0.0) * self.count.saturating_sub(1) as f32
}
}
/// 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,
pub is_rtl: bool,
}
/// Output of [`layout_mtext`]: stroke groups + the geometry the caller
/// needs for surrounding chrome (frame / fill / LOD baseline-or-rect).
pub struct MTextLayout {
/// One TextStroke per Word atom (Tab / Space contribute only to cursor
/// advance). The `color` field on each stroke carries the inline
/// `\C` / `\c` override when one was set, otherwise `None`.
pub strokes: Vec<TextStroke>,
/// Per-sub-line width in entity-local (pre-rotation) units. Includes
/// any trailing whitespace that survived the trim — kept in sync with
/// the cursor advance so the alignment numbers and the visible glyphs
/// line up.
pub line_widths: Vec<f32>,
/// Sub-line count (≥ 1; an entity with an empty value still reports 1).
pub line_count: usize,
/// Baseline-to-baseline gap used when stepping between sub-lines.
pub line_height: f32,
/// Y of the first sub-line's baseline relative to the insertion point
/// (in the entity-local, pre-rotation frame).
pub v_offset: f32,
/// One world-space AABB per visible character — only populated when
/// `MTextRenderOpts::want_glyph_boxes` is set.
pub glyph_boxes: Vec<GlyphBox>,
/// Pre-rotation glyph bounds in entity-local coordinates relative to the
/// insertion point: [min_x, min_y, max_x, max_y]. Inverted (min > max)
/// when the layout produced no glyphs. Valid for horizontal AND vertical
/// flow, so frame/background boxes can wrap what was actually drawn.
pub local_bounds: [f32; 4],
}
/// Lay out and render an MText-formatted value, returning the stroke
/// groups plus the layout metrics needed by callers that draw chrome
/// (text frame, background fill, low-detail LOD substitutes) around the
/// text block.
pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
let base_font_name = opts.style.font_name.clone();
let base_font = Face::resolve(&base_font_name);
let base_wf_abs = opts.style.width_factor.max(0.01);
let base_wf = if opts.style.is_backward { -base_wf_abs } else { base_wf_abs };
let base_oblique = opts.style.oblique_angle;
let entity_h = opts.height;
let rect_w = opts.rect_w;
// ── 1. Parse ─────────────────────────────────────────────────────────
// The editor (want_glyph_boxes) keeps blank edges so a freshly typed
// trailing newline yields an empty paragraph the caret can sit on.
let paragraphs = adapt_mtext_paragraphs(opts.value, entity_h, !opts.want_glyph_boxes);
// ── 2. Atomise + wrap each paragraph into sub-lines ──────────────────
struct SubLine {
atoms: Vec<LayoutAtom>,
align: Option<ParagraphAlign>,
is_rtl: bool,
indent_first: f32,
indent_left: f32,
indent_right: f32,
tab_stops: Vec<TabStop>,
is_first_in_paragraph: bool,
starts_column: bool,
/// Which column this line lives in; always 0 without a column layout.
column: usize,
/// 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>,
}
let cols = opts.columns.clone();
// 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;
let mut sub_lines: Vec<SubLine> = Vec::new();
let mut doc_char_offset: usize = 0;
for (para_idx, para) in paragraphs.iter().enumerate() {
if cols.active() && para.starts_column {
column = (column + 1).min(last_col);
}
if para_idx > 0 {
doc_char_offset += 1;
}
let mut atoms: Vec<LayoutAtom> = Vec::new();
for run in &para.runs {
match &run.kind {
MTextRunKind::Glyphs(text) => {
let mut word = String::new();
let mut word_start = doc_char_offset;
for ch in text.chars() {
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(' ');
doc_char_offset += 1;
} else if ch == ' ' || ch == '\t' {
if !word.is_empty() {
atoms.push(LayoutAtom {
kind: AtomKind::Word(std::mem::take(&mut word)),
state: run.state.clone(),
char_offset: word_start,
});
}
// A literal tab (opencadcodec keeps `^I` / `\t` as a tab
// char in the span) advances to the paragraph's next
// tab stop, aligning the field that follows it.
atoms.push(LayoutAtom {
kind: if ch == '\t' {
AtomKind::Tab
} else {
AtomKind::Space
},
state: run.state.clone(),
char_offset: doc_char_offset,
});
doc_char_offset += 1;
word_start = doc_char_offset;
} else {
// CJK text carries no spaces: every ideograph is a
// wrap opportunity (bar the usual "no line starts
// with a closing mark / ends with an opening one"
// rule), so a long Chinese sentence must not stay
// one unbreakable word that overshoots its column.
if let Some(prev) = word.chars().last() {
if cjk_break_between(prev, ch) {
atoms.push(LayoutAtom {
kind: AtomKind::Word(std::mem::take(&mut word)),
state: run.state.clone(),
char_offset: word_start,
});
word_start = doc_char_offset;
}
}
word.push(ch);
doc_char_offset += 1;
}
}
if !word.is_empty() {
atoms.push(LayoutAtom {
kind: AtomKind::Word(word),
state: run.state.clone(),
char_offset: word_start,
});
}
}
MTextRunKind::Tab => {
atoms.push(LayoutAtom {
kind: AtomKind::Tab,
state: run.state.clone(),
char_offset: doc_char_offset,
});
doc_char_offset += 1;
}
MTextRunKind::Stack {
numerator,
denominator,
bar,
} => {
let slots = numerator.chars().count()
+ denominator.chars().count()
+ usize::from(!denominator.is_empty());
atoms.push(LayoutAtom {
kind: AtomKind::Stack {
numerator: numerator.clone(),
denominator: denominator.clone(),
bar: *bar,
},
state: run.state.clone(),
char_offset: doc_char_offset,
});
doc_char_offset += slots;
}
}
}
// Trim leading + trailing Space atoms so line_w / cursor_start agree
// on the paragraph's visible content. Without this a stray trailing
// space measures wider than it draws and centring / right-alignment
// is off by half a space-width.
//
// Skipped when emitting glyph boxes (the MText editor) so a space the
// user just typed at the end keeps a selectable box and the caret can
// sit after it.
if !opts.want_glyph_boxes {
let first_word = atoms
.iter()
.position(|a| !matches!(a.kind, AtomKind::Space))
.unwrap_or(atoms.len());
atoms.drain(..first_word);
while matches!(atoms.last().map(|a| &a.kind), Some(AtomKind::Space)) {
atoms.pop();
}
}
let is_rtl_para = {
let mut strong_rtl = None;
for atom in &atoms {
match &atom.kind {
AtomKind::Word(w) => {
for ch in w.chars() {
match unicode_bidi::bidi_class(ch) {
unicode_bidi::BidiClass::R | unicode_bidi::BidiClass::AL => {
strong_rtl = Some(true);
break;
}
unicode_bidi::BidiClass::L => {
strong_rtl = Some(false);
break;
}
_ => {}
}
}
if strong_rtl.is_some() {
break;
}
}
_ => {}
}
}
strong_rtl.unwrap_or(false)
};
// 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. Vertical flow never pre-wraps
// horizontally — there rect_w is the COLUMN length, applied by the
// render's own down-the-column word wrap.
let wrap_w = if opts.vertical_text {
0.0
} else if cols.active() {
cols.width
} else {
rect_w
};
let wrapped = wrap_paragraph(
atoms,
wrap_w,
para.indent_first,
para.indent_left,
para.indent_right,
&para.tab_stops,
entity_h,
base_wf,
&base_font_name,
);
let n_wrapped = wrapped.len();
for (idx, atoms) in wrapped.into_iter().enumerate() {
let atoms = if opts.vertical_text {
atoms
} else {
reorder_line_atoms(atoms, is_rtl_para)
};
sub_lines.push(SubLine {
atoms,
align: para.align,
is_rtl: is_rtl_para,
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,
starts_column: idx == 0 && para.starts_column,
column,
// 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,
});
}
}
if sub_lines.is_empty() {
sub_lines.push(SubLine {
atoms: Vec::new(),
align: None,
is_rtl: false,
indent_first: 0.0,
indent_left: 0.0,
indent_right: 0.0,
tab_stops: Vec::new(),
is_first_in_paragraph: true,
starts_column: false,
column: 0,
space_before: 0.0,
space_after: 0.0,
line_spacing: None,
});
}
// ── 3. Block geometry (line spacing, attachment, rotation) ───────────
let ls_factor = if opts.line_spacing_factor > 0.0 {
opts.line_spacing_factor
} else {
1.0
};
// DXF code 44 — multiplier on the default 5/3-em baseline-to-baseline gap.
let line_h = entity_h * ls_factor * (5.0 / 3.0) * base_font.line_spacing();
let h = entity_h;
// How far the first line's glyphs actually reach above the baseline, when
// that is further than the line's own cap height.
//
// 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);
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])
})
.fold(first_line_cap, f32::max)
})
.unwrap_or(h);
// 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`.
// 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;
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);
let mh = if mh > 0.0 {
last_line_h = mh;
mh
} else {
last_line_h
};
// A paragraph's own `\psm#`/`\pse#` overrides the entity factor for
// its lines; `Exact` fixes the baseline gap outright.
let entity_gap = entity_h * ls_factor * (5.0 / 3.0) * base_font.line_spacing();
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()
}
_ if opts.exact_line_spacing => entity_gap,
_ => (mh * ls_factor * (5.0 / 3.0) * base_font.line_spacing())
.max(entity_gap),
}
})
.collect();
// Flow lines into the next column when the active column's configured
// height is exhausted. An explicit `\N` already defines the dynamic flow;
// balancing it again would consume another column before that break.
// Static/manual columns use their stored height (falling back to the entity
// rectangle height).
let has_explicit_column_break = sub_lines.iter().any(|line| line.starts_column);
if cols.active() && !(cols.auto_height && has_explicit_column_break) {
let mut pending_after = 0.0_f32;
let total_advance: f32 = sub_lines
.iter()
.enumerate()
.map(|(index, line)| {
if line.starts_column {
pending_after = 0.0;
}
let spacing = if line.is_first_in_paragraph {
line.space_before + pending_after
} else {
0.0
};
pending_after = line.space_after;
per_line_h.get(index).copied().unwrap_or(entity_h) + spacing
})
.sum();
let balanced = (total_advance / cols.count.max(1) as f32).max(entity_h);
let mut current_column = 0usize;
let mut used = 0.0_f32;
let mut pending_after = 0.0_f32;
for (index, line) in sub_lines.iter_mut().enumerate() {
if line.starts_column {
current_column = (current_column + 1).min(cols.count - 1);
used = 0.0;
pending_after = 0.0;
}
let limit = if cols.auto_height {
balanced
} else {
cols.heights
.get(current_column)
.copied()
.filter(|height| *height > 0.0)
.unwrap_or(opts.rectangle_height)
};
let line_advance = per_line_h.get(index).copied().unwrap_or(entity_h);
let spacing = if line.is_first_in_paragraph {
line.space_before + pending_after
} else {
0.0
};
let mut advance = line_advance + spacing;
if limit > 0.0
&& used > 0.0
&& used + advance > limit
&& current_column + 1 < cols.count
{
current_column += 1;
used = 0.0;
advance = line_advance
+ if line.is_first_in_paragraph {
line.space_before
} else {
0.0
};
}
line.column = current_column;
used += advance;
pending_after = line.space_after;
}
}
// 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()
};
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);
let v_offset = match opts.v_anchor {
MTextVAnchor::Top => -first_line_ascent,
MTextVAnchor::Middle => (total_depth - h) * 0.5,
MTextVAnchor::Bottom => total_depth,
MTextVAnchor::MiddleOfTopLine => -h * 0.5,
MTextVAnchor::MiddleOfBottomLine => total_depth - h * 0.5,
MTextVAnchor::BottomOfTopLine => 0.0,
};
let attach_h_anchor = opts.attach_h_anchor;
let block_width = if cols.active() {
cols.total_width()
} else {
rect_w
};
let box_left = -attach_h_anchor * block_width;
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 local_bounds = [f32::MAX, f32::MAX, f32::MIN, f32::MIN];
// Vertical-flow block metrics: each sub-line is a COLUMN (1.2h step), each
// atom cell 1.4h tall. The justification anchors place this block the way
// the horizontal path places its line stack.
// With a defined width (the flow grip, measuring DOWN the column for
// vertical text), a column wraps: characters past the limit spill into a
// fresh column to the right.
let v_cell = entity_h * 1.4;
let v_col_limit: usize = if opts.vertical_text && rect_w > 0.0 {
((rect_w / v_cell).floor() as usize).max(1)
} else {
usize::MAX
};
let (v_block_w, v_block_h) = if opts.vertical_text {
// Mirror the render's WORD-boundary column wrap exactly (a word that
// would overrun moves whole to the next column; an over-long word
// overflows its own column; a space at a column start/break is
// consumed) — a char-count estimate here under-counts columns and
// over/under-sizes the block, which drags every non-start-anchored
// text away from its attachment.
let sim_line = |sub: &SubLine| -> (usize, usize) {
let mut cols = 1usize;
let mut cur = 0usize;
let mut tallest = 0usize;
for a in &sub.atoms {
match &a.kind {
AtomKind::Word(t) => {
let w = t.chars().count();
if v_col_limit != usize::MAX && cur > 0 && cur + w > v_col_limit {
cols += 1;
cur = 0;
}
cur += w;
tallest = tallest.max(cur);
}
_ => {
if cur == 0 {
continue;
}
if v_col_limit != usize::MAX && cur + 1 > v_col_limit {
cols += 1;
cur = 0;
} else {
cur += 1;
tallest = tallest.max(cur);
}
}
}
}
(cols, tallest.max(1))
};
let mut total_cols = 0usize;
let mut tallest = 0usize;
for sub in &sub_lines {
let (c, t) = sim_line(sub);
total_cols += c;
tallest = tallest.max(t);
}
(
total_cols.max(1) as f32 * entity_h * 1.2,
tallest as f32 * v_cell,
)
} else {
(0.0, 0.0)
};
// Running column index across sub-lines (vertical flow): overflow columns
// shift every later paragraph's column right.
let mut v_col: usize = 0;
let mut line_widths: Vec<f32> = Vec::with_capacity(sub_lines.len());
let mut glyph_boxes: Vec<GlyphBox> = Vec::new();
let mut vis: usize = 0;
// Transform an entity-local point to world space (mirrors the stroke
// origin maths) so glyph boxes line up with the drawn glyphs.
let to_world = |line_base_x: f32, line_base_y: f32, lx: f32, ly: f32| -> (f32, f32) {
let wdx = lx * cos_r - ly * sin_r;
let wdy = lx * sin_r + ly * cos_r;
(
ins_x as f32 + line_base_x + wdx,
ins_y as f32 + line_base_y + wdy,
)
};
for (i, sub) in sub_lines.iter().enumerate() {
// 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)
};
if opts.vertical_text && i > 0 {
// Each paragraph starts a fresh column after the previous
// paragraph's (possibly wrapped) columns.
v_col += 1;
}
let v_col_start = v_col;
let (line_lx, line_ly) = if opts.vertical_text {
// Anchor the column block: X by the attachment's horizontal
// anchor over the block width, Y so the block hangs below /
// straddles / sits above the insertion per the vertical anchor
// (columns run DOWN from their line_ly).
let top_y = match opts.v_anchor {
MTextVAnchor::Top | MTextVAnchor::MiddleOfTopLine => 0.0,
MTextVAnchor::BottomOfTopLine => entity_h * 1.4,
MTextVAnchor::Middle => v_block_h * 0.5,
MTextVAnchor::Bottom | MTextVAnchor::MiddleOfBottomLine => v_block_h,
};
(
-attach_h_anchor * v_block_w + v_col as f32 * entity_h * 1.2,
top_y,
)
} else {
(0.0, line_y_offset[i] + v_offset)
};
let line_base_x = line_lx * cos_r - line_ly * sin_r;
let line_base_y = line_lx * sin_r + line_ly * cos_r;
let content_left = if rect_w > 0.0 {
box_left
+ if sub.is_rtl {
sub.indent_right
} else 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
- if sub.is_rtl {
if sub.is_first_in_paragraph {
sub.indent_first
} else {
sub.indent_left
}
} else {
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 => {
if sub.is_rtl {
1.0
} else {
attach_h_anchor
}
}
};
let line_w = line_total_width(
&sub.atoms,
entity_h,
base_wf,
&base_font_name,
0.0,
sub.indent_left,
&sub.tab_stops,
);
line_widths.push(line_w);
let cursor_start = if rect_w > 0.0 {
let content_w = (content_right - content_left).max(0.0);
content_left + (content_w - line_w) * line_anchor
} else if line_anchor > 0.0 {
-line_w * line_anchor
} else {
0.0
};
let line_max_h = sub
.atoms
.iter()
.map(|a| a.state.height_mul * entity_h)
.fold(entity_h, f32::max);
// A paragraph break (explicit `\n` / `\P`) that started this line gets
// a zero-width caret slot at the line start, so the MText editor can
// place the caret on a fresh/empty line. 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.
if opts.want_glyph_boxes && i > 0 && sub.is_first_in_paragraph {
let caret_h = line_text_h.get(i).copied().unwrap_or(entity_h);
let (ax, ay) = to_world(line_base_x, line_base_y, cursor_start, 0.0);
let (_, by) = to_world(line_base_x, line_base_y, cursor_start, caret_h);
glyph_boxes.push(GlyphBox {
vis,
xmin: ax,
xmax: ax,
ymin: ay.min(by),
ymax: ay.max(by),
is_rtl: sub.is_rtl,
});
vis += 1;
}
// 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)
}
};
let mut cursor_x = if opts.vertical_text { 0.0 } else { cursor_start };
for (ai, atom) in sub.atoms.iter().enumerate() {
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);
if opts.vertical_text {
// Vertical (top-to-bottom) text: every character sits
// in its own cell stacked DOWN the column — the line
// cursor runs down, not across. Each char becomes its
// own single-glyph run so the SDF path draws it at its
// cell origin; 1.4×height is the classic vertical cell
// advance.
let color =
atom.state.color.as_ref().and_then(resolve_inline_color);
let cell = run_h * 1.4;
// Word wrap, vertically: a word that would overrun the
// column limit moves WHOLE to the next column (the
// vertical analogue of space-boundary wrapping). A word
// longer than the limit overflows its own column, like
// a long word overflows a narrow horizontal box.
let word_len = text.chars().count() as f32 * cell;
if v_col_limit != usize::MAX
&& cursor_x > 0.0
&& cursor_x + word_len > v_col_limit as f32 * v_cell + 1e-3
{
v_col += 1;
cursor_x = 0.0;
}
for ch in text.chars() {
let col_dx = (v_col - v_col_start) as f32 * entity_h * 1.2;
let s = ch.to_string();
let (strokes, fill_tris) = lff::tessellate_text_run(
[0.0, 0.0],
run_h,
rot,
signed_wf,
oblique,
atom.state.tracking,
&font_name,
&s,
);
let ly = -(cursor_x + cell);
let world_dx = col_dx * cos_r - ly * sin_r;
let world_dy = col_dx * 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,
];
all_strokes.push(TextStroke {
strokes,
origin,
color,
fill_tris,
plane: None,
run: Some(GlyphRun {
text: s,
font: font_name.to_string(),
height: run_h,
rotation: rot,
width_factor: signed_wf,
oblique,
tracking: atom.state.tracking,
bold: atom.state.bold,
}),
});
let gx = line_lx + col_dx;
let (gy0, gy1) = (line_ly + ly, line_ly + ly + run_h);
local_bounds[0] = local_bounds[0].min(gx);
local_bounds[1] = local_bounds[1].min(gy0);
local_bounds[2] = local_bounds[2].max(gx + run_h);
local_bounds[3] = local_bounds[3].max(gy1);
vis += 1;
cursor_x += cell;
}
continue;
}
// 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
};
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,
];
// Glyphs from the plain word — the SDF run and the stroke
// fallback both draw clean characters.
let (strokes, fill_tris) = lff::tessellate_text_run(
[0.0, 0.0],
run_h,
rot,
signed_wf,
oblique,
tracking,
&font_name,
text,
);
let glyph_n = strokes.len();
all_strokes.push(TextStroke {
strokes,
origin,
color,
fill_tris,
plane: None,
run: Some(GlyphRun {
text: text.clone(),
font: font_name.to_string(),
height: run_h,
rotation: rot,
width_factor: signed_wf,
oblique,
tracking,
bold: atom.state.bold,
}),
});
// 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![],
plane: None,
run: None,
});
}
}
if opts.want_glyph_boxes {
let run_h = atom.state.height_mul * entity_h;
let count = text.chars().count();
let is_rtl = text.chars().any(is_rtl_char);
if is_rtl && count > 0 {
let word_w = if tracking != atom.state.tracking {
let mut st = atom.state.clone();
st.tracking = tracking;
measure_word(text, &st, entity_h, base_wf, &base_font_name)
} else {
measure_word(text, &atom.state, entity_h, base_wf, &base_font_name)
};
let slot_w = word_w / count as f32;
for ci in 0..count {
let cx = cursor_x + ci as f32 * slot_w;
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 + slot_w, ly + run_h);
glyph_boxes.push(GlyphBox {
vis: atom.char_offset + count - 1 - ci,
xmin: ax.min(bx),
xmax: ax.max(bx),
ymin: ay.min(by),
ymax: ay.max(by),
is_rtl: true,
});
}
} else {
// Per-character boxes, advancing exactly as
// `measure_word` does so they track the glyphs.
let scale = run_scale(&atom.state, entity_h, base_wf);
let face = Face::resolve(&font_name);
let mut cx = cursor_x;
for (ci, ch) in text.chars().enumerate() {
let adv = match face.glyph(ch) {
Some(g) => {
(g.advance + face.letter_spacing() * tracking) * scale
}
None => (6.0 + face.letter_spacing() * tracking) * scale,
};
let (ax, ay) = to_world(line_base_x, line_base_y, cx, ly);
let (bx, by) = to_world(line_base_x, line_base_y, cx + adv, ly + run_h);
glyph_boxes.push(GlyphBox {
vis: atom.char_offset + ci,
xmin: ax.min(bx),
xmax: ax.max(bx),
ymin: ay.min(by),
ymax: ay.max(by),
is_rtl: false,
});
cx += adv;
}
}
}
// Advance by the same tracking the glyphs drew with, so a
// distributed word's letters and the atoms after it stay put.
let word_x0 = lx;
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);
}
local_bounds[0] = local_bounds[0].min(word_x0);
local_bounds[1] = local_bounds[1].min(line_ly + ly);
local_bounds[2] = local_bounds[2].max(cursor_x);
local_bounds[3] = local_bounds[3].max(line_ly + ly + run_h);
}
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,
plane: None,
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 opts.want_glyph_boxes {
let slots = numerator.chars().count()
+ denominator.chars().count()
+ usize::from(!denominator.is_empty());
if slots > 0 {
let top = valign_dy + run_h * (STACK_RAISE + STACK_HALF_SCALE);
for index in 0..slots {
let x0 = cursor_x + slot_w * index as f32 / slots as f32;
let x1 = cursor_x + slot_w * (index + 1) as f32 / slots as f32;
let corners = [
to_world(line_base_x, line_base_y, x0, valign_dy),
to_world(line_base_x, line_base_y, x1, valign_dy),
to_world(line_base_x, line_base_y, x0, top),
to_world(line_base_x, line_base_y, x1, top),
];
let xmin = corners
.iter()
.map(|p| p.0)
.fold(f32::INFINITY, f32::min);
let xmax = corners
.iter()
.map(|p| p.0)
.fold(f32::NEG_INFINITY, f32::max);
let ymin = corners
.iter()
.map(|p| p.1)
.fold(f32::INFINITY, f32::min);
let ymax = corners
.iter()
.map(|p| p.1)
.fold(f32::NEG_INFINITY, f32::max);
glyph_boxes.push(GlyphBox {
vis: atom.char_offset + index,
xmin,
xmax,
ymin,
ymax,
is_rtl: false,
});
}
}
}
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(),
plane: None,
run: None,
});
}
local_bounds[0] = local_bounds[0].min(cursor_x);
local_bounds[1] = local_bounds[1].min(line_ly + valign_dy);
local_bounds[2] = local_bounds[2].max(cursor_x + slot_w);
local_bounds[3] = local_bounds[3]
.max(line_ly + valign_dy + run_h * (STACK_RAISE + STACK_HALF_SCALE));
cursor_x += slot_w;
}
AtomKind::Space => {
if opts.vertical_text {
// One empty cell down the column; a space that lands
// on a column break is consumed by it (no leading
// blank cell at the top of a column).
let cell = atom.state.height_mul * entity_h * 1.4;
if cursor_x <= 1e-6 {
continue;
}
if v_col_limit != usize::MAX
&& cursor_x + cell > v_col_limit as f32 * v_cell + 1e-3
{
v_col += 1;
cursor_x = 0.0;
} else {
cursor_x += cell;
}
continue;
}
// Justify / distribute widen every word gap by `gap`.
let adv =
measure_space(&atom.state, entity_h, base_wf, &base_font_name) + gap;
// 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![],
plane: None,
run: None,
});
}
}
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: atom.char_offset,
xmin: ax.min(bx),
xmax: ax.max(bx),
ymin: ay.min(by),
ymax: ay.max(by),
is_rtl: false,
});
}
cursor_x += adv;
}
AtomKind::Tab => {
// 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.
//
// 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;
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) => {
let sx = tab_base + stop.unwrap().position;
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,
tab_base,
entity_h,
);
}
}
}
}
}
}
MTextLayout {
strokes: all_strokes,
line_widths,
line_count: sub_lines.len(),
line_height: line_h,
v_offset,
glyph_boxes,
local_bounds,
}
}
#[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,
is_vertical: 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 {
columns: Default::default(),
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,
exact_line_spacing: false,
rectangle_height: 0.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 {
columns: Default::default(),
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,
exact_line_spacing: false,
rectangle_height: 0.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"
);
}
#[test]
fn explicit_column_break_is_not_balanced_twice() {
let layout = layout_mtext(&MTextRenderOpts {
columns: MTextColumns {
count: 3,
width: 10.0,
gutter: 2.0,
auto_height: true,
..Default::default()
},
value: "one\\Ptwo\\Nthree",
insertion: [0.0, 0.0, 0.0],
height: 1.0,
rect_w: 34.0,
rotation: 0.0,
style: &style("txt"),
attach_h_anchor: 0.0,
v_anchor: MTextVAnchor::Top,
line_spacing_factor: 1.0,
exact_line_spacing: false,
rectangle_height: 0.0,
vertical_text: false,
want_glyph_boxes: false,
});
let origin_x = |text: &str| {
layout
.strokes
.iter()
.find_map(|stroke| {
stroke
.run
.as_ref()
.filter(|run| run.text == text)
.map(|_| stroke.origin[0])
})
.unwrap()
};
assert!((origin_x("two") - origin_x("one")).abs() < 1e-6);
assert!((origin_x("three") - 12.0).abs() < 1e-6);
}
}
#[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 opencadcodec's MTextScalar.)
let (_, st) = first_run(&adapt_mtext_paragraphs("\\H2x;big", 2.5, true));
assert!((st.height_mul - 2.0).abs() < 1e-4, "got {}", st.height_mul);
}
#[test]
fn absolute_height_resolves_against_entity_height() {
// `\H5;` is an absolute height → divided by the entity height (2.5) → 2.0.
let (_, st) = first_run(&adapt_mtext_paragraphs("\\H5;abs", 2.5, true));
assert!((st.height_mul - 2.0).abs() < 1e-4, "got {}", st.height_mul);
}
#[test]
fn color_font_oblique_valign_decoration() {
let (_, c) = first_run(&adapt_mtext_paragraphs("\\C1;red", 2.5, true));
assert_eq!(c.color, Some(InlineColor::Aci(1)));
let (_, f) = first_run(&adapt_mtext_paragraphs("\\fArial;x", 2.5, true));
assert_eq!(f.font.as_deref(), Some("Arial"));
let (_, q) = first_run(&adapt_mtext_paragraphs("\\Q15;x", 2.5, true));
assert!((q.oblique_rad - 15f32.to_radians()).abs() < 1e-4);
let (_, w) = first_run(&adapt_mtext_paragraphs("\\W1.5;x", 2.5, true));
assert!((w.width_mul - 1.5).abs() < 1e-4);
let (_, a) = first_run(&adapt_mtext_paragraphs("\\A1;x", 2.5, true));
assert_eq!(a.valign, 1);
let (_, d) = first_run(&adapt_mtext_paragraphs("\\Lunder\\l", 2.5, true));
assert!(d.underline);
}
#[test]
fn 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);
}
#[test]
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.
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);
}
}
#[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,
is_vertical: false,
};
layout_mtext(&MTextRenderOpts {
columns: Default::default(),
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,
exact_line_spacing: false,
rectangle_height: 0.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"
);
}
}
#[test]
fn test_bidi_atoms() {
fn reorder_atoms(words: &[&str], is_rtl: bool) -> Vec<String> {
let atoms: Vec<super::LayoutAtom> = words
.iter()
.map(|w| super::LayoutAtom {
kind: if *w == " " {
super::AtomKind::Space
} else {
super::AtomKind::Word(w.to_string())
},
state: Default::default(),
char_offset: 0,
})
.collect();
let reordered = super::reorder_line_atoms(atoms, is_rtl);
reordered
.into_iter()
.map(|a| match a.kind {
super::AtomKind::Word(w) => w,
super::AtomKind::Space => " ".to_string(),
_ => String::new(),
})
.collect()
}
// Case 1: Pure Arabic
let words1 = ["بسم", " ", "الله", " ", "الرحمن", " ", "الرحيم"];
let reordered1 = reorder_atoms(&words1, true);
assert_eq!(reordered1, vec!["الرحيم", " ", "الرحمن", " ", "الله", " ", "بسم"]);
// Case 2: Arabic with numbers
let words2 = ["بسم", " ", "الله", " ", "123", " ", "الرحمن"];
let reordered2 = reorder_atoms(&words2, true);
assert_eq!(reordered2, vec!["الرحمن", " ", "123", " ", "الله", " ", "بسم"]);
// Case 3: English with Arabic
let words3 = ["Hello", " ", "بسم", " ", "الله", " ", "world"];
let reordered3 = reorder_atoms(&words3, false);
assert_eq!(reordered3, vec!["Hello", " ", "الله", " ", "بسم", " ", "world"]);
// Case 4: Pure English
let words4 = ["Hello", " ", "world"];
let reordered4 = reorder_atoms(&words4, false);
assert_eq!(reordered4, vec!["Hello", " ", "world"]);
// Case 5: Mixed Urdu + Arabic + Hebrew + English
let text_b = "یہ اردو ہے۔ مرحبا! שלום שנה 2026 is here!";
let words_b: Vec<&str> = text_b.split_inclusive(' ').collect();
let reordered_b = reorder_atoms(&words_b, true);
assert_eq!(
reordered_b,
vec!["2026 ", "is ", "here!", "שנה ", "שלום ", "مرحبا! ", "ہے۔ ", "اردو ", "یہ "]
);
// Case 6: a word starting with multi-byte neutral punctuation must
// not be sliced inside that character.
let words6 = ["שלום", " ", "«x"];
let reordered6 = reorder_atoms(&words6, false);
assert_eq!(reordered6, vec!["שלום", " ", "«x"]);
}
#[test]
fn cjk_paragraph_wraps_between_ideographs() {
use crate::entities::text_support::{layout_mtext, MTextRenderOpts, MTextVAnchor, ResolvedTextStyle};
// Breaks are allowed between ideographs but not before a closing mark
// or after an opening one.
assert!(cjk_break_between('工', '程'));
assert!(cjk_break_between('程', 'A'));
assert!(!cjk_break_between('工', ','));
assert!(!cjk_break_between('(', '工'));
assert!(!cjk_break_between('a', 'b'));
let style = ResolvedTextStyle {
font_name: "Standard".to_string(),
width_factor: 1.0,
oblique_angle: 0.0,
is_backward: false,
is_upside_down: false,
is_vertical: false,
};
// 40 ideographs with no spaces in a column ~12 characters wide used
// to stay one unbreakable word: a single line overshooting the box.
let value = "本工程之各種平面圖均須相互配合如有不符或須更正處均按工程慣例施工至完整並請於估價前提出";
let opts = MTextRenderOpts {
columns: Default::default(),
value,
insertion: [0.0, 0.0, 0.0],
height: 2.5,
rect_w: 30.0,
rotation: 0.0,
style: &style,
attach_h_anchor: 0.0,
v_anchor: MTextVAnchor::Top,
line_spacing_factor: 1.0,
exact_line_spacing: false,
rectangle_height: 0.0,
vertical_text: false,
want_glyph_boxes: true,
};
let layout = layout_mtext(&opts);
assert!(
layout.line_count >= 3,
"a 40-ideograph paragraph in a 30-unit column must wrap (got {} line(s))",
layout.line_count
);
assert!(
layout.line_widths.iter().all(|w| *w <= 30.0 + 1e-3),
"no wrapped line may overshoot the column: {:?}",
layout.line_widths
);
}
#[test]
fn test_mtext_arabic_bidi_layout() {
use crate::entities::text_support::{layout_mtext, MTextRenderOpts, MTextVAnchor, ResolvedTextStyle};
let style = ResolvedTextStyle {
font_name: "Standard".to_string(),
width_factor: 1.0,
oblique_angle: 0.0,
is_backward: false,
is_upside_down: false,
is_vertical: false,
};
let opts = MTextRenderOpts {
columns: Default::default(),
value: "بسم الله الرحمن الرحيم",
insertion: [0.0, 0.0, 0.0],
height: 2.5,
rect_w: 0.0,
rotation: 0.0,
style: &style,
attach_h_anchor: 0.0,
v_anchor: MTextVAnchor::Top,
line_spacing_factor: 1.0,
exact_line_spacing: false,
rectangle_height: 0.0,
vertical_text: false,
want_glyph_boxes: true,
};
let layout = layout_mtext(&opts);
// There should be 4 stroke groups (one per word)
assert_eq!(layout.strokes.len(), 4);
// Stroke origins should increase monotonically in X (left to right visual order)
let origins_x: Vec<f64> = layout.strokes.iter().map(|s| s.origin[0]).collect();
for w in origins_x.windows(2) {
assert!(w[0] < w[1], "Each subsequent visual word must sit further to the right: {:?}", origins_x);
}
// For RTL text with rect_w == 0.0, the last visual word (which is "بسم", the first spoken word)
// should end near X = 0.0 (anchored at right edge / insertion point)
let line_w = layout.line_widths[0];
assert!(origins_x[0] <= 0.0, "First visual word must start to the left of insertion (near -line_w): origin_x={}, line_w={}", origins_x[0], line_w);
// Glyph boxes should have is_rtl = true for Arabic words
let rtl_boxes: Vec<_> = layout.glyph_boxes.iter().filter(|b| b.is_rtl).collect();
assert!(!rtl_boxes.is_empty(), "Arabic characters must produce is_rtl = true glyph boxes");
// Test mixed multilingual string in wrapped box (rect_w = 200.0)
let opts_mixed = MTextRenderOpts {
columns: Default::default(),
value: "یہ اردو ہے۔ مرحبا! שלום שנה 2026 is here!",
insertion: [0.0, 0.0, 0.0],
height: 2.5,
rect_w: 200.0,
rotation: 0.0,
style: &style,
attach_h_anchor: 0.0,
v_anchor: MTextVAnchor::Top,
line_spacing_factor: 1.0,
exact_line_spacing: false,
rectangle_height: 0.0,
vertical_text: false,
want_glyph_boxes: true,
};
let layout_mixed = layout_mtext(&opts_mixed);
assert!(!layout_mixed.strokes.is_empty());
// In a wrapped box of width 200.0, RTL paragraph right-aligns:
// the rightmost stroke group should end near 200.0
let max_origin = layout_mixed.strokes.iter().map(|s| s.origin[0]).fold(f64::NEG_INFINITY, f64::max);
assert!(max_origin > 100.0, "RTL text in 200-width box should be right-aligned near the right margin (got max_origin={})", max_origin);
// Test single Arabic word "ميل"
let opts_mayl = MTextRenderOpts {
columns: Default::default(),
value: "ميل",
insertion: [0.0, 0.0, 0.0],
height: 2.5,
rect_w: 0.0,
rotation: 0.0,
style: &style,
attach_h_anchor: 0.0,
v_anchor: MTextVAnchor::Top,
line_spacing_factor: 1.0,
exact_line_spacing: false,
rectangle_height: 0.0,
vertical_text: false,
want_glyph_boxes: true,
};
let layout_mayl = layout_mtext(&opts_mayl);
assert_eq!(layout_mayl.strokes.len(), 1);
let s = &layout_mayl.strokes[0];
assert!(!s.strokes.is_empty() || !s.fill_tris.is_empty(), "Shaped glyphs must produce vector geometry");
}
}