Merge pull request #6669 from dotCooCoo/feat/dynamic-text-release-path

Type: Release Type on a Path keeps the size Dynamic Text drew the type at
This commit is contained in:
BFlat
2026-11-12 16:02:25 +07:00
committed by GitHub
10 changed files with 136 additions and 21 deletions
+7 -4
View File
@@ -21,7 +21,7 @@ use super::super::menucmds::whole_or;
use super::super::object::choice;
use super::super::textedit::{CharChange, text_mut, text_ref, type_ranges};
use super::super::typecmd::refresh_bounds;
use super::super::typemenu::text_ids;
use super::super::typemenu::{bake_dynamic_text, text_ids};
use super::super::{CommandSpec, bad, cmd, has_selection, number_or, switch_or, targets};
use crate::{EngineError, Result, Session};
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Release Type on a Path…",
["Object", "Plug-ins", "Type Tools"],
None,
"{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}",
"{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped; type with Dynamic Text on (type.dynamicText) comes out with it off, at the sizes it was drawn at. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}",
has_selection,
release_path
),
@@ -230,8 +230,9 @@ fn first_pen(lay: &vectorcraft_text::TextLayout) -> Option<Point> {
/// `t`, type on a path, as point type on one straight line: the same runs and character and
/// paragraph attributes, its baseline starting where its first character sat on the path (in the
/// object's own text space, so its transform stays). Type on a Path Options are dropped. None for
/// other type, or when it can't be placed.
/// object's own text space, so its transform stays). Type on a Path Options are dropped, and
/// Dynamic Text is turned off at the sizes it draws the text at. None for other type, or when it
/// can't be placed.
fn released_text(t: &TextObject) -> Option<TextObject> {
let TextKind::OnPath { path, .. } = &t.kind else { return None };
let db = vectorcraft_text::FontDb::global();
@@ -244,6 +245,8 @@ fn released_text(t: &TextObject) -> Option<TextObject> {
(p.x.is_finite() && p.y.is_finite()).then_some(p)
})?;
let mut out = t.clone();
// Point type has no brackets to fit: it keeps the sizes the path drew it at.
bake_dynamic_text(&mut out);
out.kind = TextKind::Point;
out.path_effect = Default::default();
out.path_align = Default::default();
+20
View File
@@ -234,6 +234,26 @@ fn svg_export_writes_the_drawn_size() {
assert!(!sizes.iter().any(|v| (v - 12.0).abs() < 1e-3), "the stored size is not written: {sizes:?}");
}
#[test]
fn release_type_on_a_path_keeps_the_drawn_size() {
let mut s = session();
let id = on_line(&mut s, "Release");
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
let size = 12.0 * fit(&s, id);
assert!(size > 24.0, "the text grows on the long line: {size}");
let r = s.execute("addon.type.releasePath", &json!({"ids": [id.0], "mode": "text"})).unwrap();
let released = NodeId(r["texts"][0].as_u64().unwrap());
let t = text(&s, released);
assert!(!t.dynamic_text, "point type does not keep Dynamic Text");
assert!((t.runs[0].style.size - size).abs() < 1e-9, "{} vs the drawn {size}", t.runs[0].style.size);
assert_eq!(fit(&s, released), 1.0);
// Type on a path without Dynamic Text keeps its stored size.
let plain = on_line(&mut s, "Plain");
let r = s.execute("addon.type.releasePath", &json!({"ids": [plain.0], "mode": "text"})).unwrap();
let t = text(&s, NodeId(r["texts"][0].as_u64().unwrap()));
assert!(!t.dynamic_text && t.runs[0].style.size == 12.0);
}
/// 12 pt point type at (100, 100), selected, with its layout bounds.
fn point(s: &mut Session, content: &str) -> (NodeId, vectorcraft_geom::Rect) {
let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": content, "size": 12})).unwrap();
+13
View File
@@ -4366,6 +4366,19 @@ fn dynamic_text_is_written_at_its_drawn_sizes() {
assert!(!texts[0].dynamic_text);
let k = texts[0].xf.as_coeffs()[0];
assert_eq!(r3(texts[0].runs[0].style.size * k), r3(drawn));
// Type written as outlines (here on an open path whose ends meet) loses the setting too, and
// says so.
let mut doc = Document::new(600.0, 300.0);
let layer = doc.layers[0].id;
let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "Grow", style(12.0, Color::BLACK));
let ends_meet = [(50.0, 200.0).into(), (550.0, 200.0).into(), (300.0, 50.0).into(), (50.0, 200.0).into()];
t.kind = TextKind::OnPath { path: PathData::new(vec![vectorcraft_geom::SubPath::polyline(&ends_meet, false)]), start: 0.0, end: None };
t.xf = Affine::IDENTITY;
t.dynamic_text = true;
let node = text_node(&mut doc, t);
doc.insert(Some(layer), usize::MAX, node).unwrap();
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::TYPE) && out.warnings.iter().any(|w| w == writer::DYNAMIC_TEXT), "{:?}", out.warnings);
}
/// See-through type with the knockout shape flag comes back with the flag and writes no warning.
+4
View File
@@ -1257,6 +1257,10 @@ impl<'a> Writer<'a> {
return;
}
self.warn(TYPE);
// The outlines keep the sizes Dynamic Text draws the type at, but not the setting.
if matches!(&n.kind, NodeKind::Text(t) if t.dynamic_text) {
self.warn(DYNAMIC_TEXT);
}
let doc = self.doc;
let resolved = match &n.kind {
NodeKind::Text(t) => Some(doc.inline_resolved(t).into_owned()),
+1
View File
@@ -97,6 +97,7 @@ pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level
pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) is written as outlines";
pub const ROTATED_TYPE: &str = "type with rotated characters is written as outlines, which keep the rotation, and is no longer editable text (legacy .ai text can't rotate characters)";
pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, manual kerning, optical kerning, small caps, superscript and subscript, OpenType features, Variable Font settings, optical margin alignment, System Layout, fractional widths off, area type options, type on a path's effect, alignment and spacing, an end bracket short of the path's end, subpaths after the first in an area type frame or a type path, space after paragraphs, tabs, bulleted and numbered lists) are left out";
pub const DYNAMIC_TEXT: &str = "Dynamic Text is written as the sizes the type is drawn at (legacy .ai files don't have it)";
pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, hidden)";
pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above their own (legacy .ai files can hide only layers)";
pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)";
+68
View File
@@ -1292,6 +1292,74 @@ fn a_font_height_reference_is_written_as_the_size_drawn() {
assert!((size - 20.0).abs() < 1e-6, "{size}");
}
/// Type on a path with Dynamic Text is written at the sizes it is drawn at, with a warning that
/// the setting itself is not written; without Dynamic Text there is no warning.
#[test]
fn dynamic_text_is_written_at_its_drawn_sizes_with_a_warning() {
let mut doc = typed();
assert!(!write(&doc).warnings.iter().any(|w| w == DYNAMIC_TEXT));
let id = texts(&doc).into_iter().find(|(_, t)| matches!(t.kind, vectorcraft_doc::TextKind::OnPath { .. })).unwrap().0.id;
let mut drawn = 0.0;
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
t.runs[0].style.size = 4.0;
t.dynamic_text = true;
drawn = 4.0 * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale;
}
assert!(drawn > 4.0, "{drawn}");
let out = write(&doc);
assert!(out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings);
let imported = crate::import(&out.bytes).unwrap();
let (_, back) = texts(&imported.document).into_iter().find(|(_, t)| t.runs.iter().any(|r| r.text.contains("path"))).unwrap();
assert!(!back.dynamic_text);
assert!((back.runs[0].style.size - drawn).abs() < 1e-3, "{} vs {drawn}", back.runs[0].style.size);
// Type written as outlines loses the setting too, and says so.
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
t.runs[0].text.push('\u{2192}');
}
let out = write(&doc);
assert!(out.warnings.iter().any(|w| w == TYPE) && out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings);
let out = write_with(&doc, LegacyAiOptions { outline_text: true, ..LegacyAiOptions::default() });
assert!(out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings);
}
/// Dynamic Text fits type whose size measures the cap height at the size the Character panel
/// shows: 1,000 pt measuring a 0.7 em cap height on a long path grows to the 1,296 pt limit and is
/// written at the em size drawn there, as text and as outlines.
#[test]
fn dynamic_text_with_a_font_height_reference_is_written_at_its_drawn_size() {
use vectorcraft_doc::{TextKind, TextObject};
use vectorcraft_text::test_fonts::{HEIGHT_FAMILY, height_font};
let db = vectorcraft_text::FontDb::global();
db.add_font(height_font(true).unwrap());
let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "HE", style(1000.0, red()));
let st = &mut t.runs[0].style;
st.font_family = HEIGHT_FAMILY.into();
st.font_height_reference = vectorcraft_doc::FontHeightReference::CapHeight;
let mut line = PathData::new(vec![]);
line.subpaths.push(vectorcraft_geom::SubPath::polyline(&[(0.0, 2000.0).into(), (100_000.0, 2000.0).into()], false));
t.kind = TextKind::OnPath { path: line, start: 0.0, end: None };
t.xf = Affine::IDENTITY;
t.dynamic_text = true;
let lay = vectorcraft_text::layout(db, &t);
assert!((lay.fit_scale - 1.296).abs() < 1e-9, "{}", lay.fit_scale);
let em = 1296.0 / 0.7;
let mut glyphs = kurbo::BezPath::new();
for g in &lay.glyphs {
glyphs.extend(g.outline.iter());
}
let want = glyphs.bounding_box();
let mut doc = Document::new(300.0, 200.0);
let layer = doc.layers[0].id;
let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)));
doc.insert(Some(layer), usize::MAX, n).unwrap();
let back = crate::import(&write(&doc).bytes).unwrap().document;
let size = texts(&back)[0].1.runs[0].style.size;
assert!((size - em).abs() < 1e-3, "{size} vs {em}");
let back = crate::import(&write_with(&doc, LegacyAiOptions { outline_text: true, ..LegacyAiOptions::default() }).bytes).unwrap().document;
let b = children(&children(&back.layers[0])[0])[0].visual_bounds().unwrap();
assert!((b.height() - want.height()).abs() < 0.05 && (b.width() - want.width()).abs() < 0.05, "{b:?}, laid out at {want:?}");
}
/// Characters with Auto leading are written with their paragraph's Auto Leading share of the
/// size: 18 pt type in paragraphs at 150% is written with 27 pt leading.
#[test]
+14 -10
View File
@@ -3,8 +3,8 @@ use super::encoding::{
unit, writable,
};
use super::{
Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, Gradient,
GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE,
Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, DYNAMIC_TEXT, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule,
Gradient, GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE,
LAYER_NAME, LIVE, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection, ParaStyle, PathData,
ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind,
TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke,
@@ -733,15 +733,15 @@ impl<'a> Writer<'a> {
// ---------- type ----------
/// Type: a text object when the format can hold it as text, else its outlines (type with
/// rotated characters among them, which keeps their rotation). A run whose
/// font size measures a height other than the em box is written at the em size it is drawn
/// at ([`vectorcraft_text::FontDb::font_heights_resolved`]).
/// rotated characters among them, which keeps their rotation).
fn text(&mut self, t: &TextObject) {
if !invertible(t.xf) {
return;
}
let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t);
let t = &*resolved;
// Written as text or as outlines, the type keeps its drawn sizes but not the setting.
if t.dynamic_text && matches!(t.kind, TextKind::OnPath { .. }) {
self.warn(DYNAMIC_TEXT);
}
if !self.outline_text {
if writable(self.doc, t) {
return self.text_object(t);
@@ -759,14 +759,18 @@ impl<'a> Writer<'a> {
/// A text object (§15): `type To`, the text path (`a b c d tx ty startPt Tp`, an area's frame
/// or the path type runs along, `TP`), then each run's style and characters, `TO`. The matrix
/// takes text space (y up, the first baseline's start for point type) to art space; the frame
/// and path are in art space, as the specification's examples have them (§15.5).
/// and path are in art space, as the specification's examples have them (§15.5). Type that
/// Shrink Text to Fit or Dynamic Text scales is written in the styles it draws in, fitted at
/// the sizes the Character panel shows; then a run whose font size measures a height other
/// than the em box is written at the em size it is drawn at
/// ([`vectorcraft_text::FontDb::font_heights_resolved`]).
fn text_object(&mut self, t: &TextObject) {
if lost_attributes(t) {
self.warn(TYPE_ATTRIBUTES);
}
// Type that Shrink Text to Fit or Dynamic Text scales is written in the styles it draws in.
let fitted = vectorcraft_text::drawn(t);
let t = &*fitted;
let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(&fitted);
let t = &*resolved;
let m = self.to_art * t.xf * Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
let (kind, path, start) = match &t.kind {
TextKind::Point => (0, None, 0.0),
+1 -1
View File
@@ -154,7 +154,7 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
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
// left out for point type and type in a thread, which is VectorCraft's own rule
// (<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()));
+7 -5
View File
@@ -7849,12 +7849,12 @@ its look, in one undo step. Point type in the selection is left as it is, and ar
([Dynamic Text: Block](#dynamic-text-block)). Type on a path in a
thread is refused with an error that names it, and the menu item and the task bar item are not offered for it;
`text.thread.create` and `text.thread.add` turn Dynamic Text off on the frames they thread, at their drawn sizes, and
list them in `dynamicTextOff`. When every object already has the setting asked for, nothing changes and no undo step
list them in `dynamicTextOff`; `addon.type.releasePath` turns it off at the drawn sizes on the point type it makes. When every object already has the setting asked for, nothing changes and no undo step
is recorded. It replies `{on, ids, fitScale}` (the first object's factor, 1 when off), and the menu item is checked
while the first selected type on a path or area type has it on. `document.inspect` reports `dynamicText` and `fitScale` for type
on a path. New type and documents from before it have it off, and it is saved only when on. SVG, PDF, EPS and the
editing data for other apps write the type at its drawn sizes; the editing data does not hold the setting and says
so in a warning.
on a path. New type and documents from before it have it off, and it is saved only when on. SVG, PDF, EPS, legacy
`.ai` and the editing data for other apps write the type at its drawn sizes; legacy `.ai` and the editing data do not
hold the setting, and each says so in a warning.
```json
{"name":"run_command","arguments":{"command":"type.dynamicText","params":{"on":true}}}
@@ -9710,7 +9710,9 @@ VectorCraft addition and changes no parity score.
- **The text** keeps its runs with their character attributes and its paragraph attributes, and becomes point type on
one straight line along the object's own axis, with the baseline starting where the first character sat on the path
(type with no characters starts at its start bracket). Type on a Path Options (effect, align to path, spacing, flip,
brackets) don't apply to point type and are dropped. Without `keepOriginal` the type object itself becomes the point
brackets) don't apply to point type and are dropped. Type with Dynamic Text on (`type.dynamicText`) becomes point
type with it off, at the sizes it was drawn at: each size, leading and baseline shift is multiplied by the fit factor.
Without `keepOriginal` the type object itself becomes the point
type, keeping its id, so a frame of threaded text leaves its thread with the text it holds, as
`type.convertToPointType` does for area type.
- It acts on the type on a path among `ids` (or `id`, else the selection), groups included; other objects are left
+1 -1
View File
@@ -1191,7 +1191,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text.
With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines.
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`).
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 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: reshaping or scaling the path fits the text again; 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, threading the type or Release Type on a Path keeps the drawn sizes; SVG, EPS, legacy `.ai` 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`).
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.