@doccloak/core 0.11.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 +121 -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 +752 -125
  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 +135 -7
  16. package/dist/dom/opc.js +407 -14
  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 +101 -3
  41. package/dist/session.js +397 -43
  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/CHANGELOG.md CHANGED
@@ -4,6 +4,125 @@ All notable changes to `@doccloak/core` are documented here. The format is
4
4
  based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the
5
5
  project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.12.0] - 2026-09-24
8
+
9
+ Security remediation release (audit `SECURITY_REPORT-2026-09.md`, plan
10
+ `documentation/security-remediation-plan-2026-09-v2.md`). Every change is
11
+ fail-closed: what Core cannot redact is refused or reported, never copied in
12
+ silence.
13
+
14
+ ### Breaking for hosts
15
+
16
+ - **`CoreEnv.buildTokenizer(tokenizerJson, tokenizerConfig)` (T185):**
17
+ providers now download `tokenizer.json` and `tokenizer_config.json` from
18
+ their pinned commit through `fetchModelBlob` (SHA-256 and size verified,
19
+ cached in `doccloak-models`, resumable) and hand the parsed JSON to the
20
+ host, which constructs the tokenizer (`new PreTrainedTokenizer(json,
21
+ config)` on the web). `CoreEnv.loadTokenizer(hfModelId)` still works in
22
+ 0.12.0 as a deprecated fallback (one console warning) and is removed in
23
+ 0.13.0. The `AutoTokenizer.from_pretrained` probe to `resolve/main` on
24
+ every start is gone, so a fully cached model loads with zero requests.
25
+ - **`redactOfficeFile` throws `StaleAnalysisError` (T179)** when an entity
26
+ no longer matches the fresh extraction, instead of silently dropping it
27
+ and exporting the value. Pass `onMismatch: 'drop'` for the old behaviour.
28
+ - **Files with unredactable parts are refused by the writers (T177)** unless
29
+ `allowUnredactable: true` is passed (`writeAnonymizedDocx*`,
30
+ `writeAnonymizedXlsx*`, `redactOfficeFile`). `readDocx` / `readXlsx`
31
+ report them in `extraction.unredactable` (`part`, `kind`, `label`) so a
32
+ host can ask the user first; when allowed, `result.warnings` names every
33
+ part copied verbatim.
34
+
35
+ ### Added
36
+
37
+ - **Package-wide value scrub, "layer zero" (T172):** after the offset
38
+ replacements, every known session value (entries, variants, name tokens)
39
+ is replaced in every text node and attribute of every XML part of the
40
+ package (`charts`, `diagrams`, `customXml`, `pivotCache`, `docProps`,
41
+ rels and more), with a safety rule that never touches identifiers,
42
+ geometry or layout attributes. `writeAnonymizedDocxWithReport` /
43
+ `writeAnonymizedXlsxWithReport` return `{ blob, warnings }`.
44
+ - **New text parts and units (T174, T175):** charts (`c:v` caches, titles),
45
+ SmartArt (`dgm:t`), DrawingML text (`a:t`), `customXml/item*.xml` leaf
46
+ text, the whole `word/glossary/**` set, `numbering.xml` level text,
47
+ `webSettings.xml` frame titles, `w:delInstrText`, OMML `m:t`; attribute
48
+ units for field instruction arguments, content-control aliases, drawing
49
+ alt text and bookmark names (renamed bookmarks keep `REF`, `PAGEREF`,
50
+ `NOTEREF`, `HYPERLINK \l` and anchors valid). Tabs, breaks and
51
+ non-breaking hyphens are emitted as separators, text boxes are separated
52
+ from their host paragraph, `mc:Fallback` twins are redacted too, and a
53
+ replacement never crosses a paragraph break.
54
+ - **Part policy (T177):** `classifyPart` tables for docx and xlsx (text /
55
+ structural / unredactable / unknown), `describeUnredactableParts`,
56
+ 200 MB unpacked-size guard (`UnsupportedDocumentError` code
57
+ `'too-large'`).
58
+ - **`UnsupportedDocumentError`** with codes `unrecognized-namespace`,
59
+ `invalid-package`, `unredactable-parts`, `too-large`, `fast-saved`,
60
+ `encrypted`; `extraction.empty`, `extraction.warnings`.
61
+ - **Engine (T185):** `createEngine(env, factories, { autoLoad: false })` makes
62
+ `detect()` throw `ModelNotLoadedError` until the host calls `preload()`
63
+ (hosts implement consent above Core); `serveEngine` validates every
64
+ message and answers unknown or malformed ones with an `error` frame;
65
+ verification markers carry `sha256:size` and are re-checked against the
66
+ cached blob size.
67
+ - **Session (T182, T183):** `registerDocumentValues`, `lastAmbiguous()` /
68
+ `getAmbiguousValues()`; surrogates are never a value (or a name token)
69
+ that occurs in the document; single-pass exact restore with script-aware
70
+ word boundaries; literal `[TYPE_N]` tokens in the input never collide
71
+ with generated ones.
72
+ - **Tests:** `assertNoTrace` helper scanning every part and the raw archive
73
+ in UTF-8, latin1 and UTF-16LE, 33 synthetic Office / .doc fixtures, a
74
+ real-file corpus generated by LibreOffice in CI with an independent
75
+ extraction check, a 30-document end-to-end session suite, adversarial
76
+ regex inputs.
77
+
78
+ ### Changed
79
+
80
+ - Regex rules are enabled by default (`regexEnabled` defaults to `true` when neither the host nor a saved setting says otherwise). Structured identifiers no longer depend on the model alone in a fresh profile (T203).
81
+ - ML spans are trimmed of surrounding punctuation before merging: a whitespace-split provider such as BardS.ai returned `Anna Nowak,` and the redacted text lost the comma (T204).
82
+
83
+ - **Strict OOXML is supported (T172):** transitional and
84
+ `purl.oclc.org` namespaces are both read; a main part without a
85
+ recognised body is refused (`unrecognized-namespace`) instead of being
86
+ exported untouched.
87
+ - **Regex detection runs per line (T180)** (rules with `multiline: true`
88
+ run on the whole text), which removes the multi-minute stalls on long
89
+ prose; postal/city rules end on a letter and use a Unicode look-ahead
90
+ instead of `\b` (T181).
91
+ - **False-positive filter (T181):** CJK / Thai values of 2 characters and
92
+ Latin PERSON values of 2 characters (`Li`, `Ng`, `J.`) are kept; a short
93
+ stoplist drops titles (`Mr`, `Dr`, `Jr`...). Propagation uses Unicode
94
+ boundaries, exact offsets after `İ`, exact case for terms of 2
95
+ characters, and skips lowercase hits of common-word names (`bill`,
96
+ `mark`, `will`...).
97
+ - **Restore (T182):** `renameLabel` rejects empty and colliding labels;
98
+ `importEntries` / `deserialize` validate every entry and cap at 10 000;
99
+ `personTokens` is linear. Surrogate pools no longer contain common
100
+ English words or names of 3 letters or fewer.
101
+ - **Metadata (T179):** `contentStatus`, `identifier`, `created`,
102
+ `modified`, `lastPrinted`, `revision`, `version`, document statistics,
103
+ `TitlesOfParts` / `HeadingPairs` are blanked, zeroed or normalised;
104
+ comment and revision dates are set to 2000-01-01; `w:rsid*` attributes
105
+ are removed; ZIP entries are dated 1980-01-01 with no comments.
106
+ - **Always scrubbed without detection (T174):** hyperlink tooltips,
107
+ `mailto:` relationship targets, `w:docVars`, content-control tags without
108
+ data binding.
109
+ - **Legacy .doc hardening (T178, security report 2026-09 H5, H6, M5, L2):**
110
+ `readDocText` and `writeAnonymizedDoc` now throw `UnsupportedDocumentError`
111
+ (`code: 'fast-saved'`) for incrementally saved files (`fComplex`, or
112
+ `cQuickSaves > 0` on Word 97 files) and (`code: 'encrypted'`) for
113
+ `fEncrypted` / `fObfuscated` files instead of returning partial or
114
+ ciphertext output. `writeAnonymizedDoc` takes an optional
115
+ `valueReplacements` list (same shape as `writeAnonymizedDocx`) and destroys
116
+ every remaining byte occurrence of each original value and its 4+ character
117
+ tokens, any case, in `WordDocument` (UTF-16LE), `Table` (UTF-16LE and
118
+ cp1252) and `Data` (UTF-16LE), length-preserving. `SttbfRMark`,
119
+ `GrpXstAtnOwners`, `SttbfBkmk`, `SttbSavedBy` and `AutosaveSource` are
120
+ scrubbed alongside `SttbfAssoc`. New `inspectDoc(buffer)` reports the
121
+ refusal flags and the presence of `Data`, `ObjectPool` and `Macros`
122
+ streams (the latter two are still copied through; see README "Known
123
+ limits: legacy .doc"). `UnsupportedDocumentError` is also exported from
124
+ the main entry.
125
+
7
126
  ## [0.11.0] - 2026-09-23
8
127
 
9
128
  ### Changed
@@ -98,13 +217,13 @@ project follows [Semantic Versioning](https://semver.org/).
98
217
  PEM private-key blocks, JWTs, connection strings with embedded
99
218
  credentials, `secret=`-style assignments and high-entropy tokens, and
100
219
  a GB driving-licence rule. Credentials never receive realistic
101
- stand-ins — even in surrogate mode they always fall back to typed
220
+ stand-ins, even in surrogate mode they always fall back to typed
102
221
  placeholders, since a same-shape fake key still reads as a live
103
222
  credential.
104
223
 
105
224
  - **Tolerant restore-token matching (T098):** new side-effect-free
106
225
  `@doccloak/core/restore-tokens` export that restores placeholders LLM
107
- replies have mangled — case changes (`[person_1]`), separator swaps
226
+ replies have mangled, case changes (`[person_1]`), separator swaps
108
227
  (`[PERSON 1]`), markdown wrapping (`**[PERSON_1]**`), dropped brackets
109
228
  and stray edge punctuation. Restoration only happens on an unambiguous
110
229
  canonical hit; anything ambiguous is left exactly as the model wrote
package/README.md CHANGED
@@ -38,14 +38,19 @@ the engine downloads them from Hugging Face and stores them in the
38
38
  `CoreEnv.modelCache` you provide (the browser Cache Storage in the reference
39
39
  hosts):
40
40
 
41
- | Provider | Model | Download |
41
+ | Provider id | Model | Download |
42
42
  | --- | --- | --- |
43
- | `gliner` | GLiNER PII Edge (multi-language, custom labels) | ~65 MB |
44
- | `bardsai` | BardS.ai EU PII (24 EU languages, 35 PII types) | ~279 MB |
45
-
46
- `pickDefaultProvider(hardware)` chooses `gliner` on mobile or low-memory
47
- devices and `bardsai` otherwise. Download progress is surfaced via
48
- `engine.onDownloadProgress(cb)`.
43
+ | `gliner` | GLiNER PII Small (multi-language, custom labels) | ~83 MB |
44
+ | `gliner-base` | GLiNER PII Base (often better for English and dates, custom labels) | ~197 MB |
45
+ | `bardsai` | BardS.ai EU PII (24+ EU languages, 35 PII types; labeled "Legacy" in the web app) | ~279 MB |
46
+
47
+ `pickDefaultProvider(hardware)` chooses `gliner` on mobile devices or when
48
+ `deviceMemoryGB` is 4 or less, and `bardsai` otherwise. Download progress is
49
+ surfaced via `engine.onDownloadProgress(cb)`. Every model and both of its
50
+ tokenizer files are fetched from an immutable `resolve/<commit>` URL and
51
+ verified against a pinned SHA-256 and byte size; the pins are listed in
52
+ [documentation/model-provenance.md](../documentation/model-provenance.md)
53
+ of the DocCloak workspace.
49
54
 
50
55
  ONNX Runtime also needs its WASM binaries at runtime; point
51
56
  `CoreEnv.wasm.paths` at wherever your host serves the `onnxruntime-web`
@@ -57,23 +62,198 @@ ONNX Runtime also needs its WASM binaries at runtime; point
57
62
  `DocCloakEngine`: `detect(text, signal?, onProgress?)`, `preload()`,
58
63
  `switchProvider(id)`, `getSettings()` / `updateSettings(patch)`,
59
64
  `onDownloadProgress` / `onDetectionProgress`, `release()`. Settings persist
60
- through `env.kv`.
65
+ through `env.kv`. `options.autoLoad` (default `true`) lets `detect()`
66
+ download and initialise the model on its own; with `autoLoad: false` the
67
+ engine never downloads implicitly: `detect()` before a completed
68
+ `preload()` (or `switchProvider()`) rejects with `ModelNotLoadedError`
69
+ (a preload still in flight is awaited). Use `false` when model downloads
70
+ are gated behind user consent and call `preload()` from the consent flow.
61
71
  - **Detection pipeline** - `detectEntities`, `detectWithRegex`,
62
72
  `resolveOverlaps`, `filterFalsePositives`, `propagateEntities` for hosts
63
73
  that compose their own flow; `ALL_REGEX_RULES` and `REGEX_REGIONS` describe
64
74
  the built-in regex rule set (universal + per-country rules).
65
75
  - **Session state** - `AnonymizationSession` maps detected entities to stable
66
- placeholders (labeled or blanked) and back.
67
- - **Documents** (`/dom`) - `readDocx` / `writeAnonymizedDocx`, legacy `.doc`
68
- text extraction (`readDocText` on the main entry), OCR helpers
76
+ placeholders (labeled, blanked or surrogate) and back. In surrogate mode
77
+ `registerDocumentValues(valuesOrEntities)` tells the session which values
78
+ occur in the document so no surrogate ever equals a real value from it
79
+ (`anonymizeText` does this automatically); `getAmbiguousValues()` lists the
80
+ values whose person-variant match was a tie and therefore got a new
81
+ placeholder instead of being merged with an existing person.
82
+ - **Documents** (`/dom`) - `readDocx` / `writeAnonymizedDocx` /
83
+ `writeAnonymizedDocxWithReport`, `readXlsx` / `writeAnonymizedXlsx`,
84
+ `analyzeOfficeFile` / `redactOfficeFile`, legacy `.doc` support on the main
85
+ entry (`readDocText`, `writeAnonymizedDoc`, `inspectDoc`), OCR helpers
69
86
  (`loadImageToCanvas`, `recognizeCanvas`, `renderRedactedImage`) built on
70
- tesseract.js.
87
+ tesseract.js. See "Document API: refusals, warnings and consent" below for
88
+ the error and result shapes.
71
89
  - **Worker protocol** (`/worker-protocol`) - `serveEngine(engine, port)` on
72
90
  the side that owns the engine, `connectEngine(port)` on the UI side.
73
91
  `PortLike` is satisfied by thin wrappers over `Worker`, `MessagePort` or
74
92
  `chrome.runtime.Port`.
75
93
  - **Model loader** - `fetchModelBlob` / `evictModelFromCache` implement
76
- cache-first model downloads with progress and retry (`retryAsync`).
94
+ cache-first model downloads with progress, resume, retry and SHA-256
95
+ verification (`ModelIntegrityError` on a mismatch; the entry is evicted).
96
+ Verified cache entries carry a marker `${sha256}:${size}` bound to the blob
97
+ size, so a cache hit whose size changed is re-hashed instead of trusted.
98
+ Each provider also pins its two tokenizer files (`GLINER_TOKENIZER_FILES`,
99
+ `GLINER_BASE_TOKENIZER_FILES`, `BARDSAI_TOKENIZER_FILES`: immutable
100
+ `resolve/<commit>` URL, sha256, size); `loadPinnedTokenizer` fetches them
101
+ through the same path and hands the parsed JSON to `env.buildTokenizer`.
102
+
103
+ ### Document API: refusals, warnings and consent
104
+
105
+ The readers and writers fail closed: a file the engine cannot redact
106
+ completely is refused with a typed error, or reported so the host can ask the
107
+ user, never exported under a false "redacted" label.
108
+
109
+ **`UnsupportedDocumentError`** (main entry and `/dom`) carries a stable
110
+ `code` and a `details: string[]` list for diagnostics. Map `code` to your own
111
+ localized message with a one-line remedy:
112
+
113
+ | `code` | Thrown by | Meaning | Remedy to show |
114
+ | --- | --- | --- | --- |
115
+ | `'unrecognized-namespace'` | `readDocx`, `readXlsx` | The main part uses an XML namespace the reader does not know (neither the transitional nor the strict OOXML set), so text could be missed | Open in Word/Excel and save again as .docx/.xlsx |
116
+ | `'invalid-package'` | `readDocx`, `readXlsx` | The zip or a required XML part cannot be parsed | Same |
117
+ | `'too-large'` | `readDocx`, `readXlsx` | The unpacked size of the package exceeds 200 MB (zip-bomb and memory guard) | Split the document |
118
+ | `'unredactable-parts'` | `writeAnonymizedDocx*`, `writeAnonymizedXlsx*`, `redactOfficeFile` | The package contains parts whose content cannot be redacted and the host did not pass `allowUnredactable: true` | Ask the user (see below) |
119
+ | `'fast-saved'` | `readDocText`, `writeAnonymizedDoc` | Legacy .doc saved incrementally: deleted text lives outside the piece table where no detector sees it | Open in Word, Save As (or save as .docx) |
120
+ | `'encrypted'` | `readDocText`, `writeAnonymizedDoc` | Legacy .doc is encrypted or XOR-obfuscated | Remove the password |
121
+
122
+ **Extraction results.** `readDocx` and `readXlsx` return, next to
123
+ `plainText` and the offset mappings:
124
+
125
+ - `unredactable: UnredactablePart[]` - parts the writer cannot redact, each
126
+ with `part` (zip path), `kind` (`'embedded-object' | 'macros' |
127
+ 'html-chunk' | 'external-data' | 'printer-settings' | 'unknown'`) and a
128
+ human `label` ("embedded Excel sheet"). The reader never throws for these;
129
+ the writer refuses with `'unredactable-parts'` unless the host passes
130
+ `{ allowUnredactable: true }`, in which case those parts are copied
131
+ verbatim and one warning per part is returned. Only pass it after the user
132
+ has been told which parts stay unchanged (informed consent); a host that
133
+ does not ask keeps the fail-closed default. Layer zero still scrubs every
134
+ known value from the XML parts of the package either way.
135
+ - `warnings: string[]` - parts that could not be parsed and were therefore
136
+ skipped by the layer-zero scrub, and similar non-fatal findings (a sheet
137
+ name containing an entity, for example). Show them to the user.
138
+ - `empty: boolean` - the document contains no text units at all; show
139
+ "nothing to redact" instead of exporting.
140
+
141
+ **Write results.** `writeAnonymizedDocxWithReport` /
142
+ `writeAnonymizedXlsxWithReport` return `{ blob, warnings }`; the plain
143
+ `writeAnonymizedDocx` / `writeAnonymizedXlsx` return the `Blob` only.
144
+
145
+ **One-call path.** `analyzeOfficeFile(file, detect)` reads the package,
146
+ runs your detector and exposes `unredactable[]` and `warnings[]` next to the
147
+ entities. `redactOfficeFile(file, analysis, session, options)` applies the
148
+ session's replacements and writes the file:
149
+
150
+ - `options.onMismatch` (`'throw' | 'drop'`, default `'throw'`) decides what
151
+ happens when an entity of the analysis no longer matches the text at its
152
+ offsets (the document was re-read or edited in between): `'throw'` rejects
153
+ with `StaleAnalysisError`, which lists the stale entities, so a stale
154
+ analysis never yields a leaking "success"; `'drop'` skips those entities
155
+ and reports them in `result.warnings`. `isStaleAnalysisError(err)` works
156
+ across realms.
157
+ - `options.allowUnredactable` (default `false`) is required to export a file
158
+ with embedded objects, macros, HTML chunks, external data or printer
159
+ settings; `result.warnings` then names each part copied verbatim.
160
+
161
+ All of these are exported from `@doccloak/core/dom`.
162
+
163
+ **Legacy .doc.** `inspectDoc(buffer)` returns `{ encrypted, fastSaved,
164
+ streams: { data, objectPool, macros } }` without throwing, so a host can show
165
+ the same consent card for a .doc whose `ObjectPool` (embedded objects) or
166
+ `Macros` (VBA) storage will be copied unchanged.
167
+
168
+ ## What is removed or normalised in Office packages
169
+
170
+ Independently of what was detected, every exported .docx / .xlsx has:
171
+
172
+ - **Removed:** `cp:revision`, `cp:version`; every `w:rsid*` attribute in the
173
+ content parts and `w:rsids` in `word/settings.xml`; the thumbnail
174
+ (`docProps/thumbnail.*` with its relationship and content-type override);
175
+ the attached template, mail-merge settings and document variables
176
+ (`w:docVars`) in `word/settings.xml`; hyperlink tooltips; content-control
177
+ tags without a data binding; `c:externalData` links in charts (the chart
178
+ keeps its cached data).
179
+ - **Blanked:** `creator`, `lastModifiedBy`, `title`, `subject`,
180
+ `description`, `keywords`, `category`, `contentStatus`, `identifier`
181
+ (`docProps/core.xml`); `Company`, `Manager`, `HyperlinkBase`, `Template`
182
+ and every heading / sheet / defined-name title in `TitlesOfParts` and
183
+ `HeadingPairs` (`docProps/app.xml`); custom property values (names kept,
184
+ `docProps/custom.xml`); the comment-author registry (`word/people.xml`)
185
+ and `w:author` / `w:initials` on comments and tracked changes.
186
+ - **Normalised:** `dcterms:created`, `dcterms:modified` and `cp:lastPrinted`
187
+ to `2000-01-01T00:00:00Z`; `w:date` on comments, insertions, deletions and
188
+ property-change records to `2000-01-01T00:00:00Z` (the only visible
189
+ effect: comment and revision dates in the downloaded file); page, word,
190
+ character, line and paragraph counts and `TotalTime` to 0; every ZIP entry
191
+ dated `1980-01-01` with entry and archive comments removed, so the output
192
+ no longer reveals which parts were touched; `mailto:` relationship targets
193
+ replaced by `mailto:redacted@example.invalid`.
194
+ - **Layer zero:** every value of the session (with its variants and name
195
+ tokens of 4+ characters) is scrubbed from the text nodes and attribute
196
+ values of every XML, `.rels` and `.vml` part of the package (charts,
197
+ diagrams, customXml, glossary, docProps, pivot caches), never touching
198
+ element names, `[Content_Types].xml`, relationship `Type`s, ids or numeric
199
+ layout attributes.
200
+
201
+ ## Known limits
202
+
203
+ **Office packages (.docx, .xlsx)**
204
+
205
+ - Images inside documents (`media/*`) are copied unchanged; text in pictures
206
+ is not OCR-scanned. Hosts can run the OCR helpers on extracted images.
207
+ - Embedded objects (`embeddings/*`), VBA projects, `altChunk` HTML fragments,
208
+ external data connections, external links and printer settings cannot be
209
+ redacted. They are reported in `extraction.unredactable` and exported
210
+ verbatim only with `allowUnredactable: true` (see above). Removing them is
211
+ planned for 0.13.0 (T199), gated on the real-file corpus (T196).
212
+ - Spreadsheets: sheet names with an entity are reported in `warnings`, not
213
+ renamed; numeric and date cells, formula literals, defined names and data
214
+ validation texts are not extracted yet (T176).
215
+ - A bookmark rename keeps the identifier valid (`PERSON_1`, never
216
+ `[PERSON_1]`); field instructions are replaced only inside their quoted
217
+ arguments and bookmark tokens.
218
+
219
+ **Legacy .doc**
220
+
221
+ `writeAnonymizedDoc(buffer, replacements, valueReplacements?)` rewrites the
222
+ piece table for main-text replacements, overwrites subdocument ranges in
223
+ place, space-fills the OLE property sets and the Table string tables
224
+ (`SttbfAssoc`, `SttbfRMark`, `GrpXstAtnOwners`, `SttbfBkmk`, `SttbSavedBy`,
225
+ `AutosaveSource`) and then destroys every remaining byte occurrence of each
226
+ original value (the value, its whitespace tokens of 4+ characters, any case)
227
+ without moving a single structure: `WordDocument` and `Data` are scanned as
228
+ UTF-16LE, `Table` as UTF-16LE and cp1252. What it does not do:
229
+
230
+ - **Fast-saved files are refused** (`code: 'fast-saved'`; `fComplex`, or
231
+ `cQuickSaves > 0` on Word 97 files). Deleted text of an incremental save
232
+ lives outside the piece table, so detection never sees it. Ask the user to
233
+ open the file in Word and Save As (or save as .docx).
234
+ - **Encrypted or XOR-obfuscated files are refused** (`code: 'encrypted'`).
235
+ - **cp1252 remnants in `Data` and `WordDocument` are not searched.** Hyperlink
236
+ field data and OLE previews store text as UTF-16; a cp1252 search would hit
237
+ bytes inside embedded pictures and corrupt them.
238
+ - **`ObjectPool/*` (embedded OLE objects), `Macros/*` (VBA) and `\x01CompObj`
239
+ are copied through unchanged.** Call `inspectDoc(buffer)` and warn when
240
+ `streams.objectPool` or `streams.macros` is `true`.
241
+ - A token that also occurs inside a replacement (for example `Person` in
242
+ `[PERSON_1]`) is not scrubbed on its own so placeholders survive; the full
243
+ original value always is.
244
+
245
+ **Detection and restore**
246
+
247
+ - No model catches every name; hosts should keep a review step and let users
248
+ add manual entities or dictionary terms.
249
+ - Regex rules run per line since 0.12.0 (except rules flagged `multiline`),
250
+ so a pattern split across a line break (postal code on one line, city on
251
+ the next) is not matched as one entity.
252
+ - Surrogate mode preserves structure by design: name token count, gender and
253
+ locale; the phone country prefix; the date format and a constant per-session
254
+ day shift (so one known date reveals the shift for all dates); the IBAN
255
+ country and letter positions; the email shape. Surrogate restore matches
256
+ by value and is best-effort; placeholder mode is exact.
77
257
 
78
258
  ## Host integration: CoreEnv
79
259
 
@@ -84,10 +264,12 @@ engine talks to the outside world:
84
264
  ```ts
85
265
  interface CoreEnv {
86
266
  kv: KVStore; // async string KV for settings persistence
87
- modelCache: BlobCache; // blob cache for downloaded model files
267
+ modelCache: BlobCache; // blob cache for downloaded model + tokenizer files
88
268
  fetch: typeof fetch; // injectable for tests/proxies
89
269
  wasm: { paths: string; numThreads?: number }; // onnxruntime-web asset location
90
- loadTokenizer(hfModelId: string): Promise<unknown>; // e.g. @huggingface/transformers
270
+ buildTokenizer?(tokenizerJson: unknown, tokenizerConfig: unknown): unknown | Promise<unknown>;
271
+ /** @deprecated since 0.12.0, removed in 0.13.0; used only when buildTokenizer is absent */
272
+ loadTokenizer?(hfModelId: string): Promise<unknown>;
91
273
  hardware?: HardwareHints; // deviceMemoryGB / isMobile, feeds provider default
92
274
  persistStorage?: () => Promise<boolean>; // e.g. navigator.storage.persist()
93
275
  }
@@ -96,11 +278,62 @@ interface CoreEnv {
96
278
  `memoryKV()` and `memoryBlobCache()` provide in-memory implementations for
97
279
  tests and short-lived Node processes.
98
280
 
281
+ ### buildTokenizer
282
+
283
+ Core downloads the tokenizer files itself. Every provider pins
284
+ `tokenizer.json` and `tokenizer_config.json` at the same immutable commit as
285
+ its ONNX model, with a SHA-256 and byte size, and fetches them through
286
+ `fetchModelBlob` (resume, retry, `modelCache`, verification marker,
287
+ `ModelIntegrityError`). The host only turns the two verified, parsed objects
288
+ into a tokenizer instance:
289
+
290
+ ```ts
291
+ import { PreTrainedTokenizer } from '@huggingface/transformers';
292
+
293
+ buildTokenizer: (tokenizerJson, tokenizerConfig) =>
294
+ new PreTrainedTokenizer(tokenizerJson as never, tokenizerConfig as never),
295
+ ```
296
+
297
+ Pick the class from `tokenizerConfig.tokenizer_class` if your library needs
298
+ a specific subclass (`PreTrainedTokenizerFast` for GLiNER PII Small,
299
+ `DebertaV2Tokenizer` for GLiNER PII Base, `XLMRobertaTokenizer` for BardS.ai);
300
+ `PreTrainedTokenizer` handles all three in `@huggingface/transformers`.
301
+ With `buildTokenizer` the host never contacts the network for tokenizers, a
302
+ warm `modelCache` works offline, and the only URLs fetched on a cold start
303
+ are the three pinned `resolve/<commit>/...` files of the active provider.
304
+
305
+ `loadTokenizer(hfModelId)` (0.11 and earlier) is still honoured when
306
+ `buildTokenizer` is absent, with a one-time deprecation warning. It lets the
307
+ host library probe the mutable `resolve/main` branch and keep an unverified
308
+ second cache, so migrate before 0.13.0, where `buildTokenizer` becomes
309
+ required and `loadTokenizer` is removed.
310
+
311
+ ### autoLoad and ModelNotLoadedError
312
+
313
+ ```ts
314
+ import { createEngine, ModelNotLoadedError } from '@doccloak/core';
315
+
316
+ const engine = createEngine(env, undefined, { autoLoad: false });
317
+ try {
318
+ await engine.detect(text);
319
+ } catch (err) {
320
+ if (err instanceof ModelNotLoadedError) {
321
+ // ask the user for consent, then:
322
+ await engine.preload();
323
+ }
324
+ }
325
+ ```
326
+
327
+ With `autoLoad: false` no code path of the engine downloads a model unless
328
+ the host calls `preload()` or `switchProvider()`; `ModelNotLoadedError`
329
+ carries the `providerId` that needs loading. The default (`autoLoad: true`)
330
+ keeps the 0.11 behaviour where `detect()` loads on demand.
331
+
99
332
  ### Web app quickstart
100
333
 
101
334
  ```ts
102
335
  import { createEngine, type CoreEnv } from '@doccloak/core';
103
- import { AutoTokenizer } from '@huggingface/transformers';
336
+ import { PreTrainedTokenizer } from '@huggingface/transformers';
104
337
 
105
338
  const env: CoreEnv = {
106
339
  kv: {
@@ -118,7 +351,7 @@ const env: CoreEnv = {
118
351
  },
119
352
  fetch: (...args) => fetch(...args),
120
353
  wasm: { paths: '/', numThreads: 1 }, // serve onnxruntime-web *.wasm under /
121
- loadTokenizer: (hfModelId) => AutoTokenizer.from_pretrained(hfModelId),
354
+ buildTokenizer: (json, config) => new PreTrainedTokenizer(json as never, config as never),
122
355
  hardware: { isMobile: /Mobile/i.test(navigator.userAgent) },
123
356
  };
124
357
 
@@ -190,14 +423,14 @@ minimal setup:
190
423
 
191
424
  ```ts
192
425
  import { createEngine, memoryKV, memoryBlobCache } from '@doccloak/core';
193
- import { AutoTokenizer } from '@huggingface/transformers';
426
+ import { PreTrainedTokenizer } from '@huggingface/transformers';
194
427
 
195
428
  const engine = createEngine({
196
429
  kv: memoryKV(),
197
430
  modelCache: memoryBlobCache(),
198
431
  fetch: (...args) => fetch(...args),
199
432
  wasm: { paths: 'node_modules/onnxruntime-web/dist/', numThreads: 1 },
200
- loadTokenizer: (hfModelId) => AutoTokenizer.from_pretrained(hfModelId),
433
+ buildTokenizer: (json, config) => new PreTrainedTokenizer(json as never, config as never),
201
434
  });
202
435
  await engine.ready;
203
436
  console.log(await engine.detect('Contact jane.doe@example.com or +48 601 123 456.'));
package/dist/doc.d.ts CHANGED
@@ -1,8 +1,53 @@
1
+ import { type ValueReplacement } from './docx.js';
2
+ /** Result of inspectDoc. */
3
+ export interface DocInspection {
4
+ /** fEncrypted or fObfuscated is set: readDocText / writeAnonymizedDoc refuse the file. */
5
+ encrypted: boolean;
6
+ /** fComplex is set (or Word 97 cQuickSaves > 0): readDocText / writeAnonymizedDoc refuse the file. */
7
+ fastSaved: boolean;
8
+ /** Streams that are copied through the export without being redacted. */
9
+ streams: {
10
+ /** Data stream (field data such as hyperlinks, pictures, OLE previews). Scanned and scrubbed in UTF-16LE only. */
11
+ data: boolean;
12
+ /** ObjectPool storage (embedded OLE documents). Not scrubbed. */
13
+ objectPool: boolean;
14
+ /** Macros storage (VBA project). Not scrubbed. */
15
+ macros: boolean;
16
+ };
17
+ }
18
+ /**
19
+ * Cheap look at a legacy .doc without parsing the piece table: which refusal
20
+ * flags are set and which non-text streams the file carries. Hosts use it to
21
+ * show a warning next to the export (ObjectPool / Macros are copied through
22
+ * unchanged, see writeAnonymizedDoc) or to explain a refusal up front.
23
+ * Throws only when the file is not a .doc at all.
24
+ */
25
+ export declare function inspectDoc(buffer: ArrayBuffer | Uint8Array): DocInspection;
26
+ /**
27
+ * Write a redacted copy of a legacy .doc.
28
+ *
29
+ * `replacements` are offsets into the text returned by readDocText. Main-text
30
+ * ranges are swapped for the replacement string through a rewritten piece
31
+ * table; ranges in subdocuments (footnotes, headers, comments) are overwritten
32
+ * in place. `valueReplacements` (same shape as writeAnonymizedDocx) add
33
+ * original values that were not located by offset, e.g. session entries.
34
+ *
35
+ * Every original value (from both sources) is then destroyed wherever else it
36
+ * still occurs as bytes, without moving any structure (see scrubStreamBytes).
37
+ *
38
+ * Refuses (UnsupportedDocumentError) fast-saved and encrypted files, see
39
+ * assertSupportedFib.
40
+ *
41
+ * Copied through untouched: ObjectPool (embedded OLE objects), Macros (VBA)
42
+ * and \x01CompObj. TODO(T196, T199): delete them with CFB.utils.cfb_del once
43
+ * the open-in-Word / LibreOffice experiment proves the result still opens.
44
+ * Until then hosts should call inspectDoc and warn when they are present.
45
+ */
1
46
  export declare function writeAnonymizedDoc(buffer: ArrayBuffer, replacements: Array<{
2
47
  start: number;
3
48
  end: number;
4
49
  replacement: string;
5
- }>): Promise<Blob>;
50
+ }>, valueReplacements?: ValueReplacement[]): Promise<Blob>;
6
51
  /**
7
52
  * Extract plain text from a legacy .doc (OLE2/Compound File Binary) file.
8
53
  * Parses the FIB and piece table to correctly extract text from both
@@ -11,3 +56,25 @@ export declare function writeAnonymizedDoc(buffer: ArrayBuffer, replacements: Ar
11
56
  * in those regions is visible to detection.
12
57
  */
13
58
  export declare function readDocText(buffer: ArrayBuffer): string;
59
+ /**
60
+ * Derive the byte-scrub needles from the original values: each value itself,
61
+ * each whitespace-separated token and each token stripped of surrounding
62
+ * punctuation, keeping those of at least MIN_SCRUB_NEEDLE characters.
63
+ * Matching is case-insensitive (see scrubStreamBytes), which covers every
64
+ * case variant (as written, lower, UPPER, Capitalised) in one pass.
65
+ *
66
+ * A needle that occurs (case-insensitively) inside one of the `replacements`
67
+ * is dropped: a token such as "Person" from "Leaky Person" would otherwise
68
+ * blank the "[PERSON_1]" placeholder, and a surrogate that keeps a word of
69
+ * the original ("Smith & Co" -> "Nowak & Co") would lose it. The full
70
+ * original value is never dropped.
71
+ */
72
+ export declare function buildScrubNeedles(originals: string[], replacements?: string[]): string[];
73
+ /**
74
+ * Overwrite every case-insensitive occurrence of `needle` in `bytes` with
75
+ * spaces of the same encoding. UTF-16LE: units are read at every byte
76
+ * offset (strings in the Table stream are not always 2-byte aligned) and
77
+ * replaced by 0x20 0x00; cp1252: bytes replaced by 0x20. Returns the number
78
+ * of hits. Stream length never changes.
79
+ */
80
+ export declare function scrubStreamBytes(bytes: Uint8Array, needle: string, encoding: 'utf16le' | 'cp1252'): number;