diff --git a/crates/doc/src/text/area.rs b/crates/doc/src/text/area.rs index 1901a23c8..848add8f5 100644 --- a/crates/doc/src/text/area.rs +++ b/crates/doc/src/text/area.rs @@ -167,8 +167,9 @@ pub struct AreaOptions { /// How the frame and its text fit each other (Auto Size, Shrink Text to Fit). #[serde(skip_serializing_if = "crate::skip::is_default")] pub fit: AreaFit, - /// Dynamic Text's Block ([`super::TextObject::dynamic_text`] on area type): every paragraph's - /// Desired word spacing, in percent of a word space (100, the default, is left out when saved). + /// Dynamic Text's Block ([`super::TextObject::dynamic_text`] on area type): the Desired word + /// spacing the layout sets every paragraph with ([`super::TextObject::layout_justification`]), in + /// percent of a word space (100, the default, is left out when saved). #[serde(skip_serializing_if = "AreaOptions::is_default_word_gap")] pub word_gap: f64, /// Dynamic Text's Block: the points added to each character's size to give its leading (0, the diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index 1092b871f..d46b45b1f 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -1,6 +1,6 @@ use super::area::{AreaOptions, PathAlign, PathEffect}; use super::char_style::CharStyle; -use super::para_style::ParaStyle; +use super::para_style::{JustificationOptions, ParaStyle}; use super::runs::{RubySpan, TextKind, TextRun, TypeAntiAlias, move_anchors, reverse_from_end}; use super::tabs_lists::{ListCounts, ParaList}; use super::{Affine, PathData, Point, Rect, TextObject, TextStyleScale, Vec2}; @@ -170,6 +170,19 @@ impl TextObject { pub fn para_at(&self, i: usize) -> &ParaStyle { self.paras.get(i).or(self.paras.last()).unwrap_or(&self.para) } + /// The Justification… options the layout sets `para` with: its own, except that with Dynamic + /// Text's Block on area type ([`Self::dynamic_text`]) the Desired word spacing is the word gap + /// ([`AreaOptions::block_gaps`]), and Minimum and Maximum move out to it when it lies outside + /// them. The paragraph keeps its own values, which apply again when Block is off. + pub fn layout_justification(&self, para: &ParaStyle) -> JustificationOptions { + let mut j = para.justification.clone(); + if self.dynamic_text && matches!(self.kind, TextKind::Area { .. }) { + let gap = self.area.block_gaps().0; + let [min, _, max] = j.word_spacing; + j.word_spacing = [min.min(gap), gap, max.max(gap)]; + } + j + } /// Every paragraph's attributes, one per paragraph. pub fn paragraph_styles(&self) -> Vec { (0..self.paragraph_count()).map(|i| self.para_at(i).clone()).collect() diff --git a/crates/engine/src/cmd/typecmd/block.rs b/crates/engine/src/cmd/typecmd/block.rs index a78f5254c..06453f498 100644 --- a/crates/engine/src/cmd/typecmd/block.rs +++ b/crates/engine/src/cmd/typecmd/block.rs @@ -8,7 +8,8 @@ use vectorcraft_doc::{TextKind, TextObject, TextRun}; /// Character panel's). const SIZES: (f64, f64) = (0.1, 1296.0); -/// Most area layouts a refit runs to correct lines that still wrap after the first fit. +/// Most passes a refit runs to correct lines that still wrap after the first fit; each pass measures +/// the lines and runs at most one more area layout. pub(crate) const BLOCK_PASSES: usize = 4; /// One line as typed: `start..content` is its text, `content..end` the `\n` or U+2028 that ends it @@ -73,9 +74,12 @@ fn first_sizes(runs: &[TextRun], lines: &[TypedLine]) -> Vec { } /// Each line's content width (trailing spaces left out) by its first byte, with `t` laid out as -/// point type, where no line wraps. +/// point type, where no line wraps, with the word spacing `t`'s layout uses. fn line_widths(db: &vectorcraft_text::FontDb, t: &TextObject) -> std::collections::HashMap { let mut probe = t.clone(); + for p in probe.para_styles_mut() { + p.justification = t.layout_justification(p); + } probe.kind = TextKind::Point; let laid = vectorcraft_text::layout(db, &probe); let mut out = std::collections::HashMap::with_capacity(laid.lines.len()); @@ -103,33 +107,24 @@ fn tenths(points: f64) -> f64 { ((points * 10.0 + 1e-6).floor() / 10.0).clamp(SIZES.0, SIZES.1) } -/// Fit area type with Dynamic Text (Block) on: every paragraph's Desired word spacing becomes the word gap, -/// then each line as typed gets the size (its first character's, in steps of 0.1 pt within 0.1 to +/// Fit area type with Dynamic Text (Block) on: with the word gap as every paragraph's Desired word +/// spacing ([`TextObject::layout_justification`]; the paragraphs keep their own values), each line +/// as typed gets the size (its first character's, in steps of 0.1 pt within 0.1 to /// 1,296 pt, the line's other characters keeping their proportions to it) at which its width /// equals the frame's width less the inset, the gutters and the paragraph's indents, and each /// character's leading is its size plus the line gap. An empty line or one of only spaces keeps /// its size. Nothing changes when the frame has no width along the lines or the type's transform /// has no size. A line that still wraps at its first fit is made smaller, in at most -/// [`BLOCK_PASSES`] more layouts. True when the text changed. +/// [`BLOCK_PASSES`] more passes. True when the text changed. pub(crate) fn block_fit(t: &mut TextObject) -> bool { if !t.dynamic_text { return false; } let TextKind::Area { frame } = &t.kind else { return false }; - let (word_gap, line_gap) = t.area.block_gaps(); + let (_, line_gap) = t.area.block_gaps(); let Some(b) = frame.bounds().filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite())) else { return false }; let Some(points) = t.style_scale().map(|s| s.points).filter(|p| p.is_finite() && *p > 1e-12) else { return false }; - let before = (t.runs.clone(), t.para.clone(), t.paras.clone()); - - // The word gap first: it changes the lines' widths. - let mut paras = t.paragraph_styles(); - for p in &mut paras { - let [min, _, max] = p.justification.word_spacing; - p.justification.word_spacing = [min.min(word_gap), word_gap, max.max(word_gap)]; - } - if paras != t.paragraph_styles() { - t.set_paragraph_styles(paras); - } + let before = t.runs.clone(); // The room along a line in one cell of the frame, before indents. let a = &t.area; @@ -137,7 +132,7 @@ pub(crate) fn block_fit(t: &mut TextObject) -> bool { let cells = cells.max(1) as f64; let cell = (along - 2.0 * a.inset.max(0.0) - gutter.max(0.0) * (cells - 1.0)) / cells; if !(cell.is_finite() && cell > 1e-9) { - return (t.runs.clone(), t.para.clone(), t.paras.clone()) != before; + return t.runs != before; } let db = vectorcraft_text::FontDb::global(); @@ -273,7 +268,7 @@ pub(crate) fn block_fit(t: &mut TextObject) -> bool { } apply(t, &start, &fits); } - (t.runs.clone(), t.para.clone(), t.paras.clone()) != before + t.runs != before } #[cfg(test)] diff --git a/crates/engine/src/cmd/typecmd/block_tests.rs b/crates/engine/src/cmd/typecmd/block_tests.rs index 1028fc001..2dda35614 100644 --- a/crates/engine/src/cmd/typecmd/block_tests.rs +++ b/crates/engine/src/cmd/typecmd/block_tests.rs @@ -166,6 +166,17 @@ fn bad_params_and_other_type_are_refused() { 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); + // 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(); + s.execute("select.set", &json!({"ids": [along]})).unwrap(); + let n = undo_len(&s); + for gap in [json!({"wordGap": 300}), json!({"on": false, "lineGap": 2})] { + let e = s.execute("type.dynamicText", &gap).unwrap_err().to_string(); + assert!(e.contains("need area type"), "{e}"); + } + assert_eq!(undo_len(&s), n); + assert!(!text(&s, along).dynamic_text); } #[test] @@ -179,10 +190,39 @@ fn gaps_set_word_spacing_and_leading() { let t = text(&s, id); assert!(t.dynamic_text); assert_eq!((t.area.word_gap, t.area.line_gap), (300.0, 6.0)); - assert_eq!(t.para_at(0).justification.word_spacing, [80.0, 300.0, 300.0]); + // The layout uses the gap as the Desired word spacing; the paragraph keeps its own. + assert_eq!(t.para_at(0).justification.word_spacing, [80.0, 100.0, 133.0]); + assert_eq!(t.layout_justification(t.para_at(0)).word_spacing, [80.0, 300.0, 300.0]); assert!(t.runs.iter().all(|r| (r.style.leading.unwrap() - r.style.size - 6.0).abs() < 1e-9), "{:?}", t.runs); assert_block(&t); assert!(size_at(&t, 0) < size_at(&plain, 0), "wider word spaces leave the letters less room"); + // (own, used by the layout) word spacing of the first paragraph. + let spacing = |s: &Session| { + let t = text(s, id); + (t.para_at(0).justification.word_spacing, t.layout_justification(t.para_at(0)).word_spacing) + }; + // Minimum and Maximum move out only as far as the current gap needs, and the paragraph's own + // values apply again when Block is off. + s.execute("type.dynamicText", &json!({"wordGap": 1000})).unwrap(); + assert_eq!(spacing(&s), ([80.0, 100.0, 133.0], [80.0, 1000.0, 1000.0])); + s.execute("type.dynamicText", &json!({"wordGap": 100})).unwrap(); + assert_eq!(spacing(&s), ([80.0, 100.0, 133.0], [80.0, 100.0, 133.0])); + s.execute("type.dynamicText", &json!({"wordGap": 0})).unwrap(); + assert_eq!(spacing(&s), ([80.0, 100.0, 133.0], [0.0, 0.0, 133.0])); + s.execute("type.dynamicText", &json!({"on": false})).unwrap(); + assert_eq!(spacing(&s), ([80.0, 100.0, 133.0], [80.0, 100.0, 133.0])); + // A paragraph's own values, outside the default range and equal to Desired too, stay through + // gap changes and refits after edits. + s.execute("text.setFormat", &json!({"wordSpacing": [20, 20, 150]})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true, "wordGap": 20})).unwrap(); + assert_eq!(spacing(&s), ([20.0, 20.0, 150.0], [20.0, 20.0, 150.0])); + s.execute("type.dynamicText", &json!({"wordGap": 1000})).unwrap(); + assert_eq!(spacing(&s), ([20.0, 20.0, 150.0], [20.0, 1000.0, 1000.0])); + s.execute("type.dynamicText", &json!({"wordGap": 100})).unwrap(); + s.execute("text.editRange", &json!({"id": id, "start": 0, "end": 0, "insert": "a "})).unwrap(); + assert_eq!(spacing(&s), ([20.0, 20.0, 150.0], [20.0, 100.0, 150.0])); + s.execute("type.dynamicText", &json!({"on": false})).unwrap(); + assert_eq!(spacing(&s), ([20.0, 20.0, 150.0], [20.0, 20.0, 150.0])); // A gap alone on a frame without Block turns it on. let other = area(&mut s, 200.0, 200.0, "x y"); let r = s.execute("type.dynamicText", &json!({"lineGap": -2})).unwrap(); diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index c3db47933..03681c556 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -65,7 +65,7 @@ pub fn specs() -> Vec { "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 every paragraph's Desired word spacing is wordGap (the gaps are area type's, and are kept while Block is off). 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 (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)}", has_dynamic_type, dynamic_text ), @@ -576,6 +576,9 @@ fn dynamic_text(s: &mut Session, p: &Value) -> Result { 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()) { + 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![]; diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index b12811338..645ba3d75 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -693,8 +693,8 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - let split_words = !fewer && !matches!(style.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right); // Each cluster's advance and drawing offset as a reader gives them, by its byte offset. // Justification…'s Desired word and letter spacing and glyph scaling move characters from - // where a reader draws them, on every line. - let spaced = !fewer && !style.justification.desired_is_default(); + // where a reader draws them, on every line (Block's word gap among them). + let spaced = !fewer && !t.layout_justification(style).desired_is_default(); let reader = if split_words || spaced || (!fewer && !style.mojikumi.is_none()) { Some(&*kerned.get_or_insert_with(|| reader_advances(t, lay, true))) } else { diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 56c71c2f4..e3bddbac9 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -217,8 +217,9 @@ fn prepare_paragraph<'a, 'text>( // …and a quarter em between Japanese and Latin text. let mut wakan = if para.mojikumi == Mojikumi::LineEndHalf { space_japanese_and_latin(&mut sg) } else { vec![] }; // Justification…: the glyphs at Desired word and letter spacing and glyph scaling, and how - // far a justified line may move each one from it. - let mut flex = apply_desired(&mut sg, ¶.justification, !cx.vertical); + // far a justified line may move each one from it (Dynamic Text's Block on area type sets the + // Desired word spacing to its word gap). + let mut flex = apply_desired(&mut sg, &t.layout_justification(para), !cx.vertical); // Threaded text: a first paragraph that continues from the frame before has its first // line, first-line indent and list marker there. let continued = pi == 0 && t.first_para_continues; diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index eb038f079..a7a7f0a45 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -151,14 +151,13 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { match kind { TextKind::Area { .. } => items.push((tl!("Convert To Point Type"), "type", "type.convertToPointType", none())), 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, as the Type menu's item does; left out for type in a thread - // (). - if crate::menus::enabled(app, "type.dynamicText") { - items.push((tl!("Dynamic Text"), "maximize-2", "type.dynamicText", 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 + // (). + if crate::menus::enabled(app, "type.dynamicText") { + items.push((tl!("Dynamic Text"), "maximize-2", "type.dynamicText", none())); } } else if is_image { // Offset Path and Simplify don't apply to an image. diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs index 53042baa6..d42be49ff 100644 --- a/crates/ui-egui/src/tests/taskbar.rs +++ b/crates/ui-egui/src/tests/taskbar.rs @@ -208,6 +208,30 @@ fn type_on_a_path_offers_dynamic_text() { assert!(!has(&texts, "Dynamic Text"), "{texts:?}"); } +/// Area type offers Dynamic Text beside Convert To Point Type, which turns Block on and off; area +/// type in a thread does not. +#[test] +fn area_type_offers_dynamic_text() { + let mut app = app(); + let t = id(app.run("text.create", json!({"x": 60, "y": 80, "area": {"width": 200, "height": 100}, "text": "Fit me"})).unwrap()); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + select(&mut app, &[t]); + let dynamic = |app: &VectorcraftApp| matches!(kind(app, t), NodeKind::Text(ref t) if t.dynamic_text); + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Convert To Point Type") && has(&texts, "Dynamic Text"), "{texts:?}"); + click(&mut app, "Dynamic Text"); + assert!(dynamic(&app)); + click(&mut app, "Dynamic Text"); + assert!(!dynamic(&app)); + // Threaded, it is not offered. + let frame = id(app.run("shape.rectangle", json!({"x": 60, "y": 300, "width": 200, "height": 100})).unwrap()); + select(&mut app, &[t, frame]); + app.run("text.thread.create", json!({})).unwrap(); + select(&mut app, &[t]); + 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 { match kind(app, id) { diff --git a/docs/mcp.md b/docs/mcp.md index 44da10f74..c2bd7bbb6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6514,7 +6514,8 @@ chosen artboards draw all of them. A PDF leaves out the layers that don't print ### Dynamic Text: Block -`type.dynamicText {ids?|id?, on?: bool, wordGap?, lineGap?}` (Type › Dynamic Text) turns Block on or off for the +`type.dynamicText {ids?|id?, on?: bool, wordGap?, lineGap?}` (Type › Dynamic Text, and the Contextual Task Bar for +area type outside a thread) turns Block on or off for the selected area type, as it turns Dynamic Text on or off for type on a path ([Dynamic Text](#dynamic-text)). Area Type Options › Block is the same setting, and the Properties panel's Paragraph section has a Dynamic Text checkbox, which shows Word Gap and Line Gap while it is on. The [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html) @@ -6526,8 +6527,10 @@ lines. The rest are VectorCraft's own rules: left, right and first-line indents, by its font size only (tracking and scaling stay): the 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 and their baseline shifts following. Vertical type sets each line to the frame's height (less the gutters between rows). -- `wordGap` is every paragraph's Desired word spacing (Justification…), and each character's leading is its size - plus `lineGap`. +- 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. - 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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cd3f49ef6..bab61d841 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1159,7 +1159,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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`). - Dynamic Text on area type is Block (Type › Dynamic Text, 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, and threaded frames cannot use it (`crates/engine/src/cmd/typecmd/block_tests.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, and threaded frames cannot use it (`crates/engine/src/cmd/typecmd/block_tests.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. Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines.