@stabrise/scaledp 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 (60) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +218 -0
  3. package/dist/box-DAfzwfhA.d.ts +119 -0
  4. package/dist/config-g6IrKlDC.d.ts +80 -0
  5. package/dist/data-to-image-DoZ4jQ3R.js +54 -0
  6. package/dist/data-to-image-DoZ4jQ3R.js.map +1 -0
  7. package/dist/detect/index.d.ts +71 -0
  8. package/dist/detect/index.js +2 -0
  9. package/dist/detect-q8AI_Jdj.js +274 -0
  10. package/dist/detect-q8AI_Jdj.js.map +1 -0
  11. package/dist/detector-output-C0Qt-jEq.d.ts +13 -0
  12. package/dist/detector-output-lyF1Mqb8.js +13 -0
  13. package/dist/detector-output-lyF1Mqb8.js.map +1 -0
  14. package/dist/display/index.d.ts +66 -0
  15. package/dist/display/index.js +237 -0
  16. package/dist/display/index.js.map +1 -0
  17. package/dist/document-B8I61TiY.d.ts +16 -0
  18. package/dist/entity-CedtRhU1.d.ts +22 -0
  19. package/dist/entity-D6Hxaugj.js +13 -0
  20. package/dist/entity-D6Hxaugj.js.map +1 -0
  21. package/dist/image-CAH2rLv9.js +511 -0
  22. package/dist/image-CAH2rLv9.js.map +1 -0
  23. package/dist/image-Dc5TSg46.d.ts +18 -0
  24. package/dist/image-DoZDJkcR.js +37 -0
  25. package/dist/image-DoZDJkcR.js.map +1 -0
  26. package/dist/image-draw-boxes-De0QbFv9.js +285 -0
  27. package/dist/image-draw-boxes-De0QbFv9.js.map +1 -0
  28. package/dist/index.d.ts +269 -0
  29. package/dist/index.js +11 -0
  30. package/dist/model-cache-BEaqqRZ9.js +182 -0
  31. package/dist/model-cache-BEaqqRZ9.js.map +1 -0
  32. package/dist/model-cache-BhFYpfZz.d.ts +36 -0
  33. package/dist/ner/index.d.ts +293 -0
  34. package/dist/ner/index.js +2 -0
  35. package/dist/ner-SsZLZ6ed.js +1028 -0
  36. package/dist/ner-SsZLZ6ed.js.map +1 -0
  37. package/dist/ocr/index.d.ts +440 -0
  38. package/dist/ocr/index.js +3 -0
  39. package/dist/ocr-OHX2WM3e.js +1294 -0
  40. package/dist/ocr-OHX2WM3e.js.map +1 -0
  41. package/dist/ort-CXDoPrtw.js +73 -0
  42. package/dist/ort-CXDoPrtw.js.map +1 -0
  43. package/dist/params-DapwK9Ns.js +37 -0
  44. package/dist/params-DapwK9Ns.js.map +1 -0
  45. package/dist/pdf/index.d.ts +123 -0
  46. package/dist/pdf/index.js +2 -0
  47. package/dist/pdf-BQl0dneD.js +417 -0
  48. package/dist/pdf-BQl0dneD.js.map +1 -0
  49. package/dist/pipeline-DACqGkpN.js +240 -0
  50. package/dist/pipeline-DACqGkpN.js.map +1 -0
  51. package/dist/pipeline-DeLO-OCE.d.ts +139 -0
  52. package/dist/registry/index.d.ts +169 -0
  53. package/dist/registry/index.js +1061 -0
  54. package/dist/registry/index.js.map +1 -0
  55. package/dist/text-ahMLpxN9.js +109 -0
  56. package/dist/text-ahMLpxN9.js.map +1 -0
  57. package/dist/worker/index.d.ts +105 -0
  58. package/dist/worker/index.js +180 -0
  59. package/dist/worker/index.js.map +1 -0
  60. package/package.json +135 -0
