Skip to content

Commit 06ea4fd

Browse files
authored
Merge pull request #153 from sillsdev/rtl
fix: report unknown script reading direction instead of guessing left-to-right
2 parents e8f1cf1 + 9de4456 commit 06ea4fd

7 files changed

Lines changed: 349 additions & 15 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { describe, expect, it } from "vitest";
2+
import { iso15924 } from "iso-15924";
3+
import { isRTLScript } from "./findLanguageInterfaces";
4+
5+
describe("isRTLScript", () => {
6+
it("reports true for right-to-left scripts", () => {
7+
expect(isRTLScript("Arab")).toBe(true);
8+
expect(isRTLScript("Hebr")).toBe(true);
9+
expect(isRTLScript("Thaa")).toBe(true);
10+
expect(isRTLScript("Nkoo")).toBe(true);
11+
expect(isRTLScript("Adlm")).toBe(true);
12+
});
13+
14+
it("reports false for left-to-right scripts", () => {
15+
expect(isRTLScript("Latn")).toBe(false);
16+
expect(isRTLScript("Cyrl")).toBe(false);
17+
expect(isRTLScript("Hans")).toBe(false);
18+
expect(isRTLScript("Deva")).toBe(false);
19+
expect(isRTLScript("Ethi")).toBe(false);
20+
});
21+
22+
it("reports true for RTL scripts whose direction the runtime's ICU may not know yet", () => {
23+
// CLDR release-48-2 marks Sidetic RTL=YES, but Node 22's ICU reports it as
24+
// left-to-right. This assertion may start passing for the ordinary reason
25+
// once the host ICU picks up Unicode 16, at which point the pin for it in
26+
// RTL_SCRIPTS_UNKNOWN_TO_OLDER_ICU can be dropped.
27+
expect(isRTLScript("Sidt")).toBe(true);
28+
});
29+
30+
it("takes the direction of the parent script for variant codes", () => {
31+
// Neither Intl nor CLDR has data for these, but a Nastaliq Arabic document
32+
// is still Arabic and Western Syriac is still Syriac. Intl reports all of
33+
// them as left-to-right. amw-Syrj (Western Neo-Aramaic) is reachable from a
34+
// search, making it the one genuinely user-facing fix here.
35+
expect(isRTLScript("Aran")).toBe(true); // Arabic (Nastaliq variant)
36+
expect(isRTLScript("Syre")).toBe(true); // Syriac (Estrangelo variant)
37+
expect(isRTLScript("Syrj")).toBe(true); // Syriac (Western variant)
38+
expect(isRTLScript("Syrn")).toBe(true); // Syriac (Eastern variant)
39+
expect(isRTLScript("Phlv")).toBe(true); // Book Pahlavi
40+
41+
// Variants of left-to-right scripts must stay left-to-right, not become
42+
// unknown just because there is no data for the variant code itself.
43+
expect(isRTLScript("Cyrs")).toBe(false); // Cyrillic (Old Church Slavonic)
44+
expect(isRTLScript("Latf")).toBe(false); // Latin (Fraktur variant)
45+
expect(isRTLScript("Latg")).toBe(false); // Latin (Gaelic variant)
46+
expect(isRTLScript("Hans")).toBe(false); // Han (Simplified variant)
47+
expect(isRTLScript("Hant")).toBe(false); // Han (Traditional variant)
48+
});
49+
50+
it("does not let a placeholder inherit a direction from its parent", () => {
51+
// Zsye is "Symbols (Emoji variant)", so the variant derivation would map it
52+
// to Zsym. Placeholders must keep their own answer instead.
53+
expect(isRTLScript("Zsye")).toBeUndefined();
54+
});
55+
56+
it("does not invent a right-to-left direction for left-to-right scripts", () => {
57+
// Todhri is explicitly RTL=NO in CLDR scriptMetadata and Intl agrees.
58+
// sq-Todr is reachable from a search, so wrongly pinning this as RTL would
59+
// misrender real Albanian text. Guards against re-adding it as an override.
60+
expect(isRTLScript("Todr")).toBe(false);
61+
// Egyptian hieroglyphs are left-to-right per Unicode's Bidi_Class data.
62+
expect(isRTLScript("Egyp")).toBe(false);
63+
});
64+
65+
it("keeps a usable direction for Braille", () => {
66+
// CLDR marks Braille RTL=UNKNOWN because it is script agnostic, but
67+
// Braille is read left to right, and 148 languages in our data offer it.
68+
expect(isRTLScript("Brai")).toBe(false);
69+
});
70+
71+
it("reports unknown for placeholder script codes", () => {
72+
// Zxxx covers the sign languages in our data: not merely unknown
73+
// direction, but no written form at all.
74+
expect(isRTLScript("Zxxx")).toBeUndefined();
75+
expect(isRTLScript("Zzzz")).toBeUndefined();
76+
expect(isRTLScript("Zyyy")).toBeUndefined();
77+
expect(isRTLScript("Zinh")).toBeUndefined();
78+
expect(isRTLScript("Zmth")).toBeUndefined();
79+
expect(isRTLScript("Zsym")).toBeUndefined();
80+
expect(isRTLScript("Zsye")).toBeUndefined();
81+
});
82+
83+
it("reports unknown for private use script codes", () => {
84+
expect(isRTLScript("Qaaa")).toBeUndefined();
85+
expect(isRTLScript("Qaap")).toBeUndefined();
86+
expect(isRTLScript("Qabx")).toBeUndefined();
87+
});
88+
89+
it("reports unknown for codes that are not registered scripts", () => {
90+
// Well formed but unregistered. Intl answers "ltr" for these, which is a
91+
// guess rather than information.
92+
expect(isRTLScript("Xyzw")).toBeUndefined();
93+
expect(isRTLScript("Qzzz")).toBeUndefined();
94+
});
95+
96+
it("reports unknown for empty or malformed codes", () => {
97+
expect(isRTLScript("")).toBeUndefined();
98+
expect(isRTLScript("xyz")).toBeUndefined();
99+
expect(isRTLScript("Latn-x")).toBeUndefined();
100+
expect(isRTLScript("not a script code")).toBeUndefined();
101+
});
102+
103+
it("is not case sensitive", () => {
104+
expect(isRTLScript("arab")).toBe(true);
105+
expect(isRTLScript("ARAB")).toBe(true);
106+
expect(isRTLScript("latn")).toBe(false);
107+
expect(isRTLScript("zxxx")).toBeUndefined();
108+
});
109+
110+
// Guards the placeholder and variant handling against accidentally
111+
// suppressing a real direction: whenever the runtime's own ICU data says a
112+
// script is right-to-left, we must answer true. Note this deliberately tests
113+
// for "not true" rather than "false" — returning undefined suppresses a real
114+
// direction just as effectively as returning false does.
115+
it("never suppresses a right-to-left direction the runtime reports", () => {
116+
const suppressed = iso15924
117+
.map(({ code }) => code)
118+
.filter((code) => {
119+
let intlSaysRtl = false;
120+
try {
121+
const locale = new Intl.Locale(`und-${code}`);
122+
const info =
123+
locale.getTextInfo?.() ??
124+
(locale as unknown as { textInfo?: { direction?: string } })
125+
.textInfo;
126+
intlSaysRtl = info?.direction === "rtl";
127+
} catch {
128+
return false;
129+
}
130+
return intlSaysRtl && isRTLScript(code) !== true;
131+
});
132+
133+
expect(suppressed).toEqual([]);
134+
});
135+
});

