1+ import { iso15924 } from "iso-15924" ;
2+
13export interface IRegion {
24 name : string ;
35 code : string ;
@@ -6,6 +8,11 @@ export interface IRegion {
68export 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 * \( [ ^ ) ] * v a r i a n t [ ^ ) ] * \) $ / 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+ / ^ Q a [ a b ] [ 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}
0 commit comments