@doccloak/core 0.10.0 → 0.12.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/CHANGELOG.md +162 -2
  2. package/README.md +252 -19
  3. package/dist/doc.d.ts +68 -1
  4. package/dist/doc.js +447 -62
  5. package/dist/dom/docx.d.ts +135 -7
  6. package/dist/dom/docx.js +747 -134
  7. package/dist/dom/drawingml.d.ts +59 -0
  8. package/dist/dom/drawingml.js +176 -0
  9. package/dist/dom/errors.d.ts +35 -0
  10. package/dist/dom/errors.js +24 -0
  11. package/dist/dom/index.d.ts +3 -0
  12. package/dist/dom/index.js +6 -0
  13. package/dist/dom/office.d.ts +56 -0
  14. package/dist/dom/office.js +76 -23
  15. package/dist/dom/opc.d.ts +141 -7
  16. package/dist/dom/opc.js +424 -18
  17. package/dist/dom/package-policy.d.ts +137 -0
  18. package/dist/dom/package-policy.js +355 -0
  19. package/dist/dom/xlsx.d.ts +64 -6
  20. package/dist/dom/xlsx.js +75 -20
  21. package/dist/engine.d.ts +18 -0
  22. package/dist/engine.js +37 -13
  23. package/dist/env.d.ts +21 -1
  24. package/dist/index.d.ts +8 -6
  25. package/dist/index.js +10 -5
  26. package/dist/model-loader.d.ts +61 -6
  27. package/dist/model-loader.js +122 -17
  28. package/dist/pipeline.d.ts +87 -8
  29. package/dist/pipeline.js +249 -50
  30. package/dist/providers/bardsai.d.ts +8 -1
  31. package/dist/providers/bardsai.js +22 -2
  32. package/dist/providers/gliner-base.d.ts +8 -1
  33. package/dist/providers/gliner-base.js +22 -2
  34. package/dist/providers/gliner.d.ts +8 -1
  35. package/dist/providers/gliner.js +22 -2
  36. package/dist/regex/loader.d.ts +2 -0
  37. package/dist/regex/loader.js +2 -0
  38. package/dist/regex/rules.data.js +15 -14
  39. package/dist/regex/types.d.ts +6 -0
  40. package/dist/session.d.ts +137 -4
  41. package/dist/session.js +591 -62
  42. package/dist/surrogates.js +57 -45
  43. package/dist/worker-protocol.d.ts +13 -1
  44. package/dist/worker-protocol.js +164 -12
  45. package/package.json +1 -1
  46. package/rules/at.json +1 -1
  47. package/rules/be.json +1 -1
  48. package/rules/ch.json +1 -1
  49. package/rules/de.json +1 -1
  50. package/rules/dk.json +1 -1
  51. package/rules/es.json +1 -1
  52. package/rules/fi.json +1 -1
  53. package/rules/fr.json +1 -1
  54. package/rules/it.json +1 -1
  55. package/rules/no.json +1 -1
  56. package/rules/pl.json +1 -1
  57. package/rules/pt.json +1 -1
  58. package/rules/schema.json +4 -0
  59. package/rules/se.json +1 -1
  60. package/rules/universal.json +2 -1
package/dist/pipeline.js CHANGED
@@ -1,40 +1,93 @@
1
1
  /**
2
2
  * Pure entity pipeline (T005).
3
3
  *
4
- * Extracted verbatim from the web app's detection.worker.ts: text and
5
- * entities in, entities out. No worker messaging, no providers, no
6
- * environment access. The worker (and any other host) composes these
7
- * functions around its own ML provider call.
4
+ * Extracted from the web app's detection.worker.ts: text and entities in,
5
+ * entities out. No worker messaging, no providers, no environment access.
6
+ * The worker (and any other host) composes these functions around its own
7
+ * ML provider call.
8
8
  *
9
- * Function bodies are byte-identical to the originals by design; only the
10
- * signatures of detectWithRegex (regexRegion becomes a parameter instead of
11
- * worker module state) and detectEntities (ML results are passed in instead
12
- * of fetched from a provider) differ.
9
+ * Security pass 2026-09:
10
+ * - T180: detectWithRegex runs every rule line by line (rules flagged
11
+ * `multiline` see the whole text), so quadratic patterns cost O(line^2)
12
+ * instead of O(document^2).
13
+ * - T181: filterFalsePositives is script-aware (CJK/Thai and PERSON values
14
+ * of two characters survive) and propagateEntities uses Unicode letter/
15
+ * digit boundaries, exact offsets on the original text and a common-word
16
+ * guard for lowercase PERSON matches.
13
17
  */
14
18
  import { ALL_REGEX_RULES } from "./regex/index.js";
