Skip to content

Commit da3ec54

Browse files
authored
Merge a4e6fb1 into f057fdf
2 parents f057fdf + a4e6fb1 commit da3ec54

15 files changed

Lines changed: 366 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,30 @@ compatibility (see [RELEASING.md](RELEASING.md)).
5151
key builders are byte-identical, having no confusable step on this path. Greek prose
5252
containing a final sigma changes, though `canonicalize` already rewrote Greek before
5353
this — `κόσμος` was `koouoς` and is now `koouoc`.
54+
- **The 54 negative enclosed letters fold to their letter (#815).** `🅐` and `🅰` folded on
55+
no surface. Their positive counterparts `` and `🄰` do, because NFKC decomposes those
56+
and leaves these alone — two neighbouring blocks, opposite outcomes, and a "fancy text"
57+
generator offers both side by side, so one style was neutralised and the other passed
58+
through untouched.
59+
60+
Derived from the UCD name and filtered to what NFKC does not already handle, so the set
61+
is exactly the 54 that need it: NEGATIVE CIRCLED (26), NEGATIVE SQUARED (26), CROSSED
62+
NEGATIVE SQUARED and one stray SQUARED. Two families that match the same name pattern
63+
are excluded because both are already handled correctly: **Tags** are stripped as a
64+
smuggling class (#413), and **combining letters** are category `Mn`, which is
65+
`strip_accents`' business.
66+
67+
Four of them are dual-purpose. `🅰` is NEGATIVE SQUARED LATIN CAPITAL LETTER A *and* the
68+
blood-type A button, so it now folds under `canonicalize`, following #614 — inside a
69+
comparison preset the fold wins over the name, or a spoof and its target stop being
70+
equal. `llm_guardrail` still names it, because demojize runs before the fold there;
71+
that divergence is #918's subject. The `build.rs` gate on the emoji/confusable overlap
72+
moves 50 → 54 and caught this, which is what it exists for.
73+
74+
**Upgrade note — `KEY_SCHEMA_VERSION` goes 4 → 5.** `catalog_key` moves for all 54.
75+
`search_key` and `sort_key` do not. The key-stability corpus contained none of these,
76+
which is the **third** time this cycle the fixture stayed green through a key change
77+
because its corpus did not sample the class being fixed; 58 rows added with it.
5478

5579
- **`scripts/watch_pr.py` — a PR watcher whose decision logic is tested.** Shepherding
5680
loops kept being written by hand, and four bugs kept coming back:

build.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -430,9 +430,9 @@ fn main() {
430430
.collect();
431431
assert_eq!(
432432
overlap.len(),
433-
50,
434-
"emoji_single.tsv ∩ confusables_to_latin.tsv changed: expected the 50 rows \
435-
reviewed in #614 and #801, found {}. A new row means a confusable source is \
433+
54,
434+
"emoji_single.tsv ∩ confusables_to_latin.tsv changed: expected the 54 rows \
435+
reviewed in #614, #801 and #815, found {}. A new row means a confusable source is \
436436
now named instead of folded inside strip_obfuscation. Review it, then update \
437437
this count.",
438438
overlap.len()

docs/architecture/data-tables.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ membership is the whole question and there is no value to store:
3030
| Table | Key type | Entries | Purpose |
3131
|---|---|---|---|
3232
| Hanzi pinyin | `char` | ~21K | CJK ideograph → pinyin |
33-
| Confusables (Latin) | `char` | 2,300 | TR39 + supplement (#342) + attested (#597) + ICANN LGR (#831) → Latin |
33+
| Confusables (Latin) | `char` | 2,354 | TR39 + supplement (#342) + attested (#597) + ICANN LGR (#831) → Latin |
3434
| Confusables (Cyrillic) | `char` | ~1,352 | TR39 confusable → Cyrillic |
3535
| Upstream confusable sources | `char` (set) | 6,565 | Coverage denominator (#563) |
3636
| Case folding | `char` | 1,557 | Unicode CaseFolding.txt |

docs/limitations.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ The [MDPI homoglyph detection paper (2022)](https://www.mdpi.com/2224-2708/11/3/
351351
### The bundled table does not cover every confusable, and the gap is now measurable
352352

353353
A TR39 fold is only as good as the table behind it, and disarm's to-Latin table is a
354-
*subset* of upstream `confusables.txt`: 6,565 sources upstream, 2,300 rows bundled. The
354+
*subset* of upstream `confusables.txt`: 6,565 sources upstream, 2,354 rows bundled. The
355355
gap is mostly deliberate — a source whose upstream target is CJK, Arabic or Hangul does
356356
not belong in a to-Latin table — but "mostly deliberate" is not a claim a deployment
357357
should have to take on trust.

docs/user-guide/confusables.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ By default, confusables are normalized to Latin. You can specify a different tar
351351

352352
| Target | Mappings | Description |
353353
|--------|----------|-------------|
354-
| `"latin"` (default) | 2,300 | Non-Latin → Latin. Cyrillic а→a, Greek Ρ→P, etc. |
354+
| `"latin"` (default) | 2,354 | Non-Latin → Latin. Cyrillic а→a, Greek Ρ→P, etc. |
355355
| `"cyrillic"` | 1,352 | Non-Cyrillic → Cyrillic. Latin A→А, p→р, etc. |
356356
| `"arabic"` | 373 | Non-Arabic → Arabic. ```؟`, `𞣉``٣`, etc. |
357357
| `"hebrew"` | 261 | Non-Hebrew → Hebrew. ```א`, ````, etc. |

python/disarm/_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -842,7 +842,7 @@ def normalize_confusables(
842842
Args:
843843
text: Input string potentially containing homoglyphs.
844844
target_script: Script to normalize toward. Supported values:
845-
``"latin"`` (default, 2,300 mappings), ``"cyrillic"`` (1,352 mappings),
845+
``"latin"`` (default, 2,354 mappings), ``"cyrillic"`` (1,352 mappings),
846846
``"arabic"`` (373 mappings) and ``"hebrew"`` (261 mappings).
847847
848848
The two RTL targets exist because generation drops an equivalence

scripts/gen_confusables.py

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from __future__ import annotations
2727

2828
import argparse
29+
import re
2930
import sys
3031
import unicodedata
3132
import urllib.request
@@ -344,6 +345,55 @@ class through a small capital is covered without an edit here. The shape is a
344345
return out
345346

346347

348+
def _enclosed_letter_folds() -> dict[str, str]:
349+
"""`<prefix> LATIN {CAPITAL,SMALL} LETTER X` -> `x`, derived from the UCD name (#815).
350+
351+
U+1F150 and U+1F170 fold on no surface. Their *positive* counterparts `\u24d0` and
352+
`\u1f130` fold via NFKC, which decomposes those and does not decompose these — two
353+
neighbouring blocks, opposite outcomes, and nothing said so. A generator offering
354+
"circled" and "circled (negative)" side by side gets one neutralised and one through
355+
untouched.
356+
357+
Same shape as `_small_capital_folds`: the name states the letter, so there is no
358+
visual judgment to make. Two families matching the pattern are deliberately excluded,
359+
because both are already handled correctly and folding them would be wrong:
360+
361+
* **Tags** (U+E0041 and 51 more) are stripped as a smuggling class (#413), not folded.
362+
`canonicalize` already returns `ab` for a tag between two letters, and
363+
`has_anomalies` fires on it.
364+
* **Combining letters** (`\u0363` and 22 more) are category `Mn` — diacritics written
365+
above a base in medieval manuscripts, not letters standing in for one. They are
366+
`strip_accents`' business.
367+
368+
The filter is therefore on category: a letter or a symbol, never a mark and never a
369+
format character.
370+
"""
371+
out: dict[str, str] = {}
372+
for cp in range(0x110000):
373+
ch = chr(cp)
374+
# `.+` rather than `.*`: a bare `LATIN CAPITAL LETTER A` is ASCII `A` itself, and
375+
# matching it would emit 52 identity rows.
376+
match = re.fullmatch(r".+\bLATIN (CAPITAL|SMALL) LETTER ([A-Z])", unicodedata.name(ch, ""))
377+
if not match or ch.isascii():
378+
continue
379+
if ucd_category(cp)[0] not in ("L", "S"):
380+
continue
381+
# The whole point of the set: NFKC already decomposes the positive forms, and
382+
# a row for one of those would be redundant with a step that runs before the
383+
# fold. What is left is what NFKC leaves alone — 54 code points, all of them
384+
# negative, crossed or otherwise unmapped by the compatibility data.
385+
if ucd_nfkc(cp) != ch:
386+
continue
387+
# Case comes from the NAME, not from `fix_case_mismatch`. These are category `So`,
388+
# so the case reconciler cannot tell a capital from a small letter and left every
389+
# row lowercase — which made U+1F170 fold to `a` while its positive counterpart
390+
# U+1F130 reaches `A` through NFKC. Two spellings of the same style disagreeing is
391+
# the asymmetry this set exists to remove, so it must not be reintroduced here.
392+
letter = match.group(2)
393+
out[ch] = letter if match.group(1) == "CAPITAL" else letter.lower()
394+
return out
395+
396+
347397
def _close_under_case(fold: dict[str, str]) -> dict[str, str]:
348398
"""Give every entry's case pair the same ASCII letter (#801).
349399
@@ -365,7 +415,7 @@ def _close_under_case(fold: dict[str, str]) -> dict[str, str]:
365415
return out
366416

367417

368-
ASCII_FOLD = _close_under_case({**_small_capital_folds(), **ASCII_FOLD})
418+
ASCII_FOLD = _close_under_case({**_enclosed_letter_folds(), **_small_capital_folds(), **ASCII_FOLD})
369419

370420

371421
# ---------------------------------------------------------------------------
@@ -959,8 +1009,14 @@ def generate_mappings(
9591009
# policy question in #815, not this.
9601010
#
9611011
# An existing row always wins: this only fills gaps.
962-
for glyph, letter in _small_capital_folds().items():
963-
merged.setdefault(ord(glyph), fix_case_mismatch(ord(glyph), letter))
1012+
#
1013+
# `_enclosed_letter_folds` joins it for the same reason and on the same terms
1014+
# (#815). U+1F150 and U+1F170 fold on no surface while their positive
1015+
# counterparts fold via NFKC, so a generator offering "circled" and "circled
1016+
# (negative)" side by side gets one neutralised and one through untouched.
1017+
for source in (_small_capital_folds(), _enclosed_letter_folds()):
1018+
for glyph, letter in source.items():
1019+
merged.setdefault(ord(glyph), fix_case_mismatch(ord(glyph), letter))
9641020
# #342/#343: measured cross-script supplement, applied with priority so it
9651021
# can add a missing fold or re-point an existing one.
9661022
for cp, target in (supplement or {}).items():

src/api/metadata.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,15 @@ pub fn unicode_version() -> &'static str {
164164
/// `\u0237` ȷ to `j`, `\u03a3` Σ to `С`, `\u2502` │ to `ӏ`. That moves `canonicalize`,
165165
/// `canonicalize_strict`, `strip_obfuscation` and `normalize_confusables`; the three key
166166
/// builders do not fold confusables through this path and are byte-identical.
167-
pub const KEY_SCHEMA_VERSION: u32 = 5;
167+
///
168+
/// Bumped to 6 by #815, for the negative enclosed letters. U+1F150 and U+1F170 fold
169+
/// on no surface while their positive counterparts fold via NFKC, so a generator offering
170+
/// "circled" and "circled (negative)" side by side got one neutralised and one through
171+
/// untouched. 54 rows, and `catalog_key` moves for all of them. The corpus held none of
172+
/// these either — third time in this cycle that the fixture stayed green through a key
173+
/// change because its corpus did not sample the class being fixed, so 58 rows were added
174+
/// with it.
175+
pub const KEY_SCHEMA_VERSION: u32 = 6;
168176

169177
/// SHA-256 of the key-stability fixture's *decompressed* bytes (#887).
170178
///
@@ -199,7 +207,7 @@ pub const KEY_SCHEMA_VERSION: u32 = 5;
199207
/// difference was `# generated against disarm 0.14.1` becoming `0.15.0`. The rows are
200208
/// the semantic anchor: they change when, and only when, a key moved.
201209
pub const KEY_FIXTURE_SHA256: &str =
202-
"e57cb67ff92953a3fe31e41522436d59b8d133651f8fa51c2841a6f619dae5b6";
210+
"6a1c8a2781e586210088c7322c4ad18248c414185815a9531859e94ee9f40435";
203211

204212
/// The key-schema counter, as a function (#645).
205213
///

src/tables/confusables_data.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
//! release is not restated here — [`CONFUSABLES_VERSION`] carries it, parsed from the
55
//! TSV header by build.rs so there is exactly one place it can be wrong (#560).
66
//!
7-
//! Contains 2,300 non-Latin → Latin mappings and 1,352 non-Cyrillic →
7+
//! Contains 2,354 non-Latin → Latin mappings and 1,352 non-Cyrillic →
88
//! Cyrillic mappings. Uses compile-time perfect hash maps (`phf`) for O(1)
99
//! lookups. Covers Cyrillic, Greek, Armenian, Georgian, CJK compatibility,
1010
//! mathematical symbols, fullwidth forms, and other confusable characters.

src/tables/data/confusables_to_latin.tsv

Lines changed: 54 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)