Skip to content

feat: add support for vertical writing mode#1013

Open
KewenCode wants to merge 6 commits into
linebender:mainfrom
KewenCode:feature/UAX#50
Open

feat: add support for vertical writing mode#1013
KewenCode wants to merge 6 commits into
linebender:mainfrom
KewenCode:feature/UAX#50

Conversation

@KewenCode

@KewenCode KewenCode commented Feb 1, 2026

Copy link
Copy Markdown
  1. Add support for alternative glyphs in vertical text mode when the font supports the 'vert' attribute.
    • Base on document UAX#50.
    • Add two test for vertical text.
      Test Chrome-Origin Chome-Current
      symbol-text symbol-text-chrome symbol-text-resvg
      text-cjk text-cjk-chaome text-cjk-resvg
      • In 'text-cjk', after applying feature vhal to the character , the harfbuzz rendering differs from that of Chrome, resulting in reduced spacing.

    • Finish Font rendering with vertical text #890
      • The direction of the character 'っ' (TR) was incorrect, and it has now been corrected to meet the relevant requirements of UAX#50.

    • Two tests underwent changes due to improved functionality
      text\textPath\complex text\writing-mode\tb-and-punctuation
      tests_text_textPath_complex tests_text_writing-mode_tb-and-punctuation

  2. Add font to support all CJK text.
    • Font SourceHanSansHWSC-Regular.subset.ttf to support tests.
    • Due to the presence of CJK text in the four tests, the fallback font has been changed to new font.
      Location Diff Location Diff
      text\text\xml-lang=ja tests_text_text_xml-lang=ja text\writing-mode\tb-with-rotate tests_text_writing-mode_tb-with-rotate
      text\writing-mode\mixed-languages-with-tb-and-underline tests_text_writing-mode_mixed-languages-with-tb-and-underline text\writing-mode\tb-with-rotate-and-underline tests_text_writing-mode_tb-with-rotate-and-underline

  3. Add two OpenType features (vhal halt) to accommodate CJK text.
@RazrFalcon

Copy link
Copy Markdown
Collaborator

Wow! That's a serious change. I'm no Unicode/Text Layout expert, so I would assume it's better.
As long as it does not break existing test - we're fine.

Does it actually fixes #890 ?

Also, 16MB for SourceHanSansHWSC-Bold.otf‎ is too much. Getting it below 1MB would be nice.
Can you try subsetting it as mentioned here?

Also, can you post new test file results before and the patch? Just for the reference. I want to see how exactly thing have changed. Thanks!

@KewenCode

Copy link
Copy Markdown
Author

Wow! That's a serious change. I'm no Unicode/Text Layout expert, so I would assume it's better. As long as it does not break existing test - we're fine.

Does it actually fixes #890 ?

Also, 16MB for SourceHanSansHWSC-Bold.otf‎ is too much. Getting it below 1MB would be nice. Can you try subsetting it as mentioned here?

Also, can you post new test file results before and the patch? Just for the reference. I want to see how exactly thing have changed. Thanks!

Sorry to keep you waiting. The setup of fonttools has puzzled me for quite a while。
And thank you for your suggestion. I have optimized the font file and added a comparison image.

@RazrFalcon

Copy link
Copy Markdown
Collaborator

Thanks! Looks good to me.

let mut vertical_sub = 0;

