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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +378 -0
  3. package/cordis.patch.yml +7 -0
  4. package/lib/artifacts.d.ts +100 -0
  5. package/lib/artifacts.d.ts.map +1 -0
  6. package/lib/artifacts.js +97 -0
  7. package/lib/artifacts.js.map +1 -0
  8. package/lib/config.d.ts +77 -0
  9. package/lib/config.d.ts.map +1 -0
  10. package/lib/config.js +51 -0
  11. package/lib/config.js.map +1 -0
  12. package/lib/documents.d.ts +62 -0
  13. package/lib/documents.d.ts.map +1 -0
  14. package/lib/documents.js +173 -0
  15. package/lib/documents.js.map +1 -0
  16. package/lib/events.d.ts +161 -0
  17. package/lib/events.d.ts.map +1 -0
  18. package/lib/events.js +158 -0
  19. package/lib/events.js.map +1 -0
  20. package/lib/filename.d.ts +47 -0
  21. package/lib/filename.d.ts.map +1 -0
  22. package/lib/filename.js +77 -0
  23. package/lib/filename.js.map +1 -0
  24. package/lib/index.d.ts +85 -0
  25. package/lib/index.d.ts.map +1 -0
  26. package/lib/index.js +1761 -0
  27. package/lib/index.js.map +1 -0
  28. package/lib/levels.d.ts +24 -0
  29. package/lib/levels.d.ts.map +1 -0
  30. package/lib/levels.js +52 -0
  31. package/lib/levels.js.map +1 -0
  32. package/lib/plan.d.ts +103 -0
  33. package/lib/plan.d.ts.map +1 -0
  34. package/lib/plan.js +210 -0
  35. package/lib/plan.js.map +1 -0
  36. package/lib/recognize.d.ts +36 -0
  37. package/lib/recognize.d.ts.map +1 -0
  38. package/lib/recognize.js +390 -0
  39. package/lib/recognize.js.map +1 -0
  40. package/lib/records.d.ts +91 -0
  41. package/lib/records.d.ts.map +1 -0
  42. package/lib/records.js +130 -0
  43. package/lib/records.js.map +1 -0
  44. package/lib/render.d.ts +19 -0
  45. package/lib/render.d.ts.map +1 -0
  46. package/lib/render.js +45 -0
  47. package/lib/render.js.map +1 -0
  48. package/lib/sandbox.d.ts +54 -0
  49. package/lib/sandbox.d.ts.map +1 -0
  50. package/lib/sandbox.js +101 -0
  51. package/lib/sandbox.js.map +1 -0
  52. package/lib/types.d.ts +147 -0
  53. package/lib/types.d.ts.map +1 -0
  54. package/lib/types.js +7 -0
  55. package/lib/types.js.map +1 -0
  56. package/lib/worker.d.ts +107 -0
  57. package/lib/worker.d.ts.map +1 -0
  58. package/lib/worker.js +143 -0
  59. package/lib/worker.js.map +1 -0
  60. package/package.json +98 -0
  61. package/python/README.md +125 -0
  62. package/python/assemble.py +358 -0
  63. package/python/clean.py +197 -0
  64. package/python/layout.py +403 -0
  65. package/python/ocr_worker.py +516 -0
  66. package/python/requirements.txt +16 -0
  67. package/python/source.py +182 -0
  68. package/scripts/setup.mjs +251 -0
  69. package/tsconfig.json +30 -0
  70. 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 ''
@@ -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