@doccloak/core 0.8.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 (66) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +5 -0
  4. package/README.md +230 -0
  5. package/dist/doc.d.ts +13 -0
  6. package/dist/doc.js +683 -0
  7. package/dist/docx.d.ts +31 -0
  8. package/dist/docx.js +26 -0
  9. package/dist/dom/docx.d.ts +90 -0
  10. package/dist/dom/docx.js +472 -0
  11. package/dist/dom/index.d.ts +11 -0
  12. package/dist/dom/index.js +11 -0
  13. package/dist/dom/ocr.d.ts +112 -0
  14. package/dist/dom/ocr.js +224 -0
  15. package/dist/engine.d.ts +81 -0
  16. package/dist/engine.js +274 -0
  17. package/dist/env.d.ts +49 -0
  18. package/dist/env.js +53 -0
  19. package/dist/index.d.ts +20 -0
  20. package/dist/index.js +29 -0
  21. package/dist/model-loader.d.ts +55 -0
  22. package/dist/model-loader.js +253 -0
  23. package/dist/pipeline.d.ts +19 -0
  24. package/dist/pipeline.js +128 -0
  25. package/dist/providers/bardsai.d.ts +38 -0
  26. package/dist/providers/bardsai.js +393 -0
  27. package/dist/providers/gliner.d.ts +52 -0
  28. package/dist/providers/gliner.js +360 -0
  29. package/dist/regex/index.d.ts +17 -0
  30. package/dist/regex/index.js +18 -0
  31. package/dist/regex/loader.d.ts +44 -0
  32. package/dist/regex/loader.js +44 -0
  33. package/dist/regex/rules.data.d.ts +2 -0
  34. package/dist/regex/rules.data.js +2671 -0
  35. package/dist/regex/types.d.ts +27 -0
  36. package/dist/regex/types.js +1 -0
  37. package/dist/regex/validators.d.ts +17 -0
  38. package/dist/regex/validators.js +492 -0
  39. package/dist/session.d.ts +59 -0
  40. package/dist/session.js +153 -0
  41. package/dist/types.d.ts +42 -0
  42. package/dist/types.js +28 -0
  43. package/dist/worker-protocol.d.ts +119 -0
  44. package/dist/worker-protocol.js +285 -0
  45. package/package.json +75 -0
  46. package/rules/at.json +70 -0
  47. package/rules/be.json +70 -0
  48. package/rules/ch.json +66 -0
  49. package/rules/cn.json +155 -0
  50. package/rules/de.json +192 -0
  51. package/rules/dk.json +43 -0
  52. package/rules/es.json +158 -0
  53. package/rules/fi.json +43 -0
  54. package/rules/fr.json +178 -0
  55. package/rules/gb.json +194 -0
  56. package/rules/ie.json +48 -0
  57. package/rules/it.json +116 -0
  58. package/rules/jp.json +136 -0
  59. package/rules/nl.json +99 -0
  60. package/rules/no.json +132 -0
  61. package/rules/pl.json +210 -0
  62. package/rules/pt.json +113 -0
  63. package/rules/schema.json +91 -0
  64. package/rules/se.json +149 -0
  65. package/rules/universal.json +279 -0
  66. package/rules/us.json +218 -0
