refactor(mtext): parse MTEXT via acadrust, drop OCS's own parser
OCS maintained a second MTEXT inline-code parser that duplicated acadrust's `mtext_format::parse_mtext`. Replace it with an adapter (`adapt_mtext_paragraphs`) that maps acadrust's structured MTextDocument onto the existing layout types (`MTextLine`/`RunState`); the layout engine (`layout_mtext`, `mtext_line_count`, and their callers) is unchanged. Removed the hand-rolled `parse_mtext_paragraphs*` and its helpers. Validated during migration by a differential test (adapter vs the old parser matched byte-for-byte over a 20-case corpus) and confirmed rendering identically in the app; the differential test is replaced with adapter-only unit tests. Relies on acadrust's new `MTextScalar` to tell absolute `\H` from relative `\Hx` (bumped the acadrust pin). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
896b6c385c
commit
b88e5e4ad5
2 changed files with 205 additions and 399 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -71,7 +71,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
|||
[[package]]
|
||||
name = "acadrust"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/HakanSeven12/acadrust?branch=main#4d519473ee1c03275be87d9c3bda4ddc53db45b0"
|
||||
source = "git+https://github.com/HakanSeven12/acadrust?branch=main#eae0ec3ca1777c1a89d78374fe56a1edc130e5ed"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"anyhow",
|
||||
|
|
@ -3160,7 +3160,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d"
|
||||
dependencies = [
|
||||
"bytecount",
|
||||
"memchr 1.0.2",
|
||||
"memchr 2.8.2",
|
||||
"nom 8.0.0",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -329,412 +329,138 @@ impl MTextLine {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct ParagraphProps {
|
||||
align: Option<ParagraphAlign>,
|
||||
indent_first: f32,
|
||||
indent_left: f32,
|
||||
indent_right: f32,
|
||||
tab_stops: Vec<TabStop>,
|
||||
|
||||
/// 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 a `\p...;` body. Comma-separated tokens, each with a single-letter
|
||||
/// kind prefix. Unknown tokens are skipped — anything we don't understand is
|
||||
/// silently dropped rather than poisoning the rest of the paragraph.
|
||||
fn parse_paragraph_block(body: &str, props: &mut ParagraphProps) {
|
||||
// The legacy AutoCAD writer prefixes the block with a redundant `x`; skip
|
||||
// it so it doesn't confuse the kind matcher.
|
||||
let body = body.strip_prefix('x').unwrap_or(body);
|
||||
for token in body.split(',') {
|
||||
let token = token.trim();
|
||||
let mut chars = token.chars();
|
||||
let Some(kind) = chars.next() else { continue };
|
||||
let rest: String = chars.collect();
|
||||
match kind {
|
||||
'q' | 'Q' => {
|
||||
let sel = rest.chars().next().map(|c| c.to_ascii_lowercase());
|
||||
props.align = match sel {
|
||||
Some('l') => Some(ParagraphAlign::Left),
|
||||
Some('c') => Some(ParagraphAlign::Center),
|
||||
Some('r') => Some(ParagraphAlign::Right),
|
||||
Some('j') => Some(ParagraphAlign::Justify),
|
||||
Some('d') => Some(ParagraphAlign::Distribute),
|
||||
_ => props.align,
|
||||
};
|
||||
}
|
||||
'i' => {
|
||||
if let Ok(v) = rest.parse::<f32>() {
|
||||
props.indent_first = v;
|
||||
}
|
||||
}
|
||||
'l' => {
|
||||
if let Ok(v) = rest.parse::<f32>() {
|
||||
props.indent_left = v;
|
||||
}
|
||||
}
|
||||
'r' => {
|
||||
if let Ok(v) = rest.parse::<f32>() {
|
||||
props.indent_right = v;
|
||||
}
|
||||
}
|
||||
't' => {
|
||||
// Tab list. Each entry may be prefixed with L / C / R to
|
||||
// pick the tab kind; default is Left. The remainder is the
|
||||
// position in drawing units.
|
||||
props.tab_stops.clear();
|
||||
for entry in rest.split(',') {
|
||||
let entry = entry.trim();
|
||||
if entry.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (kind, num_str) = match entry.chars().next() {
|
||||
Some(c @ ('L' | 'l')) => (TabKind::Left, &entry[c.len_utf8()..]),
|
||||
Some(c @ ('C' | 'c')) => (TabKind::Center, &entry[c.len_utf8()..]),
|
||||
Some(c @ ('R' | 'r')) => (TabKind::Right, &entry[c.len_utf8()..]),
|
||||
_ => (TabKind::Left, entry),
|
||||
};
|
||||
if let Ok(p) = num_str.parse::<f32>() {
|
||||
props.tab_stops.push(TabStop { position: p, kind });
|
||||
}
|
||||
}
|
||||
}
|
||||
's' => {} // space-before — affects line spacing; ignored for now
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an unsigned 24-bit true-colour value into linear-ish [r,g,b] floats.
|
||||
/// AutoCAD packs `\c` as a 24-bit decimal: high byte = R, mid = G, low = B.
|
||||
fn parse_true_color(s: &str) -> Option<InlineColor> {
|
||||
let n: u32 = s.trim().parse().ok()?;
|
||||
let r = ((n >> 16) & 0xFF) as f32 / 255.0;
|
||||
let g = ((n >> 8) & 0xFF) as f32 / 255.0;
|
||||
let b = (n & 0xFF) as f32 / 255.0;
|
||||
Some(InlineColor::True([r, g, b]))
|
||||
}
|
||||
|
||||
/// Parse `\H` / `\W` / `\T` value. Returns `(value, is_relative)`; a trailing
|
||||
/// `x` marks a multiplier on the current state, otherwise the value is the
|
||||
/// absolute target (interpreted by the caller against entity defaults).
|
||||
fn parse_scalar_with_x_suffix(body: &str) -> Option<(f32, bool)> {
|
||||
let body = body.trim();
|
||||
let (num, is_rel) = if let Some(stripped) = body.strip_suffix('x').or_else(|| body.strip_suffix('X')) {
|
||||
(stripped, true)
|
||||
} else {
|
||||
(body, false)
|
||||
};
|
||||
Some((num.trim().parse::<f32>().ok()?, is_rel))
|
||||
}
|
||||
|
||||
/// Flush the glyph buffer as a `Glyphs` run on `line` using a snapshot of
|
||||
/// `state`. Resolves DXF `%%x` specials (degree / diameter / `%%nnn` literal
|
||||
/// unicode) so the renderer sees fully decoded text.
|
||||
fn flush_glyph_buf(line: &mut MTextLine, buf: &mut String, state: &RunState) {
|
||||
if buf.is_empty() {
|
||||
return;
|
||||
}
|
||||
let text = resolve_dxf_special_chars(&std::mem::take(buf));
|
||||
line.runs.push(MTextRun {
|
||||
kind: MTextRunKind::Glyphs(text),
|
||||
state: state.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Walk the MTEXT value string and produce one [`MTextLine`] per visible
|
||||
/// paragraph (after stripping leading / trailing blank lines, as the legacy
|
||||
/// `split_mtext_lines` does). Every inline format code listed in the module
|
||||
/// header is recognised; unknown semicolon-terminated codes are stripped
|
||||
/// silently so future / vendor-specific extensions don't pollute the text.
|
||||
/// 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.
|
||||
///
|
||||
/// `entity_height` is needed to translate absolute `\H<v>;` declarations into
|
||||
/// the height-multiplier representation carried in [`RunState`]; pass the
|
||||
/// MTEXT entity's `height` field.
|
||||
pub fn parse_mtext_paragraphs(s: &str, entity_height: f32) -> Vec<MTextLine> {
|
||||
parse_mtext_paragraphs_ex(s, entity_height, true)
|
||||
}
|
||||
|
||||
/// Like [`parse_mtext_paragraphs`] but with control over blank-edge trimming.
|
||||
///
|
||||
/// `trim_blank_edges` drops leading and trailing blank paragraphs (the
|
||||
/// rendering default, so a stray trailing `\P` adds no empty space). The MText
|
||||
/// editor passes `false` so a freshly inserted newline keeps its empty
|
||||
/// paragraph and the caret can sit on the new line.
|
||||
pub fn parse_mtext_paragraphs_ex(
|
||||
/// 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> {
|
||||
let mut lines: Vec<MTextLine> = Vec::new();
|
||||
let mut current = MTextLine::default();
|
||||
let mut buf = String::new();
|
||||
let mut state = RunState::default();
|
||||
let mut state_stack: Vec<RunState> = Vec::new();
|
||||
let mut props = ParagraphProps::default();
|
||||
// No props_stack — paragraph props persist across braces by design.
|
||||
let entity_height = entity_height.max(1e-6);
|
||||
|
||||
let mut chars = s.chars().peekable();
|
||||
|
||||
let read_until_semi = |chars: &mut std::iter::Peekable<std::str::Chars>| -> String {
|
||||
let mut out = String::new();
|
||||
for c in chars.by_ref() {
|
||||
if c == ';' {
|
||||
break;
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
use acadrust::entities::mtext_format::{
|
||||
parse_mtext, MTextColor, MTextLineAlignment, MTextParagraphAlignment, MTextScalar,
|
||||
SpanProperties, StackingType,
|
||||
};
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'\\' => match chars.peek().copied() {
|
||||
// ── Line / paragraph break ────────────────────────────────
|
||||
Some('P') | Some('n') | Some('N') => {
|
||||
chars.next();
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
current.align = props.align;
|
||||
current.indent_first = props.indent_first;
|
||||
current.indent_left = props.indent_left;
|
||||
current.indent_right = props.indent_right;
|
||||
current.tab_stops = props.tab_stops.clone();
|
||||
lines.push(std::mem::take(&mut current));
|
||||
}
|
||||
// ── Whitespace literals ───────────────────────────────────
|
||||
Some('~') => {
|
||||
chars.next();
|
||||
buf.push('\u{00A0}'); // nbsp — treated as a regular space by the wrap pass
|
||||
}
|
||||
Some('t') => {
|
||||
chars.next();
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
current.runs.push(MTextRun {
|
||||
kind: MTextRunKind::Tab,
|
||||
state: state.clone(),
|
||||
});
|
||||
}
|
||||
// ── Unicode by hex code point ────────────────────────────
|
||||
Some('U') | Some('u') => {
|
||||
chars.next();
|
||||
if chars.peek() == Some(&'+') {
|
||||
chars.next();
|
||||
// A `\U+XXXX` escape is exactly four hex digits (a BMP
|
||||
// code point). Reading more greedily swallows the
|
||||
// following literal characters — e.g. the diameter
|
||||
// value `\U+220520` ("⌀20") was parsed as code point
|
||||
// 0x220520, which is out of range, so the whole text
|
||||
// vanished. (#139)
|
||||
let mut hex = String::with_capacity(4);
|
||||
for _ in 0..4 {
|
||||
match chars.peek() {
|
||||
Some(&c) if c.is_ascii_hexdigit() => {
|
||||
hex.push(chars.next().unwrap());
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
if chars.peek() == Some(&';') {
|
||||
chars.next();
|
||||
}
|
||||
if let Ok(n) = u32::from_str_radix(&hex, 16) {
|
||||
if let Some(c) = char::from_u32(n) {
|
||||
buf.push(c);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Bare `\U` / `\u` — strip to next `;`
|
||||
let _ = read_until_semi(&mut chars);
|
||||
}
|
||||
}
|
||||
// ── Stacked text \S<u><sep><l>; ──────────────────────────
|
||||
Some('S') | Some('s') => {
|
||||
chars.next();
|
||||
let mut upper = String::new();
|
||||
let mut lower = String::new();
|
||||
let mut sep = '/';
|
||||
let mut in_lower = false;
|
||||
for c in chars.by_ref() {
|
||||
if c == ';' {
|
||||
break;
|
||||
}
|
||||
if !in_lower && (c == '/' || c == '^' || c == '#') {
|
||||
sep = c;
|
||||
in_lower = true;
|
||||
} else if in_lower {
|
||||
lower.push(c);
|
||||
} else {
|
||||
upper.push(c);
|
||||
}
|
||||
}
|
||||
buf.push_str(&upper);
|
||||
if !lower.is_empty() {
|
||||
buf.push(if sep == '#' { '/' } else { sep });
|
||||
buf.push_str(&lower);
|
||||
}
|
||||
}
|
||||
// ── Decoration toggles (state, not markers) ──────────────
|
||||
Some('L') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.underline = true; }
|
||||
Some('l') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.underline = false; }
|
||||
Some('O') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.overline = true; }
|
||||
Some('o') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.overline = false; }
|
||||
Some('K') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.strike = true; }
|
||||
Some('k') => { chars.next(); flush_glyph_buf(&mut current, &mut buf, &state); state.strike = false; }
|
||||
// ── Literal backslash / braces ───────────────────────────
|
||||
Some('\\') => { chars.next(); buf.push('\\'); }
|
||||
Some('{') | Some('}') => { buf.push(chars.next().unwrap()); }
|
||||
// ── Paragraph props ──────────────────────────────────────
|
||||
Some('p') => {
|
||||
chars.next();
|
||||
let body = read_until_semi(&mut chars);
|
||||
parse_paragraph_block(&body, &mut props);
|
||||
}
|
||||
// ── Height ───────────────────────────────────────────────
|
||||
Some('H') => {
|
||||
chars.next();
|
||||
let body = read_until_semi(&mut chars);
|
||||
if let Some((v, is_rel)) = parse_scalar_with_x_suffix(&body) {
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
if is_rel {
|
||||
state.height_mul *= v;
|
||||
} else {
|
||||
state.height_mul = v / entity_height;
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── Width factor ─────────────────────────────────────────
|
||||
Some('W') => {
|
||||
chars.next();
|
||||
let body = read_until_semi(&mut chars);
|
||||
if let Some((v, is_rel)) = parse_scalar_with_x_suffix(&body) {
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
if is_rel {
|
||||
state.width_mul *= v;
|
||||
} else {
|
||||
state.width_mul = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── Oblique angle (degrees → radians) ────────────────────
|
||||
Some('Q') => {
|
||||
chars.next();
|
||||
let body = read_until_semi(&mut chars);
|
||||
if let Ok(deg) = body.trim().parse::<f32>() {
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
state.oblique_rad = deg.to_radians();
|
||||
}
|
||||
}
|
||||
// ── Tracking ─────────────────────────────────────────────
|
||||
Some('T') => {
|
||||
chars.next();
|
||||
let body = read_until_semi(&mut chars);
|
||||
if let Some((v, is_rel)) = parse_scalar_with_x_suffix(&body) {
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
if is_rel {
|
||||
state.tracking *= v;
|
||||
} else {
|
||||
state.tracking = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── Vertical alignment ───────────────────────────────────
|
||||
Some('A') => {
|
||||
chars.next();
|
||||
let body = read_until_semi(&mut chars);
|
||||
if let Ok(n) = body.trim().parse::<u8>() {
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
state.valign = n.min(2);
|
||||
}
|
||||
}
|
||||
// ── ACI colour ───────────────────────────────────────────
|
||||
Some('C') => {
|
||||
chars.next();
|
||||
let body = read_until_semi(&mut chars);
|
||||
if let Ok(n) = body.trim().parse::<u32>() {
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
state.color = Some(InlineColor::Aci(n.min(255) as u8));
|
||||
}
|
||||
}
|
||||
// ── True colour ──────────────────────────────────────────
|
||||
Some('c') => {
|
||||
chars.next();
|
||||
let body = read_until_semi(&mut chars);
|
||||
if let Some(col) = parse_true_color(&body) {
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
state.color = Some(col);
|
||||
}
|
||||
}
|
||||
// ── Font (name + b/i/c/p flags) ──────────────────────────
|
||||
Some('f') | Some('F') => {
|
||||
chars.next();
|
||||
let body = read_until_semi(&mut chars);
|
||||
// First `|`-separated field is the font name / file stem.
|
||||
let name = body.split('|').next().unwrap_or("").trim();
|
||||
if !name.is_empty() {
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
// Strip extension if `\F` passed a file path.
|
||||
let stem = name
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(name)
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or(name);
|
||||
state.font = Some(stem.to_string());
|
||||
}
|
||||
}
|
||||
// ── Multibyte / codepage marker — strip silently ─────────
|
||||
Some('M') => {
|
||||
chars.next();
|
||||
let _ = read_until_semi(&mut chars);
|
||||
}
|
||||
// ── Dimension MTEXT paragraph-end marker — strip silently
|
||||
Some('X') => {
|
||||
chars.next();
|
||||
}
|
||||
// ── Unknown single-letter escape ─────────────────────────
|
||||
Some(_) => {
|
||||
chars.next();
|
||||
}
|
||||
None => {}
|
||||
},
|
||||
// Scope push / pop — braces scope *character* state (font,
|
||||
// colour, height, decorations, …). Paragraph properties
|
||||
// (`\p...;`) are intentionally NOT scoped: AutoCAD treats them
|
||||
// as paragraph-level layout that persists across braces, and
|
||||
// real-world files routinely wrap inline `\pxqc;` inside a
|
||||
// `{\fArial;…}` block while expecting the alignment to apply
|
||||
// to the whole paragraph.
|
||||
'{' => {
|
||||
state_stack.push(state.clone());
|
||||
}
|
||||
'}' => {
|
||||
if let Some(prev) = state_stack.pop() {
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
state = prev;
|
||||
}
|
||||
}
|
||||
'\r' => {}
|
||||
'\n' => {
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
current.align = props.align;
|
||||
current.indent_first = props.indent_first;
|
||||
current.indent_left = props.indent_left;
|
||||
current.indent_right = props.indent_right;
|
||||
current.tab_stops = props.tab_stops.clone();
|
||||
lines.push(std::mem::take(&mut current));
|
||||
}
|
||||
other => buf.push(other),
|
||||
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,
|
||||
}
|
||||
}
|
||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
||||
current.align = props.align;
|
||||
current.indent_first = props.indent_first;
|
||||
current.indent_left = props.indent_left;
|
||||
current.indent_right = props.indent_right;
|
||||
current.tab_stops = props.tab_stops.clone();
|
||||
lines.push(current);
|
||||
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;
|
||||
|
|
@ -751,10 +477,10 @@ pub fn parse_mtext_paragraphs_ex(
|
|||
// 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
|
||||
// stripping, paragraph splitting, per-run width measurement and word-wrap;
|
||||
// keep `parse_mtext_paragraphs`, `layout_mtext`, `mtext_line_count`,
|
||||
// `text_local_bounds`, and `resolve_dxf_special_chars` as the supported
|
||||
// surface for callers.
|
||||
// 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
|
||||
|
|
@ -774,7 +500,7 @@ pub fn mtext_line_count(
|
|||
let base_font_name = resolved.font_name.clone();
|
||||
let rect_w = (m.rectangle_width as f32) * anno_scale;
|
||||
|
||||
let paragraphs = parse_mtext_paragraphs(&m.value, entity_h);
|
||||
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();
|
||||
|
|
@ -847,7 +573,7 @@ pub fn mtext_line_count(
|
|||
// it carries inline codes).
|
||||
//
|
||||
// The pipeline mirrors the MTEXT renderer:
|
||||
// 1. Parse — via `parse_mtext_paragraphs`.
|
||||
// 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.
|
||||
|
|
@ -1182,7 +908,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
// ── 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 = parse_mtext_paragraphs_ex(opts.value, entity_h, !opts.want_glyph_boxes);
|
||||
let paragraphs = adapt_mtext_paragraphs(opts.value, entity_h, !opts.want_glyph_boxes);
|
||||
|
||||
// ── 2. Atomise + wrap each paragraph into sub-lines ──────────────────
|
||||
struct SubLine {
|
||||
|
|
@ -1917,3 +1643,83 @@ mod tests {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue