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

435 lines
17 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, 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
),
]
}
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>>,
}
/// `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 {
pub(crate) fn parse(p: &Value, cmd: &str) -> Result<Self> {
let num = |k: &str| p.get(k).and_then(Value::as_f64);
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)?,
};
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()
}
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)) {
st.font_style = vectorcraft_text::FontDb::global().face(f, &st.font_style).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();
}
}
}
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)?;
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}))
}