From 8005201db92965bca1529511ebfb304d2e67c03b Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 18:47:12 +0200 Subject: [PATCH] feat(text): MTEXT paragraph formatting (indents, tabs, lists) and columns (#547) Squash of PR #547 by @vangel76. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/entity.rs | 58 ++ crates/doc/src/extents.rs | 9 +- crates/doc/src/tests.rs | 1 + crates/engine/src/cmd/annotate.rs | 2 + crates/engine/src/cmd/draw.rs | 12 +- crates/engine/src/cmd/modify.rs | 1 + crates/engine/src/cmd/mtext_opts.rs | 255 +++++++- crates/engine/src/sample.rs | 2 +- crates/engine/tests/mtext_columns.rs | 81 +++ crates/engine/tests/mtext_options.rs | 5 +- crates/fonts/src/lib.rs | 1 + crates/fonts/src/mtext.rs | 8 + crates/fonts/src/mtext/para.rs | 679 +++++++++++++++++++++ crates/fonts/src/stroke.rs | 2 + crates/fonts/tests/mtext_paragraphs.rs | 137 +++++ crates/io/src/dxf_mleader.rs | 1 + crates/io/src/dxf_read.rs | 12 +- crates/io/src/dxf_read/mtext_dxf.rs | 120 ++++ crates/io/src/dxf_write.rs | 5 +- crates/io/src/tests.rs | 3 + crates/io/tests/mleader_roundtrip.rs | 1 + crates/io/tests/mtext_columns_roundtrip.rs | 93 +++ crates/io/tests/mtext_line_spacing.rs | 1 + crates/render/src/lib.rs | 8 + crates/render/src/tests.rs | 1 + crates/ui-egui/src/dialogs.rs | 18 + crates/ui-egui/src/i18n/uk.tsv | 3 + 27 files changed, 1495 insertions(+), 24 deletions(-) create mode 100644 crates/engine/tests/mtext_columns.rs create mode 100644 crates/fonts/src/mtext/para.rs create mode 100644 crates/fonts/tests/mtext_paragraphs.rs create mode 100644 crates/io/src/dxf_read/mtext_dxf.rs create mode 100644 crates/io/tests/mtext_columns_roundtrip.rs diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index 18e36cef..db1c28e2 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -270,10 +270,65 @@ pub struct MText { /// heights; At least (`false`, the default) lets taller characters push lines apart. #[serde(default)] pub line_spacing_exact: bool, + /// Columns the text flows through, each `width` wide (DXF 75/76/78/79/48/49/50); `None`: + /// one column. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub columns: Option, } fn attach_tl() -> u8 { 1 } + +/// Static columns: a fixed number of columns of one height. Dynamic columns: as many columns +/// as the text needs, of one height (auto height) or each its own (manual heights). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum MTextColumnType { + #[default] + Static, + Dynamic, +} + +/// MTEXT columns (MTEXT Columns option, DXF Reference MTEXT groups 75–79, 48–50). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MTextColumns { + #[serde(rename = "type")] + pub kind: MTextColumnType, + /// Number of columns (static columns: the columns there are). + pub count: u16, + /// Space between two columns. + pub gutter: f64, + /// Height of every column (static, and dynamic with auto height). + pub height: f64, + /// Dynamic columns: one height for all columns (`height`) instead of `heights`. + pub auto_height: bool, + /// Dynamic columns with manual heights: the height of each column. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub heights: Vec, + /// Columns run right to left. + pub flow_reversed: bool, +} + +impl MTextColumns { + /// The width of `count` columns of `column_width` and their gutters. + pub fn total_width(&self, column_width: f64) -> f64 { + let n = f64::from(self.count.max(1)); + column_width * n + self.gutter.max(0.0) * (n - 1.0) + } + + /// The tallest column. + pub fn max_height(&self) -> f64 { + self.heights.iter().copied().fold(self.height, f64::max).max(0.0) + } + + /// Scale every length (SCALE, block insertion). + pub fn scale(&mut self, s: f64) { + self.gutter *= s; + self.height *= s; + self.heights.iter_mut().for_each(|h| *h *= s); + } +} #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Attrib { @@ -1090,6 +1145,9 @@ impl EntityKind { t3(&mut t.insert); t.height *= s; t.width *= s; + if let Some(c) = &mut t.columns { + c.scale(s); + } t.rotation = cadcraft_geom::norm_angle(t.rotation + rot); } EntityKind::Insert(i) => { diff --git a/crates/doc/src/extents.rs b/crates/doc/src/extents.rs index 9be191af..9d3a785d 100644 --- a/crates/doc/src/extents.rs +++ b/crates/doc/src/extents.rs @@ -73,8 +73,13 @@ fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap { let lines = t.contents.split("\\P").count().max(1); let longest = t.contents.split("\\P").map(|l| l.chars().count()).max().unwrap_or(1); - let w = if t.width > 0.0 { t.width } else { t.height * 0.8 * longest as f64 }; - let h = t.height * 1.66 * lines as f64; + let mut w = if t.width > 0.0 { t.width } else { t.height * 0.8 * longest as f64 }; + let mut h = t.height * 1.66 * lines as f64; + if let Some(c) = t.columns.as_ref().filter(|_| t.width > 0.0) { + // Columns side by side, the text flowing down each in turn. + w = c.total_width(t.width); + h = if c.max_height() > 0.0 { c.max_height() } else { h }; + } let (dx, dy) = match t.attach { 2 | 5 | 8 => (-w / 2.0, 0.0), 3 | 6 | 9 => (-w, 0.0), diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 504a0fad..867b051e 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -386,6 +386,7 @@ fn mtext_kind(contents: &str) -> EntityKind { contents: contents.into(), line_spacing: 1.0, line_spacing_exact: false, + columns: None, }) } diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index 2ad478c7..b00414e2 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -448,6 +448,7 @@ fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option< contents: text.replace('\n', "\\P"), line_spacing: 1.0, line_spacing_exact: false, + columns: None, }), style: st.name.clone(), arrow_size: st.arrow_size * k, @@ -494,6 +495,7 @@ fn run_leader(s: &mut Session, p: &Value) -> Result { contents: t.into(), line_spacing: 1.0, line_spacing_exact: false, + columns: None, }))? .hex(), ); diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 7fa14f86..6849a639 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -84,7 +84,11 @@ pub fn specs() -> Vec { CommandSpec::new("mtext", "Multiline Text", run_mtext) .menu(&["Draw", "Text", "Multiline Text..."]) .alias(&["t", "mt"]) - .params("{at, text, height?, width?, attach?: 1..9 | justify?: TL..BR, rotation?, style?, lineSpacing?, lineSpacingExact?}") + .params( + "{at, text, height?, width?, attach?: 1..9 | justify?: TL..BR, rotation?, style?, lineSpacing?, lineSpacingExact?, \ + list?: bullet|number|lower|upper, indent?, firstIndent?, rightIndent?, tabs?: [..], align?: left|center|right|justify|distributed, \ + columns?: {type: static|dynamic, count?, gutter?, height, heights?, reversed?}} (indents and tabs in text heights; width = one column)", + ) .interactive(|_| Ok(Box::new(MTextM::default()))), ] } @@ -363,16 +367,18 @@ fn run_mtext(s: &mut Session, p: &Value) -> Result { Some(j) => super::mtext_opts::attach_code(j).ok_or_else(|| bad("mtext", "`justify` must be TL, TC, TR, ML, MC, MR, BL, BC or BR"))?, None => p.get("attach").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9) as u8, }; + let width = f64_or(p, "width", 0.0).max(0.0); let k = EntityKind::MText(MText { insert: v3(at), height, - width: f64_or(p, "width", 0.0).max(0.0), + width, attach, rotation: f64_or(p, "rotation", 0.0).to_radians(), style, - contents: text.replace('\n', "\\P"), + contents: super::mtext_opts::json_paragraphs(p, text.replace('\n', "\\P"))?, 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)?, }); added(s.add_entity(k)?) } diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index a64c7873..302b0240 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -1274,6 +1274,7 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option, + /// Columns set by the Columns option (static or dynamic); their answers replace the + /// opposite corner too. + pub columns: Option, + /// Static columns: the total width answered, split into columns once the gutter is known. + pub total_width: f64, } impl Default for MTextOpts { fn default() -> Self { - MTextOpts { height: 0.2, attach: 1, spacing: 1.0, exact: false, rotation: 0.0, style: "Standard".into(), width: None } + MTextOpts { + height: 0.2, + attach: 1, + spacing: 1.0, + exact: false, + rotation: 0.0, + style: "Standard".into(), + width: None, + columns: None, + total_width: 0.0, + } } } @@ -43,7 +58,7 @@ impl MTextOpts { pub(super) fn new(s: &Session) -> Self { let (height, style) = s.doc().map(|d| (d.header.f64("TEXTSIZE", 0.2), d.header.str("TEXTSTYLE", "Standard"))).unwrap_or((0.2, "Standard".into())); - MTextOpts { height, attach: 1, spacing: 1.0, exact: false, rotation: 0.0, style, width: None } + MTextOpts { height, style, ..MTextOpts::default() } } /// The text box from the first corner and either the opposite corner or the Width answer: @@ -73,6 +88,7 @@ impl MTextOpts { contents, line_spacing: self.spacing, line_spacing_exact: self.exact, + columns: self.columns.clone().filter(|_| width > 0.0), } } } @@ -88,6 +104,11 @@ pub(super) enum MTextAsk { Style, Width, Columns, + /// Static columns: the total width; dynamic: the width of one column. + ColumnWidth, + ColumnCount, + ColumnGutter, + ColumnHeight, } pub(super) enum Outcome { @@ -126,7 +147,32 @@ impl MTextAsk { MTextAsk::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default(trim(o.rotation.to_degrees())).base(first), MTextAsk::Style => Prompt::new("Enter style name", curves::KW).kw(&["?"]).default(o.style.clone()), MTextAsk::Width => Prompt::new("Specify width", Accept::POINT_OR_NUMBER).base(first), - MTextAsk::Columns => Prompt::new("Enter column type", curves::KW).kw(&["Dynamic", "Static", "No"]).default("No"), + MTextAsk::Columns => Prompt::new("Enter column type", curves::KW).kw(&["Dynamic", "Static", "No"]).default(match &o.columns { + Some(c) if c.kind == MTextColumnType::Dynamic => "Dynamic", + Some(_) => "Static", + None => "No", + }), + MTextAsk::ColumnWidth => { + let stat = o.columns.as_ref().is_some_and(|c| c.kind == MTextColumnType::Static); + let p = Prompt::new(if stat { "Specify total width" } else { "Specify column width" }, Accept::POINT_OR_NUMBER).base(first); + match o.width { + Some(w) if w > 0.0 => p.default(format!("{:.4}", if stat { o.total_width.max(w) } else { w })), + _ => p, + } + } + MTextAsk::ColumnCount => { + Prompt::new("Specify number of columns", Accept::NUMBER).default(o.columns.as_ref().map_or(2, |c| c.count.max(2)).to_string()) + } + MTextAsk::ColumnGutter => Prompt::new("Specify gutter width", Accept::POINT_OR_NUMBER) + .default(format!("{:.4}", o.columns.as_ref().map_or(o.height, |c| c.gutter))) + .base(first), + MTextAsk::ColumnHeight => { + let p = Prompt::new("Specify column height", Accept::POINT_OR_NUMBER).base(first); + match o.columns.as_ref().map(|c| c.height).filter(|h| *h > 0.0) { + Some(h) => p.default(format!("{h:.4}")), + None => p, + } + } } } @@ -135,6 +181,9 @@ impl MTextAsk { Input::Keyword(k) | Input::Text(k) => Some(k.trim().to_string()), _ => None, }; + if self.is_column_question() { + return column_input(self, i, o, first); + } match (self, &i) { (_, Input::Enter) => Ok(if self == MTextAsk::SpacingType { Outcome::Ask(MTextAsk::SpacingFactor) } else { Outcome::Back }), (MTextAsk::Height, Input::Point(p)) => set_height(s, o, first.dist(*p)), @@ -193,19 +242,111 @@ impl MTextAsk { o.width = Some(number(t).filter(|w| w.is_finite() && *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?); Ok(Outcome::Text) } - (MTextAsk::Columns, _) => match word(&i).unwrap_or_default().to_ascii_lowercase().as_str() { - "no" | "n" => Ok(Outcome::Back), - "dynamic" | "d" | "static" | "s" => { - s.echo("Text columns are not available yet; the text stays in one column."); - Ok(Outcome::Back) - } - _ => Err(other("Invalid option keyword.")), - }, _ => Ok(Outcome::Ask(self)), } } } +impl MTextAsk { + fn is_column_question(self) -> bool { + matches!(self, MTextAsk::Columns | MTextAsk::ColumnWidth | MTextAsk::ColumnCount | MTextAsk::ColumnGutter | MTextAsk::ColumnHeight) + } +} + +/// The Columns option: the column type, then (static) total width, number of columns, gutter +/// and column height, or (dynamic) column width, gutter and column height. The answers replace +/// the opposite corner: the text comes next. +fn column_input(q: MTextAsk, i: Input, o: &mut MTextOpts, first: Vec2) -> Result { + let local = |p: Vec2| (p - first).rotate(-o.rotation); + // A distance: typed, picked (along the text or down it from the first corner) or the default. + let length = |i: &Input, current: Option, down: bool| -> Result { + let v = match i { + Input::Point(p) => Some(if down { local(*p).y.abs() } else { local(*p).x.abs() }), + Input::Text(t) => number(t), + Input::Enter => current, + _ => None, + }; + v.filter(|v| v.is_finite() && *v > 0.0).ok_or_else(|| other("Requires a positive value.")) + }; + let kind = o.columns.as_ref().map_or(MTextColumnType::Static, |c| c.kind); + match q { + MTextAsk::Columns => { + let k = match &i { + Input::Keyword(k) | Input::Text(k) => k.trim().to_ascii_lowercase(), + Input::Enter => return Ok(Outcome::Back), + _ => String::new(), + }; + let kind = match k.as_str() { + "no" | "n" => { + o.columns = None; + return Ok(Outcome::Back); + } + "dynamic" | "d" => MTextColumnType::Dynamic, + "static" | "s" => MTextColumnType::Static, + _ => return Err(other("Invalid option keyword.")), + }; + let prev = o.columns.take().unwrap_or(MTextColumns { count: 2, gutter: o.height, ..MTextColumns::default() }); + o.columns = Some(MTextColumns { kind, auto_height: kind == MTextColumnType::Dynamic, heights: Vec::new(), ..prev }); + Ok(Outcome::Ask(MTextAsk::ColumnWidth)) + } + MTextAsk::ColumnWidth => { + let current = o.width.map(|w| if kind == MTextColumnType::Static { o.total_width.max(w) } else { w }); + let w = length(&i, current, false)?; + if kind == MTextColumnType::Static { + o.total_width = w; + Ok(Outcome::Ask(MTextAsk::ColumnCount)) + } else { + o.width = Some(w); + Ok(Outcome::Ask(MTextAsk::ColumnGutter)) + } + } + MTextAsk::ColumnCount => { + let current = o.columns.as_ref().map_or(2, |c| c.count.max(2)); + let n = match &i { + Input::Text(t) => number(t).filter(|n| n.fract() == 0.0 && (1.0..=100.0).contains(n)).map(|n| n as u16), + Input::Enter => Some(current), + _ => None, + } + .ok_or_else(|| other("Requires a whole number of columns from 1 to 100."))?; + if let Some(c) = &mut o.columns { + c.count = n; + } + Ok(Outcome::Ask(MTextAsk::ColumnGutter)) + } + MTextAsk::ColumnGutter => { + let current = o.columns.as_ref().map(|c| c.gutter); + let g = match &i { + Input::Text(t) => number(t).filter(|g| g.is_finite() && *g >= 0.0), + Input::Point(p) => Some(local(*p).x.abs()), + Input::Enter => current, + _ => None, + } + .ok_or_else(|| other("Requires a gutter width of 0 or more."))?; + if kind == MTextColumnType::Static { + let n = f64::from(o.columns.as_ref().map_or(1, |c| c.count.max(1))); + let w = (o.total_width - g * (n - 1.0)) / n; + if !(w.is_finite() && w > 0.0) { + return Err(other("The columns and gutters don't fit in the total width.")); + } + o.width = Some(w); + } + if let Some(c) = &mut o.columns { + c.gutter = g; + } + Ok(Outcome::Ask(MTextAsk::ColumnHeight)) + } + MTextAsk::ColumnHeight => { + let current = o.columns.as_ref().map(|c| c.height).filter(|h| *h > 0.0); + let h = length(&i, current, true)?; + if let Some(c) = &mut o.columns { + c.height = h; + } + Ok(Outcome::Text) + } + _ => Ok(Outcome::Back), + } +} + fn set_height(s: &mut Session, o: &mut MTextOpts, h: f64) -> Result { if !h.is_finite() || h <= 0.0 { return Err(other("Requires a positive height.")); @@ -220,3 +361,93 @@ fn trim(v: f64) -> String { let s = format!("{v:.4}"); s.trim_end_matches('0').trim_end_matches('.').to_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`, +/// `justify`, `distributed`) as a `\p` code in front of the text. +pub(super) fn json_paragraphs(p: &serde_json::Value, text: String) -> Result { + use cadcraft_fonts::{ListStyle, ParaAlign, ParaProps, TabKind}; + let bad = |m: &str| super::bad("mtext", m); + let num = |k: &str| -> Result> { + match p.get(k) { + None => Ok(None), + Some(v) => v.as_f64().filter(|v| v.is_finite() && v.abs() <= 1000.0).map(Some).ok_or_else(|| bad(&format!("`{k}` must be a number"))), + } + }; + if let Some(l) = p.get("list") { + let style = match l.as_str().unwrap_or("").to_ascii_lowercase().as_str() { + "bullet" | "bullets" => ListStyle::Bullet, + "number" | "numbers" | "numbered" => ListStyle::Number, + "lower" | "letter" | "letters" => ListStyle::LowerLetter, + "upper" => ListStyle::UpperLetter, + _ => 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(¶s, style, num("indent")?.unwrap_or(3.0))); + } + let mut props = ParaProps { + left: num("indent")?.unwrap_or(0.0).max(0.0), + first: num("firstIndent")?.unwrap_or(0.0), + right: num("rightIndent")?.unwrap_or(0.0).max(0.0), + ..ParaProps::default() + }; + if let Some(a) = p.get("align") { + props.align = match a.as_str().unwrap_or("").to_ascii_lowercase().as_str() { + "left" | "l" => ParaAlign::Left, + "center" | "centre" | "c" => ParaAlign::Center, + "right" | "r" => ParaAlign::Right, + "justify" | "j" => ParaAlign::Justify, + "distributed" | "d" => ParaAlign::Distributed, + _ => return Err(bad("`align` must be left, center, right, justify or distributed")), + }; + } + if let Some(t) = p.get("tabs") { + let stops = t.as_array().ok_or_else(|| bad("`tabs` must be an array of numbers"))?; + for v in stops.iter().take(64) { + let v = v.as_f64().filter(|v| v.is_finite() && (0.0..=1000.0).contains(v)).ok_or_else(|| bad("`tabs` must be an array of numbers"))?; + props.tabs.push((v, TabKind::Left)); + } + props.tabs.sort_by(|a, b| a.0.total_cmp(&b.0)); + } + if props == ParaProps::default() { + return Ok(text); + } + Ok(format!("{}{text}", props.code())) +} + +/// The JSON form's `columns`: `{type: static|dynamic, count?, gutter?, height, heights?, +/// reversed?}`; the MTEXT's `width` is the width of one column. +pub(super) fn json_columns(p: &serde_json::Value, text_height: f64, width: f64) -> Result> { + let Some(c) = p.get("columns").filter(|c| !c.is_null()) else { return Ok(None) }; + let bad = |m: &str| super::bad("mtext", m); + let kind = match c.get("type").and_then(serde_json::Value::as_str).unwrap_or("static").to_ascii_lowercase().as_str() { + "static" | "s" => MTextColumnType::Static, + "dynamic" | "d" => MTextColumnType::Dynamic, + "no" | "none" => return Ok(None), + _ => return Err(bad("`columns.type` must be static or dynamic")), + }; + if width <= 0.0 { + return Err(bad("columns need a `width` (the width of one column)")); + } + let len = |k: &str| c.get(k).and_then(serde_json::Value::as_f64).filter(|v| v.is_finite() && *v >= 0.0); + let heights: Vec = c + .get("heights") + .and_then(serde_json::Value::as_array) + .map(|a| a.iter().take(1000).filter_map(serde_json::Value::as_f64).filter(|v| v.is_finite() && *v > 0.0).collect()) + .unwrap_or_default(); + let height = len("height").or_else(|| heights.iter().copied().reduce(f64::max)).unwrap_or(0.0); + if height <= 0.0 { + return Err(bad("columns need a positive `height`")); + } + let count = c.get("count").and_then(serde_json::Value::as_u64).unwrap_or(if kind == MTextColumnType::Static { 2 } else { 1 }).clamp(1, 100); + Ok(Some(MTextColumns { + kind, + count: u16::try_from(count).unwrap_or(2), + gutter: len("gutter").unwrap_or(text_height), + height, + auto_height: kind == MTextColumnType::Dynamic && heights.is_empty(), + heights: if kind == MTextColumnType::Dynamic { heights } else { Vec::new() }, + flow_reversed: c.get("reversed").and_then(serde_json::Value::as_bool).unwrap_or(false), + })) +} diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 1bd83e0d..85a222f6 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -211,7 +211,7 @@ pub fn bracket() -> Drawing { add(on("Notes"), text(Vec2::new(4.1, -0.3), 0.16, "FRONT VIEW")); add( on("Notes"), - EntityKind::MText(MText { insert: v3(Vec2::new(14.2, 6.2)), height: 0.12, width: 3.4, attach: 1, rotation: 0.0, style: "Standard".into(), contents: "NOTES:\\P1. MATERIAL: 6061-T6 ALUMINUM.\\P2. BREAK ALL SHARP EDGES 0.02 MAX.\\P3. BORE %%c1.750 THRU, H7.\\P4. FINISH: CLEAR ANODIZE.".into(), line_spacing: 1.0, line_spacing_exact: false }), + EntityKind::MText(MText { insert: v3(Vec2::new(14.2, 6.2)), height: 0.12, width: 3.4, attach: 1, rotation: 0.0, style: "Standard".into(), contents: "NOTES:\\P1. MATERIAL: 6061-T6 ALUMINUM.\\P2. BREAK ALL SHARP EDGES 0.02 MAX.\\P3. BORE %%c1.750 THRU, H7.\\P4. FINISH: CLEAR ANODIZE.".into(), line_spacing: 1.0, line_spacing_exact: false, columns: None }), ); let tb0 = Vec2::new(14.0, -0.5); add(on("Title"), lwpoly(rect_vertices(tb0, tb0 + Vec2::new(4.0, 2.0)), true)); diff --git a/crates/engine/tests/mtext_columns.rs b/crates/engine/tests/mtext_columns.rs new file mode 100644 index 00000000..84fde9a4 --- /dev/null +++ b/crates/engine/tests/mtext_columns.rs @@ -0,0 +1,81 @@ +//! MTEXT columns and paragraph formatting from the command line, scripts and the JSON form +//! (#532). + +use cadcraft_doc::{EntityKind, MText, MTextColumnType}; +use cadcraft_engine::Session; +use serde_json::json; + +fn last_mtext(s: &Session) -> MText { + match s.doc().unwrap().model.iter().last().map(|e| e.kind.clone()) { + Some(EntityKind::MText(t)) => t, + k => panic!("no mtext: {k:?}"), + } +} + +#[test] +fn static_columns_from_the_command_line() { + let mut s = Session::new(); + // Total width 21, three columns, gutter 1.5: each column (21 - 2 × 1.5) / 3 = 6 wide. + s.script("MTEXT 0,10 h 0.5 c s 21 3 1.5 4\nfirst column\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let t = last_mtext(&s); + assert!((t.width - 6.0).abs() < 1e-9, "{}", t.width); + let c = t.columns.expect("columns"); + assert_eq!((c.kind, c.count), (MTextColumnType::Static, 3)); + assert_eq!((c.gutter, c.height), (1.5, 4.0)); + // Gutters wider than the total width are refused and asked again. + s.cmdline("mtext 0,0 c s 5 3 3").unwrap(); + assert!(s.log.iter().any(|l| l.contains("don't fit in the total width")), "{:?}", s.log); + s.cmdline("1 2").unwrap(); + assert!(s.prompt_text().contains("Enter text"), "{}", s.prompt_text()); + s.cancel(); +} + +#[test] +fn dynamic_columns_and_defaults() { + let mut s = Session::new(); + s.script("MTEXT 0,10 c d 8 0.5 3\nflowing\n\n").unwrap(); + let t = last_mtext(&s); + assert_eq!(t.width, 8.0); + let c = t.columns.expect("columns"); + assert_eq!(c.kind, MTextColumnType::Dynamic); + assert!(c.auto_height); + assert_eq!((c.gutter, c.height), (0.5, 3.0)); +} + +#[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}})) + .unwrap(); + let t = last_mtext(&s); + assert_eq!(t.contents, "\\pxi-3,l3,t3;1.\tone\\P2.\ttwo\\P3.\tthree"); + assert_eq!(t.columns.as_ref().map(|c| c.count), Some(2)); + s.execute("mtext", &json!({"at": [0, 0], "text": "a\tb", "indent": 2, "tabs": [6, 4], "align": "right"})).unwrap(); + assert_eq!(last_mtext(&s).contents, "\\pxi0,l2,qr,t4,6;a\tb"); + // Columns need a column width and a height; unknown list styles are refused. + assert!(s.execute("mtext", &json!({"at": [0, 0], "text": "x", "columns": {"type": "static", "height": 2}})).is_err()); + assert!(s.execute("mtext", &json!({"at": [0, 0], "text": "x", "width": 4, "columns": {"type": "static"}})).is_err()); + assert!(s.execute("mtext", &json!({"at": [0, 0], "text": "x", "list": "roman"})).is_err()); +} + +#[test] +fn columns_shape_the_drawn_text_and_its_extents() { + let mut s = Session::new(); + let words = "word ".repeat(60); + s.execute( + "mtext", + &json!({"at": [0, 0], "text": words, "height": 1, "width": 10, "columns": {"type": "static", "count": 3, "gutter": 2, "height": 6}}), + ) + .unwrap(); + let t = last_mtext(&s); + let d = s.doc().unwrap(); + let l = cadcraft_engine::render::layout_mtext_entity(d, &t); + // Three columns of 10 with two gutters of 2; text in every column, none below the columns. + assert!((l.bounds.width() - 34.0).abs() < 1e-9, "{:?}", l.bounds); + let xs: Vec = l.strokes.iter().flatten().map(|p| p.x).collect(); + assert!(xs.iter().any(|x| *x > 24.0), "text reaches the third column"); + let e = d.model.iter().last().unwrap(); + let b = cadcraft_doc::entity_bounds(d, e, 0); + assert!((b.width() - 34.0).abs() < 1e-9 && (b.height() - 6.0).abs() < 1e-9, "{b:?}"); +} diff --git a/crates/engine/tests/mtext_options.rs b/crates/engine/tests/mtext_options.rs index cc13d049..d4806955 100644 --- a/crates/engine/tests/mtext_options.rs +++ b/crates/engine/tests/mtext_options.rs @@ -113,15 +113,14 @@ fn style_and_columns() { s.cmdline("?").unwrap(); assert!(logged(&s, "Text styles: Standard, Notes")); s.cmdline("").unwrap(); - // Columns: No is accepted; Dynamic and Static say they're not available, the text stays one column. - s.cmdline("c d").unwrap(); - assert!(logged(&s, "Text columns are not available yet")); + // Columns: No keeps one column (Dynamic and Static are in mtext_columns.rs). s.cmdline("c n 20,-10").unwrap(); s.cmdline("One column").unwrap(); s.cmdline("").unwrap(); let t = last_mtext(&s); assert_eq!(t.contents, "One column"); assert_eq!(t.style, "Standard"); + assert!(t.columns.is_none()); } #[test] diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 02da9119..1a5961bf 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -21,6 +21,7 @@ pub mod ttf; use cadcraft_geom::{Bounds2, Vec2}; +pub use mtext::para::{DEFAULT_TAB, ListStyle, MTextColumnSpec, ParaAlign, ParaProps, Paragraphs, TabKind, list_contents}; pub use mtext::{MTextColor, MTextLayout, MTextParams, MTextPiece, layout_mtext, layout_mtext_with, plain_mtext}; mod craft_fonts; diff --git a/crates/fonts/src/mtext.rs b/crates/fonts/src/mtext.rs index a55b4e44..8c08a619 100644 --- a/crates/fonts/src/mtext.rs +++ b/crates/fonts/src/mtext.rs @@ -4,6 +4,8 @@ use cadcraft_geom::{Bounds2, Vec2}; use crate::{Shaped, TextFont, shape_line, text_width}; +pub(crate) mod para; + /// Strip MTEXT formatting codes to plain text with `\n` paragraph breaks. /// Stacked fractions `\S1/2;` become `1/2`; `\~` is a space; braces are dropped. pub fn plain_mtext(s: &str) -> String { @@ -99,6 +101,8 @@ pub struct MTextParams { pub font: TextFont, pub width_factor: f64, pub oblique: f64, + /// Columns the text flows through (each `width` wide); `None`: one column. + pub columns: Option, } impl MTextParams { @@ -113,6 +117,7 @@ impl MTextParams { font: TextFont::Stroke, width_factor: 1.0, oblique: 0.0, + columns: None, } } } @@ -534,6 +539,9 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout { let rotation = if p.rotation.is_finite() { p.rotation } else { 0.0 }; let base = Fmt { font: p.font.clone(), height: h, color: None, under: false, over: false, wf, oblique: p.oblique, valign: 1 }; let (atoms, fmts) = parse(contents, base.clone()); + if para::needed(contents, p) { + return para::layout(contents, p, &base); + } let fmt = |i: usize| fmts.get(i).unwrap_or(&base); let mut lines: Vec = Vec::new(); let mut line = Line::default(); diff --git a/crates/fonts/src/mtext/para.rs b/crates/fonts/src/mtext/para.rs new file mode 100644 index 00000000..66803690 --- /dev/null +++ b/crates/fonts/src/mtext/para.rs @@ -0,0 +1,679 @@ +//! MTEXT paragraph formatting and columns: the `\p…;` paragraph codes (first-line, left and +//! right indents, tab stops, alignment, space before and after), tab characters (bulleted and +//! numbered lists are a label, a tab and a hanging indent), column breaks (`\N`) and text +//! flowing through static or dynamic columns. +//! +//! Contents without any of these lay out exactly as in [`super::layout_mtext_with`]'s plain path. + +use cadcraft_geom::{Bounds2, Vec2}; + +use super::{Atom, Fmt, MTextLayout, MTextParams, MTextPiece, Seg, arg, num, parse, seg_plain, seg_shape, seg_width}; + +/// Columns an MTEXT's text flows through, each as wide as the MTEXT's width. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MTextColumnSpec { + /// Dynamic columns: as many as the text needs. Static: exactly `count`. + pub dynamic: bool, + /// Number of static columns. + pub count: usize, + /// Space between two columns. + pub gutter: f64, + /// Height of every column (0 = unbounded: one column). + pub height: f64, + /// Heights of the columns one by one (dynamic columns with manual heights); the last one + /// repeats. Empty: `height` for all. + pub heights: Vec, + /// Columns run right to left. + pub reversed: bool, +} + +/// Where a tab stop puts the text after the tab. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TabKind { + /// Text starts at the stop. + #[default] + Left, + /// Text is centred on the stop. + Center, + /// Text ends at the stop. + Right, + /// The decimal point is at the stop (laid out like [`TabKind::Right`]). + Decimal, +} + +/// A paragraph's horizontal alignment (`\pq…`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ParaAlign { + /// The attachment point's column: left, centre or right. + #[default] + Default, + Left, + Center, + Right, + /// Lines spread to the full width, the last line of the paragraph left. + Justify, + /// Every line spread to the full width. + 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. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ParaProps { + /// `i`: first-line indent, relative to the left indent (negative: a hanging indent). + pub first: f64, + /// `l`: left indent of every line. + pub left: f64, + /// `r`: right indent. + pub right: f64, + /// `q`: alignment. + pub align: ParaAlign, + /// `t`: tab stops from the left edge, ascending. + pub tabs: Vec<(f64, TabKind)>, + /// `b`: space before the paragraph. + pub before: f64, + /// `a`: space after the paragraph. + pub after: f64, +} + +/// Upper bound on tab stops per paragraph (hostile contents). +const MAX_TABS: usize = 64; +/// Upper bound on distinct paragraph property sets per MTEXT. +const MAX_STATES: usize = 10_000; +/// Upper bound on columns. +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. +const MAX_UNITS: f64 = 10_000.0; + +fn units(s: &str) -> Option { + if s.trim() == "*" { + return Some(0.0); + } + num(s).filter(|v| v.abs() <= MAX_UNITS) +} + +impl ParaProps { + /// Apply one `\p` argument (`xi-3,l3,t3`, `qc`, `t2,c4,r6`, `tz` …) on top of these + /// properties. Unknown items are ignored. + pub fn apply(&mut self, arg: &str) { + let a = arg.trim(); + let a = a.strip_prefix('x').unwrap_or(a); + let mut in_tabs = false; + for item in a.split(',').map(str::trim) { + let mut cs = item.chars(); + let Some(key) = cs.next() else { continue }; + let rest = cs.as_str(); + if in_tabs { + let tab = match key { + 'c' => units(rest).map(|v| (v, TabKind::Center)), + 'r' => units(rest).map(|v| (v, TabKind::Right)), + 'd' => units(rest).map(|v| (v, TabKind::Decimal)), + _ if key.is_ascii_digit() || key == '.' || key == '-' => units(item).map(|v| (v, TabKind::Left)), + _ => None, + }; + if let Some(t) = tab { + self.push_tab(t); + continue; + } + if matches!(key, 'c' | 'r' | 'd') || key.is_ascii_digit() { + continue; + } + in_tabs = false; + } + match key { + 'i' => self.first = units(rest).unwrap_or(self.first), + 'l' => self.left = units(rest).unwrap_or(self.left).max(0.0), + 'r' => self.right = units(rest).unwrap_or(self.right).max(0.0), + 'b' => self.before = units(rest).unwrap_or(self.before).max(0.0), + 'a' => self.after = units(rest).unwrap_or(self.after).max(0.0), + 'q' => { + self.align = match rest.chars().next() { + Some('l') => ParaAlign::Left, + Some('c') => ParaAlign::Center, + Some('r') => ParaAlign::Right, + Some('j') => ParaAlign::Justify, + Some('d') => ParaAlign::Distributed, + Some('*') | None => ParaAlign::Default, + Some(_) => self.align, + } + } + 't' => { + self.tabs.clear(); + in_tabs = true; + let rest = rest.trim(); + if !rest.is_empty() && rest != "z" && rest != "*" { + let t = match rest.split_at(rest.chars().next().map_or(0, char::len_utf8)) { + ("c", v) => units(v).map(|v| (v, TabKind::Center)), + ("r", v) => units(v).map(|v| (v, TabKind::Right)), + ("d", v) => units(v).map(|v| (v, TabKind::Decimal)), + _ => units(rest).map(|v| (v, TabKind::Left)), + }; + if let Some(t) = t { + self.push_tab(t); + } + } + } + _ => {} + } + } + } + + fn push_tab(&mut self, t: (f64, TabKind)) { + if self.tabs.len() < MAX_TABS && t.0 >= 0.0 { + let at = self.tabs.partition_point(|(p, _)| *p < t.0); + self.tabs.insert(at, t); + } + } + + /// The `\p…;` code for these properties: the indents, then the right indent, spacing and + /// alignment where they aren't the default, then the tab stops. + pub fn code(&self) -> String { + let n = |v: f64| { + let s = format!("{v:.4}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".to_string() } else { s.to_string() } + }; + let mut items = vec![format!("i{}", n(self.first)), format!("l{}", n(self.left))]; + for (key, v) in [('r', self.right), ('b', self.before), ('a', self.after)] { + if v > 0.0 { + items.push(format!("{key}{}", n(v))); + } + } + let q = match self.align { + ParaAlign::Default => None, + ParaAlign::Left => Some("ql"), + ParaAlign::Center => Some("qc"), + ParaAlign::Right => Some("qr"), + ParaAlign::Justify => Some("qj"), + ParaAlign::Distributed => Some("qd"), + }; + items.extend(q.map(String::from)); + for (i, (p, k)) in self.tabs.iter().enumerate() { + let kind = match k { + TabKind::Left => "", + TabKind::Center => "c", + TabKind::Right => "r", + TabKind::Decimal => "d", + }; + items.push(format!("{}{kind}{}", if i == 0 { "t" } else { "" }, n(*p))); + } + format!("\\px{};", items.join(",")) + } +} + +/// The labels of a list's paragraphs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ListStyle { + /// `•` + Bullet, + /// `1.` `2.` … + Number, + /// `a.` `b.` … `z.` `aa.` … + LowerLetter, + /// `A.` `B.` … + UpperLetter, +} + +impl ListStyle { + /// The label of item `i` (from 0). + pub fn label(self, i: usize) -> String { + let letters = |upper: bool| { + // Bijective base 26: a … z, aa, ab … + let mut n = i.saturating_add(1); + let mut s = Vec::new(); + while n > 0 && s.len() < 16 { + n -= 1; + let c = b'a' + (n % 26) as u8; + s.push(char::from(if upper { c.to_ascii_uppercase() } else { c })); + n /= 26; + } + s.iter().rev().collect::() + "." + }; + match self { + ListStyle::Bullet => "•".into(), + ListStyle::Number => format!("{}.", i.saturating_add(1)), + ListStyle::LowerLetter => letters(false), + ListStyle::UpperLetter => letters(true), + } + } +} + +/// Paragraphs as a list: a hanging indent of `indent` text heights with a tab stop there, and +/// each paragraph its label and a tab (`\pxi-3,l3,t3;1.First\P2.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 }; + let props = ParaProps { first: -indent, left: indent, tabs: vec![(indent, TabKind::Left)], ..ParaProps::default() }; + let items: Vec = paragraphs.iter().enumerate().map(|(i, p)| format!("{}\t{p}", style.label(i))).collect(); + format!("{}{}", props.code(), items.join("\\P")) +} + +/// The paragraph properties of each paragraph of MTEXT contents and which paragraph breaks are +/// column breaks (`\N`), found with the same code grammar as [`super::parse`] so paragraph `k` +/// here is the text after the `k`-th [`Atom::Para`]. +#[derive(Debug, Default)] +pub struct Paragraphs { + states: Vec, + /// Index into `states` per paragraph. + para: Vec, + /// Per paragraph break: a column break. + column_break: Vec, +} + +impl Paragraphs { + pub fn scan(contents: &str) -> Paragraphs { + let chars: Vec = contents.chars().collect(); + let mut out = Paragraphs { states: vec![ParaProps::default()], ..Default::default() }; + let mut cur = 0usize; + let mut i = 0; + let brk = |out: &mut Paragraphs, cur: usize, column: bool| { + out.para.push(cur); + out.column_break.push(column); + }; + while let Some(&c) = chars.get(i) { + match c { + '\\' => { + let code = chars.get(i + 1).copied().unwrap_or(' '); + i += 2; + match code { + 'P' => brk(&mut out, cur, false), + 'N' => brk(&mut out, cur, true), + '~' | '\\' | '{' | '}' | 'L' | 'l' | 'O' | 'o' | 'K' | 'k' => {} + 'S' => { + let mut j = i; + while let Some(&ch) = chars.get(j) { + if ch == ';' { + break; + } + j += if ch == '\\' { 2 } else { 1 }; + } + i = j + 1; + } + 'p' => { + let (a, n) = arg(&chars, i); + i = n; + if out.states.len() < MAX_STATES + && let Some(st) = out.states.get(cur) + { + let mut st = st.clone(); + st.apply(&a); + out.states.push(st); + cur = out.states.len() - 1; + } + } + _ => { + let (_, n) = arg(&chars, i); + i = n; + } + } + } + '\n' => { + brk(&mut out, cur, false); + i += 1; + } + _ => i += 1, + } + } + // A `\p` anywhere in a paragraph applies to all of it: the break pushed the state at + // the paragraph's end, so the last paragraph gets the final state. + brk(&mut out, cur, false); + out + } + + /// Properties of paragraph `k`. + pub fn props(&self, k: usize) -> &ParaProps { + let st = self.para.get(k).or(self.para.last()).copied().unwrap_or(0); + self.states.get(st).or(self.states.first()).unwrap_or(&DEFAULT_PROPS) + } + + /// The break after paragraph `k` starts a new column. + pub fn column_break(&self, k: usize) -> bool { + self.column_break.get(k).copied().unwrap_or(false) + } + + /// Any paragraph formatting at all. + pub fn formatted(&self) -> bool { + self.states.len() > 1 || self.column_break.iter().any(|b| *b) + } +} + +static DEFAULT_PROPS: ParaProps = + ParaProps { first: 0.0, left: 0.0, right: 0.0, align: ParaAlign::Default, tabs: Vec::new(), before: 0.0, after: 0.0 }; + +/// Contents or columns that need the paragraph layout. +pub(super) fn needed(contents: &str, p: &MTextParams) -> bool { + p.columns.is_some() || contents.contains('\t') || contents.contains("\\p") +} + +/// One piece of a laid-out line: a segment, its format, x offset and the number of word gaps +/// before it on the line (for justified paragraphs). +struct Item { + seg: Seg, + fmt: usize, + x: f64, + gap: usize, +} + +#[derive(Default)] +struct PLine { + items: Vec, + /// Right end of the content. + width: f64, + max_h: f64, + text: String, + gaps: usize, + para: usize, + /// The last line of its paragraph. + para_end: bool, +} + +/// A word split at its tab characters. +enum Part { + Text(Vec<(Seg, usize)>), + Tab, +} + +fn split_tabs(segs: &[(Seg, usize)]) -> Vec { + let mut out = Vec::new(); + let mut cur: Vec<(Seg, usize)> = Vec::new(); + for (sg, f) in segs { + match sg { + Seg::Text(t) if t.contains('\t') => { + for (i, piece) in t.split('\t').enumerate() { + if i > 0 { + if !cur.is_empty() { + out.push(Part::Text(std::mem::take(&mut cur))); + } + out.push(Part::Tab); + } + if !piece.is_empty() { + cur.push((Seg::Text(piece.to_string()), *f)); + } + } + } + _ => cur.push((sg.clone(), *f)), + } + } + if !cur.is_empty() { + out.push(Part::Text(cur)); + } + out +} + +/// The next tab stop right of `x`: the paragraph's stops, the left indent of a hanging indent, +/// 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; + if hang > x + eps && best.is_none_or(|(p, _)| hang < p) { + best = Some((hang, TabKind::Left)); + } + if let Some(b) = best { + return b; + } + let step = DEFAULT_TAB * h; + ((((x + eps) / step).floor() + 1.0) * step, TabKind::Left) +} + +/// Lay out MTEXT with paragraph formatting and columns. `base` is the entity's own format, +/// `h` its text height, `width` the column width (0: no wrapping, no columns). +pub(super) fn layout(contents: &str, p: &MTextParams, base: &Fmt) -> MTextLayout { + let h = base.height; + let width = if p.width.is_finite() && p.width > 0.0 { p.width } else { 0.0 }; + let rotation = if p.rotation.is_finite() { p.rotation } else { 0.0 }; + let paras = Paragraphs::scan(contents); + let (atoms, fmts) = parse(contents, base.clone()); + let fmt = |i: usize| fmts.get(i).unwrap_or(base); + let space_w = |f: usize| crate::text_width(&fmt(f).font, " ", fmt(f).height, fmt(f).wf); + + let mut lines: Vec = Vec::new(); + let mut k = 0usize; + let first_start = |k: usize| { + let pp = paras.props(k); + ((pp.left + pp.first) * h).max(0.0) + }; + let mut line = PLine { width: first_start(0), ..Default::default() }; + let mut pending_space: Option = None; + let mut last_fmt = 0usize; + let finish = |lines: &mut Vec, line: &mut PLine, f: &Fmt, k: usize, para_end: bool, next_start: f64| { + if line.items.is_empty() { + line.max_h = f.height; + } + line.para = k; + line.para_end = para_end; + let next = PLine { width: next_start, ..Default::default() }; + lines.push(std::mem::replace(line, next)); + }; + 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); + match a { + Atom::Space(f) => { + if !line.items.is_empty() || pending_space.is_some() { + pending_space = Some(*f); + } + last_fmt = *f; + } + Atom::Para(f) => { + pending_space = None; + finish(&mut lines, &mut line, fmt(*f), k, true, first_start(k + 1)); + k += 1; + last_fmt = *f; + } + Atom::Word(segs) => { + let parts = split_tabs(segs); + for (pi, part) in parts.iter().enumerate() { + match part { + Part::Tab => { + pending_space = None; + let (stop, kind) = next_tab(line.width, pp, h); + let next_w: f64 = match parts.get(pi + 1) { + Some(Part::Text(s)) => s.iter().map(|(sg, f)| seg_width(sg, fmt(*f))).sum(), + _ => 0.0, + }; + let x = match kind { + TabKind::Left => stop, + TabKind::Center => (stop - next_w / 2.0).max(line.width), + TabKind::Right | TabKind::Decimal => (stop - next_w).max(line.width), + }; + if x > avail + 1e-9 && !line.items.is_empty() { + finish(&mut lines, &mut line, fmt(last_fmt), k, false, left); + } else { + line.width = x; + line.text.push(' '); + } + } + Part::Text(segs) => { + let ww: f64 = segs.iter().map(|(sg, f)| seg_width(sg, fmt(*f))).sum(); + let sw = if pi == 0 { pending_space.map(space_w).unwrap_or(0.0) } else { 0.0 }; + if width > 0.0 && !line.items.is_empty() && line.width + sw + ww > avail + 1e-9 { + finish(&mut lines, &mut line, fmt(last_fmt), k, false, left); + } else if pi == 0 && (!line.items.is_empty() || pending_space.is_some()) { + line.width += sw; + if pending_space.is_some() { + line.text.push(' '); + line.gaps += 1; + } + } + pending_space = None; + for (sg, f) in segs { + let fw = seg_width(sg, fmt(*f)); + line.max_h = line.max_h.max(fmt(*f).height); + line.text.push_str(&seg_plain(sg)); + line.items.push(Item { seg: sg.clone(), fmt: *f, x: line.width, gap: line.gaps }); + line.width += fw; + last_fmt = *f; + } + } + } + } + } + } + } + finish(&mut lines, &mut line, fmt(last_fmt), k, true, 0.0); + + // Vertical placement: baselines below the top of each line's column. + let ls = if p.line_spacing > 0.0 && p.line_spacing.is_finite() { p.line_spacing } else { 1.0 }; + let cols = p.columns.as_ref().filter(|_| width > 0.0); + let max_cols = match cols { + Some(c) if c.dynamic => MAX_COLUMNS, + Some(c) => c.count.clamp(1, MAX_COLUMNS), + None => 1, + }; + let col_height = |c: usize| -> f64 { + let Some(spec) = cols else { return 0.0 }; + let v = if spec.heights.is_empty() { spec.height } else { spec.heights.get(c).or(spec.heights.last()).copied().unwrap_or(0.0) }; + if v.is_finite() && v > 0.0 { v } else { 0.0 } + }; + let mut placed: Vec<(usize, f64)> = Vec::with_capacity(lines.len()); + let (mut c, mut y, mut first_in_col, mut force) = (0usize, 0.0f64, true, false); + let mut col_bottom = vec![0.0f64]; + let mut prev_para: Option = None; + for l in &lines { + let lh = if l.max_h > 0.0 { l.max_h } else { h }; + let new_para = prev_para != Some(l.para); + let before = if new_para && prev_para.is_some() { paras.props(l.para).before * h } else { 0.0 }; + let pitch = |first: bool| if first { lh } else { lh * 5.0 / 3.0 * ls + before }; + let hc = col_height(c); + if !first_in_col && c + 1 < max_cols && (force || (hc > 0.0 && y + pitch(false) > hc + 1e-9)) { + c += 1; + col_bottom.push(0.0); + y = 0.0; + first_in_col = true; + } + force = false; + y += pitch(first_in_col); + first_in_col = false; + placed.push((c, -y)); + if let Some(b) = col_bottom.get_mut(c) { + *b = y; + } + if l.para_end { + y += paras.props(l.para).after * h; + force = paras.column_break(l.para); + } + prev_para = Some(l.para); + } + let n_cols = match cols { + Some(spec) if !spec.dynamic => max_cols, + _ => c + 1, + }; + let content_h = col_bottom.iter().copied().fold(0.0, f64::max); + let block_h = match cols { + 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 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; + let (acol, arow) = (attach % 3, attach / 3); + let top = match arow { + 0 => 0.0, + 1 => block_h / 2.0, + _ => block_h, + }; + let box_x0 = match acol { + 0 => 0.0, + 1 => -total_w / 2.0, + _ => -total_w, + }; + let reversed = cols.is_some_and(|s| s.reversed); + let insert = p.insert; + let xf = |q: Vec2| insert + q.rotate(rotation); + let mut out = MTextLayout { lines: lines.iter().map(|l| l.text.clone()).collect(), ..Default::default() }; + for (l, (c, base_y)) in lines.iter().zip(placed) { + 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 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(), + a => a, + }; + let spread = width > 0.0 && l.gaps > 0 && free > 0.0 && (align == ParaAlign::Distributed || (align == ParaAlign::Justify && !l.para_end)); + let (shift, per_gap) = match align { + ParaAlign::Center => (free / 2.0, 0.0), + ParaAlign::Right => (free, 0.0), + _ if spread => (0.0, free / l.gaps as f64), + _ => (0.0, 0.0), + }; + for it in &l.items { + let fm = fmt(it.fmt); + let mut sh = seg_shape(&it.seg, fm, it.x); + let off = Vec2::new(col_x + shift + per_gap * it.gap as f64, top + base_y); + sh.map(|q| xf(q + off)); + match out.pieces.last_mut() { + Some(pc) if pc.color == fm.color => pc.shaped.extend(sh), + _ => out.pieces.push(MTextPiece { color: fm.color, shaped: sh }), + } + } + } + for pc in &out.pieces { + out.strokes.extend(pc.shaped.outlines()); + } + out.bounds = Bounds2::from_points( + [Vec2::new(box_x0, top), Vec2::new(box_x0 + total_w, top), Vec2::new(box_x0 + total_w, top - block_h), Vec2::new(box_x0, top - block_h)] + .map(xf), + ); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_contents_lay_out_as_the_plain_path() { + for (s, w, a) in [ + ("the quick brown fox jumps over the lazy dog", 8.0, 1u8), + ("one\\Ptwo\\P\\Pfour", 0.0, 5), + ("alpha {\\H2x;beta} gamma delta", 6.0, 9), + ("A{\\C1;B}C \\S1/2; x", 0.0, 2), + (" lead spaces and\\P more", 5.0, 3), + ] { + let p = MTextParams { width: w, attach: a, rotation: 0.3, insert: Vec2::new(1.0, 2.0), ..MTextParams::new(Vec2::ZERO, 1.0) }; + let plain = super::super::layout_mtext_with(s, &p); + // An empty `\p;` code changes nothing but takes the paragraph path. + let para = super::super::layout_mtext_with(&format!("{s}\\p;"), &p); + assert_eq!(plain.lines, para.lines, "{s}"); + assert_eq!(plain.strokes.len(), para.strokes.len(), "{s}"); + for (x, y) in plain.strokes.iter().flatten().zip(para.strokes.iter().flatten()) { + assert!(x.dist(*y) < 1e-9, "{s}: {x:?} vs {y:?}"); + } + assert!(plain.bounds.min.dist(para.bounds.min) < 1e-9 && plain.bounds.max.dist(para.bounds.max) < 1e-9, "{s}"); + } + } + + #[test] + fn list_labels() { + assert_eq!(ListStyle::Number.label(9), "10."); + assert_eq!(ListStyle::LowerLetter.label(0), "a."); + assert_eq!(ListStyle::LowerLetter.label(26), "aa."); + assert_eq!(ListStyle::UpperLetter.label(27), "AB."); + assert_eq!(list_contents(&["one", "two"], ListStyle::Number, 2.0), "\\pxi-2,l2,t2;1.\tone\\P2.\ttwo"); + } + + #[test] + fn props_parse_and_persist() { + let p = Paragraphs::scan("\\pxi-3,l3,t3;1.\tone\\P2.\ttwo\\P\\pi0,l0,tz;plain"); + assert_eq!(p.props(0).first, -3.0); + assert_eq!(p.props(0).left, 3.0); + assert_eq!(p.props(0).tabs, vec![(3.0, TabKind::Left)]); + assert_eq!(p.props(1), p.props(0)); + assert_eq!(p.props(2).left, 0.0); + assert!(p.props(2).tabs.is_empty()); + let mut pp = ParaProps::default(); + pp.apply("t2,c4,r6,d8,qc,b0.5"); + assert_eq!(pp.tabs, vec![(2.0, TabKind::Left), (4.0, TabKind::Center), (6.0, TabKind::Right), (8.0, TabKind::Decimal)]); + assert_eq!(pp.align, ParaAlign::Center); + assert_eq!(pp.before, 0.5); + let mut back = ParaProps::default(); + back.apply(pp.code().trim_start_matches("\\p").trim_end_matches(';')); + assert_eq!(back, pp); + } +} diff --git a/crates/fonts/src/stroke.rs b/crates/fonts/src/stroke.rs index 6717ddb3..e6d38b7d 100644 --- a/crates/fonts/src/stroke.rs +++ b/crates/fonts/src/stroke.rs @@ -108,6 +108,8 @@ pub(crate) fn glyph(c: char) -> Option<(f64, &'static str)> { '°' => (2.0, "1i0h1g2h1i"), '±' => (4.0, "2e2i 0g4g 0d4d"), 'Ø' | 'ø' | '⌀' => (4.0, "1c0d0h1i3i4h4d3c1c 0c4i"), + // List bullet: a small crossed diamond at mid x-height. + '•' => (2.0, "1d0e1f2e1d 0e2e 1d1f"), _ => return None, }; Some(g) diff --git a/crates/fonts/tests/mtext_paragraphs.rs b/crates/fonts/tests/mtext_paragraphs.rs new file mode 100644 index 00000000..26291b78 --- /dev/null +++ b/crates/fonts/tests/mtext_paragraphs.rs @@ -0,0 +1,137 @@ +//! MTEXT paragraph formatting and columns in the layout (#532): `\p` indents, tabs, lists, +//! alignment, column flow and column breaks, and hostile codes. + +use cadcraft_fonts::{MTextColumnSpec, MTextLayout, MTextParams, layout_mtext_with}; +use cadcraft_geom::Vec2; + +fn lay(s: &str, width: f64, columns: Option) -> MTextLayout { + layout_mtext_with(s, &MTextParams { width, columns, ..MTextParams::new(Vec2::ZERO, 1.0) }) +} + +fn xs(l: &MTextLayout) -> Vec { + l.strokes.iter().flatten().map(|p| p.x).collect() +} + +fn min_x(l: &MTextLayout) -> f64 { + xs(l).into_iter().fold(f64::MAX, f64::min) +} + +fn max_x(l: &MTextLayout) -> f64 { + xs(l).into_iter().fold(f64::MIN, f64::max) +} + +/// Leftmost x of the geometry of line `n` (0 = first; baselines 5/3 apart below -1). +fn line_min_x(l: &MTextLayout, n: usize) -> f64 { + let base = -1.0 - n as f64 * 5.0 / 3.0; + l.strokes.iter().flatten().filter(|p| p.y >= base - 0.4 && p.y <= base + 1.3).map(|p| p.x).fold(f64::MAX, f64::min) +} + +#[test] +fn left_indent_moves_every_line() { + let plain = lay("indented text", 0.0, None); + let ind = lay("\\pxl2;indented text", 0.0, None); + assert!((min_x(&ind) - min_x(&plain) - 2.0).abs() < 1e-9); +} + +#[test] +fn hanging_indent_list_aligns_wrapped_lines_after_the_label() { + // Label at 0, text at the tab stop 3, wrapped lines at the left indent 3. + let l = lay("\\pxi-3,l3,t3;1.\tthe quick brown fox jumps over the lazy dog", 12.0, None); + assert!(l.lines.len() >= 2, "{:?}", l.lines); + assert!(line_min_x(&l, 0).abs() < 0.3, "label at the left edge"); + assert!((line_min_x(&l, 1) - 3.0).abs() < 0.3, "wrapped line at the indent: {}", line_min_x(&l, 1)); + let after_tab = l.strokes.iter().flatten().filter(|p| p.y > -1.2 && p.x > 1.8).map(|p| p.x).fold(f64::MAX, f64::min); + assert!((after_tab - 3.0).abs() < 0.3, "{after_tab}"); +} + +#[test] +fn tabs_go_to_the_stops_then_every_default_interval() { + let l = lay("\\pxt5;a\tb\tc", 0.0, None); + let xs = xs(&l); + // `b` at the stop 5, `c` at the next default stop (8 = 2 × 4 text heights). + assert!(xs.iter().any(|x| (*x - 5.0).abs() < 0.3)); + assert!(xs.iter().any(|x| (*x - 8.0).abs() < 0.3)); + assert!(!xs.iter().any(|x| *x > 1.0 && *x < 4.9)); +} + +#[test] +fn paragraph_alignment_overrides_the_attachment() { + let right = lay("\\pqr;ab", 10.0, None); + assert!(max_x(&right) > 9.0 && max_x(&right) <= 10.0 + 1e-9, "{}", max_x(&right)); + let centre = lay("\\pqc;ab", 10.0, None); + let c = (min_x(¢re) + max_x(¢re)) / 2.0; + assert!((c - 5.0).abs() < 0.5, "{c}"); +} + +#[test] +fn justified_lines_fill_the_width_but_the_last() { + let l = lay("\\pqj;aa bb cc dd ee ff gg hh ii jj kk", 10.0, None); + assert!(l.lines.len() >= 2); + let first_line_max = l.strokes.iter().flatten().filter(|p| p.y > -1.3).map(|p| p.x).fold(f64::MIN, f64::max); + assert!(first_line_max > 9.5, "{first_line_max}"); +} + +#[test] +fn text_flows_through_static_columns() { + let spec = MTextColumnSpec { count: 3, gutter: 2.0, height: 6.0, ..Default::default() }; + // Two words a line, four lines a column (1 + 3 × 5/3 = 6): 20 words fill two and a half. + let l = lay(&"word ".repeat(20), 10.0, Some(spec.clone())); + assert!((l.bounds.width() - 34.0).abs() < 1e-9); + assert!((l.bounds.height() - 6.0).abs() < 1e-9); + for col in 0..3 { + let x0 = col as f64 * 12.0; + assert!(xs(&l).iter().any(|x| *x >= x0 && *x <= x0 + 10.0), "column {col} has text"); + } + // The gutters stay empty. + assert!(!xs(&l).iter().any(|x| *x > 10.0 + 1e-9 && *x < 12.0 - 1e-9)); + // Text past the last static column runs on below it. + let over = lay(&"word ".repeat(40), 10.0, Some(spec)); + assert!(over.bounds.height() > 6.0 && (over.bounds.width() - 34.0).abs() < 1e-9); +} + +#[test] +fn dynamic_columns_add_columns_and_column_breaks_force_one() { + let spec = MTextColumnSpec { dynamic: true, gutter: 1.0, height: 3.0, ..Default::default() }; + // Two lines fit in 3 (1 + 5/3): five paragraphs take three columns. + let l = lay("one\\Ptwo\\Pthree\\Pfour\\Pfive", 5.0, Some(spec.clone())); + assert!((l.bounds.width() - 17.0).abs() < 1e-9, "{:?}", l.bounds); + let b = lay("one\\Ntwo", 5.0, Some(spec)); + assert!((b.bounds.width() - 11.0).abs() < 1e-9, "the column break starts a second column"); +} + +#[test] +fn reversed_columns_run_right_to_left() { + let spec = MTextColumnSpec { count: 2, height: 1.0, reversed: true, ..Default::default() }; + let l = lay("first\\Nsecond", 10.0, Some(spec)); + assert_eq!(l.lines, vec!["first", "second"]); + let first = l.strokes.iter().flatten().filter(|p| p.x >= 10.0).count(); + let second = l.strokes.iter().flatten().filter(|p| p.x < 10.0).count(); + assert!(first > 0 && second > 0 && min_x(&l) >= 0.0); +} + +#[test] +fn hostile_paragraph_codes_do_not_panic() { + let huge_tabs = format!("\\pt{};a\tb", (0..100_000).map(|i| i.to_string()).collect::>().join(",")); + let many = "\\pl1;x\\P".repeat(20_000); + for s in [ + "\\p", + "\\pxi", + "\\pi-1e308,l1e308,r-5;x", + "\\pt;\t\t\t", + "\\pqz,t-3,cx,r;a\tb", + "\\pxl99999999;a b c", + "\t", + "\\N\\N\\N", + "{\\pl2;\\P}}}\\p;", + huge_tabs.as_str(), + many.as_str(), + ] { + let _ = lay(s, 5.0, None); + let _ = lay(s, 5.0, Some(MTextColumnSpec { dynamic: true, height: 1.0, ..Default::default() })); + let _ = lay( + s, + 1.0, + Some(MTextColumnSpec { count: 1000, gutter: f64::NAN, height: f64::INFINITY, heights: vec![f64::NAN, -1.0], ..Default::default() }), + ); + } +} diff --git a/crates/io/src/dxf_mleader.rs b/crates/io/src/dxf_mleader.rs index 33d7429e..f6ea1058 100644 --- a/crates/io/src/dxf_mleader.rs +++ b/crates/io/src/dxf_mleader.rs @@ -309,6 +309,7 @@ pub(crate) fn read(tags: &[Tag]) -> MLeader { contents: p.text.clone().unwrap_or_default(), line_spacing: pos(p.line_spacing).unwrap_or(1.0), line_spacing_exact: p.line_spacing_style == Some(2), + columns: None, } }); let block = has_block.then(|| { diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 4e8248f8..b485719a 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -9,6 +9,8 @@ use cadcraft_geom::{PolyVertex, Spline, Vec2, Vec3}; use crate::{IoError, Result}; +pub(crate) mod mtext_dxf; + /// Value lookup helpers over an entity's tags. struct T<'a>(&'a [Tag]); @@ -298,9 +300,11 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { contents.push_str(&tg.str()); } contents.push_str(&t.s(1).unwrap_or_default()); + let contents = mtext_dxf::decode_contents(&contents); + let columns = mtext_dxf::read_columns(tags); let mut rotation = match (t.f(11), t.f(21)) { (Some(x), Some(y)) if x != 0.0 || y != 0.0 => Vec2::new(x, y).angle(), - _ => t.fd(50, 0.0).to_radians(), + _ => T(mtext_dxf::before_columns(tags)).fd(50, 0.0).to_radians(), }; let mut attach = t.i(71).unwrap_or(1).clamp(1, 9) as u8; if c.extrusion.z < 0.0 { @@ -317,13 +321,17 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { EntityKind::MText(MText { insert: t.p(10), height: t.fd(40, 0.2).max(1e-9), - width: t.fd(41, 0.0).max(0.0), + width: match &columns { + Some((_, w)) if t.fd(41, 0.0) <= 0.0 => *w, + _ => t.fd(41, 0.0).max(0.0), + }, attach, rotation, style: t.s(7).unwrap_or_else(|| "Standard".into()), contents, line_spacing: t.fd(44, 1.0), line_spacing_exact: t.i(73) == Some(2), + columns: columns.map(|(c, _)| c), }) } "ATTDEF" => EntityKind::AttDef(Attrib { diff --git a/crates/io/src/dxf_read/mtext_dxf.rs b/crates/io/src/dxf_read/mtext_dxf.rs new file mode 100644 index 00000000..f955603e --- /dev/null +++ b/crates/io/src/dxf_read/mtext_dxf.rs @@ -0,0 +1,120 @@ +//! MTEXT specifics of DXF (DXF Reference, MTEXT): control characters in the contents written +//! as a caret and a letter (`^I` a tab, `^J` a line feed, `^ ` a caret), and the column groups +//! 75 (type), 76 (count), 78 (flow reversed), 79 (auto height), 48 (width), 49 (gutter) and +//! 50 (column heights, after the column type; before it, 50 is the rotation). + +use cadcraft_doc::{MText, MTextColumnType, MTextColumns}; +use cadcraft_dxf::Tag; + +/// Upper bound on column heights read from a file. +const MAX_COLUMNS: usize = 1_000; + +/// Decode the contents' `^I` (tab), `^J` (line feed) and `^ ` (caret). Other carets stay as +/// they are (older files write stacked tolerances `\S+0.1^-0.1;` with a bare caret). +pub(crate) fn decode_contents(s: &str) -> String { + if !s.contains('^') { + return s.to_string(); + } + let mut out = String::with_capacity(s.len()); + let mut it = s.chars().peekable(); + while let Some(c) = it.next() { + if c != '^' { + out.push(c); + continue; + } + match it.peek().copied() { + Some(' ') => out.push('^'), + Some('I') => out.push('\t'), + Some('J') => out.push('\n'), + _ => { + out.push('^'); + continue; + } + } + it.next(); + } + out +} + +/// Encode the contents for a DXF string: a tab as `^I`, a line feed as `^J`, a caret as `^ `; +/// other control characters (which draw nothing) are dropped. +pub(crate) fn encode_contents(s: &str) -> String { + if !s.chars().any(|c| c == '^' || c.is_control()) { + return s.to_string(); + } + let mut out = String::with_capacity(s.len() + 8); + for c in s.chars() { + match c { + '^' => out.push_str("^ "), + '\t' => out.push_str("^I"), + '\n' => out.push_str("^J"), + c if c.is_control() => {} + c => out.push(c), + } + } + out +} + +/// The MTEXT's own tags: up to an embedded object (group 101), whose groups reuse MTEXT's codes. +fn own(tags: &[Tag]) -> &[Tag] { + let end = tags.iter().position(|t| t.code == 101).unwrap_or(tags.len()); + tags.get(..end).unwrap_or(tags) +} + +/// The tags before the column groups (where group 50 is the rotation angle). +pub(crate) fn before_columns(tags: &[Tag]) -> &[Tag] { + let tags = own(tags); + let end = tags.iter().position(|t| t.code == 75).unwrap_or(tags.len()); + tags.get(..end).unwrap_or(tags) +} + +fn finite(v: f64) -> f64 { + if v.is_finite() { v } else { 0.0 } +} + +/// The columns of an MTEXT, and the column width (group 48) when there are columns. +pub(crate) fn read_columns(tags: &[Tag]) -> Option<(MTextColumns, f64)> { + let tags = own(tags); + let start = tags.iter().position(|t| t.code == 75)?; + let kind = match tags.get(start).map(Tag::i64) { + Some(1) => MTextColumnType::Static, + Some(2) => MTextColumnType::Dynamic, + _ => return None, + }; + let cols = tags.get(start..).unwrap_or_default(); + let first = |code: i32| cols.iter().find(|t| t.code == code); + let heights: Vec = cols.iter().filter(|t| t.code == 50).take(MAX_COLUMNS).map(|t| finite(t.f64()).max(0.0)).collect(); + let count = first(76).map(Tag::i64).unwrap_or(heights.len() as i64).clamp(1, MAX_COLUMNS as i64); + let auto_height = first(79).map(Tag::i64).unwrap_or(0) != 0; + let defined = tags.iter().find(|t| t.code == 46).map(|t| finite(t.f64()).max(0.0)).unwrap_or(0.0); + let manual = kind == MTextColumnType::Dynamic && !auto_height; + let height = if manual { heights.iter().copied().fold(0.0, f64::max) } else { heights.first().copied().filter(|h| *h > 0.0).unwrap_or(defined) }; + let c = MTextColumns { + kind, + count: u16::try_from(count).unwrap_or(1), + gutter: first(49).map(|t| finite(t.f64()).max(0.0)).unwrap_or(0.0), + height, + auto_height, + heights: if manual { heights } else { Vec::new() }, + flow_reversed: first(78).map(Tag::i64).unwrap_or(0) != 0, + }; + Some((c, first(48).map(|t| finite(t.f64()).max(0.0)).unwrap_or(0.0))) +} + +/// The column groups of an MTEXT with columns. +pub(crate) fn column_tags(t: &MText) -> Vec { + let Some(c) = &t.columns else { return Vec::new() }; + let manual = c.kind == MTextColumnType::Dynamic && !c.auto_height; + let heights: Vec = if manual { c.heights.clone() } else { vec![c.height; usize::from(c.count.max(1))] }; + let count = if manual { heights.len().max(1) } else { usize::from(c.count.max(1)) }; + let mut v = vec![ + Tag::i(75, if c.kind == MTextColumnType::Dynamic { 2 } else { 1 }), + Tag::i(76, count as i64), + Tag::i(78, i64::from(c.flow_reversed)), + Tag::i(79, i64::from(c.auto_height)), + Tag::f(48, t.width), + Tag::f(49, c.gutter), + ]; + v.extend(heights.into_iter().map(|h| Tag::f(50, h))); + v +} diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 34158e5e..5a0fdc9e 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -630,7 +630,7 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx w.i(71, i64::from(t.attach)); w.i(72, 1); // Long contents go in 250-char 3-chunks, the remainder in 1. - let chars: Vec = t.contents.chars().collect(); + let chars: Vec = crate::dxf_read::mtext_dxf::encode_contents(&t.contents).chars().collect(); let chunks: Vec = chars.chunks(250).map(|c| c.iter().collect()).collect(); let n = chunks.len(); for (i, c) in chunks.iter().enumerate() { @@ -646,6 +646,7 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx // Line spacing style: 1 = at least, 2 = exactly. w.i(73, if t.line_spacing_exact { 2 } else { 1 }); w.f(44, t.line_spacing); + w.t.extend(crate::dxf_read::mtext_dxf::column_tags(t)); } EntityKind::Insert(i) => { w.s(0, "INSERT"); @@ -1160,6 +1161,7 @@ fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec { contents: g.value.clone(), line_spacing: 1.0, line_spacing_exact: false, + columns: None, }), }); } @@ -1226,6 +1228,7 @@ fn table_block_entities(d: &Drawing, t: &Table, layer: &str) -> Vec { contents: cell.text.clone(), line_spacing: 1.0, line_spacing_exact: false, + columns: None, }), }); } diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 6468b561..7bc52185 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -53,6 +53,7 @@ fn sample() -> Drawing { contents: "line one\\Pline two".into(), line_spacing: 1.0, line_spacing_exact: false, + columns: None, }), ) .unwrap(); @@ -1139,6 +1140,7 @@ fn intl_sample() -> Drawing { contents: format!("{INTL}\\P第二行 Ünïcødé"), line_spacing: 1.0, line_spacing_exact: false, + columns: None, }), ) .unwrap(); @@ -1363,6 +1365,7 @@ fn mleader_is_written_as_multileader() { style: "Standard".into(), line_spacing: 1.0, line_spacing_exact: false, + columns: None, }; let m = cadcraft_doc::MLeader { leaders: vec![vec![Vec3::new(0.0, 0.0, 0.0)]], diff --git a/crates/io/tests/mleader_roundtrip.rs b/crates/io/tests/mleader_roundtrip.rs index 59933959..6940be5b 100644 --- a/crates/io/tests/mleader_roundtrip.rs +++ b/crates/io/tests/mleader_roundtrip.rs @@ -29,6 +29,7 @@ fn text_mleader() -> MLeader { contents: "FIRST\\PSECOND".into(), line_spacing: 1.25, line_spacing_exact: true, + columns: None, }), style: "Callout".into(), arrow_size: 0.3, diff --git a/crates/io/tests/mtext_columns_roundtrip.rs b/crates/io/tests/mtext_columns_roundtrip.rs new file mode 100644 index 00000000..7db9ed68 --- /dev/null +++ b/crates/io/tests/mtext_columns_roundtrip.rs @@ -0,0 +1,93 @@ +//! MTEXT columns and paragraph codes survive a DXF round trip (#532): the column groups +//! 75/76/78/79/48/49/50, tabs as `^I`, carets as `^ `. + +use cadcraft_doc::{Common, Drawing, EntityKind, MText, MTextColumnType, MTextColumns, Space}; +use cadcraft_geom::Vec3; +use cadcraft_io::{read_dxf, write_dxf}; + +fn mtext(contents: &str, rotation: f64, columns: Option) -> MText { + MText { + insert: Vec3::new(1.0, 2.0, 0.0), + height: 0.5, + width: 6.0, + attach: 1, + rotation, + style: "Standard".into(), + contents: contents.into(), + line_spacing: 1.0, + line_spacing_exact: false, + columns, + } +} + +fn round_trip(t: MText) -> (MText, String) { + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common::default(), EntityKind::MText(t)).unwrap(); + let dxf = write_dxf(&d); + let back = read_dxf(dxf.as_bytes()).unwrap(); + match back.model.iter().find_map(|e| match &e.kind { + EntityKind::MText(t) => Some(t.clone()), + _ => None, + }) { + Some(t) => (t, dxf), + None => panic!("no mtext"), + } +} + +#[test] +fn static_columns_round_trip() { + let c = MTextColumns { kind: MTextColumnType::Static, count: 3, gutter: 1.25, height: 7.5, ..Default::default() }; + let (t, dxf) = round_trip(mtext("one\\Ntwo", 0.0, Some(c.clone()))); + assert_eq!(t.columns, Some(c)); + assert_eq!(t.width, 6.0); + // Group 50 after the column type is a column height, not the rotation. + assert_eq!(t.rotation, 0.0); + assert!(dxf.contains(" 75\r\n1\r\n"), "column type written"); +} + +#[test] +fn dynamic_manual_heights_and_rotation_round_trip() { + let c = MTextColumns { + kind: MTextColumnType::Dynamic, + count: 3, + gutter: 0.5, + height: 5.0, + auto_height: false, + heights: vec![5.0, 3.0, 4.0], + flow_reversed: true, + }; + let (t, _) = round_trip(mtext("a b c", 0.5, Some(c.clone()))); + assert_eq!(t.columns, Some(c)); + assert!((t.rotation - 0.5).abs() < 1e-9); + let auto = MTextColumns { kind: MTextColumnType::Dynamic, count: 1, gutter: 0.5, height: 4.0, auto_height: true, ..Default::default() }; + assert_eq!(round_trip(mtext("x", 0.0, Some(auto.clone()))).0.columns, Some(auto)); +} + +#[test] +fn paragraph_codes_tabs_and_carets_round_trip() { + let contents = "\\pxi-3,l3,t3;1.\tFirst\\P2.\tSecond {\\H0.7x;\\S+0.1^-0.2;}"; + let (t, dxf) = round_trip(mtext(contents, 0.0, None)); + assert_eq!(t.contents, contents); + assert!(t.columns.is_none()); + assert!(dxf.contains("1.^IFirst") && dxf.contains("^ -0.2"), "tab as ^I, caret as ^ "); + // Older files with a bare caret keep it. + let old = dxf.replace("^ -0.2", "^-0.2"); + let back = read_dxf(old.as_bytes()).unwrap(); + assert!(back.model.iter().any(|e| matches!(&e.kind, EntityKind::MText(m) if m.contents == contents))); +} + +#[test] +fn hostile_column_groups_do_not_panic() { + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common::default(), EntityKind::MText(mtext("x", 0.0, None))).unwrap(); + let dxf = write_dxf(&d); + for extra in [ + " 75\r\n2\r\n 76\r\n-5\r\n 50\r\nnan\r\n", + " 75\r\n1\r\n 76\r\n99999999\r\n 48\r\n-1\r\n 49\r\ninf\r\n", + " 75\r\n7\r\n", + &" 75\r\n2\r\n 50\r\n1e308\r\n".repeat(3000), + ] { + let bad = dxf.replacen(" 44\r\n", &format!("{extra} 44\r\n"), 1); + let _ = read_dxf(bad.as_bytes()); + } +} diff --git a/crates/io/tests/mtext_line_spacing.rs b/crates/io/tests/mtext_line_spacing.rs index 70c82561..8a030cbc 100644 --- a/crates/io/tests/mtext_line_spacing.rs +++ b/crates/io/tests/mtext_line_spacing.rs @@ -17,6 +17,7 @@ fn line_spacing_style_roundtrips() { contents: "a\\Pb".into(), line_spacing: 1.5, line_spacing_exact: exact, + columns: None, }; d.add(&Space::Model, Common::default(), EntityKind::MText(t.clone())).unwrap(); let bytes = cadcraft_io::write(&d, "a.dxf").unwrap(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 8033491b..8ec9bc7f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -867,6 +867,14 @@ pub fn layout_mtext_entity(d: &Drawing, t: &cadcraft_doc::MText) -> cadcraft_fon font: cadcraft_fonts::TextFont::resolve(&ts.font), width_factor: ts.width_factor, oblique: ts.oblique, + columns: t.columns.as_ref().map(|c| cadcraft_fonts::MTextColumnSpec { + dynamic: c.kind == cadcraft_doc::MTextColumnType::Dynamic, + count: usize::from(c.count.max(1)), + gutter: c.gutter, + height: c.height, + heights: if c.kind == cadcraft_doc::MTextColumnType::Dynamic && !c.auto_height { c.heights.clone() } else { Vec::new() }, + reversed: c.flow_reversed, + }), }; cadcraft_fonts::layout_mtext_with(&t.contents, ¶ms) } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index ab4ec68c..683817ff 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -287,6 +287,7 @@ fn truetype_mtext_and_dimension_text() { contents: "A{\\C1;B}\\P\\S1/2;".into(), line_spacing: 1.0, line_spacing_exact: false, + columns: None, }), ) .unwrap(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ed425e69..293cb544 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -387,6 +387,14 @@ 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 { + let lines: Vec = + 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")) +} + /// The multiline text editor shown while MTEXT asks for its contents. fn mtext_editor(app: &mut CadApp, ctx: &egui::Context) { let active = app.session.running.as_ref().is_some_and(|r| r.id == "mtext") @@ -426,6 +434,16 @@ fn mtext_editor(app: &mut CadApp, ctx: &egui::Context) { if ui.button("⌀").on_hover_text(crate::tl!("Diameter")).clicked() { buf.push_str("%%c"); } + ui.separator(); + if ui.button("⇥").on_hover_text(crate::tl!("Insert tab")).clicked() { + buf.push('\t'); + } + if ui.button("•≡").on_hover_text(crate::tl!("Bulleted list")).clicked() { + buf = as_list(&buf, false); + } + if ui.button("1≡").on_hover_text(crate::tl!("Numbered list")).clicked() { + buf = as_list(&buf, true); + } }); let r = ui.add( egui::TextEdit::multiline(&mut buf) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 231d30ed..c3fd1e94 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -108,6 +108,7 @@ Bring Above Objects Над об'єктами Bring Annotations to Front Анотації на передній план Bring to Front На передній план + Bulleted list Маркований список ByBlock За блоком ByColor За кольором ByLayer За шаром @@ -394,6 +395,7 @@ Insert Block Вставити блок Insert Column Вставити стовпець Insert Row Вставити рядок + Insert tab Вставити табуляцію Insert this block Вставити цей блок Insertion Точка вставлення Insertion X Вставлення X @@ -535,6 +537,7 @@ None Немає Not available Недоступно Not available yet Поки недоступно + Numbered list Нумерований список OK Гаразд Object Об'єкт Object Snap Об'єктна прив'язка