mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Threaded text: type on a path threads, mixed with area type
Illustrator links type in an area or along a path (https://helpx.adobe.com/illustrator/using/manage-text-area.html); until now only area type threaded here. Type on a path that overflows ends its line where its text stops fitting, so a story flows on from it, and its red out port, just past its end bracket, loads the Selection tool with the text as area type's does. A new frame threaded from it at a point is type on a path of its shape.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
//! Type → Threaded Text: one story flowing through area-type frames in order.
|
||||
//! Type → Threaded Text: one story flowing through frames in order: area type and type on a path,
|
||||
//! in any mix. Illustrator links type objects of any shape, as long as their text is in an area or
|
||||
//! along a path, not at a point (<https://helpx.adobe.com/illustrator/using/manage-text-area.html>).
|
||||
//!
|
||||
//! Frames are ordinary area text objects holding their slice of the story; the document lists the
|
||||
//! Frames are ordinary text objects holding their slice of the story; the document lists the
|
||||
//! thread order. After every edit, threads whose frames changed are re-flowed ([`reflow`]).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -17,7 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create",
|
||||
["Type", "Threaded Text"],
|
||||
None,
|
||||
"{} thread the selected area type (and closed paths, which become frames) in stacking order → {ids}",
|
||||
"{} thread the selected area type and type on a path (and closed paths, which become area frames) in stacking order → {ids}",
|
||||
has_multi,
|
||||
create
|
||||
),
|
||||
@@ -26,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Thread Text",
|
||||
[],
|
||||
None,
|
||||
"{from, to?, rect?: [x, y, width, height], at?: [x, y]} thread area type `from` (a click on its out port) into one more frame, put after it in its thread: closed path or area type `to`, a new rectangle `rect`, or a new frame of `from`'s shape and size with its top-left at `at` (give one of the three) → {id}; the two frames are selected",
|
||||
"{from, to?, rect?: [x, y, width, height], at?: [x, y]} thread area type or type on a path `from` (a click on its out port) into one more frame, put after it in its thread: closed path, area type or type on a path `to`, a new rectangle `rect`, or a new frame of `from`'s kind, shape and size with its top-left at `at` (give one of the three) → {id}; the two frames are selected",
|
||||
has_doc,
|
||||
add
|
||||
),
|
||||
@@ -64,9 +66,10 @@ fn has_thread_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The text object `id` when it can be a frame of a thread: area type or type on a path.
|
||||
fn text_of(d: &Document, id: NodeId) -> Option<&TextObject> {
|
||||
match d.node(id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::Area { .. }) => Some(t),
|
||||
Some(NodeKind::Text(t)) if !matches!(t.kind, TextKind::Point) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -261,7 +264,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
|
||||
let from = checked_id(s, given("from").unwrap_or(&Value::Null), C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let src = text_of(d, from).ok_or_else(|| bad(C, "`from` must be area type"))?;
|
||||
let src = text_of(d, from).ok_or_else(|| bad(C, "`from` must be area type or type on a path"))?;
|
||||
let style = src.first_style();
|
||||
// The frame to thread into: an existing object (`to`), or a new one beside `from`.
|
||||
let (to, new) = match (given("to"), given("rect"), given("at")) {
|
||||
@@ -270,7 +273,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let parent = d.parent_of(to).and_then(|i| d.node(i));
|
||||
let frame = text_of(d, to).is_some() || d.node(to).is_some_and(|n| frame_from_path(n, style.clone()).is_some());
|
||||
if to == from || !frame || parent.is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) {
|
||||
return Err(bad(C, "`to` must be a closed path or area type other than `from`"));
|
||||
return Err(bad(C, "`to` must be a closed path, area type or type on a path other than `from`"));
|
||||
}
|
||||
if d.text_threads.iter().any(|t| t.contains(&to)) {
|
||||
return Err(bad(C, "`to` is already threaded"));
|
||||
@@ -282,14 +285,18 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.filter(|[_, _, w, h]| *w > 0.0 && *h > 0.0)
|
||||
.ok_or_else(|| bad(C, "`rect` must be [x, y, width, height] with a positive size"))?;
|
||||
let frame = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, w, h));
|
||||
(NodeId(0), Some(empty_frame(src, style.clone(), frame, vectorcraft_geom::Affine::translate((x, y)))))
|
||||
(NodeId(0), Some(empty_frame(src, style.clone(), TextKind::Area { frame }, vectorcraft_geom::Affine::translate((x, y)))))
|
||||
}
|
||||
(None, None, Some(v)) => {
|
||||
let [x, y] = finite_numbers(v).ok_or_else(|| bad(C, "`at` must be [x, y]"))?;
|
||||
let TextKind::Area { frame } = &src.kind else { return Err(bad(C, "`from` must be area type")) };
|
||||
let b = frame.bounds().map(|b| src.xf.transform_rect_bbox(b)).ok_or_else(|| bad(C, "`from` has an empty frame"))?;
|
||||
let shape = match &src.kind {
|
||||
TextKind::Area { frame } => frame,
|
||||
TextKind::OnPath { path, .. } => path,
|
||||
TextKind::Point => return Err(bad(C, "`from` must be area type or type on a path")),
|
||||
};
|
||||
let b = shape.bounds().map(|b| src.xf.transform_rect_bbox(b)).ok_or_else(|| bad(C, "`from` has an empty frame"))?;
|
||||
let xf = vectorcraft_geom::Affine::translate((x - b.x0, y - b.y0)) * src.xf;
|
||||
(NodeId(0), Some(empty_frame(src, style.clone(), frame.clone(), xf)))
|
||||
(NodeId(0), Some(empty_frame(src, style.clone(), src.kind.clone(), xf)))
|
||||
}
|
||||
_ => return Err(bad(C, "give one of `to`, `rect` or `at`")),
|
||||
};
|
||||
@@ -330,12 +337,13 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
/// An empty area-type frame `frame` placed by `xf`, with the type options of `src`.
|
||||
fn empty_frame(src: &TextObject, style: vectorcraft_doc::CharStyle, frame: vectorcraft_geom::PathData, xf: vectorcraft_geom::Affine) -> TextObject {
|
||||
/// An empty frame of `kind` (area type or type on a path) placed by `xf`, with the type options of
|
||||
/// `src`.
|
||||
fn empty_frame(src: &TextObject, style: vectorcraft_doc::CharStyle, kind: TextKind, xf: vectorcraft_geom::Affine) -> TextObject {
|
||||
let mut t = src.clone();
|
||||
t.runs = vec![TextRun { text: String::new(), style, inline: None }];
|
||||
t.set_all_paras(src.para_at(0).clone());
|
||||
t.kind = TextKind::Area { frame };
|
||||
t.kind = kind;
|
||||
t.xf = xf;
|
||||
refresh_bounds(&mut t);
|
||||
t
|
||||
@@ -593,6 +601,48 @@ mod tests {
|
||||
assert_eq!(threads(&s), [[a, c, d]]);
|
||||
}
|
||||
|
||||
/// Type on a path along a line beside the area type of [`setup`].
|
||||
fn type_on_a_line(s: &mut Session) -> u64 {
|
||||
let line = new_id(s.execute("shape.line", &json!({"x1": 10, "y1": 250, "x2": 110, "y2": 250})).unwrap());
|
||||
new_id(s.execute("text.createInPath", &json!({"path": line, "mode": "onPath", "text": ""})).unwrap())
|
||||
}
|
||||
|
||||
/// Type on a path threads as area type does, in any mix: a story flows from area type along a
|
||||
/// path and on into area type, and a new frame threaded from type on a path at a point is type
|
||||
/// on a path of the same shape.
|
||||
#[test]
|
||||
fn type_on_a_path_threads_with_area_type() {
|
||||
let (mut s, a, b) = setup();
|
||||
let on_path = type_on_a_line(&mut s);
|
||||
s.execute("text.thread.add", &json!({"from": a, "to": on_path})).unwrap();
|
||||
s.execute("text.thread.add", &json!({"from": on_path, "to": b})).unwrap();
|
||||
assert_eq!(threads(&s), [[a, on_path, b]]);
|
||||
let shares = [a, on_path, b].map(|id| text(&s, id));
|
||||
assert!(shares.iter().all(|t| !t.is_empty()), "each frame takes a share: {shares:?}");
|
||||
assert_eq!(shares.concat(), TEXT.repeat(4));
|
||||
|
||||
let c = new_id(s.execute("text.thread.add", &json!({"from": on_path, "at": [300, 250]})).unwrap());
|
||||
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(c)).unwrap().kind else { panic!("not text") };
|
||||
assert!(matches!(t.kind, TextKind::OnPath { .. }), "type on a path, as the frame it was threaded from");
|
||||
assert_eq!(threads(&s), [[a, on_path, c, b]]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(threads(&s), [[a, on_path, b]]);
|
||||
}
|
||||
|
||||
/// Type on a path released from its thread gives its text back to the frame before it.
|
||||
#[test]
|
||||
fn type_on_a_path_is_released_from_its_thread() {
|
||||
let (mut s, a, _) = setup();
|
||||
let on_path = type_on_a_line(&mut s);
|
||||
s.execute("text.thread.add", &json!({"from": a, "to": on_path})).unwrap();
|
||||
assert!(!text(&s, on_path).is_empty());
|
||||
s.execute("select.set", &json!({"ids": [on_path]})).unwrap();
|
||||
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
|
||||
assert!(threads(&s).is_empty());
|
||||
assert!(text(&s, on_path).is_empty());
|
||||
assert_eq!(text(&s, a), TEXT.repeat(4));
|
||||
}
|
||||
|
||||
/// Area type threaded into from an out port takes the overflow at once, before its own text.
|
||||
#[test]
|
||||
fn add_into_area_type_flows_the_story_into_it() {
|
||||
|
||||
@@ -2263,7 +2263,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
|
||||
descent: m.1,
|
||||
first_baseline: 0.0,
|
||||
start: 0,
|
||||
end: text_len,
|
||||
end: 0,
|
||||
glyph_start: 0,
|
||||
glyph_end: 0,
|
||||
avail: (0.0, 0.0),
|
||||
@@ -2353,11 +2353,15 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
|
||||
PathAlign::Center => (m.0 - m.1) * 0.5,
|
||||
};
|
||||
let mut x = 0.0;
|
||||
// Where the text that fits ends: before the first glyph that doesn't, which threaded text
|
||||
// flows on from, as from the last line of area type.
|
||||
let mut fits = text_len;
|
||||
for (k, (g, &step)) in sg.iter().zip(&steps).enumerate() {
|
||||
let s = s0 + x;
|
||||
let end = if k + hung_r >= sg.len() { room } else { room - hang_r };
|
||||
if s + step - lo > end + 1e-6 {
|
||||
cx.out.overflow = true;
|
||||
fits = sg.get(k..).and_then(|rest| rest.iter().map(|g| g.byte).min()).unwrap_or(text_len);
|
||||
break;
|
||||
}
|
||||
let (p, dir) = ap.at(s + step * 0.5);
|
||||
@@ -2406,7 +2410,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
|
||||
descent: m.1,
|
||||
first_baseline: 0.0,
|
||||
start: 0,
|
||||
end: text_len,
|
||||
end: fits,
|
||||
glyph_start: 0,
|
||||
glyph_end: cx.out.glyphs.len(),
|
||||
avail: (0.0, ap.len()),
|
||||
|
||||
@@ -47,7 +47,8 @@ fn slice_paras(paras: &[ParaStyle], first: usize, runs: &[TextRun]) -> Vec<ParaS
|
||||
}
|
||||
|
||||
/// How many bytes of `runs` fit in `frame` (whole lines; a following break space or paragraph
|
||||
/// break stays with the line that ends there).
|
||||
/// break stays with the line that ends there, but on a path, where a space with no room
|
||||
/// overflows as any character does, it starts the next frame).
|
||||
fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize {
|
||||
let mut probe = frame.clone();
|
||||
probe.runs = runs.to_vec();
|
||||
@@ -57,6 +58,9 @@ fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize {
|
||||
return text.len();
|
||||
}
|
||||
let mut end = lay.lines.last().map_or(0, |l| l.end);
|
||||
if lay.on_path {
|
||||
return end;
|
||||
}
|
||||
let bytes = text.as_bytes();
|
||||
while end < bytes.len() && bytes[end] == b' ' {
|
||||
end += 1;
|
||||
@@ -112,7 +116,7 @@ pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras:
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_doc::{CharStyle, Justify, TextKind};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
use vectorcraft_geom::{Affine, PathData, Rect, shapes};
|
||||
|
||||
fn frame(x: f64) -> TextObject {
|
||||
let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "", CharStyle::default());
|
||||
@@ -121,6 +125,73 @@ mod tests {
|
||||
t
|
||||
}
|
||||
|
||||
/// Type on a straight path `length` long.
|
||||
fn path_frame(length: f64) -> TextObject {
|
||||
let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "", CharStyle::default());
|
||||
let line = kurbo::Line::new((0.0, 0.0), (length, 0.0));
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Shape::to_path(&line, 0.1)), start: 0.0, end: None };
|
||||
t.xf = Affine::IDENTITY;
|
||||
t
|
||||
}
|
||||
|
||||
/// Type on a path that overflows ends its line where its text stops fitting, as area type's
|
||||
/// last line does, so a story flows on from there through area type and type on a path alike.
|
||||
#[test]
|
||||
fn story_flows_across_area_type_and_type_on_a_path() {
|
||||
let db = FontDb::global();
|
||||
let text = "The quick brown fox jumps over the lazy dog. ".repeat(6);
|
||||
let story = vec![TextRun { text: text.clone(), style: CharStyle::default(), inline: None }];
|
||||
let mut path = path_frame(100.0);
|
||||
path.runs = story.clone();
|
||||
let laid = layout(db, &path);
|
||||
let end = laid.lines.first().map_or(0, |l| l.end);
|
||||
assert!(laid.overflow && end > 0 && end < text.len(), "the path's line ends where its text stops: {end}");
|
||||
let (a, b, c) = (frame(0.0), path_frame(100.0), frame(400.0));
|
||||
let parts: Vec<Vec<TextRun>> = distribute(db, &[&a, &b, &c], &story, &[]).into_iter().map(|s| s.runs).collect();
|
||||
let lens: Vec<usize> = parts.iter().map(|p| runs_len(p)).collect();
|
||||
assert!(lens.iter().all(|l| *l > 0), "each frame takes a share: {lens:?}");
|
||||
let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect();
|
||||
assert_eq!(joined, text, "no character lost or duplicated");
|
||||
let mut on_path = b.clone();
|
||||
on_path.runs = parts[1].clone();
|
||||
assert!(!layout(db, &on_path).overflow, "the path's share fits it");
|
||||
}
|
||||
|
||||
/// A path that runs out of room at a space holds only what it shows: the space starts the
|
||||
/// next frame, as a space with no room on a path overflows.
|
||||
#[test]
|
||||
fn a_path_that_runs_out_at_a_space_leaves_it_to_the_next_frame() {
|
||||
let db = FontDb::global();
|
||||
let text = "The quick brown fox jumps over the lazy dog. ".repeat(6);
|
||||
let story = vec![TextRun { text: text.clone(), style: CharStyle::default(), inline: None }];
|
||||
let mut at_space = 0;
|
||||
for length in (60..160).step_by(3) {
|
||||
let (a, b) = (path_frame(f64::from(length)), frame(400.0));
|
||||
let parts: Vec<Vec<TextRun>> = distribute(db, &[&a, &b], &story, &[]).into_iter().map(|s| s.runs).collect();
|
||||
let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect();
|
||||
assert_eq!(joined, text, "no character lost or duplicated");
|
||||
let mut on_path = a.clone();
|
||||
on_path.runs = parts[0].clone();
|
||||
assert!(!layout(db, &on_path).overflow, "the path's share fits it ({length})");
|
||||
at_space += usize::from(parts[1].iter().map(|r| r.text.as_str()).collect::<String>().starts_with(' '));
|
||||
}
|
||||
assert!(at_space > 0, "some lengths end at a space");
|
||||
}
|
||||
|
||||
/// A path with no length holds no text, so the story flows past it.
|
||||
#[test]
|
||||
fn story_flows_past_a_path_with_no_length() {
|
||||
let db = FontDb::global();
|
||||
let text = "The quick brown fox jumps over the lazy dog. ".repeat(6);
|
||||
let story = vec![TextRun { text: text.clone(), style: CharStyle::default(), inline: None }];
|
||||
let (a, b, c) = (frame(0.0), path_frame(0.0), frame(400.0));
|
||||
let parts: Vec<Vec<TextRun>> = distribute(db, &[&a, &b, &c], &story, &[]).into_iter().map(|s| s.runs).collect();
|
||||
let lens: Vec<usize> = parts.iter().map(|p| runs_len(p)).collect();
|
||||
assert!(lens[0] > 0 && lens[1] == 0 && lens[2] > 0, "{lens:?}");
|
||||
let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect();
|
||||
assert_eq!(joined, text, "no character lost or duplicated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn story_flows_across_frames_and_rejoins() {
|
||||
let db = FontDb::global();
|
||||
|
||||
@@ -130,7 +130,7 @@ look = "Arrow with a type badge (a letter T)."
|
||||
id = "LoadedText"
|
||||
group = "Selection"
|
||||
shown_when = [
|
||||
"The Selection tool after a click on the red out port of selected area type whose text overflows: the pointer carries that text until a click threads it into a closed path, a click on empty canvas makes a new frame of the same shape there, or a drag draws the new frame. Escape or another tool drops it.",
|
||||
"The Selection tool after a click on the red out port of selected area type or type on a path whose text overflows: the pointer carries that text until a click threads it into a closed path, area type or type on a path, a click on empty canvas makes a new frame of the same kind and shape there, or a drag draws the new frame. Escape or another tool drops it.",
|
||||
]
|
||||
hotspot = "Where the new frame's top-left corner goes (the click), or the path clicked."
|
||||
look = "A small block of text lines: the overflow text the pointer carries. Until it is drawn it borrows the I-beam (Text)."
|
||||
|
||||
@@ -549,8 +549,8 @@ pub enum Cursor {
|
||||
/// Over the type widget of selected type (a double-click converts point type to area type and
|
||||
/// back): the arrow with a type badge.
|
||||
TypeWidget,
|
||||
/// The Selection tool loaded with the text of an area type's out port (a click threads it
|
||||
/// into a path or a new frame, a drag draws the new frame).
|
||||
/// The Selection tool loaded with the text of the out port of area type or type on a path (a
|
||||
/// click threads it into a path or a new frame, a drag draws the new frame).
|
||||
LoadedText,
|
||||
/// The Shape Builder: a crosshair with a plus (merge mode)...
|
||||
ShapeBuilder,
|
||||
|
||||
+49
-11
@@ -17,9 +17,10 @@
|
||||
//! scaling its type: the text reflows at its size.
|
||||
//! E pressed during a marquee drag makes the marquee take only the objects entirely inside it, and
|
||||
//! E pressed again makes it take the objects it touches.
|
||||
//! A click on the red out port of selected area type whose text overflows loads the pointer with
|
||||
//! that text ([`Cursor::LoadedText`]): the next click threads it into the closed path or area type
|
||||
//! under the pointer, or into a new frame of the same shape and size at the click on empty canvas,
|
||||
//! A click on the red out port of selected area type or type on a path whose text overflows loads
|
||||
//! the pointer with that text ([`Cursor::LoadedText`]): the next click threads it into the closed
|
||||
//! path, area type or type on a path under the pointer, or into a new frame of the same kind, shape
|
||||
//! and size at the click on empty canvas,
|
||||
//! and a drag draws a rectangle for the new frame (`text.thread.add`). Escape drops the loaded text.
|
||||
|
||||
use std::cell::RefCell;
|
||||
@@ -83,8 +84,8 @@ enum State {
|
||||
Mirror(crate::mirror::MirrorDrag),
|
||||
/// Dragging a bracket of type on a path.
|
||||
Bracket(BracketDrag),
|
||||
/// Loaded with the text of area type `from` by a click on its out port; `press`: where the
|
||||
/// next press went down and where the pointer is now.
|
||||
/// Loaded with the text of area type or type on a path `from` by a click on its out port;
|
||||
/// `press`: where the next press went down and where the pointer is now.
|
||||
Loaded {
|
||||
from: NodeId,
|
||||
press: Option<(Point, Point)>,
|
||||
@@ -430,13 +431,13 @@ impl SelectionTool {
|
||||
})
|
||||
}
|
||||
|
||||
/// The pointer loaded with the text of area type `from` ([`State::Loaded`]): a press starts
|
||||
/// placing it, and its release threads it (`text.thread.add`). None when `from` is no longer
|
||||
/// area type: the press is an ordinary one.
|
||||
/// The pointer loaded with the text of area type or type on a path `from` ([`State::Loaded`]):
|
||||
/// a press starts placing it, and its release threads it (`text.thread.add`). None when `from`
|
||||
/// can no longer thread: the press is an ordinary one.
|
||||
fn loaded(&mut self, cx: &ToolContext, ev: &PointerEvent, from: NodeId, press: Option<(Point, Point)>) -> Option<Vec<Action>> {
|
||||
let p = ev.pos;
|
||||
match (ev.kind, press) {
|
||||
(PointerKind::Down, _) if !cx.doc.node(from).is_some_and(is_area_type) => {
|
||||
(PointerKind::Down, _) if !cx.doc.node(from).is_some_and(is_threadable) => {
|
||||
self.state = State::Idle;
|
||||
return None;
|
||||
}
|
||||
@@ -453,14 +454,15 @@ impl SelectionTool {
|
||||
}
|
||||
|
||||
/// Where the text of `from` goes for a press at `start` released at `end`: a dragged rectangle,
|
||||
/// else the closed path or area type pressed on, else a new frame with its top-left at `start`.
|
||||
/// else the closed path, area type or type on a path pressed on, else a new frame with its
|
||||
/// top-left at `start`.
|
||||
fn thread_params(cx: &ToolContext, from: NodeId, start: Point, end: Point) -> Value {
|
||||
let r = Rect::from_points(start, end);
|
||||
if r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0) {
|
||||
return json!({ "from": from.0, "rect": [r.x0, r.y0, r.width(), r.height()] });
|
||||
}
|
||||
let frame = |n: &vectorcraft_doc::Node| {
|
||||
is_area_type(n) || matches!(&n.kind, vectorcraft_doc::NodeKind::Path { path, .. } if path.subpaths.first().is_some_and(|s| s.closed))
|
||||
is_threadable(n) || matches!(&n.kind, vectorcraft_doc::NodeKind::Path { path, .. } if path.subpaths.first().is_some_and(|s| s.closed))
|
||||
};
|
||||
// The art the text flows into: what is drawn, not a clipping path's outline.
|
||||
let to =
|
||||
@@ -927,6 +929,11 @@ pub(crate) fn is_area_type(n: &vectorcraft_doc::Node) -> bool {
|
||||
&& matches!(&n.kind, vectorcraft_doc::NodeKind::Text(t) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }))
|
||||
}
|
||||
|
||||
/// Can `n` be a frame of a thread: area type or type on a path, outside perspective?
|
||||
pub(crate) fn is_threadable(n: &vectorcraft_doc::Node) -> bool {
|
||||
n.extras().perspective.is_none() && matches!(&n.kind, vectorcraft_doc::NodeKind::Text(t) if !matches!(t.kind, vectorcraft_doc::TextKind::Point))
|
||||
}
|
||||
|
||||
/// Does a double-click or the Control bar's Isolate Selected Object button isolate an object of
|
||||
/// this kind? A group, a compound path or a single path, image, mesh or placed file does. Blends,
|
||||
/// envelopes, repeats and symbol instances do not; they have editing modes of their own.
|
||||
@@ -2187,6 +2194,37 @@ mod tests {
|
||||
(d, id, rect, port)
|
||||
}
|
||||
|
||||
/// Type on a path whose text overflows has its out port just past its end bracket, along the
|
||||
/// path: a click there loads the pointer with its text, as area type's does.
|
||||
#[test]
|
||||
fn the_out_port_of_type_on_a_path_loads_its_text() {
|
||||
let (mut d, id) = doc_with_area_type();
|
||||
let Some(vectorcraft_doc::NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("not text") };
|
||||
let line = vectorcraft_geom::kurbo::Line::new((0.0, 200.0), (100.0, 200.0));
|
||||
t.kind = vectorcraft_doc::TextKind::OnPath {
|
||||
path: vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::kurbo::Shape::to_path(&line, 0.1)),
|
||||
start: 0.0,
|
||||
end: Some(0.8),
|
||||
};
|
||||
t.xf = vectorcraft_geom::Affine::IDENTITY;
|
||||
t.runs = vec![vectorcraft_doc::TextRun::new("Words that run past the end of the path. ".repeat(5), vectorcraft_doc::CharStyle::default())];
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
// The end bracket stands at 80, 200; the port past it, along the path.
|
||||
let port = Point::new(80.0 + crate::text::PORT_RISE_PX, 200.0);
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)).is_empty());
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y)).is_empty());
|
||||
assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText);
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0)).is_empty());
|
||||
assert_eq!(
|
||||
t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)),
|
||||
vec![Action::Exec("text.thread.add".into(), json!({"from": id.0, "at": [30.0, 420.0]}))]
|
||||
);
|
||||
}
|
||||
|
||||
/// #2572: a click on the out port loads the pointer with the text; a click on empty canvas then
|
||||
/// threads it into a new frame there, a click on a closed path into the path, a drag into the
|
||||
/// rectangle it draws.
|
||||
|
||||
@@ -325,7 +325,8 @@ pub const PORT_RISE_PX: f64 = 12.0;
|
||||
const PORT_HALF_PX: f64 = 4.5;
|
||||
|
||||
/// Centre of the out port of `t`: on the right side of an area type frame just above its
|
||||
/// bottom-right corner ([`PORT_RISE_PX`]), at the end of the path for type on a path.
|
||||
/// bottom-right corner ([`PORT_RISE_PX`]), and for type on a path just past its end bracket, where
|
||||
/// its text stops, clear of the bracket, which drags.
|
||||
fn out_port(t: &TextObject, cx: &ToolContext) -> Option<Point> {
|
||||
match &t.kind {
|
||||
TextKind::Area { frame } => {
|
||||
@@ -335,7 +336,17 @@ fn out_port(t: &TextObject, cx: &ToolContext) -> Option<Point> {
|
||||
let len = up.hypot();
|
||||
Some(if len > 1e-9 && len.is_finite() { corner + up * (cx.tol(PORT_RISE_PX).min(len / 2.0) / len) } else { corner })
|
||||
}
|
||||
TextKind::OnPath { path, .. } => path.to_bezpath().segments().last().map(|s| t.xf * vectorcraft_geom::ParamCurve::end(&s)),
|
||||
TextKind::OnPath { path, .. } => {
|
||||
let arc = vectorcraft_geom::ArcPath::new(path);
|
||||
let (_, end) = t.kind.path_span()?;
|
||||
if arc.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (p, dir) = arc.at(end * arc.len());
|
||||
let (at, ahead) = (t.xf * p, t.xf * (p + dir) - t.xf * p);
|
||||
let len = ahead.hypot();
|
||||
Some(if len > 1e-9 && len.is_finite() { at + ahead * (cx.tol(PORT_RISE_PX) / len) } else { at })
|
||||
}
|
||||
TextKind::Point => None,
|
||||
}
|
||||
}
|
||||
@@ -352,15 +363,16 @@ fn overflow_port(t: &TextObject, cx: &ToolContext, o: &mut Vec<Overlay>) {
|
||||
o.push(Overlay::Line { a: Point::new(m.x, m.y - k), b: Point::new(m.x, m.y + k), color: red, dashed: false });
|
||||
}
|
||||
|
||||
/// The selected area type whose red out port is under `p` (only text that overflows shows one):
|
||||
/// a click there loads the Selection tool with its text, to thread into another frame.
|
||||
/// The selected area type or type on a path whose red out port is under `p` (only text that
|
||||
/// overflows shows one): a click there loads the Selection tool with its text, to thread into
|
||||
/// another frame.
|
||||
pub(crate) fn out_port_at(cx: &ToolContext, p: Point) -> Option<NodeId> {
|
||||
let h = cx.tol(PORT_HALF_PX + 1.0);
|
||||
let near = |t: &TextObject| out_port(t, cx).is_some_and(|c| (c.x - p.x).abs() <= h && (c.y - p.y).abs() <= h);
|
||||
cx.selection.objects.iter().copied().find(|id| {
|
||||
cx.doc
|
||||
.node(*id)
|
||||
.filter(|n| crate::select::is_area_type(n))
|
||||
.filter(|n| crate::select::is_threadable(n))
|
||||
.and_then(|_| TypeTool::text(cx, *id))
|
||||
.is_some_and(|t| near(t) && vectorcraft_text::layout(FontDb::global(), t).overflow)
|
||||
})
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
# Type parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (No Break keeps marked characters on one line; scores unchanged; a click on the out port threads the text into a closed path, a new frame at the click or a dragged rectangle, #2572 in part; scores unchanged; native family labels fall back to localized legacy names when typographic names are English-only, #2208; scores unchanged; Cmd+Shift+C aligns centre off the Mac, #1940; tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966; Cmd+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size, #1940; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (type on a path threads, mixed with area type in one thread; scores unchanged; No Break keeps marked characters on one line; scores unchanged; a click on the out port threads the text into a closed path, a new frame at the click or a dragged rectangle, #2572 in part; scores unchanged; native family labels fall back to localized legacy names when typographic names are English-only, #2208; scores unchanged; Cmd+Shift+C aligns centre off the Mac, #1940; tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966; Cmd+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size, #1940; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
|
||||
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
|
||||
@@ -21,8 +21,8 @@ against Illustrator's public documentation; **estimated** unless noted.
|
||||
| Native font-family labels | partial | With Show Font Names in English off, localized legacy names are used when typographic names contain no native label (#2208); loaded-font and catalog-only regressions. Choosing among multiple native languages is unchanged. |
|
||||
| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802); `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands |
|
||||
| Area Type Options (rows, columns, inset, first baseline, Auto Size, Shrink Text to Fit) | done | the commands that make or restyle type say when it does not fit its frame; MCP `add_text` takes Auto Size and Shrink Text to Fit (#1471) |
|
||||
| Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444) |
|
||||
| Threading from the out port: a click on the red "+" loads the text; a click on a closed path or area type threads into it, a click on empty canvas makes a frame of the same shape and size there, a drag draws a rectangular frame; Escape or another tool cancels | partial | #2572 in part: the Selection tool, `text.thread.add {from, to?, rect?, at?}`; only the red port of overflowing area type is clickable (empty ports and in ports are not drawn, so they can't be clicked yet), type on a path doesn't thread, and the loaded cursor borrows the I-beam until it's drawn |
|
||||
| Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444); a thread holds area type and type on a path in any mix, as Illustrator links type in an area or along a path ([help](https://helpx.adobe.com/illustrator/using/manage-text-area.html)) |
|
||||
| Threading from the out port: a click on the red "+" loads the text; a click on a closed path, area type or type on a path threads into it, a click on empty canvas makes a frame of the same kind, shape and size there, a drag draws a rectangular frame; Escape or another tool cancels | partial | #2572 in part: the Selection tool, `text.thread.add {from, to?, rect?, at?}`; only the red port of overflowing area type and type on a path is clickable (type on a path's sits just past its end bracket; empty ports and in ports are not drawn, so they can't be clicked yet), and the loaded cursor borrows the I-beam until it's drawn |
|
||||
| Type on a Path effects and options; brackets | done | #429 |
|
||||
| Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing |
|
||||
| Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. |
|
||||
@@ -64,6 +64,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-21 | minor | Type on a path threads, mixed with area type in one thread: its text that doesn't fit flows on into the next frame, and its red out port, just past its end bracket, loads the Selection tool with that text as area type's does; a new frame threaded from it at a point is type on a path of its shape; scores unchanged |
|
||||
| 2026-10-21 | minor | No Break: marked characters stay on one line; scores unchanged |
|
||||
| 2026-10-17 | minor | Roman Hanging Punctuation (#2756): per-paragraph hanging of Latin punctuation outside the margin; scores unchanged |
|
||||
| 2026-10-17 | minor | Justification… (#2755): word and letter spacing, glyph scaling, Auto Leading and Single Word Justification per paragraph; the default word spacing range is 80/100/133%, so justified lines break differently than with the old fixed 80–150%; scores unchanged |
|
||||
|
||||
Reference in New Issue
Block a user