mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 22:20:50 -06:00
Fix #429: Type on a path brackets, flip and Type on a Path Options that keep the type in place
Type on a path could only be repositioned by Type on a Path Options, whose menu item sent
`{start: 0}`, so OK threw the text to the start of its path; nothing showed or dragged its
brackets. Now, as in Illustrator:
- Type on a path flows between a start and an end bracket (`TextKind::OnPath { end }`, None:
the end of the path or once round a closed one; `TextKind::path_span`). Flipping
(`TextObject::flip_on_path`) reverses the path keeping a closed path's first anchor and swaps
the brackets, so the type keeps its stretch of the path.
- Type on a Path Options gains Align to Path (ascender, descender, center, baseline) and
Spacing (glyphs spaced as if set that far above the path: closed up round the outside of
curves, opened up round the inside), laid out by the text engine.
- `type.pathOptions {start?, end?, flip?, effect?, alignToPath?, spacing?, ids?}` sets them in
one undo step; with none of them it queries. The menu item opens the dialog with the selected
type's Effect, Flip, Align to Path and Spacing (dropdowns for the choices) and leaves the
brackets alone, so OK as opened changes nothing.
- The Selection and Direct Selection tools show selected type on a path's path and its start,
centre and end brackets (`tools::pathtype`, with a bracket cursor): dragging the start or end
bracket sets where the type runs, dragging the centre bracket slides it along the path and,
across the path, flips it (Cmd/Ctrl held: it only slides). Each drag is one undo step.
- Arc-length measuring moved to `vectorcraft_geom::ArcPath` (layout, the tools and
`text.createInPath` share it). SVG export outlines path type whose end bracket, alignment or
spacing `<textPath>` can't express.
- docs/mcp.md documents the command and the drags; ROADMAP updated; dialog strings translated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
696d9f619b
commit
1706e84829
@@ -453,10 +453,10 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
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,
|
||||
Some(at) if on_path => vectorcraft_geom::ArcPath::new(path).fraction_at(at).unwrap_or(0.0),
|
||||
_ => 0.0,
|
||||
};
|
||||
let kind = if on_path { TextKind::OnPath { path: path.clone(), start } } else { TextKind::Area { frame: path.clone() } };
|
||||
let kind = if on_path { TextKind::OnPath { path: path.clone(), start, end: None } } else { TextKind::Area { frame: path.clone() } };
|
||||
let mut t = TextObject {
|
||||
vertical: p.get("vertical").and_then(Value::as_bool).unwrap_or(false),
|
||||
kind,
|
||||
@@ -465,6 +465,8 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
para: super::create::new_type_para(),
|
||||
area: Default::default(),
|
||||
path_effect: Default::default(),
|
||||
path_align: Default::default(),
|
||||
path_spacing: 0.0,
|
||||
wrap: Vec::new(),
|
||||
cached_bounds: None,
|
||||
};
|
||||
@@ -595,6 +597,8 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
|
||||
para: Default::default(),
|
||||
area: Default::default(),
|
||||
path_effect: Default::default(),
|
||||
path_align: Default::default(),
|
||||
path_spacing: 0.0,
|
||||
wrap: Vec::new(),
|
||||
cached_bounds: None,
|
||||
};
|
||||
|
||||
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Type on a Path Options…",
|
||||
["Type", "Type on a Path"],
|
||||
None,
|
||||
"{start?: 0..1 (fraction of the path length), flip?: bool (reverse the path), effect?: rainbow|skew|3dRibbon|stairStep|gravity, ids?}",
|
||||
"{ids?, start?: 0..1, end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}",
|
||||
has_selection,
|
||||
path_options
|
||||
),
|
||||
@@ -343,38 +343,95 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(ids_json(&ids))
|
||||
}
|
||||
|
||||
/// The options `type.pathOptions` sets; with none of them given it queries.
|
||||
const PATH_OPTIONS: [&str; 6] = ["start", "end", "flip", "effect", "alignToPath", "spacing"];
|
||||
|
||||
/// Type on a path's text, if `n` is type on a path.
|
||||
fn path_text(n: Option<&vectorcraft_doc::Node>) -> Option<&TextObject> {
|
||||
match n.map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::OnPath { .. }) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Type on a Path Options of `t`, as `type.pathOptions` takes them (`flip` is an action: false).
|
||||
fn path_options_of(t: &TextObject) -> Value {
|
||||
let (start, end) = match &t.kind {
|
||||
TextKind::OnPath { start, end, .. } => (*start, *end),
|
||||
_ => (0.0, None),
|
||||
};
|
||||
json!({
|
||||
"start": start,
|
||||
"end": end,
|
||||
"flip": false,
|
||||
"effect": t.path_effect.id(),
|
||||
"alignToPath": t.path_align.id(),
|
||||
"spacing": t.path_spacing,
|
||||
})
|
||||
}
|
||||
|
||||
fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "type.pathOptions";
|
||||
let ids = texts(s, p, C)?;
|
||||
let start = p.get("start").and_then(Value::as_f64);
|
||||
let ids: Vec<NodeId> = {
|
||||
let d = &s.doc()?.doc;
|
||||
texts(s, p, C)?.into_iter().filter(|i| path_text(d.node(*i)).is_some()).collect()
|
||||
};
|
||||
let first = *ids.first().ok_or_else(|| bad(C, "select type on a path"))?;
|
||||
let options = |s: &Session| -> Result<Value> {
|
||||
let t = path_text(s.doc()?.doc.node(first)).ok_or_else(|| bad(C, "select type on a path"))?;
|
||||
Ok(path_options_of(t))
|
||||
};
|
||||
if !PATH_OPTIONS.iter().any(|k| p.get(k).is_some()) {
|
||||
return options(s);
|
||||
}
|
||||
// A bracket: a fraction of the path's length.
|
||||
let fraction = |k: &str| -> Result<Option<f64>> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_f64().filter(|x| x.is_finite()).map(|x| Some(x.clamp(0.0, 1.0))).ok_or_else(|| bad(C, format!("`{k}` is a number 0..1"))),
|
||||
}
|
||||
};
|
||||
let start = fraction("start")?;
|
||||
// `end: null` puts the end bracket back at the end of the path.
|
||||
let end = p.get("end").map(|_| fraction("end")).transpose()?;
|
||||
let flip = bool_or(p, "flip", false);
|
||||
let effect = match str_param(p, "effect") {
|
||||
Some(e) => Some(vectorcraft_doc::PathEffect::parse(e).ok_or_else(|| bad(C, format!("unknown effect `{e}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let n = s.edit("Type on a Path Options", |d, _| {
|
||||
let mut n = 0;
|
||||
let align = match str_param(p, "alignToPath") {
|
||||
Some(a) => Some(vectorcraft_doc::PathAlign::parse(a).ok_or_else(|| bad(C, format!("unknown alignToPath `{a}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let spacing = p.get("spacing").and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(-1000.0, 1000.0));
|
||||
s.edit("Type on a Path Options", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let TextKind::OnPath { path, start: st } = &mut t.kind else { continue };
|
||||
let TextKind::OnPath { start: st, end: en, .. } = &mut t.kind else { continue };
|
||||
if let Some(v) = start {
|
||||
*st = v.clamp(0.0, 1.0);
|
||||
*st = v;
|
||||
}
|
||||
if let Some(v) = end {
|
||||
*en = v;
|
||||
}
|
||||
// After the brackets: they are given on the path as it runs before the flip.
|
||||
if flip {
|
||||
path.reverse();
|
||||
t.flip_on_path();
|
||||
}
|
||||
if let Some(e) = effect {
|
||||
t.path_effect = e;
|
||||
}
|
||||
n += 1;
|
||||
if let Some(a) = align {
|
||||
t.path_align = a;
|
||||
}
|
||||
if let Some(v) = spacing {
|
||||
t.path_spacing = v;
|
||||
}
|
||||
refresh_bounds(t);
|
||||
}
|
||||
if n == 0 {
|
||||
return Err(bad(C, "select type on a path"));
|
||||
}
|
||||
Ok(n)
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "changed": n }))
|
||||
options(s)
|
||||
}
|
||||
|
||||
// ---------- placeholder / insert ----------
|
||||
|
||||
@@ -1399,6 +1399,8 @@ mod tests_panelcmds;
|
||||
#[cfg(test)]
|
||||
mod tests_pathops;
|
||||
#[cfg(test)]
|
||||
mod tests_pathtype;
|
||||
#[cfg(test)]
|
||||
mod tests_pattern;
|
||||
#[cfg(test)]
|
||||
mod tests_pdffidelity;
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//! Type on a path (#429): Type on a Path Options keep its brackets, flip it, align it to the path
|
||||
//! and space it; the selection tools drag its brackets to move it along the path and across it.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeKind, PathAlign, TextKind, TextObject};
|
||||
use vectorcraft_geom::{Point, Rect, Shape};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// 12 pt type on a path along `d` (SVG path data), starting where the path is nearest `at`.
|
||||
fn path_type(s: &mut Session, d: &str, at: [f64; 2], text: &str) -> NodeId {
|
||||
let pid = s.execute("path.create", &json!({ "d": d })).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": text, "size": 12, "at": at})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// Type on the 400 pt line from (100, 300) to (500, 300), from its middle on.
|
||||
fn on_line(s: &mut Session) -> NodeId {
|
||||
path_type(s, "M100 300 L500 300", [300.0, 310.0], "Slide")
|
||||
}
|
||||
|
||||
fn text(s: &Session, id: NodeId) -> TextObject {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
k => panic!("not text: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The type's start and end brackets as stored.
|
||||
fn brackets(s: &Session, id: NodeId) -> (f64, Option<f64>) {
|
||||
match text(s, id).kind {
|
||||
TextKind::OnPath { start, end, .. } => (start, end),
|
||||
k => panic!("not type on a path: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyphs' ink bounds in the document.
|
||||
fn ink(s: &Session, id: NodeId) -> Rect {
|
||||
let t = text(s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
lay.glyphs
|
||||
.iter()
|
||||
.filter(|g| !g.outline.elements().is_empty())
|
||||
.map(|g| t.xf.transform_rect_bbox(g.outline.bounding_box()))
|
||||
.reduce(|a, b| a.union(b))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The glyph origins along the path, in reading order.
|
||||
fn origins(s: &Session, id: NodeId) -> Vec<Point> {
|
||||
let t = text(s, id);
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).glyphs.iter().map(|g| t.xf * g.origin).collect()
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn last_step(s: &Session) -> String {
|
||||
s.doc().unwrap().history.undo.last().unwrap().label.clone()
|
||||
}
|
||||
|
||||
/// Press, drag and release `tool` from `from` to `to`.
|
||||
fn drag(s: &mut Session, tool: &str, from: Point, to: Point, mods: Mods) {
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool(tool, v).unwrap();
|
||||
for (kind, p) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
|
||||
s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(mods), v).unwrap();
|
||||
}
|
||||
assert!(!s.in_interaction());
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-3
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_keep_the_brackets() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s);
|
||||
assert!(near(brackets(&s, id).0, 0.5));
|
||||
// The query (what the dialog shows) has the current options.
|
||||
let mut q = s.execute("type.pathOptions", &json!({})).unwrap();
|
||||
assert!(near(q["start"].as_f64().unwrap(), 0.5), "{q}");
|
||||
q.as_object_mut().unwrap().remove("start");
|
||||
assert_eq!(q, json!({"end": null, "flip": false, "effect": "rainbow", "alignToPath": "baseline", "spacing": 0.0}));
|
||||
let start = brackets(&s, id).0;
|
||||
// OK with the dialog's values (effect, flip, align, spacing) leaves the type where it is.
|
||||
let before = origins(&s, id);
|
||||
s.execute("type.pathOptions", &json!({"effect": "rainbow", "flip": false, "alignToPath": "baseline", "spacing": 0})).unwrap();
|
||||
assert_eq!(brackets(&s, id), (start, None));
|
||||
assert_eq!(origins(&s, id), before);
|
||||
// So does a new effect.
|
||||
s.execute("type.pathOptions", &json!({"effect": "skew"})).unwrap();
|
||||
assert_eq!(brackets(&s, id), (start, None));
|
||||
assert_eq!(text(&s, id).path_effect, vectorcraft_doc::PathEffect::Skew);
|
||||
// The end bracket is set, and put back at the end of the path.
|
||||
s.execute("type.pathOptions", &json!({"end": 0.9})).unwrap();
|
||||
assert_eq!(brackets(&s, id), (start, Some(0.9)));
|
||||
s.execute("type.pathOptions", &json!({"end": null})).unwrap();
|
||||
assert_eq!(brackets(&s, id), (start, None));
|
||||
for bad in [json!({"start": "x"}), json!({"effect": "nope"}), json!({"alignToPath": "top"})] {
|
||||
assert!(s.execute("type.pathOptions", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flip_turns_the_type_to_the_other_side_of_its_stretch() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s);
|
||||
let up = ink(&s, id);
|
||||
assert!(up.y1 < 301.0 && up.x0 > 299.0, "above the path from its middle: {up:?}");
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("type.pathOptions", &json!({"flip": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
// The path runs the other way and the brackets swap ends: the type keeps the right half.
|
||||
assert!(near(r["start"].as_f64().unwrap(), 0.0) && near(r["end"].as_f64().unwrap(), 0.5), "{r}");
|
||||
let down = ink(&s, id);
|
||||
assert!(down.y0 > 299.0 && down.x0 > 299.0, "below the path, on the same half: {down:?}");
|
||||
let o = origins(&s, id);
|
||||
assert!(o.first().unwrap().x > o.last().unwrap().x, "reading right to left: {o:?}");
|
||||
// Flipped back, it is as it was.
|
||||
s.execute("type.pathOptions", &json!({"flip": true})).unwrap();
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.5) && end.is_none(), "{start} {end:?}");
|
||||
assert!(ink(&s, id).y1 < 301.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flip_round_a_closed_path_keeps_the_type_in_place() {
|
||||
let mut s = session();
|
||||
let id = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [200.0, 90.0], "Ring");
|
||||
let (start, _) = brackets(&s, id);
|
||||
assert!(near(start, 0.125), "{start}");
|
||||
let first = origins(&s, id)[0];
|
||||
s.execute("type.pathOptions", &json!({"flip": true})).unwrap();
|
||||
// Once round from the same point, the other way.
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.875) && end.is_none(), "{start} {end:?}");
|
||||
assert!(origins(&s, id)[0].distance(first) < 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_to_path_and_spacing() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s);
|
||||
s.execute("type.pathOptions", &json!({"alignToPath": "ascender"})).unwrap();
|
||||
assert_eq!(text(&s, id).path_align, PathAlign::Ascender);
|
||||
let hang = ink(&s, id);
|
||||
assert!(hang.y0 > 299.0, "hangs below the path: {hang:?}");
|
||||
s.execute("type.pathOptions", &json!({"alignToPath": "center"})).unwrap();
|
||||
let mid = ink(&s, id);
|
||||
assert!(mid.y0 < 300.0 && mid.y1 > 300.0, "the path runs through it: {mid:?}");
|
||||
// Spacing closes glyphs up round the outside of a curve.
|
||||
let id = path_type(&mut s, "M100 400 A60 60 0 0 1 220 400", [100.0, 400.0], "Round the bend");
|
||||
let spread = |s: &Session| {
|
||||
let o = origins(s, id);
|
||||
o.first().unwrap().distance(*o.last().unwrap())
|
||||
};
|
||||
let loose = spread(&s);
|
||||
s.execute("type.pathOptions", &json!({"spacing": 6})).unwrap();
|
||||
assert!(spread(&s) < loose - 1.0, "{} < {loose}", spread(&s));
|
||||
assert_eq!(s.execute("type.pathOptions", &json!({})).unwrap()["spacing"], json!(6.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_centre_bracket_slides_the_type_and_across_the_path_flips_it() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s);
|
||||
let n = undo_len(&s);
|
||||
// The centre bracket stands midway between the start (300) and the end of the path (500).
|
||||
drag(&mut s, "selection", Point::new(400.0, 295.0), Point::new(440.0, 295.0), Mods::default());
|
||||
assert_eq!(undo_len(&s), n + 1, "one step");
|
||||
assert_eq!(last_step(&s), "Move Type on a Path");
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.6) && end.is_none(), "{start} {end:?}");
|
||||
assert!(ink(&s, id).x0 > 339.0);
|
||||
// Direct Selection drags it too; Cmd/Ctrl held below the path only slides it.
|
||||
drag(&mut s, "directSelection", Point::new(420.0, 295.0), Point::new(380.0, 330.0), Mods { cmd: true, ..Mods::default() });
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.5) && end.is_none(), "{start} {end:?}");
|
||||
assert!(ink(&s, id).y1 < 301.0, "still above the path");
|
||||
// Across the path: flipped, on the same stretch.
|
||||
drag(&mut s, "selection", Point::new(400.0, 295.0), Point::new(400.0, 330.0), Mods::default());
|
||||
assert_eq!(undo_len(&s), n + 3);
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.0) && end.is_some_and(|e| near(e, 0.5)), "{start} {end:?}");
|
||||
assert!(ink(&s, id).y0 > 299.0, "below the path");
|
||||
// The step undoes as one.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.5) && end.is_none(), "{start} {end:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_start_and_end_brackets_sets_where_the_type_runs() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s);
|
||||
// The start bracket from the middle (300) to 200.
|
||||
drag(&mut s, "selection", Point::new(300.0, 290.0), Point::new(200.0, 290.0), Mods::default());
|
||||
assert!(near(brackets(&s, id).0, 0.25));
|
||||
// The end bracket from the end of the path to 400.
|
||||
drag(&mut s, "directSelection", Point::new(500.0, 290.0), Point::new(400.0, 290.0), Mods::default());
|
||||
let (start, end) = brackets(&s, id);
|
||||
assert!(near(start, 0.25) && end.is_some_and(|e| near(e, 0.75)), "{start} {end:?}");
|
||||
let v: Value = s.execute("type.pathOptions", &json!({})).unwrap();
|
||||
assert!(near(v["end"].as_f64().unwrap(), 0.75));
|
||||
let r = ink(&s, id);
|
||||
assert!(r.x0 > 199.0 && r.x1 < 401.0, "between the brackets: {r:?}");
|
||||
}
|
||||
Reference in New Issue
Block a user