@doccloak/core 0.9.0 → 0.10.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.
- package/CHANGELOG.md +43 -0
- package/dist/dom/docx.d.ts +29 -2
- package/dist/dom/docx.js +64 -10
- package/dist/dom/index.d.ts +2 -0
- package/dist/dom/index.js +3 -0
- package/dist/dom/office.d.ts +75 -0
- package/dist/dom/office.js +116 -0
- package/dist/dom/opc.d.ts +42 -0
- package/dist/dom/opc.js +146 -0
- package/dist/dom/xlsx.d.ts +64 -0
- package/dist/dom/xlsx.js +280 -0
- package/dist/engine.d.ts +8 -5
- package/dist/engine.js +21 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/providers/bardsai.js +16 -16
- package/dist/providers/gliner-base.d.ts +92 -0
- package/dist/providers/gliner-base.js +357 -0
- package/dist/providers/gliner.d.ts +22 -6
- package/dist/providers/gliner.js +43 -20
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,49 @@ 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.10.0] - 2026-09-23
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **GLiNER PII Base provider (T122):** new `gliner-base` provider for
|
|
12
|
+
`knowledgator/gliner-pii-base-v1.0` (~197 MB quint8, Apache-2.0,
|
|
13
|
+
pinned revision + SHA-256). Span-level (`markerV0`) decoding with
|
|
14
|
+
zero-shot custom labels; strongest option for English and dates.
|
|
15
|
+
Exported as `GlinerBaseProvider` plus `GLINER_BASE_MODEL_URL` /
|
|
16
|
+
`_REVISION` / `_SHA256`, `buildSpanIndices` and `decodeSpanLogits`.
|
|
17
|
+
`ProviderId` now includes `'gliner-base'` and `createEngine` registers it.
|
|
18
|
+
|
|
19
|
+
- **Office file redaction (T110):** `@doccloak/core/dom` gains
|
|
20
|
+
`readXlsx` / `writeAnonymizedXlsx` / `isExcelFile` and a
|
|
21
|
+
detection-driven `redactOfficeFile` / `analyzeOfficeFile` flow for
|
|
22
|
+
.docx and .xlsx that keeps placeholders consistent with a live
|
|
23
|
+
`AnonymizationSession`. `writeAnonymizedDocx` accepts an option to
|
|
24
|
+
accept tracked changes (deletions dropped, insertions unwrapped,
|
|
25
|
+
author/date change records removed). Defaults preserve the previous
|
|
26
|
+
output byte-for-byte.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- **Light model swapped (T121):** the `gliner` provider now loads
|
|
31
|
+
`knowledgator/gliner-pii-small-v1.0` (~83 MB, same tokenizer family)
|
|
32
|
+
instead of `gliner-pii-edge-v1.0`. Cached copies of the old edge model
|
|
33
|
+
are evicted best-effort on load. Labels read "GLiNER PII Small".
|
|
34
|
+
|
|
35
|
+
- **Provider descriptions (T127):** registry copy reflects the 2026-08
|
|
36
|
+
14-language benchmark. `pickDefaultProvider` is unchanged: `bardsai` on
|
|
37
|
+
capable devices, `gliner` on mobile / low-memory devices.
|
|
38
|
+
|
|
39
|
+
- **ONNX Runtime 1.29.0:** providers import `onnxruntime-web/webgpu`
|
|
40
|
+
(native WebGPU EP with wasm fallback). Hosts that self-host the ORT
|
|
41
|
+
runtime files must now serve `ort-wasm-simd-threaded.asyncify.wasm`
|
|
42
|
+
and `.asyncify.mjs` instead of the plain / `.jsep` pair.
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- **bardsai with transformers.js 4.x:** subtoken alignment no longer calls
|
|
47
|
+
the removed `tokenizer.model.convert_ids_to_tokens` (which made
|
|
48
|
+
detection throw on transformers.js 4); it uses `tokenizer.tokenize()`.
|
|
49
|
+
|
|
7
50
|
## [0.9.0] - 2026-08-21
|
|
8
51
|
|
|
9
52
|
### Added
|
package/dist/dom/docx.d.ts
CHANGED
|
@@ -13,8 +13,10 @@ export { normalizeReplacements };
|
|
|
13
13
|
export type { ValueReplacement };
|
|
14
14
|
/**
|
|
15
15
|
* Represents a text node in the docx XML with its position in the flat text.
|
|
16
|
+
* Also reused by the xlsx module (T110): the same flat-text replacement
|
|
17
|
+
* machinery applies to any XML part whose text lives in leaf elements.
|
|
16
18
|
*/
|
|
17
|
-
interface TextNodeMapping {
|
|
19
|
+
export interface TextNodeMapping {
|
|
18
20
|
/** The <w:t> element */
|
|
19
21
|
element: Element;
|
|
20
22
|
/** Start index in the flat text */
|
|
@@ -59,6 +61,31 @@ interface ContentPartExtraction {
|
|
|
59
61
|
* Read a .docx file and extract its text content with position mapping.
|
|
60
62
|
*/
|
|
61
63
|
export declare function readDocx(file: File): Promise<DocxExtraction>;
|
|
64
|
+
/**
|
|
65
|
+
* Options for writeAnonymizedDocx (T110). All default to off, preserving the
|
|
66
|
+
* historical output byte-for-byte for existing callers.
|
|
67
|
+
*/
|
|
68
|
+
export interface DocxWriteOptions {
|
|
69
|
+
/**
|
|
70
|
+
* Accept and remove tracked changes: deletions (w:del and friends) are
|
|
71
|
+
* dropped, insertions (w:ins, w:moveTo) are unwrapped into plain runs, and
|
|
72
|
+
* property-change records (w:rPrChange etc., which carry author and date
|
|
73
|
+
* fingerprints) are removed. Oregon SB 2025-205 flags revision metadata as
|
|
74
|
+
* identifying, so the file-redaction flow turns this on.
|
|
75
|
+
*/
|
|
76
|
+
acceptTrackedChanges?: boolean;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Apply a list of flat-text replacements to mapped text nodes, handling
|
|
80
|
+
* replacements that span multiple elements. Ranges are normalized (sorted,
|
|
81
|
+
* overlaps clamped) and applied end-to-start so earlier positions stay valid.
|
|
82
|
+
* Shared by the docx and xlsx writers (T110).
|
|
83
|
+
*/
|
|
84
|
+
export declare function applyTextReplacements(textNodes: TextNodeMapping[], replacements: Array<{
|
|
85
|
+
start: number;
|
|
86
|
+
end: number;
|
|
87
|
+
replacement: string;
|
|
88
|
+
}>): void;
|
|
62
89
|
/**
|
|
63
90
|
* Apply text replacements to the docx XML, preserving all formatting.
|
|
64
91
|
* Takes the original extraction and a list of replacements (sorted by position),
|
|
@@ -71,7 +98,7 @@ export declare function writeAnonymizedDocx(extraction: DocxExtraction, replacem
|
|
|
71
98
|
start: number;
|
|
72
99
|
end: number;
|
|
73
100
|
replacement: string;
|
|
74
|
-
}>, valueReplacements?: ValueReplacement[]): Promise<Blob>;
|
|
101
|
+
}>, valueReplacements?: ValueReplacement[], options?: DocxWriteOptions): Promise<Blob>;
|
|
75
102
|
/**
|
|
76
103
|
* Get the file extension from a filename.
|
|
77
104
|
*/
|
package/dist/dom/docx.js
CHANGED
|
@@ -153,6 +153,63 @@ export async function readDocx(file) {
|
|
|
153
153
|
contentParts,
|
|
154
154
|
};
|
|
155
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Apply a list of flat-text replacements to mapped text nodes, handling
|
|
158
|
+
* replacements that span multiple elements. Ranges are normalized (sorted,
|
|
159
|
+
* overlaps clamped) and applied end-to-start so earlier positions stay valid.
|
|
160
|
+
* Shared by the docx and xlsx writers (T110).
|
|
161
|
+
*/
|
|
162
|
+
export function applyTextReplacements(textNodes, replacements) {
|
|
163
|
+
const sorted = normalizeReplacements(replacements);
|
|
164
|
+
for (const repl of [...sorted].reverse()) {
|
|
165
|
+
applyReplacement(textNodes, repl.start, repl.end, repl.replacement);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Tracked-change elements that are removed outright when accepting changes:
|
|
170
|
+
* deleted content, move sources, and every *PrChange record (each carries a
|
|
171
|
+
* w:author/w:date pair identifying the reviser).
|
|
172
|
+
*/
|
|
173
|
+
const TRACKED_REMOVE = new Set([
|
|
174
|
+
'del', 'moveFrom', 'delText', 'delInstrText',
|
|
175
|
+
'rPrChange', 'pPrChange', 'sectPrChange', 'tblPrChange', 'tblGridChange',
|
|
176
|
+
'tcPrChange', 'trPrChange', 'numberingChange',
|
|
177
|
+
'cellDel', 'cellIns', 'cellMerge',
|
|
178
|
+
'moveFromRangeStart', 'moveFromRangeEnd', 'moveToRangeStart', 'moveToRangeEnd',
|
|
179
|
+
'customXmlDelRangeStart', 'customXmlDelRangeEnd',
|
|
180
|
+
'customXmlInsRangeStart', 'customXmlInsRangeEnd',
|
|
181
|
+
'customXmlMoveFromRangeStart', 'customXmlMoveFromRangeEnd',
|
|
182
|
+
'customXmlMoveToRangeStart', 'customXmlMoveToRangeEnd',
|
|
183
|
+
]);
|
|
184
|
+
/** Tracked-change wrappers whose content is kept: the wrapper is unwrapped. */
|
|
185
|
+
const TRACKED_UNWRAP = new Set(['ins', 'moveTo']);
|
|
186
|
+
/**
|
|
187
|
+
* Accept all tracked changes in one content part: remove deletions and
|
|
188
|
+
* change records, unwrap insertions. Operates on the live DOM after text
|
|
189
|
+
* replacements have been applied, so redaction offsets are unaffected.
|
|
190
|
+
*/
|
|
191
|
+
function acceptTrackedChangesInPart(xmlDoc) {
|
|
192
|
+
// Snapshot first: live HTMLCollections reorder while elements are removed.
|
|
193
|
+
const all = xmlDoc.getElementsByTagName('*');
|
|
194
|
+
const snapshot = [];
|
|
195
|
+
for (let i = 0; i < all.length; i++)
|
|
196
|
+
snapshot.push(all[i]);
|
|
197
|
+
for (const el of snapshot) {
|
|
198
|
+
if (el.namespaceURI !== W_NS)
|
|
199
|
+
continue;
|
|
200
|
+
if (TRACKED_REMOVE.has(el.localName)) {
|
|
201
|
+
el.parentNode?.removeChild(el);
|
|
202
|
+
}
|
|
203
|
+
else if (TRACKED_UNWRAP.has(el.localName)) {
|
|
204
|
+
const parent = el.parentNode;
|
|
205
|
+
if (!parent)
|
|
206
|
+
continue;
|
|
207
|
+
while (el.firstChild)
|
|
208
|
+
parent.insertBefore(el.firstChild, el);
|
|
209
|
+
parent.removeChild(el);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
156
213
|
/**
|
|
157
214
|
* Apply text replacements to the docx XML, preserving all formatting.
|
|
158
215
|
* Takes the original extraction and a list of replacements (sorted by position),
|
|
@@ -161,20 +218,17 @@ export async function readDocx(file) {
|
|
|
161
218
|
*
|
|
162
219
|
* Returns a new .docx file as a Blob.
|
|
163
220
|
*/
|
|
164
|
-
export async function writeAnonymizedDocx(extraction, replacements, valueReplacements = []) {
|
|
165
|
-
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
// Build a map of flat-text ranges that need replacement
|
|
169
|
-
// Process from end to start to preserve positions
|
|
170
|
-
const reverseSorted = [...sorted].reverse();
|
|
171
|
-
for (const repl of reverseSorted) {
|
|
172
|
-
applyReplacement(extraction.textNodes, repl.start, repl.end, repl.replacement);
|
|
173
|
-
}
|
|
221
|
+
export async function writeAnonymizedDocx(extraction, replacements, valueReplacements = [], options = {}) {
|
|
222
|
+
// Apply flat-text replacements to the mapped text nodes (spanning runs when
|
|
223
|
+
// needed), from end to start so positions stay valid.
|
|
224
|
+
applyTextReplacements(extraction.textNodes, replacements);
|
|
174
225
|
// Serialize all modified XML documents back to the zip
|
|
175
226
|
const serializer = new XMLSerializer();
|
|
176
227
|
for (const part of extraction.contentParts) {
|
|
177
228
|
sanitizeContentPartAttributes(part.xmlDoc, valueReplacements);
|
|
229
|
+
if (options.acceptTrackedChanges) {
|
|
230
|
+
acceptTrackedChangesInPart(part.xmlDoc);
|
|
231
|
+
}
|
|
178
232
|
const xmlStr = serializer.serializeToString(part.xmlDoc);
|
|
179
233
|
extraction.zip.file(part.path, xmlStr);
|
|
180
234
|
}
|
package/dist/dom/index.d.ts
CHANGED
package/dist/dom/index.js
CHANGED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @doccloak/core/dom - detection-driven office file redaction (T110).
|
|
3
|
+
*
|
|
4
|
+
* Ties the format modules (docx.ts, xlsx.ts) to the detection pipeline and an
|
|
5
|
+
* AnonymizationSession, so a host can hand over a File plus its live session
|
|
6
|
+
* and get back a redacted copy whose placeholders are consistent with every
|
|
7
|
+
* prompt redacted in the same session: an AI reply that mentions the file's
|
|
8
|
+
* placeholders restores through the same session map.
|
|
9
|
+
*
|
|
10
|
+
* PDF is intentionally NOT handled here: layout-preserving PDF redaction is
|
|
11
|
+
* out of scope for this module.
|
|
12
|
+
*/
|
|
13
|
+
import type { DetectedEntity } from '../types.js';
|
|
14
|
+
import type { AnonymizationSession } from '../session.js';
|
|
15
|
+
/** File kinds the office redaction flow can regenerate. */
|
|
16
|
+
export type OfficeFileKind = 'docx' | 'xlsx';
|
|
17
|
+
/**
|
|
18
|
+
* Classify a filename into a supported office kind, or null when the format
|
|
19
|
+
* cannot be regenerated by this module (.doc has its own binary writer in
|
|
20
|
+
* src/doc.ts; PDF output is permanently out of scope for browser hosts).
|
|
21
|
+
*/
|
|
22
|
+
export declare function officeFileKind(filename: string): OfficeFileKind | null;
|
|
23
|
+
/** A detect function, typically DocCloakEngine.detect or detectEntities. */
|
|
24
|
+
export type DetectFn = (text: string) => Promise<DetectedEntity[]>;
|
|
25
|
+
export interface OfficeAnalysis {
|
|
26
|
+
kind: OfficeFileKind;
|
|
27
|
+
/** Flat text extracted from every text-bearing part of the package */
|
|
28
|
+
plainText: string;
|
|
29
|
+
/** Entities detected in the flat text (offsets refer to plainText) */
|
|
30
|
+
entities: DetectedEntity[];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Extract a file's text and run detection WITHOUT touching the session. Hosts
|
|
34
|
+
* use this to show the user what was found before asking for consent to
|
|
35
|
+
* regenerate; a cancelled upload then leaves no trace in the session map.
|
|
36
|
+
* Extraction is deterministic, so entities returned here can be passed to
|
|
37
|
+
* redactOfficeFile later and land on identical offsets.
|
|
38
|
+
*/
|
|
39
|
+
export declare function analyzeOfficeFile(file: File, detect: DetectFn): Promise<OfficeAnalysis>;
|
|
40
|
+
export interface RedactOfficeFileOptions {
|
|
41
|
+
/** The live session; file placeholders join the same map as prompt ones. */
|
|
42
|
+
session: AnonymizationSession;
|
|
43
|
+
/**
|
|
44
|
+
* Entities to redact, from a prior analyzeOfficeFile call (offsets refer to
|
|
45
|
+
* that call's plainText). When omitted, detect is required and is run here.
|
|
46
|
+
*/
|
|
47
|
+
entities?: DetectedEntity[];
|
|
48
|
+
/** Detect function; used only when entities were not supplied. */
|
|
49
|
+
detect?: DetectFn;
|
|
50
|
+
/** Accept and strip tracked changes in docx output. Default: true. */
|
|
51
|
+
acceptTrackedChanges?: boolean;
|
|
52
|
+
}
|
|
53
|
+
export interface OfficeRedactionResult {
|
|
54
|
+
kind: OfficeFileKind;
|
|
55
|
+
/** The redacted file; the input file/buffer is never modified. */
|
|
56
|
+
blob: Blob;
|
|
57
|
+
/** Suggested output name: "report.docx" becomes "report.redacted.docx". */
|
|
58
|
+
suggestedName: string;
|
|
59
|
+
/** The entities that were redacted (offsets refer to plainText) */
|
|
60
|
+
entities: DetectedEntity[];
|
|
61
|
+
/** Flat text the detection ran on */
|
|
62
|
+
plainText: string;
|
|
63
|
+
/** The redacted flat text (placeholders applied), for preview UIs */
|
|
64
|
+
redactedText: string;
|
|
65
|
+
}
|
|
66
|
+
/** "report.docx" -> "report.redacted.docx"; extensionless names get a suffix. */
|
|
67
|
+
export declare function redactedFileName(filename: string): string;
|
|
68
|
+
/**
|
|
69
|
+
* Redact a DOCX or XLSX file: extract text, detect (or reuse supplied
|
|
70
|
+
* entities), map every entity through the session, and regenerate the file
|
|
71
|
+
* with placeholders spliced into the XML, formatting preserved and metadata
|
|
72
|
+
* stripped. Placeholder issuing goes through session.anonymize, so a value
|
|
73
|
+
* already seen in a prompt reuses its existing placeholder and vice versa.
|
|
74
|
+
*/
|
|
75
|
+
export declare function redactOfficeFile(file: File, options: RedactOfficeFileOptions): Promise<OfficeRedactionResult>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @doccloak/core/dom - detection-driven office file redaction (T110).
|
|
3
|
+
*
|
|
4
|
+
* Ties the format modules (docx.ts, xlsx.ts) to the detection pipeline and an
|
|
5
|
+
* AnonymizationSession, so a host can hand over a File plus its live session
|
|
6
|
+
* and get back a redacted copy whose placeholders are consistent with every
|
|
7
|
+
* prompt redacted in the same session: an AI reply that mentions the file's
|
|
8
|
+
* placeholders restores through the same session map.
|
|
9
|
+
*
|
|
10
|
+
* PDF is intentionally NOT handled here: layout-preserving PDF redaction is
|
|
11
|
+
* out of scope for this module.
|
|
12
|
+
*/
|
|
13
|
+
import { getFileExtension, readDocx, writeAnonymizedDocx } from "./docx.js";
|
|
14
|
+
import { readXlsx, writeAnonymizedXlsx } from "./xlsx.js";
|
|
15
|
+
/**
|
|
16
|
+
* Classify a filename into a supported office kind, or null when the format
|
|
17
|
+
* cannot be regenerated by this module (.doc has its own binary writer in
|
|
18
|
+
* src/doc.ts; PDF output is permanently out of scope for browser hosts).
|
|
19
|
+
*/
|
|
20
|
+
export function officeFileKind(filename) {
|
|
21
|
+
const ext = getFileExtension(filename);
|
|
22
|
+
if (ext === 'docx')
|
|
23
|
+
return 'docx';
|
|
24
|
+
if (ext === 'xlsx')
|
|
25
|
+
return 'xlsx';
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Extract a file's text and run detection WITHOUT touching the session. Hosts
|
|
30
|
+
* use this to show the user what was found before asking for consent to
|
|
31
|
+
* regenerate; a cancelled upload then leaves no trace in the session map.
|
|
32
|
+
* Extraction is deterministic, so entities returned here can be passed to
|
|
33
|
+
* redactOfficeFile later and land on identical offsets.
|
|
34
|
+
*/
|
|
35
|
+
export async function analyzeOfficeFile(file, detect) {
|
|
36
|
+
const kind = officeFileKind(file.name);
|
|
37
|
+
if (!kind)
|
|
38
|
+
throw new Error(`Unsupported office file: ${file.name}`);
|
|
39
|
+
const plainText = kind === 'docx'
|
|
40
|
+
? (await readDocx(file)).plainText
|
|
41
|
+
: (await readXlsx(file)).plainText;
|
|
42
|
+
const entities = await detect(plainText);
|
|
43
|
+
return { kind, plainText, entities };
|
|
44
|
+
}
|
|
45
|
+
/** "report.docx" -> "report.redacted.docx"; extensionless names get a suffix. */
|
|
46
|
+
export function redactedFileName(filename) {
|
|
47
|
+
const dot = filename.lastIndexOf('.');
|
|
48
|
+
if (dot <= 0)
|
|
49
|
+
return `${filename}.redacted`;
|
|
50
|
+
return `${filename.slice(0, dot)}.redacted${filename.slice(dot)}`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Redact a DOCX or XLSX file: extract text, detect (or reuse supplied
|
|
54
|
+
* entities), map every entity through the session, and regenerate the file
|
|
55
|
+
* with placeholders spliced into the XML, formatting preserved and metadata
|
|
56
|
+
* stripped. Placeholder issuing goes through session.anonymize, so a value
|
|
57
|
+
* already seen in a prompt reuses its existing placeholder and vice versa.
|
|
58
|
+
*/
|
|
59
|
+
export async function redactOfficeFile(file, options) {
|
|
60
|
+
const kind = officeFileKind(file.name);
|
|
61
|
+
if (!kind)
|
|
62
|
+
throw new Error(`Unsupported office file: ${file.name}`);
|
|
63
|
+
const extraction = kind === 'docx' ? await readDocx(file) : await readXlsx(file);
|
|
64
|
+
const plainText = extraction.plainText;
|
|
65
|
+
let entities = options.entities;
|
|
66
|
+
if (!entities) {
|
|
67
|
+
if (!options.detect) {
|
|
68
|
+
throw new Error('redactOfficeFile requires either entities or a detect function');
|
|
69
|
+
}
|
|
70
|
+
entities = await options.detect(plainText);
|
|
71
|
+
}
|
|
72
|
+
const { session } = options;
|
|
73
|
+
// Drop entities whose offsets do not match this extraction (defensive: a
|
|
74
|
+
// stale analysis of a different file must not splice garbage), then map
|
|
75
|
+
// through the session in reading order. Surrogate mode maps PERSON values
|
|
76
|
+
// first, mirroring AnonymizationSession.anonymizeText, so derived
|
|
77
|
+
// surrogates (emails from owner names) stay consistent with prompts.
|
|
78
|
+
const valid = entities
|
|
79
|
+
.filter((e) => e.start >= 0 && e.end <= plainText.length && e.start < e.end
|
|
80
|
+
&& plainText.slice(e.start, e.end) === e.value)
|
|
81
|
+
.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
82
|
+
if (session.getMode() === 'surrogate') {
|
|
83
|
+
for (const entity of valid) {
|
|
84
|
+
if (entity.type === 'PERSON')
|
|
85
|
+
session.anonymize(entity.value, entity.type);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const replacements = valid.map((entity) => ({
|
|
89
|
+
start: entity.start,
|
|
90
|
+
end: entity.end,
|
|
91
|
+
replacement: session.anonymize(entity.value, entity.type),
|
|
92
|
+
}));
|
|
93
|
+
// Value-level scrubbing (relationship targets, formulas, field instructions)
|
|
94
|
+
// uses the whole session map, so a value redacted in an earlier prompt is
|
|
95
|
+
// also caught when it hides in a hyperlink target of this file.
|
|
96
|
+
const valueReplacements = session.getEntries()
|
|
97
|
+
.filter((entry) => entry.replacement !== '________')
|
|
98
|
+
.map((entry) => ({ value: entry.original, replacement: entry.replacement }));
|
|
99
|
+
let redactedText = plainText;
|
|
100
|
+
for (const repl of [...replacements].sort((a, b) => b.start - a.start)) {
|
|
101
|
+
redactedText = redactedText.slice(0, repl.start) + repl.replacement + redactedText.slice(repl.end);
|
|
102
|
+
}
|
|
103
|
+
const blob = kind === 'docx'
|
|
104
|
+
? await writeAnonymizedDocx(extraction, replacements, valueReplacements, {
|
|
105
|
+
acceptTrackedChanges: options.acceptTrackedChanges ?? true,
|
|
106
|
+
})
|
|
107
|
+
: await writeAnonymizedXlsx(extraction, replacements, valueReplacements);
|
|
108
|
+
return {
|
|
109
|
+
kind,
|
|
110
|
+
blob,
|
|
111
|
+
suggestedName: redactedFileName(file.name),
|
|
112
|
+
entities: valid,
|
|
113
|
+
plainText,
|
|
114
|
+
redactedText,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
import type JSZip from 'jszip';
|
|
12
|
+
import type { ValueReplacement } from '../docx.js';
|
|
13
|
+
/** Parse one XML part from the zip; null when absent or malformed. */
|
|
14
|
+
export declare function readXmlPart(zip: JSZip, path: string): Promise<Document | null>;
|
|
15
|
+
/** Serialize an XML document back into the zip at the given path. */
|
|
16
|
+
export declare function writeXmlPart(zip: JSZip, path: string, doc: Document): void;
|
|
17
|
+
/** Blank the text content of every element whose local name is in the set. */
|
|
18
|
+
export declare function blankElementsByLocalName(doc: Document, names: Set<string>): void;
|
|
19
|
+
/**
|
|
20
|
+
* Replace every occurrence of each sensitive value (case-insensitive) in a
|
|
21
|
+
* string. A replacer function keeps '$' sequences in values/placeholders inert.
|
|
22
|
+
*/
|
|
23
|
+
export declare function replaceSensitiveValues(input: string, valueReplacements: ValueReplacement[], urlEncode: boolean): string;
|
|
24
|
+
/**
|
|
25
|
+
* Scrub the shared docProps parts:
|
|
26
|
+
* - core.xml: author identities and content-describing fields
|
|
27
|
+
* - app.xml: organization and template/path hints
|
|
28
|
+
* - custom.xml: every leaf value (property names/structure kept)
|
|
29
|
+
*/
|
|
30
|
+
export declare function scrubDocPropsParts(zip: JSZip): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* Remove the package thumbnail (a rendered image of page/sheet one leaks
|
|
33
|
+
* content wholesale) together with its package relationship and content-type
|
|
34
|
+
* override.
|
|
35
|
+
*/
|
|
36
|
+
export declare function removePackageThumbnail(zip: JSZip): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Scrub external relationship targets (mailto:/https: hyperlinks keep the raw
|
|
39
|
+
* sensitive value even when the display text was redacted) in every .rels file
|
|
40
|
+
* matched by the given pattern.
|
|
41
|
+
*/
|
|
42
|
+
export declare function scrubExternalRelTargets(zip: JSZip, relsPattern: RegExp, valueReplacements: ValueReplacement[]): Promise<void>;
|
package/dist/dom/opc.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
export function replaceSensitiveValues(input, valueReplacements, urlEncode) {
|
|
39
|
+
let result = input;
|
|
40
|
+
for (const { value, replacement } of valueReplacements) {
|
|
41
|
+
if (!value)
|
|
42
|
+
continue;
|
|
43
|
+
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
44
|
+
const substitute = urlEncode ? encodeURIComponent(replacement) : replacement;
|
|
45
|
+
result = result.replace(new RegExp(escaped, 'gi'), () => substitute);
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Scrub the shared docProps parts:
|
|
51
|
+
* - core.xml: author identities and content-describing fields
|
|
52
|
+
* - app.xml: organization and template/path hints
|
|
53
|
+
* - custom.xml: every leaf value (property names/structure kept)
|
|
54
|
+
*/
|
|
55
|
+
export async function scrubDocPropsParts(zip) {
|
|
56
|
+
const core = await readXmlPart(zip, 'docProps/core.xml');
|
|
57
|
+
if (core) {
|
|
58
|
+
blankElementsByLocalName(core, new Set([
|
|
59
|
+
'creator', 'lastModifiedBy', 'title', 'subject', 'description', 'keywords', 'category',
|
|
60
|
+
]));
|
|
61
|
+
writeXmlPart(zip, 'docProps/core.xml', core);
|
|
62
|
+
}
|
|
63
|
+
const app = await readXmlPart(zip, 'docProps/app.xml');
|
|
64
|
+
if (app) {
|
|
65
|
+
blankElementsByLocalName(app, new Set(['Company', 'Manager', 'HyperlinkBase', 'Template']));
|
|
66
|
+
writeXmlPart(zip, 'docProps/app.xml', app);
|
|
67
|
+
}
|
|
68
|
+
const custom = await readXmlPart(zip, 'docProps/custom.xml');
|
|
69
|
+
if (custom) {
|
|
70
|
+
const props = custom.getElementsByTagName('*');
|
|
71
|
+
for (let i = 0; i < props.length; i++) {
|
|
72
|
+
const el = props[i];
|
|
73
|
+
if (el.localName !== 'property' && el.childElementCount === 0 && (el.textContent ?? '') !== '') {
|
|
74
|
+
el.textContent = '';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
writeXmlPart(zip, 'docProps/custom.xml', custom);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Remove the package thumbnail (a rendered image of page/sheet one leaks
|
|
82
|
+
* content wholesale) together with its package relationship and content-type
|
|
83
|
+
* override.
|
|
84
|
+
*/
|
|
85
|
+
export async function removePackageThumbnail(zip) {
|
|
86
|
+
const thumbnailPaths = [];
|
|
87
|
+
zip.forEach((relativePath) => {
|
|
88
|
+
if (/^docProps\/thumbnail\.[a-z]+$/i.test(relativePath)) {
|
|
89
|
+
thumbnailPaths.push(relativePath);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
if (thumbnailPaths.length === 0)
|
|
93
|
+
return;
|
|
94
|
+
for (const path of thumbnailPaths)
|
|
95
|
+
zip.remove(path);
|
|
96
|
+
const rootRels = await readXmlPart(zip, '_rels/.rels');
|
|
97
|
+
if (rootRels) {
|
|
98
|
+
const relationships = rootRels.getElementsByTagName('Relationship');
|
|
99
|
+
for (let i = relationships.length - 1; i >= 0; i--) {
|
|
100
|
+
const type = relationships[i].getAttribute('Type') ?? '';
|
|
101
|
+
if (type.endsWith('/thumbnail')) {
|
|
102
|
+
relationships[i].parentNode?.removeChild(relationships[i]);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
writeXmlPart(zip, '_rels/.rels', rootRels);
|
|
106
|
+
}
|
|
107
|
+
const contentTypes = await readXmlPart(zip, '[Content_Types].xml');
|
|
108
|
+
if (contentTypes) {
|
|
109
|
+
const overrides = contentTypes.getElementsByTagName('Override');
|
|
110
|
+
for (let i = overrides.length - 1; i >= 0; i--) {
|
|
111
|
+
const partName = overrides[i].getAttribute('PartName') ?? '';
|
|
112
|
+
if (thumbnailPaths.some((p) => partName === '/' + p)) {
|
|
113
|
+
overrides[i].parentNode?.removeChild(overrides[i]);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
writeXmlPart(zip, '[Content_Types].xml', contentTypes);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Scrub external relationship targets (mailto:/https: hyperlinks keep the raw
|
|
121
|
+
* sensitive value even when the display text was redacted) in every .rels file
|
|
122
|
+
* matched by the given pattern.
|
|
123
|
+
*/
|
|
124
|
+
export async function scrubExternalRelTargets(zip, relsPattern, valueReplacements) {
|
|
125
|
+
const relsPaths = [];
|
|
126
|
+
zip.forEach((relativePath) => {
|
|
127
|
+
if (relsPattern.test(relativePath))
|
|
128
|
+
relsPaths.push(relativePath);
|
|
129
|
+
});
|
|
130
|
+
for (const relsPath of relsPaths.sort()) {
|
|
131
|
+
const rels = await readXmlPart(zip, relsPath);
|
|
132
|
+
if (!rels)
|
|
133
|
+
continue;
|
|
134
|
+
const relationships = rels.getElementsByTagName('Relationship');
|
|
135
|
+
for (let i = relationships.length - 1; i >= 0; i--) {
|
|
136
|
+
const rel = relationships[i];
|
|
137
|
+
if (rel.getAttribute('TargetMode') === 'External') {
|
|
138
|
+
const target = rel.getAttribute('Target') ?? '';
|
|
139
|
+
const scrubbed = replaceSensitiveValues(target, valueReplacements, true);
|
|
140
|
+
if (scrubbed !== target)
|
|
141
|
+
rel.setAttribute('Target', scrubbed);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
writeXmlPart(zip, relsPath, rels);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -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 {};
|