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,403 @@
1
+ """Box geometry, line assembly, column detection, and heading inference.
2
+
3
+ The OCR engine returns one bounding box per detected text region, in no
4
+ guaranteed order. Everything that turns those boxes into ordered lines and a
5
+ document outline lives here, with no engine, file, or process dependency, so the
6
+ layout rules can be driven directly by tests.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import unicodedata
13
+ from dataclasses import dataclass
14
+ from statistics import median
15
+ from typing import Sequence
16
+
17
+ #: Two boxes on one row further apart than this multiple of the row height are
18
+ #: treated as separate fields rather than as words of one line.
19
+ WIDE_GAP_FACTOR = 1.6
20
+
21
+ #: Vertical overlap, relative to the shorter box, that puts two boxes on one row.
22
+ ROW_OVERLAP = 0.5
23
+
24
+ #: Horizontal run of free space, as a fraction of page width, a gutter must span.
25
+ GUTTER_MIN_WIDTH = 0.02
26
+
27
+ #: Fraction of rows that must be free across a gutter for it to be a column split.
28
+ GUTTER_ROW_RATIO = 0.60
29
+
30
+ #: Fewest rows a page needs before column detection is attempted at all.
31
+ GUTTER_MIN_ROWS = 8
32
+
33
+
34
+ @dataclass
35
+ class Line:
36
+ """One detected text region, with the geometry its layout depends on."""
37
+
38
+ text: str
39
+ score: float
40
+ x0: float
41
+ y0: float
42
+ x1: float
43
+ y1: float
44
+ column: int = 0
45
+
46
+ @property
47
+ def height(self) -> float:
48
+ """Vertical extent of the region, which approximates the glyph size."""
49
+ return self.y1 - self.y0
50
+
51
+ @property
52
+ def width(self) -> float:
53
+ """Horizontal extent of the region."""
54
+ return self.x1 - self.x0
55
+
56
+ @property
57
+ def center_y(self) -> float:
58
+ """Vertical midpoint, used to order rows."""
59
+ return (self.y0 + self.y1) / 2.0
60
+
61
+
62
+ def box_geometry(box: Sequence[Sequence[float]]) -> tuple[float, float, float, float]:
63
+ """Reduce one four-point OCR box to its upright bounding rectangle.
64
+
65
+ @param box: four (x, y) corners, in any rotation order.
66
+ @returns: (x0, y0, x1, y1) of the enclosing rectangle.
67
+ """
68
+ xs = [float(point[0]) for point in box]
69
+ ys = [float(point[1]) for point in box]
70
+ return min(xs), min(ys), max(xs), max(ys)
71
+
72
+
73
+ def is_cjk(character: str) -> bool:
74
+ """Report whether a character is written without inter-word spacing.
75
+
76
+ @param character: one character.
77
+ @returns: True for CJK ideographs, kana, and full-width punctuation.
78
+ """
79
+ code = ord(character)
80
+ return (
81
+ 0x3000 <= code <= 0x303F
82
+ or 0x3040 <= code <= 0x30FF
83
+ or 0x3400 <= code <= 0x4DBF
84
+ or 0x4E00 <= code <= 0x9FFF
85
+ or 0xF900 <= code <= 0xFAFF
86
+ or 0xFF00 <= code <= 0xFFEF
87
+ or 0x20000 <= code <= 0x2FA1F
88
+ )
89
+
90
+
91
+ def collapse_space(text: str) -> str:
92
+ """Collapse whitespace runs and drop the invisible characters OCR emits.
93
+
94
+ @param text: raw recognized text.
95
+ @returns: the text with single spaces and no zero-width or control characters.
96
+ """
97
+ cleaned = text.replace('\u3000', ' ')
98
+ cleaned = ''.join(
99
+ character
100
+ for character in cleaned
101
+ if character.isspace() or unicodedata.category(character)[0] != 'C'
102
+ )
103
+ return re.sub(r'[ \t\r\n]+', ' ', cleaned).strip()
104
+
105
+
106
+ def _rows(items: Sequence[Line]) -> list[list[Line]]:
107
+ """Group same-row boxes by vertical overlap.
108
+
109
+ @param items: boxes of one column, in any order.
110
+ @returns: rows, each a list of boxes, ordered top to bottom.
111
+ """
112
+ rows: list[list[Line]] = []
113
+ for item in sorted(items, key=lambda line: line.y0):
114
+ target: list[Line] | None = None
115
+ for row in rows:
116
+ reference = row[0]
117
+ overlap = min(item.y1, reference.y1) - max(item.y0, reference.y0)
118
+ if overlap >= ROW_OVERLAP * min(item.height, reference.height):
119
+ target = row
120
+ break
121
+ if target is None:
122
+ rows.append([item])
123
+ else:
124
+ target.append(item)
125
+ return rows
126
+
127
+
128
+ def merge_row(row: Sequence[Line], column: int) -> Line:
129
+ """Flatten one row of boxes into a single line in left-to-right order.
130
+
131
+ Adjacent CJK fragments are joined with no space; a Latin fragment boundary
132
+ keeps one, because there the space is part of the text.
133
+ @param row: the row's boxes.
134
+ @param column: column index the row belongs to.
135
+ @returns: the merged line.
136
+ """
137
+ ordered = sorted(row, key=lambda line: line.x0)
138
+ pieces: list[tuple[str, float]] = []
139
+ for index, item in enumerate(ordered):
140
+ text = collapse_space(item.text)
141
+ if text == '':
142
+ continue
143
+ gap = 0.0 if not pieces else item.x0 - ordered[index - 1].x1
144
+ pieces.append((text, gap))
145
+ joined = ''
146
+ for index, (piece, gap) in enumerate(pieces):
147
+ if index > 0 and joined != '':
148
+ wide = gap > WIDE_GAP_FACTOR * max(ordered[0].height, 1.0)
149
+ if not (is_cjk(joined[-1]) and is_cjk(piece[0])):
150
+ joined += ' ' if wide else ' '
151
+ joined += piece
152
+ return Line(
153
+ text=joined.strip(),
154
+ score=min(line.score for line in ordered),
155
+ x0=min(line.x0 for line in ordered),
156
+ y0=min(line.y0 for line in ordered),
157
+ x1=max(line.x1 for line in ordered),
158
+ y1=max(line.y1 for line in ordered),
159
+ column=column,
160
+ )
161
+
162
+
163
+ def free_runs(row: Sequence[Line], width: float) -> list[tuple[float, float]]:
164
+ """Compute the horizontal spans of one row that carry no box.
165
+
166
+ @param row: the row's boxes.
167
+ @param width: page width in pixels.
168
+ @returns: free spans as (start, end) pairs, left to right.
169
+ """
170
+ spans = sorted((item.x0, item.x1) for item in row)
171
+ runs: list[tuple[float, float]] = []
172
+ cursor = 0.0
173
+ for start, end in spans:
174
+ if start - cursor > 0.0:
175
+ runs.append((cursor, start))
176
+ cursor = max(cursor, end)
177
+ if width - cursor > 0.0:
178
+ runs.append((cursor, width))
179
+ return runs
180
+
181
+
182
+ def find_gutter(rows: Sequence[Sequence[Line]], width: float) -> float | None:
183
+ """Locate a vertical gutter that splits the page into reading columns.
184
+
185
+ A gutter is a horizontal position that falls inside a free span of most
186
+ rows, so text left of it and text right of it are separate reading columns
187
+ rather than two fragments of one line.
188
+ @param rows: every row of the page.
189
+ @param width: page width in pixels.
190
+ @returns: the gutter's x position, or None when the page is single-column.
191
+ """
192
+ if len(rows) < GUTTER_MIN_ROWS or width <= 0:
193
+ return None
194
+ steps = 50
195
+ best_coverage = 0
196
+ best_positions: list[float] = []
197
+ for step in range(1, steps):
198
+ position = width * step / steps
199
+ coverage = 0
200
+ for row in rows:
201
+ for start, end in free_runs(row, width):
202
+ # A page margin is free on every row, so only an interior run
203
+ # can be the gutter between two columns.
204
+ if start <= 0.0 or end >= width:
205
+ continue
206
+ if start < position < end and (end - start) >= GUTTER_MIN_WIDTH * width:
207
+ coverage += 1
208
+ break
209
+ if coverage > best_coverage:
210
+ best_coverage = coverage
211
+ best_positions = [position]
212
+ elif coverage == best_coverage and coverage > 0:
213
+ best_positions.append(position)
214
+ if best_coverage < GUTTER_ROW_RATIO * len(rows) or not best_positions:
215
+ return None
216
+ position = median(best_positions)
217
+ left = sum(1 for row in rows for item in row if item.x0 + item.x1 <= 2.0 * position)
218
+ right = sum(1 for row in rows for item in row if item.x0 + item.x1 > 2.0 * position)
219
+ if left < 3 or right < 3:
220
+ return None
221
+ return position
222
+
223
+
224
+ def assemble_lines(
225
+ boxes: Sequence[Sequence[Sequence[float]]],
226
+ texts: Sequence[str],
227
+ scores: Sequence[float],
228
+ width: float,
229
+ min_score: float,
230
+ detect_columns: bool,
231
+ ) -> list[Line]:
232
+ """Turn engine output into reading-order lines, splitting columns when present.
233
+
234
+ @param boxes: one four-point box per recognized region.
235
+ @param texts: recognized text per region.
236
+ @param scores: recognition confidence per region.
237
+ @param width: page width in pixels.
238
+ @param min_score: regions below this confidence are dropped.
239
+ @param detect_columns: whether a multi-column page is split into columns.
240
+ @returns: the page's lines, in the order a reader reads them.
241
+ """
242
+ items = [
243
+ Line(text=text, score=float(score), **dict(zip(('x0', 'y0', 'x1', 'y1'), box_geometry(box))))
244
+ for box, text, score in zip(boxes, texts, scores)
245
+ if collapse_space(text) != '' and float(score) >= min_score
246
+ ]
247
+ if not items:
248
+ return []
249
+ rows = _rows(items)
250
+ gutter = find_gutter(rows, width) if detect_columns else None
251
+ if gutter is None:
252
+ return [merge_row(row, 0) for row in sorted(rows, key=lambda row: row[0].y0)]
253
+ left: list[Line] = []
254
+ right: list[Line] = []
255
+ for row in rows:
256
+ for item in row:
257
+ (left if item.x0 + item.x1 <= 2.0 * gutter else right).append(item)
258
+ return [merge_row([line], 0) for line in sorted(left, key=lambda line: (line.y0, line.x0))] + [
259
+ merge_row([line], 1) for line in sorted(right, key=lambda line: (line.y0, line.x0))
260
+ ]
261
+
262
+
263
+ def body_height(lines: Sequence[Line]) -> float:
264
+ """Estimate the document's body glyph height from its line heights.
265
+
266
+ Each line votes with its character count, so a few oversized titles cannot
267
+ drag the body estimate up and short captions cannot drag it down.
268
+ @param lines: every kept line of the document.
269
+ @returns: the weighted median line height, or 0 when there are no lines.
270
+ """
271
+ weighted: list[float] = []
272
+ for line in lines:
273
+ weighted.extend([line.height] * max(1, min(len(line.text), 60)))
274
+ return median(weighted) if weighted else 0.0
275
+
276
+
277
+ #: Numbering and section words that fix a heading's level independent of size.
278
+ _CHAPTER = re.compile(r'^第\s*[0-9一二三四五六七八九十百零〇]+\s*[章篇部节]')
279
+ _PART = re.compile(r'^第\s*[0-9一二三四五六七八九十百零〇]+\s*部分')
280
+ #: A multi-level section number such as 1.1 or 1.1.2. OCR often drops the thin
281
+ #: space that follows it, so the number alone is enough to recognize the level.
282
+ _SECTION_NUMBER = re.compile(r'^(\d{1,3}(?:\.\d{1,3})+)')
283
+ #: A single-level outline number. The ASCII full stop is deliberately excluded:
284
+ #: a line opening with an ASCII full stop is a numbered list item, not a heading.
285
+ _SIMPLE_NUMBER = re.compile(r'^(\d{1,2})\s*[、.::]\s*(?=\S)|^(\d{1,2})\s+(?=\S)')
286
+ _CJK_ORDINAL = re.compile(r'^[一二三四五六七八九十百零〇]+\s*[、..]\s*')
287
+ _NAMED = re.compile(
288
+ r'^(?:摘\s*要|关\s*键\s*词|目\s*录|前\s*言|引\s*言|绪\s*论|结\s*论|结\s*语|'
289
+ r'参\s*考\s*文\s*献|致\s*谢|附\s*录|附\s*件|正\s*文|简\s*介|背\s*景|'
290
+ r'abstract|introduction|conclusion|conclusions|references|appendix|'
291
+ r'contents|acknowledg\w*|keywords?)\b',
292
+ re.IGNORECASE,
293
+ )
294
+ _LIST_ITEM = re.compile(r'^(?:[-•·▪◦*]|\(\d+\)|(\d+)|\d+[.)、])\s*')
295
+ #: Punctuation that ends a sentence, so a line carrying it is prose, not a heading.
296
+ _SENTENCE_END = '。!?;!?;:'
297
+ #: The number prefix a heading carries, used to put back the space after it.
298
+ _HEADING_PREFIX = re.compile(
299
+ r'^(?:第\s*[\d一二三四五六七八九十百零〇]+\s*[章篇部节]'
300
+ r'|第\s*[\d一二三四五六七八九十百零〇]+\s*部分'
301
+ r'|\d{1,3}(?:\.\d{1,3})+'
302
+ r'|\d{1,2}(?=[、.::\s]))'
303
+ )
304
+
305
+
306
+ def heading_level(text: str, height: float, base: float, min_ratio: float) -> int | None:
307
+ """Decide whether a line is a heading, and at which Markdown level.
308
+
309
+ Explicit numbering and section words win over glyph size, because a numbered
310
+ heading keeps its place in the outline even when it is set in body type.
311
+ @param text: the line's text.
312
+ @param height: the line's glyph height.
313
+ @param base: the document's body glyph height.
314
+ @param min_ratio: how much taller than the body a line must be to be a heading.
315
+ @returns: a Markdown level from 1 to 6, or None when the line is body text.
316
+ """
317
+ stripped = text.strip()
318
+ if stripped == '' or len(stripped) > 60:
319
+ return None
320
+ numbered = section_number(stripped)
321
+ if _LIST_ITEM.match(stripped) and numbered is None:
322
+ return None
323
+ if _CHAPTER.match(stripped) or _PART.match(stripped) or is_named_section(stripped):
324
+ return 1
325
+ if numbered is not None:
326
+ depth, offset = numbered
327
+ tail = stripped[offset:].strip()
328
+ if tail != '' and len(tail) <= 40 and not tail.endswith(tuple(_SENTENCE_END)):
329
+ return depth
330
+ if _CJK_ORDINAL.match(stripped) and len(stripped) <= 30:
331
+ return 2
332
+ if base > 0:
333
+ ratio = height / base
334
+ if ratio >= min_ratio and not stripped.endswith(tuple(_SENTENCE_END)):
335
+ if ratio >= min_ratio * 1.55:
336
+ return 1
337
+ if ratio >= min_ratio * 1.28:
338
+ return 2
339
+ return 3
340
+ return None
341
+
342
+
343
+ def is_named_section(text: str) -> bool:
344
+ """Report whether a line opens with one of the document's named sections.
345
+
346
+ @param text: the line's text.
347
+ @returns: True for a named part such as an abstract, a contents list, or a
348
+ reference list, whatever its glyph size.
349
+ """
350
+ return _NAMED.match(text.strip()) is not None
351
+
352
+
353
+ def section_number(text: str) -> tuple[int, int] | None:
354
+ """Read a leading outline number and the offset it ends at.
355
+
356
+ @param text: the line's text.
357
+ @returns: the outline depth and the offset after the number, or None when
358
+ the line does not open with an outline number.
359
+ """
360
+ match = _SECTION_NUMBER.match(text)
361
+ if match is not None:
362
+ return min(match.group(1).count('.') + 1, 6), match.end()
363
+ match = _SIMPLE_NUMBER.match(text)
364
+ if match is not None:
365
+ return 1, match.end()
366
+ return None
367
+
368
+
369
+ def normalize_heading(text: str) -> str:
370
+ """Restore the space a section number and its title are set with.
371
+
372
+ Recognition frequently drops the thin space after an outline number, so a
373
+ heading arrives as one run of characters; the outline reads better and still
374
+ matches its source when the space is put back.
375
+ @param text: the heading's text.
376
+ @returns: the heading with the space after its number restored.
377
+ """
378
+ stripped = text.strip()
379
+ match = _HEADING_PREFIX.match(stripped)
380
+ if match is None:
381
+ return stripped
382
+ offset = match.end()
383
+ if offset == 0 or offset >= len(stripped) or stripped[offset].isspace():
384
+ return stripped
385
+ return stripped[:offset] + ' ' + stripped[offset:]
386
+
387
+
388
+ def is_list_item(text: str) -> bool:
389
+ """Report whether a line already carries a list marker.
390
+
391
+ @param text: the line's text.
392
+ @returns: True when the line opens with a bullet or an enumerated marker.
393
+ """
394
+ return _LIST_ITEM.match(text.strip()) is not None
395
+
396
+
397
+ def merge_list_markers(text: str) -> str:
398
+ """Normalize list markers to Markdown bullets.
399
+
400
+ @param text: a line recognized as a list item.
401
+ @returns: the line with its marker replaced by a Markdown bullet.
402
+ """
403
+ return '- ' + _LIST_ITEM.sub('', text.strip(), count=1).strip()