forked from 1b-agent/opencadstudio-upstream
Two related steps for the text pipeline: - TEXT parsing now goes through acadrust's parse_plain_text (like MTEXT uses parse_mtext). text::acad_text_encode parses the value's %% codes — including %%u/%%o underline/overline and %%nnn — and re-encodes to the stroke tessellator's \L/\O + resolved-Unicode grammar, so OCS no longer tokenizes TEXT %% codes itself. (ATTRIB/TOLERANCE/DIMENSION still use the local tokenizer for now.) Bumps the acadrust pin to the commit that completes the TEXT/MTEXT control-code set. - SDF text (OCS_TEXT_SDF) now covers MTEXT and every text-producing entity, not just top-level TEXT. TextStroke carries a GlyphRun (layout params); the collector walks convert()'s TruckObject::Text runs and applies annotation scale exactly as tessellate does for strokes. layout_glyph_quads shares lff::tokenize_run so %% specials resolve and \L/\O decorations are skipped (decoration LINES in SDF are still TODO). Verified: stroke rendering unchanged with the flag off; SDF glyphs render correctly for TEXT and MTEXT with OCS_TEXT_SDF=1. 154 lib tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1737 lines
64 KiB
Rust
1737 lines
64 KiB
Rust
use acadrust::types::aci_table::aci_to_rgb;
|
||
use acadrust::{CadDocument, EntityType};
|
||
|
||
use crate::scene::convert::acad_to_truck::{GlyphRun, TextStroke};
|
||
use crate::scene::text::font_face::Face;
|
||
use crate::scene::text::lff;
|
||
|
||
pub struct ResolvedTextStyle {
|
||
pub font_name: String,
|
||
pub width_factor: f32,
|
||
pub oblique_angle: f32,
|
||
pub is_backward: bool,
|
||
pub is_upside_down: bool,
|
||
}
|
||
|
||
pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedTextStyle {
|
||
let style = document.text_styles.iter().find(|entry| {
|
||
entry.name.eq_ignore_ascii_case(style_name)
|
||
|| (style_name.trim().is_empty() && entry.name.eq_ignore_ascii_case("Standard"))
|
||
});
|
||
|
||
let mut font_name = if let Some(style) = style {
|
||
if !style.true_type_font.trim().is_empty() {
|
||
style.true_type_font.trim().to_string()
|
||
} else if !style.font_file.trim().is_empty() {
|
||
let file = style.font_file.trim();
|
||
let basename = file.rsplit(['/', '\\']).next().unwrap_or(file);
|
||
let stem = basename.split('.').next().unwrap_or(basename).trim();
|
||
if !stem.is_empty() {
|
||
stem.to_string()
|
||
} else if !style.name.trim().is_empty() {
|
||
style.name.trim().to_string()
|
||
} else {
|
||
"Standard".to_string()
|
||
}
|
||
} else if !style.name.trim().is_empty() {
|
||
style.name.trim().to_string()
|
||
} else {
|
||
"Standard".to_string()
|
||
}
|
||
} else if style_name.trim().is_empty() {
|
||
"Standard".to_string()
|
||
} else {
|
||
style_name.trim().to_string()
|
||
};
|
||
|
||
if !lff::is_builtin(&font_name) {
|
||
if let Some(canonical) = crate::scene::text::sysfont::canonical_family_name(&font_name) {
|
||
font_name = canonical;
|
||
}
|
||
}
|
||
|
||
ResolvedTextStyle {
|
||
font_name,
|
||
width_factor: style.map(|s| s.width_factor as f32).unwrap_or(1.0),
|
||
oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0),
|
||
is_backward: style.map(|s| s.is_backward()).unwrap_or(false),
|
||
is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false),
|
||
}
|
||
}
|
||
|
||
pub struct TextLocalBounds {
|
||
/// Inked extent (glyph strokes only) — drives vertical alignment, where
|
||
/// the cap / baseline geometry is what matters.
|
||
pub ink_min: [f32; 2],
|
||
pub ink_max: [f32; 2],
|
||
/// Pen advance along the baseline, including leading / trailing spaces and
|
||
/// inter-glyph spacing. Drives horizontal alignment so spaces in the string
|
||
/// keep their width instead of collapsing to the first / last inked glyph.
|
||
pub advance: f32,
|
||
}
|
||
|
||
pub fn text_local_bounds(
|
||
font_name: &str,
|
||
text: &str,
|
||
height: f32,
|
||
width_factor: f32,
|
||
oblique_angle: f32,
|
||
) -> Option<TextLocalBounds> {
|
||
if text.is_empty() || height <= 0.0 {
|
||
return None;
|
||
}
|
||
|
||
let face = Face::resolve(font_name);
|
||
let scale = height / 9.0;
|
||
let wf = width_factor.abs().clamp(0.01, 100.0);
|
||
let ob = oblique_angle.tan();
|
||
let mut cursor_x = 0.0_f32;
|
||
let mut min_x = f32::INFINITY;
|
||
let mut max_x = f32::NEG_INFINITY;
|
||
let mut min_y = f32::INFINITY;
|
||
let mut max_y = f32::NEG_INFINITY;
|
||
|
||
for ch in text.chars() {
|
||
if ch == ' ' {
|
||
cursor_x += face.word_spacing();
|
||
continue;
|
||
}
|
||
match face.glyph(ch) {
|
||
Some(glyph) => {
|
||
for stroke in &glyph.strokes {
|
||
for &[gx, gy] in stroke {
|
||
let sx = (cursor_x + gx) * scale * wf + gy * scale * ob;
|
||
let sy = gy * scale;
|
||
min_x = min_x.min(sx);
|
||
max_x = max_x.max(sx);
|
||
min_y = min_y.min(sy);
|
||
max_y = max_y.max(sy);
|
||
}
|
||
}
|
||
cursor_x += glyph.advance + face.letter_spacing();
|
||
}
|
||
None => {
|
||
cursor_x += 6.0 + face.letter_spacing();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Pen advance is measured at the baseline, so oblique shear (which skews x
|
||
// only by gy) does not enter it. Valid even for an all-space string.
|
||
let advance = cursor_x * scale * wf;
|
||
|
||
if min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite() {
|
||
Some(TextLocalBounds {
|
||
ink_min: [min_x, min_y],
|
||
ink_max: [max_x, max_y],
|
||
advance,
|
||
})
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// Expand DXF `%%x` special-character sequences that appear in both TEXT and MTEXT values:
|
||
/// - `%%d` / `%%D` → `°`
|
||
/// - `%%p` / `%%P` → `±`
|
||
/// - `%%c` / `%%C` → `⌀`
|
||
/// - `%%u` / `%%U` → underline toggle (stripped — not renderable with stroke fonts)
|
||
/// - `%%o` / `%%O` → overline toggle (stripped)
|
||
/// - `%%%%` → `%`
|
||
/// - `%%nnn` (3 decimal digits) → Unicode scalar `nnn`
|
||
/// Any unrecognised `%%x` is passed through unchanged.
|
||
pub fn resolve_dxf_special_chars(s: &str) -> String {
|
||
let mut out = String::with_capacity(s.len());
|
||
let mut chars = s.chars().peekable();
|
||
|
||
while let Some(c) = chars.next() {
|
||
if c != '%' || chars.peek() != Some(&'%') {
|
||
out.push(c);
|
||
continue;
|
||
}
|
||
chars.next(); // consume second '%'
|
||
match chars.peek().map(|c| c.to_ascii_lowercase()) {
|
||
Some('d') => {
|
||
chars.next();
|
||
out.push('°');
|
||
}
|
||
Some('p') => {
|
||
chars.next();
|
||
out.push('±');
|
||
}
|
||
Some('c') => {
|
||
chars.next();
|
||
out.push('⌀');
|
||
}
|
||
Some('u') | Some('o') => {
|
||
chars.next();
|
||
} // toggle codes — strip silently
|
||
Some('%') => {
|
||
chars.next();
|
||
out.push('%');
|
||
}
|
||
Some(d) if d.is_ascii_digit() => {
|
||
let mut digits = String::with_capacity(3);
|
||
for _ in 0..3 {
|
||
match chars.peek() {
|
||
Some(&ch) if ch.is_ascii_digit() => {
|
||
digits.push(chars.next().unwrap());
|
||
}
|
||
_ => break,
|
||
}
|
||
}
|
||
if digits.len() == 3 {
|
||
if let Ok(n) = digits.parse::<u32>() {
|
||
if let Some(ch) = char::from_u32(n) {
|
||
out.push(ch);
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
out.push('%');
|
||
out.push('%');
|
||
out.push_str(&digits);
|
||
}
|
||
_ => {
|
||
out.push('%');
|
||
out.push('%');
|
||
}
|
||
}
|
||
}
|
||
|
||
out
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────────────────
|
||
// Rich MTEXT parser — full inline format-code coverage
|
||
//
|
||
// Recognised codes (DXF MTEXT inline):
|
||
// Escapes: \\ \{ \} \~ \t \P \n \N \U+XXXX \u+XXXX
|
||
// Toggles: \L\l \O\o \K\k (underline / overline / strike)
|
||
// State: \H<v>[x]; \W<v>[x]; \Q<v>; \T<v>[x]; \A<n>;
|
||
// \C<aci>; \c<rgb>;
|
||
// \f<name>|b<0/1>|i<0/1>|c<n>|p<n>; \F<file>;
|
||
// \M+<n>; \X \S<u><sep><l>;
|
||
// Paragraph: \p[xi<v>,l<v>,r<v>,q[lcrjd],t<positions>,s<v>...];
|
||
// Scope: { ... } push/pop full state
|
||
// ──────────────────────────────────────────────────────────────────────────
|
||
|
||
/// Paragraph alignment encoded inline via `\p...q[lcrjd]...;`.
|
||
/// `Justify` / `Distribute` render as `Left` (full inter-word redistribution
|
||
/// is not implemented in the stroke renderer).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum ParagraphAlign {
|
||
Left,
|
||
Center,
|
||
Right,
|
||
Justify,
|
||
Distribute,
|
||
}
|
||
|
||
/// Inline colour override (`\C` ACI or `\c` 24-bit true colour). Resolved to
|
||
/// linear RGB at render time via the document's ACI table.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub enum InlineColor {
|
||
Aci(u8),
|
||
True([f32; 3]),
|
||
}
|
||
|
||
/// Tab-stop alignment kind (from `\pt<L|C|R><pos>` entries).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum TabKind {
|
||
Left,
|
||
Center,
|
||
Right,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct TabStop {
|
||
pub position: f32,
|
||
pub kind: TabKind,
|
||
}
|
||
|
||
/// Per-run formatting state. All fields are multipliers / overrides relative
|
||
/// to the entity-level defaults; the renderer composes them with the resolved
|
||
/// text style at draw time.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct RunState {
|
||
/// Multiplier on entity text height (`\H<v>x;` → ×v; `\H<v>;` → v / entity_h)
|
||
pub height_mul: f32,
|
||
/// Multiplier on the (signed) style width-factor (`\W<v>;` → set, `\Wx;` → ×)
|
||
pub width_mul: f32,
|
||
/// Absolute oblique angle override in radians (`\Q<deg>;`)
|
||
pub oblique_rad: f32,
|
||
/// Tracking multiplier on `font.letter_spacing` (`\T<v>;`)
|
||
pub tracking: f32,
|
||
/// Vertical alignment of the run within its line box (0=baseline / 1=center
|
||
/// / 2=top). Mainly used for fractions and superscript-like layout (`\A`).
|
||
pub valign: u8,
|
||
/// Font-name override, `None` ⇒ inherit the resolved style font.
|
||
pub font: Option<String>,
|
||
/// Colour override, `None` ⇒ inherit entity colour.
|
||
pub color: Option<InlineColor>,
|
||
pub underline: bool,
|
||
pub overline: bool,
|
||
pub strike: bool,
|
||
}
|
||
|
||
impl Default for RunState {
|
||
fn default() -> Self {
|
||
Self {
|
||
height_mul: 1.0,
|
||
width_mul: 1.0,
|
||
oblique_rad: 0.0,
|
||
tracking: 1.0,
|
||
valign: 0,
|
||
font: None,
|
||
color: None,
|
||
underline: false,
|
||
overline: false,
|
||
strike: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub enum MTextRunKind {
|
||
/// Renderable glyph text (DXF specials resolved, decoration markers stripped).
|
||
Glyphs(String),
|
||
/// `\t` — jump the cursor to the next tab stop (or default tab interval).
|
||
Tab,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct MTextRun {
|
||
pub kind: MTextRunKind,
|
||
pub state: RunState,
|
||
}
|
||
|
||
/// One paragraph of MTEXT after parsing. Each line is a sequence of runs that
|
||
/// share text content + a snapshot of formatting state, plus paragraph-level
|
||
/// layout (alignment, indents, tab stops). `\P` / `\n` / `\N` start a new
|
||
/// line; paragraph properties carry forward until the next `\p...;` block.
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct MTextLine {
|
||
pub runs: Vec<MTextRun>,
|
||
pub align: Option<ParagraphAlign>,
|
||
pub indent_first: f32,
|
||
pub indent_left: f32,
|
||
pub indent_right: f32,
|
||
pub tab_stops: Vec<TabStop>,
|
||
}
|
||
|
||
impl MTextLine {
|
||
pub fn is_blank(&self) -> bool {
|
||
self.runs.iter().all(|r| match &r.kind {
|
||
MTextRunKind::Glyphs(t) => t.trim().is_empty(),
|
||
MTextRunKind::Tab => false,
|
||
})
|
||
}
|
||
}
|
||
|
||
|
||
/// Font-name stem: drop any path and extension, so a `\F` font path like
|
||
/// `C:\\fonts\\arial.ttf` and a plain `\f` name `arial` resolve to the same font.
|
||
fn font_stem(name: &str) -> String {
|
||
name.rsplit(['/', '\\'])
|
||
.next()
|
||
.unwrap_or(name)
|
||
.split('.')
|
||
.next()
|
||
.unwrap_or(name)
|
||
.to_string()
|
||
}
|
||
|
||
/// Parse an MTEXT string into the layout's `Vec<MTextLine>`, using acadrust's
|
||
/// structured `mtext_format::parse_mtext` — OCS keeps only the layout engine
|
||
/// (`layout_mtext` and callers read `MTextLine`/`RunState`), not a second MTEXT
|
||
/// inline parser.
|
||
///
|
||
/// Representation notes:
|
||
/// - DXF `%%d`/`%%p`/`%%c` arrive already resolved to Unicode from acadrust;
|
||
/// the stroke tokenizer treats those as ordinary glyphs.
|
||
/// - Stacking (`\S`) is flattened inline to `num<sep>den` (`^` for limit, else
|
||
/// `/`) since the stroke path has no fraction layout.
|
||
/// - `\H`: a relative factor (`\Hx`) applies directly; an absolute height is
|
||
/// divided by the entity height. See `acadrust::…::MTextScalar`.
|
||
pub fn adapt_mtext_paragraphs(
|
||
s: &str,
|
||
entity_height: f32,
|
||
trim_blank_edges: bool,
|
||
) -> Vec<MTextLine> {
|
||
use acadrust::entities::mtext_format::{
|
||
parse_mtext, MTextColor, MTextLineAlignment, MTextParagraphAlignment, MTextScalar,
|
||
SpanProperties, StackingType,
|
||
};
|
||
|
||
let entity_height = entity_height.max(1e-6);
|
||
let doc = parse_mtext(s, true);
|
||
|
||
fn map_align(a: MTextParagraphAlignment) -> Option<ParagraphAlign> {
|
||
match a {
|
||
MTextParagraphAlignment::Left => Some(ParagraphAlign::Left),
|
||
MTextParagraphAlignment::Right => Some(ParagraphAlign::Right),
|
||
MTextParagraphAlignment::Center => Some(ParagraphAlign::Center),
|
||
MTextParagraphAlignment::Justified => Some(ParagraphAlign::Justify),
|
||
MTextParagraphAlignment::Distributed => Some(ParagraphAlign::Distribute),
|
||
MTextParagraphAlignment::Default => None,
|
||
}
|
||
}
|
||
fn color_of(p: &SpanProperties) -> Option<InlineColor> {
|
||
if let Some((r, g, b)) = p.color_rgb {
|
||
return Some(InlineColor::True([
|
||
r as f32 / 255.0,
|
||
g as f32 / 255.0,
|
||
b as f32 / 255.0,
|
||
]));
|
||
}
|
||
match p.color {
|
||
Some(MTextColor::Index(n)) => Some(InlineColor::Aci(n.min(255) as u8)),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
let mut lines: Vec<MTextLine> = Vec::new();
|
||
for para in &doc.paragraphs {
|
||
let props = ¶.properties;
|
||
let mut line = MTextLine {
|
||
align: props.alignment.and_then(map_align),
|
||
indent_first: props.first_line_indent.unwrap_or(0.0) as f32,
|
||
indent_left: props.left_margin.unwrap_or(0.0) as f32,
|
||
indent_right: props.right_margin.unwrap_or(0.0) as f32,
|
||
tab_stops: props
|
||
.tab_stops
|
||
.iter()
|
||
.map(|&p| TabStop {
|
||
position: p as f32,
|
||
kind: TabKind::Left,
|
||
})
|
||
.collect(),
|
||
runs: Vec::new(),
|
||
};
|
||
for span in ¶.spans {
|
||
let p = &span.properties;
|
||
let state = RunState {
|
||
// Relative `\H…x;` is a factor applied directly; absolute `\H…;`
|
||
// is a drawing-unit height resolved against the entity height.
|
||
height_mul: match p.height {
|
||
Some(MTextScalar::Factor(f)) => f as f32,
|
||
Some(MTextScalar::Absolute(a)) => a as f32 / entity_height,
|
||
None => 1.0,
|
||
},
|
||
width_mul: p.width_factor.map(|w| w as f32).unwrap_or(1.0),
|
||
oblique_rad: p
|
||
.oblique_angle
|
||
.map(|q| (q as f32).to_radians())
|
||
.unwrap_or(0.0),
|
||
tracking: p.tracking.map(|t| t as f32).unwrap_or(1.0),
|
||
valign: match p.line_align {
|
||
Some(MTextLineAlignment::Middle) => 1,
|
||
Some(MTextLineAlignment::Top) => 2,
|
||
_ => 0,
|
||
},
|
||
font: p.font.as_ref().map(|f| font_stem(&f.name)),
|
||
color: color_of(p),
|
||
underline: p.stroke.underline(),
|
||
overline: p.stroke.overline(),
|
||
strike: p.stroke.strikethrough(),
|
||
};
|
||
let text = match &span.stacking {
|
||
Some(st) => {
|
||
let sep = match st.stacking_type {
|
||
StackingType::Limit => '^',
|
||
_ => '/',
|
||
};
|
||
let mut t = st.numerator.clone();
|
||
if !st.denominator.is_empty() {
|
||
t.push(sep);
|
||
t.push_str(&st.denominator);
|
||
}
|
||
t
|
||
}
|
||
None => span.text.clone(),
|
||
};
|
||
if text.is_empty() {
|
||
continue;
|
||
}
|
||
line.runs.push(MTextRun {
|
||
kind: MTextRunKind::Glyphs(text),
|
||
state,
|
||
});
|
||
}
|
||
lines.push(line);
|
||
}
|
||
|
||
if !trim_blank_edges {
|
||
return lines;
|
||
}
|
||
let start = lines.iter().position(|l| !l.is_blank()).unwrap_or(0);
|
||
let end = lines
|
||
.iter()
|
||
.rposition(|l| !l.is_blank())
|
||
.map(|i| i + 1)
|
||
.unwrap_or(0);
|
||
lines[start..end].to_vec()
|
||
}
|
||
|
||
// Legacy MText helpers (`strip_mtext_codes`, `split_mtext_lines`,
|
||
// `measure_mtext_chars`, `word_wrap`) were removed when every text-bearing
|
||
// entity switched to the run-aware pipeline below. The pipeline now owns
|
||
// per-run width measurement and word-wrap; MTEXT inline parsing now comes from
|
||
// acadrust via `adapt_mtext_paragraphs`. The supported surface for callers is
|
||
// `adapt_mtext_paragraphs`, `layout_mtext`, `mtext_line_count`,
|
||
// `text_local_bounds`, and `resolve_dxf_special_chars`.
|
||
|
||
/// Total number of visible lines an MText renders to — explicit `\P` /
|
||
/// `\n` / `\N` breaks plus word-wrap induced sublines when
|
||
/// `rectangle_width > 0`. Drives LOD splits (greek-rect per row, baseline
|
||
/// counts) without re-running the full stroke tessellation; routes through
|
||
/// the same parse + atomise + wrap pipeline as `layout_mtext` so the LOD
|
||
/// row count and the rendered row count never disagree.
|
||
pub fn mtext_line_count(
|
||
m: &acadrust::entities::MText,
|
||
document: &CadDocument,
|
||
anno_scale: f32,
|
||
) -> usize {
|
||
let resolved = resolve_text_style(&m.style, document);
|
||
let entity_h = (m.height as f32) * anno_scale;
|
||
let base_wf_abs = resolved.width_factor.max(0.01);
|
||
let base_wf = if resolved.is_backward { -base_wf_abs } else { base_wf_abs };
|
||
let base_font_name = resolved.font_name.clone();
|
||
let rect_w = (m.rectangle_width as f32) * anno_scale;
|
||
|
||
let paragraphs = adapt_mtext_paragraphs(&m.value, entity_h, true);
|
||
let mut total = 0usize;
|
||
for para in ¶graphs {
|
||
let mut atoms: Vec<LayoutAtom> = Vec::new();
|
||
for run in ¶.runs {
|
||
match &run.kind {
|
||
MTextRunKind::Glyphs(text) => {
|
||
let mut word = String::new();
|
||
for ch in text.chars() {
|
||
if ch == ' ' || ch == '\u{00A0}' {
|
||
if !word.is_empty() {
|
||
atoms.push(LayoutAtom {
|
||
kind: AtomKind::Word(std::mem::take(&mut word)),
|
||
state: run.state.clone(),
|
||
});
|
||
}
|
||
atoms.push(LayoutAtom {
|
||
kind: AtomKind::Space,
|
||
state: run.state.clone(),
|
||
});
|
||
} else {
|
||
word.push(ch);
|
||
}
|
||
}
|
||
if !word.is_empty() {
|
||
atoms.push(LayoutAtom {
|
||
kind: AtomKind::Word(word),
|
||
state: run.state.clone(),
|
||
});
|
||
}
|
||
}
|
||
MTextRunKind::Tab => atoms.push(LayoutAtom {
|
||
kind: AtomKind::Tab,
|
||
state: run.state.clone(),
|
||
}),
|
||
}
|
||
}
|
||
// Same edge-trim the renderer applies — otherwise a trailing space
|
||
// can inflate the wrap result by one extra sub-line.
|
||
let first_word = atoms
|
||
.iter()
|
||
.position(|a| !matches!(a.kind, AtomKind::Space))
|
||
.unwrap_or(atoms.len());
|
||
atoms.drain(..first_word);
|
||
while matches!(atoms.last().map(|a| &a.kind), Some(AtomKind::Space)) {
|
||
atoms.pop();
|
||
}
|
||
let wrapped = wrap_paragraph(
|
||
atoms,
|
||
rect_w,
|
||
para.indent_first,
|
||
para.indent_left,
|
||
para.indent_right,
|
||
¶.tab_stops,
|
||
entity_h,
|
||
base_wf,
|
||
&base_font_name,
|
||
);
|
||
total += wrapped.len().max(1);
|
||
}
|
||
total.max(1)
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────────────────────
|
||
// Shared MText layout / render pipeline
|
||
// ──────────────────────────────────────────────────────────────────────────────
|
||
//
|
||
// `layout_mtext` is the entry point used by every text-bearing entity that
|
||
// stores MText-formatted content (MTEXT, MLEADER text content, TABLE cell,
|
||
// ATTRIB / ATTDEF with `mtext_flag` set, and DIMENSION `text_override` when
|
||
// it carries inline codes).
|
||
//
|
||
// The pipeline mirrors the MTEXT renderer:
|
||
// 1. Parse — via `adapt_mtext_paragraphs` (acadrust `parse_mtext`).
|
||
// 2. Atomise — turn each MTextLine.runs into a flat sequence of atoms
|
||
// (Word / Space / Tab) so the wrapper operates at break boundaries
|
||
// while keeping per-character formatting state.
|
||
// 3. Wrap — accumulate atoms into sub-lines using paragraph indents and
|
||
// tab stops; each Tab jumps the cursor to the next user-defined stop
|
||
// (or a 4-em default grid).
|
||
// 4. Render — for each sub-line: pick paragraph alignment + indent, walk
|
||
// atoms left → right, emit one TextStroke per Word using the atom's
|
||
// RunState (height / width / oblique / tracking / font / colour /
|
||
// decorations / valign).
|
||
//
|
||
// In addition to the strokes, the helper returns enough geometry (line
|
||
// widths, line height, v_offset, h_anchor) for the caller to draw a frame /
|
||
// background rectangle, run a low-detail LOD path, or compute snap bounds.
|
||
|
||
#[derive(Clone)]
|
||
pub enum AtomKind {
|
||
Word(String),
|
||
Space,
|
||
Tab,
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
pub struct LayoutAtom {
|
||
pub kind: AtomKind,
|
||
pub state: RunState,
|
||
}
|
||
|
||
pub fn run_scale(state: &RunState, entity_h: f32, base_wf: f32) -> f32 {
|
||
(state.height_mul * entity_h / 9.0) * (state.width_mul * base_wf.abs())
|
||
}
|
||
|
||
pub fn resolve_font<'a>(state: &'a RunState, base: &'a str) -> std::borrow::Cow<'a, str> {
|
||
let Some(font) = state.font.as_deref().map(str::trim).filter(|f| !f.is_empty()) else {
|
||
return std::borrow::Cow::Borrowed(base);
|
||
};
|
||
if lff::is_builtin(font) {
|
||
return std::borrow::Cow::Borrowed(font);
|
||
}
|
||
if let Some(canonical) = crate::scene::text::sysfont::canonical_family_name(font) {
|
||
std::borrow::Cow::Owned(canonical)
|
||
} else {
|
||
std::borrow::Cow::Borrowed(base)
|
||
}
|
||
}
|
||
|
||
pub fn measure_word(
|
||
text: &str,
|
||
state: &RunState,
|
||
entity_h: f32,
|
||
base_wf: f32,
|
||
base_font: &str,
|
||
) -> f32 {
|
||
let scale = run_scale(state, entity_h, base_wf);
|
||
let font_name = resolve_font(state, base_font);
|
||
let face = Face::resolve(&font_name);
|
||
let mut w = 0.0_f32;
|
||
for ch in text.chars() {
|
||
w += match face.glyph(ch) {
|
||
Some(g) => (g.advance + face.letter_spacing() * state.tracking) * scale,
|
||
None => (6.0 + face.letter_spacing() * state.tracking) * scale,
|
||
};
|
||
}
|
||
w
|
||
}
|
||
|
||
pub fn measure_space(state: &RunState, entity_h: f32, base_wf: f32, base_font: &str) -> f32 {
|
||
let scale = run_scale(state, entity_h, base_wf);
|
||
let font_name = resolve_font(state, base_font);
|
||
Face::resolve(&font_name).word_spacing() * scale
|
||
}
|
||
|
||
pub fn atom_width(atom: &LayoutAtom, entity_h: f32, base_wf: f32, base_font: &str) -> f32 {
|
||
match &atom.kind {
|
||
AtomKind::Word(t) => measure_word(t, &atom.state, entity_h, base_wf, base_font),
|
||
AtomKind::Space => measure_space(&atom.state, entity_h, base_wf, base_font),
|
||
AtomKind::Tab => 0.0,
|
||
}
|
||
}
|
||
|
||
/// Cursor position after a `\t` atom: advance to the next user-defined tab
|
||
/// stop that lies past `cur_x`, falling back to a 4-em default grid when no
|
||
/// stop is reached.
|
||
pub fn next_tab_position(
|
||
cur_x: f32,
|
||
tab_stops: &[TabStop],
|
||
indent_left: f32,
|
||
entity_h: f32,
|
||
) -> f32 {
|
||
let local = cur_x - indent_left;
|
||
for ts in tab_stops {
|
||
if ts.position > local + 1e-4 {
|
||
return indent_left + ts.position;
|
||
}
|
||
}
|
||
let default_interval = entity_h * 4.0;
|
||
let n = (local / default_interval).floor() + 1.0;
|
||
indent_left + n * default_interval
|
||
}
|
||
|
||
/// Break a flat MText paragraph atom stream into wrap-fit sub-lines.
|
||
pub fn wrap_paragraph(
|
||
atoms: Vec<LayoutAtom>,
|
||
rect_w: f32,
|
||
indent_first: f32,
|
||
indent_left: f32,
|
||
indent_right: f32,
|
||
tab_stops: &[TabStop],
|
||
entity_h: f32,
|
||
base_wf: f32,
|
||
base_font: &str,
|
||
) -> Vec<Vec<LayoutAtom>> {
|
||
if rect_w <= 0.0 {
|
||
return vec![atoms];
|
||
}
|
||
let mut sublines: Vec<Vec<LayoutAtom>> = Vec::new();
|
||
let mut cur: Vec<LayoutAtom> = Vec::new();
|
||
let mut cur_w = 0.0_f32;
|
||
let mut subline_idx: usize = 0;
|
||
let line_start_x = |idx: usize| if idx == 0 { indent_first } else { indent_left };
|
||
let line_max_w = |idx: usize| (rect_w - indent_right - line_start_x(idx)).max(0.0);
|
||
|
||
for atom in atoms {
|
||
match &atom.kind {
|
||
AtomKind::Word(_) => {
|
||
let w = atom_width(&atom, entity_h, base_wf, base_font);
|
||
let max_w = line_max_w(subline_idx);
|
||
if !cur.is_empty() && cur_w + w > max_w {
|
||
while matches!(cur.last().map(|a| &a.kind), Some(AtomKind::Space)) {
|
||
cur.pop();
|
||
}
|
||
sublines.push(std::mem::take(&mut cur));
|
||
cur_w = 0.0;
|
||
subline_idx += 1;
|
||
}
|
||
cur.push(atom);
|
||
cur_w += w;
|
||
}
|
||
AtomKind::Space => {
|
||
if cur.is_empty() {
|
||
continue;
|
||
}
|
||
cur_w += atom_width(&atom, entity_h, base_wf, base_font);
|
||
cur.push(atom);
|
||
}
|
||
AtomKind::Tab => {
|
||
let start_x = line_start_x(subline_idx);
|
||
let new_w = next_tab_position(cur_w + start_x, tab_stops, indent_left, entity_h)
|
||
- start_x;
|
||
let max_w = line_max_w(subline_idx);
|
||
if new_w > max_w && !cur.is_empty() {
|
||
sublines.push(std::mem::take(&mut cur));
|
||
cur_w = 0.0;
|
||
subline_idx += 1;
|
||
} else {
|
||
cur.push(atom);
|
||
cur_w = new_w.min(max_w);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if !cur.is_empty() {
|
||
sublines.push(cur);
|
||
}
|
||
if sublines.is_empty() {
|
||
sublines.push(Vec::new());
|
||
}
|
||
sublines
|
||
}
|
||
|
||
pub fn line_total_width(
|
||
atoms: &[LayoutAtom],
|
||
entity_h: f32,
|
||
base_wf: f32,
|
||
base_font: &str,
|
||
line_start_x: f32,
|
||
indent_left: f32,
|
||
tab_stops: &[TabStop],
|
||
) -> f32 {
|
||
let mut x = line_start_x;
|
||
for atom in atoms {
|
||
match atom.kind {
|
||
AtomKind::Tab => {
|
||
x = next_tab_position(x, tab_stops, indent_left, entity_h);
|
||
}
|
||
_ => x += atom_width(atom, entity_h, base_wf, base_font),
|
||
}
|
||
}
|
||
x - line_start_x
|
||
}
|
||
|
||
pub fn resolve_inline_color(c: &InlineColor) -> Option<[f32; 3]> {
|
||
match c {
|
||
InlineColor::Aci(idx) => aci_to_rgb(*idx).map(|(r, g, b)| {
|
||
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]
|
||
}),
|
||
InlineColor::True(rgb) => Some(*rgb),
|
||
}
|
||
}
|
||
|
||
/// Wrap a run's glyph text with MTEXT decoration markers so lff's
|
||
/// `tessellate_text_run` emits the underline / overline / strikethrough
|
||
/// strokes for us — keeps decoration geometry in one place rather than
|
||
/// duplicating the y-position constants.
|
||
fn decorated(text: &str, state: &RunState) -> String {
|
||
if !(state.underline || state.overline || state.strike) {
|
||
return text.to_string();
|
||
}
|
||
let mut s = String::with_capacity(text.len() + 6);
|
||
if state.underline {
|
||
s.push_str("\\L");
|
||
}
|
||
if state.overline {
|
||
s.push_str("\\O");
|
||
}
|
||
if state.strike {
|
||
s.push_str("\\K");
|
||
}
|
||
s.push_str(text);
|
||
if state.underline {
|
||
s.push_str("\\l");
|
||
}
|
||
if state.overline {
|
||
s.push_str("\\o");
|
||
}
|
||
if state.strike {
|
||
s.push_str("\\k");
|
||
}
|
||
s
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub enum MTextVAnchor {
|
||
/// Block top edge at insertion (first line's cap = insertion.y).
|
||
Top,
|
||
/// Block midpoint at insertion.
|
||
Middle,
|
||
/// Block bottom edge at insertion (last line's baseline = insertion.y).
|
||
Bottom,
|
||
/// MLEADER `MiddleOfTopLine` — first line's vertical centre at insertion.
|
||
MiddleOfTopLine,
|
||
/// MLEADER `MiddleOfBottomLine` — last line's vertical centre at insertion.
|
||
MiddleOfBottomLine,
|
||
/// MLEADER `BottomOfTopLineUnderline*` — first line's baseline at insertion.
|
||
BottomOfTopLine,
|
||
}
|
||
|
||
/// Render inputs for [`layout_mtext`]. The caller resolves the text style
|
||
/// once and feeds the entity's geometry; the helper handles the entire
|
||
/// parse → wrap → render pipeline and returns both the rendered strokes and
|
||
/// the layout metrics (so callers can also draw frames / fills / LOD
|
||
/// substitutes from the same numbers).
|
||
pub struct MTextRenderOpts<'a> {
|
||
/// Raw MText-formatted value (the string the parser walks).
|
||
pub value: &'a str,
|
||
/// World-space insertion point — strokes are emitted with this as their
|
||
/// origin (after the per-sub-line rotation + cursor offset).
|
||
pub insertion: [f64; 3],
|
||
/// Entity text height in world units.
|
||
pub height: f32,
|
||
/// Box width for word-wrap (0 → no wrap; lines flow at the insertion).
|
||
pub rect_w: f32,
|
||
/// Final rotation in radians (already composed with `is_upside_down`).
|
||
pub rotation: f32,
|
||
/// Resolved style (font + width factor + oblique). Width factor sign
|
||
/// honours `is_backward` (negative → mirror).
|
||
pub style: &'a ResolvedTextStyle,
|
||
/// Horizontal anchor of the text block at the insertion point:
|
||
/// 0.0 = left, 0.5 = center, 1.0 = right.
|
||
pub attach_h_anchor: f32,
|
||
/// Vertical anchor of the text block at the insertion point.
|
||
pub v_anchor: MTextVAnchor,
|
||
/// DXF code 44 — multiplier on the default 5/3-em baseline gap.
|
||
pub line_spacing_factor: f32,
|
||
/// `true` when the entity is laid out top-to-bottom (DXF code 71 = 2).
|
||
pub vertical_text: bool,
|
||
/// When true, `layout_mtext` also fills `MTextLayout::glyph_boxes` with
|
||
/// one world-space box per visible character (used by the MText editor's
|
||
/// click-to-select preview). Off in the hot render path.
|
||
pub want_glyph_boxes: bool,
|
||
}
|
||
|
||
/// One selectable character in the laid-out text: its world-space AABB plus
|
||
/// the running index of visible characters (in reading order) so the editor
|
||
/// can map a clicked box back to an offset in the value.
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct GlyphBox {
|
||
pub vis: usize,
|
||
pub xmin: f32,
|
||
pub xmax: f32,
|
||
pub ymin: f32,
|
||
pub ymax: f32,
|
||
}
|
||
|
||
/// Output of [`layout_mtext`]: stroke groups + the geometry the caller
|
||
/// needs for surrounding chrome (frame / fill / LOD baseline-or-rect).
|
||
pub struct MTextLayout {
|
||
/// One TextStroke per Word atom (Tab / Space contribute only to cursor
|
||
/// advance). The `color` field on each stroke carries the inline
|
||
/// `\C` / `\c` override when one was set, otherwise `None`.
|
||
pub strokes: Vec<TextStroke>,
|
||
/// Per-sub-line width in entity-local (pre-rotation) units. Includes
|
||
/// any trailing whitespace that survived the trim — kept in sync with
|
||
/// the cursor advance so the alignment numbers and the visible glyphs
|
||
/// line up.
|
||
pub line_widths: Vec<f32>,
|
||
/// Sub-line count (≥ 1; an entity with an empty value still reports 1).
|
||
pub line_count: usize,
|
||
/// Baseline-to-baseline gap used when stepping between sub-lines.
|
||
pub line_height: f32,
|
||
/// Y of the first sub-line's baseline relative to the insertion point
|
||
/// (in the entity-local, pre-rotation frame).
|
||
pub v_offset: f32,
|
||
/// One world-space AABB per visible character — only populated when
|
||
/// `MTextRenderOpts::want_glyph_boxes` is set.
|
||
pub glyph_boxes: Vec<GlyphBox>,
|
||
}
|
||
|
||
/// Lay out and render an MText-formatted value, returning the stroke
|
||
/// groups plus the layout metrics needed by callers that draw chrome
|
||
/// (text frame, background fill, low-detail LOD substitutes) around the
|
||
/// text block.
|
||
pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
||
let base_font_name = opts.style.font_name.clone();
|
||
let base_font = Face::resolve(&base_font_name);
|
||
let base_wf_abs = opts.style.width_factor.max(0.01);
|
||
let base_wf = if opts.style.is_backward { -base_wf_abs } else { base_wf_abs };
|
||
let base_oblique = opts.style.oblique_angle;
|
||
let entity_h = opts.height;
|
||
let rect_w = opts.rect_w;
|
||
|
||
// ── 1. Parse ─────────────────────────────────────────────────────────
|
||
// The editor (want_glyph_boxes) keeps blank edges so a freshly typed
|
||
// trailing newline yields an empty paragraph the caret can sit on.
|
||
let paragraphs = adapt_mtext_paragraphs(opts.value, entity_h, !opts.want_glyph_boxes);
|
||
|
||
// ── 2. Atomise + wrap each paragraph into sub-lines ──────────────────
|
||
struct SubLine {
|
||
atoms: Vec<LayoutAtom>,
|
||
align: Option<ParagraphAlign>,
|
||
indent_first: f32,
|
||
indent_left: f32,
|
||
indent_right: f32,
|
||
tab_stops: Vec<TabStop>,
|
||
is_first_in_paragraph: bool,
|
||
}
|
||
|
||
let mut sub_lines: Vec<SubLine> = Vec::new();
|
||
for para in ¶graphs {
|
||
let mut atoms: Vec<LayoutAtom> = Vec::new();
|
||
for run in ¶.runs {
|
||
match &run.kind {
|
||
MTextRunKind::Glyphs(text) => {
|
||
let mut word = String::new();
|
||
for ch in text.chars() {
|
||
if ch == ' ' || ch == '\u{00A0}' {
|
||
if !word.is_empty() {
|
||
atoms.push(LayoutAtom {
|
||
kind: AtomKind::Word(std::mem::take(&mut word)),
|
||
state: run.state.clone(),
|
||
});
|
||
}
|
||
atoms.push(LayoutAtom {
|
||
kind: AtomKind::Space,
|
||
state: run.state.clone(),
|
||
});
|
||
} else {
|
||
word.push(ch);
|
||
}
|
||
}
|
||
if !word.is_empty() {
|
||
atoms.push(LayoutAtom {
|
||
kind: AtomKind::Word(word),
|
||
state: run.state.clone(),
|
||
});
|
||
}
|
||
}
|
||
MTextRunKind::Tab => {
|
||
atoms.push(LayoutAtom {
|
||
kind: AtomKind::Tab,
|
||
state: run.state.clone(),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Trim leading + trailing Space atoms so line_w / cursor_start agree
|
||
// on the paragraph's visible content. Without this a stray trailing
|
||
// space measures wider than it draws and centring / right-alignment
|
||
// is off by half a space-width.
|
||
//
|
||
// Skipped when emitting glyph boxes (the MText editor) so a space the
|
||
// user just typed at the end keeps a selectable box and the caret can
|
||
// sit after it.
|
||
if !opts.want_glyph_boxes {
|
||
let first_word = atoms
|
||
.iter()
|
||
.position(|a| !matches!(a.kind, AtomKind::Space))
|
||
.unwrap_or(atoms.len());
|
||
atoms.drain(..first_word);
|
||
while matches!(atoms.last().map(|a| &a.kind), Some(AtomKind::Space)) {
|
||
atoms.pop();
|
||
}
|
||
}
|
||
|
||
let wrapped = wrap_paragraph(
|
||
atoms,
|
||
rect_w,
|
||
para.indent_first,
|
||
para.indent_left,
|
||
para.indent_right,
|
||
¶.tab_stops,
|
||
entity_h,
|
||
base_wf,
|
||
&base_font_name,
|
||
);
|
||
for (idx, atoms) in wrapped.into_iter().enumerate() {
|
||
sub_lines.push(SubLine {
|
||
atoms,
|
||
align: para.align,
|
||
indent_first: para.indent_first,
|
||
indent_left: para.indent_left,
|
||
indent_right: para.indent_right,
|
||
tab_stops: para.tab_stops.clone(),
|
||
is_first_in_paragraph: idx == 0,
|
||
});
|
||
}
|
||
}
|
||
if sub_lines.is_empty() {
|
||
sub_lines.push(SubLine {
|
||
atoms: Vec::new(),
|
||
align: None,
|
||
indent_first: 0.0,
|
||
indent_left: 0.0,
|
||
indent_right: 0.0,
|
||
tab_stops: Vec::new(),
|
||
is_first_in_paragraph: true,
|
||
});
|
||
}
|
||
|
||
// ── 3. Block geometry (line spacing, attachment, rotation) ───────────
|
||
let n_lines = sub_lines.len().max(1) as f32;
|
||
let ls_factor = if opts.line_spacing_factor > 0.0 {
|
||
opts.line_spacing_factor
|
||
} else {
|
||
1.0
|
||
};
|
||
// DXF code 44 — multiplier on the default 5/3-em baseline-to-baseline gap.
|
||
let line_h = entity_h * ls_factor * (5.0 / 3.0) * base_font.line_spacing();
|
||
let h = entity_h;
|
||
let v_offset = match opts.v_anchor {
|
||
MTextVAnchor::Top => -h,
|
||
MTextVAnchor::Middle => ((n_lines - 1.0) * line_h - h) * 0.5,
|
||
MTextVAnchor::Bottom => (n_lines - 1.0) * line_h,
|
||
MTextVAnchor::MiddleOfTopLine => -h * 0.5,
|
||
MTextVAnchor::MiddleOfBottomLine => (n_lines - 1.0) * line_h - h * 0.5,
|
||
MTextVAnchor::BottomOfTopLine => 0.0,
|
||
};
|
||
let attach_h_anchor = opts.attach_h_anchor;
|
||
let box_left = -attach_h_anchor * rect_w;
|
||
let rot = opts.rotation;
|
||
let (cos_r, sin_r) = (rot.cos(), rot.sin());
|
||
let ins_x = opts.insertion[0];
|
||
let ins_y = opts.insertion[1];
|
||
|
||
// ── 4. Render each sub-line ──────────────────────────────────────────
|
||
let mut all_strokes: Vec<TextStroke> = Vec::new();
|
||
let mut line_widths: Vec<f32> = Vec::with_capacity(sub_lines.len());
|
||
let mut glyph_boxes: Vec<GlyphBox> = Vec::new();
|
||
let mut vis: usize = 0;
|
||
// Transform an entity-local point to world space (mirrors the stroke
|
||
// origin maths) so glyph boxes line up with the drawn glyphs.
|
||
let to_world = |line_base_x: f32, line_base_y: f32, lx: f32, ly: f32| -> (f32, f32) {
|
||
let wdx = lx * cos_r - ly * sin_r;
|
||
let wdy = lx * sin_r + ly * cos_r;
|
||
(
|
||
ins_x as f32 + line_base_x + wdx,
|
||
ins_y as f32 + line_base_y + wdy,
|
||
)
|
||
};
|
||
for (i, sub) in sub_lines.iter().enumerate() {
|
||
let li = i as f32;
|
||
let (line_base_x, line_base_y) = if opts.vertical_text {
|
||
let col_offset = li * entity_h * 1.2;
|
||
(
|
||
col_offset * cos_r + v_offset * (-sin_r),
|
||
col_offset * sin_r + v_offset * cos_r,
|
||
)
|
||
} else {
|
||
let ly = -(li * line_h) + v_offset;
|
||
(ly * (-sin_r), ly * cos_r)
|
||
};
|
||
|
||
let content_left = if rect_w > 0.0 {
|
||
box_left
|
||
+ if sub.is_first_in_paragraph {
|
||
sub.indent_first
|
||
} else {
|
||
sub.indent_left
|
||
}
|
||
} else {
|
||
0.0
|
||
};
|
||
let content_right = if rect_w > 0.0 {
|
||
box_left + rect_w - sub.indent_right
|
||
} else {
|
||
0.0
|
||
};
|
||
|
||
let line_anchor: f32 = match sub.align {
|
||
Some(ParagraphAlign::Left)
|
||
| Some(ParagraphAlign::Justify)
|
||
| Some(ParagraphAlign::Distribute) => 0.0,
|
||
Some(ParagraphAlign::Center) => 0.5,
|
||
Some(ParagraphAlign::Right) => 1.0,
|
||
None => attach_h_anchor,
|
||
};
|
||
|
||
let line_w = line_total_width(
|
||
&sub.atoms,
|
||
entity_h,
|
||
base_wf,
|
||
&base_font_name,
|
||
0.0,
|
||
sub.indent_left,
|
||
&sub.tab_stops,
|
||
);
|
||
line_widths.push(line_w);
|
||
|
||
let cursor_start = if rect_w > 0.0 {
|
||
let content_w = (content_right - content_left).max(0.0);
|
||
content_left + (content_w - line_w) * line_anchor
|
||
} else if line_anchor > 0.0 {
|
||
-line_w * line_anchor
|
||
} else {
|
||
0.0
|
||
};
|
||
|
||
let line_max_h = sub
|
||
.atoms
|
||
.iter()
|
||
.map(|a| a.state.height_mul * entity_h)
|
||
.fold(entity_h, f32::max);
|
||
|
||
// A paragraph break (explicit `\n` / `\P`) that started this line gets
|
||
// a zero-width caret slot at the line start, so the MText editor can
|
||
// place the caret on a fresh/empty line.
|
||
if opts.want_glyph_boxes && i > 0 && sub.is_first_in_paragraph {
|
||
let (ax, ay) = to_world(line_base_x, line_base_y, cursor_start, 0.0);
|
||
let (_, by) = to_world(line_base_x, line_base_y, cursor_start, entity_h);
|
||
glyph_boxes.push(GlyphBox {
|
||
vis,
|
||
xmin: ax,
|
||
xmax: ax,
|
||
ymin: ay.min(by),
|
||
ymax: ay.max(by),
|
||
});
|
||
vis += 1;
|
||
}
|
||
|
||
let mut cursor_x = cursor_start;
|
||
for atom in &sub.atoms {
|
||
match &atom.kind {
|
||
AtomKind::Word(text) => {
|
||
let run_h = atom.state.height_mul * entity_h;
|
||
let signed_wf =
|
||
base_wf.signum() * atom.state.width_mul * base_wf.abs();
|
||
let oblique = base_oblique + atom.state.oblique_rad;
|
||
let font_name = resolve_font(&atom.state, &base_font_name);
|
||
let tracking = atom.state.tracking;
|
||
let valign_dy = match atom.state.valign {
|
||
1 => (line_max_h - run_h) * 0.5,
|
||
2 => line_max_h - run_h,
|
||
_ => 0.0,
|
||
};
|
||
let color = atom.state.color.as_ref().and_then(resolve_inline_color);
|
||
let body = decorated(text, &atom.state);
|
||
|
||
let lx = cursor_x;
|
||
let ly = valign_dy;
|
||
let world_dx = lx * cos_r - ly * sin_r;
|
||
let world_dy = lx * sin_r + ly * cos_r;
|
||
let origin: [f64; 2] = [
|
||
ins_x + (line_base_x + world_dx) as f64,
|
||
ins_y + (line_base_y + world_dy) as f64,
|
||
];
|
||
let (strokes, fill_tris) = lff::tessellate_text_run(
|
||
[0.0, 0.0],
|
||
run_h,
|
||
rot,
|
||
signed_wf,
|
||
oblique,
|
||
tracking,
|
||
&font_name,
|
||
&body,
|
||
);
|
||
all_strokes.push(TextStroke {
|
||
strokes,
|
||
origin,
|
||
color,
|
||
fill_tris,
|
||
// `text` is the plain word (specials already resolved,
|
||
// no decoration markers); `run_h` is raw like the strokes.
|
||
run: Some(GlyphRun {
|
||
text: text.clone(),
|
||
font: font_name.to_string(),
|
||
height: run_h,
|
||
rotation: rot,
|
||
width_factor: signed_wf,
|
||
oblique,
|
||
tracking,
|
||
}),
|
||
});
|
||
if opts.want_glyph_boxes {
|
||
// Per-character boxes, advancing exactly as
|
||
// `measure_word` does so they track the glyphs.
|
||
let scale = run_scale(&atom.state, entity_h, base_wf);
|
||
let face = Face::resolve(&font_name);
|
||
let mut cx = cursor_x;
|
||
for ch in text.chars() {
|
||
let adv = match face.glyph(ch) {
|
||
Some(g) => {
|
||
(g.advance + face.letter_spacing() * tracking) * scale
|
||
}
|
||
None => (6.0 + face.letter_spacing() * tracking) * scale,
|
||
};
|
||
let (ax, ay) = to_world(line_base_x, line_base_y, cx, ly);
|
||
let (bx, by) = to_world(line_base_x, line_base_y, cx + adv, ly + run_h);
|
||
glyph_boxes.push(GlyphBox {
|
||
vis,
|
||
xmin: ax.min(bx),
|
||
xmax: ax.max(bx),
|
||
ymin: ay.min(by),
|
||
ymax: ay.max(by),
|
||
});
|
||
vis += 1;
|
||
cx += adv;
|
||
}
|
||
}
|
||
cursor_x +=
|
||
measure_word(text, &atom.state, entity_h, base_wf, &base_font_name);
|
||
}
|
||
AtomKind::Space => {
|
||
let adv = measure_space(&atom.state, entity_h, base_wf, &base_font_name);
|
||
if opts.want_glyph_boxes {
|
||
let run_h = atom.state.height_mul * entity_h;
|
||
let (ax, ay) = to_world(line_base_x, line_base_y, cursor_x, 0.0);
|
||
let (bx, by) =
|
||
to_world(line_base_x, line_base_y, cursor_x + adv, run_h);
|
||
glyph_boxes.push(GlyphBox {
|
||
vis,
|
||
xmin: ax.min(bx),
|
||
xmax: ax.max(bx),
|
||
ymin: ay.min(by),
|
||
ymax: ay.max(by),
|
||
});
|
||
vis += 1;
|
||
}
|
||
cursor_x += adv;
|
||
}
|
||
AtomKind::Tab => {
|
||
cursor_x = next_tab_position(
|
||
cursor_x,
|
||
&sub.tab_stops,
|
||
sub.indent_left,
|
||
entity_h,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
MTextLayout {
|
||
strokes: all_strokes,
|
||
line_widths,
|
||
line_count: sub_lines.len(),
|
||
line_height: line_h,
|
||
v_offset,
|
||
glyph_boxes,
|
||
}
|
||
}
|
||
pub(crate) fn text_obb_corners_native(
|
||
e: &EntityType,
|
||
anno_scale: f32,
|
||
mtext_lines_override: Option<usize>,
|
||
) -> Option<[[f64; 3]; 4]> {
|
||
use acadrust::entities::attribute_definition::{
|
||
HorizontalAlignment as AttrHA, VerticalAlignment as AttrVA,
|
||
};
|
||
use acadrust::entities::{AttachmentPoint, TextHorizontalAlignment, TextVerticalAlignment};
|
||
|
||
let anno = anno_scale as f64;
|
||
|
||
// Map Attribute alignment enums to the same h/v anchor fractions used by
|
||
// Text. Kept local because the Attribute enum is distinct from Text's.
|
||
let attr_h_anchor = |ha: AttrHA| -> f64 {
|
||
match ha {
|
||
AttrHA::Left => 0.0,
|
||
AttrHA::Center | AttrHA::Middle | AttrHA::Aligned | AttrHA::Fit => 0.5,
|
||
AttrHA::Right => 1.0,
|
||
}
|
||
};
|
||
let attr_v_anchor = |va: AttrVA| -> f64 {
|
||
match va {
|
||
AttrVA::Baseline | AttrVA::Bottom => 0.0,
|
||
AttrVA::Middle => 0.5,
|
||
AttrVA::Top => 1.0,
|
||
}
|
||
};
|
||
let attr_use_align_pt = |ha: AttrHA, va: AttrVA| -> bool {
|
||
!matches!((ha, va), (AttrHA::Left, AttrVA::Baseline))
|
||
};
|
||
|
||
let (ix, iy, iz, w, h, rot, h_anchor, v_anchor) = match e {
|
||
EntityType::Text(t) => {
|
||
let h_world = t.height * anno;
|
||
let w_factor = if t.width_factor > 0.0 { t.width_factor } else { 1.0 };
|
||
let n = t.value.chars().count().max(1) as f64;
|
||
// Approximate glyph width: AutoCAD's stroke fonts average ~0.6 em.
|
||
let w_world = n * h_world * w_factor * 0.6;
|
||
let h_anchor = match t.horizontal_alignment {
|
||
TextHorizontalAlignment::Left => 0.0,
|
||
TextHorizontalAlignment::Center
|
||
| TextHorizontalAlignment::Middle
|
||
| TextHorizontalAlignment::Aligned
|
||
| TextHorizontalAlignment::Fit => 0.5,
|
||
TextHorizontalAlignment::Right => 1.0,
|
||
};
|
||
let v_anchor = match t.vertical_alignment {
|
||
TextVerticalAlignment::Baseline | TextVerticalAlignment::Bottom => 0.0,
|
||
TextVerticalAlignment::Middle => 0.5,
|
||
TextVerticalAlignment::Top => 1.0,
|
||
};
|
||
// AutoCAD writes `alignment_point` (DXF 11) whenever the text
|
||
// isn't simple Left+Baseline; in that case it — not the
|
||
// insertion_point — is the anchor the alignment fractions map to.
|
||
let use_alignment_pt = !matches!(
|
||
(t.horizontal_alignment, t.vertical_alignment),
|
||
(
|
||
TextHorizontalAlignment::Left,
|
||
TextVerticalAlignment::Baseline,
|
||
)
|
||
);
|
||
let anchor = match (use_alignment_pt, t.alignment_point) {
|
||
(true, Some(p)) => p,
|
||
_ => t.insertion_point,
|
||
};
|
||
(
|
||
anchor.x,
|
||
anchor.y,
|
||
anchor.z,
|
||
w_world,
|
||
h_world,
|
||
t.rotation,
|
||
h_anchor,
|
||
v_anchor,
|
||
)
|
||
}
|
||
EntityType::AttributeDefinition(a) => {
|
||
let h_world = a.height * anno;
|
||
let w_factor = if a.width_factor > 0.0 { a.width_factor } else { 1.0 };
|
||
// Render the tag in preview when no default; matches `attribute.rs`.
|
||
let display = if a.default_value.is_empty() { &a.tag } else { &a.default_value };
|
||
let n = display.chars().count().max(1) as f64;
|
||
let w_world = n * h_world * w_factor * 0.6;
|
||
let h_anchor = attr_h_anchor(a.horizontal_alignment);
|
||
let v_anchor = attr_v_anchor(a.vertical_alignment);
|
||
let anchor = if attr_use_align_pt(a.horizontal_alignment, a.vertical_alignment) {
|
||
a.alignment_point
|
||
} else {
|
||
a.insertion_point
|
||
};
|
||
(
|
||
anchor.x, anchor.y, anchor.z, w_world, h_world, a.rotation, h_anchor, v_anchor,
|
||
)
|
||
}
|
||
EntityType::AttributeEntity(a) => {
|
||
let h_world = a.height * anno;
|
||
let w_factor = if a.width_factor > 0.0 { a.width_factor } else { 1.0 };
|
||
let n = a.value.chars().count().max(1) as f64;
|
||
let w_world = n * h_world * w_factor * 0.6;
|
||
let h_anchor = attr_h_anchor(a.horizontal_alignment);
|
||
let v_anchor = attr_v_anchor(a.vertical_alignment);
|
||
let anchor = if attr_use_align_pt(a.horizontal_alignment, a.vertical_alignment) {
|
||
a.alignment_point
|
||
} else {
|
||
a.insertion_point
|
||
};
|
||
(
|
||
anchor.x, anchor.y, anchor.z, w_world, h_world, a.rotation, h_anchor, v_anchor,
|
||
)
|
||
}
|
||
EntityType::Tolerance(t) => {
|
||
// Approximate: a feature control frame is roughly 1 line tall;
|
||
// width comes from char-count of the (already-stripped) text.
|
||
// GD&T symbols all advance one cell, so plain char count is close.
|
||
let raw_h = if t.text_height > 0.0 { t.text_height } else { 2.5 };
|
||
let h_world = raw_h * anno;
|
||
// Each cell ≈ 1.4 × height (matches `tolerance.rs` min_cell_w).
|
||
let n = t.text.chars().filter(|c| *c != '\n').count().max(1) as f64;
|
||
let w_world = h_world * 1.4 * n * 0.5; // rough — fine for LOD greek
|
||
// direction encodes rotation as a vector.
|
||
let rot = (t.direction.y).atan2(t.direction.x);
|
||
(
|
||
t.insertion_point.x,
|
||
t.insertion_point.y,
|
||
t.insertion_point.z,
|
||
w_world,
|
||
h_world * 1.5, // frame is taller than glyph cap by ~0.5 h
|
||
rot,
|
||
0.0, // anchored at insertion point (bottom-left)
|
||
0.0,
|
||
)
|
||
}
|
||
EntityType::MText(m) => {
|
||
let h_world = m.height * anno;
|
||
let raw_lines = (m.value.matches('\n').count() + 1) as f64;
|
||
let effective_lines = match mtext_lines_override {
|
||
Some(n) => n.max(1) as f64,
|
||
None => raw_lines,
|
||
};
|
||
let w_world = if m.rectangle_width > 0.0 {
|
||
m.rectangle_width
|
||
} else {
|
||
h_world * 8.0 * effective_lines.max(1.0)
|
||
};
|
||
// Wrap-aware override beats `rectangle_height` — the stored
|
||
// height can be stale on DWGs that were re-saved without
|
||
// updating the bounds.
|
||
let total_h = if mtext_lines_override.is_some() {
|
||
h_world * effective_lines.max(1.0) * m.line_spacing_factor.max(0.5)
|
||
} else {
|
||
m.rectangle_height.unwrap_or(
|
||
h_world * raw_lines.max(1.0) * m.line_spacing_factor.max(0.5),
|
||
)
|
||
};
|
||
// MText `attachment_point` puts `insertion_point` at one of the
|
||
// 9 corners/midpoints of the text bbox. h_anchor / v_anchor are
|
||
// fractions from (left, bottom) of the bbox.
|
||
let (h_anchor, v_anchor) = match m.attachment_point {
|
||
AttachmentPoint::TopLeft => (0.0, 1.0),
|
||
AttachmentPoint::TopCenter => (0.5, 1.0),
|
||
AttachmentPoint::TopRight => (1.0, 1.0),
|
||
AttachmentPoint::MiddleLeft => (0.0, 0.5),
|
||
AttachmentPoint::MiddleCenter => (0.5, 0.5),
|
||
AttachmentPoint::MiddleRight => (1.0, 0.5),
|
||
AttachmentPoint::BottomLeft => (0.0, 0.0),
|
||
AttachmentPoint::BottomCenter => (0.5, 0.0),
|
||
AttachmentPoint::BottomRight => (1.0, 0.0),
|
||
};
|
||
(
|
||
m.insertion_point.x,
|
||
m.insertion_point.y,
|
||
m.insertion_point.z,
|
||
w_world,
|
||
total_h,
|
||
m.rotation,
|
||
h_anchor,
|
||
v_anchor,
|
||
)
|
||
}
|
||
_ => return None,
|
||
};
|
||
|
||
let x0 = -h_anchor * w;
|
||
let x1 = (1.0 - h_anchor) * w;
|
||
let y0 = -v_anchor * h;
|
||
let y1 = (1.0 - v_anchor) * h;
|
||
|
||
let (s, c) = (rot.sin(), rot.cos());
|
||
let rot_pt = |lx: f64, ly: f64| -> [f64; 3] {
|
||
let rx = lx * c - ly * s;
|
||
let ry = lx * s + ly * c;
|
||
[ix + rx, iy + ry, iz]
|
||
};
|
||
|
||
Some([
|
||
rot_pt(x0, y0),
|
||
rot_pt(x1, y0),
|
||
rot_pt(x1, y1),
|
||
rot_pt(x0, y1),
|
||
])
|
||
}
|
||
pub(crate) fn text_baseline_points(
|
||
e: &EntityType,
|
||
anno_scale: f32,
|
||
n_lines: usize,
|
||
) -> Vec<[f32; 3]> {
|
||
let Some(corners) = text_obb_corners_native(e, anno_scale, Some(n_lines)) else {
|
||
return vec![];
|
||
};
|
||
let line_h = match e {
|
||
EntityType::Text(t) => (t.height * anno_scale as f64) as f32,
|
||
EntityType::MText(m) => (m.height * anno_scale as f64) as f32,
|
||
EntityType::AttributeDefinition(a) => (a.height * anno_scale as f64) as f32,
|
||
EntityType::AttributeEntity(a) => (a.height * anno_scale as f64) as f32,
|
||
EntityType::Tolerance(t) => {
|
||
let raw = if t.text_height > 0.0 { t.text_height } else { 2.5 };
|
||
(raw * anno_scale as f64) as f32
|
||
}
|
||
_ => return vec![],
|
||
};
|
||
if line_h <= 0.0 {
|
||
return vec![];
|
||
}
|
||
let n_lines = n_lines.max(1);
|
||
let cast = |p: [f64; 3]| -> [f32; 3] {
|
||
[(p[0]) as f32, (p[1]) as f32, (p[2]) as f32]
|
||
};
|
||
let bl = cast(corners[0]);
|
||
let br = cast(corners[1]);
|
||
let full_tl = cast(corners[3]);
|
||
|
||
let (ux, uy, uz) = (full_tl[0] - bl[0], full_tl[1] - bl[1], full_tl[2] - bl[2]);
|
||
let ulen = (ux * ux + uy * uy + uz * uz).sqrt();
|
||
if ulen < 1e-9 {
|
||
return vec![bl, br];
|
||
}
|
||
let (nx, ny, nz) = (ux / ulen, uy / ulen, uz / ulen);
|
||
|
||
let mut pts = Vec::with_capacity(n_lines * 3);
|
||
for i in 0..n_lines {
|
||
// i = 0 is the topmost line — its bottom sits one line_h below
|
||
// the OBB top (≈ `ulen`). For n_lines > ulen/line_h the deepest
|
||
// baselines clamp to the OBB bottom.
|
||
let bot_off = ((i + 1) as f32) * line_h;
|
||
let along = (ulen - bot_off).max(0.0);
|
||
let p0 = [bl[0] + nx * along, bl[1] + ny * along, bl[2] + nz * along];
|
||
let p1 = [br[0] + nx * along, br[1] + ny * along, br[2] + nz * along];
|
||
if !pts.is_empty() {
|
||
pts.push([f32::NAN; 3]);
|
||
}
|
||
pts.extend_from_slice(&[p0, p1]);
|
||
}
|
||
pts
|
||
}
|
||
|
||
/// Filled tris for a greeked top-level Text / MText. One 2-triangle rect
|
||
/// per visible line — `n_lines` is the actual rendered line count from
|
||
/// `mtext_line_count` (1 for Text). Stacked top → bottom along the OBB's
|
||
/// up direction. The face3d pipeline skips its 0.45 dim for wires with
|
||
/// empty `points`, so these tris render at the literal text color.
|
||
pub(crate) fn text_greek_obb_tris(
|
||
e: &EntityType,
|
||
anno_scale: f32,
|
||
n_lines: usize,
|
||
) -> Vec<[f32; 3]> {
|
||
let Some(corners) = text_obb_corners_native(e, anno_scale, Some(n_lines)) else {
|
||
return vec![];
|
||
};
|
||
let line_h = match e {
|
||
EntityType::Text(t) => (t.height * anno_scale as f64) as f32,
|
||
EntityType::MText(m) => (m.height * anno_scale as f64) as f32,
|
||
EntityType::AttributeDefinition(a) => (a.height * anno_scale as f64) as f32,
|
||
EntityType::AttributeEntity(a) => (a.height * anno_scale as f64) as f32,
|
||
EntityType::Tolerance(t) => {
|
||
let raw = if t.text_height > 0.0 { t.text_height } else { 2.5 };
|
||
(raw * anno_scale as f64) as f32
|
||
}
|
||
_ => return vec![],
|
||
};
|
||
if line_h <= 0.0 {
|
||
return vec![];
|
||
}
|
||
let n_lines = n_lines.max(1);
|
||
let cast = |p: [f64; 3]| -> [f32; 3] {
|
||
[(p[0]) as f32, (p[1]) as f32, (p[2]) as f32]
|
||
};
|
||
let bl = cast(corners[0]);
|
||
let br = cast(corners[1]);
|
||
let full_tl = cast(corners[3]);
|
||
|
||
let (ux, uy, uz) = (full_tl[0] - bl[0], full_tl[1] - bl[1], full_tl[2] - bl[2]);
|
||
let ulen = (ux * ux + uy * uy + uz * uz).sqrt();
|
||
if ulen < 1e-9 {
|
||
return vec![];
|
||
}
|
||
let (nx, ny, nz) = (ux / ulen, uy / ulen, uz / ulen);
|
||
|
||
let mut tris = Vec::with_capacity(n_lines * 6);
|
||
for i in 0..n_lines {
|
||
let top_along = (ulen - (i as f32) * line_h).max(0.0);
|
||
let bot_along = (ulen - ((i + 1) as f32) * line_h).max(0.0);
|
||
let tl = [
|
||
bl[0] + nx * top_along,
|
||
bl[1] + ny * top_along,
|
||
bl[2] + nz * top_along,
|
||
];
|
||
let tr = [
|
||
br[0] + nx * top_along,
|
||
br[1] + ny * top_along,
|
||
br[2] + nz * top_along,
|
||
];
|
||
let lbl = [
|
||
bl[0] + nx * bot_along,
|
||
bl[1] + ny * bot_along,
|
||
bl[2] + nz * bot_along,
|
||
];
|
||
let lbr = [
|
||
br[0] + nx * bot_along,
|
||
br[1] + ny * bot_along,
|
||
br[2] + nz * bot_along,
|
||
];
|
||
tris.extend_from_slice(&[lbl, lbr, tr, lbl, tr, tl]);
|
||
}
|
||
tris
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn style(font_name: &str) -> ResolvedTextStyle {
|
||
ResolvedTextStyle {
|
||
font_name: font_name.to_string(),
|
||
width_factor: 1.0,
|
||
oblique_angle: 0.0,
|
||
is_backward: false,
|
||
is_upside_down: false,
|
||
}
|
||
}
|
||
|
||
fn stroke_point_count(layout: &MTextLayout) -> usize {
|
||
layout
|
||
.strokes
|
||
.iter()
|
||
.map(|s| s.strokes.iter().map(Vec::len).sum::<usize>() + s.fill_tris.len())
|
||
.sum()
|
||
}
|
||
|
||
#[test]
|
||
fn unresolved_inline_font_falls_back_to_style_font() {
|
||
let base = "txt";
|
||
let mut state = RunState::default();
|
||
state.font = Some("__definitely_not_an_installed_font__".to_string());
|
||
|
||
assert_eq!(resolve_font(&state, base), base);
|
||
|
||
let layout = layout_mtext(&MTextRenderOpts {
|
||
value: "{\\f__definitely_not_an_installed_font__|b0|i0|c0|p34;Storage Units}",
|
||
insertion: [0.0, 0.0, 0.0],
|
||
height: 2.5,
|
||
rect_w: 0.0,
|
||
rotation: 0.0,
|
||
style: &style(base),
|
||
attach_h_anchor: 0.0,
|
||
v_anchor: MTextVAnchor::Top,
|
||
line_spacing_factor: 1.0,
|
||
vertical_text: false,
|
||
want_glyph_boxes: false,
|
||
});
|
||
|
||
assert!(
|
||
stroke_point_count(&layout) > 0,
|
||
"unresolvable inline \\f should render through the style font"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn block_style_font_name_from_ttf_file_renders_mtext() {
|
||
let layout = layout_mtext(&MTextRenderOpts {
|
||
value: "FERRAGAMO",
|
||
insertion: [0.0, 0.0, 0.0],
|
||
height: 20.0,
|
||
rect_w: 0.0,
|
||
rotation: 0.0,
|
||
style: &style("arial"),
|
||
attach_h_anchor: 0.0,
|
||
v_anchor: MTextVAnchor::Top,
|
||
line_spacing_factor: 1.0,
|
||
vertical_text: false,
|
||
want_glyph_boxes: false,
|
||
});
|
||
|
||
assert!(
|
||
stroke_point_count(&layout) > 0,
|
||
"style font derived from arial.ttf should produce drawable block text"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod adapter_tests {
|
||
use super::*;
|
||
|
||
/// Glyph text + state of the first renderable run.
|
||
fn first_run(lines: &[MTextLine]) -> (String, RunState) {
|
||
for l in lines {
|
||
for r in &l.runs {
|
||
if let MTextRunKind::Glyphs(t) = &r.kind {
|
||
return (t.clone(), r.state.clone());
|
||
}
|
||
}
|
||
}
|
||
panic!("no glyph run produced");
|
||
}
|
||
|
||
#[test]
|
||
fn plain_text_is_default_state() {
|
||
let lines = adapt_mtext_paragraphs("Hello", 2.5, true);
|
||
assert_eq!(lines.len(), 1);
|
||
let (t, st) = first_run(&lines);
|
||
assert_eq!(t, "Hello");
|
||
assert_eq!(st, RunState::default());
|
||
}
|
||
|
||
#[test]
|
||
fn relative_height_is_a_factor() {
|
||
// `\H2x;` multiplies the current height → height_mul 2.0, independent of
|
||
// the entity height. (This is the case that needed acadrust's MTextScalar.)
|
||
let (_, st) = first_run(&adapt_mtext_paragraphs("\\H2x;big", 2.5, true));
|
||
assert!((st.height_mul - 2.0).abs() < 1e-4, "got {}", st.height_mul);
|
||
}
|
||
|
||
#[test]
|
||
fn absolute_height_resolves_against_entity_height() {
|
||
// `\H5;` is an absolute height → divided by the entity height (2.5) → 2.0.
|
||
let (_, st) = first_run(&adapt_mtext_paragraphs("\\H5;abs", 2.5, true));
|
||
assert!((st.height_mul - 2.0).abs() < 1e-4, "got {}", st.height_mul);
|
||
}
|
||
|
||
#[test]
|
||
fn color_font_oblique_valign_decoration() {
|
||
let (_, c) = first_run(&adapt_mtext_paragraphs("\\C1;red", 2.5, true));
|
||
assert_eq!(c.color, Some(InlineColor::Aci(1)));
|
||
|
||
let (_, f) = first_run(&adapt_mtext_paragraphs("\\fArial;x", 2.5, true));
|
||
assert_eq!(f.font.as_deref(), Some("Arial"));
|
||
|
||
let (_, q) = first_run(&adapt_mtext_paragraphs("\\Q15;x", 2.5, true));
|
||
assert!((q.oblique_rad - 15f32.to_radians()).abs() < 1e-4);
|
||
|
||
let (_, w) = first_run(&adapt_mtext_paragraphs("\\W1.5;x", 2.5, true));
|
||
assert!((w.width_mul - 1.5).abs() < 1e-4);
|
||
|
||
let (_, a) = first_run(&adapt_mtext_paragraphs("\\A1;x", 2.5, true));
|
||
assert_eq!(a.valign, 1);
|
||
|
||
let (_, d) = first_run(&adapt_mtext_paragraphs("\\Lunder\\l", 2.5, true));
|
||
assert!(d.underline);
|
||
}
|
||
|
||
#[test]
|
||
fn stacking_flattens_like_the_legacy_parser() {
|
||
let (t, _) = first_run(&adapt_mtext_paragraphs("\\S1/2;", 2.5, true));
|
||
assert_eq!(t, "1/2");
|
||
let (t, _) = first_run(&adapt_mtext_paragraphs("\\S1^2;", 2.5, true));
|
||
assert_eq!(t, "1^2");
|
||
// Diagonal `#` renders with a `/` separator.
|
||
let (t, _) = first_run(&adapt_mtext_paragraphs("\\S1#2;", 2.5, true));
|
||
assert_eq!(t, "1/2");
|
||
}
|
||
|
||
#[test]
|
||
fn paragraph_breaks_split_lines() {
|
||
let lines = adapt_mtext_paragraphs("a\\Pb\\Pc", 2.5, true);
|
||
assert_eq!(lines.len(), 3);
|
||
}
|
||
}
|
||
|