Merge pull request #3399 from nrq6993-a11y/stream/vc-featD-manual-tate-chu-yoko

Character panel Tate-chu-yoko marks vertical type as a horizontal block
This commit is contained in:
BFlat
2026-10-22 01:24:50 +07:00
committed by GitHub
11 changed files with 208 additions and 35 deletions
+7
View File
@@ -109,6 +109,12 @@ pub struct CharStyle {
/// its proportional heights, `vpal`.
#[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")]
pub proportional_metrics: bool,
/// Tate-chu-yoko (Character panel menu, East Asian options): in vertical type these characters
/// stand side by side across one em of the column. Horizontal type keeps the mark and does not
/// move them. Two or three half-width digits do this without the mark.
/// <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,
/// 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")]
@@ -292,6 +298,7 @@ impl Default for CharStyle {
position: CharPosition::Normal,
char_align: CharAlign::RomanBaseline,
proportional_metrics: false,
tate_chu_yoko: false,
small_caps: None,
digits: Digits::Default,
no_break: false,
+5 -1
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character / Paragraph",
[],
None,
"{ids?|id?, kerning?: 1/1000 em|\"auto\", 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), 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), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), 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\", 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), 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), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), 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
),
@@ -328,6 +328,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
"leadingModel",
"charAlign",
"proportionalMetrics",
"tateChuYoko",
"noBreak",
"digits",
"fontAxes",
@@ -465,6 +466,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = flag("proportionalMetrics") {
st.proportional_metrics = v;
}
if let Some(v) = flag("tateChuYoko") {
st.tate_chu_yoko = v;
}
if let Some(v) = flag("noBreak") {
st.no_break = v;
}
+7 -1
View File
@@ -27,7 +27,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\", 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), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), 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}",
"{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\", 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), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), 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}",
has_doc,
set_range_style
),
@@ -257,6 +257,7 @@ pub(crate) struct CharChange {
small_caps: Option<Option<f64>>,
char_align: Option<vectorcraft_doc::CharAlign>,
proportional_metrics: Option<bool>,
tate_chu_yoko: Option<bool>,
no_break: Option<bool>,
digits: Option<vectorcraft_doc::Digits>,
font_axes: Option<AxisChange>,
@@ -416,6 +417,7 @@ impl CharChange {
small_caps,
char_align: char_align_param(p, cmd)?,
proportional_metrics: flag("proportionalMetrics"),
tate_chu_yoko: flag("tateChuYoko"),
no_break: flag("noBreak"),
digits: digits_param(p, cmd)?,
font_axes: font_axes_param(p, cmd)?,
@@ -458,6 +460,7 @@ impl CharChange {
&& self.small_caps.is_none()
&& self.char_align.is_none()
&& self.proportional_metrics.is_none()
&& self.tate_chu_yoko.is_none()
&& self.no_break.is_none()
&& self.digits.is_none()
&& self.font_axes.is_none()
@@ -554,6 +557,9 @@ impl CharChange {
if let Some(v) = self.proportional_metrics {
st.proportional_metrics = v;
}
if let Some(v) = self.tate_chu_yoko {
st.tate_chu_yoko = v;
}
if let Some(v) = self.no_break {
st.no_break = v;
}
+1
View File
@@ -156,6 +156,7 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
("small_caps", json!(v.small_caps)),
("charAlign", json!(v.char_align)),
("proportionalMetrics", json!(v.proportional_metrics)),
("tateChuYoko", json!(v.tate_chu_yoko)),
("noBreak", json!(v.no_break)),
("digits", json!(v.digits)),
] {
+30
View File
@@ -1627,6 +1627,36 @@ mod area_tests {
assert!(!serde_json::from_value::<vectorcraft_doc::CharStyle>(old).unwrap().proportional_metrics);
}
/// Tate-chu-yoko (Character panel menu, East Asian options): a character attribute, saved only
/// when on. Documents written before it read as off.
#[test]
fn tate_chu_yoko_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": "2026年"})).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.tate_chu_yoko);
assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("tateChuYoko").is_none());
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("text.setFormat", &json!({"tateChuYoko": true})).unwrap();
assert!(runs(&s).iter().all(|r| r.style.tate_chu_yoko));
assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["tateChuYoko"], true);
s.execute("edit.undo", &json!({})).unwrap();
assert!(!runs(&s)[0].style.tate_chu_yoko);
// The year only (2026 is 4 bytes); 年 stays unmarked.
s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 4, "tateChuYoko": true})).unwrap();
let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.tate_chu_yoko)).collect();
assert_eq!(on, [("2026".to_string(), true), ("年".to_string(), false)]);
let old = serde_json::to_value(&runs(&s)[1].style).unwrap();
assert!(old.get("tateChuYoko").is_none());
assert!(!serde_json::from_value::<vectorcraft_doc::CharStyle>(old).unwrap().tate_chu_yoko);
s.execute("text.setFormat", &json!({"tateChuYoko": false})).unwrap();
assert!(runs(&s).iter().all(|r| !r.style.tate_chu_yoko));
}
/// No Break is a character attribute, saved only when on.
#[test]
fn no_break_is_a_character_attribute_saved_only_when_on() {
+44 -20
View File
@@ -56,7 +56,10 @@ impl Ctx<'_> {
let levels = bidi.map_or(&[][..], |b| &b.levels);
shape_range(self.db, self.text, r, &self.runs, &self.inlines, &self.opts.features, levels, &mut v);
if self.vertical {
tate_chu_yoko(&mut v, |g| self.style_at(g.byte).size);
tate_chu_yoko(&mut v, |g| {
let st = self.style_at(g.byte);
(st.size, st.tate_chu_yoko)
});
// 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.
@@ -468,38 +471,59 @@ fn whole_character_widths(glyphs: &mut [SGlyph]) {
}
}
/// Tate-chu-yoko: a run of two or three half-width digits (`10`月, `100`年) stands upright across
/// the column as one block one em long, squeezed to the em when wider; longer numbers (`2026`)
/// stay on their side.
fn tate_chu_yoko(g: &mut [SGlyph], size: impl Fn(&SGlyph) -> f64) {
let digits: Vec<bool> = g.iter().map(|g| g.ch.is_ascii_digit()).collect();
let digit = |i: usize| digits.get(i).copied().unwrap_or(false);
/// Tate-chu-yoko: a horizontal block one em of the column long, squeezed to the em when wider.
///
/// Automatic: a run of two or three half-width digits (`10`月, `100`年). A single digit and a
/// longer number (`2026`) stay on their side.
///
/// Manual (Character panel menu › Tate-chu-yoko): any characters marked `tateChuYoko`, including
/// one character, letters and numbers longer than three digits. Marked characters are taken
/// before the automatic digit rule.
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
fn tate_chu_yoko(g: &mut [SGlyph], style: impl Fn(&SGlyph) -> (f64, bool)) {
let manual: Vec<bool> = g.iter().map(|gl| style(gl).1).collect();
let digits: Vec<bool> = g.iter().map(|gl| gl.inline.is_none() && gl.ch.is_ascii_digit()).collect();
let mut i = 0;
while i < g.len() {
if !digit(i) {
if manual.get(i).copied().unwrap_or(false) {
let mut end = i + 1;
while manual.get(end).copied().unwrap_or(false) {
end += 1;
}
let em = style(&g[i]).0;
place_tcy_block(&mut g[i..end], em);
i = end;
continue;
}
if !digits.get(i).copied().unwrap_or(false) {
i += 1;
continue;
}
let mut end = i;
while digit(end) {
let mut end = i + 1;
while digits.get(end).copied().unwrap_or(false) && !manual.get(end).copied().unwrap_or(false) {
end += 1;
}
if (2..=3).contains(&(end - i)) {
let em = size(&g[i]);
let width: f64 = g[i..end].iter().map(|g| g.adv).sum();
let squeeze = if width > em && width > 0.0 { em / width } else { 1.0 };
let mut ink = 0.0;
for (k, gl) in g[i..end].iter_mut().enumerate() {
let adv = gl.adv;
gl.tcy = Some(Tcy { pen: if k == 0 { 0.0 } else { em }, ink, width, squeeze });
gl.adv = if k == 0 { em } else { 0.0 };
ink += adv;
}
let em = style(&g[i]).0;
place_tcy_block(&mut g[i..end], em);
}
i = end;
}
}
/// Lay `g` across one em of the column. The first glyph takes the em; the rest share its cell.
fn place_tcy_block(g: &mut [SGlyph], em: f64) {
let width: f64 = g.iter().map(|gl| gl.adv).sum();
let squeeze = if width > em && width > 0.0 { em / width } else { 1.0 };
let mut ink = 0.0;
for (k, gl) in g.iter_mut().enumerate() {
let adv = gl.adv;
gl.tcy = Some(Tcy { pen: if k == 0 { 0.0 } else { em }, ink, width, squeeze });
gl.adv = if k == 0 { em } else { 0.0 };
ink += adv;
}
}
/// Half an em of `g`'s size when it is full-width Japanese punctuation of kind `kind` (its
/// advance an em, give or take a tenth), the space that mojikumi can take off.
fn punct_half(g: &SGlyph, kind: Punct) -> Option<f64> {
+2
View File
@@ -470,6 +470,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
small_caps,
char_align: _,
proportional_metrics,
tate_chu_yoko,
digits,
no_break,
} = a;
@@ -485,6 +486,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
&& *position == b.position
&& *small_caps == b.small_caps
&& *proportional_metrics == b.proportional_metrics
&& *tate_chu_yoko == b.tate_chu_yoko
&& *digits == b.digits
&& *no_break == b.no_break
&& ligatures_suppressed_by(*tracking) == ligatures_suppressed_by(b.tracking)
+62 -1
View File
@@ -2,7 +2,7 @@
//! Japanese text only with craft-fonts' faces, skipped without them).
use super::*;
use kurbo::Shape;
use vectorcraft_doc::{CharStyle, Justify, TextKind};
use vectorcraft_doc::{CharStyle, Justify, TextKind, TextRun};
use vectorcraft_geom::PathData;
fn vertical(text: &str) -> TextObject {
@@ -318,6 +318,67 @@ fn tracking_spaces_upright_marks_without_moving_them_off_the_centre_line() {
assert!((step - 24.0).abs() < 1e-6, "one em plus 200/1000 em of tracking: {step}");
}
/// Character panel menu › Tate-chu-yoko: a marked run stands across one em of the column, including
/// a number longer than three digits and Latin letters. Unmarked text keeps the automatic rule
/// (two or three digits only). Horizontal type stores the mark and does not move the letters.
#[test]
fn manual_tate_chu_yoko_sets_marked_characters_across_one_em() {
let marked = CharStyle { size: 20.0, tate_chu_yoko: true, ..CharStyle::default() };
let year = layout(FontDb::global(), &vertical_styled("2026", marked.clone()));
assert_eq!(year.glyphs.len(), 4);
assert!((year.glyphs[0].advance - 20.0).abs() < 1e-6, "{:?}", year.glyphs[0].advance);
assert!(year.glyphs[1..].iter().all(|g| g.advance == 0.0));
let inks: Vec<Rect> = (0..4).map(|i| ink(&year, i)).collect();
for pair in inks.windows(2) {
assert!((pair[0].center().y - pair[1].center().y).abs() < 1.0, "one row: {inks:?}");
assert!(pair[1].center().x > pair[0].center().x, "left to right: {inks:?}");
}
let union = inks.iter().copied().reduce(|a, b| a.union(b)).unwrap();
assert!(union.width() <= 21.0, "squeezed into the em: {union:?}");
assert!(union.center().x.abs() < 1.5, "centred on the column: {union:?}");
let plain = layout(FontDb::global(), &vertical("2026"));
let down: f64 = plain.glyphs.iter().map(|g| g.advance).sum();
assert!(down > 30.0 && plain.glyphs.iter().all(|g| g.advance > 0.0), "unmarked 2026 runs down the column: {down}");
let letters = layout(FontDb::global(), &vertical_styled("AB", marked.clone()));
assert!((letters.glyphs[0].advance - 20.0).abs() < 1e-6 && letters.glyphs[1].advance == 0.0);
let (a, b) = (ink(&letters, 0), ink(&letters, 1));
assert!((a.center().y - b.center().y).abs() < 1.0 && b.center().x > a.center().x, "letters side by side: {a:?} {b:?}");
let side = layout(FontDb::global(), &vertical("AB"));
assert!(ink(&side, 1).center().y > ink(&side, 0).center().y + 5.0, "unmarked letters run down the column");
let mut horizontal = TextObject::point(Point::ZERO, "AB", marked);
horizontal.xf = Affine::IDENTITY;
let row = layout(FontDb::global(), &horizontal);
assert!(!row.vertical);
assert!(row.glyphs.iter().all(|g| g.advance > 0.0));
assert!(ink(&row, 1).center().x > ink(&row, 0).center().x + 5.0, "horizontal type stays a row");
}
/// A marked run is one block; the unmarked digits beside it still follow the automatic rule.
/// A column shorter than two ems holds the marked number on one line.
#[test]
fn manual_tate_chu_yoko_leaves_unmarked_digits_and_stays_on_one_line() {
let plain = CharStyle { size: 20.0, ..CharStyle::default() };
let marked = CharStyle { tate_chu_yoko: true, ..plain.clone() };
let mut t = vertical_styled("", plain.clone());
t.runs = vec![TextRun::new("X", plain), TextRun::new("2026", marked), TextRun::new("10", CharStyle { size: 20.0, ..CharStyle::default() })];
let l = layout(FontDb::global(), &t);
assert_eq!(l.glyphs.len(), 7);
assert!(l.glyphs[0].advance > 0.0 && l.glyphs[0].advance < 20.0, "X on its side: {:?}", l.glyphs[0].advance);
assert!((l.glyphs[1].advance - 20.0).abs() < 1e-6 && l.glyphs[2].advance == 0.0 && l.glyphs[3].advance == 0.0 && l.glyphs[4].advance == 0.0);
assert!((l.glyphs[5].advance - 20.0).abs() < 1e-6 && l.glyphs[6].advance == 0.0, "unmarked 10 stays automatic");
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 80.0, 30.0).to_path(0.1)) };
t.runs = vec![TextRun::new("2026", CharStyle { size: 20.0, tate_chu_yoko: true, ..CharStyle::default() })];
let one = layout(FontDb::global(), &t);
assert_eq!(one.lines.len(), 1, "the block is one em, so it fits a 30 pt column");
t.runs = vec![TextRun::new("2026", CharStyle { size: 20.0, ..CharStyle::default() })];
let wrapped = layout(FontDb::global(), &t);
assert!(wrapped.lines.len() > 1, "unmarked 2026 is longer than the column");
}
/// 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() {
+42 -10
View File
@@ -769,6 +769,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
if menu_item(ui, tl!("Proportional Metrics"), has, proportional) {
format(app, json!({"proportionalMetrics": !proportional}));
}
// Tate-chu-yoko: the selected characters stand side by side across one em of a vertical column.
let tcy = s.as_ref().is_some_and(|s| s.tate_chu_yoko);
if menu_item(ui, tl!("Tate-chu-yoko"), has, tcy) {
format(app, json!({"tateChuYoko": !tcy}));
}
ui.separator();
}
// Digits: the numeral system the selected characters' digits are drawn in, with the Middle
@@ -814,9 +819,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
if menu_item(ui, tl!("System Layout"), open, system) {
app.run("type.systemLayout", json!({})).ok();
}
for l in ["Standard Vertical Roman Alignment", "Tate-chu-yoko"] {
menu_item(ui, tl!(l), false, false);
}
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"))]
@@ -844,7 +847,7 @@ 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 and No Break off and the Default digits, whether
/// character alignment, Proportional Metrics, Tate-chu-yoko and No Break off 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();
@@ -854,7 +857,7 @@ 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, "noBreak": d.no_break, "digits": d.digits
"proportionalMetrics": d.proportional_metrics, "tateChuYoko": d.tate_chu_yoko, "noBreak": d.no_break, "digits": d.digits
}),
);
}
@@ -1247,6 +1250,34 @@ mod tests {
assert_eq!(shown(&mut app), (true, true));
}
/// Tate-chu-yoko is in the panel menu with the East Asian options, and choosing it marks the
/// selected characters.
#[test]
fn tate_chu_yoko_menu_item_shows_with_east_asian_options_and_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": "2026"})).unwrap()["id"].as_u64().unwrap();
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
let ctx = egui::Context::default();
let painted = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu);
assert!(!painted(&mut app).contains("Tate-chu-yoko"));
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.tate_chu_yoko),
_ => panic!("text"),
};
assert!(!on(&app));
let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu);
let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "Tate-chu-yoko").unwrap_or_else(|| panic!("{texts:?}")).1;
super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu);
assert!(on(&app));
assert!(painted(&mut app).contains("✓ Tate-chu-yoko"));
let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu);
let at = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == "Tate-chu-yoko").unwrap().1;
super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu);
assert!(!on(&app));
}
/// Digits is in the panel menu with the Middle Eastern (Indic) options only, as Paragraph
/// Direction is in the Paragraph panel.
#[test]
@@ -1290,8 +1321,8 @@ mod tests {
/// Reset Panel sets every character attribute the panel shows, except the font family, style
/// and size, back to its default (Small Caps, the position, Character Alignment, Proportional
/// Metrics, No Break and Digits included), in the selected type and in the text the Type tool
/// has selected while it edits.
/// Metrics, Tate-chu-yoko, No Break and Digits included), in the selected type and in the text
/// the Type tool has selected while it edits.
#[test]
fn reset_panel_resets_every_attribute_but_the_font() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
@@ -1317,6 +1348,7 @@ mod tests {
s.position != d.position,
s.char_align != d.char_align,
s.proportional_metrics != d.proportional_metrics,
s.tate_chu_yoko != d.tate_chu_yoko,
s.no_break != d.no_break,
s.digits != d.digits,
]
@@ -1327,16 +1359,16 @@ mod tests {
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, "noBreak": true, "digits": "hindi"
"charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "noBreak": true, "digits": "hindi"
}),
)
.unwrap();
};
let plain = runs(&app)[0].1.clone();
assert_eq!(differs(&plain, &CharStyle::default()), [false; 16]);
assert_eq!(differs(&plain, &CharStyle::default()), [false; 17]);
custom(&mut app);
let styled = runs(&app)[0].1.clone();
assert_eq!(differs(&styled, &CharStyle::default()), [true; 16]);
assert_eq!(differs(&styled, &CharStyle::default()), [true; 17]);
app.run("select.set", json!({"ids": [id]})).unwrap();
reset_panel(&mut app);
assert_eq!(runs(&app), [("One 12".to_string(), plain.clone())]);
File diff suppressed because one or more lines are too long
+4 -1
View File
File diff suppressed because one or more lines are too long