confusable 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- confusable/__init__.py +52 -0
- confusable/_bidi.py +584 -0
- confusable/_data/__init__.py +8 -0
- confusable/_data/bidi.py +2203 -0
- confusable/_data/confusables.py +6722 -0
- confusable/_data/emoji.py +14 -0
- confusable/_data/graphemes.py +2843 -0
- confusable/_data/homoglyphs.py +19106 -0
- confusable/_data/identifiers.py +8404 -0
- confusable/_data/joining.py +1098 -0
- confusable/_data/normalization.py +4064 -0
- confusable/_data/properties.py +4368 -0
- confusable/_data/scripts.py +2036 -0
- confusable/_data/variation.py +2218 -0
- confusable/_graphemes.py +225 -0
- confusable/_identifiers.py +189 -0
- confusable/_invisible.py +243 -0
- confusable/_joining.py +150 -0
- confusable/_lookup.py +33 -0
- confusable/_marks.py +146 -0
- confusable/_normalize.py +119 -0
- confusable/_numbers.py +58 -0
- confusable/_restriction.py +85 -0
- confusable/_scripts.py +78 -0
- confusable/_skeleton.py +153 -0
- confusable/compat/__init__.py +1 -0
- confusable/compat/homoglyphs/__init__.py +29 -0
- confusable/compat/homoglyphs/categories.py +75 -0
- confusable/compat/homoglyphs/confusables.py +138 -0
- confusable/py.typed +0 -0
- confusable-0.1.0a1.dist-info/METADATA +203 -0
- confusable-0.1.0a1.dist-info/RECORD +35 -0
- confusable-0.1.0a1.dist-info/WHEEL +4 -0
- confusable-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- confusable-0.1.0a1.dist-info/licenses/LICENSES/Unicode-3.0.txt +39 -0
confusable/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Unicode spoofing and confusable detection (UTS #39) in pure Python."""
|
|
2
|
+
|
|
3
|
+
from confusable import _data
|
|
4
|
+
from confusable._graphemes import Segment, iter_graphemes, truncate
|
|
5
|
+
from confusable._identifiers import (
|
|
6
|
+
IdentifierStatus,
|
|
7
|
+
IdentifierType,
|
|
8
|
+
identifier_status,
|
|
9
|
+
identifier_types,
|
|
10
|
+
is_allowed,
|
|
11
|
+
isxidcontinue,
|
|
12
|
+
isxidstart,
|
|
13
|
+
)
|
|
14
|
+
from confusable._invisible import Invisible, find_invisible, has_invisible
|
|
15
|
+
from confusable._marks import has_hidden_overlay, has_unlikely_marks
|
|
16
|
+
from confusable._numbers import has_mixed_numbers
|
|
17
|
+
from confusable._restriction import RestrictionLevel, is_mixed_script, restriction_level
|
|
18
|
+
from confusable._skeleton import Match, match, skeleton
|
|
19
|
+
|
|
20
|
+
# Kept in step with pyproject.toml by tests/test_package.py. A literal, because
|
|
21
|
+
# importlib.metadata alone would add about 100 ms to importing the package.
|
|
22
|
+
__version__ = "0.1.0a1"
|
|
23
|
+
|
|
24
|
+
# The Unicode version of every table and algorithm in the package.
|
|
25
|
+
unicode_version = _data.UNICODE_VERSION
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"IdentifierStatus",
|
|
29
|
+
"IdentifierType",
|
|
30
|
+
"Invisible",
|
|
31
|
+
"Match",
|
|
32
|
+
"RestrictionLevel",
|
|
33
|
+
"Segment",
|
|
34
|
+
"__version__",
|
|
35
|
+
"find_invisible",
|
|
36
|
+
"has_hidden_overlay",
|
|
37
|
+
"has_invisible",
|
|
38
|
+
"has_mixed_numbers",
|
|
39
|
+
"has_unlikely_marks",
|
|
40
|
+
"identifier_status",
|
|
41
|
+
"identifier_types",
|
|
42
|
+
"is_allowed",
|
|
43
|
+
"is_mixed_script",
|
|
44
|
+
"isxidcontinue",
|
|
45
|
+
"isxidstart",
|
|
46
|
+
"iter_graphemes",
|
|
47
|
+
"match",
|
|
48
|
+
"restriction_level",
|
|
49
|
+
"skeleton",
|
|
50
|
+
"truncate",
|
|
51
|
+
"unicode_version",
|
|
52
|
+
]
|
confusable/_bidi.py
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
"""The Unicode Bidirectional Algorithm (UAX #9 revision 52, Unicode 18.0).
|
|
2
|
+
|
|
3
|
+
UTS #39 defines skeleton(X) as bidiSkeleton(LTR, X), which puts X into display
|
|
4
|
+
order before mapping look-alikes: rules P1-P3, X1-X10, W1-W7, N0-N2, I1-I2 and
|
|
5
|
+
L1-L2 of UAX #9, then L3 (combining marks follow their base) and L4 (mirrored
|
|
6
|
+
glyphs). This module implements all of them for the skeleton; it is not a
|
|
7
|
+
text-rendering API.
|
|
8
|
+
|
|
9
|
+
Rule X9 removes embedding controls and boundary neutrals (BN) from the
|
|
10
|
+
algorithm, and UAX #9 does not say where they go in display order, but
|
|
11
|
+
bidiSkeleton keeps the ones that are not Default_Ignorable (C0 and C1 controls,
|
|
12
|
+
for example). display() places them the way ICU does, so skeletons agree with
|
|
13
|
+
ICU's: at the level of the character that follows them, or at the paragraph
|
|
14
|
+
level when they trail a line or precede a separator.
|
|
15
|
+
|
|
16
|
+
Text reorders only within a paragraph (UAX #9 section 2), and CR LF counts as
|
|
17
|
+
one paragraph separator. ICU's bidiSkeleton reorders several paragraphs as one
|
|
18
|
+
line; the conformance oracle patches that.
|
|
19
|
+
|
|
20
|
+
UTS #39 notes a fast path: in a left-to-right paragraph, text without R or AL
|
|
21
|
+
characters keeps its order. That is incomplete. Only R, AL, AN, RLE, RLO and
|
|
22
|
+
RLI can bring text to an odd level, so all six must be absent: RLO reverses
|
|
23
|
+
Latin letters, and a neutral between two AN digits resolves to R.
|
|
24
|
+
|
|
25
|
+
The tables load on first use, so that importing the package stays fast.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from collections.abc import Sequence
|
|
31
|
+
from functools import cache
|
|
32
|
+
from typing import Literal
|
|
33
|
+
|
|
34
|
+
from confusable._lookup import Ranges, Runs
|
|
35
|
+
|
|
36
|
+
Direction = Literal["LTR", "RTL", "auto"]
|
|
37
|
+
|
|
38
|
+
MAX_DEPTH = 125
|
|
39
|
+
_BRACKET_STACK_SIZE = 63
|
|
40
|
+
|
|
41
|
+
_ISOLATE_INITIATORS = frozenset({"LRI", "RLI", "FSI"})
|
|
42
|
+
_EMBEDDING_INITIATORS = frozenset({"LRE", "RLE", "LRO", "RLO"})
|
|
43
|
+
_REMOVED_BY_X9 = frozenset({"LRE", "RLE", "LRO", "RLO", "PDF", "BN"})
|
|
44
|
+
# Neutral and isolate formatting types (NI), as rules N1 and N2 use them.
|
|
45
|
+
_NEUTRALS = frozenset({"B", "S", "WS", "ON", "LRI", "RLI", "FSI", "PDI"})
|
|
46
|
+
# Types that rule L1 resets when they trail a line or precede a separator.
|
|
47
|
+
_TRAILING = frozenset({"WS", "LRI", "RLI", "FSI", "PDI"})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@cache
|
|
51
|
+
def _bidi_classes() -> Runs:
|
|
52
|
+
from confusable._data.bidi import BIDI_CLASS_RUNS
|
|
53
|
+
|
|
54
|
+
return Runs(BIDI_CLASS_RUNS)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@cache
|
|
58
|
+
def _combining_marks() -> Ranges:
|
|
59
|
+
from confusable._data.bidi import COMBINING_MARK_RANGES
|
|
60
|
+
|
|
61
|
+
return Ranges(COMBINING_MARK_RANGES)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@cache
|
|
65
|
+
def _brackets() -> tuple[dict[int, tuple[int, str]], dict[int, int]]:
|
|
66
|
+
"""Return each bracket's pair and type, and the brackets' canonical forms."""
|
|
67
|
+
from confusable._data.bidi import BIDI_BRACKETS, BRACKET_CANONICAL
|
|
68
|
+
|
|
69
|
+
return BIDI_BRACKETS, BRACKET_CANONICAL
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@cache
|
|
73
|
+
def _mirroring_glyphs() -> dict[int, int]:
|
|
74
|
+
from confusable._data.bidi import BIDI_MIRRORING_GLYPH
|
|
75
|
+
|
|
76
|
+
return BIDI_MIRRORING_GLYPH
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def bidi_class(cp: int) -> str:
|
|
80
|
+
"""Return the Bidi_Class of a code point, as its short name."""
|
|
81
|
+
return _bidi_classes()[cp]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _odd_above(level: int) -> int:
|
|
85
|
+
"""Return the least odd level greater than level."""
|
|
86
|
+
return (level + 1) | 1
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _even_above(level: int) -> int:
|
|
90
|
+
"""Return the least even level greater than level."""
|
|
91
|
+
return (level + 2) & ~1
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _matching_pdis(types: Sequence[str]) -> dict[int, int]:
|
|
95
|
+
"""BD9: the matching PDI of each isolate initiator that has one."""
|
|
96
|
+
matches = {}
|
|
97
|
+
open_initiators: list[int] = []
|
|
98
|
+
for i, t in enumerate(types):
|
|
99
|
+
if t in _ISOLATE_INITIATORS:
|
|
100
|
+
open_initiators.append(i)
|
|
101
|
+
elif t == "PDI" and open_initiators:
|
|
102
|
+
matches[open_initiators.pop()] = i
|
|
103
|
+
return matches
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _first_strong(
|
|
107
|
+
types: Sequence[str], start: int, end: int, matches: dict[int, int]
|
|
108
|
+
) -> int | None:
|
|
109
|
+
"""P2 and P3: level 1 or 0 from the first strong type, or None if none."""
|
|
110
|
+
i = start
|
|
111
|
+
while i < end:
|
|
112
|
+
t = types[i]
|
|
113
|
+
if t == "L":
|
|
114
|
+
return 0
|
|
115
|
+
if t in ("R", "AL"):
|
|
116
|
+
return 1
|
|
117
|
+
if t in _ISOLATE_INITIATORS:
|
|
118
|
+
i = matches.get(i, end)
|
|
119
|
+
i += 1
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Paragraph:
|
|
124
|
+
"""One resolved paragraph of the input (rule P1).
|
|
125
|
+
|
|
126
|
+
A plain class: a dataclass would import inspect, and a NamedTuple
|
|
127
|
+
annotationlib on Python 3.14, each adding milliseconds to the import.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
__slots__ = ("display_levels", "level", "levels", "start")
|
|
131
|
+
|
|
132
|
+
start: int
|
|
133
|
+
level: int
|
|
134
|
+
# Resolved levels after L1; None for characters that rule X9 removes.
|
|
135
|
+
levels: list[int | None]
|
|
136
|
+
# Levels used for display, with the X9-removed characters placed too.
|
|
137
|
+
display_levels: list[int]
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
start: int,
|
|
142
|
+
level: int,
|
|
143
|
+
levels: list[int | None],
|
|
144
|
+
display_levels: list[int],
|
|
145
|
+
) -> None:
|
|
146
|
+
self.start = start
|
|
147
|
+
self.level = level
|
|
148
|
+
self.levels = levels
|
|
149
|
+
self.display_levels = display_levels
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def resolve(
|
|
153
|
+
types: Sequence[str],
|
|
154
|
+
direction: Direction,
|
|
155
|
+
code_points: Sequence[int] | None = None,
|
|
156
|
+
) -> list[Paragraph]:
|
|
157
|
+
"""Resolve embedding levels for a sequence of Bidi_Class values.
|
|
158
|
+
|
|
159
|
+
code_points gives the characters, for bracket pairs (rule N0); without
|
|
160
|
+
it no characters pair as brackets, as BidiTest.txt assumes.
|
|
161
|
+
"""
|
|
162
|
+
paragraphs = []
|
|
163
|
+
start = 0
|
|
164
|
+
for end in range(len(types)):
|
|
165
|
+
if types[end] != "B":
|
|
166
|
+
continue
|
|
167
|
+
# CR LF is a single newline function, so one paragraph separator.
|
|
168
|
+
if (
|
|
169
|
+
code_points is not None
|
|
170
|
+
and code_points[end] == 0x0D
|
|
171
|
+
and end + 1 < len(types)
|
|
172
|
+
and code_points[end + 1] == 0x0A
|
|
173
|
+
):
|
|
174
|
+
continue
|
|
175
|
+
paragraphs.append(_paragraph(types, code_points, start, end + 1, direction))
|
|
176
|
+
start = end + 1
|
|
177
|
+
if start < len(types) or not types:
|
|
178
|
+
paragraphs.append(_paragraph(types, code_points, start, len(types), direction))
|
|
179
|
+
return paragraphs
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _paragraph(
|
|
183
|
+
all_types: Sequence[str],
|
|
184
|
+
all_code_points: Sequence[int] | None,
|
|
185
|
+
start: int,
|
|
186
|
+
end: int,
|
|
187
|
+
direction: Direction,
|
|
188
|
+
) -> Paragraph:
|
|
189
|
+
types = all_types[start:end]
|
|
190
|
+
code_points = all_code_points[start:end] if all_code_points is not None else None
|
|
191
|
+
matches = _matching_pdis(types)
|
|
192
|
+
if direction == "auto":
|
|
193
|
+
level = _first_strong(types, 0, len(types), matches) or 0
|
|
194
|
+
else:
|
|
195
|
+
level = 0 if direction == "LTR" else 1
|
|
196
|
+
|
|
197
|
+
explicit, current = _explicit_levels(types, level, matches)
|
|
198
|
+
kept = [i for i, t in enumerate(types) if t not in _REMOVED_BY_X9]
|
|
199
|
+
resolved: list[int | None] = [None] * len(types)
|
|
200
|
+
for i in kept:
|
|
201
|
+
resolved[i] = explicit[i]
|
|
202
|
+
|
|
203
|
+
for sequence, sos, eos in _isolating_run_sequences(
|
|
204
|
+
types, explicit, kept, matches, level
|
|
205
|
+
):
|
|
206
|
+
_resolve_sequence(
|
|
207
|
+
sequence, sos, eos, explicit[sequence[0]], current, code_points, resolved
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# L1, on the original types and ignoring the characters X9 removed.
|
|
211
|
+
trailing = True
|
|
212
|
+
for i in reversed(kept):
|
|
213
|
+
if types[i] in ("S", "B"):
|
|
214
|
+
resolved[i] = level
|
|
215
|
+
trailing = True
|
|
216
|
+
elif types[i] in _TRAILING:
|
|
217
|
+
if trailing:
|
|
218
|
+
resolved[i] = level
|
|
219
|
+
else:
|
|
220
|
+
trailing = False
|
|
221
|
+
|
|
222
|
+
# Place the removed characters as ICU does (see the module docstring).
|
|
223
|
+
display: list[int] = [level] * len(types)
|
|
224
|
+
trailing = True
|
|
225
|
+
following = level
|
|
226
|
+
for i in reversed(range(len(types))):
|
|
227
|
+
value = resolved[i]
|
|
228
|
+
if value is None:
|
|
229
|
+
display[i] = level if trailing else following
|
|
230
|
+
else:
|
|
231
|
+
display[i] = value
|
|
232
|
+
if types[i] in ("S", "B"):
|
|
233
|
+
trailing = True
|
|
234
|
+
elif types[i] not in _TRAILING:
|
|
235
|
+
trailing = False
|
|
236
|
+
following = display[i]
|
|
237
|
+
return Paragraph(start, level, resolved, display)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _explicit_levels(
|
|
241
|
+
types: Sequence[str], paragraph_level: int, matches: dict[int, int]
|
|
242
|
+
) -> tuple[list[int], list[str]]:
|
|
243
|
+
"""X1-X8: explicit embedding levels and the types after overrides."""
|
|
244
|
+
levels = [paragraph_level] * len(types)
|
|
245
|
+
current = list(types)
|
|
246
|
+
# Entries: (embedding level, override type or "", isolate status).
|
|
247
|
+
stack: list[tuple[int, str, bool]] = [(paragraph_level, "", False)]
|
|
248
|
+
overflow_isolates = overflow_embeddings = valid_isolates = 0
|
|
249
|
+
|
|
250
|
+
for i, t in enumerate(types):
|
|
251
|
+
top_level, override, _ = stack[-1]
|
|
252
|
+
if t in _EMBEDDING_INITIATORS:
|
|
253
|
+
# Retained for display at the level outside the embedding.
|
|
254
|
+
levels[i] = top_level
|
|
255
|
+
new = _odd_above(top_level) if t[0] == "R" else _even_above(top_level)
|
|
256
|
+
if new <= MAX_DEPTH and overflow_isolates == 0 and overflow_embeddings == 0:
|
|
257
|
+
stack.append((new, {"RLO": "R", "LRO": "L"}.get(t, ""), False))
|
|
258
|
+
elif overflow_isolates == 0:
|
|
259
|
+
overflow_embeddings += 1
|
|
260
|
+
elif t in _ISOLATE_INITIATORS:
|
|
261
|
+
levels[i] = top_level
|
|
262
|
+
if override:
|
|
263
|
+
current[i] = override
|
|
264
|
+
if t == "FSI":
|
|
265
|
+
rtl = (
|
|
266
|
+
_first_strong(types, i + 1, matches.get(i, len(types)), matches)
|
|
267
|
+
== 1
|
|
268
|
+
)
|
|
269
|
+
else:
|
|
270
|
+
rtl = t == "RLI"
|
|
271
|
+
new = _odd_above(top_level) if rtl else _even_above(top_level)
|
|
272
|
+
if new <= MAX_DEPTH and overflow_isolates == 0 and overflow_embeddings == 0:
|
|
273
|
+
valid_isolates += 1
|
|
274
|
+
stack.append((new, "", True))
|
|
275
|
+
else:
|
|
276
|
+
overflow_isolates += 1
|
|
277
|
+
elif t == "PDI":
|
|
278
|
+
if overflow_isolates > 0:
|
|
279
|
+
overflow_isolates -= 1
|
|
280
|
+
elif valid_isolates > 0:
|
|
281
|
+
overflow_embeddings = 0
|
|
282
|
+
while not stack[-1][2]:
|
|
283
|
+
stack.pop()
|
|
284
|
+
stack.pop()
|
|
285
|
+
valid_isolates -= 1
|
|
286
|
+
top_level, override, _ = stack[-1]
|
|
287
|
+
levels[i] = top_level
|
|
288
|
+
if override:
|
|
289
|
+
current[i] = override
|
|
290
|
+
elif t == "PDF":
|
|
291
|
+
if overflow_isolates > 0:
|
|
292
|
+
pass
|
|
293
|
+
elif overflow_embeddings > 0:
|
|
294
|
+
overflow_embeddings -= 1
|
|
295
|
+
elif not stack[-1][2] and len(stack) >= 2:
|
|
296
|
+
stack.pop()
|
|
297
|
+
levels[i] = stack[-1][0]
|
|
298
|
+
elif t == "B":
|
|
299
|
+
levels[i] = paragraph_level
|
|
300
|
+
else:
|
|
301
|
+
# X6, extended to BN for display; BN keeps its type.
|
|
302
|
+
levels[i] = top_level
|
|
303
|
+
if override and t != "BN":
|
|
304
|
+
current[i] = override
|
|
305
|
+
return levels, current
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _isolating_run_sequences(
|
|
309
|
+
types: Sequence[str],
|
|
310
|
+
levels: Sequence[int],
|
|
311
|
+
kept: list[int],
|
|
312
|
+
matches: dict[int, int],
|
|
313
|
+
paragraph_level: int,
|
|
314
|
+
) -> list[tuple[list[int], str, str]]:
|
|
315
|
+
"""X10 and BD13: isolating run sequences, each with its sos and eos."""
|
|
316
|
+
runs: list[list[int]] = []
|
|
317
|
+
for i in kept:
|
|
318
|
+
if runs and levels[runs[-1][-1]] == levels[i]:
|
|
319
|
+
runs[-1].append(i)
|
|
320
|
+
else:
|
|
321
|
+
runs.append([i])
|
|
322
|
+
run_of = {run[0]: n for n, run in enumerate(runs)}
|
|
323
|
+
matched_pdis = set(matches.values())
|
|
324
|
+
position = {i: n for n, i in enumerate(kept)}
|
|
325
|
+
|
|
326
|
+
sequences = []
|
|
327
|
+
for run in runs:
|
|
328
|
+
if types[run[0]] == "PDI" and run[0] in matched_pdis:
|
|
329
|
+
continue
|
|
330
|
+
sequence = list(run)
|
|
331
|
+
while types[sequence[-1]] in _ISOLATE_INITIATORS and sequence[-1] in matches:
|
|
332
|
+
pdi = matches[sequence[-1]]
|
|
333
|
+
if pdi not in run_of:
|
|
334
|
+
break
|
|
335
|
+
sequence.extend(runs[run_of[pdi]])
|
|
336
|
+
|
|
337
|
+
first, last = sequence[0], sequence[-1]
|
|
338
|
+
before = position[first] - 1
|
|
339
|
+
before_level = levels[kept[before]] if before >= 0 else paragraph_level
|
|
340
|
+
after = position[last] + 1
|
|
341
|
+
if after < len(kept) and types[last] not in _ISOLATE_INITIATORS:
|
|
342
|
+
after_level = levels[kept[after]]
|
|
343
|
+
else:
|
|
344
|
+
after_level = paragraph_level
|
|
345
|
+
sos = "R" if max(levels[first], before_level) % 2 else "L"
|
|
346
|
+
eos = "R" if max(levels[last], after_level) % 2 else "L"
|
|
347
|
+
sequences.append((sequence, sos, eos))
|
|
348
|
+
return sequences
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _strong(t: str) -> str:
|
|
352
|
+
"""Return the direction of a resolved type, with EN and AN counting as R."""
|
|
353
|
+
if t == "L":
|
|
354
|
+
return "L"
|
|
355
|
+
if t in ("R", "EN", "AN"):
|
|
356
|
+
return "R"
|
|
357
|
+
return ""
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _resolve_sequence(
|
|
361
|
+
sequence: list[int],
|
|
362
|
+
sos: str,
|
|
363
|
+
eos: str,
|
|
364
|
+
level: int,
|
|
365
|
+
current: Sequence[str],
|
|
366
|
+
code_points: Sequence[int] | None,
|
|
367
|
+
resolved: list[int | None],
|
|
368
|
+
) -> None:
|
|
369
|
+
"""W1-W7, N0-N2 and I1-I2 on one isolating run sequence."""
|
|
370
|
+
t = [current[i] for i in sequence]
|
|
371
|
+
before_w1 = list(t)
|
|
372
|
+
n = len(t)
|
|
373
|
+
|
|
374
|
+
# W1: nonspacing marks take the type of what precedes them.
|
|
375
|
+
previous = sos
|
|
376
|
+
for k in range(n):
|
|
377
|
+
if t[k] == "NSM":
|
|
378
|
+
t[k] = (
|
|
379
|
+
"ON"
|
|
380
|
+
if previous in _ISOLATE_INITIATORS or previous == "PDI"
|
|
381
|
+
else previous
|
|
382
|
+
)
|
|
383
|
+
previous = t[k]
|
|
384
|
+
# W2: European numbers after Arabic letters are Arabic numbers.
|
|
385
|
+
last_strong = sos
|
|
386
|
+
for k in range(n):
|
|
387
|
+
if t[k] in ("L", "R", "AL"):
|
|
388
|
+
last_strong = t[k]
|
|
389
|
+
elif t[k] == "EN" and last_strong == "AL":
|
|
390
|
+
t[k] = "AN"
|
|
391
|
+
# W3
|
|
392
|
+
t = ["R" if x == "AL" else x for x in t]
|
|
393
|
+
# W4: a single separator between two numbers of the same type.
|
|
394
|
+
for k in range(1, n - 1):
|
|
395
|
+
if t[k] == "ES" and t[k - 1] == "EN" and t[k + 1] == "EN":
|
|
396
|
+
t[k] = "EN"
|
|
397
|
+
elif t[k] == "CS" and t[k - 1] == t[k + 1] and t[k - 1] in ("EN", "AN"):
|
|
398
|
+
t[k] = t[k - 1]
|
|
399
|
+
# W5: terminators next to European numbers.
|
|
400
|
+
k = 0
|
|
401
|
+
while k < n:
|
|
402
|
+
if t[k] != "ET":
|
|
403
|
+
k += 1
|
|
404
|
+
continue
|
|
405
|
+
j = k
|
|
406
|
+
while j < n and t[j] == "ET":
|
|
407
|
+
j += 1
|
|
408
|
+
if (k > 0 and t[k - 1] == "EN") or (j < n and t[j] == "EN"):
|
|
409
|
+
t[k:j] = ["EN"] * (j - k)
|
|
410
|
+
k = j
|
|
411
|
+
# W6
|
|
412
|
+
t = ["ON" if x in ("ES", "ET", "CS") else x for x in t]
|
|
413
|
+
# W7: European numbers after left-to-right text are left-to-right.
|
|
414
|
+
last_strong = sos
|
|
415
|
+
for k in range(n):
|
|
416
|
+
if t[k] in ("L", "R"):
|
|
417
|
+
last_strong = t[k]
|
|
418
|
+
elif t[k] == "EN" and last_strong == "L":
|
|
419
|
+
t[k] = "L"
|
|
420
|
+
|
|
421
|
+
embedding = "R" if level % 2 else "L"
|
|
422
|
+
if code_points is not None:
|
|
423
|
+
_resolve_brackets(
|
|
424
|
+
t, before_w1, [code_points[i] for i in sequence], sos, embedding
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# N1 and N2: runs of neutrals.
|
|
428
|
+
k = 0
|
|
429
|
+
while k < n:
|
|
430
|
+
if t[k] not in _NEUTRALS:
|
|
431
|
+
k += 1
|
|
432
|
+
continue
|
|
433
|
+
j = k
|
|
434
|
+
while j < n and t[j] in _NEUTRALS:
|
|
435
|
+
j += 1
|
|
436
|
+
before = sos if k == 0 else _strong(t[k - 1])
|
|
437
|
+
after = eos if j == n else _strong(t[j])
|
|
438
|
+
t[k:j] = [before if before == after else embedding] * (j - k)
|
|
439
|
+
k = j
|
|
440
|
+
|
|
441
|
+
# I1 and I2.
|
|
442
|
+
for k, i in enumerate(sequence):
|
|
443
|
+
if level % 2 == 0:
|
|
444
|
+
if t[k] == "R":
|
|
445
|
+
resolved[i] = level + 1
|
|
446
|
+
elif t[k] in ("AN", "EN"):
|
|
447
|
+
resolved[i] = level + 2
|
|
448
|
+
else:
|
|
449
|
+
resolved[i] = level
|
|
450
|
+
elif t[k] in ("L", "EN", "AN"):
|
|
451
|
+
resolved[i] = level + 1
|
|
452
|
+
else:
|
|
453
|
+
resolved[i] = level
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _bracket_pairs(t: list[str], code_points: list[int]) -> list[tuple[int, int]]:
|
|
457
|
+
"""BD16: bracket pairs in an isolating run sequence, by opening position."""
|
|
458
|
+
brackets, canonical = _brackets()
|
|
459
|
+
stack: list[tuple[int, int]] = []
|
|
460
|
+
pairs = []
|
|
461
|
+
for k, cp in enumerate(code_points):
|
|
462
|
+
info = brackets.get(cp)
|
|
463
|
+
if info is None or t[k] != "ON":
|
|
464
|
+
continue
|
|
465
|
+
pair, kind = info
|
|
466
|
+
if kind == "o":
|
|
467
|
+
if len(stack) == _BRACKET_STACK_SIZE:
|
|
468
|
+
return []
|
|
469
|
+
stack.append((canonical.get(pair, pair), k))
|
|
470
|
+
continue
|
|
471
|
+
closing = canonical.get(cp, cp)
|
|
472
|
+
for depth in range(len(stack) - 1, -1, -1):
|
|
473
|
+
if stack[depth][0] == closing:
|
|
474
|
+
pairs.append((stack[depth][1], k))
|
|
475
|
+
del stack[depth:]
|
|
476
|
+
break
|
|
477
|
+
return sorted(pairs)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _resolve_brackets(
|
|
481
|
+
t: list[str], before_w1: list[str], code_points: list[int], sos: str, embedding: str
|
|
482
|
+
) -> None:
|
|
483
|
+
"""N0: bracket pairs take the direction of their content or context."""
|
|
484
|
+
for opening, closing in _bracket_pairs(t, code_points):
|
|
485
|
+
inside = {_strong(x) for x in t[opening + 1 : closing]} - {""}
|
|
486
|
+
if not inside:
|
|
487
|
+
continue
|
|
488
|
+
if embedding in inside:
|
|
489
|
+
direction = embedding
|
|
490
|
+
else:
|
|
491
|
+
context = next((s for s in map(_strong, reversed(t[:opening])) if s), sos)
|
|
492
|
+
direction = context
|
|
493
|
+
for bracket in (opening, closing):
|
|
494
|
+
t[bracket] = direction
|
|
495
|
+
k = bracket + 1
|
|
496
|
+
while k < len(t) and before_w1[k] == "NSM":
|
|
497
|
+
t[k] = direction
|
|
498
|
+
k += 1
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _visual_order(levels: Sequence[int]) -> list[int]:
|
|
502
|
+
"""L2: the visual order, left to right, of items with these levels."""
|
|
503
|
+
order = list(range(len(levels)))
|
|
504
|
+
if not order:
|
|
505
|
+
return order
|
|
506
|
+
current = list(levels)
|
|
507
|
+
for level in range(max(current), (min(current) | 1) - 1, -1):
|
|
508
|
+
k = 0
|
|
509
|
+
while k < len(order):
|
|
510
|
+
if current[k] < level:
|
|
511
|
+
k += 1
|
|
512
|
+
continue
|
|
513
|
+
j = k
|
|
514
|
+
while j < len(order) and current[j] >= level:
|
|
515
|
+
j += 1
|
|
516
|
+
order[k:j] = order[k:j][::-1]
|
|
517
|
+
current[k:j] = current[k:j][::-1]
|
|
518
|
+
k = j
|
|
519
|
+
return order
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def levels_and_order(
|
|
523
|
+
types: Sequence[str],
|
|
524
|
+
direction: Direction,
|
|
525
|
+
code_points: Sequence[int] | None = None,
|
|
526
|
+
) -> tuple[int, list[int | None], list[int]]:
|
|
527
|
+
"""Resolve levels and visual order, as the UCD bidi test files state them.
|
|
528
|
+
|
|
529
|
+
Returns the first paragraph's level, the level of every character (None
|
|
530
|
+
for those X9 removes), and the visual order of the remaining characters
|
|
531
|
+
as indices into the input, left to right, paragraph by paragraph.
|
|
532
|
+
"""
|
|
533
|
+
paragraphs = resolve(types, direction, code_points)
|
|
534
|
+
levels: list[int | None] = []
|
|
535
|
+
order: list[int] = []
|
|
536
|
+
for paragraph in paragraphs:
|
|
537
|
+
levels.extend(paragraph.levels)
|
|
538
|
+
kept = [
|
|
539
|
+
(k, value) for k, value in enumerate(paragraph.levels) if value is not None
|
|
540
|
+
]
|
|
541
|
+
visual = _visual_order([value for _, value in kept])
|
|
542
|
+
order.extend(paragraph.start + kept[n][0] for n in visual)
|
|
543
|
+
return paragraphs[0].level, levels, order
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def display(text: str, direction: Direction) -> str:
|
|
547
|
+
"""Text in display order with L3 and L4 applied: UTS #39's R''.
|
|
548
|
+
|
|
549
|
+
The input to internalSkeleton in bidiSkeleton(direction, text).
|
|
550
|
+
"""
|
|
551
|
+
classes = _bidi_classes()
|
|
552
|
+
marks = _combining_marks()
|
|
553
|
+
mirrored = _mirroring_glyphs()
|
|
554
|
+
code_points = [ord(c) for c in text]
|
|
555
|
+
types = [classes[cp] for cp in code_points]
|
|
556
|
+
out: list[str] = []
|
|
557
|
+
for paragraph in resolve(types, direction, code_points):
|
|
558
|
+
start = paragraph.start
|
|
559
|
+
levels = paragraph.display_levels
|
|
560
|
+
# Level runs in logical order, reordered as units (L2).
|
|
561
|
+
runs: list[tuple[int, int]] = []
|
|
562
|
+
for k in range(len(levels)):
|
|
563
|
+
if runs and levels[runs[-1][0]] == levels[k]:
|
|
564
|
+
runs[-1] = (runs[-1][0], k + 1)
|
|
565
|
+
else:
|
|
566
|
+
runs.append((k, k + 1))
|
|
567
|
+
run_levels = [levels[first] for first, _ in runs]
|
|
568
|
+
for n in _visual_order(run_levels):
|
|
569
|
+
first, last = runs[n]
|
|
570
|
+
if run_levels[n] % 2 == 0:
|
|
571
|
+
out.extend(text[start + first : start + last])
|
|
572
|
+
continue
|
|
573
|
+
# A right-to-left run: reverse it, keeping each base character
|
|
574
|
+
# before its combining marks (L3) and mirroring the base (L4).
|
|
575
|
+
j = last
|
|
576
|
+
while j > first:
|
|
577
|
+
k = j - 1
|
|
578
|
+
while k > first and code_points[start + k] in marks:
|
|
579
|
+
k -= 1
|
|
580
|
+
base = code_points[start + k]
|
|
581
|
+
out.append(chr(mirrored.get(base, base)))
|
|
582
|
+
out.extend(text[start + k + 1 : start + j])
|
|
583
|
+
j = k
|
|
584
|
+
return "".join(out)
|