classics-index 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.
- classics_index-0.1.0/LICENSE +21 -0
- classics_index-0.1.0/PKG-INFO +74 -0
- classics_index-0.1.0/README.md +55 -0
- classics_index-0.1.0/classics_index/__init__.py +6 -0
- classics_index-0.1.0/classics_index/cli.py +202 -0
- classics_index-0.1.0/classics_index/core.py +288 -0
- classics_index-0.1.0/classics_index/hui.py +172 -0
- classics_index-0.1.0/classics_index.egg-info/PKG-INFO +74 -0
- classics_index-0.1.0/classics_index.egg-info/SOURCES.txt +16 -0
- classics_index-0.1.0/classics_index.egg-info/dependency_links.txt +1 -0
- classics_index-0.1.0/classics_index.egg-info/entry_points.txt +2 -0
- classics_index-0.1.0/classics_index.egg-info/requires.txt +5 -0
- classics_index-0.1.0/classics_index.egg-info/top_level.txt +1 -0
- classics_index-0.1.0/pyproject.toml +28 -0
- classics_index-0.1.0/setup.cfg +4 -0
- classics_index-0.1.0/tests/test_cli.py +39 -0
- classics_index-0.1.0/tests/test_core.py +77 -0
- classics_index-0.1.0/tests/test_hui.py +66 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 socratic-tutor contributors
|
|
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,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: classics-index
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Chapter indexes (卷 / 篇 / 回) for scanned Chinese books, built from OCR of the running header
|
|
5
|
+
Author: socratic-tutor contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: chinese-classics,scanned-books,ocr,table-of-contents,pymupdf
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Provides-Extra: ocr
|
|
15
|
+
Requires-Dist: paddleocr>=3; extra == "ocr"
|
|
16
|
+
Requires-Dist: pillow; extra == "ocr"
|
|
17
|
+
Requires-Dist: pymupdf; extra == "ocr"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# classics-index
|
|
21
|
+
|
|
22
|
+
从**扫描本**的版心(页边书眉)OCR 里建章回索引:卷 → 篇 → 回。不依赖任何在线服务,纯 Python,无第三方运行时依赖(OCR 引擎是可选的)。
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
pip install -e .
|
|
26
|
+
classics-index volumes --headers heads.jsonl --out toc.json
|
|
27
|
+
classics-index pian --toc toc.json --band titles.jsonl --out toc-pian.json
|
|
28
|
+
classics-index hui --ocr hui.jsonl --names "1=西游记,2=水浒传" --out hui.json
|
|
29
|
+
classics-index find --toc toc.json --q 卷3
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## 它解决什么问题
|
|
33
|
+
|
|
34
|
+
点校本、影印本没有文本层,目录页也常常 OCR 不可用。但**每一页的页眉都印着卷次**("史記卷二")或回目("灵根育孕源流出心性修持大道生")。于是:
|
|
35
|
+
|
|
36
|
+
| 输入 | 输出 |
|
|
37
|
+
|---|---|
|
|
38
|
+
| 每页页眉带的 OCR(`{"page": n, "vol": 2}`) | 卷的起止页,连续覆盖全书 |
|
|
39
|
+
| 每页右侧标题带的 OCR(`{"page": n, "lines": [...]}`) | 篇名页与篇页码范围 |
|
|
40
|
+
| 章回小说每页页眉 OCR(`{"page": n, "texts": [...]}`) | 回目分段、附录标记、多部书切分 |
|
|
41
|
+
|
|
42
|
+
## 设计上的三个关键判据
|
|
43
|
+
|
|
44
|
+
1. **卷靠"连续 ≥5 页"**:目录页也会写出"史記卷十三",那种孤页会被丢掉。
|
|
45
|
+
2. **篇靠"行的形态"而不是字号**:实测卷眉(48 px 宽)比篇名(46 px)还宽,宽度阈值不可靠;可靠的是"短行 + 序号结尾干净"(目录行末尾是":"或省略号)。
|
|
46
|
+
3. **回靠"页眉标题变化"**:章回小说每页页眉都印着当前回目,标题一变就是新回;附录("附/录")重复上一回页眉,不能占回号。
|
|
47
|
+
|
|
48
|
+
## 数据从哪来
|
|
49
|
+
|
|
50
|
+
配套的 OCR 脚本在本仓库的 `eval/p3/`(`vertical_header_ocr.py`、`vertical_title_ocr.py`、`novel_hui_ocr.py`),它们只是 PaddleOCR 的一层薄封装:
|
|
51
|
+
|
|
52
|
+
- 版心带:页面右侧 18%、高度 8%–55%,1.2 s/页;
|
|
53
|
+
- 标题带:右侧 42%、高度 3%–65%,3.5 s/页;
|
|
54
|
+
- 章回页眉:全宽、高度 3%–15%,0.55 s/页。
|
|
55
|
+
|
|
56
|
+
把任意 OCR 引擎的结果整理成本包要的 JSONL 也行。
|
|
57
|
+
|
|
58
|
+
## 已在什么数据上验证
|
|
59
|
+
|
|
60
|
+
| 书 | 规模 | 结果 |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| 《史记》点校本 一 | 358 页 | 7 卷 + 7 篇,覆盖 358/358 |
|
|
63
|
+
| 《三国志》二 | 140 页 | 卷10–14 五篇篇名正确 |
|
|
64
|
+
| 四大名著合订本 | 2745/5291 页 | 215 回目段 / 3 部(过切 +1%) |
|
|
65
|
+
|
|
66
|
+
## 限制
|
|
67
|
+
|
|
68
|
+
- 版心位置是按中华书局版式调的,换版式要调裁切参数;
|
|
69
|
+
- 章回小说的"换书边界"会晚一回(下一部第一回的页眉常没有"第一回");
|
|
70
|
+
- 只处理竖排/横排的中文印刷体,不做手写、不做多栏学术期刊。
|
|
71
|
+
|
|
72
|
+
## 许可
|
|
73
|
+
|
|
74
|
+
尚未选定(建议 MIT);选好之前不要发布到任何包索引。
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# classics-index
|
|
2
|
+
|
|
3
|
+
从**扫描本**的版心(页边书眉)OCR 里建章回索引:卷 → 篇 → 回。不依赖任何在线服务,纯 Python,无第三方运行时依赖(OCR 引擎是可选的)。
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pip install -e .
|
|
7
|
+
classics-index volumes --headers heads.jsonl --out toc.json
|
|
8
|
+
classics-index pian --toc toc.json --band titles.jsonl --out toc-pian.json
|
|
9
|
+
classics-index hui --ocr hui.jsonl --names "1=西游记,2=水浒传" --out hui.json
|
|
10
|
+
classics-index find --toc toc.json --q 卷3
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## 它解决什么问题
|
|
14
|
+
|
|
15
|
+
点校本、影印本没有文本层,目录页也常常 OCR 不可用。但**每一页的页眉都印着卷次**("史記卷二")或回目("灵根育孕源流出心性修持大道生")。于是:
|
|
16
|
+
|
|
17
|
+
| 输入 | 输出 |
|
|
18
|
+
|---|---|
|
|
19
|
+
| 每页页眉带的 OCR(`{"page": n, "vol": 2}`) | 卷的起止页,连续覆盖全书 |
|
|
20
|
+
| 每页右侧标题带的 OCR(`{"page": n, "lines": [...]}`) | 篇名页与篇页码范围 |
|
|
21
|
+
| 章回小说每页页眉 OCR(`{"page": n, "texts": [...]}`) | 回目分段、附录标记、多部书切分 |
|
|
22
|
+
|
|
23
|
+
## 设计上的三个关键判据
|
|
24
|
+
|
|
25
|
+
1. **卷靠"连续 ≥5 页"**:目录页也会写出"史記卷十三",那种孤页会被丢掉。
|
|
26
|
+
2. **篇靠"行的形态"而不是字号**:实测卷眉(48 px 宽)比篇名(46 px)还宽,宽度阈值不可靠;可靠的是"短行 + 序号结尾干净"(目录行末尾是":"或省略号)。
|
|
27
|
+
3. **回靠"页眉标题变化"**:章回小说每页页眉都印着当前回目,标题一变就是新回;附录("附/录")重复上一回页眉,不能占回号。
|
|
28
|
+
|
|
29
|
+
## 数据从哪来
|
|
30
|
+
|
|
31
|
+
配套的 OCR 脚本在本仓库的 `eval/p3/`(`vertical_header_ocr.py`、`vertical_title_ocr.py`、`novel_hui_ocr.py`),它们只是 PaddleOCR 的一层薄封装:
|
|
32
|
+
|
|
33
|
+
- 版心带:页面右侧 18%、高度 8%–55%,1.2 s/页;
|
|
34
|
+
- 标题带:右侧 42%、高度 3%–65%,3.5 s/页;
|
|
35
|
+
- 章回页眉:全宽、高度 3%–15%,0.55 s/页。
|
|
36
|
+
|
|
37
|
+
把任意 OCR 引擎的结果整理成本包要的 JSONL 也行。
|
|
38
|
+
|
|
39
|
+
## 已在什么数据上验证
|
|
40
|
+
|
|
41
|
+
| 书 | 规模 | 结果 |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| 《史记》点校本 一 | 358 页 | 7 卷 + 7 篇,覆盖 358/358 |
|
|
44
|
+
| 《三国志》二 | 140 页 | 卷10–14 五篇篇名正确 |
|
|
45
|
+
| 四大名著合订本 | 2745/5291 页 | 215 回目段 / 3 部(过切 +1%) |
|
|
46
|
+
|
|
47
|
+
## 限制
|
|
48
|
+
|
|
49
|
+
- 版心位置是按中华书局版式调的,换版式要调裁切参数;
|
|
50
|
+
- 章回小说的"换书边界"会晚一回(下一部第一回的页眉常没有"第一回");
|
|
51
|
+
- 只处理竖排/横排的中文印刷体,不做手写、不做多栏学术期刊。
|
|
52
|
+
|
|
53
|
+
## 许可
|
|
54
|
+
|
|
55
|
+
尚未选定(建议 MIT);选好之前不要发布到任何包索引。
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Command line for building 卷/篇/回 indexes from OCR dumps.
|
|
2
|
+
|
|
3
|
+
classics-index volumes --headers heads.jsonl --out toc.json
|
|
4
|
+
classics-index pian --toc toc.json --band titles.jsonl --out toc-pian.json
|
|
5
|
+
classics-index hui --ocr hui.jsonl --names "1=西游记,2=水浒传" --out hui.json
|
|
6
|
+
classics-index find --toc toc.json --q 卷3
|
|
7
|
+
classics-index at --toc toc.json --page 50
|
|
8
|
+
"""
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from . import core, hui
|
|
16
|
+
|
|
17
|
+
PIAN = re.compile(r"(本紀|本纪|紀|纪|世家|列傳|列传|傳|传|書|书|表|志)\s*第\s*[〇零一二三四五六七八九十百]{1,4}")
|
|
18
|
+
LEADER = re.compile(r"…|\.{2,}|·{2,}|(……)")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _read_jsonl(path):
|
|
22
|
+
out = []
|
|
23
|
+
with open(path, encoding="utf-8") as fh:
|
|
24
|
+
for line in fh:
|
|
25
|
+
line = line.strip()
|
|
26
|
+
if line:
|
|
27
|
+
out.append(json.loads(line))
|
|
28
|
+
return out
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _width(line):
|
|
32
|
+
box = line.get("box") or [0, 0, 0, 0]
|
|
33
|
+
try:
|
|
34
|
+
return abs(int(box[2]) - int(box[0]))
|
|
35
|
+
except Exception:
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _volumes_from_headers(records, min_run):
|
|
40
|
+
vols = {int(r["page"]): r.get("vol") for r in records if r.get("vol")}
|
|
41
|
+
return core.build_from_volumes(core.clean_volumes(vols, min_run=min_run))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _volumes_from_band(records, min_run):
|
|
45
|
+
vols = {}
|
|
46
|
+
for rec in records:
|
|
47
|
+
lines = rec.get("lines") or []
|
|
48
|
+
vols[int(rec["page"])] = core.volume_of(lines)
|
|
49
|
+
return core.build_from_volumes(core.clean_volumes(vols, min_run=min_run))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _with_matter(chapters, total):
|
|
53
|
+
chapters = [dict(c, level=1) for c in chapters]
|
|
54
|
+
if chapters and chapters[0]["page_start"] > 1:
|
|
55
|
+
chapters.insert(0, {"ord": 0, "level": 1, "title": "卷首(目錄/出版說明)",
|
|
56
|
+
"page_start": 1, "page_end": chapters[0]["page_start"] - 1,
|
|
57
|
+
"volume": None, "pages": chapters[0]["page_start"] - 1})
|
|
58
|
+
if chapters and chapters[-1]["page_end"] < total:
|
|
59
|
+
last = chapters[-1]
|
|
60
|
+
chapters.append({"ord": len(chapters), "level": 1, "title": "卷末(附錄)",
|
|
61
|
+
"page_start": last["page_end"] + 1, "page_end": total,
|
|
62
|
+
"volume": None, "pages": total - last["page_end"]})
|
|
63
|
+
for i, c in enumerate(chapters):
|
|
64
|
+
c["ord"] = i
|
|
65
|
+
c["pages"] = c["page_end"] - c["page_start"] + 1
|
|
66
|
+
return chapters
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _pian_candidates(records, ratio=0.0):
|
|
70
|
+
"""{page: (text, width)} for title-sized 篇 lines."""
|
|
71
|
+
out = {}
|
|
72
|
+
for rec in records:
|
|
73
|
+
lines = rec.get("lines") or []
|
|
74
|
+
widths = [_width(l) for l in lines if _width(l)]
|
|
75
|
+
median = sorted(widths)[len(widths) // 2] if widths else 0
|
|
76
|
+
best = None
|
|
77
|
+
for line in lines:
|
|
78
|
+
text = str(line.get("text") or "").strip()
|
|
79
|
+
m = PIAN.search(text)
|
|
80
|
+
if len(text) > 12 or not m:
|
|
81
|
+
continue
|
|
82
|
+
w = _width(line)
|
|
83
|
+
if ratio and median and w < median * ratio:
|
|
84
|
+
continue
|
|
85
|
+
tail = text[m.end():].strip()
|
|
86
|
+
if LEADER.search(text) or (tail and re.search(r"[::.。…·・]", tail)):
|
|
87
|
+
continue
|
|
88
|
+
key = (len(text), w)
|
|
89
|
+
if best is None or key > best[0]:
|
|
90
|
+
best = (key, (text, w))
|
|
91
|
+
if best:
|
|
92
|
+
out[int(rec["page"])] = best[1]
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_volumes(args):
|
|
97
|
+
records = _read_jsonl(args.headers or args.band)
|
|
98
|
+
chapters = (_volumes_from_headers(records, args.min_run) if args.headers
|
|
99
|
+
else _volumes_from_band(records, args.min_run))
|
|
100
|
+
total = args.total or max(int(r["page"]) for r in records)
|
|
101
|
+
chapters = _with_matter(chapters, total)
|
|
102
|
+
json.dump({"chapters": chapters}, open(args.out, "w", encoding="utf-8"),
|
|
103
|
+
ensure_ascii=False, indent=1)
|
|
104
|
+
print("volumes=%d pages=%d -> %s" % (len(chapters), total, args.out))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cmd_pian(args):
|
|
108
|
+
toc = json.load(open(args.toc, encoding="utf-8"))
|
|
109
|
+
chapters = [c for c in toc["chapters"] if (c.get("level") or 1) == 1]
|
|
110
|
+
total = max(c["page_end"] for c in chapters)
|
|
111
|
+
hits = _pian_candidates(_read_jsonl(args.band), args.ratio)
|
|
112
|
+
by_vol = {}
|
|
113
|
+
for page in sorted(hits):
|
|
114
|
+
vol = core.bind_to_volume(page, chapters)
|
|
115
|
+
if vol is not None:
|
|
116
|
+
by_vol.setdefault(vol, []).append((page, hits[page][0]))
|
|
117
|
+
by_vol = {v: core.pian_sequence(items) for v, items in by_vol.items()}
|
|
118
|
+
by_vol = {v: items for v, items in by_vol.items() if items}
|
|
119
|
+
rows = core.merge_pian(chapters, by_vol, total=total)
|
|
120
|
+
json.dump({"chapters": rows}, open(args.out, "w", encoding="utf-8"),
|
|
121
|
+
ensure_ascii=False, indent=1)
|
|
122
|
+
level1 = [r for r in rows if r["level"] == 1]
|
|
123
|
+
print("rows=%d (篇=%d) coverage=%d/%d -> %s"
|
|
124
|
+
% (len(rows), len(rows) - len(level1), sum(r["pages"] for r in level1), total, args.out))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def cmd_hui(args):
|
|
128
|
+
names = {}
|
|
129
|
+
for part in (args.names or "").split(","):
|
|
130
|
+
if "=" in part:
|
|
131
|
+
k, v = part.split("=", 1)
|
|
132
|
+
names[int(k)] = v.strip()
|
|
133
|
+
books = hui.build(_read_jsonl(args.ocr), threshold=args.threshold,
|
|
134
|
+
min_pages=args.min_pages, names=names)
|
|
135
|
+
json.dump({"books": books}, open(args.out, "w", encoding="utf-8"),
|
|
136
|
+
ensure_ascii=False, indent=1)
|
|
137
|
+
for bi, rows in enumerate(books):
|
|
138
|
+
print("book%d %s: %d 回, p%d-%d"
|
|
139
|
+
% (bi + 1, rows[0].get("book_name", ""), len(rows),
|
|
140
|
+
rows[0]["page_start"], rows[-1]["page_end"]))
|
|
141
|
+
print("-> " + args.out)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _load(path):
|
|
145
|
+
return json.load(open(path, encoding="utf-8"))["chapters"]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def cmd_find(args):
|
|
149
|
+
row = core.find(_load(args.toc), args.q)
|
|
150
|
+
print(json.dumps(row, ensure_ascii=False, indent=1) if row else "not found")
|
|
151
|
+
return 0 if row else 1
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cmd_at(args):
|
|
155
|
+
row = core.at(_load(args.toc), args.page)
|
|
156
|
+
print(json.dumps(row, ensure_ascii=False, indent=1) if row else "not found")
|
|
157
|
+
return 0 if row else 1
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def main(argv=None):
|
|
161
|
+
ap = argparse.ArgumentParser(prog="classics-index", description=__doc__)
|
|
162
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
163
|
+
|
|
164
|
+
v = sub.add_parser("volumes", help="卷 boundaries from running-header OCR")
|
|
165
|
+
v.add_argument("--headers", help="header-strip OCR JSONL (page/vol)")
|
|
166
|
+
v.add_argument("--band", help="title-band OCR JSONL (page/lines)")
|
|
167
|
+
v.add_argument("--min-run", type=int, default=5)
|
|
168
|
+
v.add_argument("--total", type=int, default=0)
|
|
169
|
+
v.add_argument("--out", required=True)
|
|
170
|
+
v.set_defaults(func=cmd_volumes)
|
|
171
|
+
|
|
172
|
+
p = sub.add_parser("pian", help="篇 rows from title-band OCR")
|
|
173
|
+
p.add_argument("--toc", required=True)
|
|
174
|
+
p.add_argument("--band", required=True)
|
|
175
|
+
p.add_argument("--ratio", type=float, default=0.0)
|
|
176
|
+
p.add_argument("--out", required=True)
|
|
177
|
+
p.set_defaults(func=cmd_pian)
|
|
178
|
+
|
|
179
|
+
h = sub.add_parser("hui", help="回 segments for 章回 novels")
|
|
180
|
+
h.add_argument("--ocr", required=True)
|
|
181
|
+
h.add_argument("--threshold", type=float, default=0.55)
|
|
182
|
+
h.add_argument("--min-pages", type=int, default=4)
|
|
183
|
+
h.add_argument("--names", default="")
|
|
184
|
+
h.add_argument("--out", required=True)
|
|
185
|
+
h.set_defaults(func=cmd_hui)
|
|
186
|
+
|
|
187
|
+
f = sub.add_parser("find", help="locate a chapter by number or title")
|
|
188
|
+
f.add_argument("--toc", required=True)
|
|
189
|
+
f.add_argument("--q", required=True)
|
|
190
|
+
f.set_defaults(func=cmd_find)
|
|
191
|
+
|
|
192
|
+
a = sub.add_parser("at", help="chapter containing a page")
|
|
193
|
+
a.add_argument("--toc", required=True)
|
|
194
|
+
a.add_argument("--page", type=int, required=True)
|
|
195
|
+
a.set_defaults(func=cmd_at)
|
|
196
|
+
|
|
197
|
+
args = ap.parse_args(argv)
|
|
198
|
+
return args.func(args) or 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
sys.exit(main())
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Chapter-index core: 卷 / 篇 / 回 from OCR of a scanned book's running headers.
|
|
2
|
+
|
|
3
|
+
Pure functions only - no storage, no OCR, no I/O beyond dicts and lists, so the
|
|
4
|
+
same code can back a CLI, a service, or a notebook.
|
|
5
|
+
|
|
6
|
+
Pipeline it supports:
|
|
7
|
+
|
|
8
|
+
1. per-page header OCR -> {page: [{"text": ...}], ...}
|
|
9
|
+
volume_of + clean_volumes + build_from_volumes -> level-1 卷 rows
|
|
10
|
+
2. title-band OCR -> 篇 candidates
|
|
11
|
+
bind_to_volume + pian_sequence + merge_pian -> level-2 篇 rows
|
|
12
|
+
3. 章回小说 header OCR -> 回 segments (see hui.py)
|
|
13
|
+
|
|
14
|
+
Extracted from the sidecar's app/toc.py; the sidecar keeps a superset with
|
|
15
|
+
SQLite storage, so changes here should be mirrored there (or the sidecar should
|
|
16
|
+
depend on this package).
|
|
17
|
+
"""
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import sqlite3
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
CN_DIGITS = {"〇": 0, "零": 0, "一": 1, "二": 2, "三": 3, "四": 4, "五": 5,
|
|
24
|
+
"六": 6, "七": 7, "八": 8, "九": 9}
|
|
25
|
+
CN_UNITS = {"十": 10, "百": 100, "千": 1000}
|
|
26
|
+
|
|
27
|
+
# "史記卷二十七", "卷二十七", "史记卷 12"
|
|
28
|
+
VOL = re.compile(r"卷\s*([0-9]{1,3}|[〇零一二三四五六七八九十百千]{1,6})")
|
|
29
|
+
PIAN = re.compile(r"(本紀|本纪|紀|纪|世家|列傳|列传|傳|传|書|书|表|志)\s*第\s*([〇零一二三四五六七八九十百]{1,4})")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cn2int(text):
|
|
33
|
+
"""二十七 -> 27; 12 -> 12."""
|
|
34
|
+
if not text:
|
|
35
|
+
return None
|
|
36
|
+
if text.isdigit():
|
|
37
|
+
return int(text)
|
|
38
|
+
total, cur = 0, 0
|
|
39
|
+
for ch in text:
|
|
40
|
+
if ch in CN_DIGITS:
|
|
41
|
+
cur = CN_DIGITS[ch]
|
|
42
|
+
elif ch in CN_UNITS:
|
|
43
|
+
total += (cur or 1) * CN_UNITS[ch]
|
|
44
|
+
cur = 0
|
|
45
|
+
else:
|
|
46
|
+
return None
|
|
47
|
+
return total + cur
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def volume_of(lines):
|
|
51
|
+
"""The volume number a page belongs to, from its running header."""
|
|
52
|
+
for line in lines:
|
|
53
|
+
m = VOL.search(str(line.get("text") or ""))
|
|
54
|
+
if m:
|
|
55
|
+
n = cn2int(m.group(1))
|
|
56
|
+
if n:
|
|
57
|
+
return n
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build(pages):
|
|
62
|
+
"""pages: {page_no: [ {text, box, score}, ... ]} -> chapter list."""
|
|
63
|
+
return build_from_volumes({p: volume_of(lines) for p, lines in pages.items()})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def clean_volumes(vols, min_run=5):
|
|
67
|
+
"""Drop volume labels that do not survive as a run of pages.
|
|
68
|
+
|
|
69
|
+
Front matter (目录) also names volumes, and those names appear in OCR of
|
|
70
|
+
the same header strip. A real 卷 spans many consecutive pages, so a label
|
|
71
|
+
that shows up on one or two pages is noise, not a chapter boundary.
|
|
72
|
+
"""
|
|
73
|
+
runs = []
|
|
74
|
+
for pno in sorted(vols):
|
|
75
|
+
vol = vols[pno]
|
|
76
|
+
if vol is None:
|
|
77
|
+
continue
|
|
78
|
+
if runs and runs[-1]["volume"] == vol and pno - runs[-1]["last"] <= 2:
|
|
79
|
+
runs[-1]["last"] = pno
|
|
80
|
+
runs[-1]["pages"] += 1
|
|
81
|
+
else:
|
|
82
|
+
runs.append({"volume": vol, "first": pno, "last": pno, "pages": 1})
|
|
83
|
+
kept = [r for r in runs if r["pages"] >= min_run]
|
|
84
|
+
out = {}
|
|
85
|
+
for r in kept:
|
|
86
|
+
for p in range(r["first"], r["last"] + 1):
|
|
87
|
+
out[p] = r["volume"]
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_from_volumes(vols):
|
|
92
|
+
"""vols: {page_no: volume|None} -> chapter list with contiguous page ranges."""
|
|
93
|
+
chapters = []
|
|
94
|
+
for pno in sorted(vols):
|
|
95
|
+
vol = vols[pno]
|
|
96
|
+
if vol is None:
|
|
97
|
+
continue
|
|
98
|
+
if not chapters or chapters[-1]["volume"] != vol:
|
|
99
|
+
chapters.append({"volume": vol, "page_start": pno, "title": "卷%d" % vol})
|
|
100
|
+
for i, ch in enumerate(chapters):
|
|
101
|
+
ch["page_end"] = (chapters[i + 1]["page_start"] - 1
|
|
102
|
+
if i + 1 < len(chapters) else max(vols or {1: None}))
|
|
103
|
+
ch["ord"] = i
|
|
104
|
+
ch["level"] = 1
|
|
105
|
+
ch["pages"] = ch["page_end"] - ch["page_start"] + 1
|
|
106
|
+
return chapters
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def enrich(chapters, pages):
|
|
110
|
+
"""Append a 篇 name to a volume title when its first page carries one."""
|
|
111
|
+
for ch in chapters:
|
|
112
|
+
for line in pages.get(ch["page_start"], []):
|
|
113
|
+
m = PIAN.search(str(line.get("text") or ""))
|
|
114
|
+
if m:
|
|
115
|
+
ch["title"] = "卷%d %s第%s" % (ch["volume"], m.group(1), m.group(2))
|
|
116
|
+
break
|
|
117
|
+
return chapters
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def bind_to_volume(page, chapters, slack=6):
|
|
121
|
+
"""Which 卷 does a candidate title page open?
|
|
122
|
+
|
|
123
|
+
Preference order: a chapter that contains the page; otherwise the chapter
|
|
124
|
+
that starts within slack pages after it (the title page can sit just
|
|
125
|
+
before the first running header). Front matter stays unbound.
|
|
126
|
+
"""
|
|
127
|
+
# A 篇 opens the 卷 whose start it sits closest to: the title page can sit a
|
|
128
|
+
# few leaves before the first running header (the header pass misses early
|
|
129
|
+
# leaves), so containment alone would bind it to the previous 卷.
|
|
130
|
+
near = [ch for ch in chapters
|
|
131
|
+
if ch.get("volume") and abs(ch["page_start"] - page) <= slack]
|
|
132
|
+
if near:
|
|
133
|
+
return min(near, key=lambda ch: abs(ch["page_start"] - page))["volume"]
|
|
134
|
+
for ch in chapters:
|
|
135
|
+
if ch.get("volume") and ch["page_start"] <= page <= ch["page_end"]:
|
|
136
|
+
return ch["volume"]
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def pian_sequence(cands, min_gap=3):
|
|
141
|
+
"""Keep a strictly increasing run of 第X titles; drop list-page noise.
|
|
142
|
+
|
|
143
|
+
cands: [(page, text)] for one 卷. 目录 pages also spell out 篇 names, so a
|
|
144
|
+
candidate only survives when its 第X is greater than the previous kept one
|
|
145
|
+
and it is at least min_gap pages away.
|
|
146
|
+
"""
|
|
147
|
+
import re as _re
|
|
148
|
+
|
|
149
|
+
kept = []
|
|
150
|
+
last_num = 0
|
|
151
|
+
last_page = -10
|
|
152
|
+
for page, text in sorted(cands):
|
|
153
|
+
m = _re.search(r"第\s*([〇零一二三四五六七八九十百]{1,4})", text)
|
|
154
|
+
num = cn2int(m.group(1)) if m else None
|
|
155
|
+
if not num:
|
|
156
|
+
continue
|
|
157
|
+
if num <= last_num or page - last_page < min_gap:
|
|
158
|
+
continue
|
|
159
|
+
kept.append((page, text))
|
|
160
|
+
last_num, last_page = num, page
|
|
161
|
+
return kept
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def enrich(chapters, pages):
|
|
165
|
+
"""Append a 篇 name to a volume title when its first page carries one."""
|
|
166
|
+
for ch in chapters:
|
|
167
|
+
for line in pages.get(ch.get("page_start"), []):
|
|
168
|
+
m = PIAN.search(str(line.get("text") or ""))
|
|
169
|
+
if m:
|
|
170
|
+
ch["title"] = "卷%s %s第%s" % (ch.get("volume"), m.group(1), m.group(2))
|
|
171
|
+
break
|
|
172
|
+
return chapters
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def merge_pian(chapters, hits, total=None):
|
|
176
|
+
"""Fold 篇 title hits into the 卷 rows.
|
|
177
|
+
|
|
178
|
+
chapters: level-1 rows from the volume index (may include 卷首/卷末).
|
|
179
|
+
hits: {volume: (page, title)} for each 卷 where a 篇 title was found.
|
|
180
|
+
Returns rows ordered 卷 -> its 篇, with contiguous 卷 ranges; a 卷 start
|
|
181
|
+
moves earlier when its 篇 title page precedes the running-header page.
|
|
182
|
+
"""
|
|
183
|
+
rows = []
|
|
184
|
+
for ch in chapters:
|
|
185
|
+
ch = dict(ch)
|
|
186
|
+
ch["level"] = 1
|
|
187
|
+
hit = hits.get(ch.get("volume"))
|
|
188
|
+
if hit and isinstance(hit, tuple):
|
|
189
|
+
hit = [hit] # one 篇 per 卷 is the common case
|
|
190
|
+
items = sorted(hit or [], key=lambda x: x[0])
|
|
191
|
+
if items:
|
|
192
|
+
ch["page_start"] = min(ch["page_start"], items[0][0])
|
|
193
|
+
ch["title"] = "卷%d %s" % (ch["volume"], items[0][1])
|
|
194
|
+
rows.append(ch)
|
|
195
|
+
for page, title in items:
|
|
196
|
+
rows.append({"level": 2, "title": title, "page_start": page,
|
|
197
|
+
"page_end": page, "volume": ch["volume"],
|
|
198
|
+
"parent": ch["volume"]})
|
|
199
|
+
total = total or max(r["page_end"] for r in chapters)
|
|
200
|
+
level1 = [r for r in rows if r["level"] == 1]
|
|
201
|
+
level1.sort(key=lambda r: r["page_start"])
|
|
202
|
+
for i, r in enumerate(level1):
|
|
203
|
+
r["page_end"] = level1[i + 1]["page_start"] - 1 if i + 1 < len(level1) else total
|
|
204
|
+
for vol in {r.get("volume") for r in rows if r["level"] == 2}:
|
|
205
|
+
parent = next(x for x in level1 if x.get("volume") == vol)
|
|
206
|
+
pian = sorted((r for r in rows if r["level"] == 2 and r["volume"] == vol),
|
|
207
|
+
key=lambda r: r["page_start"])
|
|
208
|
+
for i, r in enumerate(pian):
|
|
209
|
+
r["page_end"] = (pian[i + 1]["page_start"] - 1) if i + 1 < len(pian) else parent["page_end"]
|
|
210
|
+
r["page_end"] = max(r["page_end"], r["page_start"])
|
|
211
|
+
for i, r in enumerate(rows):
|
|
212
|
+
r["ord"] = i
|
|
213
|
+
r["pages"] = r["page_end"] - r["page_start"] + 1
|
|
214
|
+
return rows
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def at(rows, page):
|
|
218
|
+
"""The chapter row that contains a page (innermost wins)."""
|
|
219
|
+
best = None
|
|
220
|
+
for r in rows:
|
|
221
|
+
if r["page_start"] <= page <= r["page_end"]:
|
|
222
|
+
if best is None or (r.get("level") or 1) > (best.get("level") or 1):
|
|
223
|
+
best = r
|
|
224
|
+
return best
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# OCR output and 点校本 titles are traditional; readers type simplified. A
|
|
228
|
+
# full converter is overkill, so cover the characters that actually appear in
|
|
229
|
+
# the chapter names of the indexed books.
|
|
230
|
+
_TRAD = {
|
|
231
|
+
"紀": "纪", "傳": "传", "書": "书", "項": "项", "賈": "贾", "詡": "诩",
|
|
232
|
+
"張": "张", "涼": "凉", "國": "国", "鮑": "鲍", "馬": "马", "鍾": "钟",
|
|
233
|
+
"華": "华", "劉": "刘", "蔣": "蒋", "繇": "繇", "歆": "歆", "毓": "毓",
|
|
234
|
+
"禮": "礼", "樂": "乐", "曆": "历", "曆": "历", "邊": "边", "衛": "卫",
|
|
235
|
+
"權": "权", "陰": "阴", "陽": "阳", "東": "东", "車": "车", "軍": "军",
|
|
236
|
+
"吳": "吴", "魯": "鲁", "齊": "齐", "鄭": "郑", "晉": "晋", "衛": "卫",
|
|
237
|
+
"陳": "陈", "韓": "韩", "魏": "魏", "趙": "赵", "楚": "楚", "燕": "燕",
|
|
238
|
+
"漢": "汉", "秦": "秦", "夏": "夏", "殷": "殷", "周": "周", "帝": "帝",
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _simp(text):
|
|
243
|
+
return "".join(_TRAD.get(ch, ch) for ch in str(text or ""))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _num_in(title, kind):
|
|
247
|
+
"""Pull the printed number out of a title: 卷12 / 第三回."""
|
|
248
|
+
m = re.search(r"%s\s*(\d{1,4}|[〇零一二三四五六七八九十百千]{1,6})" % kind, title or "")
|
|
249
|
+
return cn2int(m.group(1)) if m else None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def find(rows, query):
|
|
253
|
+
"""Locate a chapter by ordinal, printed number, or a title fragment.
|
|
254
|
+
|
|
255
|
+
Accepts "3", "卷3", "第三回", "第7回", "五帝本纪", "项王".
|
|
256
|
+
"""
|
|
257
|
+
q = str(query or "").strip()
|
|
258
|
+
if not q:
|
|
259
|
+
return None
|
|
260
|
+
m = re.search(r"(卷|第)\s*(\d{1,4}|[〇零一二三四五六七八九十百千]{1,6})", q)
|
|
261
|
+
if m:
|
|
262
|
+
kind = "卷" if m.group(1) == "卷" else "第"
|
|
263
|
+
num = cn2int(m.group(2))
|
|
264
|
+
if num:
|
|
265
|
+
hits = [r for r in rows if _num_in(r.get("title"), kind) == num]
|
|
266
|
+
if hits:
|
|
267
|
+
return max(hits, key=lambda r: r.get("level") or 1)
|
|
268
|
+
for r in rows:
|
|
269
|
+
if kind == "卷" and (r.get("level") or 1) == 1 and r.get("volume") == num:
|
|
270
|
+
return r
|
|
271
|
+
if kind == "第" and r.get("ord") == num - 1:
|
|
272
|
+
return r
|
|
273
|
+
return None
|
|
274
|
+
if q.isdigit():
|
|
275
|
+
n = int(q)
|
|
276
|
+
for r in rows:
|
|
277
|
+
if r.get("ord") == n:
|
|
278
|
+
return r
|
|
279
|
+
for r in rows:
|
|
280
|
+
if r.get("ord") == n - 1:
|
|
281
|
+
return r
|
|
282
|
+
needle = _simp(q.replace("第", "").replace("回", "").replace("卷", "").strip())
|
|
283
|
+
if needle:
|
|
284
|
+
hits = [r for r in rows if needle in _simp(r.get("title"))]
|
|
285
|
+
if hits:
|
|
286
|
+
return max(hits, key=lambda r: r.get("level") or 1)
|
|
287
|
+
return None
|
|
288
|
+
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""章回小说: turn per-page header OCR into 回 segments.
|
|
2
|
+
|
|
3
|
+
A 章回 novel prints the current 回 title in the running header of every leaf,
|
|
4
|
+
so a change of header title is a chapter boundary. Numbers come from leaves
|
|
5
|
+
that also show "第X回"; the rest are filled by counting. An inserted 附录
|
|
6
|
+
(common in 西游记) repeats the previous header and must not consume a number.
|
|
7
|
+
|
|
8
|
+
Pure functions: pass in the parsed pages, get rows back.
|
|
9
|
+
"""
|
|
10
|
+
import difflib
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from . import core
|
|
14
|
+
|
|
15
|
+
# Chinese numerals dominate old typesetting, but modern reprints print 第12回.
|
|
16
|
+
NUM = re.compile(r"第\s*([〇零一二三四五六七八九十百千]{1,6}|\d{1,4})\s*回")
|
|
17
|
+
CJK = re.compile(r"[\u3400-\u9fff]")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def clean(text):
|
|
21
|
+
return "".join(CJK.findall(text or ""))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_records(records):
|
|
25
|
+
"""OCR records -> {page: {"num", "title", "appendix"}}.
|
|
26
|
+
|
|
27
|
+
Accepts the JSONL objects written by novel_hui_ocr.py: {"page", "texts"}.
|
|
28
|
+
"""
|
|
29
|
+
pages = {}
|
|
30
|
+
for rec in records:
|
|
31
|
+
texts = [t for t in (rec.get("texts") or []) if str(t).strip()]
|
|
32
|
+
num, titles = None, []
|
|
33
|
+
for t in texts:
|
|
34
|
+
m = NUM.search(t)
|
|
35
|
+
if m:
|
|
36
|
+
if num is None:
|
|
37
|
+
num = core.cn2int(m.group(1))
|
|
38
|
+
continue
|
|
39
|
+
c = clean(t)
|
|
40
|
+
if 4 <= len(c) <= 16:
|
|
41
|
+
titles.append(c)
|
|
42
|
+
if num is None:
|
|
43
|
+
m = NUM.search("".join(texts))
|
|
44
|
+
if m:
|
|
45
|
+
num = core.cn2int(m.group(1))
|
|
46
|
+
appendix = any(clean(t) in ("附", "录", "錄", "附录", "附錄") for t in texts)
|
|
47
|
+
pages[int(rec["page"])] = {"num": num,
|
|
48
|
+
"title": max(titles, key=len) if titles else "",
|
|
49
|
+
"appendix": appendix}
|
|
50
|
+
return pages
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def sim(a, b):
|
|
54
|
+
return difflib.SequenceMatcher(None, a, b).ratio()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def segment(pages, threshold=0.55):
|
|
58
|
+
"""Split pages into runs of one header title."""
|
|
59
|
+
runs = []
|
|
60
|
+
for pno in sorted(pages):
|
|
61
|
+
rec = pages[pno]
|
|
62
|
+
title = rec["title"]
|
|
63
|
+
if not title:
|
|
64
|
+
continue
|
|
65
|
+
if runs and sim(title, runs[-1]["title"]) >= threshold:
|
|
66
|
+
runs[-1]["end"] = pno
|
|
67
|
+
runs[-1]["count"] += 1
|
|
68
|
+
continue
|
|
69
|
+
nxt = pages.get(pno + 1, {}).get("title", "")
|
|
70
|
+
if runs and not rec["num"] and nxt and sim(nxt, runs[-1]["title"]) >= threshold:
|
|
71
|
+
runs[-1]["end"] = pno
|
|
72
|
+
continue
|
|
73
|
+
runs.append({"start": pno, "end": pno, "title": title, "count": 1})
|
|
74
|
+
return runs
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def merge_short(runs, min_pages=4):
|
|
78
|
+
"""A one- or two-leaf run is an OCR blip, not a chapter: fold it back."""
|
|
79
|
+
out = []
|
|
80
|
+
for r in runs:
|
|
81
|
+
if out and (r["end"] - r["start"] + 1) < min_pages:
|
|
82
|
+
out[-1]["end"] = r["end"]
|
|
83
|
+
out[-1]["count"] += r.get("count", 1)
|
|
84
|
+
continue
|
|
85
|
+
out.append(r)
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def mark_appendix(runs, pages):
|
|
90
|
+
for r in runs:
|
|
91
|
+
r["appendix"] = any(pages.get(p, {}).get("appendix")
|
|
92
|
+
for p in range(r["start"], r["end"] + 1))
|
|
93
|
+
return runs
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def number(runs, pages):
|
|
97
|
+
"""Assign 回 numbers: trust detected ones, count the rest, skip 附录."""
|
|
98
|
+
for r in runs:
|
|
99
|
+
r["num"] = None
|
|
100
|
+
r["detected"] = None
|
|
101
|
+
for r in runs:
|
|
102
|
+
for p in range(r["start"], min(r["end"], r["start"] + 3) + 1):
|
|
103
|
+
rec = pages.get(p) or {}
|
|
104
|
+
if rec.get("num"):
|
|
105
|
+
r["num"] = rec["num"]
|
|
106
|
+
r["detected"] = rec["num"]
|
|
107
|
+
break
|
|
108
|
+
last = 0
|
|
109
|
+
for r in runs:
|
|
110
|
+
if r.get("appendix"):
|
|
111
|
+
r["num"] = None
|
|
112
|
+
continue
|
|
113
|
+
if r["num"] is None and last:
|
|
114
|
+
r["num"] = last + 1
|
|
115
|
+
last = r["num"] or last
|
|
116
|
+
for i in range(len(runs) - 1, -1, -1):
|
|
117
|
+
if runs[i].get("appendix"):
|
|
118
|
+
continue
|
|
119
|
+
if runs[i]["num"] is None and i + 1 < len(runs):
|
|
120
|
+
nxt = next((r["num"] for r in runs[i + 1:] if r["num"]), None)
|
|
121
|
+
if nxt:
|
|
122
|
+
runs[i]["num"] = nxt - 1
|
|
123
|
+
return runs
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def trim(runs):
|
|
127
|
+
"""Drop front matter before the first numbered 回; number rows by position."""
|
|
128
|
+
first = next((i for i, r in enumerate(runs) if r["num"]), 0)
|
|
129
|
+
runs = runs[first:]
|
|
130
|
+
for i, r in enumerate(runs):
|
|
131
|
+
r["page_start"], r["page_end"] = r["start"], r["end"]
|
|
132
|
+
r["ord"] = i
|
|
133
|
+
r["level"] = 2
|
|
134
|
+
for i, r in enumerate(runs):
|
|
135
|
+
r["page_end"] = runs[i + 1]["start"] - 1 if i + 1 < len(runs) else r["end"]
|
|
136
|
+
r["pages"] = r["page_end"] - r["page_start"] + 1
|
|
137
|
+
r["printed"] = r["num"]
|
|
138
|
+
r["num"] = i + 1
|
|
139
|
+
r["title_text"] = ("附录 " + r["title"]) if r.get("appendix") else r["title"]
|
|
140
|
+
return runs
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def split_books(runs):
|
|
144
|
+
"""A new novel starts where the printed 回 number falls back to the top."""
|
|
145
|
+
books, cur, last = [], [], 0
|
|
146
|
+
for r in runs:
|
|
147
|
+
# The printed number resets to 1 at a new novel; the counted number does
|
|
148
|
+
# not, so the split must look at what the header actually said.
|
|
149
|
+
pr = r.get("detected") or 0
|
|
150
|
+
if cur and pr and pr <= 3 and last >= 10:
|
|
151
|
+
books.append(cur)
|
|
152
|
+
cur = []
|
|
153
|
+
cur.append(r)
|
|
154
|
+
last = pr or last
|
|
155
|
+
if cur:
|
|
156
|
+
books.append(cur)
|
|
157
|
+
return books
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def build(records, threshold=0.55, min_pages=4, names=None):
|
|
161
|
+
pages = parse_records(records)
|
|
162
|
+
runs = segment(pages, threshold)
|
|
163
|
+
runs = merge_short(runs, min_pages)
|
|
164
|
+
runs = mark_appendix(runs, pages)
|
|
165
|
+
runs = trim(number(runs, pages))
|
|
166
|
+
books = split_books(runs)
|
|
167
|
+
names = names or {}
|
|
168
|
+
for bi, rows in enumerate(books):
|
|
169
|
+
for r in rows:
|
|
170
|
+
r["book"] = bi + 1
|
|
171
|
+
r["book_name"] = names.get(bi + 1, "第%d部" % (bi + 1))
|
|
172
|
+
return books
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: classics-index
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Chapter indexes (卷 / 篇 / 回) for scanned Chinese books, built from OCR of the running header
|
|
5
|
+
Author: socratic-tutor contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: chinese-classics,scanned-books,ocr,table-of-contents,pymupdf
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Provides-Extra: ocr
|
|
15
|
+
Requires-Dist: paddleocr>=3; extra == "ocr"
|
|
16
|
+
Requires-Dist: pillow; extra == "ocr"
|
|
17
|
+
Requires-Dist: pymupdf; extra == "ocr"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# classics-index
|
|
21
|
+
|
|
22
|
+
从**扫描本**的版心(页边书眉)OCR 里建章回索引:卷 → 篇 → 回。不依赖任何在线服务,纯 Python,无第三方运行时依赖(OCR 引擎是可选的)。
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
pip install -e .
|
|
26
|
+
classics-index volumes --headers heads.jsonl --out toc.json
|
|
27
|
+
classics-index pian --toc toc.json --band titles.jsonl --out toc-pian.json
|
|
28
|
+
classics-index hui --ocr hui.jsonl --names "1=西游记,2=水浒传" --out hui.json
|
|
29
|
+
classics-index find --toc toc.json --q 卷3
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## 它解决什么问题
|
|
33
|
+
|
|
34
|
+
点校本、影印本没有文本层,目录页也常常 OCR 不可用。但**每一页的页眉都印着卷次**("史記卷二")或回目("灵根育孕源流出心性修持大道生")。于是:
|
|
35
|
+
|
|
36
|
+
| 输入 | 输出 |
|
|
37
|
+
|---|---|
|
|
38
|
+
| 每页页眉带的 OCR(`{"page": n, "vol": 2}`) | 卷的起止页,连续覆盖全书 |
|
|
39
|
+
| 每页右侧标题带的 OCR(`{"page": n, "lines": [...]}`) | 篇名页与篇页码范围 |
|
|
40
|
+
| 章回小说每页页眉 OCR(`{"page": n, "texts": [...]}`) | 回目分段、附录标记、多部书切分 |
|
|
41
|
+
|
|
42
|
+
## 设计上的三个关键判据
|
|
43
|
+
|
|
44
|
+
1. **卷靠"连续 ≥5 页"**:目录页也会写出"史記卷十三",那种孤页会被丢掉。
|
|
45
|
+
2. **篇靠"行的形态"而不是字号**:实测卷眉(48 px 宽)比篇名(46 px)还宽,宽度阈值不可靠;可靠的是"短行 + 序号结尾干净"(目录行末尾是":"或省略号)。
|
|
46
|
+
3. **回靠"页眉标题变化"**:章回小说每页页眉都印着当前回目,标题一变就是新回;附录("附/录")重复上一回页眉,不能占回号。
|
|
47
|
+
|
|
48
|
+
## 数据从哪来
|
|
49
|
+
|
|
50
|
+
配套的 OCR 脚本在本仓库的 `eval/p3/`(`vertical_header_ocr.py`、`vertical_title_ocr.py`、`novel_hui_ocr.py`),它们只是 PaddleOCR 的一层薄封装:
|
|
51
|
+
|
|
52
|
+
- 版心带:页面右侧 18%、高度 8%–55%,1.2 s/页;
|
|
53
|
+
- 标题带:右侧 42%、高度 3%–65%,3.5 s/页;
|
|
54
|
+
- 章回页眉:全宽、高度 3%–15%,0.55 s/页。
|
|
55
|
+
|
|
56
|
+
把任意 OCR 引擎的结果整理成本包要的 JSONL 也行。
|
|
57
|
+
|
|
58
|
+
## 已在什么数据上验证
|
|
59
|
+
|
|
60
|
+
| 书 | 规模 | 结果 |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| 《史记》点校本 一 | 358 页 | 7 卷 + 7 篇,覆盖 358/358 |
|
|
63
|
+
| 《三国志》二 | 140 页 | 卷10–14 五篇篇名正确 |
|
|
64
|
+
| 四大名著合订本 | 2745/5291 页 | 215 回目段 / 3 部(过切 +1%) |
|
|
65
|
+
|
|
66
|
+
## 限制
|
|
67
|
+
|
|
68
|
+
- 版心位置是按中华书局版式调的,换版式要调裁切参数;
|
|
69
|
+
- 章回小说的"换书边界"会晚一回(下一部第一回的页眉常没有"第一回");
|
|
70
|
+
- 只处理竖排/横排的中文印刷体,不做手写、不做多栏学术期刊。
|
|
71
|
+
|
|
72
|
+
## 许可
|
|
73
|
+
|
|
74
|
+
尚未选定(建议 MIT);选好之前不要发布到任何包索引。
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
classics_index/__init__.py
|
|
5
|
+
classics_index/cli.py
|
|
6
|
+
classics_index/core.py
|
|
7
|
+
classics_index/hui.py
|
|
8
|
+
classics_index.egg-info/PKG-INFO
|
|
9
|
+
classics_index.egg-info/SOURCES.txt
|
|
10
|
+
classics_index.egg-info/dependency_links.txt
|
|
11
|
+
classics_index.egg-info/entry_points.txt
|
|
12
|
+
classics_index.egg-info/requires.txt
|
|
13
|
+
classics_index.egg-info/top_level.txt
|
|
14
|
+
tests/test_cli.py
|
|
15
|
+
tests/test_core.py
|
|
16
|
+
tests/test_hui.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
classics_index
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "classics-index"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Chapter indexes (卷 / 篇 / 回) for scanned Chinese books, built from OCR of the running header"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "socratic-tutor contributors" }]
|
|
12
|
+
keywords = ["chinese-classics", "scanned-books", "ocr", "table-of-contents", "pymupdf"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Topic :: Text Processing :: Linguistic",
|
|
17
|
+
]
|
|
18
|
+
license = { text = "MIT" }
|
|
19
|
+
dependencies = []
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
ocr = ["paddleocr>=3", "pillow", "pymupdf"]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
classics-index = "classics_index.cli:main"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools]
|
|
28
|
+
packages = ["classics_index"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
from classics_index import cli
|
|
7
|
+
|
|
8
|
+
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
9
|
+
EX = os.path.join(HERE, "examples")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestCliOnExamples(unittest.TestCase):
|
|
13
|
+
def test_volumes_and_pian_match_expected(self):
|
|
14
|
+
tmp = tempfile.mkdtemp()
|
|
15
|
+
toc = os.path.join(tmp, "toc.json")
|
|
16
|
+
idx = os.path.join(tmp, "index.json")
|
|
17
|
+
cli.main(["volumes", "--headers", os.path.join(EX, "demo-headers.jsonl"),
|
|
18
|
+
"--out", toc])
|
|
19
|
+
cli.main(["pian", "--toc", toc,
|
|
20
|
+
"--band", os.path.join(EX, "demo-titles.jsonl"), "--out", idx])
|
|
21
|
+
got = json.load(open(idx, encoding="utf-8"))["chapters"]
|
|
22
|
+
want = json.load(open(os.path.join(EX, "expected-toc.json"), encoding="utf-8"))["chapters"]
|
|
23
|
+
self.assertEqual(len(got), len(want))
|
|
24
|
+
for g, w in zip(got, want):
|
|
25
|
+
self.assertEqual(g["title"], w["title"])
|
|
26
|
+
self.assertEqual((g["page_start"], g["page_end"]), (w["page_start"], w["page_end"]))
|
|
27
|
+
self.assertEqual(g["level"], w["level"])
|
|
28
|
+
|
|
29
|
+
def test_find_and_at(self):
|
|
30
|
+
tmp = tempfile.mkdtemp()
|
|
31
|
+
toc = os.path.join(tmp, "toc.json")
|
|
32
|
+
cli.main(["volumes", "--headers", os.path.join(EX, "demo-headers.jsonl"), "--out", toc])
|
|
33
|
+
rows = json.load(open(toc, encoding="utf-8"))["chapters"]
|
|
34
|
+
self.assertEqual(rows[1]["page_start"], 3)
|
|
35
|
+
self.assertIn("卷1", rows[1]["title"])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
unittest.main()
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from classics_index import core
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
ROWS = [
|
|
7
|
+
{"ord": 0, "level": 1, "title": "卷首", "page_start": 1, "page_end": 18, "volume": None},
|
|
8
|
+
{"ord": 1, "level": 1, "title": "卷1 五帝本紀第一", "page_start": 19, "page_end": 66, "volume": 1},
|
|
9
|
+
{"ord": 2, "level": 2, "title": "五帝本紀第一", "page_start": 19, "page_end": 66, "volume": 1},
|
|
10
|
+
{"ord": 3, "level": 1, "title": "卷2 夏本紀第二", "page_start": 67, "page_end": 108, "volume": 2},
|
|
11
|
+
{"ord": 4, "level": 2, "title": "夏本紀第二", "page_start": 67, "page_end": 108, "volume": 2},
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestCn2Int(unittest.TestCase):
|
|
16
|
+
def test_values(self):
|
|
17
|
+
self.assertEqual(core.cn2int("二十七"), 27)
|
|
18
|
+
self.assertEqual(core.cn2int("一百零五"), 105)
|
|
19
|
+
self.assertEqual(core.cn2int("12"), 12)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestVolumes(unittest.TestCase):
|
|
23
|
+
def test_volume_of_running_header(self):
|
|
24
|
+
self.assertEqual(core.volume_of([{"text": "正文"}, {"text": "史記卷二"}]), 2)
|
|
25
|
+
|
|
26
|
+
def test_clean_drops_single_page_noise(self):
|
|
27
|
+
vols = {10: 13, 11: None, 12: 28, 20: 1, 21: 1, 22: 1, 23: 1, 24: 1}
|
|
28
|
+
clean = core.clean_volumes(vols, min_run=5)
|
|
29
|
+
self.assertEqual(set(clean.values()), {1})
|
|
30
|
+
|
|
31
|
+
def test_ranges_are_contiguous(self):
|
|
32
|
+
vols = {1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 2, 7: 2, 8: 2, 9: 2, 10: 2}
|
|
33
|
+
rows = core.build_from_volumes(core.clean_volumes(vols, min_run=5))
|
|
34
|
+
self.assertEqual([r["volume"] for r in rows], [1, 2])
|
|
35
|
+
self.assertEqual(rows[0]["page_end"], 5)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TestBind(unittest.TestCase):
|
|
39
|
+
def test_inside(self):
|
|
40
|
+
self.assertEqual(core.bind_to_volume(40, ROWS), 1)
|
|
41
|
+
|
|
42
|
+
def test_title_page_before_header(self):
|
|
43
|
+
self.assertEqual(core.bind_to_volume(19, ROWS), 1)
|
|
44
|
+
self.assertEqual(core.bind_to_volume(66, ROWS), 2)
|
|
45
|
+
|
|
46
|
+
def test_far_front_matter(self):
|
|
47
|
+
self.assertIsNone(core.bind_to_volume(5, ROWS))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestFind(unittest.TestCase):
|
|
51
|
+
def test_fragment(self):
|
|
52
|
+
self.assertEqual(core.find(ROWS, "夏本纪")["title"], "夏本紀第二")
|
|
53
|
+
|
|
54
|
+
def test_juan(self):
|
|
55
|
+
self.assertEqual(core.find(ROWS, "卷2")["volume"], 2)
|
|
56
|
+
|
|
57
|
+
def test_missing(self):
|
|
58
|
+
self.assertIsNone(core.find(ROWS, "不存在"))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestMergePian(unittest.TestCase):
|
|
62
|
+
def test_start_moves_to_title_page(self):
|
|
63
|
+
rows = core.merge_pian([r for r in ROWS if r["level"] == 1], {2: (67, "夏本紀第二")}, total=108)
|
|
64
|
+
vol2 = [r for r in rows if r.get("volume") == 2 and r["level"] == 1][0]
|
|
65
|
+
self.assertEqual(vol2["page_start"], 67)
|
|
66
|
+
self.assertEqual(vol2["title"], "卷2 夏本紀第二")
|
|
67
|
+
|
|
68
|
+
def test_multiple_pian_in_one_volume(self):
|
|
69
|
+
level1 = [r for r in ROWS if r["level"] == 1]
|
|
70
|
+
rows = core.merge_pian(level1, {1: [(19, "五帝本紀第一"), (30, "夏本紀第二")]}, total=108)
|
|
71
|
+
pian = [r for r in rows if r["level"] == 2 and r.get("volume") == 1]
|
|
72
|
+
self.assertEqual([p["page_start"] for p in pian], [19, 30])
|
|
73
|
+
self.assertEqual(pian[0]["page_end"], 29)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
unittest.main()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from classics_index import hui
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def rec(page, texts):
|
|
7
|
+
return {"page": page, "texts": texts}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Distinct 回 titles: two 回 with similar headers would be one segment.
|
|
11
|
+
TITLES = [
|
|
12
|
+
"灵根育孕源流出心性修持", "悟彻菩提真妙理断魔归本", "四海千山皆拱伏九幽十类",
|
|
13
|
+
"官封弼马心何足名注齐天", "乱蟠桃大圣偷丹反天宫", "观音赴会问原因小圣施威",
|
|
14
|
+
"八卦炉中逃大圣五行山", "我佛造经传极乐观音奉旨", "陈光蕊赴任逢灾江流僧",
|
|
15
|
+
"老龙王拙计犯天条魏征", "二将军宫门镇鬼唐太宗", "还受生唐王遵善果度孤魂",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def book(start, end, num, title, with_number=True):
|
|
20
|
+
"""Pages of one 回: the first leaf carries "第X回", the rest only the title."""
|
|
21
|
+
out = []
|
|
22
|
+
for p in range(start, end + 1):
|
|
23
|
+
texts = [title]
|
|
24
|
+
if with_number and p == start:
|
|
25
|
+
texts.insert(0, "第%d回" % num)
|
|
26
|
+
out.append(rec(p, texts))
|
|
27
|
+
return out
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestHui(unittest.TestCase):
|
|
31
|
+
def test_segments_by_header(self):
|
|
32
|
+
records = book(1, 5, 1, "灵根育孕源流出心性修持大道生")
|
|
33
|
+
records += book(6, 10, 2, "悟彻菩提真妙理断魔归本合元神")
|
|
34
|
+
rows = hui.build(records)[0]
|
|
35
|
+
self.assertEqual(len(rows), 2)
|
|
36
|
+
self.assertEqual((rows[0]["page_start"], rows[0]["page_end"]), (1, 5))
|
|
37
|
+
self.assertEqual(rows[1]["page_start"], 6)
|
|
38
|
+
|
|
39
|
+
def test_short_run_is_merged(self):
|
|
40
|
+
records = book(1, 5, 1, "甲乙丙丁戊己庚辛壬癸")
|
|
41
|
+
records.append(rec(6, ["子丑寅卯辰巳午未申酉"])) # one-leaf blip
|
|
42
|
+
records += book(7, 11, 1, "甲乙丙丁戊己庚辛壬癸", with_number=False)
|
|
43
|
+
rows = hui.build(records)[0]
|
|
44
|
+
self.assertEqual(len(rows), 1)
|
|
45
|
+
|
|
46
|
+
def test_appendix_does_not_consume_a_number(self):
|
|
47
|
+
records = book(1, 5, 1, "灵根育孕源流出心性修持大道生")
|
|
48
|
+
records += [rec(p, ["陈光蕊赴任逢灾江流僧复仇报本", "附", "录"]) for p in range(6, 11)]
|
|
49
|
+
records += book(11, 15, 9, "袁守诚妙算无私曲老龙王拙计犯天条")
|
|
50
|
+
rows = hui.build(records)[0]
|
|
51
|
+
self.assertEqual(len(rows), 3)
|
|
52
|
+
self.assertIn("附录", rows[1]["title_text"])
|
|
53
|
+
self.assertEqual(rows[2]["num"], 3)
|
|
54
|
+
|
|
55
|
+
def test_book_split_on_number_reset(self):
|
|
56
|
+
records = []
|
|
57
|
+
for i in range(1, 11): # a real novel runs well past 10
|
|
58
|
+
records += book((i - 1) * 6 + 1, i * 6, i, TITLES[i - 1])
|
|
59
|
+
records += book(61, 66, 1, "三国开篇宴桃园豪杰三结义")
|
|
60
|
+
books = hui.build(records)
|
|
61
|
+
self.assertEqual(len(books), 2)
|
|
62
|
+
self.assertEqual(books[1][0]["page_start"], 61)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
unittest.main()
|