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 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)
@@ -0,0 +1,8 @@
1
+ """Unicode data tables for one pinned Unicode version.
2
+
3
+ Generated from Unicode 18.0.0 data by
4
+ tools/generate_tables.py; do not edit. Derived from Unicode data:
5
+ see LICENSES/Unicode-3.0.txt.
6
+ """
7
+
8
+ UNICODE_VERSION = "18.0.0"