19
+ /**
20
+ * Split on '\n' keeping offsets. '\r' stays inside the line (a CRLF line then
21
+ * ends with '\r', exactly as it did when the whole text was matched at once).
22
+ * Empty lines are skipped: no rule can match the empty string.
23
+ */
24
+ export function segmentLines(text) {
25
+ const segments = [];
26
+ let lineStart = 0;
27
+ while (lineStart <= text.length) {
28
+ const newline = text.indexOf('\n', lineStart);
29
+ const lineEnd = newline === -1 ? text.length : newline;
30
+ if (lineEnd > lineStart) {
31
+ segments.push({ text: text.slice(lineStart, lineEnd), offset: lineStart });
32
+ }
33
+ if (newline === -1)
34
+ break;
35
+ lineStart = newline + 1;
36
+ }
37
+ return segments;
38
+ }
39
+ function runRule(rule, segment, out) {
40
+ rule.pattern.lastIndex = 0;
41
+ let match;
42
+ while ((match = rule.pattern.exec(segment.text)) !== null) {
43
+ if (match[0].length === 0) {
44
+ // Defensive: a zero-width match would otherwise loop forever.
45
+ rule.pattern.lastIndex++;
46
+ continue;
47
+ }
48
+ if (rule.validate && !rule.validate(match[0]))
49
+ continue;
50
+ out.push({
51
+ type: rule.type,
52
+ value: match[0],
53
+ start: segment.offset + match.index,
54
+ end: segment.offset + match.index + match[0].length,
55
+ confidence: rule.confidence,
56
+ detector: rule.detector,
57
+ });
58
+ }
59
+ }
60
+ /**
61
+ * Run the regex rules of a region over the text.
62
+ *
63
+ * Rules without `multiline` run on each line separately (offsets are added
64
+ * back, so spans are document-absolute); rules with `multiline: true` run on
65
+ * the whole text. Output order is rule-major, then position, as before.
66
+ *
67
+ * Complexity note (T180): several street/company rules are quadratic in the
68
+ * length of their input. Per-line matching bounds that to the longest line,
69
+ * so 100 KB of ordinary prose is fast; a single 100 KB line without newlines
70
+ * is still slow until the patterns themselves are bounded (T198).
71
+ */
15
72
  export function detectWithRegex(text, regexRegion = 'all') {
16
73
  const entities = [];
17
74
  const rules = regexRegion === 'all'
18
75
  ? ALL_REGEX_RULES
19
76
  : ALL_REGEX_RULES.filter((r) => r.region === 'universal' || r.region === regexRegion);
77
+ const whole = { text, offset: 0 };
78
+ const lines = segmentLines(text);
20
79
  for (const rule of rules) {
21
- rule.pattern.lastIndex = 0;
22
- let match;
23
- while ((match = rule.pattern.exec(text)) !== null) {
24
- if (rule.validate && !rule.validate(match[0]))
25
- continue;
26
- entities.push({
27
- type: rule.type,
28
- value: match[0],
29
- start: match.index,
30
- end: match.index + match[0].length,
31
- confidence: rule.confidence,
32
- detector: rule.detector,
33
- });
80
+ if (rule.multiline) {
81
+ runRule(rule, whole, entities);
82
+ }
83
+ else {
84
+ for (const line of lines)
85
+ runRule(rule, line, entities);
34
86
  }
35
87
  }
36
88
  return entities;
37
89
  }
90
+ // ── resolveOverlaps ────────────────────────────────────────
38
91
  export function resolveOverlaps(entities) {
39
92
  const sorted = [...entities].sort((a, b) => {
40
93
  if (a.start !== b.start)
@@ -54,13 +107,152 @@ export function resolveOverlaps(entities) {
54
107
  }
55
108
  return resolved;
56
109
  }
110
+ // ── trimSpanPunctuation ────────────────────────────────────
111
+ const LEADING_PUNCT = /^[\s(\[{"'«“‘„]+/u;
112
+ const TRAILING_PUNCT = /[\s,.;:!?)\]}"'»”’]+$/u;
113
+ /**
114
+ * Word-level providers (BardS.ai splits on whitespace) return spans that
115
+ * carry the punctuation glued to the last word: "Anna Nowak," or
116
+ * "jan@example.com.". The redacted text then loses that punctuation and the
117
+ * mapping stores it as part of the value. Trim a fixed set of opening and
118
+ * closing punctuation at both ends of every span (T204); a span that would
119
+ * become empty is dropped. Regex spans are not passed through this: their
120
+ * patterns already stop at the identifier.
121
+ */
122
+ export function trimSpanPunctuation(entities) {
123
+ const out = [];
124
+ for (const entity of entities) {
125
+ const value = entity.value;
126
+ const lead = LEADING_PUNCT.exec(value)?.[0].length ?? 0;
127
+ const trail = TRAILING_PUNCT.exec(value.slice(lead))?.[0].length ?? 0;
128
+ if (lead === 0 && trail === 0) {
129
+ out.push(entity);
130
+ continue;
131
+ }
132
+ const trimmed = value.slice(lead, value.length - trail);
133
+ if (trimmed.length === 0)
134
+ continue;
135
+ out.push({ ...entity, value: trimmed, start: entity.start + lead, end: entity.end - trail });
136
+ }
137
+ return out;
138
+ }
139
+ // ── filterFalsePositives ───────────────────────────────────
140
+ /**
141
+ * Scripts whose names are routinely one or two characters long (and whose
142
+ * words are not space-delimited). A value containing any of these keeps the
143
+ * lower two-character threshold for every entity type.
144
+ */
145
+ const CJK_THAI = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Thai}]/u;
146
+ /**
147
+ * Two-character PERSON values that are titles, suffixes or abbreviations,
148
+ * never names. Compared against the lowercased trimmed value (T181, R2).
149
+ */
150
+ export const PERSON_SHORT_STOPLIST = new Set([
151
+ 'mr', 'ms', 'dr', 'jr', 'sr', 'st', 'ii', 'iv', 'vi', 'no', 're', 'cc',
152
+ ]);
153
+ function codePointLength(value) {
154
+ let n = 0;
155
+ for (const _ of value)
156
+ n++;
157
+ return n;
158
+ }
159
+ /**
160
+ * Drop ML spans that are too short to be real entities (R2):
161
+ * - values containing CJK or Thai characters: at least 2 code points, any type
162
+ * - PERSON: at least 2 characters, unless the value is a known 2-letter
163
+ * title/suffix (PERSON_SHORT_STOPLIST); initials like "J." pass
164
+ * - every other type: at least 3 characters (unchanged)
165
+ */
57
166
  export function filterFalsePositives(entities) {
58
167
  return entities.filter((e) => {
59
- if (e.value.trim().length < 3)
168
+ const value = e.value.trim();
169
+ if (value.length === 0)
60
170
  return false;
61
- return true;
171
+ if (CJK_THAI.test(value))
172
+ return codePointLength(value) >= 2;
173
+ if (e.type === 'PERSON') {
174
+ if (value.length < 2)
175
+ return false;
176
+ return !PERSON_SHORT_STOPLIST.has(value.toLowerCase());
177
+ }
178
+ return value.length >= 3;
62
179
  });
63
180
  }
181
+ // ── propagateEntities ──────────────────────────────────────
182
+ /**
183
+ * Words that are also given names or surnames, per language (T181, R15).
184
+ * A PERSON term found in the text with DIFFERENT casing than the detected
185
+ * entity, and NOT starting with an uppercase letter, is skipped when its
186
+ * lowercase form is listed here: "Bill" the person never turns "the bill is
187
+ * due" into "[PERSON_1] is due". Capitalised and ALL-CAPS mentions always
188
+ * propagate, and lowercase mentions of ordinary names ("thanks john") are
189
+ * still caught (golden prop-03-case-insensitive).
190
+ *
191
+ * Only words that appear in lowercase in running prose belong here. German
192
+ * nouns are always capitalised in prose, so the DE list holds adjectives and
193
+ * month names that double as surnames or first names; the PL list holds
194
+ * nouns (animals, months, plants) that are frequent Polish surnames and
195
+ * first names.
196
+ */
197
+ export const COMMON_WORD_NAMES = {
198
+ en: [
199
+ 'bill', 'mark', 'will', 'may', 'art', 'grace', 'hope', 'rose', 'jack', 'sue',
200
+ 'pat', 'chase', 'dawn', 'june', 'carol', 'guy', 'rich', 'frank', 'ray', 'don',
201
+ 'bob', 'jay', 'gene',
202
+ ],
203
+ pl: [
204
+ 'lis', 'wilk', 'wrona', 'sowa', 'kruk', 'baran', 'kot', 'mucha', 'kowal', 'sroka',
205
+ 'wróbel', 'dudek', 'mróz', 'zając', 'sobota', 'piątek', 'maj', 'marzec', 'kwiecień',
206
+ 'lipiec', 'róża', 'jagoda', 'malina', 'kalina', 'cebula',
207
+ ],
208
+ de: [
209
+ 'lang', 'klein', 'groß', 'gross', 'weiß', 'weiss', 'schwarz', 'braun', 'roth', 'kurz',
210
+ 'jung', 'alt', 'neu', 'ernst', 'reich', 'stark', 'wild', 'frei', 'gut', 'voll',
211
+ 'kühn', 'fromm', 'hell', 'klug', 'treu', 'lieb', 'mai',
212
+ ],
213
+ };
214
+ const COMMON_WORD_NAME_SET = new Set(Object.values(COMMON_WORD_NAMES).flat());
215
+ /** A character that continues a word: a Unicode letter or digit. */
216
+ const WORD_CHAR = /[\p{L}\p{N}]/u;
217
+ /**
218
+ * Scripts written without spaces between words. A term edge in one of these
219
+ * scripts cannot demand a non-letter neighbour (a Chinese name is normally
220
+ * glued to the next character), mirroring the restore-side rule that CJK
221
+ * keys carry no boundaries.
222
+ */
223
+ const UNSPACED_SCRIPT = /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Thai}]$/u;
224
+ function escapeRegExp(value) {
225
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
226
+ }
227
+ function codePointBefore(text, index) {
228
+ if (index <= 0)
229
+ return '';
230
+ const pair = Array.from(text.slice(Math.max(0, index - 2), index));
231
+ return pair[pair.length - 1] ?? '';
232
+ }
233
+ function codePointAt(text, index) {
234
+ if (index >= text.length)
235
+ return '';
236
+ const cp = text.codePointAt(index);
237
+ return cp === undefined ? '' : String.fromCodePoint(cp);
238
+ }
239
+ function startsUppercase(value) {
240
+ const first = codePointAt(value, 0);
241
+ return first !== '' && first !== first.toLowerCase();
242
+ }
243
+ /**
244
+ * Find every further occurrence of each detected value (and of the words of
245
+ * multi-word values) in the text and emit it as a 'propagated' entity.
246
+ *
247
+ * Terms: whole values of at least 2 characters; words of multi-word values
248
+ * of at least 4 characters (trailing punctuation stripped). Terms of at most
249
+ * 2 characters match with exact case; longer terms match case-insensitively
250
+ * (Unicode case folding via the 'iu' flags) on the ORIGINAL text, so offsets
251
+ * are exact even after characters whose lowercase form has a different
252
+ * length (Turkish dotted İ). A hit needs a non-letter, non-digit neighbour
253
+ * on both sides ([\p{L}\p{N}] complement), except on a side where the term
254
+ * itself ends in an unspaced script (CJK, Thai).
255
+ */
64
256
  export function propagateEntities(text, entities) {
65
257
  const propagated = [];
66
258
  const seen = new Set();
@@ -71,7 +263,7 @@ export function propagateEntities(text, entities) {
71
263
  const addedTerms = new Set();
72
264
  for (const e of entities) {
73
265
  const val = e.value.trim();
74
- if (val.length >= 3 && !addedTerms.has(val.toLowerCase())) {
266
+ if (codePointLength(val) >= 2 && !addedTerms.has(val.toLowerCase())) {
75
267
  addedTerms.add(val.toLowerCase());
76
268
  terms.push({ term: val, type: e.type });
77
269
  }
@@ -86,41 +278,48 @@ export function propagateEntities(text, entities) {
86
278
  }
87
279
  }
88
280
  for (const { term, type } of terms) {
89
- let searchFrom = 0;
90
- const lowerText = text.toLowerCase();
91
- const lowerTerm = term.toLowerCase();
92
- while (searchFrom < text.length) {
93
- const idx = lowerText.indexOf(lowerTerm, searchFrom);
94
- if (idx === -1)
95
- break;
96
- const end = idx + term.length;
281
+ const exactCase = codePointLength(term) <= 2;
282
+ const pattern = new RegExp(escapeRegExp(term), exactCase ? 'gu' : 'giu');
283
+ const needsBoundaryBefore = !UNSPACED_SCRIPT.test(codePointAt(term, 0));
284
+ const needsBoundaryAfter = !UNSPACED_SCRIPT.test(codePointBefore(term, term.length));
285
+ let match;
286
+ while ((match = pattern.exec(text)) !== null) {
287
+ const idx = match.index;
288
+ const found = match[0];
289
+ const end = idx + found.length;
290
+ // Same overlap semantics as the original indexOf loop (searchFrom = idx + 1).
291
+ pattern.lastIndex = idx + 1;
97
292
  const key = `${idx}:${end}`;
98
- if (!seen.has(key)) {
99
- const charBefore = idx > 0 ? text[idx - 1] : ' ';
100
- const charAfter = end < text.length ? text[end] : ' ';
101
- const isBoundaryBefore = /[\s.,;:!?()"'„"\-–—/]/.test(charBefore);
102
- const isBoundaryAfter = /[\s.,;:!?()"'„"\-–—/]/.test(charAfter);
103
- if (isBoundaryBefore && isBoundaryAfter) {
104
- seen.add(key);
105
- propagated.push({
106
- type,
107
- value: text.slice(idx, end),
108
- start: idx,
109
- end,
110
- confidence: 0.95,
111
- detector: 'propagated',
112
- });
113
- }
293
+ if (seen.has(key))
294
+ continue;
295
+ if (needsBoundaryBefore && WORD_CHAR.test(codePointBefore(text, idx)))
296
+ continue;
297
+ if (needsBoundaryAfter && WORD_CHAR.test(codePointAt(text, end)))
298
+ continue;
299
+ if (type === 'PERSON'
300
+ && found !== term
301
+ && !startsUppercase(found)
302
+ && COMMON_WORD_NAME_SET.has(found.toLowerCase())) {
303
+ continue;
114
304
  }
115
- searchFrom = idx + 1;
305
+ seen.add(key);
306
+ propagated.push({
307
+ type,
308
+ value: found,
309
+ start: idx,
310
+ end,
311
+ confidence: 0.95,
312
+ detector: 'propagated',
313
+ });
116
314
  }
117
315
  }
118
316
  return propagated;
119
317
  }
318
+ // ── detectEntities ─────────────────────────────────────────
120
319
  export function detectEntities(text, mlEntities, regexEntities) {
121
320
  if (!text.trim())
122
321
  return [];
123
- const mlResults = filterFalsePositives(mlEntities);
322
+ const mlResults = filterFalsePositives(trimSpanPunctuation(mlEntities));
124
323
  const regexResults = regexEntities;
125
324
  const initial = resolveOverlaps([...mlResults, ...regexResults]);
126
325
  const propagated = propagateEntities(text, initial);
@@ -12,11 +12,18 @@
12
12
  */
13
13
  import type { DetectedEntity, DetectionProvider, ProgressCallback } from '../types.js';
14
14
  import type { CoreEnv } from '../env.js';
15
- import type { ModelVerification } from '../model-loader.js';
15
+ import type { ModelVerification, TokenizerFiles } from '../model-loader.js';
16
16
  export declare const BARDSAI_MODEL_REVISION = "0e72e19f030ed4e661b1673e549af8e0dd176386";
17
17
  /** SHA-256 of onnx/model_quantized.onnx (278,736,360 bytes) at BARDSAI_MODEL_REVISION. */
18
18
  export declare const BARDSAI_MODEL_SHA256 = "8c9f555c743ed14eb7e505ff9d9c7785775a1671fd14031af33f424de8bd0e7b";
19
19
  export declare const BARDSAI_MODEL_URL = "https://huggingface.co/bardsai/eu-pii-anonimization-multilang/resolve/0e72e19f030ed4e661b1673e549af8e0dd176386/onnx/model_quantized.onnx";
20
+ /**
21
+ * Tokenizer files at the same pinned commit (T185): fetched through
22
+ * fetchModelBlob with SHA-256 + size, cached next to the model and handed to
23
+ * CoreEnv.buildTokenizer. Hashes re-measured against the resolve/<commit>
24
+ * URLs on 2026-09-24 (documentation/model-provenance.md).
25
+ */
26
+ export declare const BARDSAI_TOKENIZER_FILES: TokenizerFiles;
20
27
  export declare class BardsaiProvider implements DetectionProvider {
21
28
  private _name;
22
29
  get name(): string;
@@ -14,7 +14,7 @@
14
14
  // wasm fallback, asyncify binary). The bare entry resolves the deprecated JSEP
15
15
  // bundle whose wasm exceeds Cloudflare Pages' 25 MiB per-file limit.
16
16
  import * as ort from 'onnxruntime-web/webgpu';
17
- import { evictModelFromCache, fetchModelBlob, retryAsync } from "../model-loader.js";
17
+ import { evictModelFromCache, fetchModelBlob, loadPinnedTokenizer } from "../model-loader.js";
18
18
  // ── Model config ──────────────────────────────────────────
19
19
  // Supply-chain pinning (T116): the model is fetched from an immutable
20
20
  // commit revision, never from mutable resolve/main, and the downloaded
@@ -31,6 +31,26 @@ export const BARDSAI_MODEL_URL = `https://huggingface.co/bardsai/eu-pii-anonimiz
31
31
  const MODEL_URL = BARDSAI_MODEL_URL;
32
32
  /** Pre-pinning download URL; its cache entry is evicted best-effort on load. */
33
33
  const LEGACY_MODEL_URL = 'https://huggingface.co/bardsai/eu-pii-anonimization-multilang/resolve/main/onnx/model_quantized.onnx';
34
+ /**
35
+ * Tokenizer files at the same pinned commit (T185): fetched through
36
+ * fetchModelBlob with SHA-256 + size, cached next to the model and handed to
37
+ * CoreEnv.buildTokenizer. Hashes re-measured against the resolve/<commit>
38
+ * URLs on 2026-09-24 (documentation/model-provenance.md).
39
+ */
40
+ export const BARDSAI_TOKENIZER_FILES = [
41
+ {
42
+ url: `https://huggingface.co/bardsai/eu-pii-anonimization-multilang/resolve/${BARDSAI_MODEL_REVISION}/tokenizer.json`,
43
+ sha256: '2464f9721707cb3d5edcf9a3d73454b13e8a7b3bb8fdba94b3de3d843f30e946',
44
+ size: 16781584,
45
+ },
46
+ {
47
+ url: `https://huggingface.co/bardsai/eu-pii-anonimization-multilang/resolve/${BARDSAI_MODEL_REVISION}/tokenizer_config.json`,
48
+ sha256: 'c019e3e4f7f901adf680dde303c7d964de86675a13e089bcc0f614d3fce75333',
49
+ size: 314,
50
+ },
51
+ ];
52
+ const TOKENIZER_FILES = BARDSAI_TOKENIZER_FILES;
53
+ /** Hugging Face id for the deprecated CoreEnv.loadTokenizer fallback only. */
34
54
  const TOKENIZER_HF = 'bardsai/eu-pii-anonimization-multilang';
35
55
  const MODEL_NAME = 'BardS.ai EU PII';
36
56
  const DEFAULT_THRESHOLD = 0.5;
@@ -181,7 +201,7 @@ export class BardsaiProvider {
181
201
  }),
182
202
  ];
183
203
  if (!this.tokenizer) {
184
- tasks.push(retryAsync(() => this.env.loadTokenizer(TOKENIZER_HF), 'Tokenizer download').then((t) => {
204
+ tasks.push(loadPinnedTokenizer(this.env, TOKENIZER_FILES, TOKENIZER_HF).then((t) => {
185
205
  this.tokenizer = t;
186
206
  }));
187
207
  }
@@ -17,12 +17,19 @@
17
17
  */
18
18
  import type { DetectedEntity, DetectionProvider, ProgressCallback } from '../types.js';
19
19
  import type { CoreEnv } from '../env.js';
20
- import type { ModelVerification } from '../model-loader.js';
20
+ import type { ModelVerification, TokenizerFiles } from '../model-loader.js';
21
21
  import { type RawSpan } from './gliner.js';
22
22
  export declare const GLINER_BASE_MODEL_REVISION = "61726e0ad791dcab3e29339bbec3ad42ded65641";
23
23
  /** SHA-256 of onnx/model_quint8.onnx (196,757,174 bytes) at GLINER_BASE_MODEL_REVISION. */
24
24
  export declare const GLINER_BASE_MODEL_SHA256 = "0514c8fd86d0513ce5351a3267f132b57d5bcd8f99a90d43cde1228092881d19";
25
25
  export declare const GLINER_BASE_MODEL_URL = "https://huggingface.co/knowledgator/gliner-pii-base-v1.0/resolve/61726e0ad791dcab3e29339bbec3ad42ded65641/onnx/model_quint8.onnx";
26
+ /**
27
+ * Tokenizer files at the same pinned commit (T185): fetched through
28
+ * fetchModelBlob with SHA-256 + size, cached next to the model and handed to
29
+ * CoreEnv.buildTokenizer. Hashes re-measured against the resolve/<commit>
30
+ * URLs on 2026-09-24 (documentation/model-provenance.md).
31
+ */
32
+ export declare const GLINER_BASE_TOKENIZER_FILES: TokenizerFiles;
26
33
  /** max_width from the repo's gliner_config.json: spans cover 1..12 words. */
27
34
  export declare const GLINER_BASE_MAX_WIDTH = 12;
28
35
  export interface SpanIndices {
@@ -19,7 +19,7 @@
19
19
  // wasm fallback, asyncify binary). The bare entry resolves the deprecated JSEP
20
20
  // bundle whose wasm exceeds Cloudflare Pages' 25 MiB per-file limit.
21
21
  import * as ort from 'onnxruntime-web/webgpu';
22
- import { fetchModelBlob, retryAsync } from "../model-loader.js";
22
+ import { fetchModelBlob, loadPinnedTokenizer } from "../model-loader.js";
23
23
  import { DEFAULT_PII_LABELS, greedySelect, mapLabelToEntityType, sigmoid, splitWords, } from "./gliner.js";
24
24
  // ── Model config ──────────────────────────────────────────
25
25
  // Supply-chain pinning (T116): the model is fetched from an immutable
@@ -35,6 +35,26 @@ export const GLINER_BASE_MODEL_REVISION = '61726e0ad791dcab3e29339bbec3ad42ded65
35
35
  export const GLINER_BASE_MODEL_SHA256 = '0514c8fd86d0513ce5351a3267f132b57d5bcd8f99a90d43cde1228092881d19';
36
36
  export const GLINER_BASE_MODEL_URL = `https://huggingface.co/knowledgator/gliner-pii-base-v1.0/resolve/${GLINER_BASE_MODEL_REVISION}/onnx/model_quint8.onnx`;
37
37
  const DEFAULT_MODEL_URL = GLINER_BASE_MODEL_URL;
38
+ /**
39
+ * Tokenizer files at the same pinned commit (T185): fetched through
40
+ * fetchModelBlob with SHA-256 + size, cached next to the model and handed to
41
+ * CoreEnv.buildTokenizer. Hashes re-measured against the resolve/<commit>
42
+ * URLs on 2026-09-24 (documentation/model-provenance.md).
43
+ */
44
+ export const GLINER_BASE_TOKENIZER_FILES = [
45
+ {
46
+ url: `https://huggingface.co/knowledgator/gliner-pii-base-v1.0/resolve/${GLINER_BASE_MODEL_REVISION}/tokenizer.json`,
47
+ sha256: 'ee028763434d18611c1c36356ea1d050e90a9fa94ede57fac48b39f85f818ad1',
48
+ size: 8649232,
49
+ },
50
+ {
51
+ url: `https://huggingface.co/knowledgator/gliner-pii-base-v1.0/resolve/${GLINER_BASE_MODEL_REVISION}/tokenizer_config.json`,
52
+ sha256: '3ec8a90d8758fbc56d50831990c3a3a65660f020c5b06534adf43b04091ffa9e',
53
+ size: 1691,
54
+ },
55
+ ];
56
+ const TOKENIZER_FILES = GLINER_BASE_TOKENIZER_FILES;
57
+ /** Hugging Face id for the deprecated CoreEnv.loadTokenizer fallback only. */
38
58
  const DEFAULT_TOKENIZER_HF = 'knowledgator/gliner-pii-base-v1.0';
39
59
  const DEFAULT_MODEL_NAME = 'GLiNER PII Base';
40
60
  const CUSTOM_LABELS_STORAGE_KEY = 'doccloak-custom-labels';
@@ -208,7 +228,7 @@ export class GlinerBaseProvider {
208
228
  }),
209
229
  ];
210
230
  if (!this.tokenizer) {
211
- tasks.push(retryAsync(() => this.env.loadTokenizer(DEFAULT_TOKENIZER_HF), 'Tokenizer download').then((t) => {
231
+ tasks.push(loadPinnedTokenizer(this.env, TOKENIZER_FILES, DEFAULT_TOKENIZER_HF).then((t) => {
212
232
  this.tokenizer = t;
213
233
  }));
214
234
  }
@@ -8,11 +8,18 @@
8
8
  */
9
9
  import type { DetectedEntity, EntityType, DetectionProvider, ProgressCallback } from '../types.js';
10
10
  import type { CoreEnv } from '../env.js';
11
- import type { ModelVerification } from '../model-loader.js';
11
+ import type { ModelVerification, TokenizerFiles } from '../model-loader.js';
12
12
  export declare const GLINER_MODEL_REVISION = "d21aad5b4a7ec82b3d0970fd1ac74a12c087d85e";
13
13
  /** SHA-256 of onnx/model_quint8.onnx (82,680,500 bytes) at GLINER_MODEL_REVISION. */
14
14
  export declare const GLINER_MODEL_SHA256 = "891589426ee96f2748b16439f44fad8c3f97e198e002a6637e58dee989500216";
15
15
  export declare const GLINER_MODEL_URL = "https://huggingface.co/knowledgator/gliner-pii-small-v1.0/resolve/d21aad5b4a7ec82b3d0970fd1ac74a12c087d85e/onnx/model_quint8.onnx";
16
+ /**
17
+ * Tokenizer files at the same pinned commit (T185): fetched through
18
+ * fetchModelBlob with SHA-256 + size, cached next to the model and handed to
19
+ * CoreEnv.buildTokenizer. Hashes re-measured against the resolve/<commit>
20
+ * URLs on 2026-09-24 (documentation/model-provenance.md).
21
+ */
22
+ export declare const GLINER_TOKENIZER_FILES: TokenizerFiles;
16
23
  /**
17
24
  * Built-in zero-shot label set, shared with the GLiNER PII Base provider
18
25
  * (gliner-base.ts) - the models are from the same zero-shot family, so the
@@ -10,7 +10,7 @@
10
10
  // wasm fallback, asyncify binary). The bare entry resolves the deprecated JSEP
11
11
  // bundle whose wasm exceeds Cloudflare Pages' 25 MiB per-file limit.
12
12
  import * as ort from 'onnxruntime-web/webgpu';
13
- import { evictModelFromCache, fetchModelBlob, retryAsync } from "../model-loader.js";
13
+ import { evictModelFromCache, fetchModelBlob, loadPinnedTokenizer } from "../model-loader.js";
14
14
  // ── Model config ──────────────────────────────────────────
15
15
  // Supply-chain pinning (T116): the model is fetched from an immutable
16
16
  // commit revision, never from mutable resolve/main, and the downloaded
@@ -26,6 +26,25 @@ export const GLINER_MODEL_REVISION = 'd21aad5b4a7ec82b3d0970fd1ac74a12c087d85e';
26
26
  export const GLINER_MODEL_SHA256 = '891589426ee96f2748b16439f44fad8c3f97e198e002a6637e58dee989500216';
27
27
  export const GLINER_MODEL_URL = `https://huggingface.co/knowledgator/gliner-pii-small-v1.0/resolve/${GLINER_MODEL_REVISION}/onnx/model_quint8.onnx`;
28
28
  const DEFAULT_MODEL_URL = GLINER_MODEL_URL;
29
+ /**
30
+ * Tokenizer files at the same pinned commit (T185): fetched through
31
+ * fetchModelBlob with SHA-256 + size, cached next to the model and handed to
32
+ * CoreEnv.buildTokenizer. Hashes re-measured against the resolve/<commit>
33
+ * URLs on 2026-09-24 (documentation/model-provenance.md).
34
+ */
35
+ export const GLINER_TOKENIZER_FILES = [
36
+ {
37
+ url: `https://huggingface.co/knowledgator/gliner-pii-small-v1.0/resolve/${GLINER_MODEL_REVISION}/tokenizer.json`,
38
+ sha256: '84b3a9b18f04a0ccd03b72d9f871b7e0bec40fd7021ef50bc30a7c3693c11205',
39
+ size: 3583593,
40
+ },
41
+ {
42
+ url: `https://huggingface.co/knowledgator/gliner-pii-small-v1.0/resolve/${GLINER_MODEL_REVISION}/tokenizer_config.json`,
43
+ sha256: '3398f6d1ad4b4c4f9874d390d060a75c58cad5e5ce9b22841b3e40643b4ada27',
44
+ size: 21214,
45
+ },
46
+ ];
47
+ const TOKENIZER_FILES = GLINER_TOKENIZER_FILES;
29
48
  /**
30
49
  * Superseded download URLs; their cache entries are evicted best-effort on
31
50
  * load so stale ~46 MB copies do not linger against the origin quota:
@@ -36,6 +55,7 @@ const LEGACY_MODEL_URLS = [
36
55
  'https://huggingface.co/knowledgator/gliner-pii-edge-v1.0/resolve/main/onnx/model_quint8.onnx',
37
56
  'https://huggingface.co/knowledgator/gliner-pii-edge-v1.0/resolve/9b7f39b0a2da971a5beea78d35f1539d4009c891/onnx/model_quint8.onnx',
38
57
  ];
58
+ /** Hugging Face id for the deprecated CoreEnv.loadTokenizer fallback only. */
39
59
  const DEFAULT_TOKENIZER_HF = 'knowledgator/gliner-pii-small-v1.0';
40
60
  const DEFAULT_MODEL_NAME = 'GLiNER PII Small';
41
61
  const CUSTOM_LABELS_STORAGE_KEY = 'doccloak-custom-labels';
@@ -236,7 +256,7 @@ export class GlinerProvider {
236
256
  }),
237
257
  ];
238
258
  if (!this.tokenizer) {
239
- tasks.push(retryAsync(() => this.env.loadTokenizer(DEFAULT_TOKENIZER_HF), 'Tokenizer download').then((t) => {
259
+ tasks.push(loadPinnedTokenizer(this.env, TOKENIZER_FILES, DEFAULT_TOKENIZER_HF).then((t) => {
240
260
  this.tokenizer = t;
241
261
  }));
242
262
  }
@@ -31,6 +31,8 @@ export interface RegexRuleJson {
31
31
  falsePositiveNotes?: string;
32
32
  /** Name of a post-match validation function in the validator registry */
33
33
  validate?: string;
34
+ /** Run against the whole document instead of line by line (default false) */
35
+ multiline?: boolean;
34
36
  }
35
37
  /** JSON shape of a whole rules/<region>.json file. */
36
38
  export interface RegionRulesJson {
@@ -20,6 +20,8 @@ function compileRule(region, json) {
20
20
  rule.examples = json.examples;
21
21
  if (json.falsePositiveNotes !== undefined)
22
22
  rule.falsePositiveNotes = json.falsePositiveNotes;
23
+ if (json.multiline !== undefined)
24
+ rule.multiline = json.multiline;
23
25
  if (json.validate !== undefined) {
24
26
  const validate = VALIDATORS[json.validate];
25
27
  if (validate === undefined) {