From ece78f052fc090cb58df739ddc24b9285f48c93c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 18:03:21 -0700 Subject: [PATCH] Type: Standard Vertical Roman Alignment stands half-width characters upright in vertical type Character panel menu > Standard Vertical Roman Alignment, with Show East Asian Options, is now a check item. It sets a new character attribute, `standardVerticalRoman` (text.setFormat, text.setRangeStyle), off by default and saved only when on. In vertical type the marked Latin letters, digits and other characters that would lie on their side stand upright one below the other, each in its upright cell. Spaces keep their width and tate-chu-yoko blocks stay across the column. Horizontal type keeps the mark and does not change. A combining mark shaped as its own glyph turns with its letter, in Latin and CJK upright type alike. Character Style Options labels the Character Alignment row "Character Alignment" and gives Standard Vertical Roman Alignment its own checkbox. Reset Panel turns the attribute off. PDF and SVG export place the glyphs from the layout and need no change. --- crates/doc/src/text/char_style.rs | 9 +++ crates/engine/src/cmd/panelcmds.rs | 6 +- crates/engine/src/cmd/textedit.rs | 10 ++- crates/engine/src/cmd/textstyles.rs | 1 + crates/engine/src/cmd/typecmd/area_tests.rs | 38 ++++++++++ crates/text/src/layout.rs | 52 ++++++++++--- crates/text/src/layout/driver.rs | 1 + crates/text/src/layout/vertical.rs | 8 +- crates/text/src/layout/wrapping_tests.rs | 1 + crates/text/src/shape.rs | 6 ++ crates/text/src/tests/vertical.rs | 54 ++++++++++++++ .../ui-egui/src/dialogs/char_style_options.rs | 73 ++++++++++++++++--- crates/ui-egui/src/panels/character.rs | 17 +++-- crates/ui-egui/src/panels/character/tests.rs | 45 +++++++++++- docs/mcp.md | 2 +- docs/target-app-parity.md | 1 + docs/type-parity.md | 1 + 17 files changed, 290 insertions(+), 35 deletions(-) diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index d53c99e6c..dd84912f0 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -104,6 +104,14 @@ pub struct CharStyle { /// #[serde(default, rename = "tateChuYoko", skip_serializing_if = "crate::skip::is_default")] pub tate_chu_yoko: bool, + /// Standard Vertical Roman Alignment (Character panel menu, East Asian options): in vertical + /// type the half-width characters among these (Latin letters, digits and other characters that + /// would lie on their side) stand upright one below the other instead of being rotated + /// individually. Spaces and tate-chu-yoko blocks do not change, and horizontal type keeps the + /// mark and does not change. + /// + #[serde(default, rename = "standardVerticalRoman", skip_serializing_if = "crate::skip::is_default")] + pub standard_vertical_roman: bool, /// Digits (Character panel menu, Middle Eastern options): the numeral system the digits of /// these characters are drawn in, whatever digits were typed. #[serde(default, skip_serializing_if = "crate::skip::is_default")] @@ -369,6 +377,7 @@ impl Default for CharStyle { char_direction: None, proportional_metrics: false, tate_chu_yoko: false, + standard_vertical_roman: false, small_caps: None, digits: Digits::Default, no_break: false, diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 163d54377..7f504dcc4 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\" (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\" (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), @@ -370,6 +370,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "charDirection", "proportionalMetrics", "tateChuYoko", + "standardVerticalRoman", "noBreak", "digits", "language", @@ -535,6 +536,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = flag("tateChuYoko") { st.tate_chu_yoko = v; } + if let Some(v) = flag("standardVerticalRoman") { + st.standard_vertical_roman = v; + } if let Some(v) = flag("noBreak") { st.no_break = v; } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 1797873ae..8cfdaf47f 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -39,7 +39,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), @@ -319,6 +319,7 @@ pub(crate) struct CharChange { char_direction: Option>, proportional_metrics: Option, tate_chu_yoko: Option, + standard_vertical_roman: Option, no_break: Option, digits: Option, /// The language to set: a tag, or `None` for the default. @@ -471,7 +472,7 @@ impl CharChange { /// `setup` gives the superscript, subscript and small caps proportions. pub(crate) fn parse(p: &Value, cmd: &str, setup: &vectorcraft_doc::DocSetup) -> Result { check_numbers(p, &["size", "tracking", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?; - check_switches(p, &["underline", "strikethrough", "allCaps", "proportionalMetrics", "tateChuYoko", "noBreak"], cmd)?; + check_switches(p, &["underline", "strikethrough", "allCaps", "proportionalMetrics", "tateChuYoko", "standardVerticalRoman", "noBreak"], cmd)?; let num = |k: &str| p.get(k).and_then(Value::as_f64); let (position, small_caps) = super::docsetup::script_params(p, setup, cmd)?; let flag = |k: &str| p.get(k).and_then(Value::as_bool); @@ -517,6 +518,7 @@ impl CharChange { char_direction: char_direction_param(p, cmd)?, proportional_metrics: flag("proportionalMetrics"), tate_chu_yoko: flag("tateChuYoko"), + standard_vertical_roman: flag("standardVerticalRoman"), no_break: flag("noBreak"), digits: digits_param(p, cmd)?, language: language_param(p, cmd)?, @@ -563,6 +565,7 @@ impl CharChange { && self.char_direction.is_none() && self.proportional_metrics.is_none() && self.tate_chu_yoko.is_none() + && self.standard_vertical_roman.is_none() && self.no_break.is_none() && self.digits.is_none() && self.language.is_none() @@ -668,6 +671,9 @@ impl CharChange { if let Some(v) = self.tate_chu_yoko { st.tate_chu_yoko = v; } + if let Some(v) = self.standard_vertical_roman { + st.standard_vertical_roman = v; + } if let Some(v) = self.no_break { st.no_break = v; } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index e70af82a0..5995c490d 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -175,6 +175,7 @@ pub fn char_attrs(v: &CharStyle) -> Map { ("charDirection", json!(v.char_direction)), ("proportionalMetrics", json!(v.proportional_metrics)), ("tateChuYoko", json!(v.tate_chu_yoko)), + ("standardVerticalRoman", json!(v.standard_vertical_roman)), ("noBreak", json!(v.no_break)), ("digits", json!(v.digits)), ("language", json!(v.language)), diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 1dae041d9..65febdf0b 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -1206,6 +1206,44 @@ fn tate_chu_yoko_is_a_character_attribute_saved_only_when_on() { assert!(runs(&s).iter().all(|r| !r.style.tate_chu_yoko)); } +/// Standard Vertical Roman Alignment (Character panel menu, East Asian options): a character +/// attribute, off by default and saved only when on. Documents written before it read as off; a +/// character style captured from the characters carries it. +#[test] +fn standard_vertical_roman_is_a_character_attribute_saved_only_when_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "ABC年"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + assert!(!runs(&s)[0].style.standard_vertical_roman); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("standardVerticalRoman").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"standardVerticalRoman": true})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.standard_vertical_roman)); + assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["standardVerticalRoman"], true); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!runs(&s)[0].style.standard_vertical_roman); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 3, "standardVerticalRoman": true})).unwrap(); + let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.standard_vertical_roman)).collect(); + assert_eq!(on, [("ABC".to_string(), true), ("年".to_string(), false)]); + let old = serde_json::to_value(&runs(&s)[1].style).unwrap(); + assert!(old.get("standardVerticalRoman").is_none()); + assert!(!serde_json::from_value::(old).unwrap().standard_vertical_roman); + // Saved and reopened as it was. + 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") }; + let reopened: Vec<_> = t.runs.iter().map(|r| (r.text.clone(), r.style.standard_vertical_roman)).collect(); + assert_eq!(reopened, on); + assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[0].style)["standardVerticalRoman"], true); + assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[1].style)["standardVerticalRoman"], false); + assert!(s.execute("text.setRangeStyle", &json!({"id": id, "standardVerticalRoman": "true"})).is_err()); + s.execute("text.setFormat", &json!({"standardVerticalRoman": false})).unwrap(); + assert!(runs(&s).iter().all(|r| !r.style.standard_vertical_roman)); +} + /// No Break is a character attribute, saved only when on. #[test] fn no_break_is_a_character_attribute_saved_only_when_on() { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 66cdaf304..f217eaa3a 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -41,6 +41,12 @@ use vertical::{stands_upright, tate_chu_yoko, upright_cell, upright_centre, whol const EPS: f64 = 1e-6; +/// Is glyph `g` a later glyph of the cluster of glyph `prev` before it (a combining mark the shaper +/// set as a glyph of its own)? A list marker's glyphs stand for no character and are not. +fn continues_cluster(prev: Option<&SGlyph>, g: Option<&SGlyph>) -> bool { + prev.zip(g).is_some_and(|(a, b)| a.len > 0 && b.len > 0 && a.byte == b.byte) +} + struct Ctx<'a> { on_path: bool, db: &'a FontDb, @@ -58,6 +64,10 @@ struct Ctx<'a> { system_layout: bool, /// Character panel menu › Fractional Widths. Off, positive advances are whole points. fractional_widths: bool, + /// Vertical type: the upright glyph [`Self::emit`] placed last, as (its byte, the index the + /// next glyph of its cluster would take, the turn it got, the line-space x that next glyph is + /// set after), so that a combining mark shaped into the same cluster turns with it. + upright_cluster: Option<(usize, usize, Affine, f64)>, out: TextLayout, } @@ -108,8 +118,11 @@ impl Ctx<'_> { }); // An upright glyph advances down the column by its vertical advance (the font's vertical // metrics; without them at least one em, as CJK fonts' is), centred across it: a narrow - // mark like § must not overlap its neighbours. - for g in v.iter_mut().filter(|g| g.adv > 0.0 && stands_upright(g)) { + // mark like § must not overlap its neighbours. A combining mark shaped into the cluster + // of the glyph before it takes no cell of its own: it turns with that glyph (`emit`). + for i in 0..v.len() { + let continues = continues_cluster(i.checked_sub(1).and_then(|p| v.get(p)), v.get(i)); + let Some(g) = v.get_mut(i).filter(|g| g.adv > 0.0 && !continues && stands_upright(g)) else { continue }; let extra = upright_cell(g) - g.face.advance(g.gid) * g.sx; g.dx += extra * 0.5; g.adv += extra; @@ -181,6 +194,8 @@ impl Ctx<'_> { } fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) { + // Only the glyph right after an upright glyph can continue its cluster. + let cluster = self.upright_cluster.take(); // Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef). let blank = g.is_soft_hyphen() || g.ch.is_control() || g.ch == '\u{2028}'; // A colour glyph draws its art (COLR layers, an SVG document) or its image (a bitmap, or @@ -235,16 +250,29 @@ impl Ctx<'_> { let squeeze = Affine::translate((centre.x, 0.0)) * Affine::scale_non_uniform(t.squeeze, 1.0) * Affine::translate((-centre.x, 0.0)); m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, centre) * squeeze * across * m; } else if self.vertical && stands_upright(g) { - // Turned about the centre of its cell, which the column's centre line runs through: the - // middle of its own cell, not of its advance (tracking and justification add space - // after the cell, and must not push the glyph off the centre line). - let em = self.style_at(g.byte).size; - let cell = if g.adv > 0.0 { upright_cell(g) } else { advance }; - // A leading space taken off moves it up the column after the turn (moving the - // turning point instead would move it across the column too). - m = Affine::translate((-g.lead, 0.0)) - * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - upright_centre(g, cell, em))) - * m; + let next = self.out.glyphs.len(); + let after_base = self.out.glyphs.last().is_some_and(|p| p.byte == g.byte && p.line == line && p.len > 0); + if let Some((_, _, turn, after)) = cluster.filter(|c| c.0 == g.byte && c.1 == next && after_base && g.len > 0) { + // A later glyph of an upright glyph's cluster: set after it as the shaper placed + // it, and turned with it. + m = turn * Affine::translate((after - origin.x, 0.0)) * m; + self.upright_cluster = Some((g.byte, next + 1, turn, after)); + } else { + // Turned about the centre of its cell, which the column's centre line runs through: + // the middle of its own cell, not of its advance (tracking and justification add + // space after the cell, and must not push the glyph off the centre line). + let em = self.style_at(g.byte).size; + let cell = if g.adv > 0.0 { upright_cell(g) } else { advance }; + // A leading space taken off moves it up the column after the turn (moving the + // turning point instead would move it across the column too). + let turn = Affine::translate((-g.lead, 0.0)) + * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - upright_centre(g, cell, em))); + m = turn * m; + // Its own advance across, from where the cell centres it: where the shaper set the + // next glyph of its cluster. + let across = g.face.advance(g.gid) * g.sx; + self.upright_cluster = (g.len > 0).then_some((g.byte, next + 1, turn, origin.x + (cell - across) * 0.5 + across)); + } } let outline = if src.elements().is_empty() || blank { BezPath::new() diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index 03a5d6617..4cb518d0d 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -228,6 +228,7 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout vertical, system_layout: t.system_layout, fractional_widths: t.fractional_widths, + upright_cluster: None, out: TextLayout::default(), }; // Vertical type: line space turned a quarter turn clockwise (lines become columns, each next diff --git a/crates/text/src/layout/vertical.rs b/crates/text/src/layout/vertical.rs index 0953ae656..61251d76e 100644 --- a/crates/text/src/layout/vertical.rs +++ b/crates/text/src/layout/vertical.rs @@ -218,6 +218,12 @@ pub(super) const QUARTER_TURN: Affine = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0 /// their side when the font has none (Unicode's Vertical_Orientation Tr). /// Fullwidth !/? instead keep the nominal glyph upright (Tu fallback, UAX #50 §3.1): /// . +/// +/// Standard Vertical Roman Alignment (Character panel menu): a marked Latin letter, digit or other +/// character outside the classes above stands upright instead of lying on its side, except spaces +/// and control characters (they keep their width down the column) and the glyphs of a +/// tate-chu-yoko block, which stays across the column. +/// pub(super) fn stands_upright(g: &SGlyph) -> bool { let c = g.ch as u32; let upright = matches!(c, @@ -230,7 +236,7 @@ pub(super) fn stands_upright(g: &SGlyph) -> bool { | 0xE000..=0xFAFF | 0xFE10..=0xFE1F | 0xFE30..=0xFE4F | 0xFE50..=0xFE6F | 0xFF00..=0xFFE7 | 0x1F000..=0x1FAFF | 0x20000..=0x3FFFF); if !upright { - return false; + return g.vertical_roman && g.tcy.is_none() && g.inline.is_none() && !g.ch.is_whitespace() && !g.ch.is_control(); } let needs_vertical_form = matches!(c, 0x3001 | 0x3002 | 0x3008..=0x3011 | 0x3013..=0x301F | 0x3030 | 0x30A0 | 0x30FC diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 6be50fbd8..360c386ea 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -36,6 +36,7 @@ fn synthetic_glyphs(specs: &[(usize, usize, f64, char)]) -> Vec { vpal: None, level: unicode_bidi::Level::ltr(), no_break: false, + vertical_roman: false, cursive: false, }) .collect() diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index f0fbb0c4d..e04c6c6dc 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -69,6 +69,9 @@ pub(crate) struct SGlyph { /// No Break (Character panel menu): keep this glyph on the same line as the neighbouring /// glyphs that have it. pub no_break: bool, + /// Standard Vertical Roman Alignment (Character panel menu): in vertical type this glyph + /// stands upright when it would otherwise lie on its side ([`crate::layout`]). + pub vertical_roman: bool, /// The glyph is of a script whose letters join (Arabic, Syriac, N'Ko, Mongolian, Adlam…), /// decided for the whole segment it was shaped in ([`joins`]): on a curved path it is bent /// along the path so its joins meet. @@ -713,6 +716,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { char_direction: _, proportional_metrics, tate_chu_yoko, + standard_vertical_roman: _, digits, no_break, language, @@ -828,6 +832,7 @@ fn inline_glyph(face: &Arc, st: &CharStyle, art: &InlineArt, run: usiz inline: Some(InlineBox { art: drawn, xf }), word_end: false, no_break: st.no_break, + vertical_roman: false, cursive: false, } } @@ -1137,6 +1142,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe inline: None, word_end: false, no_break: gst.no_break, + vertical_roman: gst.standard_vertical_roman, cursive, }); } diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index a2206df5e..1010ed84a 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -379,6 +379,60 @@ fn manual_tate_chu_yoko_leaves_unmarked_digits_and_stays_on_one_line() { assert!(wrapped.lines.len() > 1, "unmarked 2026 is longer than the column"); } +/// Character panel menu › Standard Vertical Roman Alignment: marked Latin letters and digits stand +/// upright one below the other, each in a cell of one em (the bundled font has no vertical +/// metrics), centred on the column. A space keeps its width, two digits stay tate-chu-yoko, +/// unmarked letters lie on their side, and horizontal type is unchanged. +#[test] +fn standard_vertical_roman_alignment_stands_half_width_characters_upright() { + let plain = CharStyle { size: 20.0, ..CharStyle::default() }; + let marked = CharStyle { standard_vertical_roman: true, ..plain.clone() }; + let h = layout(FontDb::global(), &TextObject::point(Point::ZERO, "l", plain.clone())); + let side = layout(FontDb::global(), &vertical_styled("lA l", plain.clone())); + let up = layout(FontDb::global(), &vertical_styled("lA l", marked.clone())); + let (hl, sl, ul) = (ink(&h, 0), ink(&side, 0), ink(&up, 0)); + assert!(sl.width() > sl.height(), "unmarked l lies across the column: {sl:?}"); + assert!((ul.width() - hl.width()).abs() < 0.01 && (ul.height() - hl.height()).abs() < 0.01, "marked l upright: {hl:?} {ul:?}"); + assert!(ul.height() > ul.width()); + for i in [0, 1, 3] { + assert!((up.glyphs[i].advance - 20.0).abs() < 1e-6, "one em down the column: {:?}", up.glyphs[i].advance); + assert!(ink(&up, i).center().x.abs() < 1.5, "centred on the column: {:?}", ink(&up, i)); + assert!(side.glyphs[i].advance < 20.0, "unmarked advance: {:?}", side.glyphs[i].advance); + } + assert!(ink(&up, 1).center().y > ink(&up, 0).center().y + 15.0, "one below the other"); + assert!((up.glyphs[2].advance - side.glyphs[2].advance).abs() < 1e-6, "the space keeps its width"); + + let digits = layout(FontDb::global(), &vertical_styled("10", marked.clone())); + assert!((digits.glyphs[0].advance - 20.0).abs() < 1e-6 && digits.glyphs[1].advance == 0.0, "two digits stay tate-chu-yoko"); + let (a, b) = (ink(&digits, 0), ink(&digits, 1)); + assert!((a.center().y - b.center().y).abs() < 1.0 && b.center().x > a.center().x, "side by side: {a:?} {b:?}"); + + let mut horizontal = TextObject::point(Point::ZERO, "lA", marked); + horizontal.xf = Affine::IDENTITY; + let row = layout(FontDb::global(), &horizontal); + let plain_row = layout(FontDb::global(), &TextObject::point(Point::ZERO, "lA", plain)); + assert_eq!(row.glyphs.iter().map(|g| g.advance).collect::>(), plain_row.glyphs.iter().map(|g| g.advance).collect::>()); + assert!((ink(&row, 0).height() - hl.height()).abs() < 0.01, "horizontal type stays as it is"); +} + +/// A combining mark shaped as a glyph of its own turns upright with its letter and stays over it, +/// as in horizontal type, and takes no cell of its own down the column. +#[test] +fn standard_vertical_roman_alignment_keeps_combining_marks_on_their_letter() { + let plain = CharStyle { size: 20.0, ..CharStyle::default() }; + let text = "q\u{301}x"; + let h = layout(FontDb::global(), &TextObject::point(Point::ZERO, text, plain.clone())); + assert_eq!(h.glyphs.len(), 3, "the bundled font sets the acute as a glyph of its own"); + let up = layout(FontDb::global(), &vertical_styled(text, CharStyle { standard_vertical_roman: true, ..plain })); + assert_eq!(up.glyphs.len(), 3); + let (hq, ha, q, a, x) = (ink(&h, 0), ink(&h, 1), ink(&up, 0), ink(&up, 1), ink(&up, 2)); + let offset = |base: Rect, mark: Rect| (mark.center().x - base.center().x, mark.center().y - base.center().y); + let (want, got) = (offset(hq, ha), offset(q, a)); + assert!((want.0 - got.0).abs() < 0.01 && (want.1 - got.1).abs() < 0.01, "the acute sits on the q as in a row: {want:?} {got:?}"); + assert!((up.glyphs[0].advance - 20.0).abs() < 1e-6 && up.glyphs[1].advance == 0.0, "one cell for the cluster"); + assert!(x.center().y > q.center().y + 10.0, "the next letter below: {q:?} {x:?}"); +} + /// Issue #260 as reported, with a Japanese face of craft-fonts (whose own digits are proportional). #[test] fn japanese_numbers_keep_their_own_cells_down_the_column() { diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs index 848151ac5..71d48056b 100644 --- a/crates/ui-egui/src/dialogs/char_style_options.rs +++ b/crates/ui-egui/src/dialogs/char_style_options.rs @@ -19,9 +19,12 @@ const BASIC: &[&str] = &[ "position", "underline", "strikethrough", + "standardVerticalRoman", "charAlign", ]; const NUMBERS: &[&str] = &["size", "leading", "kerning", "tracking"]; +/// The on, off or unset checkboxes. +const SWITCHES: &[&str] = &["underline", "strikethrough", "standardVerticalRoman"]; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Character Style Options").into(), @@ -41,6 +44,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { "position", "underline", "strikethrough", + "standardVerticalRoman", "charAlign", "section", ] @@ -68,9 +72,11 @@ pub fn open(app: &mut VectorcraftApp, name: &str) -> Result { pub(crate) fn fields_from_attrs(attrs: &Map) -> Map { let mut fields = Map::new(); - for key in ["font_family", "font_style", "size", "leading", "kerning", "tracking", "underline", "strikethrough", "charAlign"] { + for key in + ["font_family", "font_style", "size", "leading", "kerning", "tracking", "underline", "strikethrough", "standardVerticalRoman", "charAlign"] + { let value = attrs.get(key).cloned().unwrap_or(Value::Null); - let shown = if ["underline", "strikethrough"].contains(&key) { + let shown = if SWITCHES.contains(&key) { value } else if value.is_null() { if attrs.contains_key(key) && ["leading", "kerning"].contains(&key) { json!("Auto") } else { json!("") } @@ -144,9 +150,9 @@ pub(crate) fn edited_attrs(d: &Dialog) -> Result, String> { attrs.insert((*key).into(), v); } } - for key in ["underline", "strikethrough"] { - if let Some(v) = d.fields.get(key).and_then(Value::as_bool) { - attrs.insert(key.into(), json!(v)); + for key in SWITCHES { + if let Some(v) = d.fields.get(*key).and_then(Value::as_bool) { + attrs.insert((*key).into(), json!(v)); } } if reset || d.fields.get("case").and_then(Value::as_str) != case_from_attrs(&original) { @@ -204,8 +210,20 @@ pub(crate) fn edited_attrs(d: &Dialog) -> Result, String> { pub(crate) fn reset_basic(d: &mut Dialog) { d.fields.insert("__resetBasic".into(), json!(true)); - for key in ["font_family", "font_style", "size", "leading", "kerning", "tracking", "case", "position", "underline", "strikethrough", "charAlign"] - { + for key in [ + "font_family", + "font_style", + "size", + "leading", + "kerning", + "tracking", + "case", + "position", + "underline", + "strikethrough", + "standardVerticalRoman", + "charAlign", + ] { d.fields.insert( key.into(), if ["font_family", "font_style", "size", "leading", "kerning", "tracking"].contains(&key) { json!("") } else { Value::Null }, @@ -255,7 +273,11 @@ pub(crate) fn basic_body(ui: &mut egui::Ui, d: &mut Dialog) { ui.label(tl!("Position")); choice(ui, d, "position", &[("normal", tl!("Normal")), ("superscript", tl!("Superscript")), ("subscript", tl!("Subscript"))]); ui.end_row(); - for (key, caption) in [("underline", tl!("Underline")), ("strikethrough", tl!("Strikethrough"))] { + for (key, caption) in [ + ("underline", tl!("Underline")), + ("strikethrough", tl!("Strikethrough")), + ("standardVerticalRoman", tl!("Standard Vertical Roman Alignment")), + ] { ui.label(""); let value = d.fields.get(key).and_then(Value::as_bool); if crate::widgets::check3(ui, caption, value, true) { @@ -270,7 +292,7 @@ pub(crate) fn basic_body(ui: &mut egui::Ui, d: &mut Dialog) { } ui.end_row(); } - ui.label(tl!("Standard Vertical Roman Alignment")); + ui.label(tl!("Character Alignment")); choice( ui, d, @@ -466,6 +488,39 @@ mod tests { assert_eq!(app.session.doc().unwrap().doc.char_styles.iter().find(|def| def.name == "Future").unwrap().attrs["size"], json!(36.0)); } + /// Basic Character Formats shows Character Alignment under its own name, and Standard Vertical + /// Roman Alignment as its own on, off or unset checkbox that the style saves. + #[test] + fn standard_vertical_roman_alignment_has_its_own_row() { + let mut d = Dialog::new(KIND, json!({})); + d.fields.extend(fields_from_attrs(&Map::new())); + d.fields.insert("__attrs".into(), json!({})); + let mut app = app(); + let shown = crate::tests_labels::painted_text(&mut app, |_, ui| basic_body(ui, &mut d)); + assert!(shown.contains("Character Alignment"), "{shown}"); + assert!(shown.contains("Standard Vertical Roman Alignment"), "{shown}"); + assert!(d.fields["standardVerticalRoman"].is_null()); + assert_eq!(edited_attrs(&d).unwrap(), Map::new()); + d.fields.insert("standardVerticalRoman".into(), json!(true)); + d.fields.insert("charAlign".into(), json!("emBoxCenter")); + assert_eq!(edited_attrs(&d).unwrap(), json!({"standardVerticalRoman": true, "charAlign": "emBoxCenter"}).as_object().unwrap().clone()); + let fields = fields_from_attrs(json!({"standardVerticalRoman": false}).as_object().unwrap()); + assert_eq!(fields["standardVerticalRoman"], json!(false)); + reset_basic(&mut d); + assert!(d.fields["standardVerticalRoman"].is_null()); + + app.run("charStyle.new", json!({"name": "Upright", "attrs": {}})).unwrap(); + let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "AB", "vertical": true})).unwrap()["id"].as_u64().unwrap(); + app.run("charStyle.apply", json!({"name": "Upright", "id": id})).unwrap(); + open(&mut app, "Upright").unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("standardVerticalRoman".into(), json!(true)); + super::super::confirm(&mut app).unwrap(); + let doc = &app.session.doc().unwrap().doc; + assert_eq!(doc.char_styles[0].attrs["standardVerticalRoman"], json!(true)); + let vectorcraft_doc::NodeKind::Text(t) = &doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind else { panic!("text") }; + assert!(t.runs.iter().all(|r| r.style.standard_vertical_roman)); + } + #[test] fn invalid_numbers_name_the_visible_field() { let mut d = Dialog::new(KIND, json!({})); diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index bf5788d98..b3a72b9d3 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -1120,6 +1120,13 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Tate-chu-yoko"), has, tcy) { format(app, json!({"tateChuYoko": !tcy})); } + // Standard Vertical Roman Alignment: the selected half-width characters stand upright in + // vertical type instead of being rotated. Checked when every selected character has it; a + // mixed selection turns it on. + let roman = shared(app, |s| s.standard_vertical_roman) == Some(true); + if menu_item(ui, tl!("Standard Vertical Roman Alignment"), has, roman) { + format(app, json!({"standardVerticalRoman": !roman})); + } ui.separator(); } // Digits: the numeral system the selected characters' digits are drawn in, with the Middle @@ -1156,7 +1163,6 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("System Layout"), open, system) { app.run("type.systemLayout", json!({})).ok(); } - menu_item(ui, tl!("Standard Vertical Roman Alignment"), false, false); ui.separator(); // The installed fonts are always listed; this picks up fonts installed since the app started. #[cfg(not(target_arch = "wasm32"))] @@ -1184,9 +1190,9 @@ fn fractional_widths_on(app: &VectorcraftApp) -> bool { /// Reset Panel: the characters the panel acts on ([`char_cmd`]) take tracking 0, Auto leading and /// kerning, no baseline shift, 100% scales, no rotation, no underline or strikethrough, All Caps /// and Small Caps off, the normal position (neither superscript nor subscript), Roman Baseline -/// character alignment, Proportional Metrics, Tate-chu-yoko and No Break off, the Default character -/// direction and the Default digits, whether or not Show East Asian Options and Show Indic Options -/// are on. +/// character alignment, Proportional Metrics, Tate-chu-yoko, Standard Vertical Roman Alignment and +/// No Break off, the Default character direction and the Default digits, whether or not Show East +/// Asian Options and Show Indic Options are on. fn reset_panel(app: &mut VectorcraftApp) { let d = CharStyle::default(); style(app, json!({"tracking": 0, "leading": "auto"})); @@ -1195,7 +1201,8 @@ fn reset_panel(app: &mut VectorcraftApp) { json!({ "kerning": "auto", "baselineShift": 0, "hScale": 100, "vScale": 100, "rotation": 0, "underline": false, "strikethrough": false, "allCaps": false, "smallCaps": d.small_caps.is_some(), "position": d.position.id(), "charAlign": d.char_align, - "proportionalMetrics": d.proportional_metrics, "tateChuYoko": d.tate_chu_yoko, "noBreak": d.no_break, "charDirection": "default", + "proportionalMetrics": d.proportional_metrics, "tateChuYoko": d.tate_chu_yoko, + "standardVerticalRoman": d.standard_vertical_roman, "noBreak": d.no_break, "charDirection": "default", "digits": d.digits }), ); diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs index 8f02228b5..92809a2a4 100644 --- a/crates/ui-egui/src/panels/character/tests.rs +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -526,6 +526,42 @@ fn tate_chu_yoko_menu_item_shows_with_east_asian_options_and_toggles() { assert!(!on(&app)); } +/// Standard Vertical Roman Alignment is in the panel menu with the East Asian options. Choosing it +/// marks the selected characters, one undo step each way; its check shows only while every +/// selected character has it, and a mixed selection turns it on. +#[test] +fn standard_vertical_roman_alignment_menu_item_toggles() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "ABC"})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + let label = "Standard Vertical Roman Alignment"; + assert!(!crate::tests_labels::painted_text(&mut app, menu).contains(label)); + app.session.prefs.show_east_asian_options = true; + let on = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().all(|r| r.style.standard_vertical_roman), + _ => panic!("text"), + }; + // Only the first letter has it: the item is unchecked. + app.session.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 1, "standardVerticalRoman": true})).unwrap(); + let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let before = undo(&app); + assert!(!on(&app)); + assert!(!crate::tests_labels::painted_text(&mut app, menu).contains(&format!("✓ {label}"))); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); + let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == label).unwrap_or_else(|| panic!("{texts:?}")).1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert!(on(&app)); + assert_eq!(undo(&app), before + 1); + assert!(crate::tests_labels::painted_text(&mut app, menu).contains(&format!("✓ {label}"))); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); + let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == label).unwrap().1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert!(!on(&app)); + assert_eq!(undo(&app), before + 2); +} + /// Digits is in the panel menu with the Middle Eastern (Indic) options only, as Paragraph /// Direction is in the Paragraph panel. #[test] @@ -715,6 +751,7 @@ fn reset_panel_resets_every_attribute_but_the_font() { s.char_align != d.char_align, s.proportional_metrics != d.proportional_metrics, s.tate_chu_yoko != d.tate_chu_yoko, + s.standard_vertical_roman != d.standard_vertical_roman, s.no_break != d.no_break, s.char_direction != d.char_direction, s.digits != d.digits, @@ -726,17 +763,17 @@ fn reset_panel_resets_every_attribute_but_the_font() { json!({ "id": id, "tracking": 50, "leading": 30, "kerning": 20, "baselineShift": 3, "hScale": 120, "vScale": 90, "rotation": 15, "underline": true, "strikethrough": true, "allCaps": true, "smallCaps": true, "position": "superscript", - "charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "noBreak": true, - "charDirection": "rightToLeft", "digits": "hindi" + "charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "standardVerticalRoman": true, + "noBreak": true, "charDirection": "rightToLeft", "digits": "hindi" }), ) .unwrap(); }; let plain = runs(&app)[0].1.clone(); - assert_eq!(differs(&plain, &CharStyle::default()), [false; 18]); + assert_eq!(differs(&plain, &CharStyle::default()), [false; 19]); custom(&mut app); let styled = runs(&app)[0].1.clone(); - assert_eq!(differs(&styled, &CharStyle::default()), [true; 18]); + assert_eq!(differs(&styled, &CharStyle::default()), [true; 19]); app.run("select.set", json!({"ids": [id]})).unwrap(); reset_panel(&mut app); assert_eq!(runs(&app), [("One 12".to_string(), plain.clone())]); diff --git a/docs/mcp.md b/docs/mcp.md index e95d5d182..799c2427c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1147,7 +1147,7 @@ a key left out. These commands refuse a string, a number or another value that is not true or false with `` `closed` must be true or false ``, naming the key, and leave the document and its undo history as they were: `clockwise` in `shape.spiral`; `closed` in `shape.arc` and `path.create`; `vertical` and `placeholder` in `text.create` -and `text.createInPath`; `underline`, `strikethrough`, `allCaps`, `proportionalMetrics`, `tateChuYoko` and `noBreak` +and `text.createInPath`; `underline`, `strikethrough`, `allCaps`, `proportionalMetrics`, `tateChuYoko`, `standardVerticalRoman` and `noBreak` in `text.setRangeStyle`; `effective` in `text.getRange`; `on` in `type.opticalMarginAlignment`, `type.systemLayout` and `type.fractionalWidths`; `remove` and `overhang` in `type.ruby`; `expandInstances` in `symbol.delete`; `alt` in `symbol.spray` and `symbol.adjust`; `spot` and `global` in `swatch.new` and `swatch.edit`; `unlink` in diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9d6e87405..8bf7e7c5c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -888,6 +888,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When a drag on the Tabs panel's ruler or a value typed in its X field moves a tab stop past another stop, the moved stop stays selected. The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again. - **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). + Character panel menu › Standard Vertical Roman Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {standardVerticalRoman}`, off by default) stands the selected Latin letters, digits and other half-width characters upright one below the other in vertical type instead of rotating them ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); spaces keep their width, tate-chu-yoko blocks stay across the column, and Character Style Options gives it its own checkbox beside Character Alignment. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286). Isolation mode and opacity mask editing exclude each other ([transparency and blending modes](https://web.archive.org/web/20230102045151/https://helpx.adobe.com/illustrator/using/transparency-blending-modes.html)): in isolation mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Enter Isolation Mode and Object › Perspective › Edit Text are off, the Contextual Task Bar leaves out Isolate, and Quick Actions leave out Isolate Group. Pattern editing mode and opacity mask editing exclude each other, by a rule of VectorCraft itself: in pattern editing mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Object › Pattern › Make and Edit Pattern are off. diff --git a/docs/type-parity.md b/docs/type-parity.md index 50f4e44bc..0b7384d65 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -59,6 +59,7 @@ against Illustrator's public documentation; **estimated** unless noted. | CJK: kinsoku (Hard, Soft, None), Line-end Punctuation Half Width, tate-chu-yoko (auto) | done / partial | custom kinsoku sets, the Mojikumi Settings dialog and the other mojikumi sets missing | | CJK: Proportional Metrics (`palt` in horizontal type, `vpal` in vertical type) | done | #966 | | CJK: manual tate-chu-yoko | done | Character panel menu › Tate-chu-yoko (`text.setFormat` / `text.setRangeStyle {tateChuYoko}`), with Show East Asian Options: marked characters stand side by side across one em of a vertical column, squeezed to the em when wider; two or three half-width digits still do so without the mark; horizontal type keeps the mark and does not move them. Up/Down and Left/Right settings remain. Evidence: `tests_vertical::manual_tate_chu_yoko_sets_marked_characters_across_one_em`, `typecmd::tate_chu_yoko_is_a_character_attribute_saved_only_when_on` | +| CJK: Standard Vertical Roman Alignment | done | Character panel menu › Standard Vertical Roman Alignment (`text.setFormat` / `text.setRangeStyle {standardVerticalRoman}`), with Show East Asian Options, off by default: in vertical type the marked half-width characters (Latin letters, digits and other characters that would lie on their side) stand upright one below the other, each in its upright cell; spaces keep their width and tate-chu-yoko blocks stay across the column; horizontal type keeps the mark and does not change. Character Style Options has its own checkbox for it. Evidence: `tests_vertical::standard_vertical_roman_alignment_stands_half_width_characters_upright`, `typecmd::standard_vertical_roman_is_a_character_attribute_saved_only_when_on` | | CJK: ruby, manual tate-chu-yoko | partial | ruby (ふりがな) for live text (`type.ruby`): a reading over a byte range, above (right of the column in vertical type) or below, start/centre/end alignment, size, overhang or squeeze; saved natively, laid out with its line growing, drawn on the canvas, in SVG and PDF (as outlines where ruby is present), in EPS, EMF/WMF and legacy `.ai` (legacy `.ai` writes type with ruby as outlines), and kept by Create Outlines, Expand Appearance and flattening, each glyph where the layout puts it (`typecmd::ruby_tests::create_outlines_keeps_the_ruby_where_it_is_laid_out`); manual tate-chu-yoko missing | #633 | | Language per character (Character panel › Language, character styles), text shaped in it | done | `text.setFormat` / `text.setRangeStyle {language}` by name or BCP 47 tag, one of Document Setup's languages (Hindi, Marathi, Gujarati, Bengali, Punjabi, Tamil, Telugu, Kannada, Malayalam, Odia and Urdu added, #4943; Serbian: Cyrillic, Serbian: Latin and Croatian); the shaper gets the tag, so the font's language forms apply (OpenType `locl`: Turkish keeps `fi` apart and the dot on its i, #4940); new type takes the document's language; saved as the tag only when set, carried by character styles (#4946). Not yet: hyphenation and spelling by it, `.ai`/PDF import of a text's language, paragraph styles. Evidence: `features::tests::language_picks_the_fonts_forms`, `features::tests::serbian_gets_the_fonts_serbian_forms`, `typecmd::area_tests::language_is_a_character_attribute_saved_only_when_set` | | No Break | done | Character panel menu (`text.setFormat` / `text.setRangeStyle {noBreak}`): the marked characters stay on one line, including across a space; a run that does not fit after earlier text moves together, and a run longer than the line stays together. Evidence: `layout::wrapping_tests::no_break_keeps_marked_characters_on_one_line`, `tests::no_break_keeps_marked_words_on_one_line`, `typecmd::no_break_is_a_character_attribute_saved_only_when_on` |