@doccloak/core 0.8.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 CHANGED
@@ -4,6 +4,157 @@ 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
+
50
+ ## [0.9.0] - 2026-08-21
51
+
52
+ ### Added
53
+
54
+ - **Secrets & credentials detection (T099/T116):** new `SECRET` and
55
+ `API_KEY` entity types plus eleven universal regex rules covering AWS
56
+ access keys, GitHub/Slack tokens, OpenAI/Anthropic/Google API keys,
57
+ PEM private-key blocks, JWTs, connection strings with embedded
58
+ credentials, `secret=`-style assignments and high-entropy tokens, and
59
+ a GB driving-licence rule. Credentials never receive realistic
60
+ stand-ins — even in surrogate mode they always fall back to typed
61
+ placeholders, since a same-shape fake key still reads as a live
62
+ credential.
63
+
64
+ - **Tolerant restore-token matching (T098):** new side-effect-free
65
+ `@doccloak/core/restore-tokens` export that restores placeholders LLM
66
+ replies have mangled — case changes (`[person_1]`), separator swaps
67
+ (`[PERSON 1]`), markdown wrapping (`**[PERSON_1]**`), dropped brackets
68
+ and stray edge punctuation. Restoration only happens on an unambiguous
69
+ canonical hit; anything ambiguous is left exactly as the model wrote
70
+ it, because a wrong restore silently corrupts the document while a
71
+ missed one is visible and recoverable.
72
+
73
+ - **Model integrity verification:** `fetchModelBlob` verifies downloads
74
+ against a pinned SHA-256 when the caller provides one and rejects
75
+ mismatches with a dedicated `ModelIntegrityError` (the corrupt copy is
76
+ discarded; verified cache entries carry a marker so they are not
77
+ re-hashed). The GLiNER and bardsai providers export their pinned
78
+ `*_MODEL_URL` / `*_MODEL_REVISION` / `*_MODEL_SHA256` constants.
79
+
80
+ - **Multilingual surrogates (T073):** realistic stand-ins now follow the
81
+ original's language beyond EN/PL. New locale packs matching every
82
+ region the web app's language picker offers: German, French, Spanish,
83
+ Italian, Czech, Ukrainian, Dutch, Portuguese, Swedish, Norwegian,
84
+ Danish, Finnish, Japanese and Chinese: given-name/surname pools with
85
+ gender detection and surname feminization (-ova, -ska/-cka), month
86
+ names for date shifting (genitive forms where dates inflect), street
87
+ formats (Hauptstrasse 5 / 12 rue des Lilas / Calle del Sol 3 / Via dei
88
+ Tigli 10 / vul. Zelena 98) and company pools with locale-detecting
89
+ legal suffixes (GmbH, SARL, S.r.l., s.r.o., TOV) kept verbatim.
90
+ Locale detection scores name-pool and surname matches per token
91
+ (shared names like "Marie" resolve to the best-matching language);
92
+ Cyrillic script resolves decisively. Non-Latin surrogate names fall
93
+ back to an unrelated ASCII identity in derived email local parts.
94
+ CJK names compose surname-first without spaces via a per-pack
95
+ formatPerson hook (Chinese given-name length mirrored; kana decides
96
+ Japanese, surname-prefix scoring separates kanji-only Japanese from
97
+ Chinese, a Han script fallback catches unpooled surnames); CJK dates
98
+ (2024\u5e743\u670815\u65e5) shift in place; Finnish dates keep the partitive.
99
+ Bare legal suffixes (AB/AS) now require a preceding space, so 'SAAB'
100
+ never sheds an 'AB'. Adding further locales is data-only (one
101
+ LocalePack entry).
102
+
103
+ - **Person-variant unification (T057):** repeated mentions of the same
104
+ person share one replacement. When a new PERSON value is a name-variant
105
+ of an already-mapped one (case-insensitive token subset with at least
106
+ one substantive shared token: "John" and "Smith" both match
107
+ "John Smith"; "Mr. Smith" and "Person 11" do not), the session reuses
108
+ its placeholder/surrogate instead of issuing a new one. The reverse map
109
+ keeps the LONGEST variant, so restore always yields the fullest known
110
+ form; `renameLabel` renames the whole variant group; `deserialize`
111
+ keeps the longest variant canonical regardless of entry order.
112
+
113
+ - **Surrogate replacement mode (T043):** `AnonymizationSession` accepts a
114
+ new `'surrogate'` mode in which replacements are realistic,
115
+ shape-preserving stand-ins instead of typed placeholders: locale-aware
116
+ fake names (EN + PL, gendered Polish surname endings), emails whose
117
+ local part mirrors the in-session person surrogate on reserved example
118
+ domains, dates shifted by a per-session day offset while keeping their
119
+ format, phones/IBANs/IDs/cards that keep their shape (IBAN mod-97,
120
+ PESEL and Luhn checksums valid; generated PESELs encode a 19th-century
121
+ birth date and SSN shapes use the never-allocated 900-999 area, so a
122
+ generated identifier can never be a real living person's). Generation
123
+ is deterministic per session (seeded by a per-session salt, new
124
+ `src/surrogates.ts` module) and collision-safe against every original
125
+ value and every already-issued replacement in the session. The default
126
+ mode remains `'labeled'` (typed placeholders); restore is unchanged
127
+ exact-literal lookup. Surrogate-mode `serialize()` wraps the entry
128
+ array in `{ mode, salt, entries }` so `deserialize()` reproduces the
129
+ mode and salt; placeholder/blanked sessions keep the plain-array
130
+ Python-parity schema byte-identically.
131
+
132
+ ### Changed
133
+
134
+ - **Breaking (user-visible output):** `AnonymizationSession` now generates
135
+ typed placeholders with per-type counters, e.g. `[PERSON_1]`, `[EMAIL_1]`,
136
+ `[DATE_2]`, instead of the global `<<REDACTED_N>>`. Typed placeholders
137
+ keep LLM answers coherent (pronouns, date reasoning, formatting), make
138
+ protected text human-readable, and avoid the `<<...>>` token that
139
+ Markdown-rendering chats can mangle. Placeholder type names are the
140
+ stable uppercase `EntityType` ids, so every generated token matches
141
+ `\[[A-Z_]+_\d+\]`.
142
+ - Blanked-mode replacements no longer consume counter numbers, so labeled
143
+ placeholders number contiguously per type.
144
+ - `AnonymizationSession.deserialize` rebuilds the per-type counters from
145
+ `[TYPE_N]` placeholders in the map, and `anonymize` skips any number
146
+ already present in the reverse map (renamed labels included), so freshly
147
+ issued placeholders never collide with restored or renamed ones.
148
+
149
+ ### Backward compatibility
150
+
151
+ - Restoring is unchanged: `deanonymize` performs exact-literal replacement
152
+ of whatever placeholder strings the map contains. Maps serialized before
153
+ 0.9.0 (or by the Python CLI's `save_map`) with `<<REDACTED_N>>` tokens
154
+ still deserialize and restore old-format text byte-identically.
155
+ - The serialization schema (`original` / `replacement` / `entity_type`,
156
+ Python `save_map` parity) is unchanged.
157
+
7
158
  ## [0.8.0] - 2026-07-27
8
159
 
9
160
  First public release. The engine was extracted from the DocCloak web app
@@ -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
- const sorted = normalizeReplacements(replacements);
166
- // For each text node, compute what its new text should be
167
- // We need to handle replacements that may span multiple <w:t> elements
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
  }
@@ -9,3 +9,5 @@
9
9
  */
10
10
  export * from './docx.js';
11
11
  export * from './ocr.js';
12
+ export * from './xlsx.js';
13
+ export * from './office.js';
package/dist/dom/index.js CHANGED
@@ -9,3 +9,6 @@
9
9
  */
10
10
  export * from "./docx.js";
11
11
  export * from "./ocr.js";
12
+ // T110: xlsx + detection-driven office file redaction
13
+ export * from "./xlsx.js";
14
+ export * from "./office.js";
@@ -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>;