Skip to content

Fix case mapping, Hangul composition, and property table bugs - #20

Merged
yhirose merged 1 commit into
masterfrom
fix/case-mapping-hangul-property-bugs
Jul 13, 2026
Merged

Fix case mapping, Hangul composition, and property table bugs#20
yhirose merged 1 commit into
masterfrom
fix/case-mapping-hangul-property-bugs

Conversation

@yhirose

@yhirose yhirose commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes seven bugs found during a code review of the hand-written logic in unicodelib.h, several of which corrupt data or crash. All were verified with small repro programs (AddressSanitizer clean after the fixes) and are covered by new regression tests.

Data corruption / crashes

  • Hangul NFC corrupted datacompose_hangul accepted TIndex == 0 and TIndex == TCount, so NFC(U+AC00 U+11A7) silently deleted U+11A7 and NFC(U+AC00 U+11C3) composed into the wrong syllable (AC1C 개). Both bounds now match UAX Improve utf8::decode() and utf16::decode() #15 (0 < TIndex && TIndex < TCount), and is_decomposed_syllable no longer treats TBase as a valid trailing consonant.
  • Out-of-bounds read in is_more_above — it scanned from i - 1 instead of i + 1, reading s32[-1] at the start of the string (caught by ASan via to_lowercase(U"Ì", "lt")) and evaluating the Lithuanian More_Above context against the wrong characters.
  • Crash on empty-string mappings — SpecialCasing entries that map to the empty string (e.g. U+0307 removed after I in Turkish) are stored as null pointers; full_case_mapping appended them to a std::u32string and segfaulted.
  • Out-of-bounds read in is_standard_korean_syllable_block|| instead of && in the trailing-consonant loop absorbed arbitrary following characters into the syllable block and read one code point past the end.

Wrong results

  • Turkish/Azeri Iı brokenis_before_dot (and is_more_above) returned true when the scan reached end of text without finding the target, so the Not_Before_Dot condition failed and to_lowercase(U"I", "tr") returned "i" instead of "ı".
  • Language-qualified unconditional SpecialCasing never applied — entries with a language but no context condition are stored with SpecialCasingContext::Unassigned, which fell into the switch's unreachable default. Lithuanian Ì/Í/Ĩ and Turkish İ → i did not work at all.
  • 13 property predicates tested the wrong bitgen_tables.py assigned _properties bits by order of appearance in PropList.txt, while the Property_* constants in the header are fixed. Unicode 17 inserted IDS_Unary_Operator and the ID_Compat_Math_* properties, shifting every bit from Radical onward: is_radical through is_prepended_concatenation_mark — including is_soft_dotted, which case mapping depends on — all returned values for a different property. The generator now uses fixed name→bit maps that match the header constants (same treatment for DerivedCoreProperties, which currently happens to match), and the _properties table is regenerated.

Tests

New regression tests cover Turkish/Azeri and Lithuanian case mappings, Hangul composition boundary values, Korean syllable block scanning, and one probe per previously misaligned property predicate. Full suite (including the UCD NormalizationTest.txt / segmentation test data) passes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LfmQizYVn2aWEovhNfzXG4

Fixes seven bugs found by review, several of which corrupt data or
crash:

- compose_hangul off-by-one on both trailing-consonant bounds:
  NFC silently deleted U+11A7 after an LV syllable and composed
  U+11C3 into the wrong syllable (e.g. AC00 11C3 -> AC1C). Also
  tighten is_decomposed_syllable to exclude TBase itself.

- is_more_above scanned from i - 1 instead of i + 1, reading
  s32[-1] at the start of the string (caught by ASan) and testing
  the wrong characters for the Lithuanian More_Above context.

- is_before_dot / is_more_above returned true when the scan hit the
  end of text without finding the target, so Turkish
  to_lowercase(U"I", "tr") produced "i" instead of dotless "ı".

- full_case_mapping had no switch case for language-qualified
  SpecialCasing entries without a context condition
  (SpecialCasingContext::Unassigned), so they were never applied:
  Lithuanian Ì/Í/Ĩ and Turkish İ -> i did not work.

- full_case_mapping crashed on mappings to the empty string (stored
  as null, e.g. U+0307 removed after 'I' in Turkish); append nothing
  instead of dereferencing the null pointer.

- is_standard_korean_syllable_block used || instead of && in its
  trailing-consonant loop, absorbing arbitrary following characters
  into the block and reading one code point past the end.

- gen_tables.py assigned property bits by order of appearance in
  PropList.txt, but the Property_* constants in unicodelib.h are
  fixed. Unicode 17 inserted IDS_Unary_Operator and the
  ID_Compat_Math properties, shifting every bit from Radical onward:
  is_radical through is_prepended_concatenation_mark (13 predicates,
  including is_soft_dotted used by case mapping) all tested the
  wrong bit. Bits are now assigned from fixed maps that match the
  header constants (DerivedCoreProperties likewise, which currently
  matches), and the _properties table is regenerated.

Adds regression tests for Turkish/Azeri and Lithuanian case
mappings, Hangul composition boundary values, Korean syllable block
scanning, and one probe per previously misaligned property.

Claude-Session: https://claude.ai/code/session_01LfmQizYVn2aWEovhNfzXG4
@yhirose
yhirose merged commit bbd10f0 into master Jul 13, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant