🐛 Set digits tate-chu-yoko upright and centred in vertical text

Four fixes to how `digits` tate-chu-yoko composites lay out in vertical
text:

- A lone digit combines too, as CSS `digits <n>` combines runs of n or
  fewer, so it stands upright instead of turning sideways. The renderer
  (`split_digit_runs`) and the export path (`digit-combine-segments`)
  share the rule.
- Composites centre on the OS/2 typographic metrics, like upright glyphs.
  The hhea ascent of CJK faces exceeds the em, which set digits about
  0.07em below the kanji beside them.
- Letter-spacing stays outside the composite, as in CSS. Counting the
  trailing spacing in its width moved the digits left by half of it.
- The regional Chinese profiles no longer add the Han/Western gap around
  a composite: it sets in one ideograph's cell, as JLREQ already does.

AI-assisted-by: claude-opus-5-5
This commit is contained in:
alonso.torres 2026-10-27 11:48:02 +00:00
parent 996f498e9b
commit b053ac8751
9 changed files with 150 additions and 33 deletions

View File

@ -184,14 +184,15 @@
(defn digit-combine-segments
"Text split for a `digits` tate-chu-yoko value: `[text combine?]` pairs,
where runs of two up to the value's maximum ASCII or full-width digits
combine and longer runs stay ordinary text. Nil for other values."
where runs of up to the value's maximum ASCII or full-width digits
combine, a lone digit included as in CSS `digits`, and longer runs stay
ordinary text. Nil for other values."
[text value]
(when-let [max-len (get digit-combine-max-lengths value)]
(->> (partition-by digit? (seq text))
(mapv (fn [chars]
(let [run (apply str chars)]
[run (and (digit? (first chars)) (<= 2 (count chars) max-len))]))))))
[run (and (digit? (first chars)) (<= (count chars) max-len))]))))))
(defn warichu-text?
"True when a text node renders as warichu, which needs at least two

View File

@ -439,7 +439,9 @@
(t/is (= [["第" false] ["123" true]]
(cjk/digit-combine-segments "第123" "digits3")))
(t/is (= [["12345" false]] (cjk/digit-combine-segments "12345" "digits")))
(t/is (= [["1" false]] (cjk/digit-combine-segments "1" "digits")))
(t/is (= [["1" true]] (cjk/digit-combine-segments "1" "digits")))
(t/is (= [["2" true] ["月" false] ["5" true] ["日" false]]
(cjk/digit-combine-segments "2月5日" "digits2")))
(t/is (nil? (cjk/digit-combine-segments "31" "all"))))
(t/deftest western-reading-test

View File

@ -23,7 +23,7 @@
- **plugin-types:** Added `ruby` property for text shapes (furigana annotation)
- **plugin-types:** Added `textEmphasis` property for text shapes (emphasis marks / bouten)
- **plugin-types:** Added `warichu` property for text shapes (inline two-line notes)
- **plugin-types:** `textCombineUpright` now accepts `digits` (combine 2-4 digit runs)
- **plugin-types:** `textCombineUpright` now accepts `digits` (combine runs of up to 4 digits, a lone digit included)
- **plugin-types:** Added `fontFeatures` (`none`, `palt`, or `vpal`) to text shapes and text ranges.
- **plugin-types:** Added `annotationClearance` (`none` or `auto`) to text shapes.
- **plugin-types:** Added `lineAdjustment` (`push-in-first`, `push-out-first` or `push-out-only`) to text shapes: how Japanese lines slightly too long are fitted.

View File

@ -4727,9 +4727,10 @@ export interface Text extends ShapeBase {
/**
* Tate-chu-yoko: sets text upright as one block in vertical writing. `all`
* combines the whole span; `digits` combines runs of 2-4 digits
* (`digits2`/`digits3` cap the run at 2/3); `none` (the default) turns it
* off. Returns 'mixed' if text spans use different values.
* combines the whole span; `digits` combines runs of up to 4 digits, so a
* lone digit stands upright (`digits2`/`digits3` cap the run at 2/3);
* `none` (the default) turns it off. Returns 'mixed' if text spans use
* different values.
*/
textCombineUpright:
'none' | 'all' | 'digits' | 'digits2' | 'digits3' | 'mixed';

View File

@ -263,11 +263,12 @@ pub enum TextCombineUpright {
#[default]
None,
All,
/// Runs of 2-4 ASCII or full-width digits combine into one upright cell.
/// Runs of up to 4 ASCII or full-width digits combine into one upright
/// cell, so a lone digit stands upright (CSS `digits`).
Digits,
/// Like `Digits`, for runs of exactly 2 (CSS `digits 2`).
/// Like `Digits`, for runs of up to 2 (CSS `digits 2`).
Digits2,
/// Like `Digits`, for runs of 2-3.
/// Like `Digits`, for runs of up to 3.
Digits3,
}

View File

@ -57,8 +57,8 @@ fn is_tcy_digit(c: char) -> bool {
}
/// Split a TCY `digits` span into pieces. Maximal ASCII or full-width digit
/// runs of 2..=max chars are marked tcy; the rest keep normal vertical
/// layout. Returns (piece text, span-relative UTF-16 start, tcy).
/// runs of 1..=max chars are marked tcy, as in CSS `digits`, so a lone digit
/// stands upright too; the rest keep normal vertical layout. Returns (piece text, span-relative UTF-16 start, tcy).
fn split_digit_runs(text: &str, max: usize) -> Vec<(String, usize, bool)> {
let mut pieces: Vec<(String, usize, bool)> = Vec::new();
let mut utf16 = 0usize;
@ -71,7 +71,7 @@ fn split_digit_runs(text: &str, max: usize) -> Vec<(String, usize, bool)> {
utf16 += c.len_utf16();
}
for (piece, _, tcy) in pieces.iter_mut() {
*tcy = *tcy && (2..=max).contains(&piece.chars().count());
*tcy = *tcy && (1..=max).contains(&piece.chars().count());
}
pieces
}
@ -219,7 +219,7 @@ impl<'a> SpanCells<'a> {
{
return;
}
// `digits` merges each run of 2..=max digits into one upright cell.
// `digits` merges each run of 1..=max digits into one upright cell.
let pieces = match combine.digits_max() {
Some(max) => split_digit_runs(text, max),
None => vec![(text.to_string(), 0, false)],
@ -280,18 +280,14 @@ impl<'a> SpanCells<'a> {
}
let letter_spacing = self.span.letter_spacing;
let mut shaped = self.shape(piece, self.span.font_size, false);
let shaped = self.shape(piece, self.span.font_size, false);
if shaped.is_empty() {
return false;
}
let em = self.span.font_size.max(1.0);
let mut combined_advance = 0.0f32;
for run in &mut shaped {
run.advance = run.spread_glyphs(letter_spacing);
combined_advance += run.advance;
}
// Letter-spacing also separates adjacent fallback runs.
combined_advance += letter_spacing * (shaped.len() - 1) as f32;
// As in CSS, the composite ignores letter-spacing inside it: spacing
// only separates it from its neighbours down the column (`extent`).
let combined_advance: f32 = shaped.iter().map(|run| run.advance).sum();
// Only a composite wider than the em is squeezed, and only across:
// glyphs keep their full height, as in JLREQ and CSS.
let h_scale = (em / combined_advance.max(1.0)).min(1.0);
@ -972,7 +968,7 @@ mod tests {
#[test]
fn split_digit_runs_honours_the_max_parameter() {
// max 2: only exactly-two-digit runs combine.
// max 2: runs of up to two digits combine.
let pieces = split_digit_runs("平成31年123日", 2);
let flags: Vec<(&str, bool)> = pieces
.iter()
@ -994,7 +990,7 @@ mod tests {
}
#[test]
fn split_digit_runs_marks_two_to_four_digit_runs() {
fn split_digit_runs_marks_runs_of_up_to_four_digits() {
let pieces = split_digit_runs("平成31年12345日5", 4);
let flags: Vec<(&str, usize, bool)> = pieces
.iter()
@ -1008,7 +1004,7 @@ mod tests {
("年", 4, false),
("12345", 5, false),
("日", 10, false),
("5", 11, false),
("5", 11, true),
]
);
}
@ -1045,6 +1041,21 @@ mod tests {
assert_eq!(layout.cells.len(), 3);
}
#[test]
fn tate_chu_yoko_digits_sets_a_lone_digit_upright() {
let mut content = make_content_with_spans(&["2月5日"], 400.0);
content.paragraphs_mut()[0].children_mut()[0].text_combine_upright =
TextCombineUpright::Digits;
let layout = layout_with(&provider(VMTX_TEST_FONT), &content);
let tcy: Vec<(usize, usize)> = layout
.cells
.iter()
.filter(|c| matches!(c.kind, CellKind::TateChuYoko { .. }))
.map(|c| (c.start, c.end))
.collect();
assert_eq!(tcy, vec![(0, 1), (2, 3)]);
}
#[test]
fn tate_chu_yoko_digits_combines_four_ascii_digits() {
let mut content = make_content_with_spans(&["2025年"], 400.0);
@ -1134,6 +1145,43 @@ mod tests {
);
}
#[test]
fn letter_spacing_stays_outside_a_tate_chu_yoko_composite() {
// CSS combines the text ignoring letter-spacing. Counting it in the
// composite width set the digits off centre, left of the kanji.
let tcy_cell = |letter_spacing: f32| {
let mut content = spaced_content("く12く", letter_spacing);
content.paragraphs_mut()[0].children_mut()[0].text_combine_upright =
TextCombineUpright::Digits;
let layout = layout_content(&content, 1000.0);
let cell = layout
.cells
.iter()
.find(|c| matches!(c.kind, CellKind::TateChuYoko { .. }))
.cloned()
.expect("the digit run combines");
let CellKind::TateChuYoko { run_start, .. } = cell.kind else {
unreachable!()
};
let positions = layout.runs[run_start].positions.clone();
(cell, positions)
};
let (plain, plain_positions) = tcy_cell(0.0);
let (spaced, spaced_positions) = tcy_cell(5.0);
assert!(
(spaced.h_advance - plain.h_advance).abs() < 0.01,
"the composite width ignores letter-spacing"
);
assert_eq!(
plain_positions, spaced_positions,
"glyphs keep their places"
);
assert!(
(spaced.extent - plain.extent - 5.0).abs() < 0.01,
"letter-spacing still follows the composite down the column"
);
}
// A tiny Noto Sans JP subset with `vmtx`/`vhea`: U+3031 (〱, vertical kana
// repeat mark) advances 2em vertically and 1em horizontally;
// U+3042/U+304F are symmetric controls.

View File

@ -416,7 +416,15 @@ pub(super) fn apply_policy_spacing(cells: &mut [FlowCell], text: &str, policy: T
- 0.5 * cells[i - 1].cell.font_size)
.max(0.0);
let compression = (policy.compression_em(before, after, true) * em).min(available);
let gap = policy.script_spacing_em(before, after) * em;
// A tate-chu-yoko composite sets in one ideograph's cell, so the
// Han/Western gap of its digits or letters does not apply.
let tate_chu_yoko =
|flow: &FlowCell| matches!(flow.cell.kind, CellKind::TateChuYoko { .. });
let gap = if tate_chu_yoko(&cells[i - 1]) || tate_chu_yoko(&cells[i]) {
0.0
} else {
policy.script_spacing_em(before, after) * em
};
cells[i - 1].cell.extent = (cells[i - 1].cell.extent + gap - compression)
.max(cells[i - 1].trailing_spacing.max(0.0));
cells[i - 1].cell.minimum_oikomi_extent = cells[i - 1].cell.extent;

View File

@ -828,7 +828,7 @@ pub fn layout_for_rect(text_content: &TextContent, rect: &Rect) -> VerticalLayou
mod tests {
use super::super::test_support::*;
use super::*;
use crate::shapes::TextOrientation;
use crate::shapes::{TextCombineUpright, TextOrientation, TextSpan};
fn cjk_layout(
texts: &[&str],
@ -1046,6 +1046,35 @@ mod tests {
.all(|cell| cell.minimum_oikomi_extent <= cell.extent));
}
#[test]
fn regional_chinese_spacing_skips_tate_chu_yoko_but_spaces_sideways_latin() {
let tops = |language: &str, text: &str, combine: TextCombineUpright| {
let span = TextSpan {
text_combine_upright: combine,
..make_span(text)
};
let mut paragraph = vertical_paragraph(vec![span], TextAlign::Left, 1.0);
paragraph.set_language(language.to_string());
let content = content_of(vec![paragraph], EM * 10.0, GrowType::Fixed);
let layout = layout_with_height(
&provider(include_bytes!("../../fonts/cjk-locl-test.ttf")),
&content,
EM * 10.0,
);
layout.cells.iter().map(|cell| cell.top).collect::<Vec<_>>()
};
// Upright digits take one ideograph's cell: no Han/Western gap.
assert_eq!(
tops("zh-Hans-CN", "文12文", TextCombineUpright::Digits),
tops("", "文12文", TextCombineUpright::Digits)
);
// A sideways Latin run keeps its quarter-em gap.
assert_ne!(
tops("zh-Hans-CN", "文AB文", TextCombineUpright::None),
tops("", "文AB文", TextCombineUpright::None)
);
}
#[test]
fn korean_word_boundaries_cross_styles_and_graphemes_stay_together() {
let mut paragraph = vertical_paragraph(

View File

@ -8,7 +8,7 @@ use crate::math::Rect;
use crate::shapes::{Stroke, StrokeKind, TextContent, VerticalAlign};
use super::annotations::{emphasis_mark_center, EmphasisMark, RubyCell};
use super::font_tables::upright_baseline;
use super::font_tables::{upright_baseline, upright_centre_metrics};
use super::layout::{column_base_center, CellKind, VerticalCell, VerticalLayout};
use super::shaping::{single_glyph_blob, ShapedRun};
@ -174,10 +174,11 @@ fn cell_draws(layout: &VerticalLayout, cell: &VerticalCell, origin: (f32, f32))
} => {
let composite = &layout.runs[run_start..run_start + run_count];
let combined_advance: f32 = composite.iter().map(|run| run.advance).sum();
// Vertical centring uses the tallest run's ascent/descent.
// Vertical centring uses the tallest run's em box, the metrics
// upright glyphs use, so the composite shares their baseline.
let (ascent, descent) = composite.iter().fold((0.0f32, 0.0f32), |(a, d), run| {
let (_, metrics) = run.font.metrics();
(a.min(metrics.ascent), d.max(metrics.descent))
let (ascent, descent) = upright_centre_metrics(&run.font);
(a.min(ascent), d.max(descent))
});
let x0 = x_center - (combined_advance * h_scale) / 2.0;
let baseline = y_top + cell.font_size / 2.0 - (ascent + descent) / 2.0;
@ -739,7 +740,7 @@ fn decoration_bar(
mod tests {
use super::super::test_support::*;
use super::*;
use crate::shapes::{StrokeStyle, TextEmphasis, TextOrientation, TextSpan};
use crate::shapes::{StrokeStyle, TextCombineUpright, TextEmphasis, TextOrientation, TextSpan};
#[test]
fn glyph_draws_are_built_once_and_follow_the_origin() {
@ -934,6 +935,32 @@ mod tests {
}
}
#[test]
fn tate_chu_yoko_digits_share_the_upright_glyph_baseline() {
// The hhea ascent of a CJK face exceeds the em; centring the
// composite on it set digits about 0.07em below the kanji beside them.
let mut content = make_content_with_spans(&["く12く"], 400.0);
content.paragraphs_mut()[0].children_mut()[0].text_combine_upright =
TextCombineUpright::Digits;
let layout = layout_with(&provider(VMTX_TEST_FONT), &content);
let cell = layout
.cells
.iter()
.find(|cell| matches!(cell.kind, CellKind::TateChuYoko { .. }))
.expect("the digit run combines");
let CellKind::TateChuYoko { run_start, .. } = cell.kind else {
unreachable!()
};
let draws = cell_draws(&layout, cell, (0.0, 0.0));
let transform = draws[0].transform.expect("a composite is transformed");
let expected = cell.top + upright_baseline(&layout.runs[run_start].font, cell.font_size);
assert!(
(transform.translate_y() - expected).abs() < 0.01,
"baseline {} instead of {expected}",
transform.translate_y()
);
}
#[test]
fn single_paint_passes_cover_ruby_like_the_fill_pass() {
let content = spans_content(