Skip to content

Commit 4e476ec

Browse files
dcojgithub-actions[bot]
authored andcommitted
Improves character rotation logic for some CJK characters in Vertical text rendering mode
GitOrigin-RevId: df5ace61a803cd20235a61cb6f6604c7f456d14f
1 parent 19fa8f9 commit 4e476ec

9 files changed

Lines changed: 355 additions & 8 deletions

File tree

src/util/script_detection.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,14 +285,54 @@ export function needsRotationInVerticalMode(char: number): boolean {
285285
// This proposal suggests changes that slightly differ from current web rendering behavior.
286286
// Since it is still in draft status, we currently adhere to web rendering results.
287287
// To ensure compatibility, only those commonly accepted as rotated in vertical mode are marked as such.
288-
if (char === 0x3018 || char === 0x3019 || char === 0x301C) {
288+
if (char === 0x3013 /* geta mark */ ||
289+
char === 0x3018 /* left white tortoise shell bracket */ ||
290+
char === 0x3019 /* right white tortoise shell bracket */ ||
291+
char === 0x301C /* wave dash */) {
289292
return true;
290293
}
291294

292295
// Katakana range
293-
if (char === 0x30FC || char === 0x30A0) {
296+
if (char === 0x30A0 /* katakana-hiragana double hyphen */ ||
297+
char === 0x30FC /* katakana-hiragana prolonged sound mark */) {
294298
return true;
295299
}
300+
301+
// Basic directional arrows (U+2190–U+2193)
302+
// ← and → were previously substituted to ↑ and ↓ via verticalize_punctuation.ts;
303+
// rotation produces the same visual result (rotating ← 90° CW = ↑ appearance),
304+
// so the substitution was removed in favour of handling all four here.
305+
if (char >= 0x2190 /* leftwards arrow */ && char <= 0x2193 /* downwards arrow */) {
306+
return true;
307+
}
308+
309+
// General Punctuation — horizontal dashes and leaders defined in Shift JIS 2-byte code
310+
if (char === 0x2010 /* hyphen */ ||
311+
char === 0x2015 /* horizontal bar */ ||
312+
char === 0x2025 /* two dot leader */) {
313+
return true;
314+
}
315+
316+
// Mathematical Operators
317+
if (char === 0x2225 /* parallel to */) {
318+
return true;
319+
}
320+
321+
// Box Drawing characters — the whole block rotates so lines connect correctly in vertical labels
322+
if (char >= 0x2500 /* box drawings light horizontal */ && char <= 0x254B /* box drawings heavy vertical and horizontal */) {
323+
return true;
324+
}
325+
326+
// Halfwidth and Fullwidth Forms — characters defined in Shift JIS 2-byte code that should rotate
327+
if (char === 0xFF0E /* fullwidth full stop */ ||
328+
char === 0xFF1D /* fullwidth equals sign */ ||
329+
char === 0xFF5E /* fullwidth tilde */) {
330+
return true;
331+
}
332+
if (char === 0xFFE3 /* fullwidth macron */) {
333+
return true;
334+
}
335+
296336
return false;
297337
}
298338

src/util/verticalize_punctuation.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,6 @@ export const verticalizedCharacterMap = {
8484
'。': '︒',
8585
'「': '﹁',
8686
'」': '﹂',
87-
'←': '↑',
88-
'→': '↓'
8987
} as const;
9088

9189
export default function verticalizePunctuation(input: string, skipContextChecking: boolean): string {
11 KB
Loading
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
{
2+
"version": 8,
3+
"metadata": {
4+
"test": {
5+
"description": "Shift JIS 2-byte special characters and directional arrows placed on purely horizontal lines. In horizontal mode these characters should appear in their natural upright orientation — the inverse of shift-jis-vertical-line. See reference.html for the expected Chrome CSS rendering.",
6+
"height": 512,
7+
"width": 300
8+
}
9+
},
10+
"zoom": 0,
11+
"center": [-50, 0],
12+
"sources": {
13+
"mapbox": {
14+
"type": "geojson",
15+
"data": {
16+
"type": "FeatureCollection",
17+
"features": [
18+
{
19+
"type": "Feature",
20+
"properties": { "name": "ア‐ア―ア‥ア∥ア" },
21+
"geometry": {
22+
"type": "LineString",
23+
"coordinates": [[-150, 20], [50, 20]]
24+
}
25+
},
26+
{
27+
"type": "Feature",
28+
"properties": { "name": "ア~ア=ア ̄ア.ア" },
29+
"geometry": {
30+
"type": "LineString",
31+
"coordinates": [[-150, 0], [50, 0]]
32+
}
33+
},
34+
{
35+
"type": "Feature",
36+
"properties": { "name": "ア←ア→ア↑ア↓ア" },
37+
"geometry": {
38+
"type": "LineString",
39+
"coordinates": [[-150, -20], [50, -20]]
40+
}
41+
}
42+
]
43+
}
44+
}
45+
},
46+
"glyphs": "local://glyphs/{fontstack}/{range}.pbf",
47+
"layers": [
48+
{
49+
"id": "background",
50+
"type": "background",
51+
"paint": { "background-color": "white" }
52+
},
53+
{
54+
"id": "lines-symbol",
55+
"type": "symbol",
56+
"source": "mapbox",
57+
"layout": {
58+
"text-field": "{name}",
59+
"symbol-placement": "line-center",
60+
"text-size": 30,
61+
"text-allow-overlap": true,
62+
"text-writing-mode": ["vertical", "horizontal"],
63+
"text-font": ["NotoCJK"]
64+
}
65+
},
66+
{
67+
"id": "lines",
68+
"type": "line",
69+
"source": "mapbox",
70+
"paint": { "line-opacity": 0.25 }
71+
}
72+
]
73+
}
19.5 KB
Loading
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
{
2+
"version": 8,
3+
"metadata": {
4+
"test": {
5+
"description": "Shift JIS 2-byte special characters and directional arrows placed on purely vertical lines. All of these characters are in needsRotationInVerticalMode so they should appear rotated 90° (lying sideways) rather than upright. The surrounding ア katakana are upright for contrast. See debug/shift-jis-vertical-text.html for the expected Chrome CSS rendering.",
6+
"height": 512,
7+
"width": 512
8+
}
9+
},
10+
"zoom": 2,
11+
"center": [-14.41400, 39.09187],
12+
"sources": {
13+
"mapbox": {
14+
"type": "geojson",
15+
"data": {
16+
"type": "FeatureCollection",
17+
"features": [
18+
{
19+
"type": "Feature",
20+
"properties": { "name": "ア‐ア―ア‥ア∥ア" },
21+
"geometry": {
22+
"type": "LineString",
23+
"coordinates": [[-30, 65], [-30, 15]]
24+
}
25+
},
26+
{
27+
"type": "Feature",
28+
"properties": { "name": "ア~ア=ア ̄ア.ア" },
29+
"geometry": {
30+
"type": "LineString",
31+
"coordinates": [[-18, 65], [-18, 15]]
32+
}
33+
},
34+
{
35+
"type": "Feature",
36+
"properties": { "name": "ア←ア→ア↑ア↓ア" },
37+
"geometry": {
38+
"type": "LineString",
39+
"coordinates": [[-6, 65], [-6, 15]]
40+
}
41+
}
42+
]
43+
}
44+
}
45+
},
46+
"glyphs": "local://glyphs/{fontstack}/{range}.pbf",
47+
"layers": [
48+
{
49+
"id": "background",
50+
"type": "background",
51+
"paint": { "background-color": "white" }
52+
},
53+
{
54+
"id": "lines-symbol",
55+
"type": "symbol",
56+
"source": "mapbox",
57+
"layout": {
58+
"text-field": "{name}",
59+
"symbol-placement": "line",
60+
"symbol-spacing": 500,
61+
"text-size": 30,
62+
"text-allow-overlap": true,
63+
"text-writing-mode": ["vertical", "horizontal"],
64+
"text-font": ["NotoCJK"]
65+
}
66+
},
67+
{
68+
"id": "lines",
69+
"type": "line",
70+
"source": "mapbox",
71+
"paint": { "line-opacity": 0.25 }
72+
}
73+
]
74+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/* eslint-disable camelcase */
2+
import {describe, test, expect} from '../../util/vitest';
3+
import {needsRotationInVerticalMode} from '../../../src/util/script_detection';
4+
5+
// ---------------------------------------------------------------------------
6+
// needsRotationInVerticalMode — tested against UAX #50 Vertical Text Layout
7+
// https://www.unicode.org/reports/tr50/
8+
// Data: https://www.unicode.org/Public/UCD/latest/ucd/VerticalOrientation.txt
9+
//
10+
// UAX #50 assigns every Unicode codepoint one of four Vertical_Orientation (vo)
11+
// values:
12+
// U — Upright: always drawn upright
13+
// R — Rotated: always drawn sideways (90° CW)
14+
// Tu — Transformed, fallback Upright
15+
// Tr — Transformed, fallback Rotated
16+
//
17+
// In allowVerticalPlacement=true mode (text-writing-mode includes "vertical"),
18+
// needsRotationInVerticalMode is the SOLE mechanism for rotating a character.
19+
// Characters absent from this function appear upright regardless of their vo.
20+
//
21+
// Scope
22+
// ─────
23+
// This function only handles characters from the Shift JIS 2-byte standard
24+
// that appear in Japanese map labels. It does NOT attempt full UAX #50
25+
// coverage (e.g. Latin letters have vo=R but are not in scope here).
26+
//
27+
// Deviations from UAX #50
28+
// ────────────────────────
29+
// Where we deliberately diverge from the standard, the reason is documented
30+
// in SHIFT_JIS_OVERRIDES below. All other characters should align exactly.
31+
// ---------------------------------------------------------------------------
32+
33+
// Characters where UAX #50 assigns vo=Tr.
34+
// "Tr" means the character ideally needs a special vertical-form glyph;
35+
// absent that, it should fall back to rotation. Since we don't load font
36+
// vertical alternates (vert/vrt2 OpenType features), we always rotate.
37+
const UAX50_Tr = [
38+
0x3018, // 〘 LEFT WHITE TORTOISE SHELL BRACKET (CJK Symbols)
39+
0x3019, // 〙 RIGHT WHITE TORTOISE SHELL BRACKET (CJK Symbols)
40+
0x301C, // 〜 WAVE DASH (CJK Symbols)
41+
0x30A0, // ゠ KATAKANA-HIRAGANA DOUBLE HYPHEN (Katakana)
42+
0x30FC, // ー KATAKANA-HIRAGANA PROLONGED SOUND MARK (Katakana)
43+
0xFF5E, // ~ FULLWIDTH TILDE (HF Forms)
44+
0xFFE3, //  ̄ FULLWIDTH MACRON (HF Forms)
45+
];
46+
47+
// Characters where UAX #50 assigns vo=R, within the Shift JIS 2-byte scope.
48+
// Ranges are verified by checking start, end, and immediate out-of-range
49+
// neighbours as separate boundary tests below.
50+
const UAX50_R = [
51+
0x2010, // ‐ HYPHEN (General Punctuation, 2010..2015 ; R)
52+
0x2015, // ― HORIZONTAL BAR (General Punctuation, 2010..2015 ; R)
53+
0x2025, // ‥ TWO DOT LEADER (General Punctuation, 2022..2027 ; R)
54+
0x2225, // ∥ PARALLEL TO (Mathematical Operators, 221F..2233 ; R)
55+
0x2190, // ← LEFTWARDS ARROW (Arrows, 2190..2194 ; R — we cover 2190..2193)
56+
0x2191, // ↑ UPWARDS ARROW
57+
0x2192, // → RIGHTWARDS ARROW
58+
0x2193, // ↓ DOWNWARDS ARROW
59+
0xFF1D, // = FULLWIDTH EQUALS SIGN (HF Forms, FF1C..FF1E ; R)
60+
// Box Drawing block — UAX #50 assigns 2500..257F ; R in its entirety.
61+
// We support 2500..254B (the characters listed in the Shift JIS 2-byte standard).
62+
// The full range is exercised in the dedicated loop test below.
63+
];
64+
65+
// Characters where we intentionally deviate from UAX #50.
66+
// These are Shift JIS 2-byte convention overrides: Japanese typography rotates
67+
// them in vertical text even though UAX #50 says upright.
68+
const SHIFT_JIS_OVERRIDES = [
69+
{char: 0x3013, uaxVo: 'U', name: '〓 GETA MARK'},
70+
{char: 0xFF0E, uaxVo: 'Tu', name: '. FULLWIDTH FULL STOP'},
71+
];
72+
73+
// Characters that must NOT return true from needsRotationInVerticalMode.
74+
// Covers three categories:
75+
// a) UAX #50 vo=U (always upright) — CJK ideographs, kana, etc.
76+
// b) UAX #50 vo=Tr, but handled by verticalizedCharacterMap substitution
77+
// instead (glyph swapped before shaping; needsRotation is never consulted)
78+
// c) UAX #50 vo=R, but outside the Shift JIS 2-byte scope we support
79+
const MUST_NOT_ROTATE = [
80+
// (a) UAX #50 vo=U — upright CJK and kana
81+
{char: 0x30A2, uaxVo: 'U', name: 'ア KATAKANA LETTER A'},
82+
{char: 0x3042, uaxVo: 'U', name: 'あ HIRAGANA LETTER A'},
83+
{char: 0x4E2D, uaxVo: 'U', name: '中 CJK UNIFIED IDEOGRAPH'},
84+
{char: 0x3012, uaxVo: 'U', name: '〒 POSTAL MARK'},
85+
86+
// (b) UAX #50 vo=Tr — handled by glyph substitution in verticalize_punctuation.ts
87+
{char: 0x3014, uaxVo: 'Tr', name: '〔 LEFT TORTOISE SHELL BRACKET (substituted to ︹)'},
88+
{char: 0x3015, uaxVo: 'Tr', name: '〕 RIGHT TORTOISE SHELL BRACKET (substituted to ︺)'},
89+
{char: 0xFF08, uaxVo: 'Tr', name: '( FULLWIDTH LEFT PARENTHESIS (substituted to ︵)'},
90+
91+
// (c) UAX #50 vo=R — outside Shift JIS 2-byte scope
92+
{char: 0x0041, uaxVo: 'R', name: 'A LATIN CAPITAL LETTER A'},
93+
{char: 0x0061, uaxVo: 'R', name: 'a LATIN SMALL LETTER A'},
94+
{char: 0x2013, uaxVo: 'R', name: '– EN DASH (handled by verticalizedCharacterMap)'},
95+
{char: 0x2014, uaxVo: 'R', name: '— EM DASH (handled by verticalizedCharacterMap)'},
96+
{char: 0x2194, uaxVo: 'R', name: '↔ LEFT RIGHT ARROW (just outside 2190–2193 range)'},
97+
{char: 0x254C, uaxVo: 'R', name: '╌ LIGHT DOUBLE DASH HORIZONTAL (just outside 2500–254B range)'},
98+
{char: 0x2580, uaxVo: 'R', name: '▀ UPPER HALF BLOCK (Block Elements, not Box Drawing)'},
99+
];
100+
101+
// ---------------------------------------------------------------------------
102+
103+
describe('needsRotationInVerticalMode', () => {
104+
105+
describe('UAX #50 vo=Tr characters — rotate as fallback (no vert glyph support)', () => {
106+
for (const char of UAX50_Tr) {
107+
test(`U+${char.toString(16).toUpperCase().padStart(4, '0')}`, () => {
108+
expect(needsRotationInVerticalMode(char)).toBe(true);
109+
});
110+
}
111+
});
112+
113+
describe('UAX #50 vo=R characters — Shift JIS 2-byte scope', () => {
114+
for (const char of UAX50_R) {
115+
test(`U+${char.toString(16).toUpperCase().padStart(4, '0')}`, () => {
116+
expect(needsRotationInVerticalMode(char)).toBe(true);
117+
});
118+
}
119+
});
120+
121+
describe('UAX #50 vo=R — Box Drawing block U+2500–U+254B (Shift JIS 2-byte subset)', () => {
122+
test('all 76 codepoints in range return true', () => {
123+
for (let char = 0x2500; char <= 0x254B; char++) {
124+
expect(needsRotationInVerticalMode(char)).toBe(true);
125+
}
126+
});
127+
128+
test('U+24FF immediately before range — must not rotate', () => {
129+
expect(needsRotationInVerticalMode(0x24FF)).toBe(false);
130+
});
131+
132+
test('U+254C immediately after range — must not rotate', () => {
133+
expect(needsRotationInVerticalMode(0x254C)).toBe(false);
134+
});
135+
});
136+
137+
describe('UAX #50 vo=R — Directional Arrows boundary check', () => {
138+
test('U+218F immediately before U+2190–U+2193 range — must not rotate', () => {
139+
expect(needsRotationInVerticalMode(0x218F)).toBe(false);
140+
});
141+
142+
test('U+2194 immediately after range — must not rotate', () => {
143+
expect(needsRotationInVerticalMode(0x2194)).toBe(false);
144+
});
145+
});
146+
147+
describe('Shift JIS overrides — rotate despite UAX #50 saying upright', () => {
148+
for (const {char, uaxVo, name} of SHIFT_JIS_OVERRIDES) {
149+
test(`U+${char.toString(16).toUpperCase().padStart(4, '0')} ${name} (UAX #50 vo=${uaxVo}, Shift JIS convention overrides)`, () => {
150+
expect(needsRotationInVerticalMode(char)).toBe(true);
151+
});
152+
}
153+
});
154+
155+
describe('characters that must NOT rotate', () => {
156+
for (const {char, uaxVo, name} of MUST_NOT_ROTATE) {
157+
test(`U+${char.toString(16).toUpperCase().padStart(4, '0')} ${name} (UAX #50 vo=${uaxVo})`, () => {
158+
expect(needsRotationInVerticalMode(char)).toBe(false);
159+
});
160+
}
161+
});
162+
});

vitest.config.query.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import {integrationTests, setupIntegrationTestsMiddlewares, serveDistPlugin, sui
44

55
export default mergeConfig(baseConfig, defineConfig({
66
define: {
7-
'import.meta.env.VITE_CI': isCI,
8-
'import.meta.env.VITE_UPDATE': process.env.UPDATE != null,
7+
'import.meta.env.VITE_CI': JSON.stringify(String(isCI)),
8+
'import.meta.env.VITE_UPDATE': JSON.stringify(String(process.env.UPDATE === 'true')),
99
'import.meta.env.VITE_DIST_BUNDLE': JSON.stringify('dev'),
1010
},
1111
test: {

vitest.config.render.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ const browser = browsers[renderBrowser === 'safari' ? 'webkit' : renderBrowser];
3535

3636
export default mergeConfig(baseConfig, defineConfig({
3737
define: {
38-
'import.meta.env.VITE_CI': isCI,
39-
'import.meta.env.VITE_UPDATE': process.env.UPDATE != null,
38+
'import.meta.env.VITE_CI': JSON.stringify(String(isCI)),
39+
'import.meta.env.VITE_UPDATE': JSON.stringify(String(process.env.UPDATE === 'true')),
4040
'import.meta.env.VITE_SPRITE_FORMAT': process.env.SPRITE_FORMAT != null ? JSON.stringify(process.env.SPRITE_FORMAT) : null,
4141
'import.meta.env.VITE_DIST_BUNDLE': JSON.stringify(bundle),
4242
},

0 commit comments

Comments
 (0)