@@ -0,0 +1,1294 @@
1
+ import { h as getConfig, i as Stage, s as DetectionError, u as OcrError } from "./pipeline-DACqGkpN.js";
2
+ import { A as polygonArea, D as boxPoints, S as isRotated, _ as boxFromBBox, a as cropBox, c as encodeImage, i as createCanvas, j as polygonPerimeter, k as minAreaRect, l as imageDataToCanvas, m as toNchwFloat32, n as IMAGENET_STD, o as cropGeometry, p as toImageData, r as context2d, s as decodeImage, t as IMAGENET_MEAN, u as letterbox, v as boxFromPolygon, w as mergeOverlappingBoxes } from "./image-CAH2rLv9.js";
3
+ import { a as isCached, n as ensureModelFiles, r as evict } from "./model-cache-BEaqqRZ9.js";
4
+ import { i as resolveParams, t as BASE_STAGE_DEFAULTS } from "./params-DapwK9Ns.js";
5
+ import { n as boxesToText, t as boxesToFormattedText } from "./text-ahMLpxN9.js";
6
+ import { t as createDetectorOutput } from "./detector-output-lyF1Mqb8.js";
7
+ import { n as createDocument, t as createImage } from "./image-DoZDJkcR.js";
8
+ import { i as loadOrt, t as createSession } from "./ort-CXDoPrtw.js";
9
+ //#region src/ocr/db-postprocess.ts
10
+ /**
11
+ * Differentiable Binarization post-processing.
12
+ *
13
+ * Port of `scaledp/models/detectors/paddle_onnx/db_postprocess.py`. Takes the
14
+ * model's probability map and turns it into text quads, with the same
15
+ * thresholds, ordering and filtering as Python so box output matches.
16
+ */
17
+ /**
18
+ * ScaleDP's own values, from `predict_det.py`. Note `boxThresh` is 0.3 there
19
+ * and the stage-level `scoreThreshold` never reaches this code -- a Python bug
20
+ * that is not reproduced: here `boxThresh` is what the stage actually sets.
21
+ */
22
+ const DB_POSTPROCESS_DEFAULTS = Object.freeze({
23
+ thresh: .5,
24
+ boxThresh: .3,
25
+ maxCandidates: 1e3,
26
+ unclipRatio: 2.5,
27
+ minSize: 3
28
+ });
29
+ /**
30
+ * Boundary pixels of each 8-connected foreground component.
31
+ *
32
+ * cv2.findContours traces outlines; we collect boundary pixels instead. For the
33
+ * only consumers here -- minAreaRect and its convex hull -- the two are
34
+ * equivalent, and this avoids porting Suzuki-Abe border following.
35
+ */
36
+ function findComponentBoundaries(map, thresh, maxCandidates) {
37
+ const { data, width, height } = map;
38
+ const visited = new Uint8Array(width * height);
39
+ const components = [];
40
+ const isForeground = (x, y) => x >= 0 && y >= 0 && x < width && y < height && data[y * width + x] > thresh;
41
+ const stack = [];
42
+ for (let start = 0; start < visited.length && components.length < maxCandidates; start++) {
43
+ if (visited[start] || !(data[start] > thresh)) continue;
44
+ const boundary = [];
45
+ stack.length = 0;
46
+ stack.push(start);
47
+ visited[start] = 1;
48
+ while (stack.length > 0) {
49
+ const index = stack.pop();
50
+ const x = index % width;
51
+ const y = (index - x) / width;
52
+ let onBoundary = false;
53
+ for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
54
+ if (dx === 0 && dy === 0) continue;
55
+ const nx = x + dx;
56
+ const ny = y + dy;
57
+ if (!isForeground(nx, ny)) {
58
+ onBoundary = true;
59
+ continue;
60
+ }
61
+ const neighbour = ny * width + nx;
62
+ if (!visited[neighbour]) {
63
+ visited[neighbour] = 1;
64
+ stack.push(neighbour);
65
+ }
66
+ }
67
+ if (onBoundary) boundary.push([x, y]);
68
+ }
69
+ if (boundary.length >= 3) components.push(boundary);
70
+ }
71
+ return components;
72
+ }
73
+ /**
74
+ * Corners of the minimum-area rect, ordered top-left, top-right, bottom-right,
75
+ * bottom-left, together with the rect's shorter side.
76
+ *
77
+ * Port of `get_mini_boxes`: sort the four corners by x, then decide within each
78
+ * pair which is the upper one.
79
+ */
80
+ function miniBox(points) {
81
+ const rect = minAreaRect(points);
82
+ const [p0, p1, p2, p3] = [...boxPoints(rect)].sort((a, b) => a[0] - b[0]);
83
+ const [topLeft, bottomLeft] = p0[1] <= p1[1] ? [p0, p1] : [p1, p0];
84
+ const [topRight, bottomRight] = p2[1] <= p3[1] ? [p2, p3] : [p3, p2];
85
+ return {
86
+ points: [
87
+ topLeft,
88
+ topRight,
89
+ bottomRight,
90
+ bottomLeft
91
+ ],
92
+ sside: Math.min(rect.size[0], rect.size[1])
93
+ };
94
+ }
95
+ /**
96
+ * Mean probability inside a quad -- port of `box_score_fast`.
97
+ *
98
+ * Python builds an integer mask with `cv2.fillPoly` and averages the
99
+ * probability map under it, so this reproduces OpenCV's fill convention:
100
+ * vertices are truncated to integers and treated as pixel *centres*, scanlines
101
+ * run at integer y, and both ends of each span are inclusive. Sampling at
102
+ * pixel centres instead (the more usual rasterisation rule) drops the boundary
103
+ * row and column, which shifts the score by around 1% and changes which
104
+ * candidates clear `boxThresh`.
105
+ */
106
+ function boxScore(map, points) {
107
+ const { data, width, height } = map;
108
+ const xs = points.map((p) => p[0]);
109
+ const ys = points.map((p) => p[1]);
110
+ const clamp = (value, max) => Math.min(Math.max(value, 0), max);
111
+ const xmin = clamp(Math.floor(Math.min(...xs)), width - 1);
112
+ const xmax = clamp(Math.ceil(Math.max(...xs)), width - 1);
113
+ const ymin = clamp(Math.floor(Math.min(...ys)), height - 1);
114
+ const ymax = clamp(Math.ceil(Math.max(...ys)), height - 1);
115
+ if (xmax < xmin || ymax < ymin) return 0;
116
+ const local = points.map(([x, y]) => [Math.trunc(x - xmin), Math.trunc(y - ymin)]);
117
+ const maskWidth = xmax - xmin;
118
+ const maskHeight = ymax - ymin;
119
+ let sum = 0;
120
+ let count = 0;
121
+ for (let y = 0; y <= maskHeight; y++) {
122
+ let left = Number.POSITIVE_INFINITY;
123
+ let right = Number.NEGATIVE_INFINITY;
124
+ for (let i = 0; i < local.length; i++) {
125
+ const a = local[i];
126
+ const b = local[(i + 1) % local.length];
127
+ if (a[1] === b[1]) {
128
+ if (a[1] !== y) continue;
129
+ left = Math.min(left, a[0], b[0]);
130
+ right = Math.max(right, a[0], b[0]);
131
+ continue;
132
+ }
133
+ if (y < Math.min(a[1], b[1]) || y > Math.max(a[1], b[1])) continue;
134
+ const x = a[0] + (y - a[1]) / (b[1] - a[1]) * (b[0] - a[0]);
135
+ left = Math.min(left, x);
136
+ right = Math.max(right, x);
137
+ }
138
+ if (left > right) continue;
139
+ const from = Math.max(0, Math.ceil(left));
140
+ const to = Math.min(maskWidth, Math.floor(right));
141
+ const rowOffset = (y + ymin) * width + xmin;
142
+ for (let x = from; x <= to; x++) {
143
+ sum += data[rowOffset + x];
144
+ count++;
145
+ }
146
+ }
147
+ return count === 0 ? 0 : sum / count;
148
+ }
149
+ /**
150
+ * Grow a detected rect outward -- port of `unclip`.
151
+ *
152
+ * Python offsets the polygon with a Clipper round join and then takes the
153
+ * minimum-area rect of the result. For a rectangle those two steps compose
154
+ * exactly: offsetting outward by `d` produces a rounded rectangle whose
155
+ * min-area rect is the original grown by `d` on each of the four sides. That
156
+ * identity is what lets this avoid a Clipper dependency entirely.
157
+ */
158
+ function unclipRect(points, unclipRatio) {
159
+ const rect = minAreaRect(points);
160
+ const perimeter = polygonPerimeter(points);
161
+ const distance = perimeter === 0 ? 0 : polygonArea(points) * unclipRatio / perimeter;
162
+ return boxPoints({
163
+ center: rect.center,
164
+ size: [rect.size[0] + distance * 2, rect.size[1] + distance * 2],
165
+ angle: rect.angle
166
+ });
167
+ }
168
+ /**
169
+ * Reorder four points clockwise from the top-left -- port of
170
+ * `order_points_clockwise`. Top-left has the smallest x+y, bottom-right the
171
+ * largest; the remaining two are separated by y-x.
172
+ */
173
+ function orderPointsClockwise(points) {
174
+ const bySum = [...points].sort((a, b) => a[0] + a[1] - (b[0] + b[1]));
175
+ const topLeft = bySum[0];
176
+ const bottomRight = bySum[bySum.length - 1];
177
+ const rest = bySum.slice(1, -1).sort((a, b) => a[1] - a[0] - (b[1] - b[0]));
178
+ return [
179
+ topLeft,
180
+ rest[0],
181
+ bottomRight,
182
+ rest[1]
183
+ ];
184
+ }
185
+ /**
186
+ * Probability map -> text quads in source-image coordinates.
187
+ *
188
+ * `scale` is the uniform factor the source was resized by. Coordinates restore
189
+ * by *dividing* by it, with no offset to subtract, because the letterbox pads
190
+ * bottom and right only.
191
+ */
192
+ function quadsFromProbabilityMap(map, source, scale, options = {}) {
193
+ const opts = {
194
+ ...DB_POSTPROCESS_DEFAULTS,
195
+ ...options
196
+ };
197
+ const quads = [];
198
+ for (const boundary of findComponentBoundaries(map, opts.thresh, opts.maxCandidates)) {
199
+ const candidate = miniBox(boundary);
200
+ if (candidate.sside < opts.minSize) continue;
201
+ const score = boxScore(map, candidate.points);
202
+ if (score < opts.boxThresh) continue;
203
+ const expanded = miniBox(unclipRect(candidate.points, opts.unclipRatio));
204
+ if (expanded.sside < opts.minSize + 2) continue;
205
+ const ordered = orderPointsClockwise(expanded.points.map(([x, y]) => [Math.min(Math.max(Math.round(x / scale), 0), source.width), Math.min(Math.max(Math.round(y / scale), 0), source.height)]));
206
+ const width = Math.hypot(ordered[0][0] - ordered[1][0], ordered[0][1] - ordered[1][1]);
207
+ const height = Math.hypot(ordered[0][0] - ordered[3][0], ordered[0][1] - ordered[3][1]);
208
+ if (width <= 3 || height <= 3) continue;
209
+ quads.push({
210
+ points: ordered,
211
+ score
212
+ });
213
+ }
214
+ return quads;
215
+ }
216
+ //#endregion
217
+ //#region src/ocr/dbnet-onnx.ts
218
+ /**
219
+ * DBNet ONNX text detection -- the direct mirror of
220
+ * `scaledp/models/detectors/DBNetOnnxDetector.py`, so ScaleDP's own detection
221
+ * model runs unchanged in the browser.
222
+ *
223
+ * Preprocessing reproduces `paddle_onnx/operators.py` exactly, including one
224
+ * quirk that matters: the Python path converts RGB to BGR and never converts
225
+ * back, so the model is fed BGR channels normalised against *RGB* ImageNet
226
+ * statistics. Feeding true RGB instead shifts the boxes.
227
+ *
228
+ * The model detects text *lines*, not words -- one region per line, in Python
229
+ * as here. Nothing downstream subdivides them either: `TesseractRecognizer`
230
+ * reads each region with PSM.SINGLE_WORD and returns one box per region, which
231
+ * is what ScaleDP does too. `TesseractOcr`, which runs tesseract's own layout
232
+ * analysis over the whole page, is the stage that yields word boxes.
233
+ */
234
+ /** Fixed input size from ScaleDP's `DetResizeForTest` config. */
235
+ const DBNET_INPUT_SIZE = 1280;
236
+ /** Model ScaleDP's DBNetOnnxDetector documents and its tests use. */
237
+ const DEFAULT_DBNET_MODEL = "StabRise/text_detection_dbnet_ml_v0.2";
238
+ const DBNET_DETECTOR_DEFAULTS = Object.freeze({
239
+ ...BASE_STAGE_DEFAULTS,
240
+ inputCol: "image",
241
+ outputCol: "boxes",
242
+ keepInputData: true,
243
+ model: DEFAULT_DBNET_MODEL,
244
+ scoreThreshold: DB_POSTPROCESS_DEFAULTS.boxThresh,
245
+ binaryThreshold: DB_POSTPROCESS_DEFAULTS.thresh,
246
+ unclipRatio: DB_POSTPROCESS_DEFAULTS.unclipRatio,
247
+ mergeBoxes: true
248
+ });
249
+ function modelSpec(model) {
250
+ return /^https?:\/\//.test(model) ? {
251
+ repo: "dbnet",
252
+ files: [{ path: model }]
253
+ } : {
254
+ repo: model,
255
+ files: [{ path: "model.onnx" }]
256
+ };
257
+ }
258
+ var DbnetOnnxDetector = class extends Stage {
259
+ name = "DbnetOnnxDetector";
260
+ session = null;
261
+ loading = null;
262
+ constructor(options = {}) {
263
+ super(resolveParams(DBNET_DETECTOR_DEFAULTS, options));
264
+ }
265
+ async init() {
266
+ await this.getSession();
267
+ }
268
+ getSession() {
269
+ if (this.session) return Promise.resolve(this.session);
270
+ if (this.loading) return this.loading;
271
+ this.loading = (async () => {
272
+ const spec = modelSpec(this.params.model);
273
+ const bytes = (await ensureModelFiles(spec))[spec.files[0]?.path ?? ""];
274
+ if (!bytes) throw new DetectionError(`Model ${this.params.model} not found`, this.name);
275
+ const session = await createSession(bytes, { executionProviders: getConfig().executionProviders });
276
+ this.session = session;
277
+ return session;
278
+ })();
279
+ this.loading.catch(() => {
280
+ this.loading = null;
281
+ });
282
+ return this.loading;
283
+ }
284
+ async dispose() {
285
+ await this.session?.release();
286
+ this.session = null;
287
+ this.loading = null;
288
+ }
289
+ async apply(input, row) {
290
+ const image = input;
291
+ if (image?.exception) throw new DetectionError(`Upstream stage failed: ${image.exception}`, this.name);
292
+ if (!image || !(image.data instanceof Uint8Array) || image.data.byteLength === 0) throw new DetectionError("Expected an Image with decoded bytes", this.name);
293
+ const bitmap = await decodeImage(image.data);
294
+ let boxes;
295
+ try {
296
+ boxes = await this.detect(bitmap);
297
+ } finally {
298
+ bitmap.close();
299
+ }
300
+ return createDetectorOutput({
301
+ path: String(row[this.params.pathCol] ?? "memory"),
302
+ type: "dbnet-onnx",
303
+ bboxes: boxes
304
+ });
305
+ }
306
+ /** Run the model over one decoded image and return boxes in its coordinates. */
307
+ async detect(source) {
308
+ const session = await this.getSession();
309
+ const fitted = letterbox(source, {
310
+ width: DBNET_INPUT_SIZE,
311
+ height: DBNET_INPUT_SIZE
312
+ }, {
313
+ padding: "end",
314
+ fill: "#ffffff"
315
+ });
316
+ const tensorData = toNchwFloat32(toImageData(fitted.canvas), {
317
+ mean: IMAGENET_MEAN,
318
+ std: IMAGENET_STD,
319
+ bgr: true
320
+ });
321
+ const { Tensor } = await import("onnxruntime-web");
322
+ const inputName = session.inputNames[0];
323
+ const outputName = session.outputNames[0];
324
+ if (!inputName || !outputName) throw new DetectionError("Model exposes no input or output", this.name);
325
+ const output = (await session.run({ [inputName]: new Tensor("float32", tensorData, [
326
+ 1,
327
+ 3,
328
+ DBNET_INPUT_SIZE,
329
+ DBNET_INPUT_SIZE
330
+ ]) }))[outputName];
331
+ if (!output) throw new DetectionError(`Model produced no "${outputName}" output`, this.name);
332
+ const [, , height = DBNET_INPUT_SIZE, width = DBNET_INPUT_SIZE] = output.dims;
333
+ const boxes = quadsFromProbabilityMap({
334
+ data: output.data,
335
+ width,
336
+ height
337
+ }, {
338
+ width: source.width,
339
+ height: source.height
340
+ }, fitted.scale, {
341
+ thresh: this.params.binaryThreshold,
342
+ boxThresh: this.params.scoreThreshold,
343
+ unclipRatio: this.params.unclipRatio
344
+ }).map((quad) => boxFromPolygon(quad.points, { score: quad.score }));
345
+ return this.params.mergeBoxes ? mergeOverlappingBoxes(boxes, .02, 10, .3) : boxes;
346
+ }
347
+ onError(message, row) {
348
+ return createDetectorOutput({
349
+ path: String(row[this.params.pathCol] ?? "memory"),
350
+ type: "dbnet-onnx",
351
+ exception: message
352
+ });
353
+ }
354
+ };
355
+ //#endregion
356
+ //#region src/ocr/detector-registry.ts
357
+ const DETECTOR_MODELS = Object.freeze([
358
+ {
359
+ id: "paddle",
360
+ name: "PaddleOCR DB (follows the OCR preset)",
361
+ kind: "paddle",
362
+ notes: "Shares the detection model already downloaded for the selected OCR preset."
363
+ },
364
+ {
365
+ id: "dbnet-v0.2",
366
+ name: "StabRise DBNet ONNX v0.2 (~5 MB)",
367
+ kind: "dbnet-onnx",
368
+ repo: "StabRise/text_detection_dbnet_ml_v0.2",
369
+ approxBytes: 48e5,
370
+ notes: "The same detection model ScaleDP uses server-side."
371
+ },
372
+ {
373
+ id: "dbnet-v0.1",
374
+ name: "StabRise DBNet ONNX v0.1 (~5 MB)",
375
+ kind: "dbnet-onnx",
376
+ repo: "StabRise/text_detection_dbnet_ml_v0.1",
377
+ approxBytes: 48e5,
378
+ notes: "The earlier revision, kept for comparison."
379
+ }
380
+ ]);
381
+ const DEFAULT_DETECTOR_ID = "paddle";
382
+ function getDetectorModel(id) {
383
+ return DETECTOR_MODELS.find((m) => m.id === id);
384
+ }
385
+ //#endregion
386
+ //#region src/ocr/line-orientation.ts
387
+ /**
388
+ * 0 / 180 degree line-orientation classifier.
389
+ *
390
+ * Port of `scaledp/models/detectors/HasDetectLineOrientation.py`. Tiny, and it
391
+ * meaningfully improves recognition on rotated crops -- an upside-down line
392
+ * otherwise recognises as noise.
393
+ *
394
+ * Reproduces the same BGR-with-RGB-statistics quirk as the DBNet path: the
395
+ * Python code converts to BGR and normalises with RGB ImageNet constants.
396
+ */
397
+ const DEFAULT_ORIENTATION_MODEL = "StabRise/line_orientation_detection_v0.1";
398
+ /** Model input, width x height. */
399
+ const ORIENTATION_INPUT = {
400
+ width: 160,
401
+ height: 80
402
+ };
403
+ const LABELS = ["0_degree", "180_degree"];
404
+ var LineOrientationClassifier = class {
405
+ model;
406
+ session = null;
407
+ loading = null;
408
+ constructor(model = DEFAULT_ORIENTATION_MODEL) {
409
+ this.model = model;
410
+ }
411
+ getSession() {
412
+ if (this.session) return Promise.resolve(this.session);
413
+ if (this.loading) return this.loading;
414
+ this.loading = (async () => {
415
+ const bytes = (await ensureModelFiles({
416
+ repo: this.model,
417
+ files: [{ path: "model.onnx" }]
418
+ }))["model.onnx"];
419
+ if (!bytes) throw new DetectionError(`Model ${this.model} has no model.onnx`, "LineOrientation");
420
+ const session = await createSession(bytes);
421
+ this.session = session;
422
+ return session;
423
+ })();
424
+ this.loading.catch(() => {
425
+ this.loading = null;
426
+ });
427
+ return this.loading;
428
+ }
429
+ async classify(source) {
430
+ const session = await this.getSession();
431
+ const canvas = createCanvas(ORIENTATION_INPUT.width, ORIENTATION_INPUT.height);
432
+ context2d(canvas).drawImage(source, 0, 0, canvas.width, canvas.height);
433
+ const data = toNchwFloat32(toImageData(canvas), {
434
+ mean: IMAGENET_MEAN,
435
+ std: IMAGENET_STD,
436
+ bgr: true
437
+ });
438
+ const { Tensor } = await import("onnxruntime-web");
439
+ const inputName = session.inputNames[0];
440
+ const outputName = session.outputNames[0];
441
+ if (!inputName || !outputName) throw new DetectionError("Model exposes no input or output", "LineOrientation");
442
+ const logits = (await session.run({ [inputName]: new Tensor("float32", data, [
443
+ 1,
444
+ 3,
445
+ ORIENTATION_INPUT.height,
446
+ ORIENTATION_INPUT.width
447
+ ]) }))[outputName]?.data;
448
+ if (!logits || logits.length < 2) throw new DetectionError("Classifier produced no logits", "LineOrientation");
449
+ return logits[1] > logits[0] ? LABELS[1] : LABELS[0];
450
+ }
451
+ async dispose() {
452
+ await this.session?.release();
453
+ this.session = null;
454
+ this.loading = null;
455
+ }
456
+ };
457
+ //#endregion
458
+ //#region src/ocr/line-orientation-stage.ts
459
+ /**
460
+ * Line-orientation detection and correction.
461
+ *
462
+ * Port of ScaleDP's `HasDetectLineOrientation`, which `TesseractRecognizer`
463
+ * mixes in: each detected box is cropped, classified 0 or 180 degrees, and the
464
+ * upside-down ones are turned before recognition. An inverted line otherwise
465
+ * recognises as noise.
466
+ *
467
+ * Python does this per crop inside the recognizer. Here it is a stage of its
468
+ * own, because `PaddleTextRecognizer` detects and recognises in a single pass
469
+ * and has no seam to hook into. Flipping each inverted region in place on a
470
+ * copy of the page gets the same result and costs one recognition pass rather
471
+ * than one per box; the regions are rectangles, so a 180-degree turn leaves
472
+ * every box's coordinates untouched and downstream stages need no adjustment.
473
+ */
474
+ const LINE_ORIENTATION_DEFAULTS = Object.freeze({
475
+ ...BASE_STAGE_DEFAULTS,
476
+ inputCol: "image",
477
+ inputCols: ["image", "boxes"],
478
+ outputCol: "oriented",
479
+ orientationCol: "orientations",
480
+ keepInputData: true,
481
+ model: DEFAULT_ORIENTATION_MODEL,
482
+ correct: true,
483
+ onlyRotated: true,
484
+ padding: 2,
485
+ imageType: "png"
486
+ });
487
+ function boxesOf$1(source) {
488
+ if (typeof source !== "object" || source === null) return [];
489
+ return source.bboxes ?? [];
490
+ }
491
+ var LineOrientationDetector = class extends Stage {
492
+ name = "LineOrientationDetector";
493
+ classifier = null;
494
+ constructor(options = {}) {
495
+ super(resolveParams(LINE_ORIENTATION_DEFAULTS, options, { inputCols: (value) => {
496
+ if (value.length !== 2) throw new RangeError("inputCols must be [imageColumn, boxColumn]");
497
+ } }));
498
+ }
499
+ async init() {
500
+ this.classifier ??= new LineOrientationClassifier(this.params.model);
501
+ }
502
+ /** One row in, one row out -- but two columns written, so expand not apply. */
503
+ async expand(_input, row, ctx) {
504
+ const { inputCols, outputCol, orientationCol, correct, onlyRotated, padding } = this.params;
505
+ const [imageCol, boxCol] = inputCols;
506
+ const image = row[imageCol];
507
+ if (image?.exception) throw new DetectionError(`Upstream stage failed: ${image.exception}`, this.name);
508
+ if (!image || !(image.data instanceof Uint8Array) || image.data.byteLength === 0) throw new DetectionError("Expected an Image with decoded bytes", this.name);
509
+ await this.init();
510
+ const classifier = this.classifier;
511
+ const boxes = boxesOf$1(row[boxCol]);
512
+ const bitmap = await decodeImage(image.data);
513
+ try {
514
+ const canvas = createCanvas(bitmap.width, bitmap.height);
515
+ const ctx2d = context2d(canvas);
516
+ ctx2d.drawImage(bitmap, 0, 0);
517
+ const orientations = [];
518
+ let flipped = 0;
519
+ for (const box of boxes) {
520
+ ctx.signal?.throwIfAborted();
521
+ if (onlyRotated && !isRotated(box)) {
522
+ orientations.push("0_degree");
523
+ continue;
524
+ }
525
+ const crop = cropBox(bitmap, box, { padding });
526
+ const orientation = await classifier.classify(crop);
527
+ orientations.push(orientation);
528
+ if (orientation === "180_degree" && correct) {
529
+ flipRegion(ctx2d, canvas, box);
530
+ flipped++;
531
+ }
532
+ }
533
+ const corrected = flipped > 0 ? createImage({
534
+ path: image.path,
535
+ resolution: image.resolution,
536
+ data: await encodeImage(canvas, `image/${this.params.imageType}`),
537
+ imageType: this.params.imageType,
538
+ width: canvas.width,
539
+ height: canvas.height
540
+ }) : image;
541
+ return [{
542
+ ...row,
543
+ [outputCol]: corrected,
544
+ [orientationCol]: orientations
545
+ }];
546
+ } finally {
547
+ bitmap.close();
548
+ }
549
+ }
550
+ async apply() {
551
+ throw new DetectionError("unreachable: expand handles every row", this.name);
552
+ }
553
+ onError(message, row) {
554
+ return createImage({
555
+ path: String(row[this.params.pathCol] ?? "memory"),
556
+ exception: message
557
+ });
558
+ }
559
+ async dispose() {
560
+ await this.classifier?.dispose();
561
+ this.classifier = null;
562
+ }
563
+ };
564
+ /**
565
+ * Turn one box's region 180 degrees in place.
566
+ *
567
+ * A rectangle maps onto itself under a 180-degree rotation about its own
568
+ * centre, whatever its angle, so the box's coordinates stay valid and
569
+ * downstream stages need no adjustment.
570
+ *
571
+ * The draw is clipped to the box's own rotated outline. Without that a rotated
572
+ * box turns its whole axis-aligned envelope, dragging neighbouring text through
573
+ * the rotation with it -- and the envelope of a skewed box is meaningfully
574
+ * larger than the box.
575
+ */
576
+ function flipRegion(ctx, canvas, box) {
577
+ const pad = 1;
578
+ const x = Math.max(0, Math.min(Math.round(box.x) - pad, canvas.width - 1));
579
+ const y = Math.max(0, Math.min(Math.round(box.y) - pad, canvas.height - 1));
580
+ const width = Math.min(Math.round(box.width) + 2, canvas.width - x);
581
+ const height = Math.min(Math.round(box.height) + 2, canvas.height - y);
582
+ if (width < 1 || height < 1) return;
583
+ const region = createCanvas(width, height);
584
+ context2d(region).drawImage(canvas, x, y, width, height, 0, 0, width, height);
585
+ const cx = x + width / 2;
586
+ const cy = y + height / 2;
587
+ ctx.save();
588
+ ctx.beginPath();
589
+ if (Math.abs(box.angle) < 3) ctx.rect(x, y, width, height);
590
+ else {
591
+ const rad = box.angle * Math.PI / 180;
592
+ const cos = Math.cos(rad);
593
+ const sin = Math.sin(rad);
594
+ [
595
+ [-box.width / 2, -box.height / 2],
596
+ [box.width / 2, -box.height / 2],
597
+ [box.width / 2, box.height / 2],
598
+ [-box.width / 2, box.height / 2]
599
+ ].forEach(([px, py], index) => {
600
+ const rx = px * cos - py * sin + cx;
601
+ const ry = px * sin + py * cos + cy;
602
+ if (index === 0) ctx.moveTo(rx, ry);
603
+ else ctx.lineTo(rx, ry);
604
+ });
605
+ ctx.closePath();
606
+ }
607
+ ctx.clip();
608
+ ctx.translate(cx, cy);
609
+ ctx.rotate(Math.PI);
610
+ ctx.drawImage(region, -width / 2, -height / 2);
611
+ ctx.restore();
612
+ }
613
+ //#endregion
614
+ //#region src/ocr/presets.ts
615
+ const PADDLE_OCR_PRESETS = Object.freeze([
616
+ {
617
+ value: "v6-small",
618
+ label: "Latin / CJK (default)",
619
+ scripts: [
620
+ "Latin",
621
+ "Han",
622
+ "Hiragana",
623
+ "Katakana"
624
+ ]
625
+ },
626
+ {
627
+ value: "v6-medium",
628
+ label: "Latin / CJK (medium, more accurate)",
629
+ scripts: [
630
+ "Latin",
631
+ "Han",
632
+ "Hiragana",
633
+ "Katakana"
634
+ ]
635
+ },
636
+ {
637
+ value: "v6-tiny",
638
+ label: "Latin / CJK (tiny, fastest)",
639
+ scripts: ["Latin", "Han"]
640
+ },
641
+ {
642
+ value: "v5-latin-mobile",
643
+ label: "Latin (French, German, Spanish, ...)",
644
+ scripts: ["Latin"]
645
+ },
646
+ {
647
+ value: "v5-eslav-mobile",
648
+ label: "Latin + Cyrillic (Russian, Ukrainian, ...)",
649
+ scripts: ["Latin", "Cyrillic"]
650
+ },
651
+ {
652
+ value: "v5-cyrillic-mobile",
653
+ label: "Cyrillic only",
654
+ scripts: ["Cyrillic"]
655
+ },
656
+ {
657
+ value: "v5-devanagari-mobile",
658
+ label: "Latin + Hindi (Devanagari)",
659
+ scripts: ["Latin", "Devanagari"]
660
+ },
661
+ {
662
+ value: "v5-arabic-mobile",
663
+ label: "Latin + Arabic",
664
+ scripts: ["Latin", "Arabic"]
665
+ },
666
+ {
667
+ value: "v5-greek-mobile",
668
+ label: "Latin + Greek",
669
+ scripts: ["Latin", "Greek"]
670
+ },
671
+ {
672
+ value: "v5-korean-mobile",
673
+ label: "Latin + Korean",
674
+ scripts: ["Latin", "Hangul"]
675
+ },
676
+ {
677
+ value: "v5-thai-mobile",
678
+ label: "Latin + Thai",
679
+ scripts: ["Latin", "Thai"]
680
+ },
681
+ {
682
+ value: "v5-tamil-mobile",
683
+ label: "Latin + Tamil",
684
+ scripts: ["Latin", "Tamil"]
685
+ },
686
+ {
687
+ value: "v5-telugu-mobile",
688
+ label: "Latin + Telugu",
689
+ scripts: ["Latin", "Telugu"]
690
+ },
691
+ {
692
+ value: "v5-en-mobile",
693
+ label: "English only (fastest)",
694
+ scripts: ["Latin"]
695
+ }
696
+ ]);
697
+ const DEFAULT_OCR_PRESET = "v6-small";
698
+ function isKnownPreset(value) {
699
+ return PADDLE_OCR_PRESETS.some((p) => p.value === value);
700
+ }
701
+ /** Presets able to read a script name as reported by OSD script detection. */
702
+ function presetsForScript(script) {
703
+ return PADDLE_OCR_PRESETS.filter((p) => p.scripts.includes(script));
704
+ }
705
+ //#endregion
706
+ //#region src/ocr/paddle-service.ts
707
+ /**
708
+ * PaddleOcrService lifecycle: model fetching, caching and per-preset reuse.
709
+ *
710
+ * Models are pulled through our own ModelCache and handed to the service as
711
+ * ArrayBuffers, rather than letting ppu-paddle-ocr fetch them itself. Its
712
+ * browser build re-downloads ~6 MB on every page load, relying only on the HTTP
713
+ * cache; routing through IndexedDB makes a repeat visit instant and offline-safe.
714
+ */
715
+ let modulePromise = null;
716
+ async function loadPpu() {
717
+ if (modulePromise) return modulePromise;
718
+ modulePromise = (async () => {
719
+ await loadOrt();
720
+ try {
721
+ return await import("ppu-paddle-ocr/web");
722
+ } catch (cause) {
723
+ throw new Error("ppu-paddle-ocr is required for PaddleOCR stages. Install it: npm i ppu-paddle-ocr", { cause });
724
+ }
725
+ })();
726
+ return modulePromise;
727
+ }
728
+ /** The three files a preset needs, as a ModelSpec our cache understands. */
729
+ async function specForPreset(preset) {
730
+ const ppu = await loadPpu();
731
+ const urls = preset in ppu.MODEL_PRESETS ? ppu.MODEL_PRESETS[preset] : ppu.DEFAULT_MODEL;
732
+ const roles = [
733
+ "detection",
734
+ "recognition",
735
+ "charactersDictionary"
736
+ ];
737
+ return {
738
+ spec: {
739
+ repo: `ppu-paddle-ocr/${preset}`,
740
+ files: roles.map((r) => ({ path: urls[r] }))
741
+ },
742
+ roles: [...roles]
743
+ };
744
+ }
745
+ /**
746
+ * Absolute model URLs bypass `modelHost`: ppu-paddle-ocr publishes its own
747
+ * catalogue and the paths are meaningful only against that host.
748
+ */
749
+ async function fetchPresetFiles(preset) {
750
+ const { spec, roles } = await specForPreset(preset);
751
+ const files = await ensureModelFiles({
752
+ ...spec,
753
+ files: spec.files.map((f) => ({ ...f }))
754
+ });
755
+ const out = {};
756
+ for (const [i, role] of roles.entries()) {
757
+ const path = spec.files[i]?.path;
758
+ if (path && files[path]) out[role] = files[path];
759
+ }
760
+ return out;
761
+ }
762
+ const services = /* @__PURE__ */ new Map();
763
+ async function getPaddleService(preset = DEFAULT_OCR_PRESET) {
764
+ const existing = services.get(preset);
765
+ if (existing) return existing;
766
+ const promise = (async () => {
767
+ const ppu = await loadPpu();
768
+ const model = await fetchPresetFiles(preset);
769
+ const service = new ppu.PaddleOcrService({
770
+ model,
771
+ session: {
772
+ executionProviders: [...getConfig().executionProviders],
773
+ graphOptimizationLevel: "all"
774
+ }
775
+ });
776
+ await service.initialize();
777
+ return service;
778
+ })();
779
+ promise.catch(() => services.delete(preset));
780
+ services.set(preset, promise);
781
+ return promise;
782
+ }
783
+ async function isPresetCached(preset) {
784
+ const { spec } = await specForPreset(preset);
785
+ return isCached(spec);
786
+ }
787
+ /** Pre-warm a preset so the first OCR call does not pay the download. */
788
+ async function loadPreset(preset) {
789
+ await getPaddleService(preset);
790
+ }
791
+ async function removePreset(preset) {
792
+ const { spec } = await specForPreset(preset);
793
+ const service = services.get(preset);
794
+ services.delete(preset);
795
+ await service?.then((s) => s.destroy()).catch(() => void 0);
796
+ await evict(spec);
797
+ }
798
+ /** Tear down every cached service. */
799
+ async function disposePaddleServices() {
800
+ const pending = [...services.values()];
801
+ services.clear();
802
+ await Promise.all(pending.map((p) => p.then((s) => s.destroy()).catch(() => void 0)));
803
+ }
804
+ //#endregion
805
+ //#region src/ocr/paddle.ts
806
+ /**
807
+ * PaddleOCR text detection and recognition -- the default OCR engines.
808
+ *
809
+ * `PaddleTextDetector` mirrors ScaleDP's detector stages (image -> boxes) and
810
+ * `PaddleTextRecognizer` mirrors its OCR stages (image -> Document with text
811
+ * and boxes). Both run PP-OCR models through ppu-paddle-ocr on onnxruntime-web.
812
+ */
813
+ const PADDLE_DETECTOR_DEFAULTS = Object.freeze({
814
+ ...BASE_STAGE_DEFAULTS,
815
+ inputCol: "image",
816
+ outputCol: "boxes",
817
+ keepInputData: true,
818
+ preset: DEFAULT_OCR_PRESET,
819
+ scoreThreshold: 0
820
+ });
821
+ function validatePreset(value) {
822
+ if (!isKnownPreset(value)) throw new RangeError(`Unknown OCR preset "${value}". See PADDLE_OCR_PRESETS for valid values.`);
823
+ }
824
+ /** Decode a stage input into something ppu-paddle-ocr accepts. */
825
+ async function toCanvas(input) {
826
+ if (typeof OffscreenCanvas !== "undefined" && input instanceof OffscreenCanvas) return input;
827
+ if (typeof ImageData !== "undefined" && input instanceof ImageData) return imageDataToCanvas(input);
828
+ const image = input;
829
+ if (image?.exception) throw new OcrError(`Upstream stage failed: ${image.exception}`, "toCanvas");
830
+ if (!image || !(image.data instanceof Uint8Array) || image.data.byteLength === 0) throw new OcrError("Expected an Image with decoded bytes", "toCanvas");
831
+ const bitmap = await decodeImage(image.data);
832
+ try {
833
+ const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
834
+ const ctx = canvas.getContext("2d");
835
+ if (!ctx) throw new OcrError("Failed to acquire a 2D context", "toCanvas");
836
+ ctx.drawImage(bitmap, 0, 0);
837
+ return canvas;
838
+ } finally {
839
+ bitmap.close();
840
+ }
841
+ }
842
+ /** Text detection only: image -> word boxes, no recognition. */
843
+ var PaddleTextDetector = class extends Stage {
844
+ name = "PaddleTextDetector";
845
+ constructor(options = {}) {
846
+ super(resolveParams(PADDLE_DETECTOR_DEFAULTS, options, { preset: validatePreset }));
847
+ }
848
+ async init() {
849
+ await getPaddleService(this.params.preset);
850
+ }
851
+ async apply(input, row) {
852
+ const service = await getPaddleService(this.params.preset);
853
+ const canvas = await toCanvas(input);
854
+ const { boxes } = await service.detect(canvas);
855
+ return createDetectorOutput({
856
+ path: String(row[this.params.pathCol] ?? "memory"),
857
+ type: "paddle",
858
+ bboxes: boxes.map((b) => boxFromBBox([
859
+ b.x,
860
+ b.y,
861
+ b.x + b.width,
862
+ b.y + b.height
863
+ ], { score: 1 }))
864
+ });
865
+ }
866
+ onError(message, row) {
867
+ return createDetectorOutput({
868
+ path: String(row[this.params.pathCol] ?? "memory"),
869
+ type: "paddle",
870
+ exception: message
871
+ });
872
+ }
873
+ };
874
+ const PADDLE_RECOGNIZER_DEFAULTS = Object.freeze({
875
+ ...BASE_STAGE_DEFAULTS,
876
+ inputCol: "image",
877
+ outputCol: "text",
878
+ keepInputData: true,
879
+ preset: DEFAULT_OCR_PRESET,
880
+ scoreThreshold: .5,
881
+ strategy: "per-box",
882
+ keepFormatting: false,
883
+ lineTolerance: 0
884
+ });
885
+ /** Full OCR: image -> Document with text and word-level boxes. */
886
+ var PaddleTextRecognizer = class extends Stage {
887
+ name = "PaddleTextRecognizer";
888
+ constructor(options = {}) {
889
+ super(resolveParams(PADDLE_RECOGNIZER_DEFAULTS, options, { preset: validatePreset }));
890
+ }
891
+ async init() {
892
+ await getPaddleService(this.params.preset);
893
+ }
894
+ async apply(input, row) {
895
+ const { preset, strategy, scoreThreshold, keepFormatting, lineTolerance } = this.params;
896
+ const service = await getPaddleService(preset);
897
+ const canvas = await toCanvas(input);
898
+ const result = await service.recognize(canvas, {
899
+ flatten: true,
900
+ strategy,
901
+ noCache: true
902
+ });
903
+ const bboxes = ("results" in result ? result.results : []).filter((item) => item.confidence >= scoreThreshold).map((item) => boxFromBBox([
904
+ item.box.x,
905
+ item.box.y,
906
+ item.box.x + item.box.width,
907
+ item.box.y + item.box.height
908
+ ], {
909
+ text: item.text,
910
+ score: item.confidence
911
+ }));
912
+ return createDocument({
913
+ path: String(row[this.params.pathCol] ?? "memory"),
914
+ type: "ocr",
915
+ text: keepFormatting ? boxesToFormattedText(bboxes, lineTolerance) : boxesToText(bboxes),
916
+ bboxes
917
+ });
918
+ }
919
+ onError(message, row) {
920
+ return createDocument({
921
+ path: String(row[this.params.pathCol] ?? "memory"),
922
+ type: "ocr",
923
+ exception: message
924
+ });
925
+ }
926
+ };
927
+ //#endregion
928
+ //#region src/ocr/script-detect.ts
929
+ /**
930
+ * Writing-script detection via Tesseract's OSD model.
931
+ *
932
+ * Kept separate from recognition on purpose: when the recognition model is
933
+ * wrong for the page the text comes back garbled, and that is precisely when
934
+ * knowing the script is most useful. Running OSD independently means the
935
+ * answer stays trustworthy.
936
+ *
937
+ * Feeds the preset picker -- see `presetsForScript`.
938
+ */
939
+ let workerPromise = null;
940
+ async function getOsdWorker() {
941
+ if (workerPromise) return workerPromise;
942
+ workerPromise = (async () => {
943
+ let mod;
944
+ try {
945
+ mod = await import("tesseract.js");
946
+ } catch (cause) {
947
+ throw new Error("tesseract.js is required for script detection. Install it: npm i tesseract.js", { cause });
948
+ }
949
+ return mod.createWorker("osd", mod.OEM.TESSERACT_ONLY);
950
+ })();
951
+ workerPromise.catch(() => {
952
+ workerPromise = null;
953
+ });
954
+ return workerPromise;
955
+ }
956
+ /** Detect the dominant writing script on a page. Returns null when unsure. */
957
+ async function detectScript(source) {
958
+ const worker = await getOsdWorker();
959
+ let canvas;
960
+ if (typeof OffscreenCanvas !== "undefined" && source instanceof OffscreenCanvas) canvas = source;
961
+ else if (typeof ImageData !== "undefined" && source instanceof ImageData) canvas = imageDataToCanvas(source);
962
+ else if (typeof ImageBitmap !== "undefined" && source instanceof ImageBitmap) canvas = imageDataToCanvas(toImageData(source));
963
+ else {
964
+ const image = source;
965
+ if (!(image.data instanceof Uint8Array) || image.data.byteLength === 0) throw new OcrError("Expected an Image with decoded bytes", "detectScript");
966
+ const bitmap = await decodeImage(image.data);
967
+ try {
968
+ canvas = imageDataToCanvas(toImageData(bitmap));
969
+ } finally {
970
+ bitmap.close();
971
+ }
972
+ }
973
+ const blob = await canvas.convertToBlob({ type: "image/png" });
974
+ const { data } = await worker.detect(blob);
975
+ if (!data?.script) return null;
976
+ return {
977
+ script: data.script,
978
+ confidence: data.script_confidence ?? 0
979
+ };
980
+ }
981
+ /** Presets able to read the detected script, best-first. */
982
+ async function suggestPresets(source) {
983
+ const detected = await detectScript(source);
984
+ return detected ? presetsForScript(detected.script) : [];
985
+ }
986
+ /** Tear down the shared OSD worker. */
987
+ async function disposeScriptDetection() {
988
+ const pending = workerPromise;
989
+ workerPromise = null;
990
+ await pending?.then((w) => w.terminate()).catch(() => void 0);
991
+ }
992
+ //#endregion
993
+ //#region src/ocr/tesseract.ts
994
+ /**
995
+ * Tesseract OCR, mirroring ScaleDP's `TesseractOcr` stage.
996
+ *
997
+ * Recognition runs on tesseract-wasm; script detection uses tesseract.js's OSD
998
+ * model. The two are deliberately independent -- script detection still works
999
+ * when the recognition model is wrong for the page and garbles the text, which
1000
+ * is exactly when you most want to know the script.
1001
+ *
1002
+ * Both the worker URL and the traineddata location come from `configure()`.
1003
+ * The pdftools prototype hardcoded `/tesseract-worker.js` and a raw GitHub URL,
1004
+ * neither of which a library can assume.
1005
+ */
1006
+ /** tessdata_fast is a good default: far smaller than tessdata, barely less accurate. */
1007
+ const DEFAULT_TESSDATA_URL = "https://raw.githubusercontent.com/tesseract-ocr/tessdata_fast/main/";
1008
+ const TESSERACT_OCR_DEFAULTS = Object.freeze({
1009
+ ...BASE_STAGE_DEFAULTS,
1010
+ inputCol: "image",
1011
+ outputCol: "text",
1012
+ keepInputData: true,
1013
+ lang: ["eng"],
1014
+ scoreThreshold: .5,
1015
+ keepFormatting: false,
1016
+ lineTolerance: 0
1017
+ });
1018
+ let clientPromise = null;
1019
+ let loadedLanguage = null;
1020
+ function trainedDataUrl(lang) {
1021
+ return `${(getConfig().tesseract.dataUrl ?? "https://raw.githubusercontent.com/tesseract-ocr/tessdata_fast/main/").replace(/\/?$/, "/")}${lang}.traineddata`;
1022
+ }
1023
+ /**
1024
+ * The shared Tesseract client, loading the model for `lang` if needed.
1025
+ *
1026
+ * Exported so the crop-based recognizer reuses this one client rather than
1027
+ * standing up a second worker and loading the same traineddata twice.
1028
+ */
1029
+ async function getTesseractClient(lang) {
1030
+ if (clientPromise && loadedLanguage === lang) return clientPromise;
1031
+ if (clientPromise) {
1032
+ const previous = clientPromise;
1033
+ clientPromise = null;
1034
+ await previous.then((c) => c.destroy()).catch(() => void 0);
1035
+ }
1036
+ loadedLanguage = lang;
1037
+ clientPromise = (async () => {
1038
+ let mod;
1039
+ try {
1040
+ mod = await import("tesseract-wasm");
1041
+ } catch (cause) {
1042
+ throw new Error("tesseract-wasm is required for the Tesseract engine. Install it: npm i tesseract-wasm", { cause });
1043
+ }
1044
+ const { workerUrl } = getConfig().tesseract;
1045
+ const client = new mod.OCRClient(workerUrl ? { workerURL: workerUrl } : {});
1046
+ const response = await fetch(trainedDataUrl(lang));
1047
+ if (!response.ok) throw new OcrError(`Failed to fetch ${lang}.traineddata: ${response.status} ${response.statusText}`, "TesseractOcr");
1048
+ await client.loadModel(new Uint8Array(await response.arrayBuffer()));
1049
+ return client;
1050
+ })();
1051
+ clientPromise.catch(() => {
1052
+ clientPromise = null;
1053
+ loadedLanguage = null;
1054
+ });
1055
+ return clientPromise;
1056
+ }
1057
+ /** Tear down the shared Tesseract client. */
1058
+ async function disposeTesseract() {
1059
+ const pending = clientPromise;
1060
+ clientPromise = null;
1061
+ loadedLanguage = null;
1062
+ await pending?.then((c) => c.destroy()).catch(() => void 0);
1063
+ }
1064
+ var TesseractOcr = class extends Stage {
1065
+ name = "TesseractOcr";
1066
+ constructor(options = {}) {
1067
+ super(resolveParams(TESSERACT_OCR_DEFAULTS, options, { lang: (value) => {
1068
+ if (value.length === 0) throw new RangeError("lang must not be empty");
1069
+ } }));
1070
+ }
1071
+ /** tesseract-wasm loads one model, so multi-language means a joined code. */
1072
+ get language() {
1073
+ return this.params.lang.join("+");
1074
+ }
1075
+ async init() {
1076
+ await getTesseractClient(this.language);
1077
+ }
1078
+ async apply(input, row) {
1079
+ const image = input;
1080
+ if (image?.exception) throw new OcrError(`Upstream stage failed: ${image.exception}`, this.name);
1081
+ if (!image || !(image.data instanceof Uint8Array) || image.data.byteLength === 0) throw new OcrError("Expected an Image with decoded bytes", this.name);
1082
+ const client = await getTesseractClient(this.language);
1083
+ const bitmap = await decodeImage(image.data);
1084
+ let words;
1085
+ try {
1086
+ await client.loadImage(toImageData(bitmap));
1087
+ words = await client.getTextBoxes("word");
1088
+ } finally {
1089
+ bitmap.close();
1090
+ }
1091
+ const { scoreThreshold, keepFormatting, lineTolerance } = this.params;
1092
+ const bboxes = words.filter((word) => word.confidence >= scoreThreshold && word.text.trim().length > 0).map((word) => boxFromBBox([
1093
+ word.rect.left,
1094
+ word.rect.top,
1095
+ word.rect.right,
1096
+ word.rect.bottom
1097
+ ], {
1098
+ text: word.text.trim(),
1099
+ score: word.confidence
1100
+ }));
1101
+ return createDocument({
1102
+ path: String(row[this.params.pathCol] ?? "memory"),
1103
+ type: "tesseract",
1104
+ text: keepFormatting ? boxesToFormattedText(bboxes, lineTolerance) : boxesToText(bboxes),
1105
+ bboxes
1106
+ });
1107
+ }
1108
+ onError(message, row) {
1109
+ return createDocument({
1110
+ path: String(row[this.params.pathCol] ?? "memory"),
1111
+ type: "tesseract",
1112
+ exception: message
1113
+ });
1114
+ }
1115
+ async dispose() {
1116
+ await disposeTesseract();
1117
+ }
1118
+ };
1119
+ //#endregion
1120
+ //#region src/ocr/tesseract-recognizer.ts
1121
+ /**
1122
+ * Recognize text inside boxes a detector already found.
1123
+ *
1124
+ * Port of ScaleDP's `TesseractRecognizer` (a `BaseRecognizer`), which is the
1125
+ * half of the OCR story `PaddleTextRecognizer` cannot cover: Paddle detects and
1126
+ * recognises in a single pass over the page, so boxes produced by a *separate*
1127
+ * detector never reach it -- rotated boxes in particular. This stage takes those
1128
+ * boxes, straightens each one, and reads it.
1129
+ */
1130
+ const TESSERACT_RECOGNIZER_DEFAULTS = Object.freeze({
1131
+ ...BASE_STAGE_DEFAULTS,
1132
+ inputCol: "image",
1133
+ inputCols: ["image", "boxes"],
1134
+ outputCol: "text",
1135
+ keepInputData: true,
1136
+ lang: ["eng"],
1137
+ scaleFactor: 1,
1138
+ padding: 5,
1139
+ scoreThreshold: .5,
1140
+ keepFormatting: false,
1141
+ lineTolerance: 0,
1142
+ boxLevel: "region",
1143
+ detectLineOrientation: true,
1144
+ onlyRotated: false,
1145
+ oriModel: DEFAULT_ORIENTATION_MODEL
1146
+ });
1147
+ function boxesOf(source) {
1148
+ if (typeof source !== "object" || source === null) return [];
1149
+ return source.bboxes ?? [];
1150
+ }
1151
+ var TesseractRecognizer = class extends Stage {
1152
+ name = "TesseractRecognizer";
1153
+ orientation = null;
1154
+ constructor(options = {}) {
1155
+ super(resolveParams(TESSERACT_RECOGNIZER_DEFAULTS, options, {
1156
+ inputCols: (value) => {
1157
+ if (value.length !== 2) throw new RangeError("inputCols must be [imageColumn, boxColumn]");
1158
+ },
1159
+ lang: (value) => {
1160
+ if (value.length === 0) throw new RangeError("lang must not be empty");
1161
+ }
1162
+ }));
1163
+ }
1164
+ get language() {
1165
+ return this.params.lang.join("+");
1166
+ }
1167
+ async init() {
1168
+ await getTesseractClient(this.language);
1169
+ if (this.params.detectLineOrientation) this.orientation ??= new LineOrientationClassifier(this.params.oriModel);
1170
+ }
1171
+ async apply(_input, row, ctx) {
1172
+ const { inputCols, scaleFactor, padding, scoreThreshold, keepFormatting, lineTolerance, boxLevel, detectLineOrientation, onlyRotated } = this.params;
1173
+ const [imageCol, boxCol] = inputCols;
1174
+ const image = row[imageCol];
1175
+ if (image?.exception) throw new OcrError(`Upstream stage failed: ${image.exception}`, this.name);
1176
+ if (!image || !(image.data instanceof Uint8Array) || image.data.byteLength === 0) throw new OcrError("Expected an Image with decoded bytes", this.name);
1177
+ const source = row[boxCol];
1178
+ if (source === void 0) throw new OcrError(`No boxes in column "${boxCol}". This stage reads a detector's output; run a text detector before it.`, this.name);
1179
+ await this.init();
1180
+ const client = await getTesseractClient(this.language);
1181
+ const bitmap = await decodeImage(image.data);
1182
+ const recognized = [];
1183
+ try {
1184
+ for (const box of boxesOf(source)) {
1185
+ ctx.signal?.throwIfAborted();
1186
+ const geometry = cropGeometry(box, {
1187
+ scaleFactor,
1188
+ padding
1189
+ });
1190
+ let crop = cropBox(bitmap, box, {
1191
+ scaleFactor,
1192
+ padding
1193
+ });
1194
+ let inverted = false;
1195
+ if (detectLineOrientation && this.orientation) {
1196
+ inverted = await this.orientation.classify(crop) === "180_degree";
1197
+ if (inverted) crop = rotate180(crop);
1198
+ }
1199
+ if (onlyRotated && !isRotated(box) && !inverted) continue;
1200
+ await client.loadImage(toImageData(crop));
1201
+ const items = await client.getTextBoxes("word");
1202
+ const words = items.filter((item) => item.text.trim());
1203
+ if (words.length === 0) continue;
1204
+ const score = items.reduce((sum, item) => sum + item.confidence, 0) / (items.length || 1);
1205
+ if (score < scoreThreshold) continue;
1206
+ if (boxLevel === "word") {
1207
+ for (const item of words) recognized.push(wordBox(item.rect, geometry, inverted, scaleFactor, item.text.trim(), item.confidence));
1208
+ continue;
1209
+ }
1210
+ recognized.push({
1211
+ ...box,
1212
+ text: words.map((item) => item.text.trim()).join(" "),
1213
+ score
1214
+ });
1215
+ }
1216
+ } finally {
1217
+ bitmap.close();
1218
+ }
1219
+ const bboxes = scaleFactor === 1 || boxLevel === "word" ? recognized : recognized.map((box) => ({
1220
+ ...box,
1221
+ x: Math.round(box.x / scaleFactor),
1222
+ y: Math.round(box.y / scaleFactor),
1223
+ width: Math.round(box.width / scaleFactor),
1224
+ height: Math.round(box.height / scaleFactor)
1225
+ }));
1226
+ return createDocument({
1227
+ path: String(row[this.params.pathCol] ?? image.path),
1228
+ type: "tesseract-recognizer",
1229
+ text: keepFormatting ? boxesToFormattedText(bboxes, lineTolerance) : boxesToText(bboxes),
1230
+ bboxes
1231
+ });
1232
+ }
1233
+ onError(message, row) {
1234
+ return createDocument({
1235
+ path: String(row[this.params.pathCol] ?? "memory"),
1236
+ type: "tesseract-recognizer",
1237
+ exception: message
1238
+ });
1239
+ }
1240
+ async dispose() {
1241
+ await this.orientation?.dispose();
1242
+ this.orientation = null;
1243
+ }
1244
+ };
1245
+ /**
1246
+ * One word's box, in the page's coordinates.
1247
+ *
1248
+ * tesseract reports the rect in the crop's own space: straightened, padded,
1249
+ * scaled, and turned the right way up if the line was upside down. Undo the
1250
+ * turn, then push the four corners back through the crop's own transform, so a
1251
+ * word inside a skewed line comes back skewed the same way.
1252
+ */
1253
+ function wordBox(rect, geometry, inverted, scaleFactor, text, score) {
1254
+ const { width, height, map } = geometry;
1255
+ const [left, top, right, bottom] = inverted ? [
1256
+ width - rect.right,
1257
+ height - rect.bottom,
1258
+ width - rect.left,
1259
+ height - rect.top
1260
+ ] : [
1261
+ rect.left,
1262
+ rect.top,
1263
+ rect.right,
1264
+ rect.bottom
1265
+ ];
1266
+ const toPage = (x, y) => {
1267
+ const [px, py] = map(x, y);
1268
+ return [px / scaleFactor, py / scaleFactor];
1269
+ };
1270
+ const corners = [
1271
+ toPage(left, top),
1272
+ toPage(right, top),
1273
+ toPage(right, bottom),
1274
+ toPage(left, bottom)
1275
+ ];
1276
+ return boxFromPolygon(corners, {
1277
+ text,
1278
+ score
1279
+ });
1280
+ }
1281
+ /** Turn a crop 180 degrees, so an inverted line reads the right way up. */
1282
+ function rotate180(source) {
1283
+ const out = new OffscreenCanvas(source.width, source.height);
1284
+ const ctx = out.getContext("2d");
1285
+ if (!ctx) throw new OcrError("Failed to acquire a 2D context", "TesseractRecognizer");
1286
+ ctx.translate(source.width / 2, source.height / 2);
1287
+ ctx.rotate(Math.PI);
1288
+ ctx.drawImage(source, -source.width / 2, -source.height / 2);
1289
+ return out;
1290
+ }
1291
+ //#endregion
1292
+ export { getDetectorModel as A, quadsFromProbabilityMap as B, LINE_ORIENTATION_DEFAULTS as C, ORIENTATION_INPUT as D, LineOrientationClassifier as E, DB_POSTPROCESS_DEFAULTS as F, boxScore as I, findComponentBoundaries as L, DBNET_INPUT_SIZE as M, DEFAULT_DBNET_MODEL as N, DEFAULT_DETECTOR_ID as O, DbnetOnnxDetector as P, miniBox as R, presetsForScript as S, DEFAULT_ORIENTATION_MODEL as T, unclipRect as V, loadPreset as _, TesseractOcr as a, PADDLE_OCR_PRESETS as b, disposeScriptDetection as c, PADDLE_RECOGNIZER_DEFAULTS as d, PaddleTextDetector as f, isPresetCached as g, getPaddleService as h, TESSERACT_OCR_DEFAULTS as i, DBNET_DETECTOR_DEFAULTS as j, DETECTOR_MODELS as k, suggestPresets as l, disposePaddleServices as m, TesseractRecognizer as n, disposeTesseract as o, PaddleTextRecognizer as p, DEFAULT_TESSDATA_URL as r, detectScript as s, TESSERACT_RECOGNIZER_DEFAULTS as t, PADDLE_DETECTOR_DEFAULTS as u, removePreset as v, LineOrientationDetector as w, isKnownPreset as x, DEFAULT_OCR_PRESET as y, orderPointsClockwise as z };
1293
+
1294
+ //# sourceMappingURL=ocr-OHX2WM3e.js.map