From b426822a9cf33461bc4fcbfac261e39efcea66b7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 13:06:51 -0800 Subject: [PATCH] Type: Release Type on a Path keeps the size Dynamic Text drew the type at Release Type on a Path copied the stored runs into point type with the Dynamic Text flag still set, so the text dropped back to its stored size and the file saved the flag on point type. The point type now gets the drawn sizes and the flag is turned off. The legacy .ai writer writes Dynamic Text type at its drawn sizes; it now also warns that the setting itself is not written, as the editing data writer does, whether the type is written as text or as outlines. docs/target-app-parity.md lists reshaping or scaling the path among VectorCraft's own rules, and a task bar comment marks leaving the item out for threaded type as VectorCraft's own rule. --- crates/engine/src/cmd/addons/typetools.rs | 11 ++++++--- crates/engine/src/tests/dynamic_text.rs | 20 +++++++++++++++ crates/eps/src/legacy_ai.rs | 1 + crates/eps/src/legacy_ai/tests.rs | 30 +++++++++++++++++++++++ crates/eps/src/legacy_ai/writer.rs | 8 ++++-- crates/ui-egui/src/canvas/task_bar.rs | 3 ++- docs/mcp.md | 12 +++++---- docs/target-app-parity.md | 2 +- 8 files changed, 74 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs index bc1d4e4f25..537e6bfe75 100644 --- a/crates/engine/src/cmd/addons/typetools.rs +++ b/crates/engine/src/cmd/addons/typetools.rs @@ -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 { "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 { /// `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 { 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 { (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(); diff --git a/crates/engine/src/tests/dynamic_text.rs b/crates/engine/src/tests/dynamic_text.rs index 80ad1f1af9..f9d21a6404 100644 --- a/crates/engine/src/tests/dynamic_text.rs +++ b/crates/engine/src/tests/dynamic_text.rs @@ -238,6 +238,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); +} + /// The id of the first text object in `s`'s document. fn first_text(s: &Session) -> NodeId { let mut found = None; diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 6b553b8d1a..0bf3960e86 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -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)"; diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 979cd2a436..988cd9a1f0 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -1233,6 +1233,36 @@ 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); +} + /// 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] diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index dc5128565d..f71018996c 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -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, @@ -666,6 +666,10 @@ impl<'a> Writer<'a> { } 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); diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index eb038f0790..4eb5e7dc01 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -153,8 +153,9 @@ 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, as the Type menu's item does; left out for type in a thread + // Dynamic Text turns on or off, as the Type menu's item does // (). + // Leaving it out for type in a thread is VectorCraft's own rule. if crate::menus::enabled(app, "type.dynamicText") { items.push((tl!("Dynamic Text"), "maximize-2", "type.dynamicText", none())); } diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..9a36ac4025 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7317,12 +7317,12 @@ sizes do not change: turning Dynamic Text off writes the sizes the text was draw its look, in one undo step. Point type and area type in the selection are left as they are. 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 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}}} @@ -9009,7 +9009,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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..2da0c4a6a6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1158,7 +1158,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`). 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.