components/language-chooser/common/find-language/findLanguageInterfaces.ts

Lines changed: 153 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { iso15924 } from "iso-15924";
2+
13
export interface IRegion {
24
name: string;
35
code: string;
@@ -6,6 +8,11 @@ export interface IRegion {
68
export interface IScript {
79
code: string;
810
name: string;
11+
// true = right-to-left, false = left-to-right, undefined = we don't know.
12+
// Undefined is a real and meaningful state: see isRTLScript below for the
13+
// cases that produce it. Consumers that need a hard boolean should decide
14+
// their own fallback (`script.isRtl ?? false`) rather than assume we
15+
// determined the direction to be left-to-right.
916
isRtl?: boolean;
1017
languageNameInScript?: string;
1118
}
@@ -58,6 +65,114 @@ export interface IOrthography {
5865
customDetails?: ICustomizableLanguageDetails;
5966
}
6067

68+
// ISO 15924 codes which are placeholders rather than actual scripts, so
69+
// reading direction is either unknown or not applicable. Zxxx in particular
70+
// covers 165 languages in our data (mostly sign languages), which have no
71+
// written form and therefore no reading direction at all. Intl reports all of
72+
// these as "ltr", which is a fabricated answer rather than a real one.
73+
const SCRIPT_CODES_WITH_NO_DIRECTION = new Set([
74+
"Zinh", // inherited
75+
"Zmth", // mathematical notation
76+
"Zsye", // symbols (emoji variant)
77+
"Zsym", // symbols
78+
"Zxxx", // unwritten
79+
"Zyyy", // undetermined
80+
"Zzzz", // uncoded
81+
]);
82+
83+
// Scripts a runtime's ICU build may not know are right-to-left yet. This is
84+
// deliberately NOT a mirror of CLDR's RTL list: Intl agrees with CLDR on 178
85+
// of the 179 scripts CLDR has an explicit verdict for, so duplicating that
86+
// list would add a second source of truth to maintain for no benefit. Only
87+
// genuine gaps belong here, and entries should be deleted as ICU catches up.
88+
//
89+
// Verified against field 6 (RTL) of CLDR release-48-2 scriptMetadata.txt:
90+
// https://github.com/unicode-org/cldr/blob/release-48-2/common/properties/scriptMetadata.txt
91+
const RTL_SCRIPTS_UNKNOWN_TO_OLDER_ICU = new Set([
92+
// Sidetic, added in Unicode 16. Node 22 reports it as left-to-right.
93+
"Sidt",
94+
]);
95+
96+
const ISO_15924_CODES = new Set(iso15924.map((script) => script.code));
97+
98+
// ISO 15924 states variant relationships in its own script names: Aran is
99+
// "Arabic (Nastaliq variant)", Syrj is "Syriac (Western variant)", and so on.
100+
// Neither Intl nor CLDR carries direction data for those variant codes, but a
101+
// Nastaliq Arabic document is still Arabic, so we take the parent's direction.
102+
//
103+
// This is derived from the registry rather than hand-listed so that a variant
104+
// code added upstream is picked up when the iso-15924 dependency is bumped,
105+
// and so nobody has to trust a transcribed table. It currently resolves:
106+
// Aran -> Arab, Syre/Syrj/Syrn -> Syrc (these four change the answer)
107+
// Cyrs -> Cyrl, Latf/Latg -> Latn, Hans/Hant -> Hani (same answer either way)
108+
const SCRIPT_CODE_VARIANT_PARENTS: ReadonlyMap<string, string> = (() => {
109+
const codesByScriptName = new Map<string, string>();
110+
for (const { code, name, pva } of iso15924) {
111+
codesByScriptName.set(name.toLowerCase(), code);
112+
if (pva) codesByScriptName.set(pva.toLowerCase().replace(/_/g, " "), code);
113+
}
114+
115+
const parents = new Map<string, string>();
116+
for (const { code, name } of iso15924) {
117+
// Matches "<parent script name> (<qualifier> variant)".
118+
const match = name.match(/^(.+?)\s*\([^)]*variant[^)]*\)$/i);
119+
if (!match) continue;
120+
const parent = codesByScriptName.get(match[1].trim().toLowerCase());
121+
// A placeholder keeps its own "no direction" answer; Zsye is "Symbols
122+
// (Emoji variant)" and must not inherit anything from Zsym.
123+
if (
124+
parent &&
125+
parent !== code &&
126+
!SCRIPT_CODES_WITH_NO_DIRECTION.has(code)
127+
) {
128+
parents.set(code, parent);
129+
}
130+
}
131+
132+
// The one variant relationship the registry does not put in a name: ISO 15924
133+
// lists Phli "Inscriptional Pahlavi" and Phlp "Psalter Pahlavi" (both RTL per
134+
// CLDR) beside Phlv "Book Pahlavi", with nothing tying them together
135+
// mechanically. Reachable only by typing a tag by hand, never from a search.
136+
parents.set("Phlv", "Phli");
137+
138+
return parents;
139+
})();
140+
141+
// ISO 15924 reserves Qaaa through Qabx for private use. The registry only
142+
// lists the two endpoints, so we range check instead of looking them up.
143+
function isPrivateUseScriptCode(titleCaseCode: string): boolean {
144+
return (
145+
/^Qa[ab][a-z]$/.test(titleCaseCode) &&
146+
titleCaseCode >= "Qaaa" &&
147+
titleCaseCode <= "Qabx"
148+
);
149+
}
150+
151+
// Script codes are conventionally title case (e.g. "Arab"), but tags that a
152+
// user typed by hand may not be.
153+
function toTitleCase(scriptCode: string): string {
154+
return scriptCode.charAt(0).toUpperCase() + scriptCode.slice(1).toLowerCase();
155+
}
156+
157+
// Determines a script's reading direction, or undefined if we cannot know it.
158+
//
159+
// Returning undefined rather than false matters because "we know this script
160+
// is left-to-right" and "we have no idea" call for different handling: a
161+
// consumer storing a writing system's direction can leave an existing setting
162+
// (or a user's own choice) alone instead of silently overwriting it with a
163+
// guess. We report undefined for placeholder script codes, private use codes,
164+
// and anything that isn't a real ISO 15924 script.
165+
//
166+
// Intl remains the authority for the actual left/right answer. It reports
167+
// "ltr" for every script it has no real data on, which keeps a useful answer
168+
// for the obscure tail (Tengwar, Mayan hieroglyphs, Braille and so on) at the
169+
// cost of trusting a default we cannot verify. Because of that fallback, the
170+
// answer for a very new script can differ between ICU builds; only outright
171+
// gaps are pinned above. Egyptian demotic (Egyd) and hieratic (Egyh) are the
172+
// known weak spots: both were normally written right to left, but they are
173+
// unencoded and no machine-readable source states a direction, so rather than
174+
// assert one we let them fall through and report left-to-right.
175+
//
61176
// Intl.Locale takes in a bcp47 tag, but here we are giving it
62177
// the tag und-{insert script code}, where the und means no
63178
// specified language, so that the rtl attribute will be based
@@ -71,15 +186,47 @@ export interface IOrthography {
71186
// .maximize will return the Arabic script for uz-AF. We always want the
72187
// isRtl setting to match its IScript in every case, which can accomplish
73188
// with und-{script}.
74-
export function isRTLScript(scriptCode: string): boolean {
189+
export function isRTLScript(scriptCode: string): boolean | undefined {
190+
if (!scriptCode) {
191+
return undefined;
192+
}
193+
const code = toTitleCase(scriptCode);
194+
195+
if (
196+
SCRIPT_CODES_WITH_NO_DIRECTION.has(code) ||
197+
isPrivateUseScriptCode(code) ||
198+
// A well formed but unregistered code such as "Xyzw" is not a script we
199+
// know anything about, even though Intl will confidently answer "ltr".
200+
!ISO_15924_CODES.has(code)
201+
) {
202+
return undefined;
203+
}
204+
205+
// A variant code carries the direction of the script it is a variant of.
206+
const effectiveCode = SCRIPT_CODE_VARIANT_PARENTS.get(code) ?? code;
207+
208+
// No registry entry currently pairs a real script with a placeholder parent,
209+
// but if one ever appears the variant must inherit "no direction" rather than
210+
// fall through to the Intl default below.
211+
if (SCRIPT_CODES_WITH_NO_DIRECTION.has(effectiveCode)) {
212+
return undefined;
213+
}
214+
215+
if (RTL_SCRIPTS_UNKNOWN_TO_OLDER_ICU.has(effectiveCode)) {
216+
return true;
217+
}
218+
75219
try {
76-
const locale = new Intl.Locale(`und-${scriptCode}`);
220+
const locale = new Intl.Locale(`und-${effectiveCode}`);
77221
// getTextInfo is the standardized property; textInfo is the older name
78222
const info = locale.getTextInfo?.() ?? (locale as any).textInfo;
79-
return info?.direction === "rtl";
223+
if (info?.direction !== "rtl" && info?.direction !== "ltr") {
224+
return undefined;
225+
}
226+
return info.direction === "rtl";
80227
} catch {
81-
// An unrecognized/malformed script code makes Intl.Locale throw. Such a
82-
// script has no known RTL direction, so treat it as not RTL.
83-
return false;
228+
// A malformed script code makes Intl.Locale throw, leaving us with no
229+
// direction information for it.
230+
return undefined;
84231
}
85232
}

components/language-chooser/common/language-chooser-controller/src/view-models/language-chooser.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,8 +316,15 @@ export function useLanguageChooserViewModel(
316316
// Returns a copy of the script with its reading direction (isRtl) populated,
317317
// so consumers receive the direction as part of the selected orthography.
318318
// Mirrors the behavior of the React useLanguageChooser hook.
319+
// When the direction is unknown (see isRTLScript) we leave isRtl off entirely
320+
// rather than claiming left-to-right.
319321
function scriptWithReadingDirection(script: IScript): IScript {
320-
return { ...script, isRtl: isRTLScript(script.code) };
322+
const isRtl = isRTLScript(script.code);
323+
if (isRtl === undefined) {
324+
const { isRtl: _unused, ...scriptWithoutDirection } = script;
325+
return scriptWithoutDirection;
326+
}
327+
return { ...script, isRtl };
321328
}
322329

323330
function hasValidDisplayName(selection: IOrthography) {

components/language-chooser/common/language-chooser-controller/test/language-chooser.spec.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,22 @@ describe("selected script", () => {
382382

383383
expect(test.viewModel.selectedScript.value?.isRtl).toBe(true);
384384
});
385+
386+
it("should leave isRtl unset for a script with no reading direction", () => {
387+
// Zxxx means "unwritten", which is how sign languages are tagged. There is
388+
// no reading direction to report, so we must not claim left-to-right.
389+
const signLanguage: ILanguage = {
390+
...WaataLanguage,
391+
scripts: [{ code: "Zxxx", name: "Code for unwritten documents" }],
392+
};
393+
const test = new TestHelper({ initialLanguages: [signLanguage] });
394+
395+
test.viewModel.listedLanguages.value[0].isSelected.requestUpdate(true);
396+
397+
expect(test.viewModel.selectedScript.value?.code).toBe("Zxxx");
398+
expect(test.viewModel.selectedScript.value?.isRtl).toBeUndefined();
399+
expect(test.viewModel.selectedScript.value).not.toHaveProperty("isRtl");
400+
});
385401
});
386402

387403
describe("creating unlisted language", () => {
@@ -717,10 +733,31 @@ describe("customize language modal", () => {
717733
},
718734
});
719735

736+
// "abc" is not a registered ISO 15924 script, so its reading direction is
737+
// unknown and isRtl is left unset. toEqual ignores properties whose value
738+
// is undefined, so assert the absence of the key explicitly as well.
720739
expect(t.viewModel.selectedScript.value).toEqual({
721740
code: "abc",
722741
name: "ABC Script",
723-
isRtl: false,
742+
});
743+
expect(t.viewModel.selectedScript.value).not.toHaveProperty("isRtl");
744+
});
745+
746+
it("sets script with reading direction on submit", () => {
747+
const t = new TestHelper({ initialLanguages: [NorthernUzbekLanguage] });
748+
t.viewModel.listedLanguages.value[0].isSelected.requestUpdate(true);
749+
750+
t.viewModel.submitCustomizeLanguageModal({
751+
script: {
752+
code: "Arab",
753+
name: "Arabic",
754+
},
755+
});
756+
757+
expect(t.viewModel.selectedScript.value).toEqual({
758+
code: "Arab",
759+
name: "Arabic",
760+
isRtl: true,
724761
});
725762
});
726763

0 commit comments

Comments
 (0)