MTEXT: paragraph indents and tab stops are in drawing units (#895)

* MTEXT: paragraph indents and tab stops are in drawing units

AutoCAD writes \pi, \pl, \pr and tab stops in drawing units, not multiples of the text
height. A file with \pi27.485 on 6-high text in a 120-wide box laid out with a 165-unit first
line indent: the first line vanished and wrapped words landed elsewhere. The JSON mtext
parameters indent, firstIndent, rightIndent and tabs follow (a list's default indent is now
3 text heights, in drawing units).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* MTEXT editor: list indent of three text heights in drawing units, matching the new \p units

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: simanov <[email protected]>
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 05:13:16 -04:00
1 parent d0438577ed
commit 678ff555ae
6 files changed
+34 -22

No files matched your search

+1 -1
View File
@@ -378,7 +378,7 @@ fn run_mtext(s: &mut Session, p: &Value) -> Result<Value> {
attach,
rotation: f64_or(p, "rotation", 0.0).to_radians(),
style,
contents: super::mtext_opts::json_paragraphs(p, text.replace('\n', "\\P"))?,
contents: super::mtext_opts::json_paragraphs(p, text.replace('\n', "\\P"), height)?,
line_spacing: f64_or(p, "lineSpacing", 1.0),
line_spacing_exact: bool_or(p, "lineSpacingExact", false),
columns: super::mtext_opts::json_columns(p, height, width)?,
+3 -3
View File
@@ -364,9 +364,9 @@ fn trim(v: f64) -> String {
/// The JSON form's paragraph formatting: `list` (`bullet`, `number`, `lower`, `upper`: each
/// paragraph labelled, with a hanging `indent`), or `indent` / `firstIndent` / `rightIndent`
/// (text heights), `tabs` (tab stops in text heights) and `align` (`left`, `center`, `right`,
/// (drawing units; a list's default is 3 text heights), `tabs` (tab stops, drawing units) and `align` (`left`, `center`, `right`,
/// `justify`, `distributed`) as a `\p` code in front of the text.
pub(super) fn json_paragraphs(p: &serde_json::Value, text: String) -> Result<String> {
pub(super) fn json_paragraphs(p: &serde_json::Value, text: String, height: f64) -> Result<String> {
use cadcraft_fonts::{ListStyle, ParaAlign, ParaProps, TabKind};
let bad = |m: &str| super::bad("mtext", m);
let num = |k: &str| -> Result<Option<f64>> {
@@ -384,7 +384,7 @@ pub(super) fn json_paragraphs(p: &serde_json::Value, text: String) -> Result<Str
_ => return Err(bad("`list` must be bullet, number, lower or upper")),
};
let paras: Vec<&str> = text.split("\\P").collect();
return Ok(cadcraft_fonts::list_contents(&paras, style, num("indent")?.unwrap_or(3.0)));
return Ok(cadcraft_fonts::list_contents(&paras, style, num("indent")?.unwrap_or(3.0 * height)));
}
let mut props = ParaProps {
left: num("indent")?.unwrap_or(0.0).max(0.0),
+1 -1
View File
@@ -46,7 +46,7 @@ fn dynamic_columns_and_defaults() {
#[test]
fn json_columns_and_lists() {
let mut s = Session::new();
s.execute("mtext", &json!({"at": [0, 0], "text": "one\ntwo\nthree", "width": 5, "list": "number", "columns": {"type": "static", "count": 2, "gutter": 1, "height": 2}}))
s.execute("mtext", &json!({"at": [0, 0], "text": "one\ntwo\nthree", "height": 1, "width": 5, "list": "number", "columns": {"type": "static", "count": 2, "gutter": 1, "height": 2}}))
.unwrap();
let t = last_mtext(&s);
assert_eq!(t.contents, "\\pxi-3,l3,t3;1.\tone\\P2.\ttwo\\P3.\tthree");
+8
View File
@@ -713,6 +713,14 @@ mod tests {
layout_mtext(s, Vec2::ZERO, 1.0, width, 1, 0.0, 1.0)
}
#[test]
fn indents_are_drawing_units() {
// An AutoCAD file's `\pi4.8634;` on 4.6652 high text in a 50.2 wide box indents the
// first line by 4.8634 units: the text still fits on one line.
let l = layout_mtext("\\pi4.8634;AAA BBB CCC", Vec2::ZERO, 4.6652, 50.2, 1, 0.0, 1.0);
assert_eq!(l.lines, vec!["AAA BBB CCC"]);
}
#[test]
fn paragraphs_make_lines() {
let l = lay("one\\Ptwo\\P\\Pfour", 0.0);
+12 -11
View File
@@ -56,8 +56,9 @@ pub enum ParaAlign {
Distributed,
}
/// Paragraph properties set by `\p…;`, in multiples of the MTEXT's text height. They stay in
/// effect for the following paragraphs until another `\p` changes them.
/// Paragraph properties set by `\p…;`. Indents and tab stops are in drawing units (as in
/// AutoCAD files: `\pi4.86;` indents the first line by 4.86 units), space before and after in
/// text heights. They stay in effect for the following paragraphs until another `\p` changes them.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ParaProps {
/// `i`: first-line indent, relative to the left indent (negative: a hanging indent).
@@ -84,7 +85,7 @@ const MAX_STATES: usize = 10_000;
const MAX_COLUMNS: usize = 1_000;
/// Default tab interval, in text heights, past the last tab stop.
pub const DEFAULT_TAB: f64 = 4.0;
/// Largest indent, tab position or spacing accepted, in text heights.
/// Largest indent, tab position or spacing accepted.
const MAX_UNITS: f64 = 10_000.0;
fn units(s: &str) -> Option<f64> {
@@ -240,7 +241,7 @@ impl ListStyle {
}
}
/// Paragraphs as a list: a hanging indent of `indent` text heights with a tab stop there, and
/// Paragraphs as a list: a hanging indent of `indent` drawing units with a tab stop there, and
/// each paragraph its label and a tab (`\pxi-3,l3,t3;1.<tab>First\P2.<tab>Second`).
pub fn list_contents(paragraphs: &[&str], style: ListStyle, indent: f64) -> String {
let indent = if indent.is_finite() && indent > 0.0 { indent.min(MAX_UNITS) } else { 3.0 };
@@ -405,8 +406,8 @@ fn split_tabs(segs: &[(Seg, usize)]) -> Vec<Part> {
/// then every [`DEFAULT_TAB`] text heights.
fn next_tab(x: f64, pp: &ParaProps, h: f64) -> (f64, TabKind) {
let eps = 1e-9;
let mut best: Option<(f64, TabKind)> = pp.tabs.iter().map(|(p, k)| (p * h, *k)).find(|(p, _)| *p > x + eps);
let hang = pp.left * h;
let mut best: Option<(f64, TabKind)> = pp.tabs.iter().map(|(p, k)| (*p, *k)).find(|(p, _)| *p > x + eps);
let hang = pp.left;
if hang > x + eps && best.is_none_or(|(p, _)| hang < p) {
best = Some((hang, TabKind::Left));
}
@@ -432,7 +433,7 @@ pub(super) fn layout(contents: &str, p: &MTextParams, base: &Fmt) -> MTextLayout
let mut k = 0usize;
let first_start = |k: usize| {
let pp = paras.props(k);
((pp.left + pp.first) * h).max(0.0)
(pp.left + pp.first).max(0.0)
};
let mut line = PLine { width: first_start(0), ..Default::default() };
let mut pending_space: Option<usize> = None;
@@ -448,8 +449,8 @@ pub(super) fn layout(contents: &str, p: &MTextParams, base: &Fmt) -> MTextLayout
};
for a in &atoms {
let pp = paras.props(k);
let avail = if width > 0.0 { (width - pp.right * h).max(0.0) } else { f64::INFINITY };
let left = (pp.left * h).max(0.0);
let avail = if width > 0.0 { (width - pp.right).max(0.0) } else { f64::INFINITY };
let left = pp.left.max(0.0);
match a {
Atom::Space(f) => {
if !line.items.is_empty() || pending_space.is_some() {
@@ -567,7 +568,7 @@ pub(super) fn layout(contents: &str, p: &MTextParams, base: &Fmt) -> MTextLayout
Some(_) => (0..n_cols).map(col_height).fold(content_h, f64::max),
None => content_h,
};
let col_w = if width > 0.0 { width } else { lines.iter().map(|l| l.width + paras.props(l.para).right * h).fold(0.0, f64::max) };
let col_w = if width > 0.0 { width } else { lines.iter().map(|l| l.width + paras.props(l.para).right).fold(0.0, f64::max) };
let gutter = cols.map(|s| s.gutter).filter(|g| g.is_finite() && *g >= 0.0).unwrap_or(0.0);
let total_w = col_w * n_cols as f64 + gutter * n_cols.saturating_sub(1) as f64;
let attach = p.attach.clamp(1, 9) - 1;
@@ -590,7 +591,7 @@ pub(super) fn layout(contents: &str, p: &MTextParams, base: &Fmt) -> MTextLayout
let pp = paras.props(l.para);
let slot = if reversed { n_cols.saturating_sub(1).saturating_sub(c) } else { c };
let col_x = box_x0 + slot as f64 * (col_w + gutter);
let right_edge = col_w - pp.right * h;
let right_edge = col_w - pp.right;
let free = right_edge - l.width;
let align = match pp.align {
ParaAlign::Default => [ParaAlign::Left, ParaAlign::Center, ParaAlign::Right].get(usize::from(acol)).copied().unwrap_or_default(),
+9 -6
View File
@@ -403,12 +403,15 @@ fn insert_block(app: &mut CadApp, name: &str) {
}
}
/// The editor's lines as a list: a hanging indent of three text heights with a tab stop there
/// (`\pxi-3,l3,t3;`) and each line a bullet or its number and a tab.
fn as_list(buf: &str, numbered: bool) -> String {
/// The editor's lines as a list: a hanging indent of three text heights (`height` is one, in
/// drawing units, as `\p` indents are) with a tab stop there, and each line a bullet or its
/// number and a tab.
fn as_list(buf: &str, numbered: bool, height: f64) -> String {
let lines: Vec<String> =
buf.split('\n').enumerate().map(|(i, l)| if numbered { format!("{}.\t{l}", i + 1) } else { format!("•\t{l}") }).collect();
format!("\\pxi-3,l3,t3;{}", lines.join("\n"))
let indent = format!("{:.6}", 3.0 * height);
let indent = indent.trim_end_matches('0').trim_end_matches('.');
format!("\\pxi-{indent},l{indent},t{indent};{}", lines.join("\n"))
}
/// The multiline text editor shown while MTEXT asks for its contents.
@@ -455,10 +458,10 @@ fn mtext_editor(app: &mut CadApp, ctx: &egui::Context) {
buf.push('\t');
}
if ui.button("•≡").on_hover_text(crate::tl!("Bulleted list")).clicked() {
buf = as_list(&buf, false);
buf = as_list(&buf, false, h);
}
if ui.button("1≡").on_hover_text(crate::tl!("Numbered list")).clicked() {
buf = as_list(&buf, true);
buf = as_list(&buf, true, h);
}
});
let r = ui.add(