//! 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::typecmd::refresh_bounds; use super::*; pub fn specs() -> Vec { 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, 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, like Illustrator) → {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 { 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 { 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 { 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> = 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 { 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, style: Option, size: Option, leading: Option>, tracking: Option, kerning: Option>, baseline_shift: Option, h_scale: Option, v_scale: Option, rotation: Option, fill: Option, stroke: Option, stroke_width: Option, underline: Option, strikethrough: Option, all_caps: Option, features: Option>, } /// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults). pub(crate) fn features_param(p: &Value, cmd: &str) -> Result>> { 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> { 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>> { 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 { 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 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_width: num("strokeWidth"), 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_width.is_none() && 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 let Some(v) = self.stroke_width { st.stroke_width = v.clamp(0.0, 1000.0); } 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 { 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 { 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 { 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 { const C: &str = "type.fitHeadline"; let ids: Vec = { 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::>(), "tracking": applied})) }