435 lines
17 KiB
Rust
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}))
|
|
}
|