From fc767dad097a60648b8dc58089e0c4d8d070213e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 13:53:20 -0800 Subject: [PATCH] Type: Dynamic Text sets each line of area type to the frame width Type > Dynamic Text (type.dynamicText) now also works on area type as Block. Each line as typed (each paragraph, and each forced line break in one) is set to the frame's width, less the inset, the column gutters and the paragraph's indents, by changing its font size only. Lines are not broken again. The first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt, and the line's other characters keep their proportions. The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step. - Word Gap (0 to 1,000 % of a word space, default 100) sets every paragraph's Desired word spacing; Line Gap (-1,296 to 1,296 pt, default 0) is added to each character's size for its leading. Both are VectorCraft's own ranges and defaults, saved in the area options only when they differ from the defaults. - Area Type Options gains a Block fit (text.areaOptions fit "block", with wordGap and lineGap), and the Properties panel's Paragraph section shows a Dynamic Text checkbox with Word Gap and Line Gap rows. - Block replaces Auto Size and Shrink Text to Fit. Off keeps the sizes. Threaded area type is refused, as for type on a path. - Empty lines keep their size, a frame with no width changes nothing, vertical type fits each line to the frame's height, and right-to-left lines and list markers are fitted like other lines. A refit runs at most four extra layouts. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/doc/src/text.rs | 11 +- crates/doc/src/text/area.rs | 25 ++ crates/engine/src/cmd/create.rs | 17 +- crates/engine/src/cmd/textedit.rs | 4 +- crates/engine/src/cmd/typecmd.rs | 102 +++-- crates/engine/src/cmd/typecmd/block.rs | 309 ++++++++++++++ crates/engine/src/cmd/typecmd/block_tests.rs | 385 ++++++++++++++++++ crates/engine/src/cmd/typemenu.rs | 115 ++++-- crates/engine/src/inspect.rs | 7 +- crates/engine/src/tests/dynamic_text.rs | 10 +- crates/ui-egui/src/dialogs/command.rs | 6 +- crates/ui-egui/src/i18n/hi.tsv | 2 +- crates/ui-egui/src/i18n/sv.tsv | 2 +- crates/ui-egui/src/menus/state.rs | 18 +- crates/ui-egui/src/menus/tests.rs | 11 +- crates/ui-egui/src/panels/properties.rs | 3 + .../src/panels/properties/dynamic_text.rs | 84 ++++ crates/ui-egui/src/panels/properties/tests.rs | 35 ++ docs/mcp.md | 55 ++- docs/target-app-parity.md | 1 + 20 files changed, 1100 insertions(+), 102 deletions(-) create mode 100644 crates/engine/src/cmd/typecmd/block.rs create mode 100644 crates/engine/src/cmd/typecmd/block_tests.rs create mode 100644 crates/ui-egui/src/panels/properties/dynamic_text.rs diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 0be38d261e..05f9c29041 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -77,10 +77,13 @@ pub struct TextObject { /// them up round the inside. #[serde(default, rename = "pathSpacing", skip_serializing_if = "crate::skip::is_default")] pub path_spacing: f64, - /// Type › Dynamic Text (type on a path only): the layout scales the text by one factor so it - /// fills the span between its start and end brackets, and fits it again after each edit. The - /// stored sizes keep their proportions; the layout's `fit_scale` is the factor drawn. Off for new - /// type and for documents from before it, and saved only when on. + /// Type › Dynamic Text. On type on a path, the layout scales the text by one factor so it + /// fills the span between its start and end brackets, and fits it again after each edit; the + /// stored sizes keep their proportions, and the layout's `fit_scale` is the factor drawn. On + /// area type it is Block: after each edit the engine sets each line as typed to the frame's + /// width by its font size and writes the sizes into the characters, with the gaps of + /// [`AreaOptions::word_gap`] and [`AreaOptions::line_gap`]. Point type does not use it. Off for + /// new type and for documents from before it, and saved only when on. #[serde(default, rename = "dynamicText", skip_serializing_if = "crate::skip::is_default")] pub dynamic_text: bool, /// Wrap objects above this area type, resolved by the engine after each edit (text space). diff --git a/crates/doc/src/text/area.rs b/crates/doc/src/text/area.rs index 7bb742f483..1901a23c8a 100644 --- a/crates/doc/src/text/area.rs +++ b/crates/doc/src/text/area.rs @@ -167,6 +167,14 @@ 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). + #[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 + /// default, is left out when saved). + #[serde(skip_serializing_if = "crate::skip::is_default")] + pub line_gap: f64, } impl Default for AreaOptions { @@ -182,6 +190,8 @@ impl Default for AreaOptions { vertical_align: VerticalAlign::Top, flow: TextFlow::Columns, fit: AreaFit::None, + word_gap: Self::DEFAULT_WORD_GAP, + line_gap: 0.0, } } } @@ -189,6 +199,21 @@ impl Default for AreaOptions { impl AreaOptions { /// The most rows, and the most columns, area type can have. pub const MAX_ROWS_COLUMNS: usize = 100; + /// Block's word gap range (percent of a word space), the Justification word spacing range. + pub const WORD_GAP: (f64, f64) = (0.0, 1000.0); + /// Block's word gap new type starts with: the font's own word space. + pub const DEFAULT_WORD_GAP: f64 = 100.0; + /// Block's line gap range in points. + pub const LINE_GAP: (f64, f64) = (-1296.0, 1296.0); + fn is_default_word_gap(v: &f64) -> bool { + *v == Self::DEFAULT_WORD_GAP + } + /// Block's gaps kept within their ranges; a gap that is not a number gives its default. + pub fn block_gaps(&self) -> (f64, f64) { + let word = if self.word_gap.is_finite() { self.word_gap.clamp(Self::WORD_GAP.0, Self::WORD_GAP.1) } else { Self::DEFAULT_WORD_GAP }; + let line = if self.line_gap.is_finite() { self.line_gap.clamp(Self::LINE_GAP.0, Self::LINE_GAP.1) } else { 0.0 }; + (word, line) + } } /// [`AreaOptions::rows`] or [`AreaOptions::columns`], read as a `u64` and clamped to diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 973c82af53..f265c3ff3d 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -109,7 +109,7 @@ pub fn specs() -> Vec { "Create Text", [], None, - "{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", + "{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (as text.areaOptions)}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", has_doc, text_create ), @@ -752,7 +752,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result { let w = f64_or(a, "width", 200.0); let h = f64_or(a, "height", 100.0); t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h)) }; - t.area.fit = new_area_fit(s, a, "text.create")?; + new_area_fit(s, a, "text.create", &mut t)?; } t.system_layout = s.doc()?.doc.setup.system_layout; if bool_or(p, "placeholder", false) { @@ -827,11 +827,16 @@ pub(crate) fn new_type_alignment(s: &mut Session, p: &Value, cmd: &str, t: &mut Ok(()) } -/// The fit new area type gets: `fit`/`fitMinPercent` from `p` (as `text.areaOptions` takes -/// them), else Auto Size when the "Auto Size New Area Type" preference is on. -pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str) -> Result { +/// The fit new area type `t` gets: `fit`/`fitMinPercent` from `p` (as `text.areaOptions` takes +/// them, `block` turning Dynamic Text on), else Auto Size when the "Auto Size New Area Type" +/// preference is on; and Block's `wordGap` and `lineGap`. +pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str, t: &mut TextObject) -> Result<()> { let default = if s.prefs.auto_size_area_type { vectorcraft_doc::AreaFit::AutoHeight } else { vectorcraft_doc::AreaFit::None }; - Ok(super::typecmd::fit_param(p, default, c)?.unwrap_or(default)) + let (word_gap, line_gap) = super::typecmd::block_gaps(p, c)?; + (t.area.fit, t.dynamic_text) = super::typecmd::fit_param(p, default, false, c)?.unwrap_or((default, false)); + t.area.word_gap = word_gap.unwrap_or(t.area.word_gap); + t.area.line_gap = line_gap.unwrap_or(t.area.line_gap); + Ok(()) } /// The character style new type gets: `size`, `font`, `style` and `color` from `p` (an RGB diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 21ed6d8023..00f1dd8adb 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -85,7 +85,7 @@ pub fn specs() -> Vec { "Area / Path Type", [], None, - "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color?, keepModel? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}", + "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color?, keepModel? (as text.create), fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}", has_doc, create_in_path ), @@ -1637,7 +1637,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { }; super::create::new_type_alignment(s, p, C, &mut t)?; if !on_path { - t.area.fit = super::create::new_area_fit(s, p, C)?; + super::create::new_area_fit(s, p, C, &mut t)?; } t.system_layout = s.doc()?.doc.setup.system_layout; if bool_or(p, "placeholder", false) { diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 6d29550fa0..33dad39d93 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -93,7 +93,7 @@ pub fn specs() -> Vec { "Area Type Options…", ["Type"], None, - "{ids?|id?, width?: pt, height?: pt, rows?, columns?, gutter?: pt (between rows and columns), rowGutter?: pt|null (between rows when it differs from gutter, which is then between columns; gutter given alone sets both; the reply gives the rows' gutter either way), inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt, verticalAlign?: top|center|bottom|justify, flow?: columns|rows (Text Flow: down each column's rows, then across, the default; or across each row's columns, then down), fit?: none|autoHeight|shrinkText, fitMinPercent?: 10..100, …the keys of its own reply (it can be passed back; the values it only reports are ignored)} set the selected area type's options (each frame takes the options given over its own and keeps the rest; fit resolves against each frame's own; options that change nothing add no undo step); width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (with fit autoHeight, Auto Size, the frame's height follows the text after every edit and height is ignored; setting the height by hand turns it off); fit shrinkText scales overflowing text down (size, leading, baseline shift) by the largest factor down to fitMinPercent % (default 50) that makes it fit (none given: query) → the first object's options, plus overflow (the text doesn't fit its frame) and fitScale (Shrink Text's factor, 1 unshrunk)", + "{ids?|id?, width?: pt, height?: pt, rows?, columns?, gutter?: pt (between rows and columns), rowGutter?: pt|null (between rows when it differs from gutter, which is then between columns; gutter given alone sets both; the reply gives the rows' gutter either way), inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt, verticalAlign?: top|center|bottom|justify, flow?: columns|rows (Text Flow: down each column's rows, then across, the default; or across each row's columns, then down), fit?: none|autoHeight|shrinkText|block, fitMinPercent?: 10..100, wordGap?: % 0..1000, lineGap?: pt -1296..1296, …the keys of its own reply (it can be passed back; the values it only reports are ignored)} set the selected area type's options (each frame takes the options given over its own and keeps the rest; fit resolves against each frame's own; options that change nothing add no undo step); width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (with fit autoHeight, Auto Size, the frame's height follows the text after every edit and height is ignored; setting the height by hand turns it off); fit shrinkText scales overflowing text down (size, leading, baseline shift) by the largest factor down to fitMinPercent % (default 50) that makes it fit; fit block is Dynamic Text's Block, as type.dynamicText sets it (in place of Auto Size and Shrink Text to Fit; threaded frames are refused; another fit turns it off, keeping the sizes it wrote), and wordGap and lineGap set its gaps (kept while Block is off; out of range is an error) (none given: query) → the first object's options, plus wordGap and lineGap, overflow (the text doesn't fit its frame) and fitScale (Shrink Text's factor, 1 unshrunk)", has_selection, area_options ), @@ -304,11 +304,13 @@ fn ruby(s: &mut Session, p: &Value) -> Result { /// Recompute the layout caches (bounds and baselines) after a text edit. Every engine text edit ends here (typing /// through `text.editRange` too), inside its `Session::edit`, so this is where Auto Size area type -/// ([`vectorcraft_doc::AreaFit::AutoHeight`]) fits its frame to the text: part of the same undo -/// step as the edit. +/// ([`vectorcraft_doc::AreaFit::AutoHeight`]) fits its frame to the text, and Dynamic Text's Block +/// (`dynamic_text` on area type, [`block::block_fit`]) sizes each line to the frame: part of the +/// same undo step as the edit. pub(crate) fn refresh_bounds(t: &mut TextObject) { // Edits that changed the paragraph count keep one paragraph style per paragraph. t.normalize_paras(); + block::block_fit(t); auto_height(t); let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); t.cached_bounds = Some(lay.bounds); @@ -320,6 +322,7 @@ pub(crate) fn refresh_bounds(t: &mut TextObject) { /// ([`vectorcraft_text::take_kept_layout`]) instead of laying the object out again. pub(crate) fn refresh_bounds_for_first_frame(t: &mut TextObject) { t.normalize_paras(); + block::block_fit(t); auto_height(t); let stamp = vectorcraft_text::LayoutStamp::now(); let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); @@ -1026,7 +1029,7 @@ pub(crate) fn reshape_area_with(t: &mut TextObject, f: impl FnOnce(&mut TextObje } /// Area type's text, if `n` is area type. -fn area_text(n: Option<&Node>) -> Option<&TextObject> { +pub(crate) fn area_text(n: Option<&Node>) -> Option<&TextObject> { match n.map(|n| &n.kind) { Some(NodeKind::Text(t)) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) => Some(t), _ => None, @@ -1059,10 +1062,15 @@ fn size_area(t: &mut TextObject, w: Option, h: Option) -> bool { reshape_area_with(t, |t| t.transform_area(a)) } -/// The fit `p` asks for (`fit`: none|autoHeight|shrinkText or `{"shrinkText": {"minPercent"}}`, -/// `fitMinPercent`), starting from `cur`. None when `p` sets neither (or only `fitMinPercent` -/// while the fit isn't Shrink Text). -pub(crate) fn fit_param(p: &Value, cur: AreaFit, c: &str) -> Result> { +/// A fit `p` asks for: the Area Type Options fit, and whether Dynamic Text's Block +/// ([`TextObject::dynamic_text`]) is on. Block's fit is [`AreaFit::None`]: it replaces Auto Size +/// and Shrink Text to Fit. +pub(crate) type FitChoice = (AreaFit, bool); + +/// The fit `p` asks for (`fit`: none|autoHeight|shrinkText|block or `{"shrinkText": {"minPercent"}}`, +/// `fitMinPercent`), starting from `cur` (`block`: Block is on). None when `p` sets neither (or only +/// `fitMinPercent` while the fit isn't Shrink Text). +pub(crate) fn fit_param(p: &Value, cur: AreaFit, block: bool, c: &str) -> Result> { check_numbers(p, &["fitMinPercent"], c)?; let min = p.get("fitMinPercent").and_then(Value::as_f64); let cur_min = match cur { @@ -1071,21 +1079,48 @@ pub(crate) fn fit_param(p: &Value, cur: AreaFit, c: &str) -> Result match (cur, min) { - (AreaFit::ShrinkText { .. }, Some(m)) => AreaFit::ShrinkText { min_percent: m }, + (AreaFit::ShrinkText { .. }, Some(m)) if !block => AreaFit::ShrinkText { min_percent: m }, _ => return Ok(None), }, + Some(Value::String(id)) if is_block_id(id) => return Ok(Some((AreaFit::None, true))), Some(Value::String(id)) => { - AreaFit::parse(id, min.or(cur_min)).ok_or_else(|| bad(c, format!("fit must be none|autoHeight|shrinkText, got {id}")))? + AreaFit::parse(id, min.or(cur_min)).ok_or_else(|| bad(c, format!("fit must be none|autoHeight|shrinkText|block, got {id}")))? } Some(v) => match serde_json::from_value::(v.clone()).map_err(|e| bad(c, format!("bad fit: {e}")))? { AreaFit::ShrinkText { min_percent } => AreaFit::ShrinkText { min_percent: min.unwrap_or(min_percent) }, f => f, }, }; - Ok(Some(match fit { - AreaFit::ShrinkText { min_percent } => AreaFit::ShrinkText { min_percent: AreaFit::clamp_percent(min_percent) }, - f => f, - })) + Ok(Some(( + match fit { + AreaFit::ShrinkText { min_percent } => AreaFit::ShrinkText { min_percent: AreaFit::clamp_percent(min_percent) }, + f => f, + }, + false, + ))) +} + +/// Whether a `fit` id names Dynamic Text's Block (case, spaces, dashes and underscores ignored). +fn is_block_id(id: &str) -> bool { + matches!(id.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str(), "block" | "dynamictext") +} + +/// The `fit` id area type reports: `block` while Dynamic Text's Block is on, else its fit's. +pub(crate) fn fit_id(t: &TextObject) -> &'static str { + if t.dynamic_text { "block" } else { t.area.fit.id() } +} + +/// Dynamic Text's Block gaps `p` gives: `wordGap` (% of a word space, 0..1000) and `lineGap` (pt, +/// -1296..1296). A value that is not a number, or is outside its range, is an error. +pub(crate) fn block_gaps(p: &Value, c: &str) -> Result<(Option, Option)> { + let gap = |key: &str, (lo, hi): (f64, f64)| -> Result> { + match number_given(p, key, c)? { + Some(v) if !(v.is_finite() && (lo..=hi).contains(&v)) => Err(bad(c, format!("`{key}` must be from {lo} to {hi}, not {v}"))), + v => Ok(v), + } + }; + use vectorcraft_doc::AreaOptions as O; + Ok((gap("wordGap", O::WORD_GAP)?, gap("lineGap", O::LINE_GAP)?)) } /// Area type's options as `text.areaOptions` reports them: the Area Type Options with `fit` as @@ -1095,7 +1130,7 @@ pub(crate) fn area_options_json(t: &TextObject) -> Result { let mut v = serde_json::to_value(&t.area).map_err(|e| EngineError::Other(e.to_string()))?; let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); if let Some(o) = v.as_object_mut() { - o.insert("fit".into(), json!(t.area.fit.id())); + o.insert("fit".into(), json!(fit_id(t))); o.insert("flow".into(), json!(t.area.flow)); // The gutter between rows, the gutter's when the rows have none of their own. o.insert("rowGutter".into(), json!(t.area.row_gutter.unwrap_or(t.area.gutter))); @@ -1104,6 +1139,9 @@ pub(crate) fn area_options_json(t: &TextObject) -> Result { _ => AreaFit::DEFAULT_MIN_PERCENT, }; o.insert("fitMinPercent".into(), json!(min)); + let (word_gap, line_gap) = t.area.block_gaps(); + o.insert("wordGap".into(), json!(word_gap)); + o.insert("lineGap".into(), json!(line_gap)); if let Some((w, h, _)) = area_size(t) { o.insert("width".into(), json!(w)); o.insert("height".into(), json!(h)); @@ -1132,8 +1170,9 @@ fn area_options(s: &mut Session, p: &Value) -> Result { // Checked once up front (its errors don't depend on the frame); each frame resolves `fit` // against its own setting below. let cur = area_text(s.doc()?.doc.node(first)).map(|t| t.area.clone()).unwrap_or_default(); - fit_param(p, cur.fit, C)?; - let fit_given = p.get("fit").is_some_and(|v| !v.is_null()) || p.get("fitMinPercent").is_some_and(|v| !v.is_null()); + fit_param(p, cur.fit, false, C)?; + let (word_gap, line_gap) = block_gaps(p, C)?; + let fit_given = ["fit", "fitMinPercent", "wordGap", "lineGap"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())); let size = |k: &str| p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(1.0, 100_000.0)); let (w, h) = (size("width"), size("height")); let given: Vec<(&String, &Value)> = p.as_object().into_iter().flatten().filter(|(k, _)| AREA_KEYS.contains(&k.as_str())).collect(); @@ -1142,7 +1181,7 @@ fn area_options(s: &mut Session, p: &Value) -> Result { } // Each area type takes the options given over its own (#3759): setting one option on several // frames leaves their other options as they were. - let merged = |own: &vectorcraft_doc::AreaOptions| -> Result { + let merged = |own: &vectorcraft_doc::AreaOptions, block: bool| -> Result<(vectorcraft_doc::AreaOptions, bool)> { let mut v = serde_json::to_value(own).map_err(|e| EngineError::Other(e.to_string()))?; if let Some(o) = v.as_object_mut() { for (k, val) in &given { @@ -1162,17 +1201,29 @@ fn area_options(s: &mut Session, p: &Value) -> Result { opts.inset = finite(opts.inset).clamp(0.0, 10_000.0); opts.first_baseline_min = finite(opts.first_baseline_min).clamp(0.0, 10_000.0); // Each frame's fit from its own (`fitMinPercent` alone changes only Shrink Text frames). - opts.fit = fit_param(p, own.fit, C)?.unwrap_or(own.fit); - Ok(opts) + let (fit, block) = fit_param(p, own.fit, block, C)?.unwrap_or((own.fit, block)); + opts.fit = fit; + opts.word_gap = word_gap.unwrap_or(own.word_gap); + opts.line_gap = line_gap.unwrap_or(own.line_gap); + Ok((opts, block)) }; let d = &s.doc()?.doc; - let planned: Vec<(NodeId, vectorcraft_doc::AreaOptions)> = ids + let planned: Vec<(NodeId, (vectorcraft_doc::AreaOptions, bool))> = ids .iter() - .filter_map(|id| area_text(d.node(*id)).map(|t| (*id, &t.area))) - .map(|(id, own)| merged(own).map(|o| (id, o))) + .filter_map(|id| area_text(d.node(*id)).map(|t| (*id, t))) + .map(|(id, t)| merged(&t.area, t.dynamic_text).map(|o| (id, o))) .collect::>()?; - let apply = |t: &mut TextObject, opts: &vectorcraft_doc::AreaOptions| { + // Dynamic Text's Block is refused for threaded frames, as `type.dynamicText` refuses it. + let frames: Vec = + planned.iter().filter(|(id, (_, block))| *block && !super::threads::story_of(d, *id).is_empty()).map(|(id, _)| id.0.to_string()).collect(); + if !frames.is_empty() { + let frames = frames.join(", "); + return Err(bad(C, format!("area type in a thread cannot use Dynamic Text: {frames}"))); + } + let apply = |t: &mut TextObject, (opts, block): &(vectorcraft_doc::AreaOptions, bool)| { t.area = opts.clone(); + // Block off keeps the sizes it wrote into the characters. + t.dynamic_text = *block; // Auto Size sets the height (Illustrator's dialog turns the Height field off). let h = if opts.fit == AreaFit::AutoHeight { None } else { h }; // A resized frame has laid its text out again; otherwise the new options do here. @@ -1313,6 +1364,9 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod area_tests; +mod block; +#[cfg(test)] +mod block_tests; /// Width of the first line and of the space it can fill, with `tracking` on the first paragraph. /// The third value is where the first line ends in the plain text. diff --git a/crates/engine/src/cmd/typecmd/block.rs b/crates/engine/src/cmd/typecmd/block.rs new file mode 100644 index 0000000000..a78f5254cb --- /dev/null +++ b/crates/engine/src/cmd/typecmd/block.rs @@ -0,0 +1,309 @@ +//! Dynamic Text's Block for area type ([`TextObject::dynamic_text`] on area type): each line as +//! typed is set to the frame's width by changing its font size, after every edit +//! ([`super::refresh_bounds`]). + +use vectorcraft_doc::{TextKind, TextObject, TextRun}; + +/// The font size range a line's first character is fitted within, in document points (the +/// 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. +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 +/// (empty for the last line). `para` is the index of its paragraph, and `para_start` whether it +/// starts that paragraph. +#[derive(Clone, Copy, Debug)] +struct TypedLine { + start: usize, + content: usize, + end: usize, + para: usize, + para_start: bool, +} + +/// The lines as typed: the text split after each `\n` and U+2028. +fn typed_lines(text: &str) -> Vec { + let mut out = vec![]; + let (mut start, mut para, mut para_start) = (0, 0, true); + for (i, c) in text.char_indices() { + if c == '\n' || c == '\u{2028}' { + let end = i + c.len_utf8(); + out.push(TypedLine { start, content: i, end, para, para_start }); + start = end; + para_start = c == '\n'; + if c == '\n' { + para += 1; + } + } + } + out.push(TypedLine { start, content: text.len(), end: text.len(), para, para_start }); + out +} + +/// The stored size of the character at `byte` (the run that holds it, not the one before it). +pub(crate) fn size_at(runs: &[TextRun], byte: usize) -> f64 { + let mut off = 0; + for r in runs { + let end = off + r.text.len(); + if (off..end).contains(&byte) { + return r.style.size; + } + off = end; + } + vectorcraft_text::edit::style_at(runs, byte).size +} + +/// The stored size of each line's first character, in one pass over the runs. +fn first_sizes(runs: &[TextRun], lines: &[TypedLine]) -> Vec { + let mut out = Vec::with_capacity(lines.len()); + let (mut ri, mut off) = (0, 0); + for l in lines { + while let Some(r) = runs.get(ri) { + if l.start < off + r.text.len() { + break; + } + off += r.text.len(); + ri += 1; + } + out.push(runs.get(ri).map_or_else(|| size_at(runs, l.start), |r| r.style.size)); + } + out +} + +/// Each line's content width (trailing spaces left out) by its first byte, with `t` laid out as +/// point type, where no line wraps. +fn line_widths(db: &vectorcraft_text::FontDb, t: &TextObject) -> std::collections::HashMap { + let mut probe = t.clone(); + probe.kind = TextKind::Point; + let laid = vectorcraft_text::layout(db, &probe); + let mut out = std::collections::HashMap::with_capacity(laid.lines.len()); + for m in &laid.lines { + out.entry(m.start).or_insert(m.x1 - m.x0); + } + out +} + +/// 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 +/// size rounded down to tenths, and None when that is its size, so fitting again changes nothing. +/// Room taken entirely by indents gives the smallest size. +fn refit(size: f64, width: f64, avail: f64, slope: f64) -> Option { + if width <= avail + 1e-6 && width + 0.1 * slope > avail { + let rounded = tenths(size); + return ((rounded - size).abs() > 1e-9).then_some(rounded); + } + Some(tenths(size + (avail.max(0.0) - width) / slope)) +} + +/// A size in document points rounded down to a whole number of tenths and kept within [`SIZES`]. +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 +/// 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. +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 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); + } + + // The room along a line in one cell of the frame, before indents. + let a = &t.area; + let (along, cells, gutter) = if t.vertical { (b.height(), a.rows, a.row_gutter.unwrap_or(a.gutter)) } else { (b.width(), a.columns, a.gutter) }; + 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; + } + + let db = vectorcraft_text::FontDb::global(); + let text = t.plain_text(); + let lines = typed_lines(&text); + let widths = line_widths(db, t); + let sizes = first_sizes(&t.runs, &lines); + // Per line: (factor applied to its sizes, the room it fills, the last size measured and its + // width), or None to keep its size. + let mut fits: Vec> = lines + .iter() + .zip(&sizes) + .map(|(l, size)| { + if l.content == l.start { + return None; + } + 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 size = size * points; + if !(size.is_finite() && size > 0.0) { + return None; + } + Some((refit(size, width, avail, width / size).map_or(1.0, |n| n / size), avail, size, width)) + }) + .collect(); + + let gap = line_gap / points; + let min_leading = SIZES.0 / points; + // Always from the sizes the refit started with, so each pass sets a line's size once: the runs + // are split where the lines end and each piece scaled by its line's factor, in one pass. + let apply = |t: &mut TextObject, from: &[TextRun], fits: &[Option<(f64, f64, f64, f64)>]| { + let mut out = Vec::with_capacity(from.len() + lines.len()); + let (mut off, mut li) = (0, 0); + for r in from { + let end = off + r.text.len(); + let mut at = off; + loop { + while lines.get(li).is_some_and(|l| l.end <= at && li + 1 < lines.len()) { + li += 1; + } + let stop = lines.get(li).map_or(end, |l| l.end.clamp(at, end)); + let stop = if stop == at { end } else { stop }; + let f = fits.get(li).copied().flatten().map_or(1.0, |(f, ..)| f); + let mut piece = TextRun { text: r.text.get(at - off..stop - off).unwrap_or_default().to_string(), ..r.clone() }; + if at > off { + piece.style.glyph_alternate = None; + } + let st = &mut piece.style; + st.size *= f; + st.baseline_shift *= f; + st.tcy_up_down *= f; + st.tcy_left_right *= f; + st.leading = Some((st.size + gap).max(min_leading)); + if let Some(art) = &mut piece.inline { + art.baseline_shift *= f; + } + out.push(piece); + at = stop; + if at >= end { + break; + } + } + off = end; + } + vectorcraft_text::edit::normalize(&mut out); + t.runs = out; + }; + let start = t.runs.clone(); + apply(t, &start, &fits); + + // Each pass measures the lines again: a line whose width did not follow its size (a tab to a + // fixed stop) is fitted again from its measured width, and when every line measures right, a + // line the frame's shape or a list marker still wraps is made smaller, by what the line the + // layout gave it holds. + // Per line: the smallest size measured too wide, which a later pass never goes back to. + let mut too_wide = vec![f64::INFINITY; lines.len()]; + for _ in 0..BLOCK_PASSES { + let now_widths = line_widths(db, t); + let mut again = false; + for (((l, fit), size), wide) in lines.iter().zip(fits.iter_mut()).zip(&sizes).zip(too_wide.iter_mut()) { + let Some((f, avail, last_size, last_width)) = fit else { continue }; + let size = size * points; + let now = size * *f; + let Some(width) = now_widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9) else { continue }; + if width > *avail + 1e-6 { + *wide = wide.min(now); + } + // The slope between the last two sizes measured, where they differ: a line whose width + // has a part that stays as the size changes is fitted in one step. + let slope = Some((width - *last_width) / (now - *last_size)) + .filter(|s| (now - *last_size).abs() > 1e-9 && s.is_finite() && *s > 1e-9) + .unwrap_or(width / now); + (*last_size, *last_width) = (now, width); + let next = refit(now, width, *avail, slope).map(|n| if n >= *wide - 1e-9 { tenths(*wide - 0.1) } else { n }); + if let Some(next) = next.filter(|n| (n - now).abs() > 1e-9) { + *f = next / size; + again = true; + } + } + if !again { + let laid = vectorcraft_text::layout(db, t); + // Per typed line: how many laid-out lines it took, and the room of its first. + let mut parts: Vec<(usize, f64)> = vec![(0, 0.0); lines.len()]; + for m in &laid.lines { + let i = lines.partition_point(|l| l.start <= m.start).saturating_sub(1); + if let Some(p) = parts.get_mut(i) { + if p.0 == 0 { + p.1 = m.avail.1 - m.avail.0; + } + p.0 += 1; + } + } + for (((l, fit), (count, room)), size) in lines.iter().zip(fits.iter_mut()).zip(&parts).zip(&sizes) { + let Some((f, ..)) = fit else { continue }; + if *count < 2 { + continue; + } + let size = size * points; + let now = size * *f; + if now <= SIZES.0 + 1e-9 { + continue; + } + let width = now_widths.get(&l.start).copied().unwrap_or(f64::INFINITY); + let smaller = if room.is_finite() && *room > 0.0 && *room < width { tenths(now * room / width) } else { now }; + *f = smaller.min(tenths(now - 0.1)) / size; + again = true; + } + } + if !again { + break; + } + apply(t, &start, &fits); + } + (t.runs.clone(), t.para.clone(), t.paras.clone()) != before +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn typed_lines_split_after_returns_and_forced_breaks() { + let l = typed_lines("ab\ncd\u{2028}e\n"); + let spans: Vec<_> = l.iter().map(|l| (l.start, l.content, l.end, l.para, l.para_start)).collect(); + assert_eq!(spans, vec![(0, 2, 3, 0, true), (3, 5, 8, 1, true), (8, 9, 10, 1, false), (10, 10, 10, 2, true)]); + assert_eq!(typed_lines("").len(), 1); + } + + #[test] + fn a_fitting_line_keeps_its_size_in_tenths() { + // 100 pt wide at 12 pt, 100.5 pt of room: a tenth larger would not fit. + assert_eq!(refit(12.0, 100.0, 100.5, 100.0 / 12.0), None); + assert_eq!(refit(12.34, 100.0, 100.5, 100.0 / 12.34), Some(12.3), "a size between tenths is rounded down"); + // Room for twice the width: twice the size. + assert_eq!(refit(12.0, 100.0, 200.0, 100.0 / 12.0), Some(24.0)); + // A part of the width that stays (a slope below width / size) needs more size. + assert_eq!(refit(12.0, 100.0, 150.0, 5.0), Some(22.0)); + } + + #[test] + fn sizes_round_down_to_tenths_within_the_range() { + assert_eq!(tenths(23.99), 23.9); + assert_eq!(tenths(24.0 - 1e-9), 24.0); + assert_eq!(tenths(0.01), 0.1); + assert_eq!(tenths(5000.0), 1296.0); + } +} diff --git a/crates/engine/src/cmd/typecmd/block_tests.rs b/crates/engine/src/cmd/typecmd/block_tests.rs new file mode 100644 index 0000000000..1028fc0017 --- /dev/null +++ b/crates/engine/src/cmd/typecmd/block_tests.rs @@ -0,0 +1,385 @@ +//! Dynamic Text's Block for area type: `type.dynamicText`, Area Type Options › Block and the refit +//! after edits. + +use serde_json::{Value, json}; +use vectorcraft_doc::{AreaFit, NodeId, NodeKind, TextKind, TextObject}; + +use crate::Session; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn area(s: &mut Session, w: f64, h: f64, text: &str) -> u64 { + let id = s.execute("text.create", &json!({"x": 40, "y": 40, "size": 12, "area": {"width": w, "height": h}, "text": text})).unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + id +} + +fn text(s: &Session, id: u64) -> TextObject { + match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + k => panic!("not text: {k:?}"), + } +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// The size of the character at `byte`. +fn size_at(t: &TextObject, byte: usize) -> f64 { + super::block::size_at(&t.runs, byte) +} + +/// Each laid-out line's (start byte, content width, room): the room is the frame's span less the +/// indents. +fn lines(t: &TextObject) -> Vec<(usize, f64, f64)> { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + lay.lines.iter().map(|l| (l.start, l.x1 - l.x0, l.avail.1 - l.avail.0)).collect() +} + +/// Every typed line is one laid-out line whose width is within one 0.1 pt size step of its room. +fn assert_block(t: &TextObject) { + let typed: Vec = std::iter::once(0).chain(t.plain_text().match_indices(['\n', '\u{2028}']).map(|(i, c)| i + c.len())).collect(); + let laid = lines(t); + assert_eq!(laid.iter().map(|l| l.0).collect::>(), typed, "one laid-out line per typed line: {laid:?}"); + for &(start, width, room) in &laid { + let size = size_at(t, start); + if width <= 1e-9 || size >= 1296.0 - 1e-9 || size <= 0.1 + 1e-9 { + continue; + } + assert!(width <= room + 0.01, "line at {start} is wider than its room: {width} > {room}"); + assert!(width >= room * (size - 0.1) / size - 0.01, "line at {start} at {size} pt is short of its room: {width} < {room}"); + } +} + +#[test] +fn block_sets_each_typed_line_to_the_frame_width_by_its_size() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "BIG\na much longer second line\nmid one"); + let n = undo_len(&s); + let r = s.execute("type.dynamicText", &json!({})).unwrap(); + assert_eq!(r, json!({"on": true, "ids": [id], "fitScale": 1.0, "wordGap": 100.0, "lineGap": 0.0})); + assert_eq!(undo_len(&s), n + 1, "one undo step"); + let t = text(&s, id); + assert!(t.dynamic_text); + assert_eq!(t.area.fit, AreaFit::None); + assert_block(&t); + let (big, long, mid) = (size_at(&t, 0), size_at(&t, 4), size_at(&t, 31)); + assert!(big > mid && mid > long, "a shorter line is set larger: {big} {mid} {long}"); + for size in [big, long, mid] { + assert!(((size * 10.0).round() - size * 10.0).abs() < 1e-6, "{size} is a whole number of tenths"); + } + // Each character's leading is its size (line gap 0). + assert!(t.runs.iter().all(|r| r.style.leading == Some(r.style.size)), "{:?}", t.runs); + // Inspect reports it. + let doc = s.execute("document.inspect", &json!({})).unwrap(); + let node = doc["layers"][0]["children"].as_array().unwrap().iter().find(|k| k["id"] == id).unwrap().clone(); + assert_eq!((node["dynamicText"].as_bool(), node["fit"].as_str()), (Some(true), Some("block"))); +} + +#[test] +fn block_keeps_the_proportions_inside_a_line_and_fits_again_after_edits() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "one two three\nfour"); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 3, "size": 24})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert_block(&t); + let ratio = size_at(&t, 0) / size_at(&t, 4); + assert!((ratio - 2.0).abs() < 0.02, "the first word stays twice the size of the rest: {ratio}"); + let four = size_at(&t, 14); + // Typing into the first line makes it smaller, in the edit's own undo step; the second line stays. + let first = size_at(&t, 4); + let n = undo_len(&s); + s.execute("text.editRange", &json!({"id": id, "start": 13, "end": 13, "insert": " and more words"})).unwrap(); + assert_eq!(undo_len(&s), n + 1); + let t = text(&s, id); + assert_block(&t); + assert!(size_at(&t, 4) < first, "{} < {first}", size_at(&t, 4)); + assert_eq!(size_at(&t, t.plain_text().find("four").unwrap()), four); + // A narrower frame sets every line smaller. + s.execute("text.areaOptions", &json!({"width": 150})).unwrap(); + let narrow = text(&s, id); + assert_block(&narrow); + let f = size_at(&narrow, narrow.plain_text().find("four").unwrap()) / four; + assert!((f - 0.5).abs() < 0.02, "half the width, half the size: {f}"); + // Undo brings the wider frame's sizes back. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id).runs, t.runs); +} + +#[test] +fn block_turns_off_keeping_its_sizes_and_records_nothing_when_nothing_changes() { + let mut s = session(); + // The tab goes to a stop that stays where it is as the size changes. + let id = area(&mut s, 300.0, 400.0, "Fit me\nand\tme too"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let fitted = text(&s, id).runs; + let laid = lines(&text(&s, id)); + assert_eq!(laid.iter().map(|l| l.0).collect::>(), vec![0, 7], "{laid:?}"); + assert!(laid.iter().all(|l| l.1 <= l.2 + 0.01 && l.1 > l.2 * 0.95), "each line fills its room: {laid:?}"); + let n = undo_len(&s); + assert_eq!(s.execute("type.dynamicText", &json!({"on": true})).unwrap()["on"], true); + assert_eq!(undo_len(&s), n, "already on: no undo step"); + let r = s.execute("type.dynamicText", &json!({})).unwrap(); + assert_eq!(r["on"], false); + assert_eq!(undo_len(&s), n + 1); + let t = text(&s, id); + assert!(!t.dynamic_text); + assert_eq!(t.runs, fitted, "off keeps the sizes it was drawn at"); + // Edits no longer fit the lines. + s.execute("text.areaOptions", &json!({"width": 100})).unwrap(); + assert_eq!(text(&s, id).runs, fitted); + assert_eq!(s.execute("type.dynamicText", &json!({"on": false})).unwrap()["on"], false); + assert_eq!(undo_len(&s), n + 2, "already off: no undo step"); +} + +#[test] +fn bad_params_and_other_type_are_refused() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "Fit me"); + for bad in [ + json!({"on": "yes"}), + json!({"on": 1}), + json!({"wordGap": "wide"}), + json!({"wordGap": -1}), + json!({"wordGap": 1001}), + json!({"lineGap": 1297}), + json!({"lineGap": -1297}), + json!({"lineGap": true}), + ] { + assert!(s.execute("type.dynamicText", &bad).is_err(), "{bad}"); + } + 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(); + 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. + s.execute("select.set", &json!({"ids": [id, point]})).unwrap(); + 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); +} + +#[test] +fn gaps_set_word_spacing_and_leading() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "two words\nthree more words"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let plain = text(&s, id); + let r = s.execute("type.dynamicText", &json!({"wordGap": 300, "lineGap": 6})).unwrap(); + assert_eq!((r["on"].as_bool(), r["wordGap"].as_f64(), r["lineGap"].as_f64()), (Some(true), Some(300.0), Some(6.0))); + 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]); + 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"); + // 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(); + assert_eq!(r["on"], true); + let t = text(&s, other); + assert!(t.dynamic_text); + assert_eq!((t.area.word_gap, t.area.line_gap), (100.0, -2.0)); +} + +#[test] +fn area_type_options_offer_block_with_its_gaps() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "Fit me\nand me too"); + let q = s.execute("text.areaOptions", &json!({})).unwrap(); + assert_eq!((q["wordGap"].as_f64(), q["lineGap"].as_f64()), (Some(100.0), Some(0.0))); + // Gaps alone are kept for Block, without turning it on or changing the text. + let (n, runs) = (undo_len(&s), text(&s, id).runs); + s.execute("text.areaOptions", &json!({"wordGap": 200})).unwrap(); + let t = text(&s, id); + assert_eq!((undo_len(&s), t.dynamic_text, t.area.word_gap), (n + 1, false, 200.0)); + assert_eq!(t.runs, runs); + let r = s.execute("text.areaOptions", &json!({"fit": "block", "lineGap": 4})).unwrap(); + assert_eq!((r["fit"].as_str(), r["wordGap"].as_f64(), r["lineGap"].as_f64()), (Some("block"), Some(200.0), Some(4.0))); + assert!(text(&s, id).dynamic_text); + assert_block(&text(&s, id)); + // The reply passed back (as the dialog does) changes nothing; naming Block again keeps the gaps. + let n = undo_len(&s); + assert_eq!(s.execute("text.areaOptions", &r).unwrap(), r); + s.execute("text.areaOptions", &json!({"fit": "block"})).unwrap(); + assert_eq!(undo_len(&s), n); + s.execute("text.areaOptions", &json!({"lineGap": 2})).unwrap(); + let t = text(&s, id); + assert_eq!((t.dynamic_text, t.area.word_gap, t.area.line_gap), (true, 200.0, 2.0)); + assert!(s.execute("text.areaOptions", &json!({"lineGap": 5000})).is_err()); + assert!(s.execute("text.areaOptions", &json!({"wordGap": 2000})).is_err()); + assert!(s.execute("text.areaOptions", &json!({"wordGap": "wide"})).is_err()); + assert!(s.execute("text.areaOptions", &json!({"fit": "squash"})).unwrap_err().to_string().contains("shrinkText|block")); + // Shrink Text to Fit replaces Block, keeping the sizes. + let runs = text(&s, id).runs; + s.execute("text.areaOptions", &json!({"fit": "shrinkText"})).unwrap(); + let t = text(&s, id); + assert!(!t.dynamic_text); + assert_eq!(t.runs, runs); + // Block replaces Shrink Text to Fit and Auto Size. + s.execute("text.areaOptions", &json!({"fit": "block"})).unwrap(); + assert_eq!(text(&s, id).area.fit, AreaFit::None); + // New area type can start with it. + let made = s + .execute( + "text.create", + &json!({"x": 10, "y": 300, "size": 12, "area": {"width": 200, "height": 200, "fit": "block", "wordGap": 150}, "text": "made"}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + let t = text(&s, made); + assert_eq!((t.dynamic_text, t.area.word_gap, t.area.line_gap), (true, 150.0, 0.0)); + assert_block(&t); +} + +#[test] +fn block_round_trips_through_the_native_format() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "Fit me\nand me too"); + s.execute("type.dynamicText", &json!({"wordGap": 120, "lineGap": 3})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let bytes = vectorcraft_format::save(&doc, false); + let back = vectorcraft_format::load(&bytes).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert_eq!((t.dynamic_text, t.area.word_gap, t.area.line_gap), (true, 120.0, 3.0)); + assert_eq!(t.runs, text(&s, id).runs, "the fitted sizes are saved in the characters"); + // Saved as `dynamicText` with the gaps beside the area options; missing gaps read as their + // defaults, which are left out when saved. + let saved = serde_json::to_value(&**t).unwrap(); + assert_eq!((&saved["dynamicText"], &saved["area"]["wordGap"], &saved["area"]["lineGap"]), (&json!(true), &json!(120.0), &json!(3.0))); + let plain = serde_json::from_value::(json!({})).unwrap(); + assert_eq!((plain.word_gap, plain.line_gap), (100.0, 0.0)); + let plain: Value = serde_json::to_value(&plain).unwrap(); + assert!(plain.get("wordGap").is_none() && plain.get("lineGap").is_none(), "{plain}"); + // Turned off, it opens off with the sizes it had. + s.execute("type.dynamicText", &json!({"on": false})).unwrap(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert!(!t.dynamic_text); + assert_eq!(t.runs, text(&s, id).runs); +} + +#[test] +fn edge_cases_fit_without_failing() { + let mut s = session(); + // Empty text, an empty line and a line of spaces keep their sizes. + let empty = area(&mut s, 300.0, 400.0, ""); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + assert_eq!(size_at(&text(&s, empty), 0), 12.0); + let gaps = area(&mut s, 300.0, 400.0, "top\n\n \nbottom line"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, gaps); + assert_eq!((size_at(&t, 4), size_at(&t, 5)), (12.0, 12.0)); + assert_block(&t); + // One word. + let word = area(&mut s, 300.0, 400.0, "Word"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + assert_block(&text(&s, word)); + // A wide frame stops at 1,296 pt; a very long line in a narrow frame stops at 0.1 pt and wraps. + let wide = area(&mut s, 100_000.0, 400.0, "i"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + assert_eq!(size_at(&text(&s, wide), 0), 1296.0); + let narrow = area(&mut s, 1.0, 400.0, &"long words ".repeat(40)); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, narrow); + assert_eq!(size_at(&t, 0), 0.1); + assert!(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "at 0.1 pt the line still does not fit"); + // A frame inset so far that it has no width keeps the sizes. + let inset = area(&mut s, 50.0, 400.0, "Inset"); + s.execute("text.areaOptions", &json!({"inset": 30})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + assert_eq!(size_at(&text(&s, inset), 0), 12.0); +} + +#[test] +fn block_fits_columns_lists_vertical_and_right_to_left_type() { + let mut s = session(); + // Two columns: each line is set to a column's width. + let cols = area(&mut s, 400.0, 600.0, "left column\nsecond"); + s.execute("text.areaOptions", &json!({"columns": 2, "gutter": 20})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, cols); + assert_block(&t); + assert!(lines(&t).iter().all(|l| (l.2 - 190.0).abs() < 1e-6), "{:?}", lines(&t)); + // A bulleted list: each item stays one line, the marker and its tab taking their room before + // the text (the room the layout reports takes in the marker's too). + let list = area(&mut s, 300.0, 400.0, "first item\nsecond item here"); + s.execute("text.setFormat", &json!({"list": "bullet"})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, list); + let laid = lines(&t); + assert_eq!(laid.iter().map(|l| l.0).collect::>(), vec![0, 11], "{laid:?}"); + assert!(laid.iter().all(|l| l.1 <= l.2), "{laid:?}"); + assert!(size_at(&t, 0) > size_at(&t, 11), "the shorter item is larger"); + // Right-to-left text. + let rtl = area(&mut s, 300.0, 400.0, "שלום עולם\nטקסט ארוך יותר בשורה"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + assert_block(&text(&s, rtl)); + // Vertical type: each column of text is set to the frame's height. + let v = s + .execute( + "text.create", + &json!({"x": 40, "y": 40, "size": 12, "vertical": true, "area": {"width": 300, "height": 200}, "text": "縦書き\nもっと長い縦書きの行"}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("select.set", &json!({"ids": [v]})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, v); + assert!(t.vertical); + assert_block(&t); + assert!(lines(&t).iter().all(|l| (l.2 - 200.0).abs() < 1e-6), "{:?}", lines(&t)); +} + +#[test] +fn threaded_frames_are_refused_and_threading_turns_block_off() { + let mut s = session(); + let a = area(&mut s, 200.0, 300.0, "Fit me\nand me too"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let fitted = text(&s, a).runs; + let b = s.execute("shape.rectangle", &json!({"x": 400, "y": 40, "width": 200, "height": 300})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let n = undo_len(&s); + s.execute("text.thread.create", &json!({})).unwrap(); + assert_eq!(undo_len(&s), n + 1); + let t = text(&s, a); + assert!(!t.dynamic_text, "threading turns Block off"); + assert_eq!(t.runs, fitted, "the text keeps its sizes"); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let e = s.execute("type.dynamicText", &json!({"on": true})).unwrap_err().to_string(); + assert!(e.contains("area type in a thread") && e.contains(&a.to_string()), "{e}"); + let runs = text(&s, a).runs; + let e = s.execute("text.areaOptions", &json!({"fit": "block"})).unwrap_err().to_string(); + assert!(e.contains("area type in a thread") && e.contains(&a.to_string()), "{e}"); + assert_eq!(text(&s, a).runs, runs, "Area Type Options refuses it too, changing nothing"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(text(&s, a).dynamic_text, "undo brings it back with the thread gone"); + assert!(matches!(text(&s, a).kind, TextKind::Area { .. })); +} + +#[test] +fn long_text_fits_in_a_bounded_number_of_layouts() { + let mut s = session(); + let story: String = (0..300).map(|i| format!("line number {i} {}\n", "x".repeat(i % 17))).collect(); + let id = area(&mut s, 300.0, 100_000.0, &story); + 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; + assert_block(&text(&s, id)); + // The command fits the object to compare and to apply (each a measure, at most two layouts a + // 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"); +} diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index deaac8acc4..c3db479332 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -1,13 +1,13 @@ //! Type menu long tail (Change Case, Smart Punctuation, Convert To Area/Point Type, Type on a Path -//! Options, Fill with Placeholder Text, Insert Special/Whitespace/Break characters) and the text -//! commands of the Edit menu (Find and Replace, Find Next, Paste without Formatting). +//! Options, Dynamic Text, Fill with Placeholder Text, Insert Special/Whitespace/Break characters) +//! and the text commands of the Edit menu (Find and Replace, Find Next, Paste without Formatting). use serde_json::{Value, json}; -use vectorcraft_doc::{CharStyle, Document, Justify, LockedArtboards, Node, NodeId, NodeKind, Quotes, TextKind, TextObject, TextRun}; +use vectorcraft_doc::{AreaFit, CharStyle, Document, Justify, LockedArtboards, Node, NodeId, NodeKind, Quotes, TextKind, TextObject, TextRun}; use vectorcraft_geom::{Affine, Rect, shapes}; use super::object::choice; -use super::typecmd::refresh_bounds; +use super::typecmd::{area_text, block_gaps, 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,8 +65,8 @@ pub fn specs() -> Vec { "Dynamic Text", ["Type"], None, - "{ids?|id?, on?: bool} turn Dynamic Text on or off for the selected type on a path (on omitted: the opposite of the first object's setting). On, 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. Off, the text keeps the sizes it was drawn at. Point type and area type in the selection are left as they are; a selection holding type on a path in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When every object already has the setting, nothing changes and no undo step is recorded → {on, ids, fitScale (the first object's factor, 1 when off)}", - has_free_path_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 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)}", + has_dynamic_type, dynamic_text ), cmd!( @@ -520,26 +520,40 @@ fn path_options(s: &mut Session, p: &Value) -> Result { options(s) } -/// Whether type on a path `id` is a frame of a thread. +/// Whether type `id` is a frame of a thread. fn threaded(d: &Document, id: NodeId) -> bool { !super::threads::story_of(d, id).is_empty() } -/// `type.dynamicText` is enabled for a selection holding type on a path, none of it in a thread. -fn has_free_path_type(s: &Session) -> std::result::Result<(), String> { +/// Type on a path or area type: the type Dynamic Text works on. +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. +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 = st.selection.objects.to_vec(); - let paths: Vec = text_ids(d, &selected).into_iter().filter(|id| path_text(d.node(*id)).is_some()).collect(); - if paths.is_empty() { - return Err("select type on a path".into()); + let types: Vec = text_ids(d, &selected).into_iter().filter(|id| dynamic_type(d.node(*id)).is_some()).collect(); + if types.is_empty() { + return Err("select type on a path or area type".into()); } + threaded_refusal(d, &types).map_or(Ok(()), Err) +} + +/// Why Dynamic Text is refused for `ids`: the frames among them that are in a thread, type on a +/// path first; None when none is. +fn threaded_refusal(d: &Document, ids: &[NodeId]) -> Option { + let (paths, areas): (Vec, Vec) = ids.iter().copied().partition(|id| path_text(d.node(*id)).is_some()); let frames = threaded_ids(d, &paths); if !frames.is_empty() { - return Err(format!("type on a path in a thread cannot use Dynamic Text: {frames}")); + return Some(format!("type on a path in a thread cannot use Dynamic Text: {frames}")); } - Ok(()) + let frames = threaded_ids(d, &areas); + (!frames.is_empty()).then(|| format!("area type in a thread cannot use Dynamic Text: {frames}")) } /// The ids among `ids` of frames of threads, as a list for a message; empty when there are none. @@ -547,32 +561,57 @@ fn threaded_ids(d: &Document, ids: &[NodeId]) -> String { ids.iter().filter(|id| threaded(d, **id)).map(|id| id.0.to_string()).collect::>().join(", ") } -/// Type › Dynamic Text: set `on` (without it, the opposite of the first object's setting) on the -/// selected type on a path, in one undo step, and none when every object already has it. Turning -/// it off keeps the sizes the text was drawn at ([`bake_dynamic_text`]). +/// 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`]). fn dynamic_text(s: &mut Session, p: &Value) -> Result { const C: &str = "type.dynamicText"; - let (ids, on, changing) = { + let (word_gap, line_gap) = block_gaps(p, C)?; + let (ids, on, planned) = { let d = &s.doc()?.doc; - let ids: Vec = texts(s, p, C)?.into_iter().filter(|i| path_text(d.node(*i)).is_some()).collect(); - let first = *ids.first().ok_or_else(|| bad(C, "select type on a path"))?; - let frames = threaded_ids(d, &ids); - if !frames.is_empty() { - return Err(bad(C, format!("type on a path in a thread cannot use Dynamic Text: {frames}"))); + let ids: Vec = texts(s, p, C)?.into_iter().filter(|i| dynamic_type(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 on = switch_or(p, "on", !path_text(d.node(first)).is_some_and(|t| t.dynamic_text), C)?; - let changing: Vec = ids.iter().copied().filter(|id| path_text(d.node(*id)).is_some_and(|t| t.dynamic_text != on)).collect(); - (ids, on, changing) + let gap_given = word_gap.is_some() || line_gap.is_some(); + 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![]; + for id in &ids { + let Some(t) = dynamic_type(d.node(*id)) else { continue }; + let mut after = t.clone(); + if matches!(t.kind, TextKind::Area { .. }) { + after.area.word_gap = word_gap.unwrap_or(t.area.word_gap); + after.area.line_gap = line_gap.unwrap_or(t.area.line_gap); + } + match (on, t.dynamic_text) { + (true, true) => refresh_bounds(&mut after), + (true, false) => { + after.dynamic_text = true; + // Block replaces Auto Size and Shrink Text to Fit. + if matches!(t.kind, TextKind::Area { .. }) { + after.area.fit = AreaFit::None; + } + refresh_bounds(&mut after); + } + (false, true) => { + bake_dynamic_text(&mut after); + } + (false, false) => {} + } + if after != *t { + planned.push((*id, after)); + } + } + (ids, on, planned) }; - if !changing.is_empty() { + if !planned.is_empty() { s.edit("Dynamic Text", |d, _| { - for id in &changing { - let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; - if on { - t.dynamic_text = true; - refresh_bounds(t); - } else { - bake_dynamic_text(t); + for (id, after) in &planned { + if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { + **t = after.clone(); } } Ok(()) @@ -581,7 +620,13 @@ fn dynamic_text(s: &mut Session, p: &Value) -> Result { let d = &s.doc()?.doc; let fit = ids.first().and_then(|id| path_text(d.node(*id))).map_or(1.0, |t| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale); - Ok(json!({"on": on, "ids": ids.iter().map(|i| i.0).collect::>(), "fitScale": fit})) + let mut reply = json!({"on": on, "ids": ids.iter().map(|i| i.0).collect::>(), "fitScale": fit}); + if let Some(t) = ids.iter().find_map(|id| area_text(d.node(*id))) { + let (w, l) = t.area.block_gaps(); + reply["wordGap"] = json!(w); + reply["lineGap"] = json!(l); + } + Ok(reply) } /// Turn Dynamic Text off on `t`, keeping the sizes its layout draws it at: every run's size, diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 347f6734e7..9bad62a6c8 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -99,14 +99,15 @@ pub fn node_summary_opts(n: &Node, opts: SummaryOpts, symbols: &SymbolBoxes) -> NodeKind::Text(t) => { v["text"] = json!(t.plain_text()); // Area type and type on a path: does the text fit (the red overflow "+")? Area type - // also reports its fit and Shrink Text's factor (1 unshrunk), and type on a path its - // Dynamic Text setting and factor (1 when off). + // also reports its fit (`block` for Dynamic Text's Block) and Shrink Text's factor (1 + // unshrunk), and type on a path its factor (1 when off); both report Dynamic Text. if !matches!(t.kind, vectorcraft_doc::TextKind::Point) { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); v["overflow"] = json!(lay.overflow); if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) { - v["fit"] = json!(t.area.fit.id()); + v["fit"] = json!(crate::cmd::typecmd::fit_id(t)); v["fitScale"] = json!(lay.fit_scale); + v["dynamicText"] = json!(t.dynamic_text); } else { v["dynamicText"] = json!(t.dynamic_text); v["fitScale"] = json!(lay.fit_scale); diff --git a/crates/engine/src/tests/dynamic_text.rs b/crates/engine/src/tests/dynamic_text.rs index 80ad1f1af9..3512db9c59 100644 --- a/crates/engine/src/tests/dynamic_text.rs +++ b/crates/engine/src/tests/dynamic_text.rs @@ -88,14 +88,10 @@ 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 and area type alone: refused. + // 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(); - let area = - s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Area", "area": {"width": 100, "height": 50}})).unwrap()["id"].as_u64().unwrap(); - for other in [point, area] { - s.execute("select.set", &json!({"ids": [other]})).unwrap(); - assert!(err(s.execute("type.dynamicText", &json!({}))).contains("select type on a path")); - } + 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()); diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 0a722cbcf8..3acd7e6c0e 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -102,7 +102,7 @@ fn lengths(command: &str) -> &'static [&'static str] { "shape.flare" => &["diameter", "pathLength"], "perspective.grid.set" => &["cell", "distance"], "object.repeat.options" => &["radius", "hSpacing", "vSpacing"], - "text.areaOptions" => &["width", "height", "gutter", "rowGutter", "inset", "firstBaselineMin"], + "text.areaOptions" => &["width", "height", "gutter", "rowGutter", "inset", "firstBaselineMin", "lineGap"], "type.pathOptions" => &["spacing"], "object.pathfinder.trap" => &["thickness"], _ => &[], @@ -156,8 +156,8 @@ const RASTERIZE_CMYK: form::Choices = &[("CMYK", "cmyk"), ("Grayscale", "graysca /// Type on a Path Options › Align to Path. const PATH_ALIGN: form::Choices = &[("Ascender", "ascender"), ("Descender", "descender"), ("Center", "center"), ("Baseline", "baseline")]; -/// Area Type Options › Fit. -const AREA_FIT: form::Choices = &[("None", "none"), ("Auto Size", "autoHeight"), ("Shrink Text to Fit", "shrinkText")]; +/// Area Type Options › Fit (Block is Dynamic Text's, as `type.dynamicText` sets it). +const AREA_FIT: form::Choices = &[("None", "none"), ("Auto Size", "autoHeight"), ("Shrink Text to Fit", "shrinkText"), ("Block", "block")]; /// Area Type Options › First Baseline. const FIRST_BASELINE: form::Choices = diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 572a19779f..73a18cb18b 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -6371,7 +6371,7 @@ antiAlias Sharp शार्प @msg files must be {name, dataBase64} files: फ़ाइलों को {name, dataBase64} होना चाहिए @msg finish editing the pattern first पहले पैटर्न को संपादित करना @msg First measured segment no longer exists पहले मापा खंड अब मौजूद नहीं है -@msg fit must be none|autoHeight|shrinkText, got {id} fit को none|autoHeight|shrinkText होना चाहिए, {id} मिला +@msg fit must be none|autoHeight|shrinkText|block, got {id} fit को none|autoHeight|shrinkText|block होना चाहिए, {id} मिला @msg Flare click coordinates must be finite numbers फ्लेयर क्लिक निर्देशांक परिमित संख्या होना चाहिए @msg Flat Logo traced the image at {_1} × {_2} pixels, averaged down from {_3} × {_4}. फ्लैट लोगो ने {_1} × {_2} पिक्सेल पर इमेज का पता लगाया, जो {_3} × {_4} से नीचे है। @msg Flatten Transparency: {e} Flatten transparency: {e} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb2c463de0..24dcb30bb4 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -7406,7 +7406,7 @@ digits Hindi Hindi @msg features must be a list of tags features måste vara en lista med taggar @msg fidelity must be 0..100 fidelity måste vara 0..100 @msg files must be {name, dataBase64} files måste vara {name, dataBase64} -@msg fit must be none|autoHeight|shrinkText, got {id} fit måste vara none|autoHeight|shrinkText, fick {id} +@msg fit must be none|autoHeight|shrinkText|block, got {id} fit måste vara none|autoHeight|shrinkText|block, fick {id} @msg Flare click coordinates must be finite numbers Klickkoordinaterna för Ljusreflex måste vara ändliga tal @msg flattener: {e} utplattning: {e} @msg flattenerPreset `{name}`: high, medium, low or a saved one (see flattener.presets.list) flattenerPreset `{name}`: high, medium, low eller en sparad (se flattener.presets.list) diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 2e45a88483..9cbeda6043 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -16,16 +16,16 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { let v = &app.ui.view; // An item that can be on or off is `Some` whatever the document and selection (off with none): // the macOS menu bar can't change an item's kind without rebuilding. - // The first type object (of type on a path, with `onpath`) among the selected objects and the - // objects inside them, in the order the type commands act on them. - let selected_text = |onpath: bool| { + // The first type object of a kind `kinds` takes among the selected objects and the objects + // inside them, in the order the type commands act on them. + let selected_text = |kinds: fn(&vectorcraft_engine::doc::TextKind) -> bool| { let st = app.session.active()?; let mut found = None; for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { n.walk(&mut |c| { if found.is_none() && let vectorcraft_engine::doc::NodeKind::Text(t) = &c.kind - && (!onpath || matches!(t.kind, vectorcraft_engine::doc::TextKind::OnPath { .. })) + && kinds(&t.kind) { found = Some(t); } @@ -41,17 +41,17 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { 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, which the item turns the other way. - "type.dynamicText" => selected_text(true).is_some_and(|t| t.dynamic_text), + // 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), "view.outline" => app.outline_view(), "view.pixelPreview" => v.pixel_preview, "view.trimView" => v.trim_view, "view.snapToPixel" => v.snap_to_pixel, "view.snapToGlyph" => v.snap_to_glyph, // Type on a Path effect of the selected path type. - "type.pathOptions" if p.get("effect").is_some() => { - selected_text(true).is_some_and(|t| p.get("effect").and_then(Value::as_str) == Some(t.path_effect.id())) - } + "type.pathOptions" if p.get("effect").is_some() => selected_text(|k| matches!(k, vectorcraft_engine::doc::TextKind::OnPath { .. })) + .is_some_and(|t| p.get("effect").and_then(Value::as_str) == Some(t.path_effect.id())), "view.smartGuides" => v.smart_guides, "edit.autoSpellCheck" => v.auto_spell_check, "view.grid" => v.grid, diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index a449fb0361..8591c4d89d 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -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 that is in no -/// thread, and is checked while the selected type on a path has it on. +/// 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. #[test] fn dynamic_text_is_a_checked_type_menu_item() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); @@ -349,6 +349,13 @@ fn dynamic_text_is_a_checked_type_menu_item() { app.run("text.thread.create", json!({})).unwrap(); app.run("select.set", json!({"ids": [t]})).unwrap(); assert!(!enabled(&app, id), "type on a path in a thread"); + // Area type takes Block, and the item is checked while it is on. + let area = app.run("text.create", json!({"x": 10, "y": 400, "text": "Block", "area": {"width": 100, "height": 50}})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [area]})).unwrap(); + assert!(enabled(&app, id), "area type"); + assert_eq!(checked(&app, id, &Value::Null), Some(false)); + app.run(id, json!({})).unwrap(); + assert_eq!(checked(&app, id, &Value::Null), Some(true)); } /// Type › Optical Margin Alignment menu check uses `shared_text_flag`; mixed values stay unchecked. diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 3dd7da7d6e..6dcd4a516f 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1014,9 +1014,12 @@ pub fn type_sections(app: &mut VectorcraftApp, ui: &mut Ui) { }); } super::paragraph::hyphenate_check(app, ui, ¶); + // Area type: Dynamic Text's Block and its gaps. + dynamic_text::rows(app, ui); } } +pub(crate) mod dynamic_text; pub(crate) mod vertical_align; #[cfg(test)] diff --git a/crates/ui-egui/src/panels/properties/dynamic_text.rs b/crates/ui-egui/src/panels/properties/dynamic_text.rs new file mode 100644 index 0000000000..7caf9e41f5 --- /dev/null +++ b/crates/ui-egui/src/panels/properties/dynamic_text.rs @@ -0,0 +1,84 @@ +//! Dynamic Text in the Properties panel's Paragraph section: the Dynamic Text checkbox for the +//! selected area type (Block, `type.dynamicText`) and, while it is on, its Word Gap and Line Gap +//! rows ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html) +//! describes Block and its spacing between words and between lines). + +use egui::Ui; +use serde_json::{Value, json}; +use vectorcraft_doc::{AreaOptions, NodeKind, TextKind}; + +use crate::VectorcraftApp; +use crate::widgets; + +/// What the selected area type shows: whether Block is on in every frame (`None` when they +/// differ), the gaps every frame shares (`None` when they differ), and whether every frame can +/// take it (none is threaded). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Shown { + pub(crate) on: Option, + pub(crate) word_gap: Option, + pub(crate) line_gap: Option, + pub(crate) enabled: bool, +} + +/// Dynamic Text of the selected area type (and area type in selected groups); `None` when the +/// selection holds no area type, or holds other type too (the command refuses it). +pub(crate) fn shown(app: &VectorcraftApp) -> Option { + let st = app.session.active()?; + let threaded = |id| st.doc.text_threads.iter().any(|t| t.len() > 1 && t.contains(&id)); + let mut frames = vec![]; + let mut other = false; + for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { + n.walk(&mut |c| { + let NodeKind::Text(t) = &c.kind else { return }; + if matches!(t.kind, TextKind::Area { .. }) { + let (word, line) = t.area.block_gaps(); + frames.push((t.dynamic_text, word, line, threaded(c.id))); + } else { + other = true; + } + }); + } + let &(on, word, line, _) = frames.first().filter(|_| !other)?; + Some(Shown { + on: frames.iter().all(|f| f.0 == on).then_some(on), + word_gap: frames.iter().all(|f| f.1 == word).then_some(word), + line_gap: frames.iter().all(|f| f.2 == line).then_some(line), + enabled: frames.iter().all(|f| !f.3), + }) +} + +fn run(app: &mut VectorcraftApp, p: Value) { + if let Err(e) = app.run("type.dynamicText", p) { + app.status(e); + } +} + +/// The checkbox, and the gap rows while every selected frame has Block on. Nothing shows without +/// area type. Returns whether anything was drawn. +pub(crate) fn rows(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { + let Some(s) = shown(app) else { return false }; + if widgets::check(ui, tl!("Dynamic Text"), s.on == Some(true), s.enabled) { + run(app, json!({ "on": s.on != Some(true) })); + } + if s.on != Some(true) { + return true; + } + let fw = super::super::field_width(ui); + let (lo, hi) = AreaOptions::WORD_GAP; + ui.horizontal(|ui| { + ui.label(tl!("Word Gap")); + if let Some(v) = widgets::spin_plain_named(ui, "props-dt-word", Some(tl!("Word Gap")), s.word_gap, "%", 0, fw, 10.0, lo, &[]) { + run(app, json!({ "wordGap": v.clamp(lo, hi) })); + } + }); + let (lo, hi) = AreaOptions::LINE_GAP; + let unit = app.session.general_unit(); + ui.horizontal(|ui| { + ui.label(tl!("Line Gap")); + if let Some(v) = widgets::spin_field_named(ui, "props-dt-line", Some(tl!("Line Gap")), s.line_gap, unit, fw, 1.0, lo, &[]) { + run(app, json!({ "lineGap": v.clamp(lo, hi) })); + } + }); + true +} diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index a9853d3173..5ed020c326 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -1946,3 +1946,38 @@ fn vertical_alignment_buttons_align_the_selected_area_type() { app.run("select.set", json!({"ids": [a]})).unwrap(); assert_eq!(vertical_align::shown(&app).map(|s| s.enabled), Some(false)); } + +/// With area type selected, the Paragraph section shows a Dynamic Text checkbox, and Word Gap and +/// Line Gap while Block is on in every selected frame. Point type in the selection shows none; +/// threaded frames turn the checkbox off. +#[test] +fn dynamic_text_rows_follow_the_selected_area_type() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 500, "height": 400})).unwrap(); + let make = |app: &mut VectorcraftApp, x: f64, y: f64| { + app.run("text.create", json!({"x": x, "y": y, "text": "Panel\ntext", "area": {"width": 100, "height": 120}})).unwrap()["id"].as_u64().unwrap() + }; + let (a, b) = (make(&mut app, 10.0, 10.0), make(&mut app, 200.0, 10.0)); + let point = app.run("text.create", json!({"x": 10, "y": 350, "text": "Point"})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [a, point]})).unwrap(); + assert_eq!(dynamic_text::shown(&app), None, "point type selected too: no rows"); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let off = dynamic_text::Shown { on: Some(false), word_gap: Some(100.0), line_gap: Some(0.0), enabled: true }; + assert_eq!(dynamic_text::shown(&app), Some(off)); + let ctx = egui::Context::default(); + let texts = panel_frame(&mut app, &ctx, vec![], type_sections); + assert!(texts.iter().any(|(t, _)| t == "Dynamic Text") && !texts.iter().any(|(t, _)| t == "Word Gap"), "{texts:?}"); + app.run("type.dynamicText", json!({"on": true, "lineGap": 2})).unwrap(); + let on = dynamic_text::Shown { on: Some(true), word_gap: Some(100.0), line_gap: Some(2.0), enabled: true }; + assert_eq!(dynamic_text::shown(&app), Some(on)); + let texts = panel_frame(&mut app, &ctx, vec![], type_sections); + assert!(texts.iter().any(|(t, _)| t == "Word Gap") && texts.iter().any(|(t, _)| t == "Line Gap"), "{texts:?}"); + // Differing gaps and settings show as mixed. + app.run("type.dynamicText", json!({"ids": [b], "wordGap": 200})).unwrap(); + assert_eq!(dynamic_text::shown(&app).map(|s| (s.on, s.word_gap, s.line_gap)), Some((Some(true), None, Some(2.0)))); + app.run("type.dynamicText", json!({"ids": [b], "on": false})).unwrap(); + assert_eq!(dynamic_text::shown(&app).map(|s| (s.on, s.word_gap)), Some((None, None))); + // A threaded frame turns the checkbox off. + app.run("text.thread.create", json!({})).unwrap(); + assert_eq!(dynamic_text::shown(&app).map(|s| (s.on, s.enabled)), Some((Some(false), false))); +} diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..44da10f748 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6477,12 +6477,17 @@ Native files save it only when it is `rows`, so files from before it open by col sizes don't change: the scaling happens at layout time, so rendering and every export see it. The factor is found by bisection over the first run's size in steps of 0.1 pt (its scaled size is a whole number of tenths of a point, so the result is deterministic); at `fitMinPercent` the text may still overflow. +- `block`: Dynamic Text's Block, as `type.dynamicText` turns it on (see below), in place of Auto Size and Shrink Text + to Fit; another fit turns it off. `wordGap` (0–1,000 % of a word space, default 100) and `lineGap` (-1,296 to 1,296 + pt, default 0) set its gaps, and are kept while Block is off. The file stores `"fit": "autoHeight"` or `"fit": {"shrinkText": {"minPercent": 40}}` in the object's `area`; the -command also accepts that object form. The query (and every reply) of `text.areaOptions` reports `fit` as its id, -`fitMinPercent`, `overflow` (the text doesn't fit its frame) and `fitScale` (Shrink Text's factor, 1 unshrunk). +command also accepts that object form. Block is stored as the object's `"dynamicText": true`, with `wordGap` and +`lineGap` in its `area` when they differ from their defaults. The query (and every reply) of `text.areaOptions` +reports `fit` as its id (`block` while Block is on), `fitMinPercent`, `wordGap`, `lineGap`, `overflow` (the text +doesn't fit its frame) and `fitScale` (Shrink Text's factor, 1 unshrunk). `document.inspect` (and `document.node {summary: true}`) reports `overflow` for area type and type on a path (text past -the end of the path), and `fit` and `fitScale` for area type. +the end of the path), and `fit`, `fitScale` and `dynamicText` (Block is on) for area type. Given for several area types (`ids`, or a selection of several), `text.areaOptions` changes only the options given on each one: each frame keeps its other options (it used to take the first frame's), and `fitMinPercent` alone changes only @@ -6507,6 +6512,45 @@ chosen artboards draw all of them. A PDF leaves out the layers that don't print {"name":"run_command","arguments":{"command":"text.areaOptions","params":{"fit":"shrinkText","fitMinPercent":60}}} ``` +### Dynamic Text: Block + +`type.dynamicText {ids?|id?, on?: bool, wordGap?, lineGap?}` (Type › Dynamic Text) 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) +describes Block as text that scales to fill a rectangular box, with settings for the spacing between words and between +lines. The rest are VectorCraft's own rules: + +- Each line as typed (each paragraph, and each forced line break, Shift+Enter, in one) is one line; lines are not + broken again. Each line is set to the frame's width, less the inset, the gutters between columns and the paragraph's + 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 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 + most four passes: a line whose width does not follow its size (a tab to a fixed stop) is sized again from what it + measures, and a line that still wraps (a frame that is not a rectangle) is made smaller. +- 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"}` + 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. + +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). + +```json +{"name":"run_command","arguments":{"command":"type.dynamicText","params":{"on":true,"wordGap":120,"lineGap":2}}} +``` + ## Threading text from the in and out ports As in the reference app, selected area type and type on a path show an in port and an out port: area type's on the @@ -7314,12 +7358,13 @@ and tate-chu-yoko offsets by one factor, so runs keep their proportions; Type on scaled. The first character's size, in document points, moves in steps of 0.1 pt within 0.1 to 1,296 pt; text that still runs past the end bracket at 0.1 pt overflows. Reshaping or scaling the path fits the text again. The stored sizes do not change: turning Dynamic Text off writes the sizes the text was drawn at into its characters, so it keeps -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 +its look, in one undo step. Point type in the selection is left as it is, and area type in it takes Block +([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 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 +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. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..cd3f49ef69 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1159,6 +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`). 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.