@doccloak/core 0.11.0 → 0.12.1
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.
- package/CHANGELOG.md +148 -2
- package/README.md +252 -19
- package/dist/doc.d.ts +68 -1
- package/dist/doc.js +447 -62
- package/dist/dom/docx.d.ts +135 -7
- package/dist/dom/docx.js +752 -125
- package/dist/dom/drawingml.d.ts +59 -0
- package/dist/dom/drawingml.js +176 -0
- package/dist/dom/errors.d.ts +35 -0
- package/dist/dom/errors.js +24 -0
- package/dist/dom/index.d.ts +3 -0
- package/dist/dom/index.js +6 -0
- package/dist/dom/office.d.ts +56 -0
- package/dist/dom/office.js +76 -23
- package/dist/dom/opc.d.ts +135 -7
- package/dist/dom/opc.js +407 -14
- package/dist/dom/package-policy.d.ts +137 -0
- package/dist/dom/package-policy.js +355 -0
- package/dist/dom/xlsx.d.ts +64 -6
- package/dist/dom/xlsx.js +130 -20
- package/dist/engine.d.ts +18 -0
- package/dist/engine.js +37 -13
- package/dist/env.d.ts +21 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.js +10 -5
- package/dist/model-loader.d.ts +61 -6
- package/dist/model-loader.js +122 -17
- package/dist/pipeline.d.ts +87 -8
- package/dist/pipeline.js +249 -50
- package/dist/providers/bardsai.d.ts +8 -1
- package/dist/providers/bardsai.js +22 -2
- package/dist/providers/gliner-base.d.ts +8 -1
- package/dist/providers/gliner-base.js +22 -2
- package/dist/providers/gliner.d.ts +8 -1
- package/dist/providers/gliner.js +22 -2
- package/dist/regex/loader.d.ts +2 -0
- package/dist/regex/loader.js +2 -0
- package/dist/regex/rules.data.js +15 -14
- package/dist/regex/types.d.ts +6 -0
- package/dist/session.d.ts +101 -3
- package/dist/session.js +397 -43
- package/dist/surrogates.js +57 -45
- package/dist/worker-protocol.d.ts +13 -1
- package/dist/worker-protocol.js +164 -12
- package/package.json +1 -1
- package/rules/at.json +1 -1
- package/rules/be.json +1 -1
- package/rules/ch.json +1 -1
- package/rules/de.json +1 -1
- package/rules/dk.json +1 -1
- package/rules/es.json +1 -1
- package/rules/fi.json +1 -1
- package/rules/fr.json +1 -1
- package/rules/it.json +1 -1
- package/rules/no.json +1 -1
- package/rules/pl.json +1 -1
- package/rules/pt.json +1 -1
- package/rules/schema.json +4 -0
- package/rules/se.json +1 -1
- package/rules/universal.json +2 -1
package/dist/dom/xlsx.js
CHANGED
|
@@ -15,18 +15,28 @@
|
|
|
15
15
|
* - worksheet header/footer definitions
|
|
16
16
|
* - legacy cell comments (xl/comments*.xml) and threaded comments
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* - drawing text boxes and chart titles / string caches (xl/drawings,
|
|
19
|
+
* xl/charts) through the DrawingML units (T175)
|
|
20
|
+
*
|
|
21
|
+
* Deliberately out of scope (T176): sheet names, defined-name formulas, table
|
|
22
|
+
* column names, pivot caches; layer zero scrubs known values there. Data
|
|
23
|
+
* connections and external links are reported as unredactable (T177).
|
|
24
|
+
* Metadata scrubbing follows the docx module's policy via the shared OPC
|
|
25
|
+
* helpers.
|
|
22
26
|
*/
|
|
23
27
|
import JSZip from 'jszip';
|
|
24
|
-
import { applyTextReplacements } from "./docx.js";
|
|
25
|
-
import {
|
|
28
|
+
import { applyTextReplacements, assertUnredactableAllowed, unredactableWarnings } from "./docx.js";
|
|
29
|
+
import { drawingUnits } from "./drawingml.js";
|
|
30
|
+
import { XLSX_DRAWING_TEXT_PARTS, MAX_UNPACKED_BYTES, assertUnpackedSize, collectUnredactableParts, } from "./package-policy.js";
|
|
31
|
+
import { OOXML_NS, isElementIn, elementsByLocalName, foreignNamespaces, readXmlPart, writeXmlPart, scrubDocPropsParts, normalizeZipEntries, removePackageThumbnail, scrubExternalRelTargets, replaceSensitiveValues, scrubPackageValues, } from "./opc.js";
|
|
26
32
|
import { getFileExtension } from "./docx.js";
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
import { UnsupportedDocumentError } from "./errors.js";
|
|
34
|
+
/** True for a SpreadsheetML element (transitional or strict namespace, T172). */
|
|
35
|
+
export function isSpreadsheetEl(el, localName) {
|
|
36
|
+
return isElementIn(el, OOXML_NS.ss, localName);
|
|
37
|
+
}
|
|
38
|
+
function isSpreadsheetNs(el) {
|
|
39
|
+
return el.namespaceURI !== null && OOXML_NS.ss.has(el.namespaceURI);
|
|
30
40
|
}
|
|
31
41
|
/** Collect descendant <t> elements (spreadsheet namespace) in document order. */
|
|
32
42
|
function collectTextLeaves(root) {
|
|
@@ -41,12 +51,51 @@ function collectTextLeaves(root) {
|
|
|
41
51
|
/** String units in xl/sharedStrings.xml: one per <si>. */
|
|
42
52
|
function sharedStringUnits(xmlDoc) {
|
|
43
53
|
const units = [];
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
units.push(collectTextLeaves(sis[i]));
|
|
54
|
+
for (const si of elementsByLocalName(xmlDoc, OOXML_NS.ss, 'si')) {
|
|
55
|
+
units.push(collectTextLeaves(si));
|
|
47
56
|
}
|
|
48
57
|
return units;
|
|
49
58
|
}
|
|
59
|
+
/** A cell number that can be an identifier: an integer of seven or more digits. */
|
|
60
|
+
const NUMERIC_IDENTIFIER = /^[+-]?\d{7,}$/;
|
|
61
|
+
/** What SpreadsheetML accepts as a numeric <v>. */
|
|
62
|
+
const NUMERIC_VALUE = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/;
|
|
63
|
+
/**
|
|
64
|
+
* After replacement, a numeric cell whose <v> no longer holds a number
|
|
65
|
+
* (it now carries a placeholder) would make Excel repair the workbook. Turn
|
|
66
|
+
* it into an inline string cell and drop its formula, which would otherwise
|
|
67
|
+
* recompute the original number (T176).
|
|
68
|
+
*/
|
|
69
|
+
function normalizeReplacedNumericCells(xmlDoc) {
|
|
70
|
+
for (const cell of elementsByLocalName(xmlDoc, OOXML_NS.ss, 'c')) {
|
|
71
|
+
const type = cell.getAttribute('t');
|
|
72
|
+
if (type !== null && type !== 'n')
|
|
73
|
+
continue;
|
|
74
|
+
let v = null;
|
|
75
|
+
let f = null;
|
|
76
|
+
for (let c = 0; c < cell.children.length; c++) {
|
|
77
|
+
const child = cell.children[c];
|
|
78
|
+
if (isSpreadsheetEl(child, 'v'))
|
|
79
|
+
v = child;
|
|
80
|
+
else if (isSpreadsheetEl(child, 'f'))
|
|
81
|
+
f = child;
|
|
82
|
+
}
|
|
83
|
+
if (!v)
|
|
84
|
+
continue;
|
|
85
|
+
const text = v.textContent ?? '';
|
|
86
|
+
if (text.length === 0 || NUMERIC_VALUE.test(text))
|
|
87
|
+
continue;
|
|
88
|
+
cell.setAttribute('t', 'inlineStr');
|
|
89
|
+
if (f)
|
|
90
|
+
cell.removeChild(f);
|
|
91
|
+
const is = xmlDoc.createElementNS(cell.namespaceURI, 'is');
|
|
92
|
+
const t = xmlDoc.createElementNS(cell.namespaceURI, 't');
|
|
93
|
+
t.setAttribute('xml:space', 'preserve');
|
|
94
|
+
t.textContent = text;
|
|
95
|
+
is.appendChild(t);
|
|
96
|
+
cell.replaceChild(is, v);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
50
99
|
const HEADER_FOOTER_NAMES = new Set([
|
|
51
100
|
'oddHeader', 'oddFooter', 'evenHeader', 'evenFooter', 'firstHeader', 'firstFooter',
|
|
52
101
|
]);
|
|
@@ -59,7 +108,7 @@ function worksheetUnits(xmlDoc) {
|
|
|
59
108
|
const all = xmlDoc.getElementsByTagName('*');
|
|
60
109
|
for (let i = 0; i < all.length; i++) {
|
|
61
110
|
const el = all[i];
|
|
62
|
-
if (el
|
|
111
|
+
if (!isSpreadsheetNs(el))
|
|
63
112
|
continue;
|
|
64
113
|
if (el.localName === 'c') {
|
|
65
114
|
const type = el.getAttribute('t');
|
|
@@ -76,6 +125,19 @@ function worksheetUnits(xmlDoc) {
|
|
|
76
125
|
units.push([child]);
|
|
77
126
|
}
|
|
78
127
|
}
|
|
128
|
+
else if (type === null || type === 'n') {
|
|
129
|
+
// Numeric cells (T176): a PESEL, phone or account number typed into
|
|
130
|
+
// a cell is stored as a number. Values of at least seven digits are
|
|
131
|
+
// extracted so the detectors see them; a replaced cell is turned
|
|
132
|
+
// into an inline string on write (normalizeReplacedNumericCells).
|
|
133
|
+
// Shorter numbers (amounts, counts, dates as serials) are not
|
|
134
|
+
// identifiers and stay out of the text.
|
|
135
|
+
for (let c = 0; c < el.children.length; c++) {
|
|
136
|
+
const child = el.children[c];
|
|
137
|
+
if (isSpreadsheetEl(child, 'v') && NUMERIC_IDENTIFIER.test(child.textContent ?? ''))
|
|
138
|
+
units.push([child]);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
79
141
|
}
|
|
80
142
|
else if (HEADER_FOOTER_NAMES.has(el.localName)) {
|
|
81
143
|
units.push([el]);
|
|
@@ -92,9 +154,8 @@ function commentUnits(xmlDoc) {
|
|
|
92
154
|
const units = [];
|
|
93
155
|
const root = xmlDoc.documentElement;
|
|
94
156
|
if (root.localName === 'comments') {
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
const leaves = collectTextLeaves(texts[i]);
|
|
157
|
+
for (const text of elementsByLocalName(xmlDoc, OOXML_NS.ss, 'text')) {
|
|
158
|
+
const leaves = collectTextLeaves(text);
|
|
98
159
|
if (leaves.length > 0)
|
|
99
160
|
units.push(leaves);
|
|
100
161
|
}
|
|
@@ -115,16 +176,19 @@ function commentUnits(xmlDoc) {
|
|
|
115
176
|
/**
|
|
116
177
|
* Read a .xlsx file and extract its text content with position mapping.
|
|
117
178
|
*/
|
|
118
|
-
export async function readXlsx(file) {
|
|
179
|
+
export async function readXlsx(file, options = {}) {
|
|
119
180
|
const arrayBuffer = await file.arrayBuffer();
|
|
120
181
|
const zip = await JSZip.loadAsync(arrayBuffer);
|
|
182
|
+
assertUnpackedSize(zip, options.maxUnpackedBytes ?? MAX_UNPACKED_BYTES);
|
|
121
183
|
if (!zip.file('xl/workbook.xml')) {
|
|
122
184
|
throw new Error('Invalid .xlsx file: missing xl/workbook.xml');
|
|
123
185
|
}
|
|
124
186
|
// Deterministic part order so load-time and export-time extractions produce
|
|
125
|
-
// identical offsets: sharedStrings, then worksheets, then comment parts
|
|
187
|
+
// identical offsets: sharedStrings, then worksheets, then comment parts,
|
|
188
|
+
// then drawing text boxes and charts (T175: a:p paragraphs, string caches).
|
|
126
189
|
const worksheetPaths = [];
|
|
127
190
|
const commentPaths = [];
|
|
191
|
+
const drawingPaths = [];
|
|
128
192
|
zip.forEach((relativePath) => {
|
|
129
193
|
if (/^xl\/worksheets\/[^/]+\.xml$/.test(relativePath)) {
|
|
130
194
|
worksheetPaths.push(relativePath);
|
|
@@ -133,20 +197,37 @@ export async function readXlsx(file) {
|
|
|
133
197
|
/^xl\/threadedComments\/[^/]+\.xml$/.test(relativePath)) {
|
|
134
198
|
commentPaths.push(relativePath);
|
|
135
199
|
}
|
|
200
|
+
else if (XLSX_DRAWING_TEXT_PARTS.test(relativePath)) {
|
|
201
|
+
drawingPaths.push(relativePath);
|
|
202
|
+
}
|
|
136
203
|
});
|
|
137
204
|
worksheetPaths.sort();
|
|
138
205
|
commentPaths.sort();
|
|
206
|
+
drawingPaths.sort();
|
|
139
207
|
const partPlans = [
|
|
140
208
|
{ path: 'xl/sharedStrings.xml', unitsOf: sharedStringUnits },
|
|
141
209
|
...worksheetPaths.map((path) => ({ path, unitsOf: worksheetUnits })),
|
|
142
210
|
...commentPaths.map((path) => ({ path, unitsOf: commentUnits })),
|
|
211
|
+
...drawingPaths.map((path) => ({ path, unitsOf: drawingUnits })),
|
|
143
212
|
];
|
|
144
213
|
let flatText = '';
|
|
145
214
|
const contentParts = [];
|
|
215
|
+
// Fail closed (H1): worksheets whose sheetData is not SpreadsheetML in a
|
|
216
|
+
// namespace we read (transitional or strict) while foreign elements exist
|
|
217
|
+
// are refused rather than copied through.
|
|
218
|
+
let sheetDataFound = false;
|
|
219
|
+
const foreign = new Set();
|
|
146
220
|
for (const plan of partPlans) {
|
|
147
221
|
const xmlDoc = await readXmlPart(zip, plan.path);
|
|
148
222
|
if (!xmlDoc)
|
|
149
223
|
continue;
|
|
224
|
+
if (plan.unitsOf === worksheetUnits) {
|
|
225
|
+
if (elementsByLocalName(xmlDoc, OOXML_NS.ss, 'sheetData').length > 0)
|
|
226
|
+
sheetDataFound = true;
|
|
227
|
+
else
|
|
228
|
+
for (const ns of foreignNamespaces(xmlDoc))
|
|
229
|
+
foreign.add(ns);
|
|
230
|
+
}
|
|
150
231
|
const textNodes = [];
|
|
151
232
|
for (const unit of plan.unitsOf(xmlDoc)) {
|
|
152
233
|
const nodes = [];
|
|
@@ -172,11 +253,20 @@ export async function readXlsx(file) {
|
|
|
172
253
|
}
|
|
173
254
|
contentParts.push({ path: plan.path, xmlDoc, textNodes });
|
|
174
255
|
}
|
|
256
|
+
if (worksheetPaths.length > 0 && !sheetDataFound && foreign.size > 0) {
|
|
257
|
+
throw new UnsupportedDocumentError('unrecognized-namespace', `Unsupported .xlsx: no SpreadsheetML sheetData in any worksheet, found elements in ${[...foreign].join(', ')}`, [...foreign]);
|
|
258
|
+
}
|
|
259
|
+
// Package policy (T177): parts the writer would copy verbatim are
|
|
260
|
+
// reported; the writer refuses them until the host passes allowUnredactable.
|
|
261
|
+
const unredactable = await collectUnredactableParts(zip, 'xlsx');
|
|
175
262
|
return {
|
|
176
263
|
plainText: flatText,
|
|
177
264
|
zip,
|
|
178
265
|
contentParts,
|
|
179
266
|
textNodes: contentParts.flatMap((part) => part.textNodes),
|
|
267
|
+
empty: flatText.trim().length === 0,
|
|
268
|
+
unredactable,
|
|
269
|
+
warnings: [],
|
|
180
270
|
};
|
|
181
271
|
}
|
|
182
272
|
/**
|
|
@@ -259,17 +349,37 @@ async function sanitizeXlsxMetadata(zip, valueReplacements) {
|
|
|
259
349
|
* Apply text replacements to the xlsx XML, preserving formatting, then scrub
|
|
260
350
|
* metadata. Returns a new .xlsx file as a Blob; the input file is untouched.
|
|
261
351
|
*/
|
|
262
|
-
export async function writeAnonymizedXlsx(extraction, replacements, valueReplacements = []) {
|
|
352
|
+
export async function writeAnonymizedXlsx(extraction, replacements, valueReplacements = [], options = {}) {
|
|
353
|
+
const result = await writeAnonymizedXlsxWithReport(extraction, replacements, valueReplacements, options);
|
|
354
|
+
return result.blob;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Same as writeAnonymizedXlsx, with the write report. After the content
|
|
358
|
+
* parts and metadata, layer zero (T172) scrubs every known value from every
|
|
359
|
+
* other XML/rels/vml part: pivot cache records, drawings, charts, tables,
|
|
360
|
+
* defined names in the workbook, docProps.
|
|
361
|
+
*/
|
|
362
|
+
export async function writeAnonymizedXlsxWithReport(extraction, replacements, valueReplacements = [], options = {}) {
|
|
363
|
+
assertUnredactableAllowed(extraction.unredactable, options.allowUnredactable);
|
|
263
364
|
applyTextReplacements(extraction.textNodes, replacements);
|
|
365
|
+
const contentPaths = new Set();
|
|
264
366
|
for (const part of extraction.contentParts) {
|
|
367
|
+
if (/^xl\/worksheets\//.test(part.path))
|
|
368
|
+
normalizeReplacedNumericCells(part.xmlDoc);
|
|
265
369
|
sanitizeXlsxPart(part.xmlDoc, valueReplacements);
|
|
266
370
|
writeXmlPart(extraction.zip, part.path, part.xmlDoc);
|
|
371
|
+
contentPaths.add(part.path);
|
|
267
372
|
}
|
|
268
373
|
await sanitizeXlsxMetadata(extraction.zip, valueReplacements);
|
|
269
|
-
|
|
374
|
+
const scrub = await scrubPackageValues(extraction.zip, valueReplacements, { skip: contentPaths });
|
|
375
|
+
// Container normalisation (T179): fixed entry dates, no comments.
|
|
376
|
+
normalizeZipEntries(extraction.zip);
|
|
377
|
+
const blob = await extraction.zip.generateAsync({
|
|
270
378
|
type: 'blob',
|
|
271
379
|
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
380
|
+
comment: '',
|
|
272
381
|
});
|
|
382
|
+
return { blob, warnings: [...scrub.warnings, ...unredactableWarnings(extraction.unredactable)] };
|
|
273
383
|
}
|
|
274
384
|
/**
|
|
275
385
|
* Check if a file is a supported Excel workbook (.xlsx only; the legacy
|
package/dist/engine.d.ts
CHANGED
|
@@ -28,6 +28,24 @@ export type ProviderFactory = (env: CoreEnv) => DetectionProvider;
|
|
|
28
28
|
export interface EngineOptions {
|
|
29
29
|
/** Override provider construction (tests inject deterministic stubs). */
|
|
30
30
|
providers?: Partial<Record<ProviderId, ProviderFactory>>;
|
|
31
|
+
/**
|
|
32
|
+
* Whether detect() may download and initialise the model on its own
|
|
33
|
+
* (T185, security report S9). Default true (previous behaviour). With
|
|
34
|
+
* false the engine never touches the network implicitly: detect() before
|
|
35
|
+
* a completed preload() (or switchProvider()) rejects with
|
|
36
|
+
* ModelNotLoadedError, while a preload that is still in flight is awaited.
|
|
37
|
+
* Hosts that gate model downloads behind user consent should set this to
|
|
38
|
+
* false and call preload() from the consent flow.
|
|
39
|
+
*/
|
|
40
|
+
autoLoad?: boolean;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* detect() was called while the active provider's model is not loaded and
|
|
44
|
+
* createEngine was given `autoLoad: false`. Call engine.preload() first.
|
|
45
|
+
*/
|
|
46
|
+
export declare class ModelNotLoadedError extends Error {
|
|
47
|
+
providerId: ProviderId;
|
|
48
|
+
constructor(providerId: ProviderId);
|
|
31
49
|
}
|
|
32
50
|
/**
|
|
33
51
|
* env.kv keys, byte-identical to the localStorage keys the web app used
|
package/dist/engine.js
CHANGED
|
@@ -36,6 +36,17 @@ export const PROVIDERS = [
|
|
|
36
36
|
description: 'Best multilingual accuracy, 24+ EU languages, 35 PII types (~279 MB)',
|
|
37
37
|
},
|
|
38
38
|
];
|
|
39
|
+
/**
|
|
40
|
+
* detect() was called while the active provider's model is not loaded and
|
|
41
|
+
* createEngine was given `autoLoad: false`. Call engine.preload() first.
|
|
42
|
+
*/
|
|
43
|
+
export class ModelNotLoadedError extends Error {
|
|
44
|
+
constructor(providerId) {
|
|
45
|
+
super(`Model for provider "${providerId}" is not loaded; call engine.preload() before detect() (createEngine autoLoad is off)`);
|
|
46
|
+
this.name = 'ModelNotLoadedError';
|
|
47
|
+
this.providerId = providerId;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
39
50
|
// ── Settings ───────────────────────────────────────────────
|
|
40
51
|
/**
|
|
41
52
|
* env.kv keys, byte-identical to the localStorage keys the web app used
|
|
@@ -95,10 +106,13 @@ export function createEngine(env, initial, options) {
|
|
|
95
106
|
bardsai: (e) => new BardsaiProvider(e),
|
|
96
107
|
...options?.providers,
|
|
97
108
|
};
|
|
109
|
+
const autoLoad = options?.autoLoad ?? true;
|
|
98
110
|
const settings = {
|
|
99
111
|
providerId: initial?.providerId ?? pickDefaultProvider(env.hardware),
|
|
100
112
|
threshold: 0,
|
|
101
|
-
|
|
113
|
+
// Regex rules are on unless the host or a saved setting turns them off
|
|
114
|
+
// (T203): structured identifiers must not depend on the model alone.
|
|
115
|
+
regexEnabled: initial?.regexEnabled ?? true,
|
|
102
116
|
regexRegion: initial?.regexRegion ?? 'all',
|
|
103
117
|
customLabels: [...(initial?.customLabels ?? [])],
|
|
104
118
|
};
|
|
@@ -175,14 +189,16 @@ export function createEngine(env, initial, options) {
|
|
|
175
189
|
await p.setCustomLabels(settings.customLabels);
|
|
176
190
|
}
|
|
177
191
|
}
|
|
178
|
-
|
|
179
|
-
|
|
192
|
+
function preload() {
|
|
193
|
+
// inflightLoad is assigned synchronously so a detect() issued right
|
|
194
|
+
// after preload() (without awaiting it) sees the load in flight.
|
|
180
195
|
if (inflightLoad)
|
|
181
196
|
return inflightLoad;
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
197
|
+
const run = (async () => {
|
|
198
|
+
await ready;
|
|
199
|
+
const p = ensureProvider();
|
|
200
|
+
if (p.isLoaded())
|
|
201
|
+
return;
|
|
186
202
|
// A previous attempt may have failed: release() clears the cached load
|
|
187
203
|
// error so load() can retry (model bytes are served from the blob
|
|
188
204
|
// cache when available). Mirrors the old worker init path.
|
|
@@ -194,12 +210,11 @@ export function createEngine(env, initial, options) {
|
|
|
194
210
|
if (supportsCustomLabels(p))
|
|
195
211
|
settings.customLabels = p.getCustomLabels();
|
|
196
212
|
})();
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
213
|
+
inflightLoad = run;
|
|
214
|
+
return run.finally(() => {
|
|
215
|
+
if (inflightLoad === run)
|
|
216
|
+
inflightLoad = null;
|
|
217
|
+
});
|
|
203
218
|
}
|
|
204
219
|
async function switchProvider(id) {
|
|
205
220
|
await ready;
|
|
@@ -254,6 +269,15 @@ export function createEngine(env, initial, options) {
|
|
|
254
269
|
if (!text.trim())
|
|
255
270
|
return [];
|
|
256
271
|
const p = ensureProvider();
|
|
272
|
+
if (!autoLoad && !p.isLoaded()) {
|
|
273
|
+
// Explicit-preload mode: never download from detect(). A preload that
|
|
274
|
+
// is already running is awaited; otherwise the host must call it.
|
|
275
|
+
if (!inflightLoad)
|
|
276
|
+
throw new ModelNotLoadedError(settings.providerId);
|
|
277
|
+
await inflightLoad;
|
|
278
|
+
if (!p.isLoaded())
|
|
279
|
+
throw new ModelNotLoadedError(settings.providerId);
|
|
280
|
+
}
|
|
257
281
|
const mlResults = await p.detect(text, (progress) => {
|
|
258
282
|
onProgress?.(progress);
|
|
259
283
|
for (const cb of detectionListeners)
|
package/dist/env.d.ts
CHANGED
|
@@ -31,7 +31,27 @@ export interface CoreEnv {
|
|
|
31
31
|
paths: string;
|
|
32
32
|
numThreads?: number;
|
|
33
33
|
};
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Build a tokenizer from the parsed contents of the provider's pinned
|
|
36
|
+
* `tokenizer.json` and `tokenizer_config.json` (T185). Core fetches both
|
|
37
|
+
* files itself through fetchModelBlob (immutable resolve/<commit> URL,
|
|
38
|
+
* SHA-256 + size check, blob cache, resume and retry) and only hands the
|
|
39
|
+
* verified, parsed objects here; the host never contacts the network for
|
|
40
|
+
* tokenizers. With @huggingface/transformers this is
|
|
41
|
+
* `new PreTrainedTokenizer(tokenizerJson, tokenizerConfig)` (or the class
|
|
42
|
+
* named by `tokenizerConfig.tokenizer_class`). May return a promise.
|
|
43
|
+
*
|
|
44
|
+
* Optional in 0.12.0 only so hosts still on loadTokenizer keep compiling;
|
|
45
|
+
* required from 0.13.0.
|
|
46
|
+
*/
|
|
47
|
+
buildTokenizer?(tokenizerJson: unknown, tokenizerConfig: unknown): unknown | Promise<unknown>;
|
|
48
|
+
/**
|
|
49
|
+
* @deprecated since 0.12.0, removed in 0.13.0. Used only when
|
|
50
|
+
* buildTokenizer is absent. Loading by Hugging Face model id lets the
|
|
51
|
+
* host library probe mutable `resolve/main` and keep an unverified
|
|
52
|
+
* second cache (security report S1/S3); implement buildTokenizer.
|
|
53
|
+
*/
|
|
54
|
+
loadTokenizer?(hfModelId: string): Promise<unknown>;
|
|
35
55
|
hardware?: HardwareHints;
|
|
36
56
|
persistStorage?: () => Promise<boolean>;
|
|
37
57
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,21 +4,23 @@
|
|
|
4
4
|
export type { KVStore, BlobCache, HardwareHints, CoreEnv, MemoryBlobCacheOptions, } from './env.js';
|
|
5
5
|
export { memoryKV, memoryBlobCache } from './env.js';
|
|
6
6
|
export declare const CORE_PACKAGE_NAME = "@doccloak/core";
|
|
7
|
-
export { fetchModelBlob, evictModelFromCache, retryAsync, ModelIntegrityError, verificationMarkerKey } from './model-loader.js';
|
|
8
|
-
export type { ModelLoaderEnv, DownloadProgress, FetchModelOptions, ModelVerification } from './model-loader.js';
|
|
7
|
+
export { fetchModelBlob, evictModelFromCache, retryAsync, ModelIntegrityError, verificationMarkerKey, verificationMarkerValue, parseVerificationMarker, fetchPinnedJson, loadPinnedTokenizer, } from './model-loader.js';
|
|
8
|
+
export type { ModelLoaderEnv, DownloadProgress, FetchModelOptions, ModelVerification, TokenizerFileSpec, TokenizerFiles, PinnedTokenizerOptions, } from './model-loader.js';
|
|
9
9
|
export { ALL_REGEX_RULES, REGEX_REGIONS } from './regex/index.js';
|
|
10
10
|
export type { RegexRule, PiiDomain, RegionCode, RegexRegionId } from './regex/index.js';
|
|
11
11
|
export * from './types.js';
|
|
12
12
|
export * from './session.js';
|
|
13
13
|
export * from './doc.js';
|
|
14
|
+
export { UnsupportedDocumentError, isUnsupportedDocumentError } from './dom/errors.js';
|
|
15
|
+
export type { UnsupportedDocumentCode } from './dom/errors.js';
|
|
14
16
|
export { generateSurrogate, generateUniqueSurrogate, generateSessionSalt, sessionDayOffset, FAKE_EMAIL_DOMAINS, } from './surrogates.js';
|
|
15
17
|
export type { SurrogateContext } from './surrogates.js';
|
|
16
|
-
export { GlinerProvider, GLINER_MODEL_URL, GLINER_MODEL_REVISION, GLINER_MODEL_SHA256 } from './providers/gliner.js';
|
|
17
|
-
export { GlinerBaseProvider, GLINER_BASE_MODEL_URL, GLINER_BASE_MODEL_REVISION, GLINER_BASE_MODEL_SHA256, GLINER_BASE_MAX_WIDTH, buildSpanIndices, decodeSpanLogits, } from './providers/gliner-base.js';
|
|
18
|
+
export { GlinerProvider, GLINER_MODEL_URL, GLINER_MODEL_REVISION, GLINER_MODEL_SHA256, GLINER_TOKENIZER_FILES, } from './providers/gliner.js';
|
|
19
|
+
export { GlinerBaseProvider, GLINER_BASE_MODEL_URL, GLINER_BASE_MODEL_REVISION, GLINER_BASE_MODEL_SHA256, GLINER_BASE_TOKENIZER_FILES, GLINER_BASE_MAX_WIDTH, buildSpanIndices, decodeSpanLogits, } from './providers/gliner-base.js';
|
|
18
20
|
export type { SpanIndices } from './providers/gliner-base.js';
|
|
19
|
-
export { BardsaiProvider, BARDSAI_MODEL_URL, BARDSAI_MODEL_REVISION, BARDSAI_MODEL_SHA256 } from './providers/bardsai.js';
|
|
21
|
+
export { BardsaiProvider, BARDSAI_MODEL_URL, BARDSAI_MODEL_REVISION, BARDSAI_MODEL_SHA256, BARDSAI_TOKENIZER_FILES, } from './providers/bardsai.js';
|
|
20
22
|
export { detectWithRegex, resolveOverlaps, filterFalsePositives, propagateEntities, detectEntities, } from './pipeline.js';
|
|
21
|
-
export { createEngine, PROVIDERS, pickDefaultProvider, defaultThresholdFor, clampThreshold, ENGINE_SETTINGS_KEYS, } from './engine.js';
|
|
23
|
+
export { createEngine, ModelNotLoadedError, PROVIDERS, pickDefaultProvider, defaultThresholdFor, clampThreshold, ENGINE_SETTINGS_KEYS, } from './engine.js';
|
|
22
24
|
export type { ProviderId, ProviderEntry, ProviderFactory, EngineOptions, EngineSettings, DocCloakEngine, } from './engine.js';
|
|
23
25
|
export { serveEngine, connectEngine } from './worker-protocol.js';
|
|
24
26
|
export type { PortLike, EngineClient, EngineRequest, EngineResponse, } from './worker-protocol.js';
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
export { memoryKV, memoryBlobCache } from "./env.js";
|
|
5
5
|
export const CORE_PACKAGE_NAME = '@doccloak/core';
|
|
6
6
|
// T006: model loader
|
|
7
|
-
export { fetchModelBlob, evictModelFromCache, retryAsync, ModelIntegrityError, verificationMarkerKey } from "./model-loader.js";
|
|
7
|
+
export { fetchModelBlob, evictModelFromCache, retryAsync, ModelIntegrityError, verificationMarkerKey, verificationMarkerValue, parseVerificationMarker, fetchPinnedJson, loadPinnedTokenizer, } from "./model-loader.js";
|
|
8
8
|
// end T006
|
|
9
9
|
// T004: regex rules (REGEX_REGIONS moved here from the web engine in T009)
|
|
10
10
|
export { ALL_REGEX_RULES, REGEX_REGIONS } from "./regex/index.js";
|
|
@@ -14,22 +14,27 @@ export * from "./types.js";
|
|
|
14
14
|
export * from "./session.js";
|
|
15
15
|
export * from "./doc.js";
|
|
16
16
|
// end T003
|
|
17
|
+
// T178: readDocText / writeAnonymizedDoc refuse fast-saved and encrypted .doc
|
|
18
|
+
// files with this error (also exported from '@doccloak/core/dom'); re-exported
|
|
19
|
+
// here so hosts using only the main entry can switch on `code`.
|
|
20
|
+
export { UnsupportedDocumentError, isUnsupportedDocumentError } from "./dom/errors.js";
|
|
21
|
+
// end T178
|
|
17
22
|
// T043: surrogate generator (realistic, shape-preserving replacements)
|
|
18
23
|
export { generateSurrogate, generateUniqueSurrogate, generateSessionSalt, sessionDayOffset, FAKE_EMAIL_DOMAINS, } from "./surrogates.js";
|
|
19
24
|
// end T043
|
|
20
25
|
// T007: gliner provider
|
|
21
|
-
export { GlinerProvider, GLINER_MODEL_URL, GLINER_MODEL_REVISION, GLINER_MODEL_SHA256 } from "./providers/gliner.js";
|
|
26
|
+
export { GlinerProvider, GLINER_MODEL_URL, GLINER_MODEL_REVISION, GLINER_MODEL_SHA256, GLINER_TOKENIZER_FILES, } from "./providers/gliner.js";
|
|
22
27
|
// end T007
|
|
23
28
|
// T122: gliner base provider (markerV0 span mode)
|
|
24
|
-
export { GlinerBaseProvider, GLINER_BASE_MODEL_URL, GLINER_BASE_MODEL_REVISION, GLINER_BASE_MODEL_SHA256, GLINER_BASE_MAX_WIDTH, buildSpanIndices, decodeSpanLogits, } from "./providers/gliner-base.js";
|
|
29
|
+
export { GlinerBaseProvider, GLINER_BASE_MODEL_URL, GLINER_BASE_MODEL_REVISION, GLINER_BASE_MODEL_SHA256, GLINER_BASE_TOKENIZER_FILES, GLINER_BASE_MAX_WIDTH, buildSpanIndices, decodeSpanLogits, } from "./providers/gliner-base.js";
|
|
25
30
|
// end T122
|
|
26
31
|
// T008: bardsai provider
|
|
27
|
-
export { BardsaiProvider, BARDSAI_MODEL_URL, BARDSAI_MODEL_REVISION, BARDSAI_MODEL_SHA256 } from "./providers/bardsai.js";
|
|
32
|
+
export { BardsaiProvider, BARDSAI_MODEL_URL, BARDSAI_MODEL_REVISION, BARDSAI_MODEL_SHA256, BARDSAI_TOKENIZER_FILES, } from "./providers/bardsai.js";
|
|
28
33
|
// end T008
|
|
29
34
|
// T005: pipeline
|
|
30
35
|
export { detectWithRegex, resolveOverlaps, filterFalsePositives, propagateEntities, detectEntities, } from "./pipeline.js";
|
|
31
36
|
// end T005
|
|
32
37
|
// T009: engine assembly + worker protocol
|
|
33
|
-
export { createEngine, PROVIDERS, pickDefaultProvider, defaultThresholdFor, clampThreshold, ENGINE_SETTINGS_KEYS, } from "./engine.js";
|
|
38
|
+
export { createEngine, ModelNotLoadedError, PROVIDERS, pickDefaultProvider, defaultThresholdFor, clampThreshold, ENGINE_SETTINGS_KEYS, } from "./engine.js";
|
|
34
39
|
export { serveEngine, connectEngine } from "./worker-protocol.js";
|
|
35
40
|
// end T009
|
package/dist/model-loader.d.ts
CHANGED
|
@@ -17,13 +17,18 @@
|
|
|
17
17
|
* - Verifies downloads against a pinned SHA-256 when the caller provides one
|
|
18
18
|
* (supply-chain hardening, T116): mismatches evict the cache entry and
|
|
19
19
|
* reject with ModelIntegrityError; verified cache entries carry a marker
|
|
20
|
-
* so they are not re-hashed on every startup.
|
|
20
|
+
* so they are not re-hashed on every startup. The marker records the
|
|
21
|
+
* verified byte size (T185): a cache hit whose blob size no longer matches
|
|
22
|
+
* the marker is re-hashed instead of trusted.
|
|
23
|
+
* - Fetches the pinned tokenizer files (tokenizer.json, tokenizer_config.json)
|
|
24
|
+
* through the same path (loadPinnedTokenizer, T185) so tokenizers get the
|
|
25
|
+
* same resume, retry, cache and integrity treatment as the model.
|
|
21
26
|
*
|
|
22
27
|
* Environment-agnostic: all host capabilities (blob cache, fetch, persistent
|
|
23
28
|
* storage request) are injected via ModelLoaderEnv. This module never touches
|
|
24
29
|
* caches, navigator or global fetch directly.
|
|
25
30
|
*/
|
|
26
|
-
import type { BlobCache } from './env.js';
|
|
31
|
+
import type { BlobCache, CoreEnv } from './env.js';
|
|
27
32
|
export type DownloadProgress = (downloaded: number, total: number) => void;
|
|
28
33
|
/**
|
|
29
34
|
* Host capabilities the loader needs. Hosts pass the relevant slice of their
|
|
@@ -64,6 +69,19 @@ export interface ModelVerification {
|
|
|
64
69
|
* written for the previous pin.
|
|
65
70
|
*/
|
|
66
71
|
export declare function verificationMarkerKey(url: string, sha256: string): string;
|
|
72
|
+
/**
|
|
73
|
+
* Value stored in a verification marker: `${sha256}:${size}`, binding the
|
|
74
|
+
* marker to the byte size of the blob that was actually hashed (T185, S4).
|
|
75
|
+
* A cache hit is only trusted without re-hashing when the cached blob's
|
|
76
|
+
* size equals the recorded one. Markers written by 0.11.0 hold the single
|
|
77
|
+
* byte '1' (size unknown): they are upgraded by hashing once and rewriting.
|
|
78
|
+
*/
|
|
79
|
+
export declare function verificationMarkerValue(sha256: string, size: number): string;
|
|
80
|
+
/**
|
|
81
|
+
* Size recorded in a marker value for the given hash, or null when the
|
|
82
|
+
* marker is the legacy 0.11.0 '1' or otherwise unreadable (size unknown).
|
|
83
|
+
*/
|
|
84
|
+
export declare function parseVerificationMarker(value: string, sha256: string): number | null;
|
|
67
85
|
export interface FetchModelOptions {
|
|
68
86
|
maxAttempts?: number;
|
|
69
87
|
retryBaseDelayMs?: number;
|
|
@@ -76,6 +94,13 @@ export interface FetchModelOptions {
|
|
|
76
94
|
* marked verified or evicted and re-downloaded.
|
|
77
95
|
*/
|
|
78
96
|
sha256?: string;
|
|
97
|
+
/**
|
|
98
|
+
* Pinned byte size of the file. With sha256 it is cross-checked against
|
|
99
|
+
* every download (a size mismatch is an integrity failure like a hash
|
|
100
|
+
* mismatch). It also serves as the progress total when the server sends
|
|
101
|
+
* no content-length. Without sha256 it is only a progress hint.
|
|
102
|
+
*/
|
|
103
|
+
size?: number;
|
|
79
104
|
/** Fired once the served blob is known to match the pinned sha256. */
|
|
80
105
|
onVerified?: (verification: ModelVerification) => void;
|
|
81
106
|
}
|
|
@@ -90,9 +115,39 @@ export declare function fetchModelBlob(env: ModelLoaderEnv, url: string, onProgr
|
|
|
90
115
|
*/
|
|
91
116
|
export declare function evictModelFromCache(env: ModelLoaderEnv, url: string, sha256?: string): Promise<void>;
|
|
92
117
|
/**
|
|
93
|
-
* Retry an async operation with exponential backoff.
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
118
|
+
* Retry an async operation with exponential backoff. Kept for the
|
|
119
|
+
* deprecated CoreEnv.loadTokenizer path (a host library without its own
|
|
120
|
+
* retry handling) and for hosts composing their own downloads; the pinned
|
|
121
|
+
* tokenizer files now go through fetchModelBlob via loadPinnedTokenizer.
|
|
97
122
|
*/
|
|
98
123
|
export declare function retryAsync<T>(operation: () => Promise<T>, label: string, options?: FetchModelOptions): Promise<T>;
|
|
124
|
+
/** One pinned companion file: immutable resolve/<commit> URL, SHA-256 and byte size. */
|
|
125
|
+
export interface TokenizerFileSpec {
|
|
126
|
+
url: string;
|
|
127
|
+
/** Lowercase hex SHA-256 of the file at the pinned commit. */
|
|
128
|
+
sha256: string;
|
|
129
|
+
/** Byte size of the file at the pinned commit. */
|
|
130
|
+
size: number;
|
|
131
|
+
}
|
|
132
|
+
/** The two files a provider pins for its tokenizer: tokenizer.json, then tokenizer_config.json. */
|
|
133
|
+
export type TokenizerFiles = readonly [tokenizerJson: TokenizerFileSpec, tokenizerConfig: TokenizerFileSpec];
|
|
134
|
+
export type PinnedTokenizerOptions = Pick<FetchModelOptions, 'maxAttempts' | 'retryBaseDelayMs' | 'onVerified'>;
|
|
135
|
+
/**
|
|
136
|
+
* Download (or serve from cache) one pinned JSON file through fetchModelBlob
|
|
137
|
+
* with its sha256 and size, then parse it. Integrity failures surface as
|
|
138
|
+
* ModelIntegrityError with the entry evicted, like the model itself.
|
|
139
|
+
*/
|
|
140
|
+
export declare function fetchPinnedJson(env: ModelLoaderEnv, file: TokenizerFileSpec, options?: PinnedTokenizerOptions): Promise<unknown>;
|
|
141
|
+
/**
|
|
142
|
+
* Resolve a provider's tokenizer through the host env.
|
|
143
|
+
*
|
|
144
|
+
* Preferred path: fetch both pinned files via fetchPinnedJson (resume,
|
|
145
|
+
* retry, blob cache, verification marker, ModelIntegrityError) and hand the
|
|
146
|
+
* parsed objects to CoreEnv.buildTokenizer. No library probes mutable
|
|
147
|
+
* resolve/main, no second cache bucket, and a warm cache works offline.
|
|
148
|
+
*
|
|
149
|
+
* Fallback (deprecated, removed in 0.13.0): hosts that only implement
|
|
150
|
+
* CoreEnv.loadTokenizer(hfModelId) are still served through it with the
|
|
151
|
+
* previous retry wrapper.
|
|
152
|
+
*/
|
|
153
|
+
export declare function loadPinnedTokenizer(env: CoreEnv, files: TokenizerFiles, legacyHfModelId: string, options?: PinnedTokenizerOptions): Promise<unknown>;
|