for i in 0..output.len() {
for i in 0..output_horizontal.len() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some concerns by Claude: Ignore, if this is noise:

Cross-indexing the two shaping passes can panic / mismatch glyphs.
The loop is bounded by output_horizontal.len(), but in vertical mode positions/infos come from output_vertical (and sub_positions/sub_infos are the other pass). This assumes both passes return the same glyph count and a 1:1 positional correspondence. rustybuzz/HarfBuzz guarantees neither across different directions and feature sets (vert/vrtr/vhal substitutions, ligatures, cluster regrouping). If the other pass yields fewer glyphs, positions[i] / sub_positions[i] is an out-of-bounds panic — and because sub_* is indexed unconditionally, this can fire on horizontal text too, not just vertical.

Suggest bounding by min(len) (or .get(i)) and, better, looking up the backup glyph by cluster index rather than positional i. A property test over mixed Latin+CJK+ligature runs would cover this.

Related: sub_text[start..end] below slices using start = info.cluster from one pass and end from another — if cluster order isn't monotonic for the chosen pass, start > end panics. Worth a guard.

Comment on lines +1618 to +1619
let output_horizontal = rustybuzz::shape(&rb_font, &features, buffer_horizontal);
let output_vertical = rustybuzz::shape(&rb_font, &features, buffer_vertical);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some concerns by Claude: Ignore, if this is noise:

Both shaping passes run unconditionally — perf regression on the common path.
output_vertical is always computed, but in horizontal mode it's never read (the if writing_mode == TopToBottom branches that consume sub_* are skipped). This roughly doubles shaping cost for all existing horizontal text, and this function is on the hot path for every <text> in every SVG. Please gate the second pass (and its buffer) behind writing_mode == WritingMode::TopToBottom.

Comment on lines +1088 to +1092
} else if matches!(
orientation,
// Characters which are displayed sideways, rotated 90 degrees clockwise compared to the code charts.
unicode_vo::Orientation::TransformedOrRotated | _
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some concerns by Claude: Ignore, if this is noise:

This matches! is always true — the else if is just an else.
The | _ wildcard matches every variant, so naming TransformedOrRotated is misleading and it trips Clippy's wildcard_in_or_patterns. Make it a plain else, or restrict to the orientation(s) you actually mean.

Suggested change
} else if matches!(
orientation,
// Characters which are displayed sideways, rotated 90 degrees clockwise compared to the code charts.
unicode_vo::Orientation::TransformedOrRotated | _
) {
} else {
if glyph.text_orientation == unicode_vo::Orientation::Rotated {
glyph.width as f32 * sx // Use original width for rotated characters
} else {
glyph.dx.abs() as f32 * 2.0 * sx // Vertical advance for upright characters

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some concerns by Claude: Ignore, if this is noise:

Comment/code mismatch + undocumented assumption.
The comment says "vertical advance (dy * 2)" but the code uses dx. This looks like it's reconstructing the advance from HarfBuzz's vertical-centering x-offset (≈ −width/2) — clever but brittle and undocumented. Please fix the comment to match, and explain why dx*2 recovers the advance (or read the actual vertical advance directly).

for glyph in glyphs {
let sx = glyph.font.scale(font_size);

glyphs_orientation = glyph.text_orientation;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some concerns by Claude: Ignore, if this is noise:

Cluster orientation reflects only the last glyph.
glyphs_orientation/glyphs_replaced are overwritten each iteration, so a multi-glyph cluster ends up with whatever the final glyph had. The comment states the "same orientation" assumption but nothing enforces it — a mixed cluster would silently mis-rotate. Consider asserting it (debug) or deriving from the first glyph deliberately.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some concerns by Claude: Ignore, if this is noise:

This swap may defeat the test's purpose.
This test (per the linked "your code displays Japanese wrong") exists to show that xml:lang=ja vs zh-HANT select different glyphs via language-aware fallback. Pinning a single subset font risks the three lines rendering identically, removing the behavior under test. Same concern applies to the AmiriSource Han Sans HW SC swaps in mixed-languages-with-tb-and-underline.svg and tb-with-rotate*.svg, where the text still contains Arabic. If the goal is just deterministic CI rendering, prefer keeping the original fonts and adding new vertical-mode tests — or explain in the PR why each swap preserves the original intent.

Also: these files dropped their trailing newline and were fully re-indented (4→6 spaces, attributes rejoined), which buries the one real change. Please revert the reformatting and change only font-family.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants