cjkfold 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- cjkfold-0.1.0/PKG-INFO +247 -0
- cjkfold-0.1.0/README.md +218 -0
- cjkfold-0.1.0/pyproject.toml +48 -0
- cjkfold-0.1.0/setup.cfg +4 -0
- cjkfold-0.1.0/src/cjkfold/__init__.py +36 -0
- cjkfold-0.1.0/src/cjkfold/__main__.py +3 -0
- cjkfold-0.1.0/src/cjkfold/_table.py +361 -0
- cjkfold-0.1.0/src/cjkfold/cli.py +110 -0
- cjkfold-0.1.0/src/cjkfold/integrations.py +126 -0
- cjkfold-0.1.0/src/cjkfold/normalize.py +138 -0
- cjkfold-0.1.0/src/cjkfold/score.py +87 -0
- cjkfold-0.1.0/src/cjkfold.egg-info/PKG-INFO +247 -0
- cjkfold-0.1.0/src/cjkfold.egg-info/SOURCES.txt +20 -0
- cjkfold-0.1.0/src/cjkfold.egg-info/dependency_links.txt +1 -0
- cjkfold-0.1.0/src/cjkfold.egg-info/entry_points.txt +2 -0
- cjkfold-0.1.0/src/cjkfold.egg-info/requires.txt +3 -0
- cjkfold-0.1.0/src/cjkfold.egg-info/top_level.txt +1 -0
- cjkfold-0.1.0/tests/test_integrations.py +144 -0
- cjkfold-0.1.0/tests/test_normalize.py +123 -0
- cjkfold-0.1.0/tests/test_score.py +91 -0
- cjkfold-0.1.0/tests/test_table.py +81 -0
cjkfold-0.1.0/LICENSE
ADDED
|
@@ -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.
|
cjkfold-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
cjkfold-0.1.0/README.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# cjkfold
|
|
2
|
+
|
|
3
|
+
Text pulled out of a PDF often contains characters that **look** like ordinary
|
|
4
|
+
Han characters but are not them. Comparison against them quietly fails.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
⻆ U+2EC6 CJK RADICAL SIMPLIFIED HORN renders as 角 U+89D2
|
|
8
|
+
⾊ U+2F8A KANGXI RADICAL COLOR renders as 色 U+8272
|
|
9
|
+
⺮ U+2EAE CJK RADICAL BAMBOO folds to 𥫗 U+25AD7
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`cjkfold` folds them back onto the characters you meant:
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from cjkfold import fold
|
|
16
|
+
|
|
17
|
+
fold("标准颜⾊值与⻆色按钮")
|
|
18
|
+
# '标准颜色值与角色按钮'
|
|
19
|
+
|
|
20
|
+
"颜色" in "标准颜⾊值" # False ← the bug
|
|
21
|
+
"颜色" in fold("标准颜⾊值") # True
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Zero runtime dependencies. Pure functions. Python 3.9+.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
**背景阅读 / Background:** [PDF 抽出来的中文,为什么搜不到](docs/why-cjk-text-breaks.md)
|
|
29
|
+
—— 完整的排查经过(中文),包括我自己踩的四个坑。
|
|
30
|
+
社区版:[掘金](https://juejin.cn/post/7695671592383807542)
|
|
31
|
+
|
|
32
|
+
**Built on this:** [corpusfold](https://github.com/cdcailf/corpusfold) — this
|
|
33
|
+
character layer wired into a full PDF → knowledge-base pipeline (抽取 / 分栏 /
|
|
34
|
+
表格 / 分块 / 混合检索).
|
|
35
|
+
|
|
36
|
+
## The problem
|
|
37
|
+
|
|
38
|
+
Nothing raises. The text displays correctly, because the glyphs really are
|
|
39
|
+
identical. What breaks is everything downstream that compares characters:
|
|
40
|
+
|
|
41
|
+
- `"紫色" in text` returns `False` for a document that shows 紫色
|
|
42
|
+
- a keyword search misses
|
|
43
|
+
- a vector retriever ranks an unrelated *short* document above the one that
|
|
44
|
+
contains the query verbatim, because a short document's embedding is tighter
|
|
45
|
+
and scores a higher cosine — measured on a real corpus, the relevant document
|
|
46
|
+
scored **0.29** against a decoy's **0.36**
|
|
47
|
+
- deduplication, caching and ground-truth checks all start disagreeing
|
|
48
|
+
|
|
49
|
+
## Why NFKC is not enough
|
|
50
|
+
|
|
51
|
+
The reflex is `unicodedata.normalize("NFKC", text)`. It handles the **Kangxi
|
|
52
|
+
Radicals** block completely — all 214 characters carry compatibility
|
|
53
|
+
decompositions — and then stops:
|
|
54
|
+
|
|
55
|
+
| Block | Characters | Repaired by NFKC | Repaired by cjkfold |
|
|
56
|
+
|---|---|---|---|
|
|
57
|
+
| Kangxi Radicals `U+2F00–U+2FDF` | 214 | **214** | 214 |
|
|
58
|
+
| CJK Radicals Supplement `U+2E80–U+2EFF` | 115 | **2** | **114** |
|
|
59
|
+
|
|
60
|
+
Unicode withholds compatibility mappings in the supplement block *on purpose*:
|
|
61
|
+
those glyphs are distinct encoded radicals, not compatibility variants of an
|
|
62
|
+
existing character. So NFKC leaves 113 of the 115 standing.
|
|
63
|
+
|
|
64
|
+
`cjkfold` closes that gap with the `EquivalentUnifiedIdeograph` property from
|
|
65
|
+
the Unicode Character Database (Unicode 15.1+), which maps 114 of them onto a
|
|
66
|
+
unified ideograph. The one remainder, `⺀ U+2E80 CJK RADICAL REPEAT`, has no
|
|
67
|
+
ideograph equivalent and is left alone.
|
|
68
|
+
|
|
69
|
+
## Install
|
|
70
|
+
|
|
71
|
+
Not published on PyPI yet, so install from a checkout:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
git clone https://github.com/cdcailf/cjkfold
|
|
75
|
+
cd cjkfold
|
|
76
|
+
uv venv .venv --python 3.11
|
|
77
|
+
uv pip install -e .
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Once it is published this collapses to `pip install cjkfold`.
|
|
81
|
+
|
|
82
|
+
## Usage
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from cjkfold import fold, has_lookalikes, iter_lookalikes
|
|
86
|
+
|
|
87
|
+
fold("紫⾊的⻆落") # '紫色的角落'
|
|
88
|
+
fold("普通文本") # unchanged; returns the same object
|
|
89
|
+
|
|
90
|
+
has_lookalikes("⻆") # True
|
|
91
|
+
has_lookalikes("角") # False
|
|
92
|
+
|
|
93
|
+
for item in iter_lookalikes("第一行\n第⼆行⻆"):
|
|
94
|
+
print(item)
|
|
95
|
+
# 2:2 ⼆ U+2F06 -> 二 U+4E8C (KANGXI RADICAL TWO)
|
|
96
|
+
# 2:4 ⻆ U+2EC6 -> 角 U+89D2 (CJK RADICAL SIMPLIFIED HORN)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`fold()` is idempotent, and it is deliberately **not** an NFKC pass: it will
|
|
100
|
+
not touch full-width punctuation (`,` `;` `(` `)`), compatibility characters
|
|
101
|
+
or ligatures, so it is safe to apply to text that is still going to be stored or
|
|
102
|
+
displayed. Combine with NFKC yourself if you want both:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
import unicodedata
|
|
106
|
+
clean = fold(unicodedata.normalize("NFKC", text))
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Command line
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
cjkfold check document.txt # report look-alikes, exit 1 if any (CI-friendly)
|
|
113
|
+
cjkfold check --quiet docs/*.txt
|
|
114
|
+
cjkfold fold document.txt # print the folded text
|
|
115
|
+
cjkfold fold -w document.txt # rewrite in place
|
|
116
|
+
cat document.txt | cjkfold check -
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
$ cjkfold check extracted.txt
|
|
121
|
+
extracted.txt: 3 look-alike character(s) (1 kangxi, 2 radical-supplement)
|
|
122
|
+
1:4 ⾊ U+2F8A -> 色 U+8272 (KANGXI RADICAL COLOR)
|
|
123
|
+
1:7 ⻆ U+2EC6 -> 角 U+89D2 (CJK RADICAL SIMPLIFIED HORN)
|
|
124
|
+
2:5 ⺮ U+2EAE -> 𥫗 U+25AD7 (CJK RADICAL BAMBOO)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Drop `cjkfold check` into an ingest pipeline to catch extraction regressions
|
|
128
|
+
before they reach the index.
|
|
129
|
+
|
|
130
|
+
## Retrieval helper
|
|
131
|
+
|
|
132
|
+
Folding is the fix; ranking is the follow-up. Dense retrieval is bad at literal
|
|
133
|
+
tokens, so `exact_boost` adds a bonus for query n-grams that appear verbatim in a
|
|
134
|
+
document:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from cjkfold import exact_boost
|
|
138
|
+
|
|
139
|
+
score = vector_similarity(query, document) + exact_boost(query, document)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Both sides are folded inside the helper. That is not a convenience — folding the
|
|
143
|
+
index but not the query (or the reverse) silently disables the whole mechanism,
|
|
144
|
+
because nothing ever matches. The API gives you no way to get it wrong.
|
|
145
|
+
|
|
146
|
+
Queries shorter than the shingle size fall back to a containment test, so a
|
|
147
|
+
two-character query is not silently scored zero.
|
|
148
|
+
|
|
149
|
+
## Wiring it into a pipeline
|
|
150
|
+
|
|
151
|
+
Three touch points, zero framework dependencies:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from cjkfold import fold, fold_documents, rerank
|
|
155
|
+
|
|
156
|
+
# 1. on the way in -- clean the corpus before it is embedded
|
|
157
|
+
documents = fold_documents(loader.load())
|
|
158
|
+
|
|
159
|
+
# 2. on the way out -- fold the query so it can match
|
|
160
|
+
hits = vectorstore.similarity_search_with_score(fold(question), k=20)
|
|
161
|
+
|
|
162
|
+
# 3. before you trust the order -- restore the verbatim matches
|
|
163
|
+
documents, scores = zip(*hits)
|
|
164
|
+
for document, score in rerank(question, documents, scores):
|
|
165
|
+
...
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`fold_documents` and `rerank` recognise LangChain documents (`page_content`),
|
|
169
|
+
LlamaIndex nodes (`text`) and plain strings -- and import none of them. Making
|
|
170
|
+
you install a framework in order to fold a string would be a bad trade:
|
|
171
|
+
framework APIs move, string folding does not.
|
|
172
|
+
|
|
173
|
+
## Caveats
|
|
174
|
+
|
|
175
|
+
- **Glyph folding only.** No traditional⇄simplified conversion, no z-variant
|
|
176
|
+
normalisation. Those are different problems with different trade-offs.
|
|
177
|
+
- **Table follows the latest UCD**, which can run ahead of the `unicodedata`
|
|
178
|
+
database bundled with an older Python — 3.9 ships Unicode 13, where one target
|
|
179
|
+
(`U+2B739`) is not yet assigned. Folding is unaffected (it is a plain code point
|
|
180
|
+
substitution), but `unicodedata.name()` on that result will come up empty.
|
|
181
|
+
- **Existing stored text is not retroactively repaired.** Fold on write; if you
|
|
182
|
+
have already indexed unfolded text, re-ingest or re-fold it.
|
|
183
|
+
- Characters above the BMP are written as `\U000xxxxx` in the table. A `\uXXXX`
|
|
184
|
+
escape silently truncates there and turns one character into two — the table
|
|
185
|
+
test suite guards against exactly that regression.
|
|
186
|
+
|
|
187
|
+
## Regenerating the table
|
|
188
|
+
|
|
189
|
+
`src/cjkfold/_table.py` is generated, never hand-edited:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
python scripts/build_table.py # fetch from unicode.org
|
|
193
|
+
python scripts/build_table.py CJKRadicals.txt # or use a local copy
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Development
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
uv venv .venv --python 3.9
|
|
200
|
+
uv pip install -e ".[dev]"
|
|
201
|
+
.venv/bin/python -m pytest
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## 中文简述
|
|
205
|
+
|
|
206
|
+
PDF 抽出来的中文常常混着**形近异码**字符:`⻆` 和 `角` 长得一样、码位不同,于是
|
|
207
|
+
`"颜色" in 文本` 返回 `False`,检索排错、去重失效、缓存打不中。
|
|
208
|
+
|
|
209
|
+
`unicodedata.normalize("NFKC", ...)` 只能解决康熙部首区(214 个全都能还原),
|
|
210
|
+
**解决不了 CJK 部首补充区**——那个区 115 个字符里 NFKC 只救得了 2 个。
|
|
211
|
+
|
|
212
|
+
`cjkfold` 用 Unicode 官方的 `EquivalentUnifiedIdeograph` 属性补上这个缺口,
|
|
213
|
+
115 个里能还原 114 个。同时提供 `exact_boost()`,把"查询片段在文档里逐字命中"
|
|
214
|
+
补进向量相似度,避免短文档靠余弦虚高把真正命中的长文档挤下去。
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
MIT
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cjkfold"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Fold look-alike CJK glyphs (Kangxi radicals, CJK radical supplements) so text extracted from PDFs matches again."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "cdcailf" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"cjk", "chinese", "unicode", "nfkc", "normalization",
|
|
15
|
+
"pdf-extraction", "rag", "retrieval", "text-matching",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Natural Language :: Chinese (Simplified)",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.9",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Topic :: Text Processing :: Linguistic",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/cdcailf/cjkfold"
|
|
34
|
+
Repository = "https://github.com/cdcailf/cjkfold"
|
|
35
|
+
Issues = "https://github.com/cdcailf/cjkfold/issues"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
cjkfold = "cjkfold.cli:main"
|
|
39
|
+
|
|
40
|
+
[project.optional-dependencies]
|
|
41
|
+
dev = ["pytest>=7"]
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.packages.find]
|
|
44
|
+
where = ["src"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
addopts = "-q"
|
cjkfold-0.1.0/setup.cfg
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
|
+
]
|