vectorcraft/crates/engine/src/cmd/textedit.rs

489 lines
19 KiB
Rust

//! Rich text editing commands: range edits and range styling (what the Type tool and the
//! Character panel use while editing), creating area / on-path type from a path, and Fit Headline.
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind, TextKind, TextObject, TextRun};
use vectorcraft_geom::Affine;
use vectorcraft_text::edit;
use super::stroke::StrokeChange;
use super::typecmd::refresh_bounds;
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"text.editRange",
"Edit Text",
[],
None,
"{id, start: byte, end: byte, insert?: string, runs?: [{text, style}]} replace bytes start..end of the plain text (inserted text takes the replaced text's style unless styled `runs` are given) → {id, caret}",
has_doc,
edit_range
),
cmd!(
"text.setRangeStyle",
"Character",
[],
None,
"{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …]} style a character range (runs are split at the range ends) → {id, runs}",
has_doc,
set_range_style
),
cmd!(
query "text.getRange",
"Get Text Range",
[],
None,
"{id, start?, end?} → {text, runs: [{text, style}], length}",
has_doc,
get_range
),
cmd!(
"text.createInPath",
"Area / Path Type",
[],
None,
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", at?: [x, y] (on-path start: nearest point), size?, font?} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
has_doc,
create_in_path
),
cmd!(
"type.fitHeadline",
"Fit Headline",
["Type"],
None,
"{ids?} track the first line of area type so it fills the frame width → {ids, tracking}",
has_selection,
fit_headline
),
cmd!(
"text.discardEmpty",
"Discard Empty Type",
[],
None,
"{id} remove text object `id` if it has no characters (what the Type tool does with point type it placed when editing ends). When nothing but that text changed since the step that created it, the steps since are dropped instead, leaving no trace in the history → {removed}",
has_doc,
discard_empty
),
]
}
fn text_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Option<&mut TextObject> {
match d.node_mut(id).map(|n| &mut n.kind) {
Some(NodeKind::Text(t)) => Some(t),
_ => None,
}
}
fn text_ref(s: &Session, id: NodeId) -> Result<TextObject> {
match s.doc()?.doc.node(id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Ok((**t).clone()),
Some(_) => Err(EngineError::Other(format!("node {} is not text", id.0))),
None => Err(EngineError::NoNode(id)),
}
}
fn byte_param(p: &Value, k: &str) -> Option<usize> {
p.get(k).and_then(Value::as_u64).map(|v| v as usize)
}
fn range_of(p: &Value, len: usize) -> (usize, usize) {
let a = byte_param(p, "start").unwrap_or(0).min(len);
let b = byte_param(p, "end").unwrap_or(len).min(len);
(a.min(b), a.max(b))
}
fn edit_range(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.editRange";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let t = text_ref(s, id)?;
let len = edit::runs_len(&t.runs);
let (a, b) = range_of(p, len);
let styled: Option<Vec<TextRun>> = match p.get("runs") {
None | Some(Value::Null) => None,
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(C, format!("bad `runs`: {e}")))?),
};
let insert = str_param(p, "insert").unwrap_or("").to_string();
let caret = s.edit("Typing", |d, _| {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
let caret = match &styled {
Some(r) => edit::replace_range_styled(&mut t.runs, a, b, r),
None => edit::replace_range(&mut t.runs, a, b, &insert),
};
refresh_bounds(t);
Ok(caret)
})?;
Ok(json!({"id": id.0, "caret": caret}))
}
fn get_range(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("text.getRange", "missing `id`"))?;
let t = text_ref(s, id)?;
let len = edit::runs_len(&t.runs);
let (a, b) = range_of(p, len);
let runs = edit::slice_runs(&t.runs, a, b);
let text: String = runs.iter().map(|r| r.text.as_str()).collect();
Ok(json!({"text": text, "runs": runs, "length": len}))
}
/// Character attribute changes parsed from params (shared by range and whole-object styling).
#[derive(Default)]
pub(crate) struct CharChange {
font: Option<String>,
style: Option<String>,
size: Option<f64>,
leading: Option<Option<f64>>,
tracking: Option<f64>,
kerning: Option<Option<f64>>,
baseline_shift: Option<f64>,
h_scale: Option<f64>,
v_scale: Option<f64>,
rotation: Option<f64>,
fill: Option<Paint>,
stroke: Option<Paint>,
/// Weight (`strokeWidth`), cap, join, miter limit and dashes of the character stroke.
stroke_opts: StrokeChange,
underline: Option<bool>,
strikethrough: Option<bool>,
all_caps: Option<bool>,
features: Option<Vec<String>>,
position: Option<vectorcraft_doc::CharPosition>,
small_caps: Option<Option<f64>>,
}
/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults).
pub(crate) fn features_param(p: &Value, cmd: &str) -> Result<Option<Vec<String>>> {
let Some(v) = p.get("features").filter(|v| !v.is_null()) else { return Ok(None) };
let tags: Vec<&str> = v.as_array().ok_or_else(|| bad(cmd, "features must be a list of tags"))?.iter().filter_map(Value::as_str).collect();
if let Some(t) = tags.iter().find(|t| !vectorcraft_text::OtFeatures::known_tag(t)) {
return Err(bad(cmd, format!("unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh; prefix - to turn off)")));
}
Ok(Some(vectorcraft_text::OtFeatures::default().with_tags(tags).to_tags()))
}
fn paint_param(p: &Value, k: &str, cmd: &str) -> Result<Option<Paint>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(n)) if n.eq_ignore_ascii_case("none") => Ok(Some(Paint::None)),
Some(v) => Ok(Some(Paint::solid(color_value(v).ok_or_else(|| bad(cmd, format!("bad {k} colour")))?))),
}
}
fn auto_or_num(p: &Value, k: &str, cmd: &str) -> Result<Option<Option<f64>>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Ok(Some(None)),
Some(v) => Ok(Some(Some(v.as_f64().ok_or_else(|| bad(cmd, format!("{k} must be a number or \"auto\"")))?))),
}
}
impl CharChange {
/// `setup` gives the superscript, subscript and small caps proportions.
pub(crate) fn parse(p: &Value, cmd: &str, setup: &vectorcraft_doc::DocSetup) -> Result<Self> {
let num = |k: &str| p.get(k).and_then(Value::as_f64);
let (position, small_caps) = super::docsetup::script_params(p, setup, cmd)?;
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
let mut stroke_opts = match p.get("strokeOptions") {
None | Some(Value::Null) => StrokeChange::default(),
Some(o) if o.is_object() => StrokeChange::parse(o, cmd)?,
Some(_) => return Err(bad(cmd, "strokeOptions must be an object of stroke.set options")),
};
if let Some(w) = num("strokeWidth") {
stroke_opts.weight = Some(w.clamp(0.0, 1000.0));
}
let c = Self {
font: str_param(p, "font").map(str::to_string),
style: str_param(p, "style").map(str::to_string),
size: num("size"),
leading: auto_or_num(p, "leading", cmd)?,
tracking: num("tracking"),
kerning: auto_or_num(p, "kerning", cmd)?,
baseline_shift: num("baselineShift"),
h_scale: num("hScale"),
v_scale: num("vScale"),
rotation: num("rotation"),
fill: paint_param(p, "fill", cmd)?,
stroke: paint_param(p, "stroke", cmd)?,
stroke_opts,
underline: flag("underline"),
strikethrough: flag("strikethrough"),
all_caps: flag("allCaps"),
features: features_param(p, cmd)?,
position,
small_caps,
};
if c.size.is_some_and(|v| v <= 0.0) {
return Err(bad(cmd, "size must be positive"));
}
Ok(c)
}
pub(crate) fn is_empty(&self) -> bool {
self.font.is_none()
&& self.style.is_none()
&& self.size.is_none()
&& self.leading.is_none()
&& self.tracking.is_none()
&& self.kerning.is_none()
&& self.baseline_shift.is_none()
&& self.h_scale.is_none()
&& self.v_scale.is_none()
&& self.rotation.is_none()
&& self.fill.is_none()
&& self.stroke.is_none()
&& self.stroke_opts.is_empty()
&& self.underline.is_none()
&& self.strikethrough.is_none()
&& self.all_caps.is_none()
&& self.features.is_none()
&& self.position.is_none()
&& self.small_caps.is_none()
}
pub(crate) fn apply(&self, st: &mut CharStyle) {
if let Some(f) = &self.font {
st.font_family = f.clone();
// Keep the style when the new family has it, else its closest match.
if self.style.is_none() {
let styles = vectorcraft_text::FontDb::global().styles(f);
if !styles.is_empty()
&& !styles.iter().any(|s| s.eq_ignore_ascii_case(&st.font_style))
&& let Some(face) = vectorcraft_text::FontDb::global().face(f, &st.font_style)
{
st.font_style = face.style.clone();
}
}
}
if let Some(v) = &self.style {
st.font_style = v.clone();
}
if let Some(v) = self.size {
st.size = v.clamp(0.1, 1296.0);
}
if let Some(v) = self.leading {
st.leading = v.map(|l| l.clamp(0.1, 5000.0));
}
if let Some(v) = self.tracking {
st.tracking = v.clamp(-1000.0, 10000.0);
}
if let Some(v) = self.kerning {
st.kerning = v.map(|k| k.clamp(-1000.0, 10000.0));
}
if let Some(v) = self.baseline_shift {
st.baseline_shift = v.clamp(-1296.0, 1296.0);
}
if let Some(v) = self.h_scale {
st.h_scale = v.clamp(1.0, 10000.0);
}
if let Some(v) = self.v_scale {
st.v_scale = v.clamp(1.0, 10000.0);
}
if let Some(v) = self.rotation {
st.rotation = (v + 180.0).rem_euclid(360.0) - 180.0;
}
if let Some(v) = &self.fill {
st.fill = v.clone();
}
if let Some(v) = &self.stroke {
st.stroke = v.clone();
}
if !self.stroke_opts.is_empty() {
self.stroke_opts.apply_char(st);
}
if let Some(v) = self.underline {
st.underline = v;
}
if let Some(v) = self.strikethrough {
st.strikethrough = v;
}
if let Some(v) = self.all_caps {
st.all_caps = v;
}
if let Some(v) = &self.features {
st.features = v.clone();
}
if let Some(v) = self.position {
st.position = v;
}
if let Some(v) = self.small_caps {
st.small_caps = v;
}
}
}
fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setRangeStyle";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
if change.is_empty() {
return Err(bad(C, "nothing to change"));
}
let t = text_ref(s, id)?;
let (a, b) = range_of(p, edit::runs_len(&t.runs));
let n = s.edit("Character", |d, _| {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
if a == b && !t.plain_text().is_empty() {
// An empty range inside text styles nothing (Illustrator keeps it for the next typing).
return Ok(t.runs.len());
}
edit::style_range(&mut t.runs, a, b, |st| change.apply(st));
refresh_bounds(t);
Ok(t.runs.len())
})?;
Ok(json!({"id": id.0, "runs": n}))
}
fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.createInPath";
let pid = id_param(p, "path").ok_or_else(|| bad(C, "missing `path` (a path object id)"))?;
let on_path = match str_param(p, "mode").unwrap_or("area") {
"area" => false,
"onPath" | "path" => true,
o => return Err(bad(C, format!("mode must be area|onPath, got {o}"))),
};
let node = s.doc()?.doc.node(pid).cloned().ok_or(EngineError::NoNode(pid))?;
let NodeKind::Path { path, .. } = &node.kind else { return Err(bad(C, "`path` must be a path object")) };
if path.is_empty() {
return Err(bad(C, "the path is empty"));
}
if !on_path && !path.is_closed() && path.bounds().is_none_or(|b| b.width() < 1.0 || b.height() < 1.0) {
return Err(bad(C, "area type needs a path that encloses an area"));
}
let mut style = CharStyle::default();
if let Some(v) = p.get("size").and_then(Value::as_f64) {
style.size = v.clamp(0.1, 1296.0);
}
if let Some(f) = str_param(p, "font") {
style.font_family = f.to_string();
}
if !s.paint.fill.is_none() && s.paint.fill != Paint::solid(vectorcraft_color::Color::WHITE) {
style.fill = s.paint.fill.clone();
}
let text = str_param(p, "text").unwrap_or("").to_string();
let start = match point_param(p, "at") {
Some(at) if on_path => vectorcraft_text::path_fraction_at(&path.to_bezpath(), at).0,
_ => 0.0,
};
let kind = if on_path { TextKind::OnPath { path: path.clone(), start } } else { TextKind::Area { frame: path.clone() } };
let mut t = TextObject {
kind,
xf: Affine::IDENTITY,
runs: vec![TextRun { text, style }],
para: Default::default(),
area: Default::default(),
path_effect: Default::default(),
wrap: Vec::new(),
cached_bounds: None,
};
refresh_bounds(&mut t);
let id = s.edit(if on_path { "Type on a Path" } else { "Area Type" }, |d, sel| {
let (par, idx, _) = d.position(pid).ok_or(EngineError::NoNode(pid))?;
d.remove(pid)?;
let id = d.alloc_id();
let mut n = Node::new(id, NodeKind::Text(Box::new(t)));
n.appearance = Appearance::default();
n.opacity = node.opacity;
n.name = node.name.clone();
d.insert(par, idx, n)?;
sel.set([id]);
Ok(id)
})?;
Ok(json!({"id": id.0}))
}
/// Tracking (1/1000 em) that makes the first paragraph of `t` exactly `target` wide, if it fits
/// on one line at all.
fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
let plain = t.plain_text();
let end = plain.find('\n').unwrap_or(plain.len());
if end == 0 {
return None;
}
let head = edit::slice_runs(&t.runs, 0, end);
let measure = |tr: f64| {
let mut h = TextObject {
kind: TextKind::Point,
xf: Affine::IDENTITY,
runs: head.clone(),
para: Default::default(),
area: Default::default(),
path_effect: Default::default(),
wrap: Vec::new(),
cached_bounds: None,
};
for r in &mut h.runs {
r.style.tracking = tr;
}
let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &h);
l.lines.first().map_or(0.0, |li| li.x1 - li.x0)
};
// Width is linear in tracking: w(tr) = w0 + tr * slope.
let (w0, w1) = (measure(0.0), measure(100.0));
let slope = (w1 - w0) / 100.0;
if slope <= 1e-9 {
return None;
}
// A hair under the target so the line doesn't wrap.
Some(((target - w0) / slope - 0.05).clamp(-1000.0, 10000.0))
}
fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "type.fitHeadline";
let ids: Vec<NodeId> = {
let st = s.doc()?;
targets(s, p)?
.into_iter()
.filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::Area { .. })))
.collect()
};
if ids.is_empty() {
return Err(bad(C, "select area type"));
}
let mut applied = vec![];
s.edit("Fit Headline", |d, _| {
for id in &ids {
let Some(t) = text_mut(d, *id) else { continue };
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let Some(first) = lay.lines.first() else { continue };
let target = first.avail.1 - first.avail.0;
let Some(tr) = headline_tracking(t, target) else { continue };
let end = t.plain_text().find('\n').unwrap_or(t.plain_text().len());
edit::style_range(&mut t.runs, 0, end, |st| st.tracking = tr);
refresh_bounds(t);
applied.push(tr);
}
Ok(())
})?;
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "tracking": applied}))
}
fn discard_empty(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.discardEmpty";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
if edit::runs_len(&text_ref(s, id)?.runs) > 0 {
return Ok(json!({"removed": false}));
}
// The newest step whose document lacks the text created it: when the document now is that one
// plus the text (and its id), roll back to it without a trace.
let st = s.doc_mut()?;
if st.interaction.is_none()
&& let Some(k) = st.history.undo.iter().rposition(|e| e.doc.node(id).is_none())
&& let Some(created) = st.history.undo.get(k)
{
let mut now = (*st.doc).clone();
let mut before = (*created.doc).clone();
before.alloc_id();
if now.remove(id).is_ok() && now == before {
st.doc = created.doc.clone();
st.history.undo.truncate(k);
st.selection.prune(&st.doc);
st.revision += 1;
return Ok(json!({"removed": true}));
}
}
s.edit("Discard Empty Type", |d, _| d.remove(id).map(|_| ()).map_err(|_| EngineError::NoNode(id)))?;
Ok(json!({"removed": true}))
}