mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-19 02:09:48 -05:00
TXT2MTXT: selected TEXT combined into one MTEXT top-down, or one MTEXT per TEXT, with SEttings (#1343)
Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
8 files changed
+515
-1
No files matched your search
@@ -40,6 +40,8 @@ mod spline_opts;
|
||||
mod table;
|
||||
mod tolerance;
|
||||
mod trimextend;
|
||||
mod txt2mtxt;
|
||||
pub use txt2mtxt::Txt2MtxtOptions;
|
||||
mod utility;
|
||||
mod view;
|
||||
|
||||
@@ -201,6 +203,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(utility::specs());
|
||||
v.extend(table::specs());
|
||||
v.extend(tolerance::specs());
|
||||
v.extend(txt2mtxt::specs());
|
||||
v.extend(constraints::specs());
|
||||
v.extend(lisp::specs());
|
||||
v
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
//! TXT2MTXT (Express Tools): single-line TEXT, and MTEXT when combining, turned into MTEXT.
|
||||
//!
|
||||
//! Combined, the texts become one MTEXT in place of the first of them (its handle, layer and
|
||||
//! other properties kept), one line each, from the top down or in selection order, at the top
|
||||
//! left of their extents. Otherwise each TEXT becomes its own MTEXT, attached at the point it
|
||||
//! is justified by.
|
||||
|
||||
use cadcraft_doc::{EntityKind, HAlign, Handle, MText, Text, VAlign, entity_bounds, mtext_literal};
|
||||
use cadcraft_geom::Bounds2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::curves::KW;
|
||||
use super::helpers::v3;
|
||||
use super::machines::{SelOutcome, SelectPhase};
|
||||
use super::{CommandSpec, bad, bool_or, str_param, targets};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("txt2mtxt", "Convert Text to Mtext", run_txt2mtxt)
|
||||
.menu(&["Tools", "Express Tools", "Convert Text to Mtext"])
|
||||
.params(
|
||||
"{handles? (default the selection; TEXT, and MTEXT when combining), combine? (default true: one MTEXT; false: one per TEXT), \
|
||||
sort?: \"topdown\" (default) | \"selection\", wrap? (default false: one line per text)} → {handles}",
|
||||
)
|
||||
.interactive(|s| Ok(Box::new(Txt2MtxtM::new(s)))),
|
||||
]
|
||||
}
|
||||
|
||||
/// TXT2MTXT's settings: one MTEXT or one per TEXT, the order of the lines and word wrap.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Txt2MtxtOptions {
|
||||
pub combine: bool,
|
||||
pub top_down: bool,
|
||||
pub wrap: bool,
|
||||
}
|
||||
|
||||
impl Default for Txt2MtxtOptions {
|
||||
fn default() -> Self {
|
||||
Txt2MtxtOptions { combine: true, top_down: true, wrap: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts the TEXT (and, combining, MTEXT) objects among `hs`; objects on locked layers are
|
||||
/// left alone. Returns the MTEXT objects made and how many texts were on a locked layer.
|
||||
fn convert(s: &mut Session, hs: &[Handle], o: &Txt2MtxtOptions) -> Result<(Vec<Handle>, usize)> {
|
||||
let d = s.doc()?;
|
||||
let mut locked = 0;
|
||||
let mut items: Vec<(Handle, EntityKind, Bounds2)> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for h in hs {
|
||||
let Some(e) = d.entity(*h) else { continue };
|
||||
let wanted = match &e.kind {
|
||||
EntityKind::Text(_) => true,
|
||||
EntityKind::MText(_) => o.combine,
|
||||
_ => false,
|
||||
};
|
||||
if !wanted || !seen.insert(*h) {
|
||||
continue;
|
||||
}
|
||||
if d.layer(&e.common.layer).is_some_and(|l| l.locked) {
|
||||
locked += 1;
|
||||
continue;
|
||||
}
|
||||
items.push((*h, e.kind.clone(), entity_bounds(d, e, 0)));
|
||||
}
|
||||
if !o.combine {
|
||||
let mut made = Vec::new();
|
||||
for (h, k, _) in items {
|
||||
if let EntityKind::Text(t) = k {
|
||||
s.doc_mut()?.modify_entity(h, |e| e.kind = EntityKind::MText(single(&t)))?;
|
||||
made.push(h);
|
||||
}
|
||||
}
|
||||
return Ok((made, locked));
|
||||
}
|
||||
if o.top_down {
|
||||
// Highest first; a line shared by several texts reads left to right.
|
||||
items.sort_by(|a, b| {
|
||||
let (pa, pb) = (insertion(&a.1), insertion(&b.1));
|
||||
pb.y.total_cmp(&pa.y).then(pa.x.total_cmp(&pb.x))
|
||||
});
|
||||
}
|
||||
// A lone MTEXT is already what TXT2MTXT would make of it.
|
||||
if let [(_, EntityKind::MText(_), _)] = &items[..] {
|
||||
return Ok((Vec::new(), locked));
|
||||
}
|
||||
let Some((first, k0, _)) = items.first() else { return Ok((Vec::new(), locked)) };
|
||||
let (height, style, rotation) = match k0 {
|
||||
EntityKind::Text(t) => (t.height, t.style.clone(), t.rotation),
|
||||
EntityKind::MText(m) => (m.height, m.style.clone(), m.rotation),
|
||||
_ => return Ok((Vec::new(), locked)),
|
||||
};
|
||||
let ext = items.iter().fold(Bounds2::EMPTY, |b, (.., x)| b.union(x));
|
||||
let corner = if ext.is_empty() { insertion(k0) } else { cadcraft_geom::Vec2::new(ext.min.x, ext.max.y) };
|
||||
let sep = if o.wrap { " " } else { "\\P" };
|
||||
let contents = items.iter().map(|(_, k, _)| contents(k, height)).collect::<Vec<_>>().join(sep);
|
||||
let width = if o.wrap { items.iter().map(|(_, k, _)| line_width(k)).fold(0.0, f64::max) } else { 0.0 };
|
||||
let m = MText {
|
||||
insert: v3(corner),
|
||||
height,
|
||||
width,
|
||||
attach: 1,
|
||||
rotation,
|
||||
style,
|
||||
contents,
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
};
|
||||
let first = *first;
|
||||
let rest: Vec<Handle> = items.iter().skip(1).map(|(h, ..)| *h).collect();
|
||||
let d = s.doc_mut()?;
|
||||
d.modify_entity(first, |e| e.kind = EntityKind::MText(m))?;
|
||||
for h in rest {
|
||||
d.remove_entity(h);
|
||||
}
|
||||
Ok((vec![first], locked))
|
||||
}
|
||||
|
||||
/// The point a TEXT or MTEXT is sorted by: its insertion point.
|
||||
fn insertion(k: &EntityKind) -> cadcraft_geom::Vec2 {
|
||||
match k {
|
||||
EntityKind::Text(t) => t.insert.xy(),
|
||||
EntityKind::MText(m) => m.insert.xy(),
|
||||
_ => cadcraft_geom::Vec2::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// A text's lines as MTEXT contents of base height `base`: the TEXT's characters read literally
|
||||
/// (its width factor and oblique angle as inline codes), an MTEXT's contents as they are; either
|
||||
/// keeps its own height with an absolute `\H` code when that differs from `base`.
|
||||
fn contents(k: &EntityKind, base: f64) -> String {
|
||||
match k {
|
||||
EntityKind::Text(t) => text_contents(t, base),
|
||||
EntityKind::MText(m) if differs(m.height, base) => format!("{{\\H{};{}}}", m.height, m.contents),
|
||||
EntityKind::MText(m) => m.contents.clone(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a line of height `h` needs its own height code in an MTEXT of height `base`.
|
||||
fn differs(h: f64, base: f64) -> bool {
|
||||
h.is_finite() && h > 0.0 && (h - base).abs() > base.abs() * 1e-9
|
||||
}
|
||||
|
||||
fn text_contents(t: &Text, base: f64) -> String {
|
||||
let mut codes = String::new();
|
||||
if differs(t.height, base) {
|
||||
codes.push_str(&format!("\\H{};", t.height));
|
||||
}
|
||||
if t.width_factor.is_finite() && t.width_factor > 0.0 && (t.width_factor - 1.0).abs() > 1e-9 {
|
||||
codes.push_str(&format!("\\W{};", t.width_factor));
|
||||
}
|
||||
if t.oblique.is_finite() && t.oblique.abs() > 1e-9 {
|
||||
codes.push_str(&format!("\\Q{};", t.oblique.to_degrees()));
|
||||
}
|
||||
let lit = mtext_literal(&t.value);
|
||||
if codes.is_empty() { lit } else { format!("{{{codes}{lit}}}") }
|
||||
}
|
||||
|
||||
/// How wide a text's longest line is in the drafting font: word wrap's MTEXT width.
|
||||
fn line_width(k: &EntityKind) -> f64 {
|
||||
let w = match k {
|
||||
EntityKind::Text(t) => cadcraft_fonts::line_width(&t.value, t.height, t.width_factor),
|
||||
EntityKind::MText(m) if m.width > 0.0 => m.width,
|
||||
EntityKind::MText(m) => {
|
||||
cadcraft_fonts::plain_mtext(&m.contents).lines().map(|l| cadcraft_fonts::line_width(l, m.height, 1.0)).fold(0.0, f64::max)
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
if w.is_finite() { w } else { 0.0 }
|
||||
}
|
||||
|
||||
/// One TEXT as an MTEXT of its own, attached where the TEXT is justified (a baseline as the
|
||||
/// bottom).
|
||||
fn single(t: &Text) -> MText {
|
||||
let col = match t.halign {
|
||||
HAlign::Left | HAlign::Aligned | HAlign::Fit => 0,
|
||||
HAlign::Center | HAlign::Middle => 1,
|
||||
HAlign::Right => 2,
|
||||
};
|
||||
let row = match (t.halign, t.valign) {
|
||||
(HAlign::Middle, _) | (_, VAlign::Middle) => 1,
|
||||
(_, VAlign::Top) => 0,
|
||||
_ => 2,
|
||||
};
|
||||
MText {
|
||||
insert: v3(t.justify_point()),
|
||||
height: t.height,
|
||||
width: 0.0,
|
||||
attach: row * 3 + col + 1,
|
||||
rotation: t.rotation,
|
||||
style: t.style.clone(),
|
||||
contents: text_contents(t, t.height),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn locked_note(n: usize) -> Option<String> {
|
||||
(n > 0).then(|| format!("{n} object(s) on a locked layer."))
|
||||
}
|
||||
|
||||
fn run_txt2mtxt(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let top_down = match str_param(p, "sort") {
|
||||
None | Some("topdown") => true,
|
||||
Some("selection") => false,
|
||||
Some(v) => return Err(bad("txt2mtxt", format!("`sort` must be \"topdown\" or \"selection\", got \"{v}\""))),
|
||||
};
|
||||
let o = Txt2MtxtOptions { combine: bool_or(p, "combine", true), top_down, wrap: bool_or(p, "wrap", false) };
|
||||
let (made, locked) = convert(s, &hs, &o)?;
|
||||
if made.is_empty() {
|
||||
let why = locked_note(locked).unwrap_or_else(|| "No text objects selected.".into());
|
||||
return Err(EngineError::Other(why));
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
let mut r = json!({ "handles": made.iter().map(|h| h.hex()).collect::<Vec<_>>() });
|
||||
if let Some(m) = locked_note(locked) {
|
||||
r["message"] = json!(m);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Ask {
|
||||
Select,
|
||||
Combine,
|
||||
Sort,
|
||||
Wrap,
|
||||
}
|
||||
|
||||
struct Txt2MtxtM {
|
||||
opts: Txt2MtxtOptions,
|
||||
ask: Ask,
|
||||
sel: SelectPhase,
|
||||
}
|
||||
|
||||
impl Txt2MtxtM {
|
||||
fn new(s: &Session) -> Self {
|
||||
Txt2MtxtM { opts: s.last_used.txt2mtxt.clone(), ask: Ask::Select, sel: SelectPhase::default() }
|
||||
}
|
||||
fn finish(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
|
||||
let (made, locked) = convert(s, hs, &self.opts)?;
|
||||
if let Some(m) = locked_note(locked) {
|
||||
s.echo(m);
|
||||
}
|
||||
if made.is_empty() {
|
||||
s.echo("No text objects selected.");
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
fn yes_no(b: bool) -> &'static str {
|
||||
if b { "Yes" } else { "No" }
|
||||
}
|
||||
|
||||
impl Interactive for Txt2MtxtM {
|
||||
fn name(&self) -> &'static str {
|
||||
"TXT2MTXT"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
let o = &self.opts;
|
||||
s.echo(format!("Current settings: Combine= {}, Sort top-down= {}, Word-wrap= {}", yes_no(o.combine), yes_no(o.top_down), yes_no(o.wrap)));
|
||||
self.sel = SelectPhase::begin(s);
|
||||
if self.sel.done {
|
||||
let hs = self.sel.picked.clone();
|
||||
return self.finish(s, &hs);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.ask {
|
||||
Ask::Select if self.sel.removing => self.sel.prompt(),
|
||||
Ask::Select => Prompt::new("Select objects", Accept::SELECT).kw(&["SEttings"]),
|
||||
Ask::Combine => Prompt::new("Combine text into a single MTEXT?", KW).kw(&["Yes", "No"]).default(yes_no(self.opts.combine)),
|
||||
Ask::Sort => Prompt::new("Sort text top-down?", KW).kw(&["Yes", "No"]).default(yes_no(self.opts.top_down)),
|
||||
Ask::Wrap => Prompt::new("Word-wrap text?", KW).kw(&["Yes", "No"]).default(yes_no(self.opts.wrap)),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let k = match &i {
|
||||
Input::Keyword(k) => Some(k.as_str()),
|
||||
_ => None,
|
||||
};
|
||||
match self.ask {
|
||||
Ask::Select => {
|
||||
if k == Some("SEttings") {
|
||||
self.ask = Ask::Combine;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
return match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => self.finish(s, &hs),
|
||||
};
|
||||
}
|
||||
Ask::Combine => {
|
||||
match k {
|
||||
Some("Yes") => self.opts.combine = true,
|
||||
Some("No") => self.opts.combine = false,
|
||||
_ if i == Input::Enter => {}
|
||||
_ => return Ok(Step::Continue),
|
||||
}
|
||||
// Sorting and word wrap only apply to text combined into one MTEXT.
|
||||
self.ask = if self.opts.combine { Ask::Sort } else { Ask::Select };
|
||||
}
|
||||
Ask::Sort => {
|
||||
match k {
|
||||
Some("Yes") => self.opts.top_down = true,
|
||||
Some("No") => self.opts.top_down = false,
|
||||
_ if i == Input::Enter => {}
|
||||
_ => return Ok(Step::Continue),
|
||||
}
|
||||
self.ask = Ask::Wrap;
|
||||
}
|
||||
Ask::Wrap => {
|
||||
match k {
|
||||
Some("Yes") => self.opts.wrap = true,
|
||||
Some("No") => self.opts.wrap = false,
|
||||
_ if i == Input::Enter => {}
|
||||
_ => return Ok(Step::Continue),
|
||||
}
|
||||
self.ask = Ask::Select;
|
||||
}
|
||||
}
|
||||
s.last_used.txt2mtxt = self.opts.clone();
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "txt2mtxt_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,166 @@
|
||||
use cadcraft_doc::{EntityKind, MText};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Three TEXT lines made out of order: "B" lowest, "A" highest. The highest is on layer TOP with
|
||||
/// height 2.5, the others on layer 0 with height 1, so the first text's properties are told apart.
|
||||
fn three_lines(s: &mut Session) -> Vec<String> {
|
||||
s.execute("layer.new", &json!({"name": "TOP"})).unwrap();
|
||||
let h = |r: serde_json::Value| r["handle"].as_str().unwrap().to_string();
|
||||
let b = h(s.execute("text", &json!({"at": [0, 0], "text": "B", "height": 1})).unwrap());
|
||||
s.execute("layer.current", &json!({"name": "TOP"})).unwrap();
|
||||
let a = h(s.execute("text", &json!({"at": [2, 10], "text": "A", "height": 2.5})).unwrap());
|
||||
s.execute("layer.current", &json!({"name": "0"})).unwrap();
|
||||
let c = h(s.execute("text", &json!({"at": [1, 5], "text": "C{1}", "height": 1})).unwrap());
|
||||
vec![b, a, c]
|
||||
}
|
||||
|
||||
fn handles(hs: &[String]) -> Vec<cadcraft_doc::Handle> {
|
||||
hs.iter().map(|h| cadcraft_doc::Handle::parse_hex(h).unwrap()).collect()
|
||||
}
|
||||
|
||||
fn mtexts(s: &Session) -> Vec<(String, MText)> {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::MText(m) => Some((e.common.layer.clone(), m.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combines_texts_top_down_into_one_mtext_with_the_first_ones_properties() {
|
||||
let mut s = Session::new();
|
||||
let hs = three_lines(&mut s);
|
||||
let r = s.execute("txt2mtxt", &json!({"handles": hs})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.model.len(), 1, "the three texts became one object");
|
||||
let ms = mtexts(&s);
|
||||
let [(layer, m)] = &ms[..] else { panic!("one MTEXT: {ms:?}") };
|
||||
// Highest first whatever the selection order; braces are literal characters; lines of
|
||||
// another height than the first keep theirs.
|
||||
assert_eq!(m.contents, "A\\P{\\H1;C\\{1\\}}\\P{\\H1;B}");
|
||||
assert_eq!((layer.as_str(), m.height, m.attach, m.width), ("TOP", 2.5, 1, 0.0));
|
||||
// Top left of the texts: the leftmost start point, the top of the highest text.
|
||||
assert_eq!((m.insert.x, m.insert.y), (0.0, 12.5));
|
||||
assert_eq!(r["handles"], json!([hs[1]]), "the highest text's object holds the MTEXT");
|
||||
|
||||
s.undo().unwrap();
|
||||
let kinds: Vec<_> = s.doc().unwrap().model.iter().map(|e| e.kind.type_name()).collect();
|
||||
assert_eq!(kinds, ["Text", "Text", "Text"], "one undo step brings the three texts back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_order_wrap_and_one_mtext_per_text() {
|
||||
let mut s = Session::new();
|
||||
let hs = three_lines(&mut s);
|
||||
s.execute("txt2mtxt", &json!({"handles": hs, "sort": "selection", "wrap": true})).unwrap();
|
||||
let ms = mtexts(&s);
|
||||
let [(layer, m)] = &ms[..] else { panic!("one MTEXT: {ms:?}") };
|
||||
assert_eq!(m.contents, "B {\\H2.5;A} C\\{1\\}", "selection order, one wrapped paragraph");
|
||||
assert_eq!((layer.as_str(), m.height), ("0", 1.0), "the first selected text's properties");
|
||||
assert!(m.width > 0.0, "word wrap gives the MTEXT a width");
|
||||
s.undo().unwrap();
|
||||
|
||||
s.execute("txt2mtxt", &json!({"handles": hs, "combine": false})).unwrap();
|
||||
let ms = mtexts(&s);
|
||||
assert_eq!(ms.len(), 3, "one MTEXT per TEXT: {ms:?}");
|
||||
let a = ms.iter().find(|(_, m)| m.contents == "A").unwrap();
|
||||
// A baseline-left TEXT is attached bottom left at its start point, keeping layer and height.
|
||||
assert_eq!((a.0.as_str(), a.1.height, a.1.attach, a.1.insert.x, a.1.insert.y), ("TOP", 2.5, 7, 2.0, 10.0));
|
||||
assert_eq!(s.doc().unwrap().model.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_line_settings_and_locked_layers() {
|
||||
let mut s = Session::new();
|
||||
let hs = three_lines(&mut s);
|
||||
let steps = s.state().unwrap().undo.len();
|
||||
s.start("txt2mtxt").unwrap();
|
||||
assert!(s.prompt_text().contains("Select objects or [SEttings]"), "{}", s.prompt_text());
|
||||
s.cmdline("se").unwrap();
|
||||
assert!(s.prompt_text().contains("Combine text into a single MTEXT? [Yes/No] <Yes>"), "{}", s.prompt_text());
|
||||
s.cmdline("n").unwrap();
|
||||
assert!(s.prompt_text().contains("Select objects"), "sorting and wrapping are skipped: {}", s.prompt_text());
|
||||
s.input(crate::Input::Pick(handles(&hs))).unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(mtexts(&s).len(), 3);
|
||||
assert_eq!(s.state().unwrap().undo.len(), steps + 1, "one undo step");
|
||||
s.undo().unwrap();
|
||||
|
||||
// The setting is remembered; a text on a locked layer is left alone.
|
||||
s.execute("layer.set", &json!({"name": "TOP", "locked": true})).unwrap();
|
||||
s.start("txt2mtxt").unwrap();
|
||||
s.cmdline("se").unwrap();
|
||||
assert!(s.prompt_text().contains("<No>"), "{}", s.prompt_text());
|
||||
s.cmdline("y").unwrap();
|
||||
assert!(s.prompt_text().contains("Sort text top-down? [Yes/No] <Yes>"), "{}", s.prompt_text());
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.prompt_text().contains("Word-wrap text? [Yes/No] <No>"), "{}", s.prompt_text());
|
||||
s.cmdline("").unwrap();
|
||||
s.input(crate::Input::Pick(handles(&hs))).unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
let ms = mtexts(&s);
|
||||
let [(_, m)] = &ms[..] else { panic!("one MTEXT: {ms:?}") };
|
||||
assert_eq!(m.contents, "C\\{1\\}\\PB");
|
||||
assert_eq!(s.doc().unwrap().model.len(), 2, "the locked text stays a TEXT");
|
||||
|
||||
let n = s.doc().unwrap().model.len();
|
||||
for p in [json!({"handles": []}), json!({"handles": [hs[1]]}), json!({"handles": hs, "sort": "sideways"})] {
|
||||
assert!(s.execute("txt2mtxt", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().model.len(), n);
|
||||
}
|
||||
|
||||
/// The vertical extent of each group of strokes that overlap vertically: one per line of
|
||||
/// capitals, top line first.
|
||||
fn line_extents(strokes: &[Vec<cadcraft_geom::Vec2>]) -> Vec<f64> {
|
||||
let mut spans: Vec<(f64, f64)> = strokes
|
||||
.iter()
|
||||
.filter(|st| !st.is_empty())
|
||||
.map(|st| st.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.y), hi.max(p.y))))
|
||||
.collect();
|
||||
spans.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
let mut lines: Vec<(f64, f64)> = Vec::new();
|
||||
for (lo, hi) in spans {
|
||||
match lines.last_mut() {
|
||||
Some(l) if hi >= l.0 => l.0 = l.0.min(lo),
|
||||
_ => lines.push((lo, hi)),
|
||||
}
|
||||
}
|
||||
lines.iter().map(|(lo, hi)| hi - lo).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_lines_keep_their_own_heights() {
|
||||
let mut s = Session::new();
|
||||
for (y, t, h) in [(0, "LOW", 3.5), (20, "TOP", 5.0), (10, "MID", 3.5)] {
|
||||
s.execute("text", &json!({"at": [0, y], "text": t, "height": h})).unwrap();
|
||||
}
|
||||
s.execute("txt2mtxt", &json!({"handles": s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect::<Vec<_>>()})).unwrap();
|
||||
let ms = mtexts(&s);
|
||||
let [(_, m)] = &ms[..] else { panic!("one MTEXT: {ms:?}") };
|
||||
assert_eq!((m.height, m.contents.as_str()), (5.0, "TOP\\P{\\H3.5;MID}\\P{\\H3.5;LOW}"));
|
||||
// Drawn, each line is as tall as the TEXT it came from.
|
||||
let drawn = |c: &str, h: f64| cadcraft_fonts::layout_mtext(c, m.insert.xy(), h, 0.0, 1, 0.0, 1.0).strokes;
|
||||
let got = line_extents(&drawn(&m.contents, m.height));
|
||||
let want: Vec<f64> = [("TOP", 5.0), ("MID", 3.5), ("LOW", 3.5)].iter().flat_map(|(t, h)| line_extents(&drawn(t, *h))).collect();
|
||||
assert_eq!(got.len(), 3, "{got:?}");
|
||||
for (g, w) in got.iter().zip(&want) {
|
||||
assert!((g - w).abs() < 1e-6, "line heights {got:?}, TEXT heights {want:?}");
|
||||
}
|
||||
assert!(want[1] < want[0] * 0.8, "the fixture's heights differ: {want:?}");
|
||||
s.undo().unwrap();
|
||||
|
||||
// One MTEXT per TEXT: each its own height.
|
||||
let hs: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
|
||||
s.execute("txt2mtxt", &json!({"handles": hs, "combine": false})).unwrap();
|
||||
let mut got: Vec<(String, f64)> = mtexts(&s).into_iter().map(|(_, m)| (m.contents, m.height)).collect();
|
||||
got.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
assert_eq!(got, [("LOW".to_string(), 3.5), ("MID".to_string(), 3.5), ("TOP".to_string(), 5.0)]);
|
||||
}
|
||||
@@ -548,6 +548,8 @@ pub struct LastUsed {
|
||||
pub rotate_angle: f64,
|
||||
/// SCALE factor.
|
||||
pub scale_factor: f64,
|
||||
/// TXT2MTXT's Settings.
|
||||
pub txt2mtxt: cmd::Txt2MtxtOptions,
|
||||
/// MATCHPROP Settings: the properties it copies.
|
||||
pub matchprop: cmd::MatchProps,
|
||||
/// MLEADER placement order and Options.
|
||||
@@ -565,6 +567,7 @@ impl Default for LastUsed {
|
||||
polygon_circumscribed: false,
|
||||
rotate_angle: 0.0,
|
||||
scale_factor: 1.0,
|
||||
txt2mtxt: cmd::Txt2MtxtOptions::default(),
|
||||
matchprop: cmd::MatchProps::default(),
|
||||
mleader: MLeaderOptions::default(),
|
||||
mleader_block: String::new(),
|
||||
|
||||
@@ -176,6 +176,7 @@ menu Modify Edita
|
||||
Contributors Collaboratori
|
||||
Control Vertices Vertici di controllo
|
||||
Convert Converti
|
||||
Convert Text to Mtext Converti testo in testo multilinea
|
||||
Copy Copia
|
||||
Copy Full File Path Copia percorso completo del file
|
||||
Copy History Copia cronologia
|
||||
|
||||
|
@@ -173,6 +173,7 @@
|
||||
Contributors Учасники
|
||||
Control Vertices Керувальні вершини
|
||||
Convert Перетворити
|
||||
Convert Text to Mtext Перетворити текст на багаторядковий текст
|
||||
Copy Копіювати
|
||||
Copy Full File Path Копіювати повний шлях до файлу
|
||||
Copy History Копіювати історію
|
||||
|
||||
|
+2
-1
@@ -134,7 +134,7 @@ Detail: [localization-parity.md](localization-parity.md).
|
||||
| # | Gap | Est. |
|
||||
|---|---|---|
|
||||
| E1 | AutoLISP beyond the core (#758 landed the language, `command`, entity lists, selection sets, `get…` input, APPLOAD, `(load)`, `c:` commands and `acad.lsp`/`acaddoc.lsp` at startup): Visual LISP ActiveX (`vlax-`, `vla-`), DCL dialogs, reactors, an APPLOAD dialog with a Startup Suite, UCS-aware `trans` | 30–50 |
|
||||
| E3 | Express Tools equivalents (TXT2MTXT, BURST, SUPERHATCH, Move/Copy/Rotate; NCOPY and BREAKLINE exist, BREAKLINE's Block option takes a block of the drawing, not a `.dwg` file) | 28–47 |
|
||||
| E3 | Express Tools equivalents (BURST, SUPERHATCH, Move/Copy/Rotate; NCOPY, BREAKLINE and TXT2MTXT exist: BREAKLINE's Block option takes a block of the drawing, not a `.dwg` file, and TXT2MTXT's Settings are command-line prompts rather than a dialog, without the uniform line spacing option) | 24–40 |
|
||||
| E4 | Plug-in API (owner decision: Rust/WASM plug-ins rather than ObjectARX/.NET) | 30–50 |
|
||||
| E5 | CUI customisation and alias (`.pgp`) editor | 15–25 |
|
||||
|
||||
@@ -154,6 +154,7 @@ Detail: [localization-parity.md](localization-parity.md).
|
||||
| 2026-10-14 | minor | HATCHEDIT adds boundary loops from internal points or closed objects and removes selected loops, in the dialog, command line and JSON command: F13 narrowed |
|
||||
| 2026-10-15 | minor | `entities` / MCP `query_entities` accept `selected:true`, combined with existing filters and paging; selection, drawing and undo/redo stay unchanged. No parity estimate change. |
|
||||
| 2026-10-15 | minor | BREAKLINE (Tools ▸ Express Tools ▸ Break-line Symbol): one polyline between two points with a break symbol, Block/Size/Extension options, size times DIMSCALE: E3 narrowed |
|
||||
| 2026-10-15 | minor | TXT2MTXT (Tools ▸ Express Tools ▸ Convert Text to Mtext): selected TEXT, and MTEXT, combined into one MTEXT top-down or in selection order with optional word wrap, or one MTEXT per TEXT; SEttings remembered for the session: E3 narrowed |
|
||||
| 2026-10-13 | minor | Selection window gesture preference (PICKDRAG 0/2) added to Options ▸ Selection and saved in the profile (U6), with Portuguese and Ukrainian labels |
|
||||
| 2026-10-12 | minor | Object transparency (entity, ByLayer, ByBlock) is drawn: on screen (TRANSPARENCYDISPLAY, on by default), in PNG/SVG exports, and in plots when the page setup's "Plot transparency" is on (PDF alpha, PNG blending, SVG opacity; PLOTTRANSPARENCYOVERRIDE): F14 narrowed |
|
||||
| 2026-10-12 | minor | Hatch and Gradient dialog (HATCH, GRADIENT; HPDLGMODE) and Hatch Edit dialog (HATCHEDIT, right-click, double-click a hatch): F13 narrowed to Preview, Inherit Properties and boundary add/remove in edit mode |
|
||||
|
||||
@@ -104,6 +104,7 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com
|
||||
- Tools > Import Settings
|
||||
- Tools > Export Settings
|
||||
- Tools > Express Tools > Convert Text to Mtext
|
||||
- Tools > Express Tools > Break-line Symbol
|
||||
- Tools > Express Tools > Explode Attributes to Text
|
||||
- Tools > Express Tools > Move/Copy/Rotate
|
||||
- Tools > Express Tools > Replace Block...
|
||||
|
||||
Reference in new issue
Block a user