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.
This commit is contained in:
dotCooCoo
2026-11-01 08:32:55 +07:00
committed by bflatastic
parent ac896cfe3d
commit ece78f052f
17 changed files with 290 additions and 35 deletions
+9
View File
@@ -104,6 +104,14 @@ pub struct CharStyle {
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
#[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.
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
#[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,
File diff suppressed because one or more lines are too long
+8 -2
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Option<vectorcraft_doc::ParaDirection>>,
proportional_metrics: Option<bool>,
tate_chu_yoko: Option<bool>,
standard_vertical_roman: Option<bool>,
no_break: Option<bool>,
digits: Option<vectorcraft_doc::Digits>,
/// 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<Self> {
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;
}
+1
View File
@@ -175,6 +175,7 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
("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)),
@@ -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::<vectorcraft_doc::CharStyle>(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() {
+40 -12
View File
@@ -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()
+1
View File
@@ -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
+7 -1
View File
@@ -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):
/// <https://www.unicode.org/reports/tr50/#vo>.
///
/// 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.
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
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
+1
View File
@@ -36,6 +36,7 @@ fn synthetic_glyphs(specs: &[(usize, usize, f64, char)]) -> Vec<SGlyph> {
vpal: None,
level: unicode_bidi::Level::ltr(),
no_break: false,
vertical_roman: false,
cursive: false,
})
.collect()
+6
View File
@@ -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<FontFace>, 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<usize>, seg: &Segment, feats: &OtFe
inline: None,
word_end: false,
no_break: gst.no_break,
vertical_roman: gst.standard_vertical_roman,
cursive,
});
}
+54
View File
@@ -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::<Vec<_>>(), plain_row.glyphs.iter().map(|g| g.advance).collect::<Vec<_>>());
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() {
@@ -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<Value, String> {
pub(crate) fn fields_from_attrs(attrs: &Map<String, Value>) -> Map<String, Value> {
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<Map<String, Value>, 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<Map<String, Value>, 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!({}));
+12 -5
View File
@@ -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
}),
);
+41 -4
View File
@@ -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())]);
+1 -1
View File
@@ -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
+1
View File
@@ -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 `<mask>` 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.
+1
View File
@@ -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` |