mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
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:
1 parent
a4504cc098
commit
8005201db9
27 files changed
+1495
-24
No files matched your search
@@ -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) => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -386,6 +386,7 @@ fn mtext_kind(contents: &str) -> EntityKind {
|
||||
contents: contents.into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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)?)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(¶s, style, num("indent")?.unwrap_or(3.0)));
|
||||
}
|
||||
let mut props = ParaProps {
|
||||
left: num("indent")?.unwrap_or(0.0).max(0.0),
|
||||
first: num("firstIndent")?.unwrap_or(0.0),
|
||||
right: num("rightIndent")?.unwrap_or(0.0).max(0.0),
|
||||
..ParaProps::default()
|
||||
};
|
||||
if let Some(a) = p.get("align") {
|
||||
props.align = match a.as_str().unwrap_or("").to_ascii_lowercase().as_str() {
|
||||
"left" | "l" => ParaAlign::Left,
|
||||
"center" | "centre" | "c" => ParaAlign::Center,
|
||||
"right" | "r" => ParaAlign::Right,
|
||||
"justify" | "j" => ParaAlign::Justify,
|
||||
"distributed" | "d" => ParaAlign::Distributed,
|
||||
_ => return Err(bad("`align` must be left, center, right, justify or distributed")),
|
||||
};
|
||||
}
|
||||
if let Some(t) = p.get("tabs") {
|
||||
let stops = t.as_array().ok_or_else(|| bad("`tabs` must be an array of numbers"))?;
|
||||
for v in stops.iter().take(64) {
|
||||
let v = v.as_f64().filter(|v| v.is_finite() && (0.0..=1000.0).contains(v)).ok_or_else(|| bad("`tabs` must be an array of numbers"))?;
|
||||
props.tabs.push((v, TabKind::Left));
|
||||
}
|
||||
props.tabs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
}
|
||||
if props == ParaProps::default() {
|
||||
return Ok(text);
|
||||
}
|
||||
Ok(format!("{}{text}", props.code()))
|
||||
}
|
||||
|
||||
/// The JSON form's `columns`: `{type: static|dynamic, count?, gutter?, height, heights?,
|
||||
/// reversed?}`; the MTEXT's `width` is the width of one column.
|
||||
pub(super) fn json_columns(p: &serde_json::Value, text_height: f64, width: f64) -> Result<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),
|
||||
}))
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(¢re) + max_x(¢re)) / 2.0;
|
||||
assert!((c - 5.0).abs() < 0.5, "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn justified_lines_fill_the_width_but_the_last() {
|
||||
let l = lay("\\pqj;aa bb cc dd ee ff gg hh ii jj kk", 10.0, None);
|
||||
assert!(l.lines.len() >= 2);
|
||||
let first_line_max = l.strokes.iter().flatten().filter(|p| p.y > -1.3).map(|p| p.x).fold(f64::MIN, f64::max);
|
||||
assert!(first_line_max > 9.5, "{first_line_max}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_flows_through_static_columns() {
|
||||
let spec = MTextColumnSpec { count: 3, gutter: 2.0, height: 6.0, ..Default::default() };
|
||||
// Two words a line, four lines a column (1 + 3 × 5/3 = 6): 20 words fill two and a half.
|
||||
let l = lay(&"word ".repeat(20), 10.0, Some(spec.clone()));
|
||||
assert!((l.bounds.width() - 34.0).abs() < 1e-9);
|
||||
assert!((l.bounds.height() - 6.0).abs() < 1e-9);
|
||||
for col in 0..3 {
|
||||
let x0 = col as f64 * 12.0;
|
||||
assert!(xs(&l).iter().any(|x| *x >= x0 && *x <= x0 + 10.0), "column {col} has text");
|
||||
}
|
||||
// The gutters stay empty.
|
||||
assert!(!xs(&l).iter().any(|x| *x > 10.0 + 1e-9 && *x < 12.0 - 1e-9));
|
||||
// Text past the last static column runs on below it.
|
||||
let over = lay(&"word ".repeat(40), 10.0, Some(spec));
|
||||
assert!(over.bounds.height() > 6.0 && (over.bounds.width() - 34.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_columns_add_columns_and_column_breaks_force_one() {
|
||||
let spec = MTextColumnSpec { dynamic: true, gutter: 1.0, height: 3.0, ..Default::default() };
|
||||
// Two lines fit in 3 (1 + 5/3): five paragraphs take three columns.
|
||||
let l = lay("one\\Ptwo\\Pthree\\Pfour\\Pfive", 5.0, Some(spec.clone()));
|
||||
assert!((l.bounds.width() - 17.0).abs() < 1e-9, "{:?}", l.bounds);
|
||||
let b = lay("one\\Ntwo", 5.0, Some(spec));
|
||||
assert!((b.bounds.width() - 11.0).abs() < 1e-9, "the column break starts a second column");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversed_columns_run_right_to_left() {
|
||||
let spec = MTextColumnSpec { count: 2, height: 1.0, reversed: true, ..Default::default() };
|
||||
let l = lay("first\\Nsecond", 10.0, Some(spec));
|
||||
assert_eq!(l.lines, vec!["first", "second"]);
|
||||
let first = l.strokes.iter().flatten().filter(|p| p.x >= 10.0).count();
|
||||
let second = l.strokes.iter().flatten().filter(|p| p.x < 10.0).count();
|
||||
assert!(first > 0 && second > 0 && min_x(&l) >= 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_paragraph_codes_do_not_panic() {
|
||||
let huge_tabs = format!("\\pt{};a\tb", (0..100_000).map(|i| i.to_string()).collect::<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() }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(|| {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)]],
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -867,6 +867,14 @@ pub fn layout_mtext_entity(d: &Drawing, t: &cadcraft_doc::MText) -> cadcraft_fon
|
||||
font: cadcraft_fonts::TextFont::resolve(&ts.font),
|
||||
width_factor: ts.width_factor,
|
||||
oblique: ts.oblique,
|
||||
columns: t.columns.as_ref().map(|c| cadcraft_fonts::MTextColumnSpec {
|
||||
dynamic: c.kind == cadcraft_doc::MTextColumnType::Dynamic,
|
||||
count: usize::from(c.count.max(1)),
|
||||
gutter: c.gutter,
|
||||
height: c.height,
|
||||
heights: if c.kind == cadcraft_doc::MTextColumnType::Dynamic && !c.auto_height { c.heights.clone() } else { Vec::new() },
|
||||
reversed: c.flow_reversed,
|
||||
}),
|
||||
};
|
||||
cadcraft_fonts::layout_mtext_with(&t.contents, ¶ms)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 Об'єктна прив'язка
|
||||
|
||||
|
Reference in new issue
Block a user