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 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein
2026-10-10 19:19:53 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ee34f423cc
commit 3ffe1ca2af
14 changed files with 464 additions and 28 deletions
+4
View File
@@ -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
+1
View File
@@ -385,6 +385,7 @@ fn mtext_kind(contents: &str) -> EntityKind {
style: "Standard".into(),
contents: contents.into(),
line_spacing: 1.0,
line_spacing_exact: false,
})
}
+2
View File
@@ -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<Value> {
style: "Standard".into(),
contents: t.into(),
line_spacing: 1.0,
line_spacing_exact: false,
}))?
.hex(),
);
+59 -27
View File
@@ -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> {
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<Value> {
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<String>,
/// 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<super::mtext_opts::MTextAsk>,
}
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<Step> {
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<Step> {
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<EntityKind> {
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(),
}
}
+1
View File
@@ -18,6 +18,7 @@ mod layer;
mod layout;
mod modify;
mod modify2;
mod mtext_opts;
mod pline_opts;
mod props;
mod qselect;
+1
View File
@@ -1250,6 +1250,7 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
style: style.text_style.clone(),
contents: g.value.clone(),
line_spacing: 1.0,
line_spacing_exact: false,
}),
});
}
+222
View File
@@ -0,0 +1,222 @@
//! MTEXT's options before the text (Height, Justify, Line spacing, Rotation, Style, Width,
//! Columns) for the prompt machine in `draw.rs`.
use cadcraft_doc::MText;
use cadcraft_geom::{Vec2, Vec3};
use super::curves;
use super::machines::number;
use crate::{Accept, EngineError, Input, Prompt, Result, Session};
fn other(m: impl Into<String>) -> 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<u8> {
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<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 }
}
}
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<Vec2>, 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<MTextAsk> {
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<Outcome> {
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<String> = 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<Outcome> {
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()
}
+1 -1
View File
@@ -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));
+136
View File
@@ -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());
}
+1
View File
@@ -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 {
+4
View File
@@ -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<Entity> {
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<Entity> {
style: "Standard".into(),
contents: cell.text.clone(),
line_spacing: 1.0,
line_spacing_exact: false,
}),
});
}
+3
View File
@@ -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)]],
+28
View File
@@ -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);
}
}
+1
View File
@@ -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();