@@ -0,0 +1,112 @@
1
+ /**
2
+ * OCR support: extract text (with word-level bounding boxes) from images
3
+ * so it can flow through the regular PII detection pipeline, and render a
4
+ * redacted copy of the image with detected entities blacked out.
5
+ *
6
+ * Uses tesseract.js, which runs Tesseract compiled to WebAssembly inside its
7
+ * own Web Worker - the image never leaves the browser. Language data is
8
+ * downloaded once and cached by tesseract.js in IndexedDB.
9
+ *
10
+ * Lives under the @doccloak/core/dom submodule: it needs a canvas. Canvas
11
+ * creation and image decoding are feature-detected so the module also runs in
12
+ * DOM-less contexts (workers, MV3 offscreen/service contexts) that provide
13
+ * OffscreenCanvas and createImageBitmap. Tesseract worker/core/lang asset
14
+ * paths are host-provided via TesseractAssetPaths (core never derives them
15
+ * from import.meta or its own location).
16
+ */
17
+ /** Canvas type used throughout OCR: a DOM canvas or an OffscreenCanvas. */
18
+ export type OcrCanvas = HTMLCanvasElement | OffscreenCanvas;
19
+ export interface OcrWord {
20
+ text: string;
21
+ /** Character offsets into the reconstructed OCR text */
22
+ start: number;
23
+ end: number;
24
+ /** Pixel bounding box in the OCR'd image's coordinate space */
25
+ bbox: {
26
+ x0: number;
27
+ y0: number;
28
+ x1: number;
29
+ y1: number;
30
+ };
31
+ }
32
+ export interface OcrExtraction {
33
+ text: string;
34
+ words: OcrWord[];
35
+ }
36
+ /**
37
+ * Locations of the self-hosted tesseract.js runtime assets, supplied by the
38
+ * host (web app: `${BASE_URL}tesseract/...` copied by scripts/copy-assets.mjs;
39
+ * extension: bundled copies). langPath must be an absolute URL - relative
40
+ * paths are treated as cache keys by the tesseract.js worker, not fetched.
41
+ */
42
+ export interface TesseractAssetPaths {
43
+ workerPath: string;
44
+ corePath: string;
45
+ langPath: string;
46
+ }
47
+ export declare function isImageFile(fileName: string): boolean;
48
+ /**
49
+ * Decode an image file into a canvas, downscaling if it exceeds
50
+ * MAX_IMAGE_DIMENSION. All OCR bounding boxes are relative to the returned
51
+ * canvas, so redaction is drawn on the same coordinate space.
52
+ *
53
+ * Decoding is feature-detected: createImageBitmap where available (all
54
+ * workers, modern DOM), an HTMLImageElement as the DOM-only fallback.
55
+ */
56
+ export declare function loadImageToCanvas(file: Blob): Promise<OcrCanvas>;
57
+ /**
58
+ * Run OCR on a canvas. Returns the extracted text plus per-word character
59
+ * offsets and pixel bounding boxes. `assets` locates the self-hosted
60
+ * tesseract.js worker/core/language files (host-provided; see
61
+ * TesseractAssetPaths). onProgress receives 0..1 during the recognition phase.
62
+ */
63
+ export declare function recognizeCanvas(canvas: OcrCanvas, uiLanguage: string, assets: TesseractAssetPaths, onProgress?: (progress: number) => void): Promise<OcrExtraction>;
64
+ interface BlockLike {
65
+ paragraphs: {
66
+ lines: {
67
+ words: {
68
+ text: string;
69
+ bbox: {
70
+ x0: number;
71
+ y0: number;
72
+ x1: number;
73
+ y1: number;
74
+ };
75
+ }[];
76
+ }[];
77
+ }[];
78
+ }
79
+ /**
80
+ * Reconstruct plain text from Tesseract's block hierarchy while recording
81
+ * each word's character range. The reconstruction (not Tesseract's own
82
+ * `data.text`) is used as the detection input, so entity offsets always map
83
+ * back to word bounding boxes exactly.
84
+ *
85
+ * Exported for tests.
86
+ */
87
+ export declare function buildTextFromBlocks(blocks: BlockLike[]): OcrExtraction;
88
+ /**
89
+ * Pick the bounding boxes of every OCR word that overlaps one of the given
90
+ * character ranges. A word partially covered by a range is fully redacted
91
+ * (over-redaction is safer than leaking half an identifier).
92
+ *
93
+ * Exported for tests.
94
+ */
95
+ export declare function selectRedactionBoxes(words: OcrWord[], ranges: {
96
+ start: number;
97
+ end: number;
98
+ }[]): {
99
+ x0: number;
100
+ y0: number;
101
+ x1: number;
102
+ y1: number;
103
+ }[];
104
+ /**
105
+ * Draw black boxes over the given character ranges and return the redacted
106
+ * image as a PNG blob.
107
+ */
108
+ export declare function renderRedactedImage(sourceCanvas: OcrCanvas, words: OcrWord[], ranges: {
109
+ start: number;
110
+ end: number;
111
+ }[]): Promise<Blob>;
112
+ export {};
@@ -0,0 +1,224 @@
1
+ /**
2
+ * OCR support: extract text (with word-level bounding boxes) from images
3
+ * so it can flow through the regular PII detection pipeline, and render a
4
+ * redacted copy of the image with detected entities blacked out.
5
+ *
6
+ * Uses tesseract.js, which runs Tesseract compiled to WebAssembly inside its
7
+ * own Web Worker - the image never leaves the browser. Language data is
8
+ * downloaded once and cached by tesseract.js in IndexedDB.
9
+ *
10
+ * Lives under the @doccloak/core/dom submodule: it needs a canvas. Canvas
11
+ * creation and image decoding are feature-detected so the module also runs in
12
+ * DOM-less contexts (workers, MV3 offscreen/service contexts) that provide
13
+ * OffscreenCanvas and createImageBitmap. Tesseract worker/core/lang asset
14
+ * paths are host-provided via TesseractAssetPaths (core never derives them
15
+ * from import.meta or its own location).
16
+ */
17
+ import * as Tesseract from 'tesseract.js';
18
+ /** Images above this dimension are downscaled before OCR to bound memory use on mobile */
19
+ const MAX_IMAGE_DIMENSION = 2560;
20
+ const IMAGE_EXTENSIONS = /\.(png|jpe?g|webp|bmp|gif)$/i;
21
+ /** Map DocCloak UI languages to Tesseract traineddata codes */
22
+ const UI_LANG_TO_TESSERACT = {
23
+ en: 'eng',
24
+ pl: 'pol',
25
+ de: 'deu',
26
+ fr: 'fra',
27
+ es: 'spa',
28
+ pt: 'por',
29
+ sv: 'swe',
30
+ no: 'nor',
31
+ };
32
+ export function isImageFile(fileName) {
33
+ return IMAGE_EXTENSIONS.test(fileName);
34
+ }
35
+ function buildLangList(uiLanguage) {
36
+ const mapped = UI_LANG_TO_TESSERACT[uiLanguage];
37
+ if (mapped && mapped !== 'eng')
38
+ return [mapped, 'eng'];
39
+ return ['eng'];
40
+ }
41
+ /**
42
+ * Create a 2D-drawable canvas in whatever context we run in:
43
+ * a DOM canvas when `document` exists, an OffscreenCanvas otherwise
44
+ * (workers, MV3 offscreen/service contexts).
45
+ */
46
+ function createCompatibleCanvas(width, height) {
47
+ if (typeof document !== 'undefined' && typeof document.createElement === 'function') {
48
+ const canvas = document.createElement('canvas');
49
+ canvas.width = width;
50
+ canvas.height = height;
51
+ return canvas;
52
+ }
53
+ if (typeof OffscreenCanvas !== 'undefined') {
54
+ return new OffscreenCanvas(width, height);
55
+ }
56
+ throw new Error('No canvas implementation available (need document or OffscreenCanvas)');
57
+ }
58
+ function get2dContext(canvas) {
59
+ const ctx = canvas.getContext('2d');
60
+ if (!ctx)
61
+ throw new Error('Canvas 2D context unavailable');
62
+ return ctx;
63
+ }
64
+ /**
65
+ * Decode an image file into a canvas, downscaling if it exceeds
66
+ * MAX_IMAGE_DIMENSION. All OCR bounding boxes are relative to the returned
67
+ * canvas, so redaction is drawn on the same coordinate space.
68
+ *
69
+ * Decoding is feature-detected: createImageBitmap where available (all
70
+ * workers, modern DOM), an HTMLImageElement as the DOM-only fallback.
71
+ */
72
+ export async function loadImageToCanvas(file) {
73
+ let width;
74
+ let height;
75
+ let source;
76
+ if (typeof createImageBitmap === 'function') {
77
+ const bitmap = await createImageBitmap(file);
78
+ width = bitmap.width;
79
+ height = bitmap.height;
80
+ source = bitmap;
81
+ }
82
+ else {
83
+ source = await decodeViaImageElement(file);
84
+ width = source.naturalWidth;
85
+ height = source.naturalHeight;
86
+ }
87
+ const scale = Math.min(1, MAX_IMAGE_DIMENSION / Math.max(width, height));
88
+ const canvas = createCompatibleCanvas(Math.max(1, Math.round(width * scale)), Math.max(1, Math.round(height * scale)));
89
+ const ctx = get2dContext(canvas);
90
+ ctx.drawImage(source, 0, 0, canvas.width, canvas.height);
91
+ if (typeof source.close === 'function') {
92
+ source.close();
93
+ }
94
+ return canvas;
95
+ }
96
+ function decodeViaImageElement(file) {
97
+ if (typeof Image === 'undefined') {
98
+ return Promise.reject(new Error('No image decoder available (need createImageBitmap or Image)'));
99
+ }
100
+ return new Promise((resolve, reject) => {
101
+ const url = URL.createObjectURL(file);
102
+ const img = new Image();
103
+ img.onload = () => {
104
+ URL.revokeObjectURL(url);
105
+ resolve(img);
106
+ };
107
+ img.onerror = () => {
108
+ URL.revokeObjectURL(url);
109
+ reject(new Error('Failed to decode image'));
110
+ };
111
+ img.src = url;
112
+ });
113
+ }
114
+ /**
115
+ * Run OCR on a canvas. Returns the extracted text plus per-word character
116
+ * offsets and pixel bounding boxes. `assets` locates the self-hosted
117
+ * tesseract.js worker/core/language files (host-provided; see
118
+ * TesseractAssetPaths). onProgress receives 0..1 during the recognition phase.
119
+ */
120
+ export async function recognizeCanvas(canvas, uiLanguage, assets, onProgress) {
121
+ onProgress?.(0);
122
+ const worker = await Tesseract.createWorker(buildLangList(uiLanguage), 1, // OEM.LSTM_ONLY
123
+ {
124
+ workerPath: assets.workerPath,
125
+ corePath: assets.corePath,
126
+ langPath: assets.langPath,
127
+ logger: (m) => {
128
+ if (m.status === 'recognizing text' && typeof m.progress === 'number') {
129
+ onProgress?.(m.progress);
130
+ }
131
+ },
132
+ });
133
+ try {
134
+ const { data } = await worker.recognize(canvas, {}, { blocks: true, text: true });
135
+ onProgress?.(1);
136
+ return buildTextFromBlocks(data.blocks ?? []);
137
+ }
138
+ finally {
139
+ await worker.terminate();
140
+ }
141
+ }
142
+ /**
143
+ * Reconstruct plain text from Tesseract's block hierarchy while recording
144
+ * each word's character range. The reconstruction (not Tesseract's own
145
+ * `data.text`) is used as the detection input, so entity offsets always map
146
+ * back to word bounding boxes exactly.
147
+ *
148
+ * Exported for tests.
149
+ */
150
+ export function buildTextFromBlocks(blocks) {
151
+ const words = [];
152
+ let text = '';
153
+ for (const block of blocks) {
154
+ for (const paragraph of block.paragraphs ?? []) {
155
+ for (const line of paragraph.lines ?? []) {
156
+ let lineHasWords = false;
157
+ for (const word of line.words ?? []) {
158
+ const value = word.text?.trim();
159
+ if (!value)
160
+ continue;
161
+ if (lineHasWords)
162
+ text += ' ';
163
+ const start = text.length;
164
+ text += value;
165
+ words.push({ text: value, start, end: text.length, bbox: word.bbox });
166
+ lineHasWords = true;
167
+ }
168
+ if (lineHasWords)
169
+ text += '\n';
170
+ }
171
+ }
172
+ if (text.length > 0 && !text.endsWith('\n\n'))
173
+ text += '\n';
174
+ }
175
+ return { text: text.trimEnd(), words };
176
+ }
177
+ /**
178
+ * Pick the bounding boxes of every OCR word that overlaps one of the given
179
+ * character ranges. A word partially covered by a range is fully redacted
180
+ * (over-redaction is safer than leaking half an identifier).
181
+ *
182
+ * Exported for tests.
183
+ */
184
+ export function selectRedactionBoxes(words, ranges) {
185
+ return words
186
+ .filter((w) => ranges.some((r) => w.start < r.end && w.end > r.start))
187
+ .map((w) => w.bbox);
188
+ }
189
+ /** Encode a canvas as a PNG blob, whichever canvas flavor it is. */
190
+ function canvasToPngBlob(canvas) {
191
+ if ('convertToBlob' in canvas) {
192
+ return canvas.convertToBlob({ type: 'image/png' });
193
+ }
194
+ return new Promise((resolve, reject) => {
195
+ canvas.toBlob((blob) => {
196
+ if (blob)
197
+ resolve(blob);
198
+ else
199
+ reject(new Error('Failed to encode redacted image'));
200
+ }, 'image/png');
201
+ });
202
+ }
203
+ /**
204
+ * Draw black boxes over the given character ranges and return the redacted
205
+ * image as a PNG blob.
206
+ */
207
+ export function renderRedactedImage(sourceCanvas, words, ranges) {
208
+ let canvas;
209
+ let ctx;
210
+ try {
211
+ canvas = createCompatibleCanvas(sourceCanvas.width, sourceCanvas.height);
212
+ ctx = get2dContext(canvas);
213
+ }
214
+ catch (err) {
215
+ return Promise.reject(err instanceof Error ? err : new Error(String(err)));
216
+ }
217
+ ctx.drawImage(sourceCanvas, 0, 0);
218
+ ctx.fillStyle = '#111111';
219
+ const padding = 2;
220
+ for (const box of selectRedactionBoxes(words, ranges)) {
221
+ ctx.fillRect(box.x0 - padding, box.y0 - padding, (box.x1 - box.x0) + padding * 2, (box.y1 - box.y0) + padding * 2);
222
+ }
223
+ return canvasToPngBlob(canvas);
224
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @doccloak/core - engine assembly (T009).
3
+ *
4
+ * createEngine() turns the injected CoreEnv plus the two built-in detection
5
+ * providers into a DocCloakEngine: provider registry, settings persisted
6
+ * through env.kv under the exact localStorage keys the web app has always
7
+ * used (see ENGINE_SETTINGS_KEYS - no prefixing, so pre-extraction user
8
+ * profiles read back unchanged), per-provider threshold defaults, progress
9
+ * event fan-out and the mobile/low-memory default-model heuristic fed by
10
+ * env.hardware.
11
+ *
12
+ * The engine is transport-agnostic; worker-protocol.ts serves it over any
13
+ * PortLike transport (Web Worker postMessage, MessageChannel, extension
14
+ * ports).
15
+ */
16
+ import type { CoreEnv, HardwareHints } from './env.js';
17
+ import type { DetectedEntity, DetectionProvider } from './types.js';
18
+ import { type RegexRegionId } from './regex/index.js';
19
+ export type ProviderId = 'gliner' | 'bardsai';
20
+ export interface ProviderEntry {
21
+ id: ProviderId;
22
+ label: string;
23
+ description: string;
24
+ }
25
+ /** Catalog of the built-in providers (labels/descriptions shown by hosts). */
26
+ export declare const PROVIDERS: ProviderEntry[];
27
+ export type ProviderFactory = (env: CoreEnv) => DetectionProvider;
28
+ export interface EngineOptions {
29
+ /** Override provider construction (tests inject deterministic stubs). */
30
+ providers?: Partial<Record<ProviderId, ProviderFactory>>;
31
+ }
32
+ /**
33
+ * env.kv keys, byte-identical to the localStorage keys the web app used
34
+ * before the extraction. A 1:1 KV adapter (no prefixing) therefore reads a
35
+ * legacy profile back correctly.
36
+ */
37
+ export declare const ENGINE_SETTINGS_KEYS: {
38
+ readonly provider: "doccloak-active-provider";
39
+ readonly customLabels: "doccloak-custom-labels";
40
+ readonly regexEnabled: "doccloak-regex-enabled";
41
+ readonly regexRegion: "doccloak-regex-region";
42
+ };
43
+ export interface EngineSettings {
44
+ providerId: ProviderId;
45
+ /** Not persisted (matches the pre-extraction behavior): resets to the provider default on init/switch. */
46
+ threshold: number;
47
+ regexEnabled: boolean;
48
+ regexRegion: RegexRegionId;
49
+ customLabels: string[];
50
+ }
51
+ export interface DocCloakEngine {
52
+ /** Resolves once persisted settings have been read from env.kv. */
53
+ readonly ready: Promise<void>;
54
+ detect(text: string, signal?: AbortSignal, onProgress?: (progress: number) => void): Promise<DetectedEntity[]>;
55
+ /** Download + init the current provider. */
56
+ preload(): Promise<void>;
57
+ switchProvider(id: ProviderId): Promise<void>;
58
+ getSettings(): EngineSettings;
59
+ /** Merges the patch, persists via env.kv and applies it to the live provider. */
60
+ updateSettings(patch: Partial<EngineSettings>): Promise<void>;
61
+ onDownloadProgress(cb: (p: {
62
+ loaded: number;
63
+ total: number;
64
+ }) => void): () => void;
65
+ onDetectionProgress(cb: (progress: number) => void): () => void;
66
+ /** Free ONNX session memory; the model reloads on the next detect/preload. */
67
+ release(): Promise<void>;
68
+ }
69
+ /** Per-provider confidence threshold defaults. */
70
+ export declare function defaultThresholdFor(id: ProviderId): number;
71
+ /** Clamp a detection threshold to the supported 0.05-0.95 range. */
72
+ export declare function clampThreshold(value: number): number;
73
+ /**
74
+ * Default-model heuristic for devices without a saved provider choice.
75
+ * The BardS.ai model (~279 MB download, ~500+ MB peak RAM while loading)
76
+ * routinely gets the tab killed on mobile browsers, so constrained devices
77
+ * default to the lightweight GLiNER model (~65 MB). Hosts feed the hints
78
+ * (userAgent/deviceMemory sniffing stays host-side).
79
+ */
80
+ export declare function pickDefaultProvider(hardware?: HardwareHints): ProviderId;
81
+ export declare function createEngine(env: CoreEnv, initial?: Partial<EngineSettings>, options?: EngineOptions): DocCloakEngine;
package/dist/engine.js ADDED
@@ -0,0 +1,274 @@
1
+ /**
2
+ * @doccloak/core - engine assembly (T009).
3
+ *
4
+ * createEngine() turns the injected CoreEnv plus the two built-in detection
5
+ * providers into a DocCloakEngine: provider registry, settings persisted
6
+ * through env.kv under the exact localStorage keys the web app has always
7
+ * used (see ENGINE_SETTINGS_KEYS - no prefixing, so pre-extraction user
8
+ * profiles read back unchanged), per-provider threshold defaults, progress
9
+ * event fan-out and the mobile/low-memory default-model heuristic fed by
10
+ * env.hardware.
11
+ *
12
+ * The engine is transport-agnostic; worker-protocol.ts serves it over any
13
+ * PortLike transport (Web Worker postMessage, MessageChannel, extension
14
+ * ports).
15
+ */
16
+ import { detectWithRegex, detectEntities } from "./pipeline.js";
17
+ import { GlinerProvider } from "./providers/gliner.js";
18
+ import { BardsaiProvider } from "./providers/bardsai.js";
19
+ import { REGEX_REGIONS } from "./regex/index.js";
20
+ /** Catalog of the built-in providers (labels/descriptions shown by hosts). */
21
+ export const PROVIDERS = [
22
+ {
23
+ id: 'gliner',
24
+ label: 'GLiNER PII Edge',
25
+ description: 'Lightweight, multi-language, supports custom labels (~65 MB)',
26
+ },
27
+ {
28
+ id: 'bardsai',
29
+ label: 'BardS.ai EU PII',
30
+ description: '24 EU languages, 35 PII types, high accuracy (~279 MB)',
31
+ },
32
+ ];
33
+ // ── Settings ───────────────────────────────────────────────
34
+ /**
35
+ * env.kv keys, byte-identical to the localStorage keys the web app used
36
+ * before the extraction. A 1:1 KV adapter (no prefixing) therefore reads a
37
+ * legacy profile back correctly.
38
+ */
39
+ export const ENGINE_SETTINGS_KEYS = {
40
+ provider: 'doccloak-active-provider',
41
+ customLabels: 'doccloak-custom-labels',
42
+ regexEnabled: 'doccloak-regex-enabled',
43
+ regexRegion: 'doccloak-regex-region',
44
+ };
45
+ // ── Helpers ────────────────────────────────────────────────
46
+ /** Per-provider confidence threshold defaults. */
47
+ export function defaultThresholdFor(id) {
48
+ return id === 'bardsai' ? 0.5 : 0.35;
49
+ }
50
+ /** Clamp a detection threshold to the supported 0.05-0.95 range. */
51
+ export function clampThreshold(value) {
52
+ return Math.max(0.05, Math.min(0.95, value));
53
+ }
54
+ /**
55
+ * Default-model heuristic for devices without a saved provider choice.
56
+ * The BardS.ai model (~279 MB download, ~500+ MB peak RAM while loading)
57
+ * routinely gets the tab killed on mobile browsers, so constrained devices
58
+ * default to the lightweight GLiNER model (~65 MB). Hosts feed the hints
59
+ * (userAgent/deviceMemory sniffing stays host-side).
60
+ */
61
+ export function pickDefaultProvider(hardware) {
62
+ if (hardware?.isMobile)
63
+ return 'gliner';
64
+ if (typeof hardware?.deviceMemoryGB === 'number' && hardware.deviceMemoryGB <= 4)
65
+ return 'gliner';
66
+ return 'bardsai';
67
+ }
68
+ function isProviderId(value) {
69
+ return PROVIDERS.some((p) => p.id === value);
70
+ }
71
+ function isRegexRegionId(value) {
72
+ return typeof value === 'string' && REGEX_REGIONS.includes(value);
73
+ }
74
+ function supportsCustomLabels(p) {
75
+ return 'setCustomLabels' in p;
76
+ }
77
+ // ── Engine ─────────────────────────────────────────────────
78
+ export function createEngine(env, initial, options) {
79
+ const factories = {
80
+ gliner: (e) => new GlinerProvider(e),
81
+ bardsai: (e) => new BardsaiProvider(e),
82
+ ...options?.providers,
83
+ };
84
+ const settings = {
85
+ providerId: initial?.providerId ?? pickDefaultProvider(env.hardware),
86
+ threshold: 0,
87
+ regexEnabled: initial?.regexEnabled ?? false,
88
+ regexRegion: initial?.regexRegion ?? 'all',
89
+ customLabels: [...(initial?.customLabels ?? [])],
90
+ };
91
+ settings.threshold = initial?.threshold !== undefined
92
+ ? clampThreshold(initial.threshold)
93
+ : defaultThresholdFor(settings.providerId);
94
+ /** Load persisted settings; explicit `initial` values win over env.kv. */
95
+ const ready = (async () => {
96
+ if (initial?.providerId === undefined) {
97
+ try {
98
+ const saved = await env.kv.get(ENGINE_SETTINGS_KEYS.provider);
99
+ if (isProviderId(saved)) {
100
+ settings.providerId = saved;
101
+ if (initial?.threshold === undefined)
102
+ settings.threshold = defaultThresholdFor(saved);
103
+ }
104
+ }
105
+ catch { /* KV unavailable - keep the heuristic default */ }
106
+ }
107
+ if (initial?.customLabels === undefined) {
108
+ try {
109
+ const saved = await env.kv.get(ENGINE_SETTINGS_KEYS.customLabels);
110
+ if (saved) {
111
+ const parsed = JSON.parse(saved);
112
+ if (Array.isArray(parsed)) {
113
+ settings.customLabels = parsed.filter((l) => typeof l === 'string');
114
+ }
115
+ }
116
+ }
117
+ catch { /* unreadable value - keep default */ }
118
+ }
119
+ if (initial?.regexEnabled === undefined) {
120
+ try {
121
+ const saved = await env.kv.get(ENGINE_SETTINGS_KEYS.regexEnabled);
122
+ if (saved !== null)
123
+ settings.regexEnabled = JSON.parse(saved) === true;
124
+ }
125
+ catch { /* unreadable value - keep default */ }
126
+ }
127
+ if (initial?.regexRegion === undefined) {
128
+ try {
129
+ const saved = await env.kv.get(ENGINE_SETTINGS_KEYS.regexRegion);
130
+ if (isRegexRegionId(saved))
131
+ settings.regexRegion = saved;
132
+ }
133
+ catch { /* unreadable value - keep default */ }
134
+ }
135
+ })();
136
+ let provider = null;
137
+ let inflightLoad = null;
138
+ const downloadListeners = new Set();
139
+ const detectionListeners = new Set();
140
+ async function kvSet(key, value) {
141
+ try {
142
+ await env.kv.set(key, value);
143
+ }
144
+ catch { /* KV unavailable (e.g. storage-less worker) - settings stay in memory */ }
145
+ }
146
+ function ensureProvider() {
147
+ if (!provider) {
148
+ const factory = factories[settings.providerId];
149
+ if (!factory)
150
+ throw new Error(`Unknown provider: ${settings.providerId}`);
151
+ provider = factory(env);
152
+ provider.onProgress((downloaded, total) => {
153
+ for (const cb of downloadListeners)
154
+ cb({ loaded: downloaded, total });
155
+ });
156
+ }
157
+ return provider;
158
+ }
159
+ async function applyCustomLabels(p) {
160
+ if (supportsCustomLabels(p)) {
161
+ await p.setCustomLabels(settings.customLabels);
162
+ }
163
+ }
164
+ async function preload() {
165
+ await ready;
166
+ if (inflightLoad)
167
+ return inflightLoad;
168
+ const p = ensureProvider();
169
+ if (p.isLoaded())
170
+ return;
171
+ inflightLoad = (async () => {
172
+ // A previous attempt may have failed: release() clears the cached load
173
+ // error so load() can retry (model bytes are served from the blob
174
+ // cache when available). Mirrors the old worker init path.
175
+ if (!p.isLoading())
176
+ p.release();
177
+ await applyCustomLabels(p);
178
+ await p.load();
179
+ settings.threshold = p.getThreshold();
180
+ if (supportsCustomLabels(p))
181
+ settings.customLabels = p.getCustomLabels();
182
+ })();
183
+ try {
184
+ await inflightLoad;
185
+ }
186
+ finally {
187
+ inflightLoad = null;
188
+ }
189
+ }
190
+ async function switchProvider(id) {
191
+ await ready;
192
+ if (!factories[id])
193
+ throw new Error(`Unknown provider: ${id}`);
194
+ if (id === settings.providerId && provider?.isLoaded())
195
+ return;
196
+ // Release the previous ONNX session to free WASM heap. Cached model
197
+ // files are kept so switching back does not re-download hundreds of
198
+ // megabytes; the browser evicts them under quota pressure.
199
+ provider?.release();
200
+ provider = null;
201
+ settings.providerId = id;
202
+ settings.threshold = defaultThresholdFor(id);
203
+ await kvSet(ENGINE_SETTINGS_KEYS.provider, id);
204
+ await preload();
205
+ }
206
+ async function updateSettings(patch) {
207
+ await ready;
208
+ if (patch.providerId !== undefined && patch.providerId !== settings.providerId) {
209
+ if (!factories[patch.providerId])
210
+ throw new Error(`Unknown provider: ${patch.providerId}`);
211
+ provider?.release();
212
+ provider = null;
213
+ settings.providerId = patch.providerId;
214
+ settings.threshold = defaultThresholdFor(patch.providerId);
215
+ await kvSet(ENGINE_SETTINGS_KEYS.provider, patch.providerId);
216
+ }
217
+ if (patch.threshold !== undefined) {
218
+ settings.threshold = clampThreshold(patch.threshold);
219
+ provider?.setThreshold(settings.threshold);
220
+ }
221
+ if (patch.customLabels !== undefined) {
222
+ settings.customLabels = patch.customLabels.filter((l) => l.trim().length > 0);
223
+ await kvSet(ENGINE_SETTINGS_KEYS.customLabels, JSON.stringify(settings.customLabels));
224
+ if (provider)
225
+ await applyCustomLabels(provider);
226
+ }
227
+ if (patch.regexEnabled !== undefined) {
228
+ settings.regexEnabled = patch.regexEnabled;
229
+ await kvSet(ENGINE_SETTINGS_KEYS.regexEnabled, JSON.stringify(patch.regexEnabled));
230
+ }
231
+ if (patch.regexRegion !== undefined) {
232
+ settings.regexRegion = patch.regexRegion;
233
+ await kvSet(ENGINE_SETTINGS_KEYS.regexRegion, patch.regexRegion);
234
+ }
235
+ }
236
+ async function detect(text, signal, onProgress) {
237
+ await ready;
238
+ if (signal?.aborted)
239
+ throw new Error('Detection aborted');
240
+ if (!text.trim())
241
+ return [];
242
+ const p = ensureProvider();
243
+ const mlResults = await p.detect(text, (progress) => {
244
+ onProgress?.(progress);
245
+ for (const cb of detectionListeners)
246
+ cb(progress);
247
+ });
248
+ if (signal?.aborted)
249
+ throw new Error('Detection aborted');
250
+ const regexResults = settings.regexEnabled ? detectWithRegex(text, settings.regexRegion) : [];
251
+ return detectEntities(text, mlResults, regexResults);
252
+ }
253
+ return {
254
+ ready,
255
+ detect,
256
+ preload,
257
+ switchProvider,
258
+ getSettings() {
259
+ return { ...settings, customLabels: [...settings.customLabels] };
260
+ },
261
+ updateSettings,
262
+ onDownloadProgress(cb) {
263
+ downloadListeners.add(cb);
264
+ return () => downloadListeners.delete(cb);
265
+ },
266
+ onDetectionProgress(cb) {
267
+ detectionListeners.add(cb);
268
+ return () => detectionListeners.delete(cb);
269
+ },
270
+ async release() {
271
+ provider?.release();
272
+ },
273
+ };
274
+ }