2626from __future__ import annotations
2727
2828import argparse
29+ import re
2930import sys
3031import unicodedata
3132import 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+ `\u1f13 0` 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+
347397def _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 ():
0 commit comments