indra-ocr 0.3.0__py3-none-any.whl

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.
indra_ocr/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ """Indra OCR: fast CPU OCR (line detection, CTC recognition, reading order, markdown)."""
2
+
3
+ from .document import Converter
4
+ from .models import model_dir
5
+ from .parallel import convert_many, usable_cores
6
+ from .pipeline import InputError, Line, OrtOCR, Page, load_pages
7
+
8
+ __all__ = [
9
+ "Converter",
10
+ "InputError",
11
+ "Line",
12
+ "OrtOCR",
13
+ "Page",
14
+ "convert_many",
15
+ "usable_cores",
16
+ "load_pages",
17
+ "load",
18
+ "model_dir",
19
+ ]
20
+ __version__ = "0.3.0"
21
+
22
+
23
+ def load(
24
+ path=None,
25
+ threads: int = 4,
26
+ dpi: float = 200.0,
27
+ mode: str = "auto",
28
+ pdf_order: bool = True,
29
+ image_ocr: bool = True,
30
+ providers: list | None = None,
31
+ furniture: bool = False,
32
+ math: bool = False,
33
+ ) -> Converter:
34
+ """A ready Converter (see ``Converter`` for the options). mode="ocr" never
35
+ reads the PDF text layer. furniture: keep page headers and footers in the
36
+ markdown. math: write math as LaTeX (slower on math pages; off by default)."""
37
+ return Converter(
38
+ OrtOCR(
39
+ model_dir(path, math=math),
40
+ threads=threads,
41
+ providers=providers,
42
+ furniture=furniture,
43
+ math=math,
44
+ ),
45
+ dpi=dpi,
46
+ mode=mode,
47
+ pdf_order=pdf_order,
48
+ image_ocr=image_ocr,
49
+ )
indra_ocr/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from indra_ocr.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
indra_ocr/cli.py ADDED
@@ -0,0 +1,161 @@
1
+ """Command line: PDFs or images in, markdown (or text, or JSON lines) out.
2
+
3
+ indra-ocr paper.pdf # markdown to stdout
4
+ indra-ocr *.pdf --out results/ # one .md per input
5
+ indra-ocr scan.png --format json # lines with boxes, confidences, timings
6
+
7
+ Pages run in parallel on all the cores the process may use (--cores to limit).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+ from dataclasses import asdict
16
+ from pathlib import Path
17
+
18
+ from . import InputError
19
+ from .parallel import convert_many, usable_cores
20
+
21
+
22
+ def add_source_args(ap: argparse.ArgumentParser) -> None:
23
+ """Options that choose between the PDF text layer and OCR."""
24
+ g = ap.add_argument_group("text layer or OCR (PDFs)")
25
+ g.add_argument(
26
+ "--mode",
27
+ choices=["auto", "ocr", "text"],
28
+ default="auto",
29
+ help="auto: text layer where it passes the checks, OCR elsewhere (default); "
30
+ "ocr: never read the text layer; text: use the text layer even when the "
31
+ "checks fail",
32
+ )
33
+ g.add_argument(
34
+ "--model-order",
35
+ action="store_true",
36
+ help="order text-layer lines with the order model, not the PDF content order",
37
+ )
38
+ g.add_argument(
39
+ "--no-image-ocr",
40
+ action="store_true",
41
+ help="on text-layer pages, do not OCR text inside images",
42
+ )
43
+
44
+
45
+ def source_options(a) -> dict:
46
+ return {
47
+ "mode": a.mode,
48
+ "pdf_order": not a.model_order,
49
+ "image_ocr": not a.no_image_ocr,
50
+ }
51
+
52
+
53
+ def main(argv=None):
54
+ ap = argparse.ArgumentParser(
55
+ prog="indra-ocr",
56
+ description=__doc__,
57
+ formatter_class=argparse.RawDescriptionHelpFormatter,
58
+ )
59
+ ap.add_argument("inputs", nargs="+", type=Path)
60
+ ap.add_argument(
61
+ "--out", type=Path, default=None, help="write one file per input here"
62
+ )
63
+ ap.add_argument("--format", choices=["md", "text", "json"], default="md")
64
+ add_source_args(ap)
65
+ ap.add_argument("--model-dir", type=Path, default=None)
66
+ ap.add_argument(
67
+ "--cores",
68
+ "--threads",
69
+ dest="cores",
70
+ type=int,
71
+ default=0,
72
+ help=f"cores to use (default: all this process may run on, here {usable_cores()})",
73
+ )
74
+ ap.add_argument("--dpi", type=float, default=200.0)
75
+ ap.add_argument("--max-pages", type=int, default=0)
76
+ ap.add_argument("--password", default=None, help="password of encrypted PDFs")
77
+ ap.add_argument(
78
+ "--math",
79
+ action="store_true",
80
+ help="write math as LaTeX ($...$); slower on pages with math (off by default)",
81
+ )
82
+ ap.add_argument(
83
+ "--keep-furniture",
84
+ action="store_true",
85
+ help="keep page headers and footers (page numbers, running titles) in the markdown",
86
+ )
87
+ ap.add_argument(
88
+ "--timings", action="store_true", help="print per-page timings to stderr"
89
+ )
90
+ a = ap.parse_args(argv)
91
+ if a.out:
92
+ a.out.mkdir(parents=True, exist_ok=True)
93
+ ext = {"md": ".md", "text": ".txt", "json": ".jsonl"}[a.format]
94
+ failed, names = 0, set()
95
+ results = convert_many(
96
+ a.inputs,
97
+ cores=a.cores,
98
+ max_pages=a.max_pages,
99
+ password=a.password,
100
+ path=a.model_dir,
101
+ dpi=a.dpi,
102
+ furniture=a.keep_furniture,
103
+ math=a.math,
104
+ **source_options(a),
105
+ )
106
+ for path, result in results:
107
+ path = Path(path)
108
+ chunks = []
109
+ if isinstance(result, InputError):
110
+ print(f"indra-ocr: {result}", file=sys.stderr)
111
+ failed += 1
112
+ continue
113
+ for i, page in result:
114
+ if page.source == "error":
115
+ print(f"indra-ocr: {path} page {i + 1}: {page.note}", file=sys.stderr)
116
+ if a.format == "json":
117
+ chunks.append(
118
+ json.dumps(
119
+ {
120
+ "file": str(path),
121
+ "page": i,
122
+ "source": page.source,
123
+ "rotation": page.rotation,
124
+ "skew": page.skew,
125
+ "note": page.note,
126
+ "lines": [asdict(ln) for ln in page.lines],
127
+ "markdown": page.markdown,
128
+ "blocks": page.blocks,
129
+ "timings": page.timings,
130
+ },
131
+ ensure_ascii=False,
132
+ )
133
+ )
134
+ elif a.format == "md":
135
+ chunks.append(page.markdown if page.markdown is not None else page.text)
136
+ else:
137
+ chunks.append(page.text)
138
+ if a.timings:
139
+ t = page.timings
140
+ print(
141
+ f"{path} page {i + 1}: {page.source}, {t.get('total_ms', 0):.0f} ms {page.note}",
142
+ file=sys.stderr,
143
+ )
144
+ sep = "\n" if a.format == "json" else "\n\n"
145
+ body = sep.join(chunks) + "\n"
146
+ if a.out:
147
+ name, k = path.stem, 1
148
+ while name in names: # two inputs with one name: keep both outputs
149
+ k += 1
150
+ name = f"{path.stem}_{k}"
151
+ names.add(name)
152
+ (a.out / (name + ext)).write_text(body, encoding="utf-8")
153
+ else:
154
+ sys.stdout.write(body)
155
+ if failed:
156
+ print(f"indra-ocr: {failed} of {len(a.inputs)} inputs failed", file=sys.stderr)
157
+ return 1 if failed else 0
158
+
159
+
160
+ if __name__ == "__main__":
161
+ sys.exit(main())
indra_ocr/det_post.py ADDED
@@ -0,0 +1,155 @@
1
+ """Torch-free pre- and post-processing for LineNet (shared by training and inference).
2
+
3
+ Inference with ONNX Runtime needs only numpy and cv2, so these functions live
4
+ apart from the PyTorch model in det_model.py, which re-exports them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+
11
+ import numpy as np
12
+
13
+ # Target geometry, shared by training (encode) and inference (decode).
14
+ CORE_FRAC = 0.4 # core band height as a fraction of the line ink height
15
+ CORE_X_SHRINK = 0.15 # the core is shorter than the line by this fraction of its height, at each end
16
+ OUT_STRIDE = 2
17
+ INPUT_MULTIPLE = 32 # H and W must divide by this
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Input preprocessing (shared by training and inference)
22
+ # ---------------------------------------------------------------------------
23
+
24
+
25
+ def page_to_input(gray: np.ndarray, long_side: int) -> tuple[np.ndarray, float]:
26
+ """Resize a uint8 grayscale page so its long side is ``long_side`` and pad
27
+ to a multiple of 32 with white. Returns (float32 [H, W] ink map, scale)."""
28
+ import cv2
29
+
30
+ h, w = gray.shape
31
+ scale = long_side / max(h, w)
32
+ nh, nw = max(1, round(h * scale)), max(1, round(w * scale))
33
+ if (nh, nw) != (h, w):
34
+ interp = cv2.INTER_AREA if scale < 1 else cv2.INTER_LINEAR
35
+ gray = cv2.resize(gray, (nw, nh), interpolation=interp)
36
+ ph = math.ceil(nh / INPUT_MULTIPLE) * INPUT_MULTIPLE
37
+ pw = math.ceil(nw / INPUT_MULTIPLE) * INPUT_MULTIPLE
38
+ out = np.zeros((ph, pw), np.float32) # ink map: 0 = white
39
+ out[:nh, :nw] = 1.0 - gray.astype(np.float32) / 255.0
40
+ return out, scale
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Targets
45
+ # ---------------------------------------------------------------------------
46
+
47
+
48
+ def encode_targets(
49
+ boxes: np.ndarray,
50
+ in_hw: tuple[int, int],
51
+ valid_hw: tuple[int, int],
52
+ ignore: np.ndarray | None = None,
53
+ ):
54
+ """Build training targets at 1/2 resolution.
55
+
56
+ boxes: [N, 4] line ink boxes in input pixels (x0, y0, x1, y1)
57
+ in_hw: padded input size; valid_hw: size of the real page inside it
58
+ ignore: [M, 4] regions with no loss, in input pixels
59
+
60
+ Returns core [h, w] float32, dist [2, h, w] float32, weight [h, w] float32
61
+ (0 on padding and ignore regions), and reg_mask [h, w] bool (core pixels).
62
+ """
63
+ s = OUT_STRIDE
64
+ h, w = in_hw[0] // s, in_hw[1] // s
65
+ core = np.zeros((h, w), np.float32)
66
+ dist = np.zeros((2, h, w), np.float32)
67
+ weight = np.zeros((h, w), np.float32)
68
+ weight[: math.ceil(valid_hw[0] / s), : math.ceil(valid_hw[1] / s)] = 1.0
69
+ if ignore is not None:
70
+ for x0, y0, x1, y1 in ignore:
71
+ weight[
72
+ max(0, int(y0 // s)) : max(0, math.ceil(y1 / s)),
73
+ max(0, int(x0 // s)) : max(0, math.ceil(x1 / s)),
74
+ ] = 0.0
75
+ # Larger lines first, so a small line drawn later keeps its own band.
76
+ order = np.argsort(-(boxes[:, 3] - boxes[:, 1])) if len(boxes) else []
77
+ ys = (np.arange(h, dtype=np.float32) + 0.5) * s # pixel centres in input px
78
+ for i in order:
79
+ x0, y0, x1, y1 = boxes[i]
80
+ lh = y1 - y0
81
+ if lh < 2 or x1 - x0 < 2:
82
+ continue
83
+ cy = 0.5 * (y0 + y1)
84
+ half = 0.5 * CORE_FRAC * lh
85
+ r0 = max(0, int(math.floor((cy - half) / s)))
86
+ r1 = min(h, max(r0 + 1, int(math.ceil((cy + half) / s))))
87
+ sx = min(CORE_X_SHRINK * lh, 0.25 * (x1 - x0))
88
+ c0 = max(0, int(math.floor((x0 + sx) / s)))
89
+ c1 = min(w, max(c0 + 1, int(math.ceil((x1 - sx) / s))))
90
+ if r0 >= h or c0 >= w:
91
+ continue
92
+ core[r0:r1, c0:c1] = 1.0
93
+ dist[0, r0:r1, c0:c1] = (ys[r0:r1] - y0)[:, None]
94
+ dist[1, r0:r1, c0:c1] = (y1 - ys[r0:r1])[:, None]
95
+ # A line inside an ignore region is still a positive.
96
+ weight[r0:r1, c0:c1] = np.maximum(weight[r0:r1, c0:c1], 1.0)
97
+ return core, dist, weight, core > 0.5
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Decoding
102
+ # ---------------------------------------------------------------------------
103
+
104
+
105
+ def decode(
106
+ out: np.ndarray,
107
+ thresh: float = 0.5,
108
+ min_area: int = 2,
109
+ grow_y: float = 0.0,
110
+ grow_x: float = 0.0,
111
+ ) -> np.ndarray:
112
+ """Decode one page. out: [3, h, w] (core logit, top dist, bottom dist).
113
+
114
+ ``grow_y`` / ``grow_x`` widen every box by that fraction of its height
115
+ (each side), to absorb the +-1 px error of the 1/2-resolution output.
116
+
117
+ Returns [N, 5] boxes in input pixels: x0, y0, x1, y1, score."""
118
+ import cv2
119
+
120
+ prob = 1.0 / (1.0 + np.exp(-out[0]))
121
+ mask = (prob > thresh).astype(np.uint8)
122
+ n, lab, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=4)
123
+ if n <= 1:
124
+ return np.zeros((0, 5), np.float32)
125
+ s = OUT_STRIDE
126
+ flat = lab.ravel()
127
+ keep = flat > 0
128
+ idx = flat[keep]
129
+ ys = (np.indices(lab.shape)[0].ravel()[keep].astype(np.float32) + 0.5) * s
130
+ top = ys - out[1].ravel()[keep]
131
+ bot = ys + out[2].ravel()[keep]
132
+ sc = prob.ravel()[keep]
133
+ cnt = np.bincount(idx, minlength=n).astype(np.float32)
134
+ # Mean is a robust enough summary here and is far cheaper than a median.
135
+ y0 = np.bincount(idx, weights=top, minlength=n) / np.maximum(cnt, 1)
136
+ y1 = np.bincount(idx, weights=bot, minlength=n) / np.maximum(cnt, 1)
137
+ score = np.bincount(idx, weights=sc, minlength=n) / np.maximum(cnt, 1)
138
+ boxes = []
139
+ for i in range(1, n):
140
+ if stats[i, cv2.CC_STAT_AREA] < min_area:
141
+ continue
142
+ lh = max(1.0, y1[i] - y0[i])
143
+ ex = min(CORE_X_SHRINK * lh, 0.5 * lh)
144
+ x0 = stats[i, cv2.CC_STAT_LEFT] * s - ex
145
+ x1 = (stats[i, cv2.CC_STAT_LEFT] + stats[i, cv2.CC_STAT_WIDTH]) * s + ex
146
+ boxes.append(
147
+ (
148
+ x0 - grow_x * lh,
149
+ y0[i] - grow_y * lh,
150
+ x1 + grow_x * lh,
151
+ y1[i] + grow_y * lh,
152
+ score[i],
153
+ )
154
+ )
155
+ return np.asarray(boxes, np.float32).reshape(-1, 5)
indra_ocr/document.py ADDED
@@ -0,0 +1,291 @@
1
+ """Whole documents: the PDF text layer where it can be trusted, OCR elsewhere.
2
+
3
+ For each PDF page (``mode="auto"``):
4
+
5
+ 1. Read the text layer with pdfium and check it (``textlayer.read_text_layer``). A page
6
+ without a sound layer (a scan, an OCR layer, a broken font map) goes to OCR.
7
+ 2. The detector finds the visual lines, as in OCR. Each text-layer character
8
+ goes to the detected line that contains its center. Inside a line, the
9
+ characters of each text-layer run (pdfium's segment between line breaks) keep the PDF order
10
+ and spaces, and runs are ordered by x, right to left in a right-to-left line. So inline math, sub- and
11
+ superscripts and footnote marks land in their line, and a run across two
12
+ table cells splits between them. A detected line with no text (text inside
13
+ an image) is OCR'd when the recognizer reads the page's script.
14
+ 3. The rest is the OCR path: lines split at wide gaps, reading order, layout
15
+ classes and markdown. The PDF content order is kept when it is coherent
16
+ (few upward jumps that are not column changes, and the order model agrees
17
+ with at least 85% of consecutive pairs).
18
+
19
+ ``mode="ocr"`` never reads the text layer; ``mode="text"`` uses it even when
20
+ the checks fail (and OCR only on pages with no text at all).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import time
26
+ import unicodedata
27
+ from pathlib import Path
28
+
29
+ import numpy as np
30
+
31
+ from .pipeline import InputError, OrtOCR, Page, is_pdf, read_images
32
+ from .textlayer import BROKEN_FONT_MAP, read_text_layer
33
+
34
+ CHECK_DPI = 72.0 # resolution of the render for the text-layer ink checks
35
+ MAX_PAGE_PIXELS = 100e6 # a page larger than this at the set dpi renders at a lower dpi (large scans reach ~65 MP)
36
+
37
+ READABLE_MIN = 0.9 # share of the layer's letters the recognizer must know
38
+
39
+
40
+ def readable(ocr: OrtOCR, runs: list) -> bool:
41
+ """Whether the recognizer can read the script of these text-layer runs."""
42
+ letters = [c for r in runs for c in r.text if c.isalpha()]
43
+ if not letters:
44
+ return True
45
+ cjk = ocr.vocab_cjk.index if ocr.vocab_cjk is not None else {}
46
+ known = sum(c in ocr.vocab.index or c in cjk for c in letters)
47
+ return known >= READABLE_MIN * len(letters)
48
+
49
+
50
+ def right_to_left(tokens: list) -> bool:
51
+ """Unicode bidi base direction: most strong characters are right-to-left."""
52
+ rtl = ltr = 0
53
+ for t in tokens:
54
+ b = unicodedata.bidirectional(t[0]) if len(t[0]) == 1 else ""
55
+ rtl += b in ("R", "AL")
56
+ ltr += b == "L"
57
+ return rtl > ltr
58
+
59
+
60
+ def _render(page, scale: float) -> np.ndarray:
61
+ arr = page.render(scale=scale, grayscale=True).to_numpy()
62
+ return np.ascontiguousarray(arr[..., 0] if arr.ndim == 3 else arr)
63
+
64
+
65
+ def assign_runs(runs: list, boxes: np.ndarray, k: float) -> tuple[list[list], int]:
66
+ """Tokens (char, x, content index) per detected line.
67
+
68
+ A run is one text-layer line piece (``textlayer.TextLine``), in content
69
+ order. Each character goes to the smallest detected box that contains its
70
+ center (a space follows the character before it), so a run that spans two
71
+ table cells splits between them. Inside a detected line, each run's part
72
+ keeps the PDF character order, and the parts are ordered by x. ``boxes``
73
+ are pixels, ``k`` pixels per point. Returns (tokens per detected line,
74
+ characters outside every box)."""
75
+ parts: list[dict] = [{} for _ in range(len(boxes))] # line -> run idx -> tokens
76
+ lost = 0
77
+ if not len(boxes):
78
+ return [], sum(not c.isspace() for r in runs for c, _ in r.chars)
79
+ area = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1])
80
+ for idx, run in enumerate(runs):
81
+ j = None
82
+ for c, b in run.chars:
83
+ if not c.isspace():
84
+ cx, cy = (b[0] + b[2]) / 2 * k, (b[1] + b[3]) / 2 * k
85
+ inside = (
86
+ (boxes[:, 0] <= cx)
87
+ & (cx <= boxes[:, 2])
88
+ & (boxes[:, 1] <= cy)
89
+ & (cy <= boxes[:, 3])
90
+ )
91
+ if not inside.any():
92
+ lost += 1
93
+ j = None
94
+ continue
95
+ j = int(np.flatnonzero(inside)[np.argmin(area[inside])])
96
+ parts[j].setdefault(idx, []).append((c, cx, idx))
97
+ elif j is not None:
98
+ prev = parts[j][idx][-1]
99
+ parts[j][idx].append((c, prev[1], idx))
100
+ toks: list[list] = []
101
+ for line in parts:
102
+ tk: list = []
103
+ rtl = right_to_left([t for part in line.values() for t in part])
104
+ order = (
105
+ sorted(line, key=lambda i: -max(t[1] for t in line[i]))
106
+ if rtl
107
+ else sorted(line, key=lambda i: min(t[1] for t in line[i]))
108
+ )
109
+ for idx in order:
110
+ if tk:
111
+ tk.append((" ", tk[-1][1], idx)) # parts of different runs
112
+ tk += line[idx]
113
+ toks.append(tk)
114
+ return toks, lost
115
+
116
+
117
+ class Converter:
118
+ def __init__(
119
+ self,
120
+ ocr: OrtOCR,
121
+ dpi: float = 200.0,
122
+ mode: str = "auto",
123
+ pdf_order: bool = True,
124
+ image_ocr: bool = True,
125
+ ):
126
+ """mode: auto (text layer where sound, OCR elsewhere), ocr (always OCR,
127
+ never read the text layer), text (text layer even when the checks fail).
128
+ pdf_order: keep the PDF content order when it is coherent (False: the
129
+ order model always orders the lines). image_ocr: OCR detected lines that
130
+ hold no text-layer characters (False: drop them)."""
131
+ if mode not in ("auto", "ocr", "text"):
132
+ raise ValueError(f"mode must be auto, ocr or text, not {mode!r}")
133
+ self.ocr, self.dpi, self.mode = ocr, dpi, mode
134
+ self.pdf_order, self.image_ocr = pdf_order, image_ocr
135
+
136
+ def page_scale(self, page) -> float:
137
+ """Pixels per point for this page: the set dpi, lowered for pages so
138
+ large that the render would pass MAX_PAGE_PIXELS (posters, plans)."""
139
+ w, h = page.get_size()
140
+ k = self.dpi / 72.0
141
+ return min(k, (MAX_PAGE_PIXELS / max(w * h, 1.0)) ** 0.5)
142
+
143
+ def pdf_page(self, pdf_path: str, doc, index: int) -> Page:
144
+ page = doc[index]
145
+ t: dict = {}
146
+ note = "mode=ocr"
147
+ if self.mode != "ocr":
148
+ t0 = time.perf_counter()
149
+ s = CHECK_DPI / 72.0
150
+ tl = read_text_layer(page, _render(page, s), s)
151
+ t["text_ms"] = 1000 * (time.perf_counter() - t0)
152
+ # A layer the recognizer cannot read beats OCR, unless its codes are junk.
153
+ unreadable = tl.reason != BROKEN_FONT_MAP and not readable(
154
+ self.ocr, tl.lines
155
+ )
156
+ if tl.lines and (tl.trusted or self.mode == "text" or unreadable):
157
+ return self._text_page(page, tl, t)
158
+ note = tl.reason
159
+ # A layer with a broken font map still places its glyphs: when most
160
+ # run upright, the page is upright (orient.onnx is not asked).
161
+ st = tl.stats
162
+ turn = not (
163
+ tl.reason == BROKEN_FONT_MAP and st["rotated"] < 0.5 * st["chars"]
164
+ )
165
+ else:
166
+ turn = True
167
+ t0 = time.perf_counter()
168
+ gray = _render(page, self.page_scale(page))
169
+ t["render_ms"] = 1000 * (time.perf_counter() - t0)
170
+ out = self.ocr(gray, turn)
171
+ out.timings = {**t, **out.timings}
172
+ out.timings["total_ms"] = sum(
173
+ v for k, v in out.timings.items() if k != "total_ms"
174
+ )
175
+ out.note = f"{note}; {out.note}"
176
+ return out
177
+
178
+ def _text_page(self, page, tl, t: dict) -> Page:
179
+ k = self.page_scale(page)
180
+ t0 = time.perf_counter()
181
+ gray = _render(page, k)
182
+ t["render_ms"] = 1000 * (time.perf_counter() - t0)
183
+ t0 = time.perf_counter()
184
+ boxes = self.ocr.detect(gray)
185
+ t["det_ms"] = 1000 * (time.perf_counter() - t0)
186
+ toks, lost = assign_runs(tl.lines, boxes[:, :4], k)
187
+ rec: list = [None] * len(boxes)
188
+ empty = []
189
+ for i, tk in enumerate(toks):
190
+ text = self.ocr._decode(tk) if tk else ""
191
+ if text:
192
+ rec[i] = (text, 1.0, tk)
193
+ else:
194
+ empty.append(i)
195
+ source = "text"
196
+ if empty and self.image_ocr and readable(self.ocr, tl.lines):
197
+ t0 = time.perf_counter()
198
+ for i, r in zip(empty, self.ocr.recognize(gray, boxes[empty])):
199
+ if r[0].strip():
200
+ rec[i] = r
201
+ source = "hybrid"
202
+ t["rec_ms"] = 1000 * (time.perf_counter() - t0)
203
+ if getattr(self.ocr, "math", None) is not None:
204
+ t0 = time.perf_counter()
205
+ self._read_math(gray, boxes, rec)
206
+ t["math_ms"] = 1000 * (time.perf_counter() - t0)
207
+ keep = [i for i, r in enumerate(rec) if r is not None]
208
+ out = self.ocr.assemble(
209
+ (gray.shape[1], gray.shape[0]),
210
+ boxes[keep].reshape(-1, boxes.shape[1]),
211
+ [rec[i] for i in keep],
212
+ t,
213
+ content_order=self.pdf_order,
214
+ )
215
+ out.source = source
216
+ out.note += f"; {lost} chars outside detected lines" if lost else ""
217
+ return out
218
+
219
+ def _read_math(self, gray, boxes, rec: list) -> None:
220
+ """Text-layer lines that hold math (the classifier, on their crops) are read
221
+ by MathRec instead: the layer has the symbols but not the LaTeX. The line
222
+ keeps its place in the PDF content order (its first character's index)."""
223
+ from .line_mask import crop_lines
224
+
225
+ idx = [
226
+ i
227
+ for i, r in enumerate(rec)
228
+ if r is not None
229
+ and r[2]
230
+ and isinstance(r[2][0][0], str)
231
+ and len(r[2][0]) > 2
232
+ ]
233
+ if not idx:
234
+ return
235
+ crops = crop_lines(gray, boxes[idx, :4], self.ocr.height, None)
236
+ math = [i for i, f in zip(idx, self.ocr.math.flags(crops)) if f]
237
+ if not math:
238
+ return
239
+ from .math import MATH_MIN_CONF
240
+
241
+ for i, (text, conf) in zip(math, self.ocr.math.read(gray, boxes[math])):
242
+ # A reading without math, or not confident, leaves the layer's text.
243
+ if "$" in text and conf >= MATH_MIN_CONF:
244
+ first = min(tk[2] for tk in rec[i][2])
245
+ xc = float(boxes[i, 0] + boxes[i, 2]) / 2
246
+ rec[i] = (text, 1.0, [(text, xc, first)])
247
+
248
+ def convert(
249
+ self, path: str | Path, max_pages: int = 0, password: str | None = None
250
+ ):
251
+ """Yield (page index, Page) for a PDF or an image file (the file's bytes
252
+ decide which). A page that fails yields an empty Page whose ``source``
253
+ is "error" and whose ``note`` says why; a file that cannot be opened
254
+ raises InputError."""
255
+ path = Path(path)
256
+ if not path.is_file():
257
+ raise InputError(f"{path}: no such file")
258
+ if not is_pdf(path):
259
+ for i, gray in enumerate(read_images(path)[: max_pages or None]):
260
+ yield i, self._safe(lambda: self.ocr(gray))
261
+ return
262
+ import pypdfium2 as pdfium
263
+
264
+ try:
265
+ doc = pdfium.PdfDocument(str(path), password=password)
266
+ except pdfium.PdfiumError as e:
267
+ locked = "password" in str(e).lower()
268
+ raise InputError(
269
+ f"{path}: password-protected PDF (pass password=, or --password)"
270
+ if locked
271
+ else f"{path}: cannot open the PDF ({e})"
272
+ ) from e
273
+ try:
274
+ n = len(doc) if not max_pages else min(len(doc), max_pages)
275
+ for i in range(n):
276
+ yield i, self._safe(lambda: self.pdf_page(str(path), doc, i))
277
+ finally:
278
+ doc.close()
279
+
280
+ @staticmethod
281
+ def _safe(read) -> Page:
282
+ try:
283
+ return read()
284
+ except Exception as e: # noqa: BLE001 (one bad page must not end the document)
285
+ return Page(
286
+ [],
287
+ {"total_ms": 0.0},
288
+ markdown="",
289
+ source="error",
290
+ note=f"{type(e).__name__}: {e}",
291
+ )