From 3ffe1ca2af4fd285c10bbc53e2dfa1759e77f60b Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 01:19:53 +0200 Subject: [PATCH] MTEXT: Height, Justify, Line spacing, Rotation, Style, Width, Columns (part of #408) (#415) The options at MTEXT's "Specify opposite corner" prompt printed "not available yet". Each now asks its question and returns to the corner prompt: Height (also sets TEXTSIZE), Justify (TL..BR, the insertion point is the box's attachment point), Line spacing (At least/Exactly, then a factor such as 1.5x or a distance), Rotation (picked, or typed through the drawing's angle settings; the box turns with it), Style (? lists them), Width (replaces the opposite corner) and Columns (No; Dynamic and Static say columns are not available yet). MText stores the line spacing style (DXF group 73) so Exactly round-trips. Co-authored-by: Claude Opus 5.5 --- crates/doc/src/entity.rs | 4 + crates/doc/src/tests.rs | 1 + crates/engine/src/cmd/annotate.rs | 2 + crates/engine/src/cmd/draw.rs | 86 ++++++---- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/modify.rs | 1 + crates/engine/src/cmd/mtext_opts.rs | 222 ++++++++++++++++++++++++++ crates/engine/src/sample.rs | 2 +- crates/engine/tests/mtext_options.rs | 136 ++++++++++++++++ crates/io/src/dxf_read.rs | 1 + crates/io/src/dxf_write.rs | 4 + crates/io/src/tests.rs | 3 + crates/io/tests/mtext_line_spacing.rs | 28 ++++ crates/render/src/tests.rs | 1 + 14 files changed, 464 insertions(+), 28 deletions(-) create mode 100644 crates/engine/src/cmd/mtext_opts.rs create mode 100644 crates/engine/tests/mtext_options.rs create mode 100644 crates/io/tests/mtext_line_spacing.rs diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index a78332ff..984720db 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -252,6 +252,10 @@ pub struct MText { pub contents: String, #[serde(default = "one")] pub line_spacing: f64, + /// Line spacing style (DXF 73): Exactly (`true`) keeps `line_spacing` whatever the character + /// heights; At least (`false`, the default) lets taller characters push lines apart. + #[serde(default)] + pub line_spacing_exact: bool, } fn attach_tl() -> u8 { 1 diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 0f8d9f04..d123d7ab 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -385,6 +385,7 @@ fn mtext_kind(contents: &str) -> EntityKind { style: "Standard".into(), contents: contents.into(), line_spacing: 1.0, + line_spacing_exact: false, }) } diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index 715513ca..a948ee92 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -485,6 +485,7 @@ fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option< style: st.text_style.clone(), contents: text.replace('\n', "\\P"), line_spacing: 1.0, + line_spacing_exact: false, }), style: st.name.clone(), arrow_size: st.arrow_size * k, @@ -525,6 +526,7 @@ fn run_leader(s: &mut Session, p: &Value) -> Result { style: "Standard".into(), contents: t.into(), line_spacing: 1.0, + line_spacing_exact: false, }))? .hex(), ); diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 65b997db..dfb7be58 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -2,7 +2,7 @@ //! SPLINE, DONUT, TEXT, MTEXT. Each has a JSON form and an interactive prompt sequence. use cadcraft_doc::{EntityKind, HAlign, MText, Point, RayLine, Text, VAlign}; -use cadcraft_geom::{Arc, Circle, Ellipse, PolyVertex, Polyline, Spline, Vec2, Vec3, arc_to_bulge, bulge_to_arc}; +use cadcraft_geom::{Arc, Circle, Ellipse, PolyVertex, Polyline, Spline, Vec2, arc_to_bulge, bulge_to_arc}; use serde_json::{Value, json}; use super::helpers::*; @@ -84,7 +84,7 @@ 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, rotation?}") + .params("{at, text, height?, width?, attach?: 1..9 | justify?: TL..BR, rotation?, style?, lineSpacing?, lineSpacingExact?}") .interactive(|_| Ok(Box::new(MTextM::default()))), ] } @@ -352,16 +352,24 @@ fn run_mtext(s: &mut Session, p: &Value) -> Result { if height <= 0.0 { return Err(bad("mtext", "height must be positive")); } - let style = s.doc()?.header.str("TEXTSTYLE", "Standard"); + let style = match str_param(p, "style") { + Some(n) => s.doc()?.text_style(n).map(|t| t.name.clone()).ok_or_else(|| bad("mtext", format!("no text style \"{n}\"")))?, + None => s.doc()?.header.str("TEXTSTYLE", "Standard"), + }; + let attach = match str_param(p, "justify") { + 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 k = EntityKind::MText(MText { insert: v3(at), height, width: f64_or(p, "width", 0.0).max(0.0), - attach: p.get("attach").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9) as u8, + attach, rotation: f64_or(p, "rotation", 0.0).to_radians(), style, contents: text.replace('\n', "\\P"), line_spacing: f64_or(p, "lineSpacing", 1.0), + line_spacing_exact: bool_or(p, "lineSpacingExact", false), }); added(s.add_entity(k)?) } @@ -1414,16 +1422,35 @@ struct MTextM { /// Paragraphs typed so far: one per input line, an empty line ends the text (as on /// AutoCAD's command line and in scripts). lines: Vec, + /// Height, justification, line spacing, rotation, style and width set by the options. + opts: super::mtext_opts::MTextOpts, + /// An option asking a further question. + ask: Option, +} + +impl MTextM { + /// The box is known: the opposite corner was picked or the Width option answered. + fn boxed(&self) -> bool { + self.second.is_some() || self.opts.width.is_some() + } } impl Interactive for MTextM { fn name(&self) -> &'static str { "MTEXT" } + fn begin(&mut self, s: &mut Session) -> Result { + self.opts = super::mtext_opts::MTextOpts::new(s); + s.echo(format!("Current text style: \"{}\" Text height: {:.4} Annotative: No", self.opts.style, self.opts.height)); + Ok(Step::Continue) + } fn prompt(&self, _s: &Session) -> Prompt { - match (self.first, self.second) { - (None, _) => Prompt::new("Specify first corner", Accept::POINT), - (Some(a), None) => Prompt::new("Specify opposite corner", Accept::POINT) + if let (Some(a), Some(q)) = (self.first, self.ask) { + return q.prompt(&self.opts, a); + } + match self.first { + None => Prompt::new("Specify first corner", Accept::POINT), + Some(a) if !self.boxed() => Prompt::new("Specify opposite corner", Accept::POINT) .kw(&["Height", "Justify", "Line spacing", "Rotation", "Style", "Width", "Columns"]) .base(a), _ if self.lines.is_empty() => Prompt::new("Enter text (use \\P for new paragraphs)", Accept::TEXT), @@ -1431,28 +1458,27 @@ impl Interactive for MTextM { } } fn input(&mut self, s: &mut Session, i: Input) -> Result { - match (self.first, self.second, i) { + if let (Some(a), Some(q)) = (self.first, self.ask) { + use super::mtext_opts::Outcome; + self.ask = match q.input(s, i, &mut self.opts, a)? { + Outcome::Ask(next) => Some(next), + Outcome::Back | Outcome::Text => None, + }; + return Ok(Step::Continue); + } + match (self.first, self.boxed(), i) { (None, _, Input::Point(p)) => self.first = Some(p), - (Some(_), None, Input::Point(p)) => self.second = Some(p), - (Some(_), Some(_), Input::Text(t)) if !t.is_empty() => self.lines.push(t), - (Some(a), Some(b), Input::Text(_) | Input::Enter) => { + (Some(_), false, Input::Point(p)) => self.second = Some(p), + (Some(_), false, Input::Keyword(k)) => { + self.ask = + Some(super::mtext_opts::MTextAsk::from_keyword(&k).ok_or_else(|| crate::EngineError::Other("Invalid option keyword.".into()))?); + } + (Some(_), true, Input::Text(t)) if !t.is_empty() => self.lines.push(t), + (Some(a), true, Input::Text(_) | Input::Enter) => { if self.lines.is_empty() { return Ok(Step::Done); } - let d = s.doc()?; - let h = d.header.f64("TEXTSIZE", 0.2); - let style = d.header.str("TEXTSTYLE", "Standard"); - let tl = Vec2::new(a.x.min(b.x), a.y.max(b.y)); - s.add_entity(EntityKind::MText(MText { - insert: Vec3::new(tl.x, tl.y, 0.0), - height: h, - width: (a.x - b.x).abs(), - attach: 1, - rotation: 0.0, - style, - contents: self.lines.join("\\P"), - line_spacing: 1.0, - }))?; + s.add_entity(EntityKind::MText(self.opts.mtext(a, self.second, self.lines.join("\\P"))))?; return Ok(Step::Done); } (_, _, Input::Enter) => return Ok(Step::Done), @@ -1461,8 +1487,14 @@ impl Interactive for MTextM { Ok(Step::Continue) } fn preview(&self, _s: &Session, c: Vec2) -> Vec { - match (self.first, self.second) { - (Some(a), None) => vec![lwpoly(rect_vertices(a, c), true)], + match (self.first, self.boxed(), self.ask) { + (Some(a), false, None) => { + // The box, turned by the rotation about the first corner. + let r = self.opts.rotation; + let l = (c - a).rotate(-r); + let corners = [Vec2::ZERO, Vec2::new(l.x, 0.0), l, Vec2::new(0.0, l.y)]; + vec![lwpoly(corners.iter().map(|q| PolyVertex::new(a + q.rotate(r))).collect(), true)] + } _ => Vec::new(), } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 85146881..eb454aa4 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -18,6 +18,7 @@ mod layer; mod layout; mod modify; mod modify2; +mod mtext_opts; mod pline_opts; mod props; mod qselect; diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index faf0d412..24381fe6 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -1250,6 +1250,7 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option) -> EngineError { + EngineError::Other(m.into()) +} + +/// The justification keywords, in attachment order 1..=9. +pub(super) const JUSTIFY: [&str; 9] = ["TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"]; + +/// Attachment code (1..=9) for a justification keyword. +pub(super) fn attach_code(k: &str) -> Option { + JUSTIFY.iter().position(|j| j.eq_ignore_ascii_case(k.trim())).map(|i| i as u8 + 1) +} + +/// What the options set before the text is typed. +pub(super) struct MTextOpts { + pub height: f64, + pub attach: u8, + /// Line spacing factor (1 = 5/3 of the text height between baselines). + pub spacing: f64, + pub exact: bool, + pub rotation: f64, + pub style: String, + /// The Width option replaces the opposite corner. + pub width: Option, +} + +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 } + } +} + +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 } + } + + /// The text box from the first corner and either the opposite corner or the Width answer: + /// the insertion point is the box's attachment point, the box turned by the rotation about + /// the first corner. + pub(super) fn mtext(&self, first: Vec2, second: Option, contents: String) -> MText { + let (insert, width) = match (second, self.width) { + (Some(b), _) => { + // The opposite corner in the box's own (rotated) frame. + let l = (b - first).rotate(-self.rotation); + let (x0, x1) = (l.x.min(0.0), l.x.max(0.0)); + let (y0, y1) = (l.y.min(0.0), l.y.max(0.0)); + let i = usize::from(self.attach.clamp(1, 9) - 1); + let x = [x0, (x0 + x1) / 2.0, x1][i % 3]; + let y = [y1, (y0 + y1) / 2.0, y0][i / 3]; + (first + Vec2::new(x, y).rotate(self.rotation), x1 - x0) + } + (None, w) => (first, w.unwrap_or(0.0)), + }; + MText { + insert: Vec3::new(insert.x, insert.y, 0.0), + height: self.height, + width, + attach: self.attach.clamp(1, 9), + rotation: self.rotation, + style: self.style.clone(), + contents, + line_spacing: self.spacing, + line_spacing_exact: self.exact, + } + } +} + +/// A question an option asks before MTEXT goes back to "Specify opposite corner". +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum MTextAsk { + Height, + Justify, + SpacingType, + SpacingFactor, + Rotation, + Style, + Width, + Columns, +} + +pub(super) enum Outcome { + /// Ask this next (the same question again after an invalid answer). + Ask(MTextAsk), + /// Back to the opposite-corner prompt. + Back, + /// The Width answer replaces the opposite corner: go on to the text. + Text, +} + +impl MTextAsk { + pub(super) fn from_keyword(k: &str) -> Option { + Some(match k { + "Height" => MTextAsk::Height, + "Justify" => MTextAsk::Justify, + "Line spacing" => MTextAsk::SpacingType, + "Rotation" => MTextAsk::Rotation, + "Style" => MTextAsk::Style, + "Width" => MTextAsk::Width, + "Columns" => MTextAsk::Columns, + _ => return None, + }) + } + + pub(super) fn prompt(self, o: &MTextOpts, first: Vec2) -> Prompt { + match self { + MTextAsk::Height => Prompt::new("Specify height", Accept::POINT_OR_NUMBER).default(format!("{:.4}", o.height)).base(first), + MTextAsk::Justify => Prompt::new("Enter justification", curves::KW) + .kw(&JUSTIFY) + .default(JUSTIFY.get(usize::from(o.attach.clamp(1, 9) - 1)).copied().unwrap_or("TL")), + MTextAsk::SpacingType => { + Prompt::new("Enter line spacing type", curves::KW).kw(&["At least", "Exactly"]).default(if o.exact { "Exactly" } else { "At least" }) + } + MTextAsk::SpacingFactor => Prompt::new("Enter line spacing factor or distance", Accept::NUMBER).default(format!("{}x", trim(o.spacing))), + 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"), + } + } + + pub(super) fn input(self, s: &mut Session, i: Input, o: &mut MTextOpts, first: Vec2) -> Result { + let word = |i: &Input| match i { + Input::Keyword(k) | Input::Text(k) => Some(k.trim().to_string()), + _ => None, + }; + 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)), + (MTextAsk::Height, Input::Text(t)) => set_height(s, o, number(t).unwrap_or(f64::NAN)), + (MTextAsk::Justify, _) => { + let k = word(&i).unwrap_or_default(); + o.attach = attach_code(&k).ok_or_else(|| other("Invalid option keyword."))?; + Ok(Outcome::Back) + } + (MTextAsk::SpacingType, _) => { + let k = word(&i).unwrap_or_default().to_ascii_lowercase(); + o.exact = match k.as_str() { + "at least" | "a" | "at" => false, + "exactly" | "e" => true, + _ => return Err(other("Invalid option keyword.")), + }; + Ok(Outcome::Ask(MTextAsk::SpacingFactor)) + } + (MTextAsk::SpacingFactor, Input::Text(t)) => { + // "1.5x" is a factor; a plain number is the distance between lines (1x = 5/3 of the height). + let t = t.trim(); + let f = match t.strip_suffix(['x', 'X']) { + Some(f) => number(f), + None => number(t).map(|d| d / (o.height * 5.0 / 3.0)), + }; + o.spacing = f + .filter(|f| (0.25 - 1e-9..=4.0 + 1e-9).contains(f)) + .ok_or_else(|| other("Requires a factor between 0.25x and 4x, or a distance."))?; + Ok(Outcome::Back) + } + (MTextAsk::Rotation, Input::Point(p)) => { + o.rotation = first.angle_to(*p); + Ok(Outcome::Back) + } + (MTextAsk::Rotation, Input::Text(t)) => { + o.rotation = s.angle_settings().direction(t).filter(|a| a.is_finite()).ok_or_else(|| other("Requires an angle."))?; + Ok(Outcome::Back) + } + (MTextAsk::Style, _) => { + let name = word(&i).unwrap_or_default(); + let d = s.doc()?; + if name == "?" { + let names: Vec = d.text_styles.iter().map(|t| t.name.clone()).collect(); + s.echo(format!("Text styles: {}", names.join(", "))); + return Ok(Outcome::Ask(MTextAsk::Style)); + } + o.style = d.text_style(&name).map(|t| t.name.clone()).ok_or_else(|| other(format!("Cannot find text style \"{name}\".")))?; + Ok(Outcome::Back) + } + (MTextAsk::Width, Input::Point(p)) => { + // The distance along the text direction from the first corner. + o.width = Some((*p - first).rotate(-o.rotation).x.abs()); + Ok(Outcome::Text) + } + (MTextAsk::Width, Input::Text(t)) => { + 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)), + } + } +} + +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.")); + } + o.height = h; + s.doc_mut()?.header.set_f64("TEXTSIZE", h); + Ok(Outcome::Back) +} + +/// A number without trailing zeros (`1.5`, `2`). +fn trim(v: f64) -> String { + let s = format!("{v:.4}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() +} diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 772b4dc4..2eb7c4d0 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -208,7 +208,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 }), + 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 }), ); 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_options.rs b/crates/engine/tests/mtext_options.rs new file mode 100644 index 00000000..cc13d049 --- /dev/null +++ b/crates/engine/tests/mtext_options.rs @@ -0,0 +1,136 @@ +//! MTEXT's options before the text: Height, Justify, Line spacing, Rotation, Style, Width and +//! Columns, from the command line and scripts (#408). + +use cadcraft_doc::{EntityKind, MText, TextStyle}; +use cadcraft_engine::Session; +use cadcraft_geom::Vec2; +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:?}"), + } +} + +fn logged(s: &Session, text: &str) -> bool { + s.log.iter().any(|l| l.contains(text)) +} + +fn at(t: &MText) -> Vec2 { + t.insert.xy() +} + +#[test] +fn height_justify_and_corners() { + let mut s = Session::new(); + s.script("MTEXT 0,10 h 0.5 j mc 20,0\nHello\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let t = last_mtext(&s); + assert_eq!(t.height, 0.5); + assert_eq!(t.attach, 5); + assert_eq!(t.width, 20.0); + assert!(at(&t).near(Vec2::new(10.0, 5.0), 1e-9), "middle centre of the box: {:?}", at(&t)); + assert_eq!(t.contents, "Hello"); + // The height becomes the default for the next text. + s.cmdline("mtext 0,0 h").unwrap(); + assert!(s.prompt_text().contains("0.5000"), "{}", s.prompt_text()); + // A picked height is the distance from the first corner. + s.cmdline("0,2").unwrap(); + s.cmdline("j br 30,-10").unwrap(); + s.cmdline("x").unwrap(); + s.cmdline("").unwrap(); + let t = last_mtext(&s); + assert_eq!(t.height, 2.0); + assert_eq!(t.attach, 9); + assert!(at(&t).near(Vec2::new(30.0, -10.0), 1e-9)); + // Bad answers are reported and asked again. + s.cmdline("mtext 0,0 j zz").unwrap(); + assert!(logged(&s, "Invalid option keyword.")); + s.cmdline("tl h -1").unwrap(); + assert!(logged(&s, "Requires a positive height.")); + s.cmdline("*cancel*").ok(); +} + +#[test] +fn line_spacing_type_and_factor_or_distance() { + let mut s = Session::new(); + s.script("MTEXT 0,10 l e 1.5x 20,0\nA\nB\n\n").unwrap(); + let t = last_mtext(&s); + assert!(t.line_spacing_exact); + assert_eq!(t.line_spacing, 1.5); + // A distance: 1x is 5/3 of the height (0.2 here), so 2/3 is 2x. + s.script("MTEXT 0,10 l a 0.6667 20,0\nA\n\n").unwrap(); + let t = last_mtext(&s); + assert!(!t.line_spacing_exact); + assert!((t.line_spacing - 2.0).abs() < 1e-3, "{}", t.line_spacing); + // Out of range. + s.cmdline("mtext 0,0 l e 9x").unwrap(); + assert!(logged(&s, "Requires a factor between 0.25x and 4x")); + s.cmdline("*cancel*").ok(); +} + +#[test] +fn rotation_turns_the_box() { + let mut s = Session::new(); + // Rotated 90 degrees: the box's width runs up from the first corner, its top edge through it. + s.script("MTEXT 0,0 r 90 5,20\nUp\n\n").unwrap(); + let t = last_mtext(&s); + assert!((t.rotation - std::f64::consts::FRAC_PI_2).abs() < 1e-9); + assert!((t.width - 20.0).abs() < 1e-9, "{}", t.width); + assert!(at(&t).near(Vec2::new(0.0, 0.0), 1e-9), "top left stays at the first corner: {:?}", at(&t)); + // A picked rotation is the angle from the first corner. + s.script("MTEXT 1,1 r 2,2 10,0\nDiag\n\n").unwrap(); + assert!((last_mtext(&s).rotation - std::f64::consts::FRAC_PI_4).abs() < 1e-9); +} + +#[test] +fn width_replaces_the_opposite_corner() { + let mut s = Session::new(); + s.script("MTEXT 2,3 j tc w 12\nCentred\nTwo lines\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let t = last_mtext(&s); + assert_eq!(t.width, 12.0); + assert_eq!(t.attach, 2); + assert!(at(&t).near(Vec2::new(2.0, 3.0), 1e-9), "the first point is the insertion point"); + assert_eq!(t.contents, "Centred\\PTwo lines"); + // Picked: the distance along the text direction. + s.script("MTEXT 0,0 w 7,4\nX\n\n").unwrap(); + assert_eq!(last_mtext(&s).width, 7.0); + // Zero: no wrapping. + s.script("MTEXT 0,0 w 0\nY\n\n").unwrap(); + assert_eq!(last_mtext(&s).width, 0.0); +} + +#[test] +fn style_and_columns() { + let mut s = Session::new(); + s.doc_mut().unwrap().text_styles.push(TextStyle { name: "Notes".into(), ..TextStyle::default() }); + s.script("MTEXT 0,10 s notes 20,0\nStyled\n\n").unwrap(); + assert_eq!(last_mtext(&s).style, "Notes"); + s.cmdline("mtext 0,0 s nosuch").unwrap(); + assert!(logged(&s, "Cannot find text style \"nosuch\".")); + 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")); + 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"); +} + +#[test] +fn json_form_takes_justify_style_and_spacing() { + let mut s = Session::new(); + s.execute("mtext", &json!({"at": [1, 2], "text": "a\nb", "justify": "BR", "lineSpacing": 1.25, "lineSpacingExact": true})).unwrap(); + let t = last_mtext(&s); + assert_eq!(t.attach, 9); + assert!(t.line_spacing_exact && t.line_spacing == 1.25); + assert!(s.execute("mtext", &json!({"at": [1, 2], "text": "a", "justify": "XX"})).is_err()); + assert!(s.execute("mtext", &json!({"at": [1, 2], "text": "a", "style": "missing"})).is_err()); +} diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index c4e9daf6..e8172dc3 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -308,6 +308,7 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { 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), }) } "ATTDEF" => EntityKind::AttDef(Attrib { diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 8bb55b47..5c12f1d4 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -549,6 +549,8 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx if t.rotation != 0.0 { w.f(50, t.rotation.to_degrees()); } + // 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); } EntityKind::Insert(i) => { @@ -993,6 +995,7 @@ fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec { style: style.text_style.clone(), contents: g.value.clone(), line_spacing: 1.0, + line_spacing_exact: false, }), }); } @@ -1058,6 +1061,7 @@ fn table_block_entities(d: &Drawing, t: &Table, layer: &str) -> Vec { style: "Standard".into(), contents: cell.text.clone(), line_spacing: 1.0, + line_spacing_exact: false, }), }); } diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index cdd7f69b..98a18b1d 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -52,6 +52,7 @@ fn sample() -> Drawing { style: "Standard".into(), contents: "line one\\Pline two".into(), line_spacing: 1.0, + line_spacing_exact: false, }), ) .unwrap(); @@ -1127,6 +1128,7 @@ fn intl_sample() -> Drawing { style: "Standard".into(), contents: format!("{INTL}\\P第二行 Ünïcødé"), line_spacing: 1.0, + line_spacing_exact: false, }), ) .unwrap(); @@ -1348,6 +1350,7 @@ fn mleader_is_written_as_leader_and_mtext() { contents: "Note".into(), style: "Standard".into(), line_spacing: 1.0, + line_spacing_exact: false, }; let m = cadcraft_doc::MLeader { leaders: vec![vec![Vec3::new(0.0, 0.0, 0.0)]], diff --git a/crates/io/tests/mtext_line_spacing.rs b/crates/io/tests/mtext_line_spacing.rs new file mode 100644 index 00000000..70c82561 --- /dev/null +++ b/crates/io/tests/mtext_line_spacing.rs @@ -0,0 +1,28 @@ +//! MTEXT line spacing style (group 73: 1 at least, 2 exactly) round-trips through DXF (#408). + +use cadcraft_doc::{Common, Drawing, EntityKind, MText, Space}; +use cadcraft_geom::Vec3; + +#[test] +fn line_spacing_style_roundtrips() { + for exact in [false, true] { + let mut d = Drawing::new_metric(); + let t = MText { + insert: Vec3::new(1.0, 2.0, 0.0), + height: 2.5, + width: 40.0, + attach: 5, + rotation: 0.0, + style: "Standard".into(), + contents: "a\\Pb".into(), + line_spacing: 1.5, + line_spacing_exact: exact, + }; + d.add(&Space::Model, Common::default(), EntityKind::MText(t.clone())).unwrap(); + let bytes = cadcraft_io::write(&d, "a.dxf").unwrap(); + let back = cadcraft_io::read(&bytes, "a.dxf").unwrap(); + let Some(EntityKind::MText(r)) = back.model.iter().map(|e| e.kind.clone()).next() else { panic!("no mtext") }; + assert_eq!(r.line_spacing_exact, exact); + assert_eq!(r.line_spacing, 1.5); + } +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 9c72acdd..3551c575 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -284,6 +284,7 @@ fn truetype_mtext_and_dimension_text() { style: "Standard".into(), contents: "A{\\C1;B}\\P\\S1/2;".into(), line_spacing: 1.0, + line_spacing_exact: false, }), ) .unwrap();