mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-15 06:46:45 -04:00
Squash of PR #547 by @vangel76. Co-Authored-By: Claude Opus 5.5 <[email protected]>
454 lines
20 KiB
Rust
454 lines
20 KiB
Rust
//! 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, MTextColumnType, MTextColumns};
|
|
use cadcraft_geom::{Vec2, Vec3};
|
|
|
|
use super::curves;
|
|
use super::machines::number;
|
|
use crate::{Accept, EngineError, Input, Prompt, Result, Session};
|
|
|
|
fn other(m: impl Into<String>) -> EngineError {
|
|
EngineError::Other(m.into())
|
|
}
|
|
|
|
/// The justification keywords, in attachment order 1..=9.
|
|
pub(super) const JUSTIFY: [&str; 9] = ["TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"];
|
|
|
|
/// Attachment code (1..=9) for a justification keyword.
|
|
pub(super) fn attach_code(k: &str) -> Option<u8> {
|
|
JUSTIFY.iter().position(|j| j.eq_ignore_ascii_case(k.trim())).map(|i| i as u8 + 1)
|
|
}
|
|
|
|
/// What the options set before the text is typed.
|
|
pub(super) struct MTextOpts {
|
|
pub height: f64,
|
|
pub attach: u8,
|
|
/// Line spacing factor (1 = 5/3 of the text height between baselines).
|
|
pub spacing: f64,
|
|
pub exact: bool,
|
|
pub rotation: f64,
|
|
pub style: String,
|
|
/// The Width option replaces the opposite corner.
|
|
pub width: Option<f64>,
|
|
/// 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,
|
|
columns: None,
|
|
total_width: 0.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
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, style, ..MTextOpts::default() }
|
|
}
|
|
|
|
/// The text box from the first corner and either the opposite corner or the Width answer:
|
|
/// the insertion point is the box's attachment point, the box turned by the rotation about
|
|
/// the first corner.
|
|
pub(super) fn mtext(&self, first: Vec2, second: Option<Vec2>, contents: String) -> MText {
|
|
let (insert, width) = match (second, self.width) {
|
|
(Some(b), _) => {
|
|
// The opposite corner in the box's own (rotated) frame.
|
|
let l = (b - first).rotate(-self.rotation);
|
|
let (x0, x1) = (l.x.min(0.0), l.x.max(0.0));
|
|
let (y0, y1) = (l.y.min(0.0), l.y.max(0.0));
|
|
let i = usize::from(self.attach.clamp(1, 9) - 1);
|
|
let x = [x0, (x0 + x1) / 2.0, x1][i % 3];
|
|
let y = [y1, (y0 + y1) / 2.0, y0][i / 3];
|
|
(first + Vec2::new(x, y).rotate(self.rotation), x1 - x0)
|
|
}
|
|
(None, w) => (first, w.unwrap_or(0.0)),
|
|
};
|
|
MText {
|
|
insert: Vec3::new(insert.x, insert.y, 0.0),
|
|
height: self.height,
|
|
width,
|
|
attach: self.attach.clamp(1, 9),
|
|
rotation: self.rotation,
|
|
style: self.style.clone(),
|
|
contents,
|
|
line_spacing: self.spacing,
|
|
line_spacing_exact: self.exact,
|
|
columns: self.columns.clone().filter(|_| width > 0.0),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A question an option asks before MTEXT goes back to "Specify opposite corner".
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
pub(super) enum MTextAsk {
|
|
Height,
|
|
Justify,
|
|
SpacingType,
|
|
SpacingFactor,
|
|
Rotation,
|
|
Style,
|
|
Width,
|
|
Columns,
|
|
/// Static columns: the total width; dynamic: the width of one column.
|
|
ColumnWidth,
|
|
ColumnCount,
|
|
ColumnGutter,
|
|
ColumnHeight,
|
|
}
|
|
|
|
pub(super) enum Outcome {
|
|
/// Ask this next (the same question again after an invalid answer).
|
|
Ask(MTextAsk),
|
|
/// Back to the opposite-corner prompt.
|
|
Back,
|
|
/// The Width answer replaces the opposite corner: go on to the text.
|
|
Text,
|
|
}
|
|
|
|
impl MTextAsk {
|
|
pub(super) fn from_keyword(k: &str) -> Option<MTextAsk> {
|
|
Some(match k {
|
|
"Height" => MTextAsk::Height,
|
|
"Justify" => MTextAsk::Justify,
|
|
"Line spacing" => MTextAsk::SpacingType,
|
|
"Rotation" => MTextAsk::Rotation,
|
|
"Style" => MTextAsk::Style,
|
|
"Width" => MTextAsk::Width,
|
|
"Columns" => MTextAsk::Columns,
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
pub(super) fn prompt(self, o: &MTextOpts, first: Vec2) -> Prompt {
|
|
match self {
|
|
MTextAsk::Height => Prompt::new("Specify height", Accept::POINT_OR_NUMBER).default(format!("{:.4}", o.height)).base(first),
|
|
MTextAsk::Justify => Prompt::new("Enter justification", curves::KW)
|
|
.kw(&JUSTIFY)
|
|
.default(JUSTIFY.get(usize::from(o.attach.clamp(1, 9) - 1)).copied().unwrap_or("TL")),
|
|
MTextAsk::SpacingType => {
|
|
Prompt::new("Enter line spacing type", curves::KW).kw(&["At least", "Exactly"]).default(if o.exact { "Exactly" } else { "At least" })
|
|
}
|
|
MTextAsk::SpacingFactor => Prompt::new("Enter line spacing factor or distance", Accept::NUMBER).default(format!("{}x", trim(o.spacing))),
|
|
MTextAsk::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default(trim(o.rotation.to_degrees())).base(first),
|
|
MTextAsk::Style => Prompt::new("Enter style name", curves::KW).kw(&["?"]).default(o.style.clone()),
|
|
MTextAsk::Width => Prompt::new("Specify width", Accept::POINT_OR_NUMBER).base(first),
|
|
MTextAsk::Columns => Prompt::new("Enter column type", curves::KW).kw(&["Dynamic", "Static", "No"]).default(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,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn input(self, s: &mut Session, i: Input, o: &mut MTextOpts, first: Vec2) -> Result<Outcome> {
|
|
let word = |i: &Input| match i {
|
|
Input::Keyword(k) | Input::Text(k) => Some(k.trim().to_string()),
|
|
_ => None,
|
|
};
|
|
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)),
|
|
(MTextAsk::Height, Input::Text(t)) => set_height(s, o, number(t).unwrap_or(f64::NAN)),
|
|
(MTextAsk::Justify, _) => {
|
|
let k = word(&i).unwrap_or_default();
|
|
o.attach = attach_code(&k).ok_or_else(|| other("Invalid option keyword."))?;
|
|
Ok(Outcome::Back)
|
|
}
|
|
(MTextAsk::SpacingType, _) => {
|
|
let k = word(&i).unwrap_or_default().to_ascii_lowercase();
|
|
o.exact = match k.as_str() {
|
|
"at least" | "a" | "at" => false,
|
|
"exactly" | "e" => true,
|
|
_ => return Err(other("Invalid option keyword.")),
|
|
};
|
|
Ok(Outcome::Ask(MTextAsk::SpacingFactor))
|
|
}
|
|
(MTextAsk::SpacingFactor, Input::Text(t)) => {
|
|
// "1.5x" is a factor; a plain number is the distance between lines (1x = 5/3 of the height).
|
|
let t = t.trim();
|
|
let f = match t.strip_suffix(['x', 'X']) {
|
|
Some(f) => number(f),
|
|
None => number(t).map(|d| d / (o.height * 5.0 / 3.0)),
|
|
};
|
|
o.spacing = f
|
|
.filter(|f| (0.25 - 1e-9..=4.0 + 1e-9).contains(f))
|
|
.ok_or_else(|| other("Requires a factor between 0.25x and 4x, or a distance."))?;
|
|
Ok(Outcome::Back)
|
|
}
|
|
(MTextAsk::Rotation, Input::Point(p)) => {
|
|
o.rotation = first.angle_to(*p);
|
|
Ok(Outcome::Back)
|
|
}
|
|
(MTextAsk::Rotation, Input::Text(t)) => {
|
|
o.rotation = s.angle_settings().direction(t).filter(|a| a.is_finite()).ok_or_else(|| other("Requires an angle."))?;
|
|
Ok(Outcome::Back)
|
|
}
|
|
(MTextAsk::Style, _) => {
|
|
let name = word(&i).unwrap_or_default();
|
|
let d = s.doc()?;
|
|
if name == "?" {
|
|
let names: Vec<String> = d.text_styles.iter().map(|t| t.name.clone()).collect();
|
|
s.echo(format!("Text styles: {}", names.join(", ")));
|
|
return Ok(Outcome::Ask(MTextAsk::Style));
|
|
}
|
|
o.style = d.text_style(&name).map(|t| t.name.clone()).ok_or_else(|| other(format!("Cannot find text style \"{name}\".")))?;
|
|
Ok(Outcome::Back)
|
|
}
|
|
(MTextAsk::Width, Input::Point(p)) => {
|
|
// The distance along the text direction from the first corner.
|
|
o.width = Some((*p - first).rotate(-o.rotation).x.abs());
|
|
Ok(Outcome::Text)
|
|
}
|
|
(MTextAsk::Width, Input::Text(t)) => {
|
|
o.width = Some(number(t).filter(|w| w.is_finite() && *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?);
|
|
Ok(Outcome::Text)
|
|
}
|
|
_ => 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."));
|
|
}
|
|
o.height = h;
|
|
s.doc_mut()?.header.set_f64("TEXTSIZE", h);
|
|
Ok(Outcome::Back)
|
|
}
|
|
|
|
/// A number without trailing zeros (`1.5`, `2`).
|
|
fn trim(v: f64) -> String {
|
|
let s = format!("{v:.4}");
|
|
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
|
}
|
|
|
|
/// 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),
|
|
}))
|
|
}
|