feat(text): MTEXT paragraph formatting (indents, tabs, lists) and columns (#547)

Squash of PR #547 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 12:47:12 -04:00
1 parent a4504cc098
commit 8005201db9
27 files changed
+1495 -24

No files matched your search

+58
View File
@@ -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<MTextColumns>,
}
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<f64>,
/// 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) => {
+7 -2
View File
@@ -73,8 +73,13 @@ fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<String,
EntityKind::MText(t) => {
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),
+1
View File
@@ -386,6 +386,7 @@ fn mtext_kind(contents: &str) -> EntityKind {
contents: contents.into(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
})
}
+2
View File
@@ -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<Value> {
contents: t.into(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}))?
.hex(),
);
+9 -3
View File
@@ -84,7 +84,11 @@ 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 | 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<Value> {
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)?)
}
+1
View File
@@ -1274,6 +1274,7 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
contents: g.value.clone(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}),
});
}
+243 -12
View File
@@ -1,7 +1,7 @@
//! 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_doc::{MText, MTextColumnType, MTextColumns};
use cadcraft_geom::{Vec2, Vec3};
use super::curves;
@@ -31,11 +31,26 @@ pub(super) struct MTextOpts {
pub style: String,
/// The Width option replaces the opposite corner.
pub width: Option<f64>,
/// Columns set by the Columns option (static or dynamic); their answers replace the
/// opposite corner too.
pub columns: Option<MTextColumns>,
/// 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<Outcome> {
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<f64>, down: bool| -> Result<f64> {
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<Outcome> {
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<String> {
use cadcraft_fonts::{ListStyle, ParaAlign, ParaProps, TabKind};
let bad = |m: &str| super::bad("mtext", m);
let num = |k: &str| -> Result<Option<f64>> {
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(&paras, 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<Option<MTextColumns>> {
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<f64> = 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),
}))
}
+1 -1
View File
@@ -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));
+81
View File
@@ -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<f64> = 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:?}");
}
+2 -3
View File
@@ -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]
+1
View File
@@ -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;
+8
View File
@@ -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<para::MTextColumnSpec>,
}
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<Line> = Vec::new();
let mut line = Line::default();
+679
View File
@@ -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<f64>,
/// 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<f64> {
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::<String>() + "."
};
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.<tab>First\P2.<tab>Second`).
pub fn list_contents(paragraphs: &[&str], style: ListStyle, indent: f64) -> String {
let indent = if indent.is_finite() && indent > 0.0 { indent.min(MAX_UNITS) } else { 3.0 };
let props = ParaProps { first: -indent, left: indent, tabs: vec![(indent, TabKind::Left)], ..ParaProps::default() };
let items: Vec<String> = 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<ParaProps>,
/// Index into `states` per paragraph.
para: Vec<usize>,
/// Per paragraph break: a column break.
column_break: Vec<bool>,
}
impl Paragraphs {
pub fn scan(contents: &str) -> Paragraphs {
let chars: Vec<char> = 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<Item>,
/// 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<Part> {
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<PLine> = 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<usize> = None;
let mut last_fmt = 0usize;
let finish = |lines: &mut Vec<PLine>, 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<usize> = 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);
}
}
+2
View File
@@ -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)
+137
View File
@@ -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<MTextColumnSpec>) -> MTextLayout {
layout_mtext_with(s, &MTextParams { width, columns, ..MTextParams::new(Vec2::ZERO, 1.0) })
}
fn xs(l: &MTextLayout) -> Vec<f64> {
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(&centre) + max_x(&centre)) / 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::<Vec<_>>().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() }),
);
}
}
+1
View File
@@ -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(|| {
+10 -2
View File
@@ -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 {
+120
View File
@@ -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<f64> = 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<Tag> {
let Some(c) = &t.columns else { return Vec::new() };
let manual = c.kind == MTextColumnType::Dynamic && !c.auto_height;
let heights: Vec<f64> = 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
}
+4 -1
View File
@@ -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<char> = t.contents.chars().collect();
let chars: Vec<char> = crate::dxf_read::mtext_dxf::encode_contents(&t.contents).chars().collect();
let chunks: Vec<String> = 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<Entity> {
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<Entity> {
contents: cell.text.clone(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}),
});
}
+3
View File
@@ -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)]],
+1
View File
@@ -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,
@@ -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<MTextColumns>) -> 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());
}
}
+1
View File
@@ -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();
+8
View File
@@ -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, &params)
}
+1
View File
@@ -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();
+18
View File
@@ -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<String> =
buf.split('\n').enumerate().map(|(i, l)| if numbered { format!("{}.\t{l}", i + 1) } else { format!("•\t{l}") }).collect();
format!("\\pxi-3,l3,t3;{}", lines.join("\n"))
}
/// 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)
+3
View File
@@ -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 Об'єктна прив'язка
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
108
109
110
111
112
113
114
395
396
397
398
399
400
401
537
538
539
540
541
542
543