dsh-ab-ocr 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +378 -0
- package/cordis.patch.yml +7 -0
- package/lib/artifacts.d.ts +100 -0
- package/lib/artifacts.d.ts.map +1 -0
- package/lib/artifacts.js +97 -0
- package/lib/artifacts.js.map +1 -0
- package/lib/config.d.ts +77 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +51 -0
- package/lib/config.js.map +1 -0
- package/lib/documents.d.ts +62 -0
- package/lib/documents.d.ts.map +1 -0
- package/lib/documents.js +173 -0
- package/lib/documents.js.map +1 -0
- package/lib/events.d.ts +161 -0
- package/lib/events.d.ts.map +1 -0
- package/lib/events.js +158 -0
- package/lib/events.js.map +1 -0
- package/lib/filename.d.ts +47 -0
- package/lib/filename.d.ts.map +1 -0
- package/lib/filename.js +77 -0
- package/lib/filename.js.map +1 -0
- package/lib/index.d.ts +85 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +1761 -0
- package/lib/index.js.map +1 -0
- package/lib/levels.d.ts +24 -0
- package/lib/levels.d.ts.map +1 -0
- package/lib/levels.js +52 -0
- package/lib/levels.js.map +1 -0
- package/lib/plan.d.ts +103 -0
- package/lib/plan.d.ts.map +1 -0
- package/lib/plan.js +210 -0
- package/lib/plan.js.map +1 -0
- package/lib/recognize.d.ts +36 -0
- package/lib/recognize.d.ts.map +1 -0
- package/lib/recognize.js +390 -0
- package/lib/recognize.js.map +1 -0
- package/lib/records.d.ts +91 -0
- package/lib/records.d.ts.map +1 -0
- package/lib/records.js +130 -0
- package/lib/records.js.map +1 -0
- package/lib/render.d.ts +19 -0
- package/lib/render.d.ts.map +1 -0
- package/lib/render.js +45 -0
- package/lib/render.js.map +1 -0
- package/lib/sandbox.d.ts +54 -0
- package/lib/sandbox.d.ts.map +1 -0
- package/lib/sandbox.js +101 -0
- package/lib/sandbox.js.map +1 -0
- package/lib/types.d.ts +147 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +7 -0
- package/lib/types.js.map +1 -0
- package/lib/worker.d.ts +107 -0
- package/lib/worker.d.ts.map +1 -0
- package/lib/worker.js +143 -0
- package/lib/worker.js.map +1 -0
- package/package.json +98 -0
- package/python/README.md +125 -0
- package/python/assemble.py +358 -0
- package/python/clean.py +197 -0
- package/python/layout.py +403 -0
- package/python/ocr_worker.py +516 -0
- package/python/requirements.txt +16 -0
- package/python/source.py +182 -0
- package/scripts/setup.mjs +251 -0
- package/tsconfig.json +30 -0
- package/tsdown.config.ts +18 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Assemble cleaned pages into one Markdown document.
|
|
2
|
+
|
|
3
|
+
The merge is deliberately a separate pass over the per-page records: the OCR
|
|
4
|
+
pass only has to produce page files, and this pass only has to read them, so the
|
|
5
|
+
resident cost of a long document stays flat and an interrupted run keeps every
|
|
6
|
+
page it already finished.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from statistics import median
|
|
14
|
+
from typing import Mapping, Sequence
|
|
15
|
+
|
|
16
|
+
from clean import Page, ends_sentence, join_wrapped, running_labels, strip_noise
|
|
17
|
+
from layout import (
|
|
18
|
+
Line,
|
|
19
|
+
body_height,
|
|
20
|
+
heading_level,
|
|
21
|
+
is_cjk,
|
|
22
|
+
is_list_item,
|
|
23
|
+
is_named_section,
|
|
24
|
+
merge_list_markers,
|
|
25
|
+
normalize_heading,
|
|
26
|
+
section_number,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class MergeOptions:
|
|
32
|
+
"""Document-level switches and thresholds for the merge pass."""
|
|
33
|
+
|
|
34
|
+
remove_page_numbers: bool = True
|
|
35
|
+
remove_running_heads: bool = True
|
|
36
|
+
running_head_ratio: float = 0.6
|
|
37
|
+
running_head_min_pages: int = 3
|
|
38
|
+
detect_headings: bool = True
|
|
39
|
+
heading_min_ratio: float = 1.18
|
|
40
|
+
#: Left-edge offset, in body glyph heights, that marks an indented first line.
|
|
41
|
+
indent_ratio: float = 1.0
|
|
42
|
+
#: Vertical gap, in body glyph heights, that separates two paragraphs.
|
|
43
|
+
paragraph_gap_ratio: float = 0.85
|
|
44
|
+
#: Glyph height over body height that makes a line an outline candidate.
|
|
45
|
+
outline_candidate_ratio: float = 1.05
|
|
46
|
+
#: Most candidates one document may carry; a model pass reads the first ones.
|
|
47
|
+
max_outline_candidates: int = 400
|
|
48
|
+
#: Candidate id to heading level, assigned by a model-assisted outline pass.
|
|
49
|
+
#: A level below 1, like the absence of one, makes the line body text.
|
|
50
|
+
level_overrides: Mapping[str, int | None] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
#: A line taller than this multiple of the body glyph height is display type,
|
|
54
|
+
#: which is content even when it reprints the running head on every page.
|
|
55
|
+
FURNITURE_HEIGHT_RATIO = 1.15
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class MergeStats:
|
|
60
|
+
"""What the merge pass produced, for the tool's result line."""
|
|
61
|
+
|
|
62
|
+
pages: int
|
|
63
|
+
lines: int
|
|
64
|
+
headings: int
|
|
65
|
+
dropped_page_numbers: int
|
|
66
|
+
dropped_running_heads: int
|
|
67
|
+
joined_across_pages: int
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class OutlineCandidate:
|
|
72
|
+
"""One line a model-assisted outline pass may re-level.
|
|
73
|
+
|
|
74
|
+
The heuristic reads geometry and numbering; a candidate is a line where that
|
|
75
|
+
reading may be wrong in either direction, so the caller can have a model
|
|
76
|
+
assign the level instead.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
id: str
|
|
80
|
+
page: int
|
|
81
|
+
text: str
|
|
82
|
+
level: int | None
|
|
83
|
+
#: Glyph height over body glyph height, rounded for the caller's prompt.
|
|
84
|
+
ratio: float
|
|
85
|
+
#: The section number the line opens with, when it opens with one.
|
|
86
|
+
numbered: str | None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def page_text(lines: Sequence[Line]) -> str:
|
|
90
|
+
"""Render one page's lines as the plain text stored beside its record."""
|
|
91
|
+
return '\n'.join(line.text.strip() for line in lines if line.text.strip() != '')
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _left_margin(lines: Sequence[Line]) -> float:
|
|
95
|
+
"""Estimate the left margin from the lines that are not indented."""
|
|
96
|
+
if not lines:
|
|
97
|
+
return 0.0
|
|
98
|
+
return median([line.x0 for line in lines])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _dedupe_repeats(lines: Sequence[Line]) -> list[Line]:
|
|
102
|
+
"""Drop runs of identical consecutive lines, which OCR sometimes emits."""
|
|
103
|
+
kept: list[Line] = []
|
|
104
|
+
for line in lines:
|
|
105
|
+
if kept and kept[-1].text == line.text:
|
|
106
|
+
continue
|
|
107
|
+
kept.append(line)
|
|
108
|
+
return kept
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _override_level(candidate_id: str, value: object) -> int | None:
|
|
112
|
+
"""Read the level a model pass assigned to one outline candidate.
|
|
113
|
+
|
|
114
|
+
A level below 1, or no level at all, makes the line body text: that is how a
|
|
115
|
+
candidate the heuristic wrongly called a heading is demoted.
|
|
116
|
+
@param candidate_id: the candidate the caller named.
|
|
117
|
+
@param value: the level as it arrived from the caller.
|
|
118
|
+
@returns: a Markdown level from 1 to 6, or None for body text.
|
|
119
|
+
"""
|
|
120
|
+
if value is None:
|
|
121
|
+
return None
|
|
122
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
123
|
+
raise ValueError('outline level override for ' + candidate_id + ' is not an integer: ' + repr(value))
|
|
124
|
+
if value < 1:
|
|
125
|
+
return None
|
|
126
|
+
return min(value, 6)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _outline_pass(
|
|
130
|
+
pages: Sequence[Page],
|
|
131
|
+
kept: Mapping[int, list[Line]],
|
|
132
|
+
base: float,
|
|
133
|
+
options: MergeOptions,
|
|
134
|
+
) -> tuple[list[OutlineCandidate], dict[tuple[int, int], int | None], bool]:
|
|
135
|
+
"""Collect the lines a model-assisted outline pass may re-level.
|
|
136
|
+
|
|
137
|
+
The walk follows the merge's own order, so an id names the line the block
|
|
138
|
+
loop reaches at the same page number and position.
|
|
139
|
+
@param pages: every page of the document, in order.
|
|
140
|
+
@param kept: each page number's kept lines.
|
|
141
|
+
@param base: the document's body glyph height.
|
|
142
|
+
@param options: the deployment's thresholds and the caller's level overrides.
|
|
143
|
+
@returns: the capped candidate list, the levels the overrides settle, keyed
|
|
144
|
+
by page number and line position, and whether the cap dropped a
|
|
145
|
+
candidate.
|
|
146
|
+
"""
|
|
147
|
+
settled = {
|
|
148
|
+
candidate_id: _override_level(candidate_id, value)
|
|
149
|
+
for candidate_id, value in options.level_overrides.items()
|
|
150
|
+
}
|
|
151
|
+
candidates: list[OutlineCandidate] = []
|
|
152
|
+
overrides: dict[tuple[int, int], int | None] = {}
|
|
153
|
+
truncated = False
|
|
154
|
+
for page in pages:
|
|
155
|
+
for position, line in enumerate(kept[page.index]):
|
|
156
|
+
text = line.text.strip()
|
|
157
|
+
# The candidate list is what the caller's model pass reads, so it
|
|
158
|
+
# does not depend on whether the heuristic may render headings on
|
|
159
|
+
# its own: a candidate the overrides leave alone keeps that
|
|
160
|
+
# behaviour exactly.
|
|
161
|
+
level = heading_level(text, line.height, base, options.heading_min_ratio)
|
|
162
|
+
numbered = section_number(text)
|
|
163
|
+
if is_list_item(text) and numbered is None:
|
|
164
|
+
continue
|
|
165
|
+
tall = base > 0 and line.height / base >= options.outline_candidate_ratio
|
|
166
|
+
if level is None and numbered is None and not tall and not is_named_section(text):
|
|
167
|
+
continue
|
|
168
|
+
if len(candidates) >= options.max_outline_candidates:
|
|
169
|
+
truncated = True
|
|
170
|
+
continue
|
|
171
|
+
candidate_id = 'h' + str(len(candidates) + 1)
|
|
172
|
+
if candidate_id in settled:
|
|
173
|
+
overrides[(page.index, position)] = settled[candidate_id]
|
|
174
|
+
candidates.append(OutlineCandidate(
|
|
175
|
+
id=candidate_id,
|
|
176
|
+
page=page.index,
|
|
177
|
+
text=normalize_heading(text),
|
|
178
|
+
level=level,
|
|
179
|
+
ratio=round(line.height / base, 2) if base > 0 else 0.0,
|
|
180
|
+
numbered=None if numbered is None else text[:numbered[1]].strip(),
|
|
181
|
+
))
|
|
182
|
+
return candidates, overrides, truncated
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def build_markdown(
|
|
186
|
+
pages: Sequence[Page],
|
|
187
|
+
options: MergeOptions,
|
|
188
|
+
) -> tuple[str, MergeStats, list[OutlineCandidate], bool]:
|
|
189
|
+
"""Turn the per-page records of one document into a single Markdown text.
|
|
190
|
+
|
|
191
|
+
@param pages: every page of the document, in order.
|
|
192
|
+
@param options: the deployment's cleaning and structure thresholds.
|
|
193
|
+
@returns: the Markdown document, the counts the tool reports, the outline
|
|
194
|
+
candidates a model-assisted pass may re-level, and whether the candidate
|
|
195
|
+
list hit its cap.
|
|
196
|
+
"""
|
|
197
|
+
# The size ceiling that separates furniture from display type comes from a
|
|
198
|
+
# first pass over the raw lines, because the lines it removes must not be
|
|
199
|
+
# allowed to shape the body estimate they are measured against.
|
|
200
|
+
raw_lines = [line for page in pages for line in page.lines]
|
|
201
|
+
raw_base = body_height(raw_lines)
|
|
202
|
+
furniture_ceiling = raw_base * FURNITURE_HEIGHT_RATIO if raw_base > 0 else 0.0
|
|
203
|
+
labels = (
|
|
204
|
+
running_labels(
|
|
205
|
+
pages,
|
|
206
|
+
options.running_head_ratio,
|
|
207
|
+
options.running_head_min_pages,
|
|
208
|
+
max_height=furniture_ceiling,
|
|
209
|
+
)
|
|
210
|
+
if options.remove_running_heads
|
|
211
|
+
else set()
|
|
212
|
+
)
|
|
213
|
+
# Kept lines are keyed by page number, not by position: a selection may
|
|
214
|
+
# start at any page, and a repeated page number would otherwise be read
|
|
215
|
+
# twice.
|
|
216
|
+
kept: dict[int, list[Line]] = {}
|
|
217
|
+
for page in pages:
|
|
218
|
+
if page.index in kept:
|
|
219
|
+
raise ValueError('page index ' + str(page.index) + ' appears twice in the document')
|
|
220
|
+
kept[page.index] = _dedupe_repeats(
|
|
221
|
+
strip_noise(
|
|
222
|
+
page,
|
|
223
|
+
labels,
|
|
224
|
+
options.remove_page_numbers,
|
|
225
|
+
options.remove_running_heads,
|
|
226
|
+
max_height=furniture_ceiling,
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
all_lines = [line for page in pages for line in kept[page.index]]
|
|
230
|
+
base = body_height(all_lines)
|
|
231
|
+
margin = _left_margin([line for line in all_lines if not is_list_item(line.text)])
|
|
232
|
+
|
|
233
|
+
after_numbers = sum(
|
|
234
|
+
len(strip_noise(page, set(), True, False)) for page in pages
|
|
235
|
+
)
|
|
236
|
+
dropped_numbers = len(raw_lines) - after_numbers
|
|
237
|
+
dropped_heads = max(0, after_numbers - len(all_lines))
|
|
238
|
+
|
|
239
|
+
candidates, overrides, truncated = _outline_pass(pages, kept, base, options)
|
|
240
|
+
|
|
241
|
+
blocks: list[list[str]] = []
|
|
242
|
+
paragraph: list[tuple[Page, Line]] = []
|
|
243
|
+
headings = 0
|
|
244
|
+
joined = 0
|
|
245
|
+
|
|
246
|
+
def flush() -> None:
|
|
247
|
+
if paragraph:
|
|
248
|
+
text = paragraph[0][1].text.strip()
|
|
249
|
+
for _page, line in paragraph[1:]:
|
|
250
|
+
text = join_wrapped(text, line.text.strip())
|
|
251
|
+
blocks.append(['text', text])
|
|
252
|
+
paragraph.clear()
|
|
253
|
+
|
|
254
|
+
for page in pages:
|
|
255
|
+
lines = kept[page.index]
|
|
256
|
+
previous: Line | None = None
|
|
257
|
+
for position, line in enumerate(lines):
|
|
258
|
+
text = line.text.strip()
|
|
259
|
+
level = (
|
|
260
|
+
heading_level(text, line.height, base, options.heading_min_ratio)
|
|
261
|
+
if options.detect_headings
|
|
262
|
+
else None
|
|
263
|
+
)
|
|
264
|
+
# An override to None means body text, so the key's presence decides.
|
|
265
|
+
if (page.index, position) in overrides:
|
|
266
|
+
level = overrides[(page.index, position)]
|
|
267
|
+
if level is not None:
|
|
268
|
+
flush()
|
|
269
|
+
blocks.append(['heading', '#' * level + ' ' + normalize_heading(text)])
|
|
270
|
+
headings += 1
|
|
271
|
+
previous = line
|
|
272
|
+
continue
|
|
273
|
+
if is_list_item(text):
|
|
274
|
+
flush()
|
|
275
|
+
blocks.append(['item', merge_list_markers(text)])
|
|
276
|
+
previous = line
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
first_on_page = position == 0
|
|
280
|
+
if paragraph and first_on_page:
|
|
281
|
+
open_text = paragraph[0][1].text.strip()
|
|
282
|
+
for _page, earlier in paragraph[1:]:
|
|
283
|
+
open_text = join_wrapped(open_text, earlier.text.strip())
|
|
284
|
+
indented = line.x0 > margin + options.indent_ratio * base
|
|
285
|
+
if not indented and not ends_sentence(open_text):
|
|
286
|
+
paragraph.append((page, line))
|
|
287
|
+
joined += 1
|
|
288
|
+
previous = line
|
|
289
|
+
continue
|
|
290
|
+
flush()
|
|
291
|
+
elif paragraph:
|
|
292
|
+
gap = 0.0 if previous is None else line.y0 - previous.y1
|
|
293
|
+
indented = line.x0 > margin + options.indent_ratio * base
|
|
294
|
+
if indented or gap > options.paragraph_gap_ratio * base:
|
|
295
|
+
flush()
|
|
296
|
+
paragraph.append((page, line))
|
|
297
|
+
previous = line
|
|
298
|
+
flush()
|
|
299
|
+
|
|
300
|
+
markdown = _render_blocks(blocks)
|
|
301
|
+
return markdown, MergeStats(
|
|
302
|
+
pages=len(pages),
|
|
303
|
+
lines=len(all_lines),
|
|
304
|
+
headings=headings,
|
|
305
|
+
dropped_page_numbers=dropped_numbers,
|
|
306
|
+
dropped_running_heads=dropped_heads,
|
|
307
|
+
joined_across_pages=joined,
|
|
308
|
+
), candidates, truncated
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _render_blocks(blocks: Sequence[Sequence[str]]) -> str:
|
|
312
|
+
"""Join the merged blocks into Markdown, grouping consecutive list items."""
|
|
313
|
+
parts: list[str] = []
|
|
314
|
+
pending_items: list[str] = []
|
|
315
|
+
for block in blocks:
|
|
316
|
+
if block[0] == 'item':
|
|
317
|
+
pending_items.append(block[1])
|
|
318
|
+
continue
|
|
319
|
+
if pending_items:
|
|
320
|
+
parts.append('\n'.join(pending_items))
|
|
321
|
+
pending_items = []
|
|
322
|
+
parts.append(block[1])
|
|
323
|
+
if pending_items:
|
|
324
|
+
parts.append('\n'.join(pending_items))
|
|
325
|
+
return normalize_markdown('\n\n'.join(parts))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
#: A space between two CJK characters is a line-box artifact, never text. Only
|
|
329
|
+
#: blanks within a line are removed: a newline still separates two blocks.
|
|
330
|
+
_CJK_SPACE = re.compile(r'(?<=[\u3400-\u9fff\uf900-\ufaff\u3040-\u30ff])[ \t]+(?=[\u3400-\u9fff\uf900-\ufaff\u3040-\u30ff])')
|
|
331
|
+
|
|
332
|
+
#: Spaces that OCR inserts before closing punctuation or after opening punctuation.
|
|
333
|
+
_BEFORE_CLOSER = re.compile(r'[ \t]+(?=[,。、;:?!)》」』】”’])')
|
|
334
|
+
_AFTER_OPENER = re.compile(r'(?<=[(《「『【“‘])[ \t]+')
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def normalize_markdown(text: str) -> str:
|
|
338
|
+
"""Remove the whitespace artifacts a page-by-page merge leaves behind.
|
|
339
|
+
|
|
340
|
+
@param text: the joined Markdown body.
|
|
341
|
+
@returns: the same body with collapsed blank runs, no trailing spaces, and no
|
|
342
|
+
spaces that would only exist because of a line box.
|
|
343
|
+
"""
|
|
344
|
+
lines = [line.rstrip() for line in text.replace('\r\n', '\n').split('\n')]
|
|
345
|
+
collapsed: list[str] = []
|
|
346
|
+
for line in lines:
|
|
347
|
+
if line == '' and collapsed and collapsed[-1] == '':
|
|
348
|
+
continue
|
|
349
|
+
collapsed.append(line)
|
|
350
|
+
# A heading's spacing is deliberate, so the line-box cleanup skips it.
|
|
351
|
+
cleaned = [
|
|
352
|
+
line if line.startswith('#') else _CJK_SPACE.sub('', line)
|
|
353
|
+
for line in collapsed
|
|
354
|
+
]
|
|
355
|
+
body = '\n'.join(cleaned).strip('\n')
|
|
356
|
+
body = _BEFORE_CLOSER.sub('', body)
|
|
357
|
+
body = _AFTER_OPENER.sub('', body)
|
|
358
|
+
return body.rstrip() + '\n' if body.strip() != '' else ''
|
package/python/clean.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Page-level noise removal and cross-page paragraph stitching.
|
|
2
|
+
|
|
3
|
+
A page of OCR output carries furniture that is not part of the document body:
|
|
4
|
+
the folio, the running head or foot, and the whitespace the page break leaves
|
|
5
|
+
behind. This module identifies that furniture from geometry and from repetition
|
|
6
|
+
across pages, so a bound volume reads as continuous prose.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import unicodedata
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from math import ceil
|
|
15
|
+
from typing import Sequence
|
|
16
|
+
|
|
17
|
+
from layout import Line, is_cjk
|
|
18
|
+
|
|
19
|
+
#: Fraction of page height at each edge that counts as the header/footer band.
|
|
20
|
+
BAND_FRACTION = 0.14
|
|
21
|
+
|
|
22
|
+
#: Patterns that identify a folio on their own, wherever the line sits. Each
|
|
23
|
+
#: carries a decoration, a caption, or a total, so plain content cannot match it.
|
|
24
|
+
STRONG_PAGE_NUMBER = (
|
|
25
|
+
re.compile(r'^[-—–=~·]\s*\d{1,5}\s*[-—–=~·]$'),
|
|
26
|
+
re.compile(r'^\d{1,5}\s*/\s*\d{1,5}$'),
|
|
27
|
+
re.compile(r'^[第頁页]\s*\d{1,5}\s*[頁页]?$'),
|
|
28
|
+
re.compile(r'^第\s*\d+\s*页\s*共\s*\d+\s*页$'),
|
|
29
|
+
re.compile(r'^(?:page|page\s+no\.?|p\.)\s*\d+(?:\s*(?:of|/)\s*\d+)?\.?$', re.IGNORECASE),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
#: Patterns that identify a folio only inside the header or footer band, because
|
|
33
|
+
#: a bare number in the middle of a page is ordinary content.
|
|
34
|
+
WEAK_PAGE_NUMBER = (
|
|
35
|
+
re.compile(r'^\d{1,5}$'),
|
|
36
|
+
re.compile(r'^[ivxlcdm]{1,8}$', re.IGNORECASE),
|
|
37
|
+
re.compile(r'^[-—–=~·]\s*\d{1,5}$'),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
#: Characters a folio may carry around its number without becoming content.
|
|
41
|
+
_FOLIO_FILLER = re.compile(r'^[\s\-—–=~·.()\[\]{}<>|/\\]+$')
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Page:
|
|
46
|
+
"""One rendered page: its size and the lines recognized on it."""
|
|
47
|
+
|
|
48
|
+
index: int
|
|
49
|
+
width: float
|
|
50
|
+
height: float
|
|
51
|
+
lines: list[Line] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def normalize_label(text: str) -> str:
|
|
55
|
+
"""Fold a line to the key used for cross-page repetition counting.
|
|
56
|
+
|
|
57
|
+
Digits and Roman numerals are replaced by a placeholder so that a running
|
|
58
|
+
head whose only variation is the folio counts as one repeated label.
|
|
59
|
+
"""
|
|
60
|
+
folded = unicodedata.normalize('NFKC', text).strip().lower()
|
|
61
|
+
folded = re.sub(r'\d+', '#', folded)
|
|
62
|
+
folded = re.sub(r'\s+', ' ', folded)
|
|
63
|
+
return folded
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def band_of(line: Line, page_height: float, fraction: float = BAND_FRACTION) -> str | None:
|
|
67
|
+
"""Report which edge band, if any, contains a line."""
|
|
68
|
+
if page_height <= 0:
|
|
69
|
+
return None
|
|
70
|
+
if line.y1 <= fraction * page_height:
|
|
71
|
+
return 'top'
|
|
72
|
+
if line.y0 >= (1.0 - fraction) * page_height:
|
|
73
|
+
return 'bottom'
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_page_number(line: Line, page_height: float, fraction: float = BAND_FRACTION) -> bool:
|
|
78
|
+
"""Report whether a line is a folio rather than document content."""
|
|
79
|
+
text = line.text.strip()
|
|
80
|
+
if text == '' or len(text) > 32:
|
|
81
|
+
return False
|
|
82
|
+
bare = text.strip('-—–=~·. ')
|
|
83
|
+
if bare == '' or _FOLIO_FILLER.match(text):
|
|
84
|
+
return True
|
|
85
|
+
for pattern in STRONG_PAGE_NUMBER:
|
|
86
|
+
if pattern.match(text):
|
|
87
|
+
return True
|
|
88
|
+
if band_of(line, page_height, fraction) is not None:
|
|
89
|
+
for pattern in WEAK_PAGE_NUMBER:
|
|
90
|
+
if pattern.match(text):
|
|
91
|
+
return True
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def running_labels(
|
|
96
|
+
pages: Sequence[Page],
|
|
97
|
+
ratio: float,
|
|
98
|
+
min_pages: int,
|
|
99
|
+
fraction: float = BAND_FRACTION,
|
|
100
|
+
max_height: float = 0.0,
|
|
101
|
+
) -> set[str]:
|
|
102
|
+
"""Collect the labels that repeat in the edge bands of most pages.
|
|
103
|
+
|
|
104
|
+
A label repeated in the same band on enough pages is furniture (a running
|
|
105
|
+
head, a journal name, a download stamp), not content. Requiring an absolute
|
|
106
|
+
page minimum keeps a two-page document from losing its own repeated lines,
|
|
107
|
+
and the height ceiling keeps a title set in display type from voting: a
|
|
108
|
+
document whose title is reprinted as its running head must keep the title on
|
|
109
|
+
the page that opens it.
|
|
110
|
+
|
|
111
|
+
@param pages: every page of the document, in order.
|
|
112
|
+
@param ratio: fraction of pages a label must appear on.
|
|
113
|
+
@param min_pages: fewest pages a label must appear on, whatever the ratio.
|
|
114
|
+
@param fraction: size of each edge band as a fraction of page height.
|
|
115
|
+
@param max_height: tallest line that may count as furniture; 0 disables it.
|
|
116
|
+
@returns: the folded labels to drop.
|
|
117
|
+
"""
|
|
118
|
+
if not pages:
|
|
119
|
+
return set()
|
|
120
|
+
seen: dict[tuple[str, str], set[int]] = {}
|
|
121
|
+
for page in pages:
|
|
122
|
+
for line in page.lines:
|
|
123
|
+
if max_height > 0 and line.height > max_height:
|
|
124
|
+
continue
|
|
125
|
+
band = band_of(line, page.height, fraction)
|
|
126
|
+
if band is None:
|
|
127
|
+
continue
|
|
128
|
+
label = normalize_label(line.text)
|
|
129
|
+
if label == '' or len(label) > 80:
|
|
130
|
+
continue
|
|
131
|
+
seen.setdefault((band, label), set()).add(page.index)
|
|
132
|
+
threshold = max(min_pages, ceil(ratio * len(pages)))
|
|
133
|
+
return {label for (_band, label), indexes in seen.items() if len(indexes) >= threshold}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def strip_noise(
|
|
137
|
+
page: Page,
|
|
138
|
+
labels: set[str],
|
|
139
|
+
remove_page_numbers: bool,
|
|
140
|
+
remove_running_heads: bool,
|
|
141
|
+
fraction: float = BAND_FRACTION,
|
|
142
|
+
max_height: float = 0.0,
|
|
143
|
+
) -> list[Line]:
|
|
144
|
+
"""Drop a page's furniture and return the lines that are document content.
|
|
145
|
+
|
|
146
|
+
@param page: the page to clean.
|
|
147
|
+
@param labels: running-head labels collected from the whole document.
|
|
148
|
+
@param remove_page_numbers: whether folios are dropped.
|
|
149
|
+
@param remove_running_heads: whether repeated edge labels are dropped.
|
|
150
|
+
@param fraction: size of each edge band as a fraction of page height.
|
|
151
|
+
@param max_height: tallest line the label rule may drop; 0 disables it.
|
|
152
|
+
@returns: the remaining lines, in reading order.
|
|
153
|
+
"""
|
|
154
|
+
kept: list[Line] = []
|
|
155
|
+
for line in page.lines:
|
|
156
|
+
if line.text.strip() == '':
|
|
157
|
+
continue
|
|
158
|
+
if remove_page_numbers and is_page_number(line, page.height, fraction):
|
|
159
|
+
continue
|
|
160
|
+
if remove_running_heads and (max_height <= 0 or line.height <= max_height):
|
|
161
|
+
band = band_of(line, page.height, fraction)
|
|
162
|
+
if band is not None and normalize_label(line.text) in labels:
|
|
163
|
+
continue
|
|
164
|
+
kept.append(line)
|
|
165
|
+
return kept
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
#: Characters that close a sentence, so the next line starts a new one.
|
|
169
|
+
TERMINAL = '。!?…;!?;'
|
|
170
|
+
|
|
171
|
+
#: Characters that close a bracket run, which also ends the sentence.
|
|
172
|
+
CLOSERS = '”"』」)》】)\']'
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def ends_sentence(text: str) -> bool:
|
|
176
|
+
"""Report whether a line finishes the sentence it belongs to."""
|
|
177
|
+
stripped = text.rstrip()
|
|
178
|
+
while stripped and stripped[-1] in CLOSERS:
|
|
179
|
+
stripped = stripped[:-1].rstrip()
|
|
180
|
+
return stripped.endswith(tuple(TERMINAL))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def join_wrapped(previous: str, following: str) -> str:
|
|
184
|
+
"""Join two lines of one paragraph the way the source language demands.
|
|
185
|
+
|
|
186
|
+
Latin text keeps a single space and repairs a hyphenated break; CJK text is
|
|
187
|
+
joined with nothing, because the space would be an artifact of the line box.
|
|
188
|
+
"""
|
|
189
|
+
left = previous.rstrip()
|
|
190
|
+
right = following.lstrip()
|
|
191
|
+
if left.endswith('-') and right[:1].islower():
|
|
192
|
+
return left[:-1] + right
|
|
193
|
+
if left.endswith(tuple('([{“‘《〈「『(【')):
|
|
194
|
+
return left + right
|
|
195
|
+
if left and right and (is_cjk(left[-1]) or is_cjk(right[0])):
|
|
196
|
+
return left + right
|
|
197
|
+
return left + ' ' + right
|