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]]
|
[[package]]
|
||||||
name = "acadrust"
|
name = "acadrust"
|
||||||
version = "0.4.0"
|
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 = [
|
dependencies = [
|
||||||
"ahash 0.8.12",
|
"ahash 0.8.12",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|
@ -3160,7 +3160,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d"
|
checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytecount",
|
"bytecount",
|
||||||
"memchr 1.0.2",
|
"memchr 2.8.2",
|
||||||
"nom 8.0.0",
|
"nom 8.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -329,412 +329,138 @@ impl MTextLine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
struct ParagraphProps {
|
/// Font-name stem: drop any path and extension, so a `\F` font path like
|
||||||
align: Option<ParagraphAlign>,
|
/// `C:\\fonts\\arial.ttf` and a plain `\f` name `arial` resolve to the same font.
|
||||||
indent_first: f32,
|
fn font_stem(name: &str) -> String {
|
||||||
indent_left: f32,
|
name.rsplit(['/', '\\'])
|
||||||
indent_right: f32,
|
.next()
|
||||||
tab_stops: Vec<TabStop>,
|
.unwrap_or(name)
|
||||||
|
.split('.')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(name)
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a `\p...;` body. Comma-separated tokens, each with a single-letter
|
/// Parse an MTEXT string into the layout's `Vec<MTextLine>`, using acadrust's
|
||||||
/// kind prefix. Unknown tokens are skipped — anything we don't understand is
|
/// structured `mtext_format::parse_mtext` — OCS keeps only the layout engine
|
||||||
/// silently dropped rather than poisoning the rest of the paragraph.
|
/// (`layout_mtext` and callers read `MTextLine`/`RunState`), not a second MTEXT
|
||||||
fn parse_paragraph_block(body: &str, props: &mut ParagraphProps) {
|
/// inline parser.
|
||||||
// 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.
|
|
||||||
///
|
///
|
||||||
/// `entity_height` is needed to translate absolute `\H<v>;` declarations into
|
/// Representation notes:
|
||||||
/// the height-multiplier representation carried in [`RunState`]; pass the
|
/// - DXF `%%d`/`%%p`/`%%c` arrive already resolved to Unicode from acadrust;
|
||||||
/// MTEXT entity's `height` field.
|
/// the stroke tokenizer treats those as ordinary glyphs.
|
||||||
pub fn parse_mtext_paragraphs(s: &str, entity_height: f32) -> Vec<MTextLine> {
|
/// - Stacking (`\S`) is flattened inline to `num<sep>den` (`^` for limit, else
|
||||||
parse_mtext_paragraphs_ex(s, entity_height, true)
|
/// `/`) 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`.
|
||||||
/// Like [`parse_mtext_paragraphs`] but with control over blank-edge trimming.
|
pub fn adapt_mtext_paragraphs(
|
||||||
///
|
|
||||||
/// `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(
|
|
||||||
s: &str,
|
s: &str,
|
||||||
entity_height: f32,
|
entity_height: f32,
|
||||||
trim_blank_edges: bool,
|
trim_blank_edges: bool,
|
||||||
) -> Vec<MTextLine> {
|
) -> Vec<MTextLine> {
|
||||||
let mut lines: Vec<MTextLine> = Vec::new();
|
use acadrust::entities::mtext_format::{
|
||||||
let mut current = MTextLine::default();
|
parse_mtext, MTextColor, MTextLineAlignment, MTextParagraphAlignment, MTextScalar,
|
||||||
let mut buf = String::new();
|
SpanProperties, StackingType,
|
||||||
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
|
|
||||||
};
|
};
|
||||||
|
|
||||||
while let Some(ch) = chars.next() {
|
let entity_height = entity_height.max(1e-6);
|
||||||
match ch {
|
let doc = parse_mtext(s, true);
|
||||||
'\\' => match chars.peek().copied() {
|
|
||||||
// ── Line / paragraph break ────────────────────────────────
|
fn map_align(a: MTextParagraphAlignment) -> Option<ParagraphAlign> {
|
||||||
Some('P') | Some('n') | Some('N') => {
|
match a {
|
||||||
chars.next();
|
MTextParagraphAlignment::Left => Some(ParagraphAlign::Left),
|
||||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
MTextParagraphAlignment::Right => Some(ParagraphAlign::Right),
|
||||||
current.align = props.align;
|
MTextParagraphAlignment::Center => Some(ParagraphAlign::Center),
|
||||||
current.indent_first = props.indent_first;
|
MTextParagraphAlignment::Justified => Some(ParagraphAlign::Justify),
|
||||||
current.indent_left = props.indent_left;
|
MTextParagraphAlignment::Distributed => Some(ParagraphAlign::Distribute),
|
||||||
current.indent_right = props.indent_right;
|
MTextParagraphAlignment::Default => None,
|
||||||
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),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flush_glyph_buf(&mut current, &mut buf, &state);
|
fn color_of(p: &SpanProperties) -> Option<InlineColor> {
|
||||||
current.align = props.align;
|
if let Some((r, g, b)) = p.color_rgb {
|
||||||
current.indent_first = props.indent_first;
|
return Some(InlineColor::True([
|
||||||
current.indent_left = props.indent_left;
|
r as f32 / 255.0,
|
||||||
current.indent_right = props.indent_right;
|
g as f32 / 255.0,
|
||||||
current.tab_stops = props.tab_stops.clone();
|
b as f32 / 255.0,
|
||||||
lines.push(current);
|
]));
|
||||||
|
}
|
||||||
|
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 {
|
if !trim_blank_edges {
|
||||||
return lines;
|
return lines;
|
||||||
|
|
@ -751,10 +477,10 @@ pub fn parse_mtext_paragraphs_ex(
|
||||||
// Legacy MText helpers (`strip_mtext_codes`, `split_mtext_lines`,
|
// Legacy MText helpers (`strip_mtext_codes`, `split_mtext_lines`,
|
||||||
// `measure_mtext_chars`, `word_wrap`) were removed when every text-bearing
|
// `measure_mtext_chars`, `word_wrap`) were removed when every text-bearing
|
||||||
// entity switched to the run-aware pipeline below. The pipeline now owns
|
// entity switched to the run-aware pipeline below. The pipeline now owns
|
||||||
// stripping, paragraph splitting, per-run width measurement and word-wrap;
|
// per-run width measurement and word-wrap; MTEXT inline parsing now comes from
|
||||||
// keep `parse_mtext_paragraphs`, `layout_mtext`, `mtext_line_count`,
|
// acadrust via `adapt_mtext_paragraphs`. The supported surface for callers is
|
||||||
// `text_local_bounds`, and `resolve_dxf_special_chars` as the supported
|
// `adapt_mtext_paragraphs`, `layout_mtext`, `mtext_line_count`,
|
||||||
// surface for callers.
|
// `text_local_bounds`, and `resolve_dxf_special_chars`.
|
||||||
|
|
||||||
/// Total number of visible lines an MText renders to — explicit `\P` /
|
/// Total number of visible lines an MText renders to — explicit `\P` /
|
||||||
/// `\n` / `\N` breaks plus word-wrap induced sublines when
|
/// `\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 base_font_name = resolved.font_name.clone();
|
||||||
let rect_w = (m.rectangle_width as f32) * anno_scale;
|
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;
|
let mut total = 0usize;
|
||||||
for para in ¶graphs {
|
for para in ¶graphs {
|
||||||
let mut atoms: Vec<LayoutAtom> = Vec::new();
|
let mut atoms: Vec<LayoutAtom> = Vec::new();
|
||||||
|
|
@ -847,7 +573,7 @@ pub fn mtext_line_count(
|
||||||
// it carries inline codes).
|
// it carries inline codes).
|
||||||
//
|
//
|
||||||
// The pipeline mirrors the MTEXT renderer:
|
// 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
|
// 2. Atomise — turn each MTextLine.runs into a flat sequence of atoms
|
||||||
// (Word / Space / Tab) so the wrapper operates at break boundaries
|
// (Word / Space / Tab) so the wrapper operates at break boundaries
|
||||||
// while keeping per-character formatting state.
|
// while keeping per-character formatting state.
|
||||||
|
|
@ -1182,7 +908,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
||||||
// ── 1. Parse ─────────────────────────────────────────────────────────
|
// ── 1. Parse ─────────────────────────────────────────────────────────
|
||||||
// The editor (want_glyph_boxes) keeps blank edges so a freshly typed
|
// The editor (want_glyph_boxes) keeps blank edges so a freshly typed
|
||||||
// trailing newline yields an empty paragraph the caret can sit on.
|
// 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 ──────────────────
|
// ── 2. Atomise + wrap each paragraph into sub-lines ──────────────────
|
||||||
struct SubLine {
|
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