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,182 @@
1
+ """Document sources: one still image, or a PDF rendered one page at a time.
2
+
3
+ Every source reports how many pages it carries and renders one page on demand,
4
+ so the caller can OCR a page and drop its pixels before asking for the next. No
5
+ source keeps more than the page currently being processed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ #: Bytes read at a time while digesting a source, so a large scan is not held twice.
15
+ DIGEST_CHUNK = 1024 * 1024
16
+
17
+ #: Extensions handled as a still image (a multi-frame file is read frame by frame).
18
+ IMAGE_SUFFIXES = frozenset({
19
+ '.png', '.jpg', '.jpeg', '.jpe', '.bmp', '.dib', '.tif', '.tiff',
20
+ '.webp', '.gif', '.jp2', '.pbm', '.pgm', '.ppm', '.tga', '.ico',
21
+ })
22
+
23
+ #: Extensions handled as a PDF.
24
+ PDF_SUFFIXES = frozenset({'.pdf'})
25
+
26
+ #: Smallest render scale, so an absurd maxPixels still yields a readable page.
27
+ MIN_SCALE = 0.2
28
+
29
+
30
+ class UnsupportedDocument(Exception):
31
+ """Raised when a path is neither a readable image nor a readable PDF."""
32
+
33
+
34
+ def source_digest(path: str) -> str:
35
+ """Digest a source file's bytes.
36
+
37
+ The caller names a recognition after this, so the name follows the document's
38
+ content and not merely its length: two files that happen to weigh the same are
39
+ still two recognitions, and a file that has not changed is always the same one.
40
+ @param path: the source file to read.
41
+ @returns: the lowercase hex digest of its bytes.
42
+ """
43
+ import hashlib
44
+
45
+ digest = hashlib.sha256()
46
+ with open(path, 'rb') as handle:
47
+ for chunk in iter(lambda: handle.read(DIGEST_CHUNK), b''):
48
+ digest.update(chunk)
49
+ return digest.hexdigest()
50
+
51
+
52
+ @dataclass
53
+ class RenderedPage:
54
+ """One page's pixels, in the channel order the OCR engine expects."""
55
+
56
+ index: int
57
+ width: int
58
+ height: int
59
+ image: Any
60
+
61
+
62
+ class Document:
63
+ """A page source the worker renders and releases one page at a time."""
64
+
65
+ def page_count(self) -> int:
66
+ """Return the number of pages this source carries."""
67
+ raise NotImplementedError
68
+
69
+ def render(self, index: int) -> RenderedPage:
70
+ """Render one zero-based page into a BGR array."""
71
+ raise NotImplementedError
72
+
73
+ def close(self) -> None:
74
+ """Release the underlying file handle or document."""
75
+
76
+
77
+ def document_kind(path: str) -> str:
78
+ """Classify a path as a PDF, an image, or unsupported."""
79
+ suffix = os.path.splitext(path)[1].lower()
80
+ if suffix in PDF_SUFFIXES:
81
+ return 'pdf'
82
+ if suffix in IMAGE_SUFFIXES:
83
+ return 'image'
84
+ return 'unsupported'
85
+
86
+
87
+ class ImageDocument(Document):
88
+ """A still image, or every frame of a multi-frame image file."""
89
+
90
+ def __init__(self, path: str, max_pixels: int) -> None:
91
+ from PIL import Image
92
+
93
+ self._image = Image.open(path)
94
+ self._frames = int(getattr(self._image, 'n_frames', 1))
95
+ self._index = 0
96
+ self._max_pixels = max_pixels
97
+ if self._frames > 1:
98
+ # Force a decode now so a corrupt later frame fails before any OCR.
99
+ self._image.seek(0)
100
+
101
+ def page_count(self) -> int:
102
+ return self._frames
103
+
104
+ def render(self, index: int) -> RenderedPage:
105
+ # Imported here so the worker's protocol helpers load on an interpreter
106
+ # that carries no OCR package, which is where the unit suite runs.
107
+ import numpy as np
108
+
109
+ if index != self._index:
110
+ self._image.seek(index)
111
+ self._index = index
112
+ frame = self._image.convert('RGB')
113
+ array = np.asarray(frame)
114
+ array = np.ascontiguousarray(array[:, :, ::-1])
115
+ pixels = self._max_pixels
116
+ if pixels > 0 and array.shape[0] * array.shape[1] > pixels:
117
+ step = max(1, int(np.ceil(np.sqrt(array.shape[0] * array.shape[1] / pixels))))
118
+ array = np.ascontiguousarray(array[::step, ::step])
119
+ return RenderedPage(index=index + 1, width=array.shape[1], height=array.shape[0], image=array)
120
+
121
+ def close(self) -> None:
122
+ self._image.close()
123
+
124
+
125
+ class PdfDocument(Document):
126
+ """A PDF, rendered with PDFium at a scale derived from the requested DPI."""
127
+
128
+ def __init__(self, path: str, dpi: int, max_pixels: int) -> None:
129
+ import pypdfium2 as pdfium
130
+
131
+ self._pdfium = pdfium
132
+ self._document = pdfium.PdfDocument(path)
133
+ self._dpi = dpi
134
+ self._max_pixels = max_pixels
135
+
136
+ def page_count(self) -> int:
137
+ return len(self._document)
138
+
139
+ def render(self, index: int) -> RenderedPage:
140
+ import numpy as np
141
+
142
+ page = self._document[index]
143
+ try:
144
+ scale = self._dpi / 72.0
145
+ if self._max_pixels > 0:
146
+ width_points, height_points = page.get_size()
147
+ pixels = (width_points * scale) * (height_points * scale)
148
+ if pixels > self._max_pixels:
149
+ scale = max(MIN_SCALE, scale * (self._max_pixels / pixels) ** 0.5)
150
+ bitmap = page.render(scale=scale)
151
+ try:
152
+ # PDFium's default bitmap is BGRA, which is the channel order the
153
+ # OCR engine's detector and recognizer were trained on.
154
+ array = np.ascontiguousarray(bitmap.to_numpy())
155
+ finally:
156
+ bitmap.close()
157
+ finally:
158
+ # Closing each page immediately is what keeps a long document's
159
+ # resident cost flat: PDFium holds per-page caches otherwise.
160
+ page.close()
161
+ return RenderedPage(index=index + 1, width=array.shape[1], height=array.shape[0], image=array)
162
+
163
+ def close(self) -> None:
164
+ self._document.close()
165
+
166
+
167
+ def open_document(path: str, dpi: int, max_pixels: int) -> Document:
168
+ """Open a path as a page source.
169
+
170
+ @param path: path to a PDF or a still image.
171
+ @param dpi: render resolution for PDF pages.
172
+ @param max_pixels: ceiling on one rendered page's pixel count; 0 disables it.
173
+ @returns: a document that renders one page per call.
174
+ """
175
+ kind = document_kind(path)
176
+ if kind == 'pdf':
177
+ return PdfDocument(path, dpi, max_pixels)
178
+ if kind == 'image':
179
+ return ImageDocument(path, max_pixels)
180
+ raise UnsupportedDocument(
181
+ 'unsupported document: ' + os.path.basename(path) + ' is neither a PDF nor a supported image'
182
+ )
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Create the Python environment the OCR worker runs in.
3
+ *
4
+ * The OCR engine and its model weights ship inside the RapidOCR wheel, so
5
+ * "installing the model" and "installing the dependencies" are the same act:
6
+ * a virtual environment holding `python/requirements.txt`. This script performs
7
+ * that act, verifies it with the worker's own `--self-test`, and reports what it
8
+ * found — including the model files the wheel actually carries, so a failure to
9
+ * recognize can be told apart from a failure to install.
10
+ *
11
+ * It is idempotent: a run against a working environment verifies and exits
12
+ * without touching the network. `--force` rebuilds the environment from scratch.
13
+ * @module @deepseek-ai/dsh-ab-ocr/scripts/setup
14
+ */
15
+
16
+ import { spawnSync } from 'node:child_process'
17
+ import { existsSync, readFileSync, rmSync } from 'node:fs'
18
+ import { dirname, join, resolve } from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+
21
+ const HERE = dirname(fileURLToPath(import.meta.url))
22
+ const PACKAGE = resolve(HERE, '..')
23
+ const PYTHON_DIR = join(PACKAGE, 'python')
24
+ const REQUIREMENTS = join(PYTHON_DIR, 'requirements.txt')
25
+ const WORKER = join(PYTHON_DIR, 'ocr_worker.py')
26
+ const VENV = join(PYTHON_DIR, '.venv')
27
+
28
+ /** Interpreter names to try, in order, when no path is given. */
29
+ const CANDIDATES = ['python', 'python3', 'py']
30
+
31
+ /**
32
+ * Run one command and capture its streams.
33
+ * @param command - the executable.
34
+ * @param args - its arguments.
35
+ * @returns the exit status and the two streams.
36
+ */
37
+ function run(command, args) {
38
+ const result = spawnSync(command, args, { encoding: 'utf8', windowsHide: true })
39
+ return {
40
+ status: result.status,
41
+ stdout: result.stdout ?? '',
42
+ stderr: result.stderr ?? '',
43
+ error: result.error,
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Find a base interpreter that runs.
49
+ * @param requested - an explicit path from the command line or the environment.
50
+ * @returns the working command, or undefined when none of the candidates run.
51
+ */
52
+ function findInterpreter(requested) {
53
+ const tried = requested === undefined ? CANDIDATES : [requested]
54
+ for (const candidate of tried) {
55
+ const probe = run(candidate, ['--version'])
56
+ if (probe.error === undefined && probe.status === 0) return candidate
57
+ }
58
+ return undefined
59
+ }
60
+
61
+ /**
62
+ * The interpreter inside the virtual environment.
63
+ * @returns its path, which differs by platform.
64
+ */
65
+ function venvInterpreter() {
66
+ return process.platform === 'win32'
67
+ ? join(VENV, 'Scripts', 'python.exe')
68
+ : join(VENV, 'bin', 'python')
69
+ }
70
+
71
+ /**
72
+ * Ask the worker to report its environment.
73
+ * @param python - the interpreter to ask.
74
+ * @returns the parsed report, or undefined when the worker did not answer.
75
+ */
76
+ function selfTest(python) {
77
+ const result = run(python, [WORKER, '--self-test'])
78
+ const line = result.stdout.split('\n').find(entry => entry.trim() !== '')
79
+ if (line === undefined) return undefined
80
+ try {
81
+ return JSON.parse(line)
82
+ } catch {
83
+ // A worker that cannot start prints nothing parseable; the caller reports the
84
+ // absence rather than this parse.
85
+ return undefined
86
+ }
87
+ }
88
+
89
+ /**
90
+ * The model files the installed wheel carries.
91
+ * @param python - the interpreter holding RapidOCR.
92
+ * @returns file names mapped to their size in bytes, or an empty record.
93
+ */
94
+ function modelFiles(python) {
95
+ const script = [
96
+ 'import json, os, rapidocr',
97
+ "d = os.path.join(os.path.dirname(rapidocr.__file__), 'models')",
98
+ "names = sorted(n for n in os.listdir(d) if n.endswith('.onnx')) if os.path.isdir(d) else []",
99
+ 'print(json.dumps({n: os.path.getsize(os.path.join(d, n)) for n in names}))',
100
+ ].join('\n')
101
+ const result = run(python, ['-c', script])
102
+ if (result.status !== 0) return {}
103
+ try {
104
+ return JSON.parse(result.stdout.trim())
105
+ } catch {
106
+ return {}
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Parse the command line.
112
+ * @param argv - the arguments after the script name.
113
+ * @returns the options, or a message describing what is wrong.
114
+ */
115
+ function parseArgs(argv) {
116
+ const options = { force: false, prefetch: false, json: false, python: process.env.DSH_OCR_PYTHON }
117
+ for (let index = 0; index < argv.length; index += 1) {
118
+ const flag = argv[index]
119
+ if (flag === '--force') options.force = true
120
+ else if (flag === '--prefetch') options.prefetch = true
121
+ else if (flag === '--json') options.json = true
122
+ else if (flag === '--python') {
123
+ const value = argv[index += 1]
124
+ if (value === undefined || value.startsWith('--')) return { error: '--python needs an interpreter path' }
125
+ options.python = value
126
+ } else return { error: `unknown option ${JSON.stringify(flag)}` }
127
+ }
128
+ return options
129
+ }
130
+
131
+ /**
132
+ * Lay out one version of a requirement line for the report.
133
+ * @param requirements - the requirement file's text.
134
+ * @returns the requirement lines, comments dropped.
135
+ */
136
+ function requirements(requirements) {
137
+ return readFileSync(requirements, 'utf8')
138
+ .split('\n')
139
+ .map(line => line.trim())
140
+ .filter(line => line !== '' && !line.startsWith('#'))
141
+ }
142
+
143
+ /**
144
+ * Install one environment and verify it.
145
+ * @param options - the parsed command line.
146
+ * @returns the process exit status.
147
+ */
148
+ function main(options) {
149
+ const say = (message) => {
150
+ if (!options.json) console.log(message)
151
+ }
152
+ const base = findInterpreter(options.python)
153
+ if (base === undefined) {
154
+ console.error('setup: no Python interpreter found; pass --python <path> or set DSH_OCR_PYTHON')
155
+ return 2
156
+ }
157
+
158
+ const python = venvInterpreter()
159
+ const existing = existsSync(python)
160
+ say(`setup: base interpreter ${base}`)
161
+ say(`setup: environment ${VENV}${existing ? '' : ' (creating)'}`)
162
+
163
+ if (options.force && existsSync(VENV)) {
164
+ rmSync(VENV, { recursive: true, force: true })
165
+ say('setup: removed the existing environment (--force)')
166
+ }
167
+
168
+ if (!existsSync(python)) {
169
+ const created = run(base, ['-m', 'venv', VENV])
170
+ if (created.status !== 0) {
171
+ console.error(`setup: could not create the environment\n${created.stderr.trim()}`)
172
+ return 3
173
+ }
174
+ }
175
+
176
+ let report = selfTest(python)
177
+ if (report?.ready !== true) {
178
+ say('setup: installing ' + requirements(REQUIREMENTS).join(', '))
179
+ // Prefer a local wheels cache shipped with the plugin (python/wheels) so
180
+ // setup works offline; pip still falls back to the configured index for any
181
+ // wheel not present locally.
182
+ const wheelsDir = join(PYTHON_DIR, 'wheels')
183
+ const findLinks = existsSync(wheelsDir)
184
+ ? ['--find-links', wheelsDir]
185
+ : []
186
+ if (findLinks.length) say(`setup: using local wheel cache ${wheelsDir}`)
187
+ // Bootstrap the build tools from the local cache so --no-build-isolation can
188
+ // compile any source distribution (e.g. antlr4-python3-runtime) without
189
+ // reaching the network. Standard venvs ship these, but an offline venv may
190
+ // not, and a build-isolation subprocess would otherwise hit the index.
191
+ if (findLinks.length) {
192
+ const bootstrap = run(python, ['-m', 'pip', 'install', '--disable-pip-version-check', ...findLinks, 'setuptools', 'wheel'])
193
+ if (bootstrap.status !== 0) {
194
+ console.error(`setup: could not bootstrap build tools\n${bootstrap.stderr.trim()}`)
195
+ return 3
196
+ }
197
+ }
198
+ const install = run(python, ['-m', 'pip', 'install', '--disable-pip-version-check', '--no-build-isolation', ...findLinks, '-r', REQUIREMENTS])
199
+ if (install.status !== 0) {
200
+ console.error(`setup: pip failed\n${install.stderr.trim()}`)
201
+ return 3
202
+ }
203
+ report = selfTest(python)
204
+ }
205
+ if (report?.ready !== true) {
206
+ console.error('setup: the environment still fails its own check')
207
+ for (const [name, version] of Object.entries(report?.modules ?? {})) {
208
+ if (version === null || String(name).endsWith('Error')) console.error(` ${name}: ${version ?? 'missing'}`)
209
+ }
210
+ return 3
211
+ }
212
+
213
+ const models = modelFiles(python)
214
+ if (options.prefetch) {
215
+ say('setup: loading the engine and its models')
216
+ const warm = run(python, ['-c', 'import logging; logging.disable(logging.CRITICAL); from rapidocr import RapidOCR; RapidOCR()'])
217
+ if (warm.status !== 0) {
218
+ console.error(`setup: the engine did not load\n${warm.stderr.trim()}`)
219
+ return 3
220
+ }
221
+ }
222
+
223
+ const summary = {
224
+ python,
225
+ base,
226
+ version: report.python,
227
+ modules: report.modules,
228
+ models,
229
+ prefetched: options.prefetch,
230
+ }
231
+ if (options.json) {
232
+ console.log(JSON.stringify(summary, null, 2))
233
+ return 0
234
+ }
235
+ say(`setup: ready — Python ${report.python}`)
236
+ for (const [name, version] of Object.entries(report.modules)) say(` ${name} ${version}`)
237
+ const names = Object.keys(models)
238
+ const bytes = Object.values(models).reduce((total, size) => total + size, 0)
239
+ say(names.length === 0
240
+ ? 'setup: no model files found beside the wheel'
241
+ : `setup: ${names.length} model file(s), ${(bytes / 1048576).toFixed(1)} MB — ${names.join(', ')}`)
242
+ return 0
243
+ }
244
+
245
+ const OPTIONS = parseArgs(process.argv.slice(2))
246
+ if ('error' in OPTIONS) {
247
+ console.error(`setup: ${OPTIONS.error}`)
248
+ process.exitCode = 2
249
+ } else {
250
+ process.exitCode = main(OPTIONS)
251
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2024",
4
+ "module": "esnext",
5
+ "moduleResolution": "bundler",
6
+ "declaration": true,
7
+ "declarationMap": true,
8
+ "sourceMap": true,
9
+ "skipLibCheck": true,
10
+ "esModuleInterop": true,
11
+ "allowImportingTsExtensions": true,
12
+ "rewriteRelativeImportExtensions": true,
13
+ "strict": true,
14
+ "noUncheckedIndexedAccess": true,
15
+ "exactOptionalPropertyTypes": true,
16
+ "noImplicitOverride": true,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "noUnusedLocals": true,
19
+ "noUnusedParameters": true,
20
+ "jsx": "react-jsx",
21
+ "types": [
22
+ "node"
23
+ ],
24
+ "rootDir": "src",
25
+ "outDir": "lib"
26
+ },
27
+ "include": [
28
+ "src"
29
+ ]
30
+ }
@@ -0,0 +1,18 @@
1
+ /** Bundle config: one node entry (host tool). */
2
+ import { defineConfig } from 'tsdown'
3
+
4
+ export default defineConfig({
5
+ name: "dsh-ab-ocr",
6
+ entry: ['lib/index.js'],
7
+ outDir: 'lib',
8
+ format: ['esm'],
9
+ platform: 'node',
10
+ target: 'es2024',
11
+ fixedExtension: false,
12
+ dts: false,
13
+ clean: false,
14
+ deps: {
15
+ neverBundle: [/^@deepseek-ai\//],
16
+ alwaysBundle: (specifier) => !specifier.startsWith('@deepseek-ai/') && !specifier.startsWith('node:'),
17
+ },
18
+ })