cjkfold 0.1.0__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.
cjkfold/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """cjkfold -- fold look-alike CJK glyphs so extracted text matches again.
2
+
3
+ >>> from cjkfold import fold
4
+ >>> fold("⻆⺁⼀") == "角厂一"
5
+ True
6
+
7
+ See :mod:`cjkfold.normalize` for the background, :mod:`cjkfold.score` for the
8
+ retrieval-ranking helper that pairs with it, and :mod:`cjkfold.integrations` for
9
+ framework-agnostic adapters that fold a whole document set.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from ._table import EQUIVALENT_IDEOGRAPH
15
+ from .integrations import fold_document, fold_documents, rerank, text_of
16
+ from .normalize import Lookalike, fold, has_lookalikes, iter_lookalikes, kind_of
17
+ from .score import exact_boost, ngram_overlap, ngrams
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ "fold",
23
+ "has_lookalikes",
24
+ "iter_lookalikes",
25
+ "Lookalike",
26
+ "kind_of",
27
+ "ngrams",
28
+ "ngram_overlap",
29
+ "exact_boost",
30
+ "text_of",
31
+ "fold_document",
32
+ "fold_documents",
33
+ "rerank",
34
+ "EQUIVALENT_IDEOGRAPH",
35
+ "__version__",
36
+ ]
cjkfold/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
cjkfold/_table.py ADDED
@@ -0,0 +1,361 @@
1
+ """Glyph equivalence table. GENERATED — do not edit by hand.
2
+
3
+ Regenerate: python scripts/build_table.py
4
+ Source: Unicode Character Database, EquivalentUnifiedIdeograph.txt
5
+ https://www.unicode.org/Public/UCD/latest/ucd/EquivalentUnifiedIdeograph.txt
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ # Characters that are visually identical to a unified ideograph but carry a
11
+ # different code point. NFKC does NOT fold most of these (see README).
12
+ EQUIVALENT_IDEOGRAPH: dict[str, str] = {
13
+ "\u2e81": "\u5382", # ⺁ CJK RADICAL CLIFF -> 厂
14
+ "\u2e82": "\u4e5b", # ⺂ CJK RADICAL SECOND ONE -> 乛
15
+ "\u2e83": "\u4e5a", # ⺃ CJK RADICAL SECOND TWO -> 乚
16
+ "\u2e84": "\u4e59", # ⺄ CJK RADICAL SECOND THREE -> 乙
17
+ "\u2e85": "\u4ebb", # ⺅ CJK RADICAL PERSON -> 亻
18
+ "\u2e86": "\u5182", # ⺆ CJK RADICAL BOX -> 冂
19
+ "\u2e87": "\U00020628", # ⺇ CJK RADICAL TABLE -> 𠘨 [U+20628]
20
+ "\u2e88": "\u5200", # ⺈ CJK RADICAL KNIFE ONE -> 刀
21
+ "\u2e89": "\u5202", # ⺉ CJK RADICAL KNIFE TWO -> 刂
22
+ "\u2e8a": "\u535c", # ⺊ CJK RADICAL DIVINATION -> 卜
23
+ "\u2e8b": "\u353e", # ⺋ CJK RADICAL SEAL -> 㔾
24
+ "\u2e8c": "\u5c0f", # ⺌ CJK RADICAL SMALL ONE -> 小
25
+ "\u2e8d": "\u5c0f", # ⺍ CJK RADICAL SMALL TWO -> 小
26
+ "\u2e8e": "\u5140", # ⺎ CJK RADICAL LAME ONE -> 兀
27
+ "\u2e8f": "\u5c23", # ⺏ CJK RADICAL LAME TWO -> 尣
28
+ "\u2e90": "\u5c22", # ⺐ CJK RADICAL LAME THREE -> 尢
29
+ "\u2e91": "\U00021bc2", # ⺑ CJK RADICAL LAME FOUR -> 𡯂 [U+21BC2]
30
+ "\u2e92": "\u5df3", # ⺒ CJK RADICAL SNAKE -> 巳
31
+ "\u2e93": "\u5e7a", # ⺓ CJK RADICAL THREAD -> 幺
32
+ "\u2e94": "\u5f51", # ⺔ CJK RADICAL SNOUT ONE -> 彑
33
+ "\u2e95": "\U0002b739", # ⺕ CJK RADICAL SNOUT TWO -> 𫜹 [U+2B739]
34
+ "\u2e96": "\u5fc4", # ⺖ CJK RADICAL HEART ONE -> 忄
35
+ "\u2e97": "\u5fc3", # ⺗ CJK RADICAL HEART TWO -> 心
36
+ "\u2e98": "\u624c", # ⺘ CJK RADICAL HAND -> 扌
37
+ "\u2e99": "\u6535", # ⺙ CJK RADICAL RAP -> 攵
38
+ "\u2e9b": "\u65e1", # ⺛ CJK RADICAL CHOKE -> 旡
39
+ "\u2e9c": "\u65e5", # ⺜ CJK RADICAL SUN -> 日
40
+ "\u2e9d": "\u6708", # ⺝ CJK RADICAL MOON -> 月
41
+ "\u2e9e": "\u6b7a", # ⺞ CJK RADICAL DEATH -> 歺
42
+ "\u2e9f": "\u6bcd", # ⺟ CJK RADICAL MOTHER -> 母
43
+ "\u2ea0": "\u6c11", # ⺠ CJK RADICAL CIVILIAN -> 民
44
+ "\u2ea1": "\u6c35", # ⺡ CJK RADICAL WATER ONE -> 氵
45
+ "\u2ea2": "\u6c3a", # ⺢ CJK RADICAL WATER TWO -> 氺
46
+ "\u2ea3": "\u706c", # ⺣ CJK RADICAL FIRE -> 灬
47
+ "\u2ea4": "\u722b", # ⺤ CJK RADICAL PAW ONE -> 爫
48
+ "\u2ea5": "\u722b", # ⺥ CJK RADICAL PAW TWO -> 爫
49
+ "\u2ea6": "\u4e2c", # ⺦ CJK RADICAL SIMPLIFIED HALF TREE TRUNK -> 丬
50
+ "\u2ea7": "\u725b", # ⺧ CJK RADICAL COW -> 牛
51
+ "\u2ea8": "\u72ad", # ⺨ CJK RADICAL DOG -> 犭
52
+ "\u2ea9": "\u738b", # ⺩ CJK RADICAL JADE -> 王
53
+ "\u2eaa": "\U00024d14", # ⺪ CJK RADICAL BOLT OF CLOTH -> 𤴔 [U+24D14]
54
+ "\u2eab": "\u76ee", # ⺫ CJK RADICAL EYE -> 目
55
+ "\u2eac": "\u793a", # ⺬ CJK RADICAL SPIRIT ONE -> 示
56
+ "\u2ead": "\u793b", # ⺭ CJK RADICAL SPIRIT TWO -> 礻
57
+ "\u2eae": "\U00025ad7", # ⺮ CJK RADICAL BAMBOO -> 𥫗 [U+25AD7]
58
+ "\u2eaf": "\u7cf9", # ⺯ CJK RADICAL SILK -> 糹
59
+ "\u2eb0": "\u7e9f", # ⺰ CJK RADICAL C-SIMPLIFIED SILK -> 纟
60
+ "\u2eb1": "\u7f53", # ⺱ CJK RADICAL NET ONE -> 罓
61
+ "\u2eb2": "\u7f52", # ⺲ CJK RADICAL NET TWO -> 罒
62
+ "\u2eb3": "\u34c1", # ⺳ CJK RADICAL NET THREE -> 㓁
63
+ "\u2eb4": "\u5197", # ⺴ CJK RADICAL NET FOUR -> 冗
64
+ "\u2eb5": "\U0002626b", # ⺵ CJK RADICAL MESH -> 𦉫 [U+2626B]
65
+ "\u2eb6": "\u7f8a", # ⺶ CJK RADICAL SHEEP -> 羊
66
+ "\u2eb7": "\U0002634c", # ⺷ CJK RADICAL RAM -> 𦍌 [U+2634C]
67
+ "\u2eb8": "\U0002634b", # ⺸ CJK RADICAL EWE -> 𦍋 [U+2634B]
68
+ "\u2eb9": "\u8002", # ⺹ CJK RADICAL OLD -> 耂
69
+ "\u2eba": "\u8080", # ⺺ CJK RADICAL BRUSH ONE -> 肀
70
+ "\u2ebb": "\u807f", # ⺻ CJK RADICAL BRUSH TWO -> 聿
71
+ "\u2ebc": "\u8089", # ⺼ CJK RADICAL MEAT -> 肉
72
+ "\u2ebd": "\U00026951", # ⺽ CJK RADICAL MORTAR -> 𦥑 [U+26951]
73
+ "\u2ebe": "\u8279", # ⺾ CJK RADICAL GRASS ONE -> 艹
74
+ "\u2ebf": "\u8279", # ⺿ CJK RADICAL GRASS TWO -> 艹
75
+ "\u2ec0": "\u8279", # ⻀ CJK RADICAL GRASS THREE -> 艹
76
+ "\u2ec1": "\u864e", # ⻁ CJK RADICAL TIGER -> 虎
77
+ "\u2ec2": "\u8864", # ⻂ CJK RADICAL CLOTHES -> 衤
78
+ "\u2ec3": "\u8980", # ⻃ CJK RADICAL WEST ONE -> 覀
79
+ "\u2ec4": "\u897f", # ⻄ CJK RADICAL WEST TWO -> 西
80
+ "\u2ec5": "\u89c1", # ⻅ CJK RADICAL C-SIMPLIFIED SEE -> 见
81
+ "\u2ec6": "\u89d2", # ⻆ CJK RADICAL SIMPLIFIED HORN -> 角
82
+ "\u2ec7": "\U000278b2", # ⻇ CJK RADICAL HORN -> 𧢲 [U+278B2]
83
+ "\u2ec8": "\u8ba0", # ⻈ CJK RADICAL C-SIMPLIFIED SPEECH -> 讠
84
+ "\u2ec9": "\u8d1d", # ⻉ CJK RADICAL C-SIMPLIFIED SHELL -> 贝
85
+ "\u2eca": "\U00027fb7", # ⻊ CJK RADICAL FOOT -> 𧾷 [U+27FB7]
86
+ "\u2ecb": "\u8f66", # ⻋ CJK RADICAL C-SIMPLIFIED CART -> 车
87
+ "\u2ecc": "\u8fb6", # ⻌ CJK RADICAL SIMPLIFIED WALK -> 辶
88
+ "\u2ecd": "\u8fb6", # ⻍ CJK RADICAL WALK ONE -> 辶
89
+ "\u2ece": "\u8fb6", # ⻎ CJK RADICAL WALK TWO -> 辶
90
+ "\u2ecf": "\u9091", # ⻏ CJK RADICAL CITY -> 邑
91
+ "\u2ed0": "\u9485", # ⻐ CJK RADICAL C-SIMPLIFIED GOLD -> 钅
92
+ "\u2ed1": "\u9577", # ⻑ CJK RADICAL LONG ONE -> 長
93
+ "\u2ed2": "\u9578", # ⻒ CJK RADICAL LONG TWO -> 镸
94
+ "\u2ed3": "\u957f", # ⻓ CJK RADICAL C-SIMPLIFIED LONG -> 长
95
+ "\u2ed4": "\u95e8", # ⻔ CJK RADICAL C-SIMPLIFIED GATE -> 门
96
+ "\u2ed5": "\U00028e0f", # ⻕ CJK RADICAL MOUND ONE -> 𨸏 [U+28E0F]
97
+ "\u2ed6": "\u961d", # ⻖ CJK RADICAL MOUND TWO -> 阝
98
+ "\u2ed7": "\u96e8", # ⻗ CJK RADICAL RAIN -> 雨
99
+ "\u2ed8": "\u9752", # ⻘ CJK RADICAL BLUE -> 青
100
+ "\u2ed9": "\u97e6", # ⻙ CJK RADICAL C-SIMPLIFIED TANNED LEATHER -> 韦
101
+ "\u2eda": "\u9875", # ⻚ CJK RADICAL C-SIMPLIFIED LEAF -> 页
102
+ "\u2edb": "\u98ce", # ⻛ CJK RADICAL C-SIMPLIFIED WIND -> 风
103
+ "\u2edc": "\u98de", # ⻜ CJK RADICAL C-SIMPLIFIED FLY -> 飞
104
+ "\u2edd": "\u98df", # ⻝ CJK RADICAL EAT ONE -> 食
105
+ "\u2ede": "\U0002967f", # ⻞ CJK RADICAL EAT TWO -> 𩙿 [U+2967F]
106
+ "\u2edf": "\u98e0", # ⻟ CJK RADICAL EAT THREE -> 飠
107
+ "\u2ee0": "\u9963", # ⻠ CJK RADICAL C-SIMPLIFIED EAT -> 饣
108
+ "\u2ee1": "\U00029810", # ⻡ CJK RADICAL HEAD -> 𩠐 [U+29810]
109
+ "\u2ee2": "\u9a6c", # ⻢ CJK RADICAL C-SIMPLIFIED HORSE -> 马
110
+ "\u2ee3": "\u9aa8", # ⻣ CJK RADICAL BONE -> 骨
111
+ "\u2ee4": "\u9b3c", # ⻤ CJK RADICAL GHOST -> 鬼
112
+ "\u2ee5": "\u9c7c", # ⻥ CJK RADICAL C-SIMPLIFIED FISH -> 鱼
113
+ "\u2ee6": "\u9e1f", # ⻦ CJK RADICAL C-SIMPLIFIED BIRD -> 鸟
114
+ "\u2ee7": "\u5364", # ⻧ CJK RADICAL C-SIMPLIFIED SALT -> 卤
115
+ "\u2ee8": "\u9ea6", # ⻨ CJK RADICAL SIMPLIFIED WHEAT -> 麦
116
+ "\u2ee9": "\u9ec4", # ⻩ CJK RADICAL SIMPLIFIED YELLOW -> 黄
117
+ "\u2eea": "\u9efe", # ⻪ CJK RADICAL C-SIMPLIFIED FROG -> 黾
118
+ "\u2eeb": "\u6589", # ⻫ CJK RADICAL J-SIMPLIFIED EVEN -> 斉
119
+ "\u2eec": "\u9f50", # ⻬ CJK RADICAL C-SIMPLIFIED EVEN -> 齐
120
+ "\u2eed": "\u6b6f", # ⻭ CJK RADICAL J-SIMPLIFIED TOOTH -> 歯
121
+ "\u2eee": "\u9f7f", # ⻮ CJK RADICAL C-SIMPLIFIED TOOTH -> 齿
122
+ "\u2eef": "\u7adc", # ⻯ CJK RADICAL J-SIMPLIFIED DRAGON -> 竜
123
+ "\u2ef0": "\u9f99", # ⻰ CJK RADICAL C-SIMPLIFIED DRAGON -> 龙
124
+ "\u2ef1": "\u9f9c", # ⻱ CJK RADICAL TURTLE -> 龜
125
+ "\u2ef2": "\u4e80", # ⻲ CJK RADICAL J-SIMPLIFIED TURTLE -> 亀
126
+ "\u2ef3": "\u9f9f", # ⻳ CJK RADICAL C-SIMPLIFIED TURTLE -> 龟
127
+ "\u2f00": "\u4e00", # ⼀ KANGXI RADICAL ONE -> 一
128
+ "\u2f01": "\u4e28", # ⼁ KANGXI RADICAL LINE -> 丨
129
+ "\u2f02": "\u4e36", # ⼂ KANGXI RADICAL DOT -> 丶
130
+ "\u2f03": "\u4e3f", # ⼃ KANGXI RADICAL SLASH -> 丿
131
+ "\u2f04": "\u4e59", # ⼄ KANGXI RADICAL SECOND -> 乙
132
+ "\u2f05": "\u4e85", # ⼅ KANGXI RADICAL HOOK -> 亅
133
+ "\u2f06": "\u4e8c", # ⼆ KANGXI RADICAL TWO -> 二
134
+ "\u2f07": "\u4ea0", # ⼇ KANGXI RADICAL LID -> 亠
135
+ "\u2f08": "\u4eba", # ⼈ KANGXI RADICAL MAN -> 人
136
+ "\u2f09": "\u513f", # ⼉ KANGXI RADICAL LEGS -> 儿
137
+ "\u2f0a": "\u5165", # ⼊ KANGXI RADICAL ENTER -> 入
138
+ "\u2f0b": "\u516b", # ⼋ KANGXI RADICAL EIGHT -> 八
139
+ "\u2f0c": "\u5182", # ⼌ KANGXI RADICAL DOWN BOX -> 冂
140
+ "\u2f0d": "\u5196", # ⼍ KANGXI RADICAL COVER -> 冖
141
+ "\u2f0e": "\u51ab", # ⼎ KANGXI RADICAL ICE -> 冫
142
+ "\u2f0f": "\u51e0", # ⼏ KANGXI RADICAL TABLE -> 几
143
+ "\u2f10": "\u51f5", # ⼐ KANGXI RADICAL OPEN BOX -> 凵
144
+ "\u2f11": "\u5200", # ⼑ KANGXI RADICAL KNIFE -> 刀
145
+ "\u2f12": "\u529b", # ⼒ KANGXI RADICAL POWER -> 力
146
+ "\u2f13": "\u52f9", # ⼓ KANGXI RADICAL WRAP -> 勹
147
+ "\u2f14": "\u5315", # ⼔ KANGXI RADICAL SPOON -> 匕
148
+ "\u2f15": "\u531a", # ⼕ KANGXI RADICAL RIGHT OPEN BOX -> 匚
149
+ "\u2f16": "\u5338", # ⼖ KANGXI RADICAL HIDING ENCLOSURE -> 匸
150
+ "\u2f17": "\u5341", # ⼗ KANGXI RADICAL TEN -> 十
151
+ "\u2f18": "\u535c", # ⼘ KANGXI RADICAL DIVINATION -> 卜
152
+ "\u2f19": "\u5369", # ⼙ KANGXI RADICAL SEAL -> 卩
153
+ "\u2f1a": "\u5382", # ⼚ KANGXI RADICAL CLIFF -> 厂
154
+ "\u2f1b": "\u53b6", # ⼛ KANGXI RADICAL PRIVATE -> 厶
155
+ "\u2f1c": "\u53c8", # ⼜ KANGXI RADICAL AGAIN -> 又
156
+ "\u2f1d": "\u53e3", # ⼝ KANGXI RADICAL MOUTH -> 口
157
+ "\u2f1e": "\u56d7", # ⼞ KANGXI RADICAL ENCLOSURE -> 囗
158
+ "\u2f1f": "\u571f", # ⼟ KANGXI RADICAL EARTH -> 土
159
+ "\u2f20": "\u58eb", # ⼠ KANGXI RADICAL SCHOLAR -> 士
160
+ "\u2f21": "\u5902", # ⼡ KANGXI RADICAL GO -> 夂
161
+ "\u2f22": "\u590a", # ⼢ KANGXI RADICAL GO SLOWLY -> 夊
162
+ "\u2f23": "\u5915", # ⼣ KANGXI RADICAL EVENING -> 夕
163
+ "\u2f24": "\u5927", # ⼤ KANGXI RADICAL BIG -> 大
164
+ "\u2f25": "\u5973", # ⼥ KANGXI RADICAL WOMAN -> 女
165
+ "\u2f26": "\u5b50", # ⼦ KANGXI RADICAL CHILD -> 子
166
+ "\u2f27": "\u5b80", # ⼧ KANGXI RADICAL ROOF -> 宀
167
+ "\u2f28": "\u5bf8", # ⼨ KANGXI RADICAL INCH -> 寸
168
+ "\u2f29": "\u5c0f", # ⼩ KANGXI RADICAL SMALL -> 小
169
+ "\u2f2a": "\u5c22", # ⼪ KANGXI RADICAL LAME -> 尢
170
+ "\u2f2b": "\u5c38", # ⼫ KANGXI RADICAL CORPSE -> 尸
171
+ "\u2f2c": "\u5c6e", # ⼬ KANGXI RADICAL SPROUT -> 屮
172
+ "\u2f2d": "\u5c71", # ⼭ KANGXI RADICAL MOUNTAIN -> 山
173
+ "\u2f2e": "\u5ddb", # ⼮ KANGXI RADICAL RIVER -> 巛
174
+ "\u2f2f": "\u5de5", # ⼯ KANGXI RADICAL WORK -> 工
175
+ "\u2f30": "\u5df1", # ⼰ KANGXI RADICAL ONESELF -> 己
176
+ "\u2f31": "\u5dfe", # ⼱ KANGXI RADICAL TURBAN -> 巾
177
+ "\u2f32": "\u5e72", # ⼲ KANGXI RADICAL DRY -> 干
178
+ "\u2f33": "\u5e7a", # ⼳ KANGXI RADICAL SHORT THREAD -> 幺
179
+ "\u2f34": "\u5e7f", # ⼴ KANGXI RADICAL DOTTED CLIFF -> 广
180
+ "\u2f35": "\u5ef4", # ⼵ KANGXI RADICAL LONG STRIDE -> 廴
181
+ "\u2f36": "\u5efe", # ⼶ KANGXI RADICAL TWO HANDS -> 廾
182
+ "\u2f37": "\u5f0b", # ⼷ KANGXI RADICAL SHOOT -> 弋
183
+ "\u2f38": "\u5f13", # ⼸ KANGXI RADICAL BOW -> 弓
184
+ "\u2f39": "\u5f50", # ⼹ KANGXI RADICAL SNOUT -> 彐
185
+ "\u2f3a": "\u5f61", # ⼺ KANGXI RADICAL BRISTLE -> 彡
186
+ "\u2f3b": "\u5f73", # ⼻ KANGXI RADICAL STEP -> 彳
187
+ "\u2f3c": "\u5fc3", # ⼼ KANGXI RADICAL HEART -> 心
188
+ "\u2f3d": "\u6208", # ⼽ KANGXI RADICAL HALBERD -> 戈
189
+ "\u2f3e": "\u6236", # ⼾ KANGXI RADICAL DOOR -> 戶
190
+ "\u2f3f": "\u624b", # ⼿ KANGXI RADICAL HAND -> 手
191
+ "\u2f40": "\u652f", # ⽀ KANGXI RADICAL BRANCH -> 支
192
+ "\u2f41": "\u6534", # ⽁ KANGXI RADICAL RAP -> 攴
193
+ "\u2f42": "\u6587", # ⽂ KANGXI RADICAL SCRIPT -> 文
194
+ "\u2f43": "\u6597", # ⽃ KANGXI RADICAL DIPPER -> 斗
195
+ "\u2f44": "\u65a4", # ⽄ KANGXI RADICAL AXE -> 斤
196
+ "\u2f45": "\u65b9", # ⽅ KANGXI RADICAL SQUARE -> 方
197
+ "\u2f46": "\u65e0", # ⽆ KANGXI RADICAL NOT -> 无
198
+ "\u2f47": "\u65e5", # ⽇ KANGXI RADICAL SUN -> 日
199
+ "\u2f48": "\u66f0", # ⽈ KANGXI RADICAL SAY -> 曰
200
+ "\u2f49": "\u6708", # ⽉ KANGXI RADICAL MOON -> 月
201
+ "\u2f4a": "\u6728", # ⽊ KANGXI RADICAL TREE -> 木
202
+ "\u2f4b": "\u6b20", # ⽋ KANGXI RADICAL LACK -> 欠
203
+ "\u2f4c": "\u6b62", # ⽌ KANGXI RADICAL STOP -> 止
204
+ "\u2f4d": "\u6b79", # ⽍ KANGXI RADICAL DEATH -> 歹
205
+ "\u2f4e": "\u6bb3", # ⽎ KANGXI RADICAL WEAPON -> 殳
206
+ "\u2f4f": "\u6bcb", # ⽏ KANGXI RADICAL DO NOT -> 毋
207
+ "\u2f50": "\u6bd4", # ⽐ KANGXI RADICAL COMPARE -> 比
208
+ "\u2f51": "\u6bdb", # ⽑ KANGXI RADICAL FUR -> 毛
209
+ "\u2f52": "\u6c0f", # ⽒ KANGXI RADICAL CLAN -> 氏
210
+ "\u2f53": "\u6c14", # ⽓ KANGXI RADICAL STEAM -> 气
211
+ "\u2f54": "\u6c34", # ⽔ KANGXI RADICAL WATER -> 水
212
+ "\u2f55": "\u706b", # ⽕ KANGXI RADICAL FIRE -> 火
213
+ "\u2f56": "\u722a", # ⽖ KANGXI RADICAL CLAW -> 爪
214
+ "\u2f57": "\u7236", # ⽗ KANGXI RADICAL FATHER -> 父
215
+ "\u2f58": "\u723b", # ⽘ KANGXI RADICAL DOUBLE X -> 爻
216
+ "\u2f59": "\u723f", # ⽙ KANGXI RADICAL HALF TREE TRUNK -> 爿
217
+ "\u2f5a": "\u7247", # ⽚ KANGXI RADICAL SLICE -> 片
218
+ "\u2f5b": "\u7259", # ⽛ KANGXI RADICAL FANG -> 牙
219
+ "\u2f5c": "\u725b", # ⽜ KANGXI RADICAL COW -> 牛
220
+ "\u2f5d": "\u72ac", # ⽝ KANGXI RADICAL DOG -> 犬
221
+ "\u2f5e": "\u7384", # ⽞ KANGXI RADICAL PROFOUND -> 玄
222
+ "\u2f5f": "\u7389", # ⽟ KANGXI RADICAL JADE -> 玉
223
+ "\u2f60": "\u74dc", # ⽠ KANGXI RADICAL MELON -> 瓜
224
+ "\u2f61": "\u74e6", # ⽡ KANGXI RADICAL TILE -> 瓦
225
+ "\u2f62": "\u7518", # ⽢ KANGXI RADICAL SWEET -> 甘
226
+ "\u2f63": "\u751f", # ⽣ KANGXI RADICAL LIFE -> 生
227
+ "\u2f64": "\u7528", # ⽤ KANGXI RADICAL USE -> 用
228
+ "\u2f65": "\u7530", # ⽥ KANGXI RADICAL FIELD -> 田
229
+ "\u2f66": "\u758b", # ⽦ KANGXI RADICAL BOLT OF CLOTH -> 疋
230
+ "\u2f67": "\u7592", # ⽧ KANGXI RADICAL SICKNESS -> 疒
231
+ "\u2f68": "\u7676", # ⽨ KANGXI RADICAL DOTTED TENT -> 癶
232
+ "\u2f69": "\u767d", # ⽩ KANGXI RADICAL WHITE -> 白
233
+ "\u2f6a": "\u76ae", # ⽪ KANGXI RADICAL SKIN -> 皮
234
+ "\u2f6b": "\u76bf", # ⽫ KANGXI RADICAL DISH -> 皿
235
+ "\u2f6c": "\u76ee", # ⽬ KANGXI RADICAL EYE -> 目
236
+ "\u2f6d": "\u77db", # ⽭ KANGXI RADICAL SPEAR -> 矛
237
+ "\u2f6e": "\u77e2", # ⽮ KANGXI RADICAL ARROW -> 矢
238
+ "\u2f6f": "\u77f3", # ⽯ KANGXI RADICAL STONE -> 石
239
+ "\u2f70": "\u793a", # ⽰ KANGXI RADICAL SPIRIT -> 示
240
+ "\u2f71": "\u79b8", # ⽱ KANGXI RADICAL TRACK -> 禸
241
+ "\u2f72": "\u79be", # ⽲ KANGXI RADICAL GRAIN -> 禾
242
+ "\u2f73": "\u7a74", # ⽳ KANGXI RADICAL CAVE -> 穴
243
+ "\u2f74": "\u7acb", # ⽴ KANGXI RADICAL STAND -> 立
244
+ "\u2f75": "\u7af9", # ⽵ KANGXI RADICAL BAMBOO -> 竹
245
+ "\u2f76": "\u7c73", # ⽶ KANGXI RADICAL RICE -> 米
246
+ "\u2f77": "\u7cf8", # ⽷ KANGXI RADICAL SILK -> 糸
247
+ "\u2f78": "\u7f36", # ⽸ KANGXI RADICAL JAR -> 缶
248
+ "\u2f79": "\u7f51", # ⽹ KANGXI RADICAL NET -> 网
249
+ "\u2f7a": "\u7f8a", # ⽺ KANGXI RADICAL SHEEP -> 羊
250
+ "\u2f7b": "\u7fbd", # ⽻ KANGXI RADICAL FEATHER -> 羽
251
+ "\u2f7c": "\u8001", # ⽼ KANGXI RADICAL OLD -> 老
252
+ "\u2f7d": "\u800c", # ⽽ KANGXI RADICAL AND -> 而
253
+ "\u2f7e": "\u8012", # ⽾ KANGXI RADICAL PLOW -> 耒
254
+ "\u2f7f": "\u8033", # ⽿ KANGXI RADICAL EAR -> 耳
255
+ "\u2f80": "\u807f", # ⾀ KANGXI RADICAL BRUSH -> 聿
256
+ "\u2f81": "\u8089", # ⾁ KANGXI RADICAL MEAT -> 肉
257
+ "\u2f82": "\u81e3", # ⾂ KANGXI RADICAL MINISTER -> 臣
258
+ "\u2f83": "\u81ea", # ⾃ KANGXI RADICAL SELF -> 自
259
+ "\u2f84": "\u81f3", # ⾄ KANGXI RADICAL ARRIVE -> 至
260
+ "\u2f85": "\u81fc", # ⾅ KANGXI RADICAL MORTAR -> 臼
261
+ "\u2f86": "\u820c", # ⾆ KANGXI RADICAL TONGUE -> 舌
262
+ "\u2f87": "\u821b", # ⾇ KANGXI RADICAL OPPOSE -> 舛
263
+ "\u2f88": "\u821f", # ⾈ KANGXI RADICAL BOAT -> 舟
264
+ "\u2f89": "\u826e", # ⾉ KANGXI RADICAL STOPPING -> 艮
265
+ "\u2f8a": "\u8272", # ⾊ KANGXI RADICAL COLOR -> 色
266
+ "\u2f8b": "\u8278", # ⾋ KANGXI RADICAL GRASS -> 艸
267
+ "\u2f8c": "\u864d", # ⾌ KANGXI RADICAL TIGER -> 虍
268
+ "\u2f8d": "\u866b", # ⾍ KANGXI RADICAL INSECT -> 虫
269
+ "\u2f8e": "\u8840", # ⾎ KANGXI RADICAL BLOOD -> 血
270
+ "\u2f8f": "\u884c", # ⾏ KANGXI RADICAL WALK ENCLOSURE -> 行
271
+ "\u2f90": "\u8863", # ⾐ KANGXI RADICAL CLOTHES -> 衣
272
+ "\u2f91": "\u897e", # ⾑ KANGXI RADICAL WEST -> 襾
273
+ "\u2f92": "\u898b", # ⾒ KANGXI RADICAL SEE -> 見
274
+ "\u2f93": "\u89d2", # ⾓ KANGXI RADICAL HORN -> 角
275
+ "\u2f94": "\u8a00", # ⾔ KANGXI RADICAL SPEECH -> 言
276
+ "\u2f95": "\u8c37", # ⾕ KANGXI RADICAL VALLEY -> 谷
277
+ "\u2f96": "\u8c46", # ⾖ KANGXI RADICAL BEAN -> 豆
278
+ "\u2f97": "\u8c55", # ⾗ KANGXI RADICAL PIG -> 豕
279
+ "\u2f98": "\u8c78", # ⾘ KANGXI RADICAL BADGER -> 豸
280
+ "\u2f99": "\u8c9d", # ⾙ KANGXI RADICAL SHELL -> 貝
281
+ "\u2f9a": "\u8d64", # ⾚ KANGXI RADICAL RED -> 赤
282
+ "\u2f9b": "\u8d70", # ⾛ KANGXI RADICAL RUN -> 走
283
+ "\u2f9c": "\u8db3", # ⾜ KANGXI RADICAL FOOT -> 足
284
+ "\u2f9d": "\u8eab", # ⾝ KANGXI RADICAL BODY -> 身
285
+ "\u2f9e": "\u8eca", # ⾞ KANGXI RADICAL CART -> 車
286
+ "\u2f9f": "\u8f9b", # ⾟ KANGXI RADICAL BITTER -> 辛
287
+ "\u2fa0": "\u8fb0", # ⾠ KANGXI RADICAL MORNING -> 辰
288
+ "\u2fa1": "\u8fb5", # ⾡ KANGXI RADICAL WALK -> 辵
289
+ "\u2fa2": "\u9091", # ⾢ KANGXI RADICAL CITY -> 邑
290
+ "\u2fa3": "\u9149", # ⾣ KANGXI RADICAL WINE -> 酉
291
+ "\u2fa4": "\u91c6", # ⾤ KANGXI RADICAL DISTINGUISH -> 釆
292
+ "\u2fa5": "\u91cc", # ⾥ KANGXI RADICAL VILLAGE -> 里
293
+ "\u2fa6": "\u91d1", # ⾦ KANGXI RADICAL GOLD -> 金
294
+ "\u2fa7": "\u9577", # ⾧ KANGXI RADICAL LONG -> 長
295
+ "\u2fa8": "\u9580", # ⾨ KANGXI RADICAL GATE -> 門
296
+ "\u2fa9": "\u961c", # ⾩ KANGXI RADICAL MOUND -> 阜
297
+ "\u2faa": "\u96b6", # ⾪ KANGXI RADICAL SLAVE -> 隶
298
+ "\u2fab": "\u96b9", # ⾫ KANGXI RADICAL SHORT TAILED BIRD -> 隹
299
+ "\u2fac": "\u96e8", # ⾬ KANGXI RADICAL RAIN -> 雨
300
+ "\u2fad": "\u9751", # ⾭ KANGXI RADICAL BLUE -> 靑
301
+ "\u2fae": "\u975e", # ⾮ KANGXI RADICAL WRONG -> 非
302
+ "\u2faf": "\u9762", # ⾯ KANGXI RADICAL FACE -> 面
303
+ "\u2fb0": "\u9769", # ⾰ KANGXI RADICAL LEATHER -> 革
304
+ "\u2fb1": "\u97cb", # ⾱ KANGXI RADICAL TANNED LEATHER -> 韋
305
+ "\u2fb2": "\u97ed", # ⾲ KANGXI RADICAL LEEK -> 韭
306
+ "\u2fb3": "\u97f3", # ⾳ KANGXI RADICAL SOUND -> 音
307
+ "\u2fb4": "\u9801", # ⾴ KANGXI RADICAL LEAF -> 頁
308
+ "\u2fb5": "\u98a8", # ⾵ KANGXI RADICAL WIND -> 風
309
+ "\u2fb6": "\u98db", # ⾶ KANGXI RADICAL FLY -> 飛
310
+ "\u2fb7": "\u98df", # ⾷ KANGXI RADICAL EAT -> 食
311
+ "\u2fb8": "\u9996", # ⾸ KANGXI RADICAL HEAD -> 首
312
+ "\u2fb9": "\u9999", # ⾹ KANGXI RADICAL FRAGRANT -> 香
313
+ "\u2fba": "\u99ac", # ⾺ KANGXI RADICAL HORSE -> 馬
314
+ "\u2fbb": "\u9aa8", # ⾻ KANGXI RADICAL BONE -> 骨
315
+ "\u2fbc": "\u9ad8", # ⾼ KANGXI RADICAL TALL -> 高
316
+ "\u2fbd": "\u9adf", # ⾽ KANGXI RADICAL HAIR -> 髟
317
+ "\u2fbe": "\u9b25", # ⾾ KANGXI RADICAL FIGHT -> 鬥
318
+ "\u2fbf": "\u9b2f", # ⾿ KANGXI RADICAL SACRIFICIAL WINE -> 鬯
319
+ "\u2fc0": "\u9b32", # ⿀ KANGXI RADICAL CAULDRON -> 鬲
320
+ "\u2fc1": "\u9b3c", # ⿁ KANGXI RADICAL GHOST -> 鬼
321
+ "\u2fc2": "\u9b5a", # ⿂ KANGXI RADICAL FISH -> 魚
322
+ "\u2fc3": "\u9ce5", # ⿃ KANGXI RADICAL BIRD -> 鳥
323
+ "\u2fc4": "\u9e75", # ⿄ KANGXI RADICAL SALT -> 鹵
324
+ "\u2fc5": "\u9e7f", # ⿅ KANGXI RADICAL DEER -> 鹿
325
+ "\u2fc6": "\u9ea5", # ⿆ KANGXI RADICAL WHEAT -> 麥
326
+ "\u2fc7": "\u9ebb", # ⿇ KANGXI RADICAL HEMP -> 麻
327
+ "\u2fc8": "\u9ec3", # ⿈ KANGXI RADICAL YELLOW -> 黃
328
+ "\u2fc9": "\u9ecd", # ⿉ KANGXI RADICAL MILLET -> 黍
329
+ "\u2fca": "\u9ed1", # ⿊ KANGXI RADICAL BLACK -> 黑
330
+ "\u2fcb": "\u9ef9", # ⿋ KANGXI RADICAL EMBROIDERY -> 黹
331
+ "\u2fcc": "\u9efd", # ⿌ KANGXI RADICAL FROG -> 黽
332
+ "\u2fcd": "\u9f0e", # ⿍ KANGXI RADICAL TRIPOD -> 鼎
333
+ "\u2fce": "\u9f13", # ⿎ KANGXI RADICAL DRUM -> 鼓
334
+ "\u2fcf": "\u9f20", # ⿏ KANGXI RADICAL RAT -> 鼠
335
+ "\u2fd0": "\u9f3b", # ⿐ KANGXI RADICAL NOSE -> 鼻
336
+ "\u2fd1": "\u9f4a", # ⿑ KANGXI RADICAL EVEN -> 齊
337
+ "\u2fd2": "\u9f52", # ⿒ KANGXI RADICAL TOOTH -> 齒
338
+ "\u2fd3": "\u9f8d", # ⿓ KANGXI RADICAL DRAGON -> 龍
339
+ "\u2fd4": "\u9f9c", # ⿔ KANGXI RADICAL TURTLE -> 龜
340
+ "\u2fd5": "\u9fa0", # ⿕ KANGXI RADICAL FLUTE -> 龠
341
+ "\u31c6": "\U000200cc", # ㇆ CJK STROKE HZG -> 𠃌 [U+200CC]
342
+ "\u31cf": "\u4e40", # ㇏ CJK STROKE N -> 乀
343
+ "\u31d0": "\u4e00", # ㇐ CJK STROKE H -> 一
344
+ "\u31d1": "\u4e28", # ㇑ CJK STROKE S -> 丨
345
+ "\u31d2": "\u4e3f", # ㇒ CJK STROKE P -> 丿
346
+ "\u31d3": "\u4e3f", # ㇓ CJK STROKE SP -> 丿
347
+ "\u31d4": "\u4e36", # ㇔ CJK STROKE D -> 丶
348
+ "\u31d5": "\U000200cd", # ㇕ CJK STROKE HZ -> 𠃍 [U+200CD]
349
+ "\u31d6": "\u4e5b", # ㇖ CJK STROKE HG -> 乛
350
+ "\u31d7": "\U000200ca", # ㇗ CJK STROKE SZ -> 𠃊 [U+200CA]
351
+ "\u31d8": "\U000200ce", # ㇘ CJK STROKE SWZ -> 𠃎 [U+200CE]
352
+ "\u31d9": "\U0002010c", # ㇙ CJK STROKE ST -> 𠄌 [U+2010C]
353
+ "\u31da": "\u4e85", # ㇚ CJK STROKE SG -> 亅
354
+ "\u31db": "\U00021fe8", # ㇛ CJK STROKE PD -> 𡿨 [U+21FE8]
355
+ "\u31dc": "\U000200cb", # ㇜ CJK STROKE PZ -> 𠃋 [U+200CB]
356
+ "\u31dd": "\u4e40", # ㇝ CJK STROKE TN -> 乀
357
+ "\u31de": "\U000200d1", # ㇞ CJK STROKE SZZ -> 𠃑 [U+200D1]
358
+ "\u31df": "\u4e5a", # ㇟ CJK STROKE SWG -> 乚
359
+ "\u31e0": "\u4e59", # ㇠ CJK STROKE HXWG -> 乙
360
+ "\u31e1": "\U0002010e", # ㇡ CJK STROKE HZZZG -> 𠄎 [U+2010E]
361
+ }
cjkfold/cli.py ADDED
@@ -0,0 +1,110 @@
1
+ """Command line interface for cjkfold.
2
+
3
+ cjkfold check FILE... report look-alike characters
4
+ cjkfold fold FILE... print (or rewrite) folded text
5
+
6
+ ``check`` exits non-zero when it finds anything, so it can be dropped into a
7
+ build or ingest pipeline to catch PDF extraction regressions early.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Sequence
16
+
17
+ from .normalize import fold, iter_lookalikes
18
+
19
+ __all__ = ["main"]
20
+
21
+
22
+ def _read(path: Path) -> str:
23
+ return path.read_text(encoding="utf-8", errors="replace")
24
+
25
+
26
+ def _cmd_check(args: argparse.Namespace) -> int:
27
+ total = 0
28
+ for name in args.paths:
29
+ path = Path(name)
30
+ text = _read(path)
31
+ found = list(iter_lookalikes(text))
32
+ total += len(found)
33
+ if not found:
34
+ if not args.quiet:
35
+ print(f"{path}: clean")
36
+ continue
37
+ kinds: dict[str, int] = {}
38
+ for item in found:
39
+ kinds[item.kind] = kinds.get(item.kind, 0) + 1
40
+ summary = ", ".join(f"{count} {kind}" for kind, count in sorted(kinds.items()))
41
+ print(f"{path}: {len(found)} look-alike character(s) ({summary})")
42
+ for item in found[: args.max] if args.max else found:
43
+ print(f" {item}")
44
+ if args.max and len(found) > args.max:
45
+ print(f" ... and {len(found) - args.max} more (raise --max to see them)")
46
+ if total:
47
+ print(f"\n{total} character(s) would change under `cjkfold fold`.")
48
+ return 1
49
+ return 0
50
+
51
+
52
+ def _cmd_fold(args: argparse.Namespace) -> int:
53
+ for name in args.paths:
54
+ path = Path(name)
55
+ folded = fold(_read(path))
56
+ if args.write:
57
+ path.write_text(folded, encoding="utf-8")
58
+ print(f"{path}: rewritten", file=sys.stderr)
59
+ else:
60
+ if len(args.paths) > 1:
61
+ print(f"===== {path} =====", file=sys.stderr)
62
+ sys.stdout.write(folded)
63
+ return 0
64
+
65
+
66
+ def build_parser() -> argparse.ArgumentParser:
67
+ parser = argparse.ArgumentParser(
68
+ prog="cjkfold",
69
+ description="Fold look-alike CJK glyphs onto their canonical ideograph.",
70
+ )
71
+ parser.add_argument("--version", action="version", version="cjkfold 0.1.0")
72
+ sub = parser.add_subparsers(dest="command", required=True)
73
+
74
+ check = sub.add_parser(
75
+ "check",
76
+ help="report look-alike characters (exit 1 if any are found)",
77
+ )
78
+ check.add_argument("paths", nargs="+", help="files to inspect, or - for stdin")
79
+ check.add_argument("--quiet", action="store_true", help="only report files with findings")
80
+ check.add_argument("--max", type=int, default=20, help="max findings to print per file (0 = all)")
81
+ check.set_defaults(func=_cmd_check)
82
+
83
+ fold_cmd = sub.add_parser("fold", help="write folded text to stdout")
84
+ fold_cmd.add_argument("paths", nargs="+", help="files to fold, or - for stdin")
85
+ fold_cmd.add_argument("-w", "--write", action="store_true", help="rewrite the files in place")
86
+ fold_cmd.set_defaults(func=_cmd_fold)
87
+ return parser
88
+
89
+
90
+ def main(argv: Sequence[str] | None = None) -> int:
91
+ parser = build_parser()
92
+ args = parser.parse_args(argv)
93
+ if getattr(args, "paths", None) and "-" in args.paths:
94
+ text = sys.stdin.read()
95
+ args.paths = [p for p in args.paths if p != "-"]
96
+ if args.command == "fold":
97
+ sys.stdout.write(fold(text))
98
+ return 0
99
+ found = list(iter_lookalikes(text))
100
+ for item in found[: args.max] if args.max else found:
101
+ print(f" <stdin> {item}")
102
+ if args.max and len(found) > args.max:
103
+ print(f" ... and {len(found) - args.max} more")
104
+ print(f"<stdin>: {len(found)} look-alike character(s)")
105
+ return 1 if found else 0
106
+ return args.func(args)
107
+
108
+
109
+ if __name__ == "__main__": # pragma: no cover
110
+ raise SystemExit(main())
@@ -0,0 +1,126 @@
1
+ """Framework-agnostic adapters for the two places folding has to happen.
2
+
3
+ Folding belongs on *both* sides of a retrieval pipeline -- on documents as they
4
+ go into the index, and on the query as it comes out -- and a dense score should
5
+ not be trusted until the exact-match boost is added to it. That is three touch
6
+ points, and none of them is specific to any framework.
7
+
8
+ These helpers therefore import nothing. A LangChain ``Document`` carries
9
+ ``page_content``; a LlamaIndex node carries ``text``; both are ordinary
10
+ attribute lookups, so a duck-typed adapter covers them. Making the caller
11
+ install a whole framework in order to fold a string would be a bad trade --
12
+ framework APIs move, string folding does not.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import copy
18
+ from typing import Any, Iterable, Sequence, TypeVar
19
+
20
+ from .normalize import fold
21
+ from .score import exact_boost
22
+
23
+ __all__ = ["fold_documents", "rerank", "text_of"]
24
+
25
+ T = TypeVar("T")
26
+
27
+ # Attributes that carry the text, in priority order. ``page_content`` is
28
+ # LangChain, ``text`` is LlamaIndex; ``content`` catches the rest.
29
+ _TEXT_ATTRIBUTES = ("page_content", "text", "content")
30
+
31
+
32
+ def text_of(document: Any) -> str:
33
+ """Return the text carried by *document*, or raise ``TypeError``.
34
+
35
+ Recognises plain strings and objects exposing ``page_content``, ``text`` or
36
+ ``content`` -- which covers LangChain documents, LlamaIndex nodes, and most
37
+ home-grown record types.
38
+ """
39
+ if isinstance(document, str):
40
+ return document
41
+ for attribute in _TEXT_ATTRIBUTES:
42
+ value = getattr(document, attribute, None)
43
+ if isinstance(value, str):
44
+ return value
45
+ raise TypeError(
46
+ "cannot find text on "
47
+ f"{type(document).__name__}: expected a str or one of {_TEXT_ATTRIBUTES}"
48
+ )
49
+
50
+
51
+ def _shallow_copy(document: Any) -> Any:
52
+ try:
53
+ return copy.copy(document)
54
+ except Exception: # pragma: no cover - exotic objects without copy support
55
+ return document
56
+
57
+
58
+ def fold_document(document: T, *, inplace: bool = False) -> T:
59
+ """Fold the text of one document.
60
+
61
+ Returns a shallow copy by default, so callers can use it in a comprehension
62
+ without mutating the objects they were handed. Pass ``inplace=True`` when
63
+ the original is disposable.
64
+ """
65
+ target = document if inplace else _shallow_copy(document)
66
+
67
+ if isinstance(target, str):
68
+ return fold(target) # type: ignore[return-value]
69
+
70
+ for attribute in _TEXT_ATTRIBUTES:
71
+ value = getattr(target, attribute, None)
72
+ if isinstance(value, str):
73
+ folded = fold(value)
74
+ if folded is not value:
75
+ setattr(target, attribute, folded)
76
+ return target
77
+
78
+ raise TypeError(
79
+ "cannot find text on "
80
+ f"{type(document).__name__}: expected a str or one of {_TEXT_ATTRIBUTES}"
81
+ )
82
+
83
+
84
+ def fold_documents(documents: Iterable[T], *, inplace: bool = False) -> list[T]:
85
+ """Fold the text of every document -- the ingestion-side pass.
86
+
87
+ Only the text attribute is touched; ``metadata`` and everything else is left
88
+ exactly as it was.
89
+ """
90
+ return [fold_document(document, inplace=inplace) for document in documents]
91
+
92
+
93
+ def rerank(
94
+ query: str,
95
+ documents: Sequence[T],
96
+ scores: Sequence[float],
97
+ *,
98
+ cap: float = 0.25,
99
+ n: int = 4,
100
+ ) -> list[tuple[T, float]]:
101
+ """Add the exact-match boost to *scores* and return results best-first.
102
+
103
+ This is the fix for the ranking inversion described in :mod:`cjkfold.score`:
104
+ a short, unrelated document out-scores a long document that contains the
105
+ query verbatim, purely because its embedding is tighter. Give this the same
106
+ query, documents and vector scores your store returned::
107
+
108
+ hits = vectorstore.similarity_search_with_score(query, k=20)
109
+ docs, scores = zip(*hits)
110
+ for document, score in rerank(query, docs, scores):
111
+ ...
112
+
113
+ The query is folded internally, so it does not matter whether it was typed
114
+ with ordinary characters or with look-alikes.
115
+ """
116
+ if len(documents) != len(scores):
117
+ raise ValueError(
118
+ f"documents and scores must be the same length "
119
+ f"(got {len(documents)} and {len(scores)})"
120
+ )
121
+ combined = [
122
+ (document, float(score) + exact_boost(query, text_of(document), n=n, cap=cap))
123
+ for document, score in zip(documents, scores)
124
+ ]
125
+ combined.sort(key=lambda pair: pair[1], reverse=True)
126
+ return combined
cjkfold/normalize.py ADDED
@@ -0,0 +1,138 @@
1
+ """Fold look-alike CJK glyphs onto their canonical ideograph.
2
+
3
+ Text extracted from PDFs -- and from some OCR pipelines -- regularly contains
4
+ characters that *render* identically to an ordinary Han character while sitting
5
+ on a different code point::
6
+
7
+ ⼀ U+2F00 KANGXI RADICAL ONE renders as 一 U+4E00
8
+ ⻆ U+2EC6 CJK RADICAL SIMPLIFIED HORN renders as 角 U+89D2
9
+
10
+ Nothing raises. Comparison simply starts disagreeing::
11
+
12
+ >>> "紫色" in extracted_text
13
+ False
14
+
15
+ and a retriever quietly ranks an unrelated short document above the one that
16
+ contains the query verbatim.
17
+
18
+ NFKC already solves the Kangxi block (U+2F00-U+2FDF): all 214 of its characters
19
+ carry compatibility decompositions. It does *not* solve the CJK Radicals
20
+ Supplement (U+2E80-U+2EFF), where 113 of the 115 named characters survive NFKC
21
+ untouched -- Unicode withholds compatibility mappings there deliberately, since
22
+ those glyphs are distinct encoded radicals rather than compatibility variants.
23
+
24
+ This module closes that gap using the ``EquivalentUnifiedIdeograph`` property
25
+ from the Unicode Character Database (Unicode 15.1+).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import unicodedata
31
+ from dataclasses import dataclass
32
+ from typing import Iterator
33
+
34
+ from ._table import EQUIVALENT_IDEOGRAPH
35
+
36
+ __all__ = [
37
+ "fold",
38
+ "has_lookalikes",
39
+ "iter_lookalikes",
40
+ "Lookalike",
41
+ "kind_of",
42
+ ]
43
+
44
+ # str.translate() mapping: code point -> canonical form
45
+ _FOLD: dict[int, str] = {ord(char): target for char, target in EQUIVALENT_IDEOGRAPH.items()}
46
+
47
+ # Radic supplement: the block NFKC cannot repair.
48
+ _RADICAL_SUPPLEMENT = (0x2E80, 0x2EFF)
49
+ # Kangxi radicals: NFKC repairs these, included for completeness / older engines.
50
+ _KANGXI = (0x2F00, 0x2FDF)
51
+ # CJK strokes: stroke glyphs that fold onto a look-alike ideograph.
52
+ _CJK_STROKE = (0x31C0, 0x31EF)
53
+
54
+
55
+ def kind_of(codepoint: int) -> str:
56
+ """Classify a code point by the Unicode block that causes the look-alike."""
57
+ if _RADICAL_SUPPLEMENT[0] <= codepoint <= _RADICAL_SUPPLEMENT[1]:
58
+ return "radical-supplement"
59
+ if _KANGXI[0] <= codepoint <= _KANGXI[1]:
60
+ return "kangxi"
61
+ if _CJK_STROKE[0] <= codepoint <= _CJK_STROKE[1]:
62
+ return "cjk-stroke"
63
+ return "other"
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Lookalike:
68
+ """One character that renders like a different, more common character."""
69
+
70
+ index: int
71
+ line: int
72
+ column: int
73
+ char: str
74
+ codepoint: int
75
+ replacement: str
76
+ name: str
77
+ kind: str
78
+
79
+ @property
80
+ def codepoint_label(self) -> str:
81
+ return f"U+{self.codepoint:04X}"
82
+
83
+ @property
84
+ def replacement_label(self) -> str:
85
+ return f"U+{ord(self.replacement):04X}"
86
+
87
+ def __str__(self) -> str: # pragma: no cover - trivial
88
+ return (
89
+ f"{self.line}:{self.column} {self.char} {self.codepoint_label} "
90
+ f"-> {self.replacement} {self.replacement_label} ({self.name})"
91
+ )
92
+
93
+
94
+ def fold(text: str) -> str:
95
+ """Return *text* with every look-alike glyph replaced by its canonical form.
96
+
97
+ Purely a character-level fold: it never rewrites full-width punctuation,
98
+ ligatures or other NFKC compatibility characters, so it is safe to apply to
99
+ text that will still be displayed or stored. Use
100
+ ``unicodedata.normalize("NFKC", ...)`` as well if you also want those.
101
+
102
+ Idempotent -- ``fold(fold(s)) == fold(s)`` -- and returns the original
103
+ string object unchanged when there is nothing to fold.
104
+ """
105
+ if not text:
106
+ return text
107
+ for char in text:
108
+ if ord(char) in _FOLD:
109
+ return text.translate(_FOLD)
110
+ return text
111
+
112
+
113
+ def has_lookalikes(text: str) -> bool:
114
+ """True if *text* contains at least one foldable code point."""
115
+ return any(ord(char) in _FOLD for char in text)
116
+
117
+
118
+ def iter_lookalikes(text: str) -> Iterator[Lookalike]:
119
+ """Yield every foldable character in *text*, in order, with 1-based position."""
120
+ line, column = 1, 1
121
+ for index, char in enumerate(text):
122
+ codepoint = ord(char)
123
+ replacement = _FOLD.get(codepoint)
124
+ if replacement is not None:
125
+ yield Lookalike(
126
+ index=index,
127
+ line=line,
128
+ column=column,
129
+ char=char,
130
+ codepoint=codepoint,
131
+ replacement=replacement,
132
+ name=unicodedata.name(char, "<unnamed>"),
133
+ kind=kind_of(codepoint),
134
+ )
135
+ if char == "\n":
136
+ line, column = line + 1, 1
137
+ else:
138
+ column += 1
cjkfold/score.py ADDED
@@ -0,0 +1,87 @@
1
+ """Score bonuses that restore exact-token ranking in dense retrieval.
2
+
3
+ Embeddings are bad at literal tokens. A rare identifier or short code sits
4
+ almost nowhere useful in vector space, and a *short*, unrelated document can
5
+ easily out-score the long document that contains the token verbatim -- short
6
+ documents have tighter embeddings and therefore inflated cosine similarity.
7
+
8
+ Measured on a real corpus, the document that literally contained the query
9
+ string scored 0.29 while an unrelated short document scored 0.36: the ranking
10
+ was simply inverted.
11
+
12
+ :func:`exact_boost` restores the ordering by rewarding character n-grams of the
13
+ query that occur verbatim in the document. Add the result to your vector score
14
+ and re-rank.
15
+
16
+ Both sides are folded through :func:`cjkfold.fold` internally. Folding only the
17
+ index, or only the query, silently disables the whole mechanism -- so the
18
+ helper does it for you and the API gives you no way to get it wrong.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Iterable
24
+
25
+ from .normalize import fold
26
+
27
+ __all__ = ["ngram_overlap", "exact_boost", "ngrams"]
28
+
29
+
30
+ def ngrams(text: str, n: int = 4) -> set[str]:
31
+ """Return the set of character *n*-grams of *text*.
32
+
33
+ Text shorter than *n* yields the whole string (or nothing, if empty), so a
34
+ two-character query still produces one comparison unit.
35
+ """
36
+ if n < 1:
37
+ raise ValueError("n must be >= 1")
38
+ folded = fold(text)
39
+ if not folded:
40
+ return set()
41
+ if len(folded) <= n:
42
+ return {folded}
43
+ return {folded[i : i + n] for i in range(len(folded) - n + 1)}
44
+
45
+
46
+ def ngram_overlap(query: str, document: str, *, n: int = 4) -> float:
47
+ """Fraction of the query's n-grams that appear verbatim in *document* (0..1).
48
+
49
+ Queries shorter than *n* degrade to a containment test, because a query of
50
+ two or three characters yields no shingle to compare. Without that branch
51
+ every short query scores 0.0 -- the mechanism silently does nothing for
52
+ exactly the queries users type most.
53
+ """
54
+ query_folded = fold(query)
55
+ if not query_folded:
56
+ return 0.0
57
+ document_folded = fold(document)
58
+
59
+ if len(query_folded) <= n:
60
+ return 1.0 if query_folded in document_folded else 0.0
61
+
62
+ query_grams = ngrams(query_folded, n)
63
+ if not query_grams:
64
+ return 0.0
65
+ document_grams = ngrams(document_folded, n)
66
+ return len(query_grams & document_grams) / len(query_grams)
67
+
68
+
69
+ def exact_boost(
70
+ query: str,
71
+ document: str,
72
+ *,
73
+ n: int = 4,
74
+ cap: float = 0.25,
75
+ ) -> float:
76
+ """Bonus in ``[0, cap]`` for literal n-gram overlap between query and document.
77
+
78
+ Scales linearly with :func:`ngram_overlap`, so a document containing the
79
+ entire query earns the full ``cap`` while one sharing half of it earns half.
80
+
81
+ Typical use::
82
+
83
+ score = vector_similarity(query, document) + exact_boost(query, document)
84
+ """
85
+ if cap < 0:
86
+ raise ValueError("cap must be >= 0")
87
+ return cap * ngram_overlap(query, document, n=n)
@@ -0,0 +1,247 @@
1
+ Metadata-Version: 2.4
2
+ Name: cjkfold
3
+ Version: 0.1.0
4
+ Summary: Fold look-alike CJK glyphs (Kangxi radicals, CJK radical supplements) so text extracted from PDFs matches again.
5
+ Author: cdcailf
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/cdcailf/cjkfold
8
+ Project-URL: Repository, https://github.com/cdcailf/cjkfold
9
+ Project-URL: Issues, https://github.com/cdcailf/cjkfold/issues
10
+ Keywords: cjk,chinese,unicode,nfkc,normalization,pdf-extraction,rag,retrieval,text-matching
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Natural Language :: Chinese (Simplified)
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Text Processing :: Linguistic
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # cjkfold
31
+
32
+ Text pulled out of a PDF often contains characters that **look** like ordinary
33
+ Han characters but are not them. Comparison against them quietly fails.
34
+
35
+ ```
36
+ ⻆ U+2EC6 CJK RADICAL SIMPLIFIED HORN renders as 角 U+89D2
37
+ ⾊ U+2F8A KANGXI RADICAL COLOR renders as 色 U+8272
38
+ ⺮ U+2EAE CJK RADICAL BAMBOO folds to 𥫗 U+25AD7
39
+ ```
40
+
41
+ `cjkfold` folds them back onto the characters you meant:
42
+
43
+ ```python
44
+ from cjkfold import fold
45
+
46
+ fold("标准颜⾊值与⻆色按钮")
47
+ # '标准颜色值与角色按钮'
48
+
49
+ "颜色" in "标准颜⾊值" # False ← the bug
50
+ "颜色" in fold("标准颜⾊值") # True
51
+ ```
52
+
53
+ Zero runtime dependencies. Pure functions. Python 3.9+.
54
+
55
+ ---
56
+
57
+ **背景阅读 / Background:** [PDF 抽出来的中文,为什么搜不到](docs/why-cjk-text-breaks.md)
58
+ —— 完整的排查经过(中文),包括我自己踩的四个坑。
59
+ 社区版:[掘金](https://juejin.cn/post/7695671592383807542)
60
+
61
+ **Built on this:** [corpusfold](https://github.com/cdcailf/corpusfold) — this
62
+ character layer wired into a full PDF → knowledge-base pipeline (抽取 / 分栏 /
63
+ 表格 / 分块 / 混合检索).
64
+
65
+ ## The problem
66
+
67
+ Nothing raises. The text displays correctly, because the glyphs really are
68
+ identical. What breaks is everything downstream that compares characters:
69
+
70
+ - `"紫色" in text` returns `False` for a document that shows 紫色
71
+ - a keyword search misses
72
+ - a vector retriever ranks an unrelated *short* document above the one that
73
+ contains the query verbatim, because a short document's embedding is tighter
74
+ and scores a higher cosine — measured on a real corpus, the relevant document
75
+ scored **0.29** against a decoy's **0.36**
76
+ - deduplication, caching and ground-truth checks all start disagreeing
77
+
78
+ ## Why NFKC is not enough
79
+
80
+ The reflex is `unicodedata.normalize("NFKC", text)`. It handles the **Kangxi
81
+ Radicals** block completely — all 214 characters carry compatibility
82
+ decompositions — and then stops:
83
+
84
+ | Block | Characters | Repaired by NFKC | Repaired by cjkfold |
85
+ |---|---|---|---|
86
+ | Kangxi Radicals `U+2F00–U+2FDF` | 214 | **214** | 214 |
87
+ | CJK Radicals Supplement `U+2E80–U+2EFF` | 115 | **2** | **114** |
88
+
89
+ Unicode withholds compatibility mappings in the supplement block *on purpose*:
90
+ those glyphs are distinct encoded radicals, not compatibility variants of an
91
+ existing character. So NFKC leaves 113 of the 115 standing.
92
+
93
+ `cjkfold` closes that gap with the `EquivalentUnifiedIdeograph` property from
94
+ the Unicode Character Database (Unicode 15.1+), which maps 114 of them onto a
95
+ unified ideograph. The one remainder, `⺀ U+2E80 CJK RADICAL REPEAT`, has no
96
+ ideograph equivalent and is left alone.
97
+
98
+ ## Install
99
+
100
+ Not published on PyPI yet, so install from a checkout:
101
+
102
+ ```bash
103
+ git clone https://github.com/cdcailf/cjkfold
104
+ cd cjkfold
105
+ uv venv .venv --python 3.11
106
+ uv pip install -e .
107
+ ```
108
+
109
+ Once it is published this collapses to `pip install cjkfold`.
110
+
111
+ ## Usage
112
+
113
+ ```python
114
+ from cjkfold import fold, has_lookalikes, iter_lookalikes
115
+
116
+ fold("紫⾊的⻆落") # '紫色的角落'
117
+ fold("普通文本") # unchanged; returns the same object
118
+
119
+ has_lookalikes("⻆") # True
120
+ has_lookalikes("角") # False
121
+
122
+ for item in iter_lookalikes("第一行\n第⼆行⻆"):
123
+ print(item)
124
+ # 2:2 ⼆ U+2F06 -> 二 U+4E8C (KANGXI RADICAL TWO)
125
+ # 2:4 ⻆ U+2EC6 -> 角 U+89D2 (CJK RADICAL SIMPLIFIED HORN)
126
+ ```
127
+
128
+ `fold()` is idempotent, and it is deliberately **not** an NFKC pass: it will
129
+ not touch full-width punctuation (`,` `;` `(` `)`), compatibility characters
130
+ or ligatures, so it is safe to apply to text that is still going to be stored or
131
+ displayed. Combine with NFKC yourself if you want both:
132
+
133
+ ```python
134
+ import unicodedata
135
+ clean = fold(unicodedata.normalize("NFKC", text))
136
+ ```
137
+
138
+ ### Command line
139
+
140
+ ```bash
141
+ cjkfold check document.txt # report look-alikes, exit 1 if any (CI-friendly)
142
+ cjkfold check --quiet docs/*.txt
143
+ cjkfold fold document.txt # print the folded text
144
+ cjkfold fold -w document.txt # rewrite in place
145
+ cat document.txt | cjkfold check -
146
+ ```
147
+
148
+ ```
149
+ $ cjkfold check extracted.txt
150
+ extracted.txt: 3 look-alike character(s) (1 kangxi, 2 radical-supplement)
151
+ 1:4 ⾊ U+2F8A -> 色 U+8272 (KANGXI RADICAL COLOR)
152
+ 1:7 ⻆ U+2EC6 -> 角 U+89D2 (CJK RADICAL SIMPLIFIED HORN)
153
+ 2:5 ⺮ U+2EAE -> 𥫗 U+25AD7 (CJK RADICAL BAMBOO)
154
+ ```
155
+
156
+ Drop `cjkfold check` into an ingest pipeline to catch extraction regressions
157
+ before they reach the index.
158
+
159
+ ## Retrieval helper
160
+
161
+ Folding is the fix; ranking is the follow-up. Dense retrieval is bad at literal
162
+ tokens, so `exact_boost` adds a bonus for query n-grams that appear verbatim in a
163
+ document:
164
+
165
+ ```python
166
+ from cjkfold import exact_boost
167
+
168
+ score = vector_similarity(query, document) + exact_boost(query, document)
169
+ ```
170
+
171
+ Both sides are folded inside the helper. That is not a convenience — folding the
172
+ index but not the query (or the reverse) silently disables the whole mechanism,
173
+ because nothing ever matches. The API gives you no way to get it wrong.
174
+
175
+ Queries shorter than the shingle size fall back to a containment test, so a
176
+ two-character query is not silently scored zero.
177
+
178
+ ## Wiring it into a pipeline
179
+
180
+ Three touch points, zero framework dependencies:
181
+
182
+ ```python
183
+ from cjkfold import fold, fold_documents, rerank
184
+
185
+ # 1. on the way in -- clean the corpus before it is embedded
186
+ documents = fold_documents(loader.load())
187
+
188
+ # 2. on the way out -- fold the query so it can match
189
+ hits = vectorstore.similarity_search_with_score(fold(question), k=20)
190
+
191
+ # 3. before you trust the order -- restore the verbatim matches
192
+ documents, scores = zip(*hits)
193
+ for document, score in rerank(question, documents, scores):
194
+ ...
195
+ ```
196
+
197
+ `fold_documents` and `rerank` recognise LangChain documents (`page_content`),
198
+ LlamaIndex nodes (`text`) and plain strings -- and import none of them. Making
199
+ you install a framework in order to fold a string would be a bad trade:
200
+ framework APIs move, string folding does not.
201
+
202
+ ## Caveats
203
+
204
+ - **Glyph folding only.** No traditional⇄simplified conversion, no z-variant
205
+ normalisation. Those are different problems with different trade-offs.
206
+ - **Table follows the latest UCD**, which can run ahead of the `unicodedata`
207
+ database bundled with an older Python — 3.9 ships Unicode 13, where one target
208
+ (`U+2B739`) is not yet assigned. Folding is unaffected (it is a plain code point
209
+ substitution), but `unicodedata.name()` on that result will come up empty.
210
+ - **Existing stored text is not retroactively repaired.** Fold on write; if you
211
+ have already indexed unfolded text, re-ingest or re-fold it.
212
+ - Characters above the BMP are written as `\U000xxxxx` in the table. A `\uXXXX`
213
+ escape silently truncates there and turns one character into two — the table
214
+ test suite guards against exactly that regression.
215
+
216
+ ## Regenerating the table
217
+
218
+ `src/cjkfold/_table.py` is generated, never hand-edited:
219
+
220
+ ```bash
221
+ python scripts/build_table.py # fetch from unicode.org
222
+ python scripts/build_table.py CJKRadicals.txt # or use a local copy
223
+ ```
224
+
225
+ ## Development
226
+
227
+ ```bash
228
+ uv venv .venv --python 3.9
229
+ uv pip install -e ".[dev]"
230
+ .venv/bin/python -m pytest
231
+ ```
232
+
233
+ ## 中文简述
234
+
235
+ PDF 抽出来的中文常常混着**形近异码**字符:`⻆` 和 `角` 长得一样、码位不同,于是
236
+ `"颜色" in 文本` 返回 `False`,检索排错、去重失效、缓存打不中。
237
+
238
+ `unicodedata.normalize("NFKC", ...)` 只能解决康熙部首区(214 个全都能还原),
239
+ **解决不了 CJK 部首补充区**——那个区 115 个字符里 NFKC 只救得了 2 个。
240
+
241
+ `cjkfold` 用 Unicode 官方的 `EquivalentUnifiedIdeograph` 属性补上这个缺口,
242
+ 115 个里能还原 114 个。同时提供 `exact_boost()`,把"查询片段在文档里逐字命中"
243
+ 补进向量相似度,避免短文档靠余弦虚高把真正命中的长文档挤下去。
244
+
245
+ ## License
246
+
247
+ MIT
@@ -0,0 +1,13 @@
1
+ cjkfold/__init__.py,sha256=_EOUkgQR8spIOiVIGB0wRWUyCKNI2EpUx-5dEmoGhJg,968
2
+ cjkfold/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ cjkfold/_table.py,sha256=4dDN1c7igxcWOcwAbP7-bG1g_OuCtJO_DCdGvKPHRVo,21548
4
+ cjkfold/cli.py,sha256=vJS3iK7ZwBOGKQeziN5RH9xCDHYvdx5LTTPulXZyv3g,3923
5
+ cjkfold/integrations.py,sha256=NMZzDJzTHwF49aEcJW9BKiOvLnblfxV724UhPJkJauw,4487
6
+ cjkfold/normalize.py,sha256=Fd5VTWeaFqPVZPHhUdoitRc6UoA22etm_jTLUAhjI_A,4526
7
+ cjkfold/score.py,sha256=zVICOIjegNUGz_2kH8lwsBF0lCDqPU1lhIc5DkPW0iM,3015
8
+ cjkfold-0.1.0.dist-info/licenses/LICENSE,sha256=ocrzPN24a0cu0LMN4cam137idMPpCS_nP8Iex87K2MM,1064
9
+ cjkfold-0.1.0.dist-info/METADATA,sha256=QlTnPgx9-Xnrnak3Vr7-2M8iNyyeXjnj3Qs49mAIOTo,9130
10
+ cjkfold-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ cjkfold-0.1.0.dist-info/entry_points.txt,sha256=H1ubhADv91yVhByDxooDt1xHX_caN-XEEy3qw9JZXyE,45
12
+ cjkfold-0.1.0.dist-info/top_level.txt,sha256=dxf3JC3zYsVaUv9MGR-V0xaF6JnPC8LAJI_TghpK5d8,8
13
+ cjkfold-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cjkfold = cjkfold.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cdcailf
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ cjkfold