Type: Dynamic Text turns point type into area type with Block on

Type > Dynamic Text and the Contextual Task Bar now also take point
type. Turning it on makes the point type area type with Block on, in
the same undo step:

- Along the lines, the frame is as wide as the widest line as typed
  with its paragraph's indents, measured with the word gap, so that
  line keeps its size and stays where it was across the page by its
  own alignment. The other lines are set to that width. When negative
  indents take more than that line's width, the frame is as wide as
  the line, placed so the line keeps its aligned point, and Block makes
  the line larger.
- The frame starts at the top of the text and grows down (to the left
  for vertical type) to hold every fitted line, around any Text Wrap
  objects above it, so nothing overflows.
- The new frame takes the default Area Type Options and keeps only the
  gaps; options kept from an earlier frame do not carry over.
- Point type with no line to fit (empty, or only spaces and breaks) is
  left as it is, and so is point type when `on` is false. Without `on`,
  point type counts as off, and the Type menu item is unchecked when
  point type comes first in the selection. A gap given with nothing it
  can apply to is an error.

Block now subtracts a list level's indent from the room it sets each
line to, as the layout does. Before, a nested list item was first set
too large for its line and then made smaller on a later pass.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-11 23:33:22 -08:00
parent 48e066f475
commit f8c234e670
11 changed files with 487 additions and 42 deletions
+1
View File
@@ -1365,6 +1365,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
mod area_tests;
mod block;
pub(crate) use block::needed_room;
#[cfg(test)]
mod block_tests;
+34 -1
View File
@@ -89,6 +89,39 @@ fn line_widths(db: &vectorcraft_text::FontDb, t: &TextObject) -> std::collection
out
}
/// The room along its lines that a frame of `t`'s text needs for Block to keep the widest line as
/// typed at its present size: that line's width plus its paragraph's indents, as [`block_fit`]
/// measures them, and a margin of under half a size step so that the line is not made a step
/// smaller; with the widest line's first byte and its paragraph's index. A line whose negative
/// indents take more than its width needs its own width. None when no line has a width.
pub(crate) fn needed_room(t: &TextObject) -> Option<(f64, usize, usize)> {
let points = t.style_scale().map(|s| s.points).filter(|p| p.is_finite() && *p > 1e-12)?;
let text = t.plain_text();
let lines = typed_lines(&text);
let widths = line_widths(vectorcraft_text::FontDb::global(), t);
let sizes = first_sizes(&t.runs, &lines);
lines
.iter()
.zip(&sizes)
.filter_map(|(l, size)| {
let width = widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9)?;
let size = size * points;
if l.content == l.start || !(size.is_finite() && size > 0.0) {
return None;
}
let para = t.para_at(l.para);
let first = if l.para_start { para.first_line_indent } else { 0.0 };
let indents = para.laid_out_left_indent() + para.right_indent + first;
// Negative indents that take more than the line's width leave the line no room at its
// size; the frame is then as wide as the line itself, and Block makes it larger.
let room = if width + indents > 1e-9 { width + indents } else { width };
// A tenth of a point more size widens the line by 0.1 * width / size.
Some((room + 0.04 * width / size, l.start, l.para))
})
.filter(|r| r.0.is_finite() && r.0 > 1e-9)
.reduce(|a, b| if b.0 > a.0 { b } else { a })
}
/// The size a line set at `size` points and `width` wide takes to fill `avail`, its width growing
/// by `slope` a point of size (`width / size` when the whole line follows its size): rounded down
/// to tenths and kept within [`SIZES`]. A line that fits and would not fit a tenth larger keeps its
@@ -152,7 +185,7 @@ pub(crate) fn block_fit(t: &mut TextObject) -> bool {
let width = widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9)?;
let para = t.para_at(l.para);
let first = if l.para_start { para.first_line_indent } else { 0.0 };
let avail = cell - para.left_indent - para.right_indent - first;
let avail = cell - para.laid_out_left_indent() - para.right_indent - first;
let size = size * points;
if !(size.is_finite() && size > 0.0) {
return None;
+249 -3
View File
@@ -188,13 +188,25 @@ fn bad_params_and_other_type_are_refused() {
assert!(!text(&s, id).dynamic_text);
let point = s.execute("text.create", &json!({"x": 10, "y": 500, "text": "point"})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [point]})).unwrap();
// Off leaves point type as it is, with no undo step, and a gap needs it turned on.
let n = undo_len(&s);
assert_eq!(s.execute("type.dynamicText", &json!({"on": false})).unwrap()["ids"], json!([point]));
let e = s.execute("type.dynamicText", &json!({"on": false, "wordGap": 200})).unwrap_err().to_string();
assert!(e.contains("need area type"), "{e}");
assert_eq!(undo_len(&s), n);
assert!(matches!(text(&s, point).kind, TextKind::Point));
let shape = s.execute("shape.rectangle", &json!({"x": 500, "y": 500, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [shape]})).unwrap();
let e = s.execute("type.dynamicText", &json!({})).unwrap_err().to_string();
assert!(e.contains("select type on a path or area type"), "{e}");
// With point type selected too, only the area type changes.
// With point type selected too, both turn on, the point type as area type with Block on.
s.execute("select.set", &json!({"ids": [id, point]})).unwrap();
let n = undo_len(&s);
let r = s.execute("type.dynamicText", &json!({})).unwrap();
assert_eq!(r["ids"], json!([id]));
assert!(text(&s, id).dynamic_text && !text(&s, point).dynamic_text);
assert_eq!(r["ids"], json!([id, point]));
assert!(text(&s, id).dynamic_text && text(&s, point).dynamic_text);
assert!(matches!(text(&s, point).kind, TextKind::Area { .. }));
assert_eq!(undo_len(&s), n + 1);
// A gap with only type on a path selected is an error, and nothing changes.
let path = s.execute("path.create", &json!({"d": "M60 550 L340 550"})).unwrap()["id"].as_u64().unwrap();
let along = s.execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "Along"})).unwrap()["id"].as_u64().unwrap();
@@ -438,6 +450,228 @@ fn threaded_frames_are_refused_and_threading_turns_block_off() {
assert!(matches!(text(&s, a).kind, TextKind::Area { .. }));
}
/// 12 pt point type at (300, 100) with `extra` params for `text.create`, selected.
fn point(s: &mut Session, text: &str, extra: Value) -> u64 {
let mut p = json!({"x": 300, "y": 100, "size": 12, "text": text});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
let id = s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
id
}
/// Where the first glyph of the laid-out line starting at byte `start` is in the document.
fn line_start(t: &TextObject, start: usize) -> vectorcraft_geom::Point {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let l = lay.lines.iter().find(|l| l.start == start).expect("a line starts there");
t.xf * lay.glyphs[l.glyph_start].origin
}
#[test]
fn point_type_becomes_area_type_with_block_at_its_widest_line() {
for justify in ["left", "center", "right"] {
let mut s = session();
let id = point(&mut s, "Short\nA much longer line", json!({}));
s.execute("text.setStyle", &json!({"ids": [id], "justify": justify})).unwrap();
let before = line_start(&text(&s, id), 6);
let n = undo_len(&s);
let r = s.execute("type.dynamicText", &json!({})).unwrap();
assert_eq!((r["on"].clone(), r["ids"].clone()), (json!(true), json!([id])));
assert_eq!((r["wordGap"].clone(), r["lineGap"].clone()), (json!(100.0), json!(0.0)), "the reply gives the new frame's gaps");
assert_eq!(undo_len(&s), n + 1, "{justify}: one undo step");
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Dynamic Text");
let t = text(&s, id);
assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }), "{justify}");
assert_eq!(t.area.fit, AreaFit::None);
assert_block(&t);
assert_eq!(size_at(&t, 6), 12.0, "{justify}: the widest line keeps its size");
assert!(size_at(&t, 0) > 24.0, "{justify}: the short line grows to the frame's width: {}", size_at(&t, 0));
// Across, the widest line stays where it was; the short line above it grew, so it moved down.
let after = line_start(&t, 6);
assert!((after.x - before.x).abs() < 0.05, "{justify}: the widest line stays where it was across: {before:?} → {after:?}");
assert!(after.y > before.y + 12.0, "{justify}");
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "{justify}: the frame holds every line");
// Edits fit it again as area type; undo brings the point type back as it was.
s.execute("edit.undo", &json!({})).unwrap();
let back = text(&s, id);
assert!(matches!(back.kind, TextKind::Point) && !back.dynamic_text && size_at(&back, 0) == 12.0, "{justify}");
}
}
#[test]
fn point_type_with_gaps_indents_and_vertical_type_turns_into_block() {
let mut s = session();
// The gaps apply to the frame point type becomes.
let id = point(&mut s, "one\ntwo words here", json!({}));
let r = s.execute("type.dynamicText", &json!({"wordGap": 300, "lineGap": 4})).unwrap();
assert_eq!((r["wordGap"].clone(), r["lineGap"].clone()), (json!(300.0), json!(4.0)));
let t = text(&s, id);
assert!(t.dynamic_text && (t.area.word_gap, t.area.line_gap) == (300.0, 4.0));
assert_block(&t);
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
// Indents: the frame takes them in, so the widest line keeps its size.
let id = point(&mut s, "Tiny\nThe widest line of all", json!({"y": 300}));
s.execute("text.setFormat", &json!({"ids": [id], "leftIndent": 20, "firstLineIndent": 10})).unwrap();
let before = line_start(&text(&s, id), 5);
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
let t = text(&s, id);
assert_block(&t);
assert_eq!(size_at(&t, 5), 12.0);
assert!((line_start(&t, 5).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 5));
// A nested list item: the frame takes in the level's indent too.
let id = point(&mut s, "Tiny\nThe widest item of all", json!({"y": 200}));
s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet", "listLevel": 3})).unwrap();
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
let t = text(&s, id);
assert_eq!(size_at(&t, 5), 12.0, "nested list");
assert!(size_at(&t, 0) > 12.0);
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
// Mixed alignments: the widest line keeps its size and its place.
let id = point(&mut s, "Left\nThe widest line, right aligned", json!({"y": 400}));
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
if let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) {
let right = vectorcraft_doc::ParaStyle { justify: vectorcraft_doc::Justify::Right, ..t.para.clone() };
t.set_paragraph_styles(vec![t.para.clone(), right]);
}
let before = line_start(&text(&s, id), 5);
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
let t = text(&s, id);
assert_eq!(size_at(&t, 5), 12.0, "mixed alignments");
assert!((line_start(&t, 5).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 5));
// A right-to-left list, whose indents the layout mirrors.
let id = point(&mut s, "קצר\nשורה ארוכה בהרבה מהראשונה", json!({"y": 450}));
s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet", "rightIndent": 9})).unwrap();
let widest = "קצר\n".len();
let before = line_start(&text(&s, id), widest);
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
let t = text(&s, id);
assert_eq!(size_at(&t, widest), 12.0, "right-to-left list");
assert!((line_start(&t, widest).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, widest));
// Point type that was area type with its own options: the new frame takes the defaults but the
// gaps.
let id = point(&mut s, "Was\nonce a frame of two columns", json!({"y": 500}));
s.execute("type.convertToAreaType", &json!({"ids": [id]})).unwrap();
s.execute(
"text.areaOptions",
&json!({"columns": 2, "gutter": 12, "inset": 6, "verticalAlign": "bottom", "firstBaselineMin": 40, "wordGap": 150}),
)
.unwrap();
s.execute("type.convertToPointType", &json!({"ids": [id]})).unwrap();
let before = line_start(&text(&s, id), 4);
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
let t = text(&s, id);
let defaults = vectorcraft_doc::AreaOptions { word_gap: 150.0, ..Default::default() };
assert_eq!(t.area, defaults);
assert_eq!(size_at(&t, 4), 12.0, "options from an earlier frame");
assert!((line_start(&t, 4).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 4));
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
// Vertical type: the frame is as tall as the longest column and grows to the left.
let id = point(&mut s, "縦書き\nもっと長い縦書きの行", json!({"x": 600, "y": 40, "vertical": true}));
let before = line_start(&text(&s, id), "縦書き\n".len());
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
let t = text(&s, id);
assert!(t.vertical && t.dynamic_text && matches!(t.kind, TextKind::Area { .. }));
assert_block(&t);
assert_eq!(size_at(&t, "縦書き\n".len()), 12.0);
let after = line_start(&t, "縦書き\n".len());
assert!((after.y - before.y).abs() < 0.05, "the longest column stays where it was down the page: {before:?} → {after:?}");
assert!(after.x < before.x - 12.0, "the short column before it grew, so it moved left: {before:?} → {after:?}");
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
}
#[test]
fn point_type_with_negative_indents_wider_than_its_line_turns_into_block() {
// Block makes the line larger; it keeps its start, center or end by its alignment.
let ends = |t: &TextObject| {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let l = &lay.lines[0];
((t.xf * vectorcraft_geom::Point::new(l.x0, l.baseline)).x, (t.xf * vectorcraft_geom::Point::new(l.x1 - l.trailing, l.baseline)).x)
};
for justify in ["left", "center", "right"] {
let mut s = session();
let id = point(&mut s, "Short", json!({}));
s.execute("text.setStyle", &json!({"ids": [id], "justify": justify})).unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "leftIndent": -200})).unwrap();
let (b0, b1) = ends(&text(&s, id));
let n = undo_len(&s);
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "{justify}");
let t = text(&s, id);
assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }), "{justify}: the frame is as wide as the line");
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "{justify}");
assert!(size_at(&t, 0) > 12.0, "{justify}: Block makes the line larger");
let (a0, a1) = ends(&t);
let kept = match justify {
"left" => (a0, b0),
"center" => ((a0 + a1) / 2.0, (b0 + b1) / 2.0),
_ => (a1, b1),
};
assert!((kept.0 - kept.1).abs() < 0.05, "{justify}: the line keeps its aligned point: {b0},{b1} → {a0},{a1}");
}
}
#[test]
fn point_type_under_a_text_wrap_object_turns_into_block_that_holds_every_line() {
let mut s = session();
let id = point(&mut s, "One\nTwo\nThree\nA much longer line than the rest", json!({}));
// A wrap object above the type, across the top of the frame it becomes.
let r = s.execute("shape.rectangle", &json!({"x": 250, "y": 80, "width": 400, "height": 40})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("object.textWrap.make", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
let t = text(&s, id);
assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }));
assert!(!t.wrap.is_empty(), "the new area type takes the wrap object");
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame holds every line around the wrap");
// A wrap object deeper than any size Block sets, across the whole frame: the lines go below it.
let id = point(&mut s, "Below", json!({"x": 1000, "y": 100}));
let r = s.execute("shape.rectangle", &json!({"x": 900, "y": 80, "width": 400, "height": 9000})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("object.textWrap.make", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
let t = text(&s, id);
assert!(t.dynamic_text && !t.wrap.is_empty());
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame reaches past a deep wrap object");
// An inverted wrap object far below the type holds the lines inside it: the frame reaches it.
let id = point(&mut s, "Inside", json!({"x": 1600, "y": 100}));
let r = s.execute("shape.rectangle", &json!({"x": 1550, "y": 9000, "width": 400, "height": 100})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("object.textWrap.make", &json!({})).unwrap();
s.execute("object.textWrap.options", &json!({"invert": true})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
let t = text(&s, id);
assert!(t.dynamic_text && !t.wrap.is_empty());
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame reaches a distant inverted wrap object");
}
#[test]
fn point_type_with_nothing_to_fit_is_left_as_it_is() {
let mut s = session();
for blank in ["", " ", "\n\n"] {
let id = point(&mut s, blank, json!({}));
let before = text(&s, id);
let n = undo_len(&s);
let r = s.execute("type.dynamicText", &json!({})).unwrap();
assert_eq!(r["on"], json!(true));
assert_eq!(undo_len(&s), n, "{blank:?}: no undo step");
assert_eq!(text(&s, id), before, "{blank:?}");
// A gap needs type it can apply to.
let e = s.execute("type.dynamicText", &json!({"wordGap": 200})).unwrap_err().to_string();
assert!(e.contains("need area type"), "{blank:?}: {e}");
}
// A line far shorter than the widest one stops at the size limit, and the frame still holds it.
let id = point(&mut s, &format!(".\n{}", "w".repeat(400)), json!({"size": 6}));
s.execute("type.dynamicText", &json!({})).unwrap();
let t = text(&s, id);
assert!(t.dynamic_text);
assert_eq!(size_at(&t, 0), 1296.0);
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
}
#[test]
fn long_text_fits_in_a_bounded_number_of_layouts() {
let mut s = session();
@@ -451,4 +685,16 @@ fn long_text_fits_in_a_bounded_number_of_layouts() {
// pass and the caches), plus the reply's own.
let bound = 2 * (2 + 2 * super::block::BLOCK_PASSES as u64) + 4;
assert!(used <= bound, "{used} layouts");
// Long point type: measured, fitted in its frame and fitted again at its depth, with the
// layouts that place and measure the frame.
let id = point(&mut s, &story, json!({}));
let before = vectorcraft_text::test_fonts::layouts_on_this_thread();
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
let used = vectorcraft_text::test_fonts::layouts_on_this_thread() - before;
let t = text(&s, id);
assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }));
assert_block(&t);
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
let bound = 2 * (2 + 2 * super::block::BLOCK_PASSES as u64) + 8;
assert!(used <= bound, "point type: {used} layouts");
}
+152 -21
View File
@@ -7,7 +7,7 @@ use vectorcraft_doc::{AreaFit, CharStyle, Document, Justify, LockedArtboards, No
use vectorcraft_geom::{Affine, Rect, shapes};
use super::object::choice;
use super::typecmd::{area_text, block_gaps, refresh_bounds};
use super::typecmd::{area_text, block_gaps, needed_room, refresh_bounds};
use super::{
CommandSpec, EngineError, Result, Session, bad, checked_ids_param, cmd, find_command, has_clipboard, has_doc, has_selection, number_or,
switch_or, targets, text_param,
@@ -65,7 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Dynamic Text",
["Type"],
None,
"{ids?|id?, on?: bool, wordGap?: % of a word space 0..1000 (100), lineGap?: pt -1296..1296 (0)} turn Dynamic Text on or off for the selected type on a path and area type (on omitted: the opposite of the first object's setting, or on when a gap is given). On type on a path, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. On area type it is Block: each line as typed (each paragraph, and each forced line break in one; lines are not broken again) is set to the frame's width, less the inset, the gutters and the paragraph's indents, by changing its font size only: its first character's size, in steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it, with baseline shifts following. Every character's leading is its size plus lineGap, and the layout sets every paragraph with wordGap as its Desired word spacing, its Minimum or Maximum moving out to wordGap when wordGap lies outside it (the paragraphs keep their own Justification… values, which apply again when Block is off; the gaps are area type's, and are kept while Block is off; a gap with no area type in the selection is an error). The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step; at a size limit a line can be narrower than the frame or wrap. An empty line keeps its size. Block replaces Auto Size and Shrink Text to Fit (Area Type Options › Block is the same setting). Off, the text keeps the sizes it was drawn at. Point type in the selection is left as it is; a selection holding type in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When nothing changes, no undo step is recorded → {on, ids, fitScale (the first object's factor; 1 when off and for area type), wordGap?, lineGap? (the first area type's; left out without area type)}",
"{ids?|id?, on?: bool, wordGap?: % of a word space 0..1000 (100), lineGap?: pt -1296..1296 (0)} turn Dynamic Text on or off for the selected type on a path and area type, or turn it on for point type (on omitted: the opposite of the first object's setting, point type counting as off, or on when a gap is given). On type on a path, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. On area type it is Block: each line as typed (each paragraph, and each forced line break in one; lines are not broken again) is set to the frame's width, less the inset, the gutters and the paragraph's indents, by changing its font size only: its first character's size, in steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it, with baseline shifts following. Every character's leading is its size plus lineGap, and the layout sets every paragraph with wordGap as its Desired word spacing, its Minimum or Maximum moving out to wordGap when wordGap lies outside it (the paragraphs keep their own Justification… values, which apply again when Block is off; the gaps are area type's, and are kept while Block is off; a gap with no area type in the selection, and no point type it turns into area type, is an error). The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step; at a size limit a line can be narrower than the frame or wrap. An empty line keeps its size. Block replaces Auto Size and Shrink Text to Fit (Area Type Options › Block is the same setting). Off, the text keeps the sizes it was drawn at. Turning it on turns point type into area type with Block on, with the default Area Type Options but the gaps (options kept from an earlier frame do not carry over): the frame is as wide (along the lines) as the widest line as typed with its paragraph's indents, so that line keeps its size and sits where it was (by its own alignment), the other lines are set to that width (when negative indents take more than that line's width, the frame is as wide as the line, and Block makes it larger), and the frame then grows in depth (down, or to the left for vertical type) to hold every fitted line, around any Text Wrap objects above it; the gaps apply to it as to area type. Point type with no line to fit (empty, or only spaces and breaks) is left as it is, and so is point type when it is turned off; a selection holding type in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When nothing changes, no undo step is recorded → {on, ids, fitScale (the first object's factor; 1 when off and for area type), wordGap?, lineGap? (the first area type's; left out without area type)}",
has_dynamic_type,
dynamic_text
),
@@ -325,13 +325,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
// A little slack on the right so the text doesn't rewrap.
let top = lay.lines.first().map(|l| l.baseline - l.ascent).unwrap_or(b.y0).min(b.y0);
let slack = size * 0.5 + trailing;
// Slack goes where the alignment leaves room, so the text doesn't move or rewrap.
let (sl, sr) = match t.para_at(0).justify {
Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5),
Justify::Right | Justify::JustifyRight => (slack, 0.0),
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0),
_ => (0.0, slack),
};
let (sl, sr) = slack_sides(t.para_at(0).justify, lay.lines.first().is_some_and(|l| l.rtl), slack);
let frame = Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0));
t.kind = TextKind::Area { frame: shapes::rectangle(frame) };
refresh_bounds(t);
@@ -341,6 +335,107 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
Ok(ids_json(&ids))
}
/// How `slack` added to point type's lines in a frame is shared before and after them: where the
/// alignment leaves room, so the text doesn't move or rewrap.
fn slack_sides(justify: Justify, rtl: bool, slack: f64) -> (f64, f64) {
match justify {
Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5),
Justify::Right | Justify::JustifyRight => (slack, 0.0),
Justify::Auto if rtl => (slack, 0.0),
_ => (0.0, slack),
}
}
/// Point type `t` turned into area type with Dynamic Text's Block on, with the default Area Type
/// Options but its gaps: the frame takes the room along the lines that keeps the widest line as
/// typed at its size ([`needed_room`]), placed so that line stays where it is by its own
/// alignment, and after the fit it takes the depth that holds every line, so nothing overflows.
/// Lines run in line space (for vertical type, down the page), and the frame is mapped to the
/// type's own space by the layout's `line_xf`. `wrap` is the Text Wrap shapes the new area type
/// takes, in the type's own space, which the fit and the depth follow. When negative indents take
/// more than the widest line's width, the frame is as wide as that line and placed so that the
/// line keeps its aligned point. None when no line has a
/// width (empty text, or only spaces and breaks), which Block leaves at its size.
fn point_to_block(t: &TextObject, wrap: &[vectorcraft_doc::WrapShape]) -> Option<TextObject> {
let db = vectorcraft_text::FontDb::global();
// Options point type kept from a frame it had before (columns, inset, alignment, first
// baseline) do not carry over to the new frame.
let mut t = t.clone();
let (word_gap, line_gap) = (t.area.word_gap, t.area.line_gap);
t.area = vectorcraft_doc::AreaOptions { word_gap, line_gap, ..Default::default() };
t.wrap = wrap.to_vec();
let t = &t;
// The room is measured as Block sets the lines in a frame, with the word gap.
let as_block = TextObject { dynamic_text: true, kind: TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)) }, ..t.clone() };
let (room, widest, para_index) = needed_room(&as_block)?;
let lay = vectorcraft_text::layout(db, t);
let first = lay.lines.first()?;
let line = lay.lines.iter().find(|l| l.start == widest)?;
// Across, the widest line as it is aligned, with its paragraph's indents, which the frame holds.
let para = t.para_at(para_index);
let starts = widest == 0 || t.plain_text().as_bytes().get(widest - 1) == Some(&b'\n');
let start_indent = para.laid_out_left_indent() + if starts { para.first_line_indent } else { 0.0 };
// A horizontal right-to-left list paragraph has its indents mirrored, as the layout sets them.
let mirror = line.rtl && !t.vertical && para.list.is_some();
let (left, right) = if mirror { (para.right_indent, start_indent) } else { (start_indent, para.right_indent) };
let (lo, hi) = (line.x0 - left, line.x1 - line.trailing + right);
let top = lay.lines.iter().map(|l| l.baseline - l.ascent).fold(first.baseline - first.ascent, f64::min);
let bottom = lay.lines.iter().map(|l| l.baseline + l.descent).fold(top, f64::max);
if ![lo, hi, top, bottom, room].iter().all(|v| v.is_finite()) {
return None;
}
// Negative indents can take more than the line's width (hi below lo); the frame is then wider
// than the line with its indents, and the same split of the difference keeps the line's
// aligned point (its start, center or end) where it was when Block makes the line fill it.
let (sl, _) = slack_sides(para.justify, line.rtl, room - (hi - lo));
let frame = |depth: f64| lay.line_xf.transform_rect_bbox(Rect::new(lo - sl, top, lo - sl + room, top + depth.max(1.0)));
let start = frame(bottom - top);
let mut out = t.clone();
out.kind = TextKind::Area { frame: shapes::rectangle(start) };
out.dynamic_text = true;
refresh_bounds(&mut out);
// The lines fitted in one cell from the frame's top deep enough for all of them (every line at
// the largest size Block sets), around any wrap shapes, give the depth the frame takes.
let mut probe = out.clone();
let points = t.style_scale().map_or(1.0, |s| s.points).max(1e-12);
let deep = (bottom - top) + (lay.lines.len() as f64 + 1.0) * 3.0 * (1296.0 + out.area.line_gap.abs()) / points;
// Wrap shapes push lines on past them (an inverted one holds them inside it), so the cell also
// reaches as far again as the farthest one reaches past the frame's top.
let inv = lay.line_xf.inverse();
let deep = deep
+ wrap
.iter()
.filter_map(|w| w.path.bounds().map(|r| inv.transform_rect_bbox(r)).map(|r| (r.y1 - top).max(0.0) + 2.0 * w.wrap.offset.abs()))
.filter(|d| d.is_finite())
.fold(0.0, f64::max);
probe.kind = TextKind::Area { frame: shapes::rectangle(frame(deep)) };
if !wrap.is_empty() {
refresh_bounds(&mut probe);
}
let laid = vectorcraft_text::layout(db, &probe);
if laid.overflow {
return Some(out);
}
let mut grown = start;
for l in &laid.lines {
for y in [l.baseline - l.ascent, l.baseline + l.descent] {
grown = grown.union_pt(laid.line_xf * vectorcraft_geom::Point::new(l.x0, y));
}
}
// Only the depth grows (for vertical type, the columns run on to the left), with a hundredth
// of a point to spare.
if t.vertical {
(grown.y0, grown.y1, grown.x0) = (start.y0, start.y1, grown.x0 - 0.01);
} else {
(grown.x0, grown.x1, grown.y1) = (start.x0, start.x1, grown.y1 + 0.01);
}
if grown.is_finite() && grown != start {
out.kind = TextKind::Area { frame: shapes::rectangle(grown) };
refresh_bounds(&mut out);
}
Some(out)
}
/// (run, byte within the run) for a byte offset into the plain text.
fn locate(runs: &[TextRun], byte: usize) -> Option<(usize, usize)> {
let mut off = 0;
@@ -542,14 +637,23 @@ fn dynamic_type(n: Option<&Node>) -> Option<&TextObject> {
path_text(n).or_else(|| area_text(n))
}
/// `type.dynamicText` is enabled for a selection holding type on a path or area type, none of it
/// in a thread.
/// Type `type.dynamicText` takes: type on a path, area type, and point type, which it turns into
/// area type with Block on.
fn dynamic_target(n: Option<&Node>) -> Option<&TextObject> {
match n.map(|n| &n.kind) {
Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::Point) => Some(t),
_ => dynamic_type(n),
}
}
/// `type.dynamicText` is enabled for a selection holding type on a path, area type or point type,
/// none of it in a thread.
fn has_dynamic_type(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let st = s.active().ok_or("no document open")?;
let d = &st.doc;
let selected: Vec<NodeId> = st.selection.objects.to_vec();
let types: Vec<NodeId> = text_ids(d, &selected).into_iter().filter(|id| dynamic_type(d.node(*id)).is_some()).collect();
let types: Vec<NodeId> = text_ids(d, &selected).into_iter().filter(|id| dynamic_target(d.node(*id)).is_some()).collect();
if types.is_empty() {
return Err("select type on a path or area type".into());
}
@@ -575,32 +679,46 @@ fn threaded_ids(d: &Document, ids: &[NodeId]) -> String {
/// Type › Dynamic Text: set `on` (without it, the opposite of the first object's setting, or on
/// when a gap is given) on the selected type on a path and area type (Block, with its gaps), in
/// one undo step, and none when nothing changes. Turning it off keeps the sizes the text was drawn
/// at ([`bake_dynamic_text`]).
/// one undo step, and none when nothing changes. Turning it on also turns the selected point type
/// into area type with Block on ([`point_to_block`]). Turning it off keeps the sizes the text was
/// drawn at ([`bake_dynamic_text`]).
fn dynamic_text(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "type.dynamicText";
let (word_gap, line_gap) = block_gaps(p, C)?;
let (ids, on, planned) = {
let (ids, on, planned, converted) = {
let d = &s.doc()?.doc;
let ids: Vec<NodeId> = texts(s, p, C)?.into_iter().filter(|i| dynamic_type(d.node(*i)).is_some()).collect();
let ids: Vec<NodeId> = texts(s, p, C)?.into_iter().filter(|i| dynamic_target(d.node(*i)).is_some()).collect();
let first = *ids.first().ok_or_else(|| bad(C, "select type on a path or area type"))?;
if let Some(why) = threaded_refusal(d, &ids) {
return Err(bad(C, why));
}
let gap_given = word_gap.is_some() || line_gap.is_some();
if gap_given && !ids.iter().any(|id| area_text(d.node(*id)).is_some()) {
let on = switch_or(p, "on", gap_given || !dynamic_type(d.node(first)).is_some_and(|t| t.dynamic_text), C)?;
// Point type with a line to fit, which turning it on makes area type.
let converts = |id: &NodeId| on && dynamic_target(d.node(*id)).is_some_and(|t| matches!(t.kind, TextKind::Point) && needed_room(t).is_some());
if gap_given && !ids.iter().any(|id| area_text(d.node(*id)).is_some() || converts(id)) {
return Err(bad(C, "wordGap and lineGap need area type in the selection"));
}
let on = switch_or(p, "on", gap_given || !dynamic_type(d.node(first)).is_some_and(|t| t.dynamic_text), C)?;
// Each object as the command leaves it, and only those it changes.
let mut planned: Vec<(NodeId, TextObject)> = vec![];
// The point type that becomes area type, with the gaps applied, by id.
let mut converted: Vec<(NodeId, TextObject)> = vec![];
for id in &ids {
let Some(t) = dynamic_type(d.node(*id)) else { continue };
let Some(t) = dynamic_target(d.node(*id)) else { continue };
let mut after = t.clone();
if matches!(t.kind, TextKind::Area { .. }) {
if matches!(t.kind, TextKind::Area { .. } | TextKind::Point) {
after.area.word_gap = word_gap.unwrap_or(t.area.word_gap);
after.area.line_gap = line_gap.unwrap_or(t.area.line_gap);
}
if matches!(t.kind, TextKind::Point) {
// Point type has no Dynamic Text of its own: on, it becomes area type with Block
// on; off, or with no line to fit, it stays as it is.
if let Some(block) = on.then(|| point_to_block(&after, &[])).flatten() {
planned.push((*id, block));
converted.push((*id, after));
}
continue;
}
match (on, t.dynamic_text) {
(true, true) => refresh_bounds(&mut after),
(true, false) => {
@@ -620,7 +738,7 @@ fn dynamic_text(s: &mut Session, p: &Value) -> Result<Value> {
planned.push((*id, after));
}
}
(ids, on, planned)
(ids, on, planned, converted)
};
if !planned.is_empty() {
s.edit("Dynamic Text", |d, _| {
@@ -629,6 +747,19 @@ fn dynamic_text(s: &mut Session, p: &Value) -> Result<Value> {
**t = after.clone();
}
}
if converted.is_empty() {
return Ok(());
}
// The new area type takes the Text Wrap shapes above it; one that has any is fitted
// again around them, with the depth that holds every line.
for id in vectorcraft_doc::text::resolve_wrap(None, d) {
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { continue };
let block = converted.iter().find(|(c, _)| *c == id).and_then(|(_, src)| point_to_block(src, &t.wrap));
match block {
Some(block) => **t = block,
None => refresh_bounds(t),
}
}
Ok(())
})?;
}
+4 -6
View File
@@ -88,19 +88,17 @@ fn dynamic_text_refuses_bad_params_and_other_type() {
assert!(err(s.execute("type.dynamicText", &json!({"on": 1}))).contains("on"));
assert_eq!(undo_len(&s), before);
assert!(!text(&s, id).dynamic_text);
// Point type alone: refused (area type takes Block, `typecmd::block_tests`).
let point = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Point"})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [point]})).unwrap();
assert!(err(s.execute("type.dynamicText", &json!({}))).contains("select type on a path or area type"));
let empty = s.execute("shape.rectangle", &json!({"x": 300, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [empty]})).unwrap();
assert!(s.execute("type.dynamicText", &json!({})).is_err());
// With point type selected too, only the type on a path changes.
// With point type selected too, both change in one undo step: the point type becomes area
// type with Block on (`typecmd::block_tests`).
let before = undo_len(&s);
s.execute("select.set", &json!({"ids": [point, id.0]})).unwrap();
let r = s.execute("type.dynamicText", &json!({"on": true})).unwrap();
assert_eq!(r["ids"], json!([id.0]));
assert!(!text(&s, NodeId(point)).dynamic_text);
assert_eq!(r["ids"], json!([point, id.0]));
assert!(text(&s, NodeId(point)).dynamic_text && text(&s, id).dynamic_text);
assert_eq!(undo_len(&s), before + 1);
}
+2 -2
View File
@@ -153,8 +153,8 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
TextKind::Point => items.push((tl!("Convert To Area Type"), "dc-type-area", "type.convertToAreaType", none())),
TextKind::OnPath { .. } => items.push((tl!("Type on a Path Options"), "dc-type-path", "type.pathOptions", none())),
}
// Dynamic Text turns on or off for type on a path and area type (Block), as the Type menu's item does;
// left out for point type and type in a thread
// Dynamic Text turns on or off for type on a path and area type (Block), and turns point type into area
// type with Block on, as the Type menu's item does; left out for type in a thread
// (<https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/adjust-formatting-and-resize-text-with-dynamic-text.html>).
if crate::menus::enabled(app, "type.dynamicText") {
items.push((tl!("Dynamic Text"), "maximize-2", "type.dynamicText", none()));
+3 -3
View File
@@ -41,9 +41,9 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
crate::panels::character::shared_text_flag(app, |t| t.vertical) == Some(id == "type.orientation.vertical")
}
"type.opticalMarginAlignment" => crate::panels::character::shared_text_flag(app, |t| t.optical_margin) == Some(true),
// Dynamic Text of the first selected type on a path or area type, which the item turns the
// other way.
"type.dynamicText" => selected_text(|k| !matches!(k, vectorcraft_engine::doc::TextKind::Point)).is_some_and(|t| t.dynamic_text),
// Dynamic Text of the first selected type, which the item turns the other way (point type
// counts as off: the item turns it into area type with Block on).
"type.dynamicText" => selected_text(|_| true).is_some_and(|t| t.dynamic_text && !matches!(t.kind, vectorcraft_engine::doc::TextKind::Point)),
"view.outline" => app.outline_view(),
"view.pixelPreview" => v.pixel_preview,
"view.trimView" => v.trim_view,
+9 -3
View File
@@ -313,8 +313,8 @@ fn optical_margin_alignment_is_a_checked_type_menu_item() {
assert_eq!(checked(&app, "type.orientation.horizontal", &Value::Null), Some(false));
}
/// Type › Dynamic Text runs `type.dynamicText`, is enabled for type on a path and area type that
/// are in no thread, and is checked while the selected type has it on.
/// Type › Dynamic Text runs `type.dynamicText`, is enabled for point type and for type on a path
/// and area type that are in no thread, and is checked while the selected type has it on.
#[test]
fn dynamic_text_is_a_checked_type_menu_item() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
@@ -334,7 +334,8 @@ fn dynamic_text_is_a_checked_type_menu_item() {
assert!(!enabled(&app, id));
let point = app.run("text.create", json!({"x": 10, "y": 10, "text": "Point"})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [point]})).unwrap();
assert!(!enabled(&app, id), "point type");
assert!(enabled(&app, id), "point type, which it turns into area type with Block on");
assert_eq!(checked(&app, id, &Value::Null), Some(false), "point type");
let path = app.run("path.create", json!({"d": "M60 150 L340 150"})).unwrap()["id"].clone();
let t = app.run("text.createInPath", json!({"path": path, "mode": "onPath", "text": "Along", "at": [60, 150]})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [t]})).unwrap();
@@ -356,6 +357,11 @@ fn dynamic_text_is_a_checked_type_menu_item() {
assert_eq!(checked(&app, id, &Value::Null), Some(false));
app.run(id, json!({})).unwrap();
assert_eq!(checked(&app, id, &Value::Null), Some(true));
// Point type first: unchecked, as the item turns it on.
app.run("select.set", json!({"ids": [point, area]})).unwrap();
assert_eq!(checked(&app, id, &Value::Null), Some(false), "point type first");
app.run(id, json!({})).unwrap();
assert_eq!(checked(&app, id, &Value::Null), Some(true), "both on");
}
/// Type › Dynamic Text Presets › Circle, Arch and Bow run `type.dynamicTextPreset` with their
+16
View File
@@ -232,6 +232,22 @@ fn area_type_offers_dynamic_text() {
assert!(has(&texts, "Convert To Point Type") && !has(&texts, "Dynamic Text"), "{texts:?}");
}
/// Point type offers Dynamic Text beside Convert To Area Type, which turns it into area type with
/// Block on; the bar then offers what area type does.
#[test]
fn point_type_offers_dynamic_text() {
let mut app = app();
let t = id(app.run("text.create", json!({"x": 60, "y": 80, "text": "Fit\nme in a frame"})).unwrap());
app.run("tool.select", json!({"tool": "selection"})).unwrap();
select(&mut app, &[t]);
let (_, texts) = bar(&mut app);
assert!(has(&texts, "Convert To Area Type") && has(&texts, "Dynamic Text"), "{texts:?}");
click(&mut app, "Dynamic Text");
assert!(matches!(kind(&app, t), NodeKind::Text(ref t) if t.dynamic_text && matches!(t.kind, TextKind::Area { .. })));
let (_, texts) = bar(&mut app);
assert!(has(&texts, "Convert To Point Type") && has(&texts, "Dynamic Text"), "{texts:?}");
}
/// The path's anchors (its first subpath).
fn anchors(app: &VectorcraftApp, id: u64) -> Vec<vectorcraft_geom::Anchor> {
match kind(app, id) {
+16 -3
View File
@@ -6923,7 +6923,7 @@ lines. The rest are VectorCraft's own rules:
- The layout sets every paragraph with `wordGap` as its Desired word spacing (Justification…), and its Minimum or
Maximum word spacing moves out to `wordGap` when `wordGap` lies outside it. The paragraphs keep their own
Justification… values, which apply again when Block is off. Each character's leading is its size plus `lineGap`. A
gap given with no area type in the selection is an error.
gap given with no area type in the selection, and no point type that it turns into area type, is an error.
- The sizes are written into the characters, so every export and the Character panel see them. After every edit to
the text or the frame (typing, `text.editRange`, character and paragraph changes, `text.areaOptions {width}`, a
frame resize) the lines are fitted again, in the edit's own undo step. The lines are then measured again, in at
@@ -6932,14 +6932,27 @@ lines. The rest are VectorCraft's own rules:
- An empty line, or a line of spaces, keeps its size. At 1,296 pt a line can be narrower than the frame; at 0.1 pt it
can still wrap. A frame with no room along its lines changes nothing. Right-to-left lines are fitted as left-to-right
ones are.
- Off keeps the sizes the text was drawn at. On replaces Auto Size and Shrink Text to Fit. Point type in the selection
is left as it is. Area type in a thread is refused with an error that names it (`text.areaOptions {fit: "block"}`
- Off keeps the sizes the text was drawn at. On replaces Auto Size and Shrink Text to Fit. Area type in a thread is refused with an error that names it (`text.areaOptions {fit: "block"}`
refuses it too); threading a frame turns Block off, keeping its sizes. Without `on`, it toggles from the first
object's setting, or turns on when a gap is given. A value that is not a boolean, or a gap outside its range, is an
error. When nothing changes, no undo step is recorded.
- Saved and opened, the native file keeps Block on with its gaps and the sizes in the characters; with Block off it
keeps the sizes.
Turning it on also takes point type, in the same undo step (the Contextual Task Bar offers it beside Convert To Area
Type). [Adjust formatting and resize text](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/adjust-formatting-and-resize-text-with-dynamic-text.html)
applies Dynamic Text, Block among its shapes, to existing text. VectorCraft's own rules for point type:
- It becomes area type with Block on and the default Area Type Options, keeping only its gaps: options it kept from
an earlier frame (columns, inset, vertical alignment, first baseline) do not carry over. Along its lines, the frame
is as wide as the widest line as typed with its paragraph's indents, so that line keeps its size and stays where it
was across the page by its own alignment; the other lines are set to that width. When negative indents take more
than the widest line's width, the frame is as wide as that line, and Block makes it larger. The frame starts at the
top of the text and then grows down (to the left for vertical type) to hold every fitted line, around any Text Wrap
objects above it, so nothing overflows.
- The gaps given apply to its frame. Point type with no line to fit (empty, or only spaces and breaks) is left as it
is, and so is point type when `on` is false. Without `on`, point type counts as off.
The reply is `{on, ids, fitScale, wordGap, lineGap}` (`fitScale` as for type on a path, 1 when the first object is area
type; the gaps are the first area type's).
+1
View File
@@ -1189,6 +1189,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Type on a Path Options › Align to Path (option names as in [Create type on a path](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-type-on-a-path.html)) moves each character by its own font, size and vertical scale: Ascender runs the top of the font's em box along the path (the ideographic em box that its `BASE` table, its typographic or `hhea` box or its ideographs give, else an em box centered on the cap height), Descender runs the bottom of the font's bounding box along it, and Center runs a line at half the height of the top of the font's H above the baseline along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. These distances are VectorCraft's own values (`tests::on_path_align_runs_each_glyph_by_its_own_metrics`, `tests::on_path_align_finds_the_em_box_of_each_kind_of_face`, `tests::on_path_align_in_vertical_type_moves_half_an_em`).
Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar for type on a path) sizes type on a path to fill the span between its start and end brackets and fits it again after each edit to the text, the path or a bracket ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html), [start and end points](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html)). VectorCraft's own rules: one factor scales every size, leading and baseline shift; the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt; turning it off or threading the type keeps the drawn sizes; SVG, EPS and the editing data write the drawn sizes (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`).
Dynamic Text on area type is Block (Type › Dynamic Text, the Contextual Task Bar, Area Type Options › Block, the Properties panel's Dynamic Text checkbox with Word Gap and Line Gap; [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)): each line as typed is set to the frame's width by its font size, again after every edit to the text or the frame. VectorCraft's own rules: lines are not broken again, the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt, Word Gap (0 to 1,000 %, default 100) is the Desired word spacing and Line Gap (-1,296 to 1,296 pt, default 0) is added to each size for the leading, the sizes are written into the characters, threaded frames cannot use it, and Convert To Point Type turns it off, keeping the sizes (`crates/engine/src/cmd/typecmd/block_tests.rs`).
Dynamic Text on point type (Type › Dynamic Text, the Contextual Task Bar; [Adjust formatting and resize text](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/adjust-formatting-and-resize-text-with-dynamic-text.html) applies it to existing text) turns it into area type with Block on. VectorCraft's own rules: the frame is as wide as the widest line with its indents, so that line keeps its size and place across the page, and it grows in depth to hold every fitted line (`crates/engine/src/cmd/typecmd/block_tests.rs`).
Type › Dynamic Text Presets › Circle, Arch and Bow (`type.dynamicTextPreset`) put point type or type on a path on a circular, upward-curving or downward-curving path with Dynamic Text on, with the text's position against the path (above, center, below) and its direction ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)). VectorCraft's own rules: the circle is as wide as the text and the text starts at its bottom; Arch and Bow are a 120° arc whose chord is as wide as the text; above, center and below are Align to Path's Descender, Center and Ascender (`crates/engine/src/tests/dynamic_text.rs`).
The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it.
The frames of threaded text share one writing direction: Type › Type Orientation (`type.orientation.vertical`, `type.orientation.horizontal`) in one frame sets every frame, threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and the story flows through every frame in the thread's direction.