1
0

ucd.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. #!/usr/bin/env python3
  2. # Copyright © 2025 Pierre Le Marre <dev@wismill.eu>
  3. # SPDX-License-Identifier: MIT
  4. """
  5. Utils to parse the Unicode database files
  6. """
  7. import sys
  8. from collections.abc import Callable, Iterator
  9. from dataclasses import dataclass
  10. from pathlib import Path
  11. from typing import ClassVar, Self
  12. def parse_code_point(raw: str) -> int | None:
  13. return None if not raw else int(raw, 16)
  14. @dataclass
  15. class CodePointRange:
  16. start: int
  17. end: int
  18. def __iter__(self) -> Iterator[int]:
  19. yield from range(self.start, self.end + 1)
  20. @classmethod
  21. def parse(cls, raw: str) -> Self:
  22. start, *end = raw.strip().split("..")
  23. return cls(
  24. start=int(start, 16), end=int(start, 16) if not end else int(end[0], 16)
  25. )
  26. @dataclass
  27. class PropertyEntry:
  28. code_point: int
  29. property: str
  30. @classmethod
  31. def parse_file(
  32. cls, path: Path, filter: Callable[[str], bool] | None = None
  33. ) -> Iterator[Self]:
  34. with path.open("rt", encoding="utf-8") as fd:
  35. for line in fd:
  36. # Remove comment
  37. line, *_ = line.split("#")
  38. line = line.strip()
  39. # Skip empty lines
  40. if not line:
  41. continue
  42. raw_range, property, *_ = line.split(";")
  43. range = CodePointRange.parse(raw_range)
  44. property = property.strip()
  45. if filter and not filter(property):
  46. continue
  47. for code_point in range:
  48. yield cls(code_point=code_point, property=property)
  49. @dataclass
  50. class UnicodeDataEntry:
  51. code_point: int
  52. general_category: str
  53. lower_case: int | None
  54. upper_case: int | None
  55. title_case: int | None
  56. @classmethod
  57. def parse_file(cls, path: Path) -> Iterator[Self]:
  58. with path.open("rt", encoding="utf-8") as fd:
  59. for line in fd:
  60. line = line.strip()
  61. if not line or line.startswith("#"):
  62. continue
  63. (
  64. cp,
  65. _name,
  66. general_category,
  67. _cc,
  68. _bc,
  69. _d,
  70. _decimal,
  71. _digit,
  72. _numeric,
  73. _mirrored,
  74. _,
  75. _,
  76. upper_case,
  77. lower_case,
  78. title_case,
  79. *_,
  80. ) = line.split(";")
  81. code_point = int(cp, 16)
  82. yield cls(
  83. code_point=code_point,
  84. general_category=general_category,
  85. lower_case=parse_code_point(lower_case),
  86. upper_case=parse_code_point(upper_case),
  87. title_case=parse_code_point(title_case),
  88. )
  89. @dataclass
  90. class DB:
  91. lower_case: set[int]
  92. upper_case: set[int]
  93. title_case: set[int]
  94. lower_case_mappings: dict[int, int]
  95. upper_case_mappings: dict[int, int]
  96. title_case_mappings: dict[int, int]
  97. case_properties: ClassVar[frozenset[str]] = frozenset(("Lowercase", "Uppercase"))
  98. @classmethod
  99. def filter_case_properties(cls, property: str) -> bool:
  100. return property in cls.case_properties
  101. @classmethod
  102. def parse_ucd(cls, path: Path) -> Self:
  103. lower_case: set[int] = set()
  104. upper_case: set[int] = set()
  105. title_case: set[int] = set()
  106. lower_case_mappings: dict[int, int] = {}
  107. upper_case_mappings: dict[int, int] = {}
  108. title_case_mappings: dict[int, int] = {}
  109. for entry in UnicodeDataEntry.parse_file(path / "UnicodeData.txt"):
  110. if entry.general_category == "Lt":
  111. title_case.add(entry.code_point)
  112. if entry.lower_case is not None:
  113. lower_case_mappings[entry.code_point] = entry.lower_case
  114. if entry.upper_case is not None:
  115. upper_case_mappings[entry.code_point] = entry.upper_case
  116. if entry.title_case is not None:
  117. title_case_mappings[entry.code_point] = entry.title_case
  118. for entry in PropertyEntry.parse_file(
  119. path / "DerivedCoreProperties.txt", filter=cls.filter_case_properties
  120. ):
  121. match entry.property:
  122. case "Lowercase":
  123. lower_case.add(entry.code_point)
  124. case "Uppercase":
  125. upper_case.add(entry.code_point)
  126. case _:
  127. raise ValueError(entry)
  128. return cls(
  129. lower_case=lower_case,
  130. upper_case=upper_case,
  131. title_case=title_case,
  132. lower_case_mappings=lower_case_mappings,
  133. upper_case_mappings=upper_case_mappings,
  134. title_case_mappings=title_case_mappings,
  135. )
  136. def isULowercase(self, cp: int) -> bool:
  137. return cp in self.lower_case
  138. def isUUppercase(self, cp: int) -> bool:
  139. return cp in self.upper_case
  140. def istitle(self, cp: int) -> bool:
  141. return cp in self.title_case
  142. def tolower(self, cp_or_char: int | str) -> int | str:
  143. cp = cp_or_char if isinstance(cp_or_char, int) else ord(cp_or_char)
  144. mapping = self.lower_case_mappings.get(cp, cp)
  145. return mapping if isinstance(cp_or_char, int) else chr(mapping)
  146. def toupper(self, cp_or_char: int | str) -> int | str:
  147. cp = cp_or_char if isinstance(cp_or_char, int) else ord(cp_or_char)
  148. mapping = self.upper_case_mappings.get(cp, cp)
  149. return mapping if isinstance(cp_or_char, int) else chr(mapping)
  150. if __name__ == "__main__":
  151. # Test
  152. import icu
  153. c = icu.Locale.createFromName("C")
  154. icu.Locale.setDefault(c)
  155. path = Path(sys.argv[1])
  156. db = DB.parse_ucd(path)
  157. for cp in range(0, 0x10FFFF + 1):
  158. assert db.isULowercase(cp) == icu.DB.isULowercase(cp), (
  159. cp,
  160. db.isULowercase(cp),
  161. icu.DB.isULowercase(cp),
  162. )
  163. assert db.isUUppercase(cp) == icu.DB.isUUppercase(cp), cp
  164. assert db.istitle(cp) == icu.DB.istitle(cp), cp
  165. assert db.tolower(cp) == icu.DB.tolower(cp)
  166. assert db.toupper(cp) == icu.DB.toupper(cp)