@doccloak/core 0.9.0 → 0.11.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.
@@ -0,0 +1,159 @@
1
+ /**
2
+ * @doccloak/core/dom - shared OPC (Open Packaging Conventions) helpers (T110).
3
+ *
4
+ * Both .docx and .xlsx are OPC packages: a zip with XML parts, docProps
5
+ * metadata, relationship files and an optional thumbnail. The metadata
6
+ * scrubbing that is format-independent lives here so the xlsx module does not
7
+ * duplicate the docx module's logic. The docx module keeps its historical
8
+ * implementation untouched (behavior-preservation for existing callers); new
9
+ * code paths use these helpers.
10
+ */
11
+ /** Parse one XML part from the zip; null when absent or malformed. */
12
+ export async function readXmlPart(zip, path) {
13
+ const file = zip.file(path);
14
+ if (!file)
15
+ return null;
16
+ const xmlStr = await file.async('string');
17
+ const doc = new DOMParser().parseFromString(xmlStr, 'application/xml');
18
+ if (doc.getElementsByTagName('parsererror').length > 0)
19
+ return null;
20
+ return doc;
21
+ }
22
+ /** Serialize an XML document back into the zip at the given path. */
23
+ export function writeXmlPart(zip, path, doc) {
24
+ zip.file(path, new XMLSerializer().serializeToString(doc));
25
+ }
26
+ /** Blank the text content of every element whose local name is in the set. */
27
+ export function blankElementsByLocalName(doc, names) {
28
+ const all = doc.getElementsByTagName('*');
29
+ for (let i = 0; i < all.length; i++) {
30
+ if (names.has(all[i].localName))
31
+ all[i].textContent = '';
32
+ }
33
+ }
34
+ /**
35
+ * Replace every occurrence of each sensitive value (case-insensitive) in a
36
+ * string. A replacer function keeps '$' sequences in values/placeholders inert.
37
+ *
38
+ * Longer values are applied first so a name variant ("Kowalski") can never
39
+ * pre-empt the fuller form it is part of ("Jan Kowalski") and leave the rest
40
+ * of it behind. When the input is a URL (urlEncode), the percent-encoded form
41
+ * of each value is matched too, so "Jan%20Kowalski" in a hyperlink target is
42
+ * scrubbed as a whole.
43
+ */
44
+ export function replaceSensitiveValues(input, valueReplacements, urlEncode) {
45
+ let result = input;
46
+ const ordered = [...valueReplacements]
47
+ .filter(({ value }) => value)
48
+ .sort((a, b) => b.value.length - a.value.length);
49
+ for (const { value, replacement } of ordered) {
50
+ const forms = [value];
51
+ if (urlEncode) {
52
+ const encoded = encodeURIComponent(value);
53
+ if (encoded !== value)
54
+ forms.push(encoded);
55
+ }
56
+ const pattern = forms.map((form) => form.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
57
+ const substitute = urlEncode ? encodeURIComponent(replacement) : replacement;
58
+ result = result.replace(new RegExp(pattern, 'gi'), () => substitute);
59
+ }
60
+ return result;
61
+ }
62
+ /**
63
+ * Scrub the shared docProps parts:
64
+ * - core.xml: author identities and content-describing fields
65
+ * - app.xml: organization and template/path hints
66
+ * - custom.xml: every leaf value (property names/structure kept)
67
+ */
68
+ export async function scrubDocPropsParts(zip) {
69
+ const core = await readXmlPart(zip, 'docProps/core.xml');
70
+ if (core) {
71
+ blankElementsByLocalName(core, new Set([
72
+ 'creator', 'lastModifiedBy', 'title', 'subject', 'description', 'keywords', 'category',
73
+ ]));
74
+ writeXmlPart(zip, 'docProps/core.xml', core);
75
+ }
76
+ const app = await readXmlPart(zip, 'docProps/app.xml');
77
+ if (app) {
78
+ blankElementsByLocalName(app, new Set(['Company', 'Manager', 'HyperlinkBase', 'Template']));
79
+ writeXmlPart(zip, 'docProps/app.xml', app);
80
+ }
81
+ const custom = await readXmlPart(zip, 'docProps/custom.xml');
82
+ if (custom) {
83
+ const props = custom.getElementsByTagName('*');
84
+ for (let i = 0; i < props.length; i++) {
85
+ const el = props[i];
86
+ if (el.localName !== 'property' && el.childElementCount === 0 && (el.textContent ?? '') !== '') {
87
+ el.textContent = '';
88
+ }
89
+ }
90
+ writeXmlPart(zip, 'docProps/custom.xml', custom);
91
+ }
92
+ }
93
+ /**
94
+ * Remove the package thumbnail (a rendered image of page/sheet one leaks
95
+ * content wholesale) together with its package relationship and content-type
96
+ * override.
97
+ */
98
+ export async function removePackageThumbnail(zip) {
99
+ const thumbnailPaths = [];
100
+ zip.forEach((relativePath) => {
101
+ if (/^docProps\/thumbnail\.[a-z]+$/i.test(relativePath)) {
102
+ thumbnailPaths.push(relativePath);
103
+ }
104
+ });
105
+ if (thumbnailPaths.length === 0)
106
+ return;
107
+ for (const path of thumbnailPaths)
108
+ zip.remove(path);
109
+ const rootRels = await readXmlPart(zip, '_rels/.rels');
110
+ if (rootRels) {
111
+ const relationships = rootRels.getElementsByTagName('Relationship');
112
+ for (let i = relationships.length - 1; i >= 0; i--) {
113
+ const type = relationships[i].getAttribute('Type') ?? '';
114
+ if (type.endsWith('/thumbnail')) {
115
+ relationships[i].parentNode?.removeChild(relationships[i]);
116
+ }
117
+ }
118
+ writeXmlPart(zip, '_rels/.rels', rootRels);
119
+ }
120
+ const contentTypes = await readXmlPart(zip, '[Content_Types].xml');
121
+ if (contentTypes) {
122
+ const overrides = contentTypes.getElementsByTagName('Override');
123
+ for (let i = overrides.length - 1; i >= 0; i--) {
124
+ const partName = overrides[i].getAttribute('PartName') ?? '';
125
+ if (thumbnailPaths.some((p) => partName === '/' + p)) {
126
+ overrides[i].parentNode?.removeChild(overrides[i]);
127
+ }
128
+ }
129
+ writeXmlPart(zip, '[Content_Types].xml', contentTypes);
130
+ }
131
+ }
132
+ /**
133
+ * Scrub external relationship targets (mailto:/https: hyperlinks keep the raw
134
+ * sensitive value even when the display text was redacted) in every .rels file
135
+ * matched by the given pattern.
136
+ */
137
+ export async function scrubExternalRelTargets(zip, relsPattern, valueReplacements) {
138
+ const relsPaths = [];
139
+ zip.forEach((relativePath) => {
140
+ if (relsPattern.test(relativePath))
141
+ relsPaths.push(relativePath);
142
+ });
143
+ for (const relsPath of relsPaths.sort()) {
144
+ const rels = await readXmlPart(zip, relsPath);
145
+ if (!rels)
146
+ continue;
147
+ const relationships = rels.getElementsByTagName('Relationship');
148
+ for (let i = relationships.length - 1; i >= 0; i--) {
149
+ const rel = relationships[i];
150
+ if (rel.getAttribute('TargetMode') === 'External') {
151
+ const target = rel.getAttribute('Target') ?? '';
152
+ const scrubbed = replaceSensitiveValues(target, valueReplacements, true);
153
+ if (scrubbed !== target)
154
+ rel.setAttribute('Target', scrubbed);
155
+ }
156
+ }
157
+ writeXmlPart(zip, relsPath, rels);
158
+ }
159
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @doccloak/core/dom - xlsx read/write (T110, file redaction phase 1).
3
+ *
4
+ * Minimal zip+XML implementation on the package's existing JSZip + DOMParser
5
+ * machinery (the same stack the docx module uses). SheetJS was considered and
6
+ * rejected at claim time: the community edition is Apache-2.0 but is a heavy
7
+ * dependency distributed outside npm since 0.18.x (vendor CDN), and DocCloak
8
+ * only needs string-cell rewriting, not formula evaluation or format
9
+ * conversion. The zip+XML path keeps the dependency surface unchanged.
10
+ *
11
+ * Text-bearing locations covered:
12
+ * - xl/sharedStrings.xml <si> entries (plain and rich-text runs, phonetic runs)
13
+ * - worksheet inline strings (<c t="inlineStr"><is>...)
14
+ * - worksheet cached formula string results (<c t="str"><v>)
15
+ * - worksheet header/footer definitions
16
+ * - legacy cell comments (xl/comments*.xml) and threaded comments
17
+ *
18
+ * Deliberately out of scope in phase 1 (documented for T-later): drawing/chart
19
+ * text parts (xl/drawings, xl/charts), data connection definitions
20
+ * (xl/connections.xml) and defined-name formulas. Metadata scrubbing follows
21
+ * the docx module's policy via the shared OPC helpers.
22
+ */
23
+ import JSZip from 'jszip';
24
+ import type { ValueReplacement } from '../docx.js';
25
+ import type { TextNodeMapping } from './docx.js';
26
+ interface XlsxContentPart {
27
+ path: string;
28
+ xmlDoc: Document;
29
+ textNodes: TextNodeMapping[];
30
+ }
31
+ /**
32
+ * Result of extracting text from an xlsx file. Mirrors DocxExtraction: a flat
33
+ * plain text plus a mapping from flat positions back to XML text elements, so
34
+ * detection offsets can be applied surgically without touching formatting.
35
+ */
36
+ export interface XlsxExtraction {
37
+ /** Flat plain text: one line per string unit (shared string, cell, comment) */
38
+ plainText: string;
39
+ /** The JSZip instance for re-packaging */
40
+ zip: JSZip;
41
+ /** All text-bearing parts with their per-part node mappings */
42
+ contentParts: XlsxContentPart[];
43
+ /** All text node mappings across parts, in flat-text order */
44
+ textNodes: TextNodeMapping[];
45
+ }
46
+ /**
47
+ * Read a .xlsx file and extract its text content with position mapping.
48
+ */
49
+ export declare function readXlsx(file: File): Promise<XlsxExtraction>;
50
+ /**
51
+ * Apply text replacements to the xlsx XML, preserving formatting, then scrub
52
+ * metadata. Returns a new .xlsx file as a Blob; the input file is untouched.
53
+ */
54
+ export declare function writeAnonymizedXlsx(extraction: XlsxExtraction, replacements: Array<{
55
+ start: number;
56
+ end: number;
57
+ replacement: string;
58
+ }>, valueReplacements?: ValueReplacement[]): Promise<Blob>;
59
+ /**
60
+ * Check if a file is a supported Excel workbook (.xlsx only; the legacy
61
+ * binary .xls format is not supported in phase 1).
62
+ */
63
+ export declare function isExcelFile(filename: string): boolean;
64
+ export {};
@@ -0,0 +1,280 @@
1
+ /**
2
+ * @doccloak/core/dom - xlsx read/write (T110, file redaction phase 1).
3
+ *
4
+ * Minimal zip+XML implementation on the package's existing JSZip + DOMParser
5
+ * machinery (the same stack the docx module uses). SheetJS was considered and
6
+ * rejected at claim time: the community edition is Apache-2.0 but is a heavy
7
+ * dependency distributed outside npm since 0.18.x (vendor CDN), and DocCloak
8
+ * only needs string-cell rewriting, not formula evaluation or format
9
+ * conversion. The zip+XML path keeps the dependency surface unchanged.
10
+ *
11
+ * Text-bearing locations covered:
12
+ * - xl/sharedStrings.xml <si> entries (plain and rich-text runs, phonetic runs)
13
+ * - worksheet inline strings (<c t="inlineStr"><is>...)
14
+ * - worksheet cached formula string results (<c t="str"><v>)
15
+ * - worksheet header/footer definitions
16
+ * - legacy cell comments (xl/comments*.xml) and threaded comments
17
+ *
18
+ * Deliberately out of scope in phase 1 (documented for T-later): drawing/chart
19
+ * text parts (xl/drawings, xl/charts), data connection definitions
20
+ * (xl/connections.xml) and defined-name formulas. Metadata scrubbing follows
21
+ * the docx module's policy via the shared OPC helpers.
22
+ */
23
+ import JSZip from 'jszip';
24
+ import { applyTextReplacements } from "./docx.js";
25
+ import { readXmlPart, writeXmlPart, scrubDocPropsParts, removePackageThumbnail, scrubExternalRelTargets, replaceSensitiveValues, } from "./opc.js";
26
+ import { getFileExtension } from "./docx.js";
27
+ const SS_NS = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main';
28
+ function isSpreadsheetEl(el, localName) {
29
+ return el.namespaceURI === SS_NS && el.localName === localName;
30
+ }
31
+ /** Collect descendant <t> elements (spreadsheet namespace) in document order. */
32
+ function collectTextLeaves(root) {
33
+ const result = [];
34
+ const all = root.getElementsByTagName('*');
35
+ for (let i = 0; i < all.length; i++) {
36
+ if (isSpreadsheetEl(all[i], 't'))
37
+ result.push(all[i]);
38
+ }
39
+ return result;
40
+ }
41
+ /** String units in xl/sharedStrings.xml: one per <si>. */
42
+ function sharedStringUnits(xmlDoc) {
43
+ const units = [];
44
+ const sis = xmlDoc.getElementsByTagNameNS(SS_NS, 'si');
45
+ for (let i = 0; i < sis.length; i++) {
46
+ units.push(collectTextLeaves(sis[i]));
47
+ }
48
+ return units;
49
+ }
50
+ const HEADER_FOOTER_NAMES = new Set([
51
+ 'oddHeader', 'oddFooter', 'evenHeader', 'evenFooter', 'firstHeader', 'firstFooter',
52
+ ]);
53
+ /**
54
+ * String units in a worksheet: inline-string cells, cached string formula
55
+ * results and header/footer definitions, in document order.
56
+ */
57
+ function worksheetUnits(xmlDoc) {
58
+ const units = [];
59
+ const all = xmlDoc.getElementsByTagName('*');
60
+ for (let i = 0; i < all.length; i++) {
61
+ const el = all[i];
62
+ if (el.namespaceURI !== SS_NS)
63
+ continue;
64
+ if (el.localName === 'c') {
65
+ const type = el.getAttribute('t');
66
+ if (type === 'inlineStr') {
67
+ const leaves = collectTextLeaves(el);
68
+ if (leaves.length > 0)
69
+ units.push(leaves);
70
+ }
71
+ else if (type === 'str') {
72
+ // Cached formula result; the <f> formula itself is scrubbed by value.
73
+ for (let c = 0; c < el.children.length; c++) {
74
+ const child = el.children[c];
75
+ if (isSpreadsheetEl(child, 'v'))
76
+ units.push([child]);
77
+ }
78
+ }
79
+ }
80
+ else if (HEADER_FOOTER_NAMES.has(el.localName)) {
81
+ units.push([el]);
82
+ }
83
+ }
84
+ return units;
85
+ }
86
+ /**
87
+ * String units in a comments part. Legacy comments keep text in <t> leaves
88
+ * under each <comment><text>; threaded comments keep plain text in a <text>
89
+ * element (Microsoft threadedComments namespace, matched by local name).
90
+ */
91
+ function commentUnits(xmlDoc) {
92
+ const units = [];
93
+ const root = xmlDoc.documentElement;
94
+ if (root.localName === 'comments') {
95
+ const texts = xmlDoc.getElementsByTagNameNS(SS_NS, 'text');
96
+ for (let i = 0; i < texts.length; i++) {
97
+ const leaves = collectTextLeaves(texts[i]);
98
+ if (leaves.length > 0)
99
+ units.push(leaves);
100
+ }
101
+ }
102
+ else {
103
+ // threadedComments (or any other vendor comments schema): plain-text
104
+ // <text> leaves, matched leniently by local name.
105
+ const all = xmlDoc.getElementsByTagName('*');
106
+ for (let i = 0; i < all.length; i++) {
107
+ const el = all[i];
108
+ if (el.localName === 'text' && el.childElementCount === 0) {
109
+ units.push([el]);
110
+ }
111
+ }
112
+ }
113
+ return units;
114
+ }
115
+ /**
116
+ * Read a .xlsx file and extract its text content with position mapping.
117
+ */
118
+ export async function readXlsx(file) {
119
+ const arrayBuffer = await file.arrayBuffer();
120
+ const zip = await JSZip.loadAsync(arrayBuffer);
121
+ if (!zip.file('xl/workbook.xml')) {
122
+ throw new Error('Invalid .xlsx file: missing xl/workbook.xml');
123
+ }
124
+ // Deterministic part order so load-time and export-time extractions produce
125
+ // identical offsets: sharedStrings, then worksheets, then comment parts.
126
+ const worksheetPaths = [];
127
+ const commentPaths = [];
128
+ zip.forEach((relativePath) => {
129
+ if (/^xl\/worksheets\/[^/]+\.xml$/.test(relativePath)) {
130
+ worksheetPaths.push(relativePath);
131
+ }
132
+ else if (/^xl\/comments\d*\.xml$/.test(relativePath) ||
133
+ /^xl\/threadedComments\/[^/]+\.xml$/.test(relativePath)) {
134
+ commentPaths.push(relativePath);
135
+ }
136
+ });
137
+ worksheetPaths.sort();
138
+ commentPaths.sort();
139
+ const partPlans = [
140
+ { path: 'xl/sharedStrings.xml', unitsOf: sharedStringUnits },
141
+ ...worksheetPaths.map((path) => ({ path, unitsOf: worksheetUnits })),
142
+ ...commentPaths.map((path) => ({ path, unitsOf: commentUnits })),
143
+ ];
144
+ let flatText = '';
145
+ const contentParts = [];
146
+ for (const plan of partPlans) {
147
+ const xmlDoc = await readXmlPart(zip, plan.path);
148
+ if (!xmlDoc)
149
+ continue;
150
+ const textNodes = [];
151
+ for (const unit of plan.unitsOf(xmlDoc)) {
152
+ const nodes = [];
153
+ let unitLength = 0;
154
+ for (const element of unit) {
155
+ const text = element.textContent ?? '';
156
+ if (text.length === 0)
157
+ continue;
158
+ nodes.push({ element, flatStart: unitLength, flatEnd: unitLength + text.length });
159
+ unitLength += text.length;
160
+ }
161
+ if (unitLength === 0)
162
+ continue;
163
+ const base = flatText.length === 0 ? 0 : flatText.length + 1;
164
+ if (flatText.length > 0)
165
+ flatText += '\n';
166
+ for (const node of nodes) {
167
+ node.flatStart += base;
168
+ node.flatEnd += base;
169
+ flatText += node.element.textContent ?? '';
170
+ textNodes.push(node);
171
+ }
172
+ }
173
+ contentParts.push({ path: plan.path, xmlDoc, textNodes });
174
+ }
175
+ return {
176
+ plainText: flatText,
177
+ zip,
178
+ contentParts,
179
+ textNodes: contentParts.flatMap((part) => part.textNodes),
180
+ };
181
+ }
182
+ /**
183
+ * Scrub identity-bearing attributes inside a worksheet or comments part:
184
+ * hyperlink display/tooltip strings, legacy comment author registry, and
185
+ * literal strings inside formulas (<f>), which offset replacement cannot
186
+ * reach because formulas are not part of the flat text.
187
+ */
188
+ function sanitizeXlsxPart(xmlDoc, valueReplacements) {
189
+ const all = xmlDoc.getElementsByTagName('*');
190
+ for (let i = 0; i < all.length; i++) {
191
+ const el = all[i];
192
+ if (el.localName === 'hyperlink') {
193
+ for (const attr of ['display', 'tooltip', 'location']) {
194
+ const value = el.getAttribute(attr);
195
+ if (value)
196
+ el.setAttribute(attr, replaceSensitiveValues(value, valueReplacements, false));
197
+ }
198
+ }
199
+ else if (isSpreadsheetEl(el, 'f')) {
200
+ const formula = el.textContent ?? '';
201
+ const scrubbed = replaceSensitiveValues(formula, valueReplacements, false);
202
+ if (scrubbed !== formula)
203
+ el.textContent = scrubbed;
204
+ }
205
+ else if (el.localName === 'author' && el.parentElement?.localName === 'authors') {
206
+ // Legacy comments author registry (indexes must stay stable)
207
+ el.textContent = 'Redacted';
208
+ }
209
+ }
210
+ }
211
+ /**
212
+ * Scrub metadata parts that survive text replacement untouched: docProps,
213
+ * the package thumbnail, the workbook's absolute-path fingerprint and
214
+ * file-sharing identity, the threaded-comment person registry, and external
215
+ * relationship targets (hyperlink mailto:/URLs).
216
+ */
217
+ async function sanitizeXlsxMetadata(zip, valueReplacements) {
218
+ await scrubDocPropsParts(zip);
219
+ await removePackageThumbnail(zip);
220
+ // Workbook: x15ac:absPath records the full local path the file was saved
221
+ // from (user name, client folders); fileSharing records the reserving user.
222
+ const workbook = await readXmlPart(zip, 'xl/workbook.xml');
223
+ if (workbook) {
224
+ const all = workbook.getElementsByTagName('*');
225
+ const doomed = [];
226
+ for (let i = 0; i < all.length; i++) {
227
+ if (all[i].localName === 'absPath' || all[i].localName === 'fileSharing') {
228
+ doomed.push(all[i]);
229
+ }
230
+ }
231
+ for (const el of doomed)
232
+ el.parentNode?.removeChild(el);
233
+ writeXmlPart(zip, 'xl/workbook.xml', workbook);
234
+ }
235
+ // Threaded-comment person registry: display names and provider user ids
236
+ const personsPaths = [];
237
+ zip.forEach((relativePath) => {
238
+ if (/^xl\/persons\/[^/]+\.xml$/.test(relativePath))
239
+ personsPaths.push(relativePath);
240
+ });
241
+ for (const path of personsPaths.sort()) {
242
+ const persons = await readXmlPart(zip, path);
243
+ if (!persons)
244
+ continue;
245
+ const all = persons.getElementsByTagName('*');
246
+ for (let i = 0; i < all.length; i++) {
247
+ const attrs = all[i].attributes;
248
+ for (let a = 0; a < attrs.length; a++) {
249
+ if (attrs[a].localName === 'displayName' || attrs[a].localName === 'userId') {
250
+ attrs[a].value = 'Redacted';
251
+ }
252
+ }
253
+ }
254
+ writeXmlPart(zip, path, persons);
255
+ }
256
+ await scrubExternalRelTargets(zip, /^xl\/(?:[^/]+\/)*_rels\/[^/]+\.rels$/, valueReplacements);
257
+ }
258
+ /**
259
+ * Apply text replacements to the xlsx XML, preserving formatting, then scrub
260
+ * metadata. Returns a new .xlsx file as a Blob; the input file is untouched.
261
+ */
262
+ export async function writeAnonymizedXlsx(extraction, replacements, valueReplacements = []) {
263
+ applyTextReplacements(extraction.textNodes, replacements);
264
+ for (const part of extraction.contentParts) {
265
+ sanitizeXlsxPart(part.xmlDoc, valueReplacements);
266
+ writeXmlPart(extraction.zip, part.path, part.xmlDoc);
267
+ }
268
+ await sanitizeXlsxMetadata(extraction.zip, valueReplacements);
269
+ return extraction.zip.generateAsync({
270
+ type: 'blob',
271
+ mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
272
+ });
273
+ }
274
+ /**
275
+ * Check if a file is a supported Excel workbook (.xlsx only; the legacy
276
+ * binary .xls format is not supported in phase 1).
277
+ */
278
+ export function isExcelFile(filename) {
279
+ return getFileExtension(filename) === 'xlsx';
280
+ }
package/dist/engine.d.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  import type { CoreEnv, HardwareHints } from './env.js';
17
17
  import type { DetectedEntity, DetectionProvider } from './types.js';
18
18
  import { type RegexRegionId } from './regex/index.js';
19
- export type ProviderId = 'gliner' | 'bardsai';
19
+ export type ProviderId = 'gliner' | 'gliner-base' | 'bardsai';
20
20
  export interface ProviderEntry {
21
21
  id: ProviderId;
22
22
  label: string;
@@ -72,10 +72,13 @@ export declare function defaultThresholdFor(id: ProviderId): number;
72
72
  export declare function clampThreshold(value: number): number;
73
73
  /**
74
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).
75
+ * Large models (~200-500+ MB peak RAM while loading) routinely get the tab
76
+ * killed on mobile browsers, so constrained devices default to the
77
+ * lightweight GLiNER Small model (~83 MB). Desktop-class devices default
78
+ * to GLiNER PII Base (~197 MB, higher accuracy, zero-shot custom labels;
79
+ * T122 - previously BardS.ai, which stays selectable as legacy). A saved
80
+ * provider choice always wins over this heuristic (existing behavior).
81
+ * Hosts feed the hints (userAgent/deviceMemory sniffing stays host-side).
79
82
  */
80
83
  export declare function pickDefaultProvider(hardware?: HardwareHints): ProviderId;
81
84
  export declare function createEngine(env: CoreEnv, initial?: Partial<EngineSettings>, options?: EngineOptions): DocCloakEngine;
package/dist/engine.js CHANGED
@@ -15,19 +15,25 @@
15
15
  */
16
16
  import { detectWithRegex, detectEntities } from "./pipeline.js";
17
17
  import { GlinerProvider } from "./providers/gliner.js";
18
+ import { GlinerBaseProvider } from "./providers/gliner-base.js";
18
19
  import { BardsaiProvider } from "./providers/bardsai.js";
19
20
  import { REGEX_REGIONS } from "./regex/index.js";
20
21
  /** Catalog of the built-in providers (labels/descriptions shown by hosts). */
21
22
  export const PROVIDERS = [
22
23
  {
23
24
  id: 'gliner',
24
- label: 'GLiNER PII Edge',
25
- description: 'Lightweight, multi-language, supports custom labels (~65 MB)',
25
+ label: 'GLiNER PII Small',
26
+ description: 'Lightweight, multi-language, supports custom labels (~83 MB)',
27
+ },
28
+ {
29
+ id: 'gliner-base',
30
+ label: 'GLiNER PII Base',
31
+ description: 'May be better for English and dates, supports custom labels (~197 MB)',
26
32
  },
27
33
  {
28
34
  id: 'bardsai',
29
35
  label: 'BardS.ai EU PII',
30
- description: '24 EU languages, 35 PII types, high accuracy (~279 MB)',
36
+ description: 'Best multilingual accuracy, 24+ EU languages, 35 PII types (~279 MB)',
31
37
  },
32
38
  ];
33
39
  // ── Settings ───────────────────────────────────────────────
@@ -53,16 +59,23 @@ export function clampThreshold(value) {
53
59
  }
54
60
  /**
55
61
  * 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).
62
+ * Large models (~200-500+ MB peak RAM while loading) routinely get the tab
63
+ * killed on mobile browsers, so constrained devices default to the
64
+ * lightweight GLiNER Small model (~83 MB). Desktop-class devices default
65
+ * to GLiNER PII Base (~197 MB, higher accuracy, zero-shot custom labels;
66
+ * T122 - previously BardS.ai, which stays selectable as legacy). A saved
67
+ * provider choice always wins over this heuristic (existing behavior).
68
+ * Hosts feed the hints (userAgent/deviceMemory sniffing stays host-side).
60
69
  */
61
70
  export function pickDefaultProvider(hardware) {
62
71
  if (hardware?.isMobile)
63
72
  return 'gliner';
64
73
  if (typeof hardware?.deviceMemoryGB === 'number' && hardware.deviceMemoryGB <= 4)
65
74
  return 'gliner';
75
+ // T127: the 2026-08 benchmark (documentation/model-comparison-2026-08.md)
76
+ // showed bardsai is the strongest and most uniform across languages, so it
77
+ // is the capable-device default again; gliner-base stays selectable as the
78
+ // English/dates/custom-labels alternative.
66
79
  return 'bardsai';
67
80
  }
68
81
  function isProviderId(value) {
@@ -78,6 +91,7 @@ function supportsCustomLabels(p) {
78
91
  export function createEngine(env, initial, options) {
79
92
  const factories = {
80
93
  gliner: (e) => new GlinerProvider(e),
94
+ 'gliner-base': (e) => new GlinerBaseProvider(e),
81
95
  bardsai: (e) => new BardsaiProvider(e),
82
96
  ...options?.providers,
83
97
  };
package/dist/index.d.ts CHANGED
@@ -14,6 +14,8 @@ export * from './doc.js';
14
14
  export { generateSurrogate, generateUniqueSurrogate, generateSessionSalt, sessionDayOffset, FAKE_EMAIL_DOMAINS, } from './surrogates.js';
15
15
  export type { SurrogateContext } from './surrogates.js';
16
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 type { SpanIndices } from './providers/gliner-base.js';
17
19
  export { BardsaiProvider, BARDSAI_MODEL_URL, BARDSAI_MODEL_REVISION, BARDSAI_MODEL_SHA256 } from './providers/bardsai.js';
18
20
  export { detectWithRegex, resolveOverlaps, filterFalsePositives, propagateEntities, detectEntities, } from './pipeline.js';
19
21
  export { createEngine, PROVIDERS, pickDefaultProvider, defaultThresholdFor, clampThreshold, ENGINE_SETTINGS_KEYS, } from './engine.js';
package/dist/index.js CHANGED
@@ -20,6 +20,9 @@ export { generateSurrogate, generateUniqueSurrogate, generateSessionSalt, sessio
20
20
  // T007: gliner provider
21
21
  export { GlinerProvider, GLINER_MODEL_URL, GLINER_MODEL_REVISION, GLINER_MODEL_SHA256 } from "./providers/gliner.js";
22
22
  // end T007
23
+ // 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";
25
+ // end T122
23
26
  // T008: bardsai provider
24
27
  export { BardsaiProvider, BARDSAI_MODEL_URL, BARDSAI_MODEL_REVISION, BARDSAI_MODEL_SHA256 } from "./providers/bardsai.js";
25
28
  // end T008
@@ -10,7 +10,10 @@
10
10
  * tokenizer all arrive through the injected CoreEnv. This module never touches
11
11
  * import.meta, caches, navigator or @huggingface/transformers directly.
12
12
  */
13
- import * as ort from 'onnxruntime-web';
13
+ // ort 1.29: the /webgpu entry is the non-deprecated build (native WebGPU EP +
14
+ // wasm fallback, asyncify binary). The bare entry resolves the deprecated JSEP
15
+ // bundle whose wasm exceeds Cloudflare Pages' 25 MiB per-file limit.
16
+ import * as ort from 'onnxruntime-web/webgpu';
14
17
  import { evictModelFromCache, fetchModelBlob, retryAsync } from "../model-loader.js";
15
18
  // ── Model config ──────────────────────────────────────────
16
19
  // Supply-chain pinning (T116): the model is fetched from an immutable
@@ -231,13 +234,11 @@ export class BardsaiProvider {
231
234
  }
232
235
  if (words.length === 0)
233
236
  return [];
234
- // Tokenize full text once to count subtokens per word via ▁ prefix
237
+ // Tokenize full text once to count subtokens per word via ▁ prefix.
238
+ // transformers.js 4.x removed tokenizer.model.convert_ids_to_tokens;
239
+ // tokenize() returns the same SentencePiece token strings directly.
235
240
  onProgress?.(0);
236
- const fullEncoded = await this.tokenizer(words.map((w) => w.word).join(' '), {
237
- add_special_tokens: false,
238
- });
239
- const tokenIds = Array.from(fullEncoded.input_ids.data);
240
- const tokenStrings = this.tokenizer.model.convert_ids_to_tokens(tokenIds.map(Number));
241
+ const tokenStrings = this.tokenizer.tokenize(words.map((w) => w.word).join(' '));
241
242
  // Count subtokens per word: ▁ prefix marks word boundaries (SentencePiece)
242
243
  const subtokenCounts = [];
243
244
  let currentCount = 0;
@@ -317,16 +318,15 @@ export class BardsaiProvider {
317
318
  const attentionMask = encoded.attention_mask.data;
318
319
  const seqLen = encoded.input_ids.dims[1];
319
320
  // Build word→subtoken alignment via ▁ prefix (no per-word tokenization needed)
320
- const chunkTokenIds = Array.from(inputIds);
321
- const chunkTokenStrings = this.tokenizer.model.convert_ids_to_tokens(chunkTokenIds.map(Number));
321
+ // transformers.js 4.x removed tokenizer.model.convert_ids_to_tokens;
322
+ // tokenize() returns the token strings without specials, so positions are
323
+ // offset by 1 for the leading <s> in the encoded sequence and the seqLen
324
+ // guard stops at the truncation boundary.
325
+ const chunkTokenStrings = this.tokenizer.tokenize(chunkText);
322
326
  const wordSubtokenStart = [];
323
- // Skip <s> at position 0; first real token at position 1
324
- for (let t = 1; t < seqLen - 1; t++) {
325
- const tok = chunkTokenStrings[t];
326
- if (!tok || tok === '</s>')
327
- break;
328
- if (tok.startsWith('\u2581')) {
329
- wordSubtokenStart.push(t);
327
+ for (let i = 0; i < chunkTokenStrings.length && i + 1 < seqLen - 1; i++) {
328
+ if (chunkTokenStrings[i].startsWith('\u2581')) {
329
+ wordSubtokenStart.push(i + 1);
330
330
  }
331
331
  }
332
332
  // XLM-RoBERTa: only input_ids + attention_mask (no token_type_ids)