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 +36 -0
- cjkfold/__main__.py +3 -0
- cjkfold/_table.py +361 -0
- cjkfold/cli.py +110 -0
- cjkfold/integrations.py +126 -0
- cjkfold/normalize.py +138 -0
- cjkfold/score.py +87 -0
- cjkfold-0.1.0.dist-info/METADATA +247 -0
- cjkfold-0.1.0.dist-info/RECORD +13 -0
- cjkfold-0.1.0.dist-info/WHEEL +5 -0
- cjkfold-0.1.0.dist-info/entry_points.txt +2 -0
- cjkfold-0.1.0.dist-info/licenses/LICENSE +21 -0
- cjkfold-0.1.0.dist-info/top_level.txt +1 -0
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
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())
|
cjkfold/integrations.py
ADDED
|
@@ -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,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
|