@doccloak/core 0.8.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 +35 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +230 -0
- package/dist/doc.d.ts +13 -0
- package/dist/doc.js +683 -0
- package/dist/docx.d.ts +31 -0
- package/dist/docx.js +26 -0
- package/dist/dom/docx.d.ts +90 -0
- package/dist/dom/docx.js +472 -0
- package/dist/dom/index.d.ts +11 -0
- package/dist/dom/index.js +11 -0
- package/dist/dom/ocr.d.ts +112 -0
- package/dist/dom/ocr.js +224 -0
- package/dist/engine.d.ts +81 -0
- package/dist/engine.js +274 -0
- package/dist/env.d.ts +49 -0
- package/dist/env.js +53 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +29 -0
- package/dist/model-loader.d.ts +55 -0
- package/dist/model-loader.js +253 -0
- package/dist/pipeline.d.ts +19 -0
- package/dist/pipeline.js +128 -0
- package/dist/providers/bardsai.d.ts +38 -0
- package/dist/providers/bardsai.js +393 -0
- package/dist/providers/gliner.d.ts +52 -0
- package/dist/providers/gliner.js +360 -0
- package/dist/regex/index.d.ts +17 -0
- package/dist/regex/index.js +18 -0
- package/dist/regex/loader.d.ts +44 -0
- package/dist/regex/loader.js +44 -0
- package/dist/regex/rules.data.d.ts +2 -0
- package/dist/regex/rules.data.js +2671 -0
- package/dist/regex/types.d.ts +27 -0
- package/dist/regex/types.js +1 -0
- package/dist/regex/validators.d.ts +17 -0
- package/dist/regex/validators.js +492 -0
- package/dist/session.d.ts +59 -0
- package/dist/session.js +153 -0
- package/dist/types.d.ts +42 -0
- package/dist/types.js +28 -0
- package/dist/worker-protocol.d.ts +119 -0
- package/dist/worker-protocol.js +285 -0
- package/package.json +75 -0
- package/rules/at.json +70 -0
- package/rules/be.json +70 -0
- package/rules/ch.json +66 -0
- package/rules/cn.json +155 -0
- package/rules/de.json +192 -0
- package/rules/dk.json +43 -0
- package/rules/es.json +158 -0
- package/rules/fi.json +43 -0
- package/rules/fr.json +178 -0
- package/rules/gb.json +194 -0
- package/rules/ie.json +48 -0
- package/rules/it.json +116 -0
- package/rules/jp.json +136 -0
- package/rules/nl.json +99 -0
- package/rules/no.json +132 -0
- package/rules/pl.json +210 -0
- package/rules/pt.json +113 -0
- package/rules/schema.json +91 -0
- package/rules/se.json +149 -0
- package/rules/universal.json +279 -0
- package/rules/us.json +218 -0
package/dist/docx.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, DOM-free part of the docx module. The full docx read/write logic
|
|
3
|
+
* (DOMParser/XMLSerializer-dependent) lives in src/dom/docx.ts behind the
|
|
4
|
+
* '@doccloak/core/dom' export (T011); it imports and re-exports the helpers
|
|
5
|
+
* below so the main entry stays free of DOM references. doc.ts also imports
|
|
6
|
+
* normalizeReplacements from this path. Bodies copied verbatim from
|
|
7
|
+
* DocCloak/src/core/docx.ts.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* A pairing of an original sensitive value with its replacement placeholder,
|
|
11
|
+
* used to scrub places offset-based replacement cannot reach (relationship
|
|
12
|
+
* targets, field instruction attributes).
|
|
13
|
+
*/
|
|
14
|
+
export interface ValueReplacement {
|
|
15
|
+
value: string;
|
|
16
|
+
replacement: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Sort replacements and clamp overlapping ranges so each character is covered
|
|
20
|
+
* by at most one replacement. Overlaps would otherwise double-apply and corrupt
|
|
21
|
+
* the output around the overlap.
|
|
22
|
+
*/
|
|
23
|
+
export declare function normalizeReplacements(replacements: Array<{
|
|
24
|
+
start: number;
|
|
25
|
+
end: number;
|
|
26
|
+
replacement: string;
|
|
27
|
+
}>): Array<{
|
|
28
|
+
start: number;
|
|
29
|
+
end: number;
|
|
30
|
+
replacement: string;
|
|
31
|
+
}>;
|
package/dist/docx.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, DOM-free part of the docx module. The full docx read/write logic
|
|
3
|
+
* (DOMParser/XMLSerializer-dependent) lives in src/dom/docx.ts behind the
|
|
4
|
+
* '@doccloak/core/dom' export (T011); it imports and re-exports the helpers
|
|
5
|
+
* below so the main entry stays free of DOM references. doc.ts also imports
|
|
6
|
+
* normalizeReplacements from this path. Bodies copied verbatim from
|
|
7
|
+
* DocCloak/src/core/docx.ts.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Sort replacements and clamp overlapping ranges so each character is covered
|
|
11
|
+
* by at most one replacement. Overlaps would otherwise double-apply and corrupt
|
|
12
|
+
* the output around the overlap.
|
|
13
|
+
*/
|
|
14
|
+
export function normalizeReplacements(replacements) {
|
|
15
|
+
const sorted = [...replacements].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
16
|
+
const result = [];
|
|
17
|
+
let lastEnd = -1;
|
|
18
|
+
for (const repl of sorted) {
|
|
19
|
+
const start = Math.max(repl.start, lastEnd);
|
|
20
|
+
if (start >= repl.end)
|
|
21
|
+
continue; // fully covered by the previous replacement
|
|
22
|
+
result.push(start === repl.start ? repl : { ...repl, start });
|
|
23
|
+
lastEnd = repl.end;
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @doccloak/core/dom - docx read/write (moved verbatim from the web app, T011).
|
|
3
|
+
*
|
|
4
|
+
* Requires DOM XML APIs (DOMParser, XMLSerializer), which is why this module
|
|
5
|
+
* lives under the dom/ submodule: hosts must run it in a DOM-capable context
|
|
6
|
+
* (window, jsdom, or an MV3 offscreen document). The pure normalizeReplacements
|
|
7
|
+
* helper lives in the DOM-free main entry (src/docx.ts) and is re-exported here.
|
|
8
|
+
*/
|
|
9
|
+
import JSZip from 'jszip';
|
|
10
|
+
import { normalizeReplacements } from '../docx.js';
|
|
11
|
+
import type { ValueReplacement } from '../docx.js';
|
|
12
|
+
export { normalizeReplacements };
|
|
13
|
+
export type { ValueReplacement };
|
|
14
|
+
/**
|
|
15
|
+
* Represents a text node in the docx XML with its position in the flat text.
|
|
16
|
+
*/
|
|
17
|
+
interface TextNodeMapping {
|
|
18
|
+
/** The <w:t> element */
|
|
19
|
+
element: Element;
|
|
20
|
+
/** Start index in the flat text */
|
|
21
|
+
flatStart: number;
|
|
22
|
+
/** End index in the flat text */
|
|
23
|
+
flatEnd: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Tracks the boundary between paragraphs in the flat text (newline positions).
|
|
27
|
+
*/
|
|
28
|
+
interface ParagraphBreak {
|
|
29
|
+
flatIndex: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Result of extracting text from a docx file.
|
|
33
|
+
*/
|
|
34
|
+
export interface DocxExtraction {
|
|
35
|
+
/** The flat plain text extracted from the document */
|
|
36
|
+
plainText: string;
|
|
37
|
+
/** The parsed XML document */
|
|
38
|
+
xmlDoc: Document;
|
|
39
|
+
/** Mapping from flat text positions to XML <w:t> elements */
|
|
40
|
+
textNodes: TextNodeMapping[];
|
|
41
|
+
/** Positions of paragraph breaks in the flat text */
|
|
42
|
+
paragraphBreaks: ParagraphBreak[];
|
|
43
|
+
/** The JSZip instance for re-packaging */
|
|
44
|
+
zip: JSZip;
|
|
45
|
+
/** The path of the main document XML within the zip */
|
|
46
|
+
documentXmlPath: string;
|
|
47
|
+
/** All content XML paths (document, headers, footers) and their extractions */
|
|
48
|
+
contentParts: ContentPartExtraction[];
|
|
49
|
+
}
|
|
50
|
+
interface ContentPartExtraction {
|
|
51
|
+
path: string;
|
|
52
|
+
xmlDoc: Document;
|
|
53
|
+
textNodes: TextNodeMapping[];
|
|
54
|
+
paragraphBreaks: ParagraphBreak[];
|
|
55
|
+
flatTextStart: number;
|
|
56
|
+
flatTextEnd: number;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Read a .docx file and extract its text content with position mapping.
|
|
60
|
+
*/
|
|
61
|
+
export declare function readDocx(file: File): Promise<DocxExtraction>;
|
|
62
|
+
/**
|
|
63
|
+
* Apply text replacements to the docx XML, preserving all formatting.
|
|
64
|
+
* Takes the original extraction and a list of replacements (sorted by position),
|
|
65
|
+
* and modifies the XML in-place. Also scrubs document metadata and relationship
|
|
66
|
+
* targets that can leak identities (authors, revision names, hyperlink emails).
|
|
67
|
+
*
|
|
68
|
+
* Returns a new .docx file as a Blob.
|
|
69
|
+
*/
|
|
70
|
+
export declare function writeAnonymizedDocx(extraction: DocxExtraction, replacements: Array<{
|
|
71
|
+
start: number;
|
|
72
|
+
end: number;
|
|
73
|
+
replacement: string;
|
|
74
|
+
}>, valueReplacements?: ValueReplacement[]): Promise<Blob>;
|
|
75
|
+
/**
|
|
76
|
+
* Get the file extension from a filename.
|
|
77
|
+
*/
|
|
78
|
+
export declare function getFileExtension(filename: string): string;
|
|
79
|
+
/**
|
|
80
|
+
* Check if a file is a supported Word document (.doc or .docx).
|
|
81
|
+
*/
|
|
82
|
+
export declare function isWordFile(filename: string): boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Check if a file is a legacy .doc format (not .docx).
|
|
85
|
+
*/
|
|
86
|
+
export declare function isLegacyDoc(filename: string): boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Check if a file is any supported document format.
|
|
89
|
+
*/
|
|
90
|
+
export declare function isSupportedFile(filename: string): boolean;
|
package/dist/dom/docx.js
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @doccloak/core/dom - docx read/write (moved verbatim from the web app, T011).
|
|
3
|
+
*
|
|
4
|
+
* Requires DOM XML APIs (DOMParser, XMLSerializer), which is why this module
|
|
5
|
+
* lives under the dom/ submodule: hosts must run it in a DOM-capable context
|
|
6
|
+
* (window, jsdom, or an MV3 offscreen document). The pure normalizeReplacements
|
|
7
|
+
* helper lives in the DOM-free main entry (src/docx.ts) and is re-exported here.
|
|
8
|
+
*/
|
|
9
|
+
import JSZip from 'jszip';
|
|
10
|
+
import { normalizeReplacements } from "../docx.js";
|
|
11
|
+
export { normalizeReplacements };
|
|
12
|
+
const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
13
|
+
/**
|
|
14
|
+
* Element local names (in the w: namespace) that carry document text.
|
|
15
|
+
* - t: regular run text
|
|
16
|
+
* - delText: text inside tracked deletions (w:del); still physically present in the file
|
|
17
|
+
* - instrText: field instructions (e.g. HYPERLINK targets, MERGEFIELD sources)
|
|
18
|
+
*/
|
|
19
|
+
const TEXT_ELEMENT_NAMES = new Set(['t', 'delText', 'instrText']);
|
|
20
|
+
/**
|
|
21
|
+
* Extract all text-bearing elements from an XML document, building a flat text
|
|
22
|
+
* and position mapping.
|
|
23
|
+
*/
|
|
24
|
+
function extractTextFromXml(xmlDoc) {
|
|
25
|
+
const textNodes = [];
|
|
26
|
+
const paragraphBreaks = [];
|
|
27
|
+
let flatText = '';
|
|
28
|
+
const body = xmlDoc.getElementsByTagNameNS(W_NS, 'body')[0];
|
|
29
|
+
if (!body) {
|
|
30
|
+
// For headers/footers/footnotes/comments, process the root element's children
|
|
31
|
+
const root = xmlDoc.documentElement;
|
|
32
|
+
processParagraphs(root);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
processParagraphs(body);
|
|
36
|
+
}
|
|
37
|
+
function processParagraphs(parent) {
|
|
38
|
+
const paragraphs = parent.getElementsByTagNameNS(W_NS, 'p');
|
|
39
|
+
// Paragraphs can nest (text boxes hold w:p inside an outer w:p run), so the
|
|
40
|
+
// same text element is reachable from more than one paragraph. Track visited
|
|
41
|
+
// elements to map each one exactly once.
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
for (let pi = 0; pi < paragraphs.length; pi++) {
|
|
44
|
+
const para = paragraphs[pi];
|
|
45
|
+
if (pi > 0) {
|
|
46
|
+
paragraphBreaks.push({ flatIndex: flatText.length });
|
|
47
|
+
flatText += '\n';
|
|
48
|
+
}
|
|
49
|
+
// Walk all descendants in document order so t/delText/instrText interleave correctly
|
|
50
|
+
const descendants = para.getElementsByTagName('*');
|
|
51
|
+
for (let di = 0; di < descendants.length; di++) {
|
|
52
|
+
const el = descendants[di];
|
|
53
|
+
if (el.namespaceURI !== W_NS || !TEXT_ELEMENT_NAMES.has(el.localName))
|
|
54
|
+
continue;
|
|
55
|
+
if (seen.has(el))
|
|
56
|
+
continue;
|
|
57
|
+
seen.add(el);
|
|
58
|
+
const text = el.textContent ?? '';
|
|
59
|
+
if (text.length > 0) {
|
|
60
|
+
textNodes.push({
|
|
61
|
+
element: el,
|
|
62
|
+
flatStart: flatText.length,
|
|
63
|
+
flatEnd: flatText.length + text.length,
|
|
64
|
+
});
|
|
65
|
+
flatText += text;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { plainText: flatText, textNodes, paragraphBreaks };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Read a .docx file and extract its text content with position mapping.
|
|
74
|
+
*/
|
|
75
|
+
export async function readDocx(file) {
|
|
76
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
77
|
+
const zip = await JSZip.loadAsync(arrayBuffer);
|
|
78
|
+
// Find the main document XML path
|
|
79
|
+
const documentXmlPath = 'word/document.xml';
|
|
80
|
+
const docXmlFile = zip.file(documentXmlPath);
|
|
81
|
+
if (!docXmlFile) {
|
|
82
|
+
throw new Error('Invalid .docx file: missing word/document.xml');
|
|
83
|
+
}
|
|
84
|
+
const docXmlStr = await docXmlFile.async('string');
|
|
85
|
+
const parser = new DOMParser();
|
|
86
|
+
const xmlDoc = parser.parseFromString(docXmlStr, 'application/xml');
|
|
87
|
+
const parseError = xmlDoc.querySelector('parsererror');
|
|
88
|
+
if (parseError) {
|
|
89
|
+
throw new Error('Failed to parse document XML');
|
|
90
|
+
}
|
|
91
|
+
const { plainText, textNodes, paragraphBreaks } = extractTextFromXml(xmlDoc);
|
|
92
|
+
// Also process headers and footers
|
|
93
|
+
const contentParts = [];
|
|
94
|
+
let combinedText = plainText;
|
|
95
|
+
// Add main document as first content part
|
|
96
|
+
contentParts.push({
|
|
97
|
+
path: documentXmlPath,
|
|
98
|
+
xmlDoc,
|
|
99
|
+
textNodes,
|
|
100
|
+
paragraphBreaks,
|
|
101
|
+
flatTextStart: 0,
|
|
102
|
+
flatTextEnd: plainText.length,
|
|
103
|
+
});
|
|
104
|
+
// Find all other text-bearing parts: headers, footers, footnotes, endnotes,
|
|
105
|
+
// comments, and the glossary (building blocks) subdocument. PII in any of
|
|
106
|
+
// these survives in the exported file if it is not extracted and redacted.
|
|
107
|
+
const headerFooterPaths = [];
|
|
108
|
+
zip.forEach((relativePath) => {
|
|
109
|
+
if (relativePath.match(/^word\/(?:(?:header|footer)\d*|footnotes|endnotes|comments)\.xml$/) ||
|
|
110
|
+
relativePath === 'word/glossary/document.xml') {
|
|
111
|
+
headerFooterPaths.push(relativePath);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
// Deterministic order so load-time and export-time extractions produce identical offsets
|
|
115
|
+
headerFooterPaths.sort();
|
|
116
|
+
for (const hfPath of headerFooterPaths) {
|
|
117
|
+
const hfFile = zip.file(hfPath);
|
|
118
|
+
if (!hfFile)
|
|
119
|
+
continue;
|
|
120
|
+
const hfXmlStr = await hfFile.async('string');
|
|
121
|
+
const hfXmlDoc = parser.parseFromString(hfXmlStr, 'application/xml');
|
|
122
|
+
if (hfXmlDoc.querySelector('parsererror'))
|
|
123
|
+
continue;
|
|
124
|
+
const hfExtraction = extractTextFromXml(hfXmlDoc);
|
|
125
|
+
if (hfExtraction.plainText.length === 0)
|
|
126
|
+
continue;
|
|
127
|
+
const offset = combinedText.length + 1; // +1 for separator newline
|
|
128
|
+
combinedText += '\n' + hfExtraction.plainText;
|
|
129
|
+
// Adjust text node mappings to the combined text offset
|
|
130
|
+
const adjustedNodes = hfExtraction.textNodes.map((node) => ({
|
|
131
|
+
...node,
|
|
132
|
+
flatStart: node.flatStart + offset,
|
|
133
|
+
flatEnd: node.flatEnd + offset,
|
|
134
|
+
}));
|
|
135
|
+
contentParts.push({
|
|
136
|
+
path: hfPath,
|
|
137
|
+
xmlDoc: hfXmlDoc,
|
|
138
|
+
textNodes: adjustedNodes,
|
|
139
|
+
paragraphBreaks: hfExtraction.paragraphBreaks.map((pb) => ({
|
|
140
|
+
flatIndex: pb.flatIndex + offset,
|
|
141
|
+
})),
|
|
142
|
+
flatTextStart: offset,
|
|
143
|
+
flatTextEnd: offset + hfExtraction.plainText.length,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
plainText: combinedText,
|
|
148
|
+
xmlDoc,
|
|
149
|
+
textNodes: contentParts.flatMap((cp) => cp.textNodes),
|
|
150
|
+
paragraphBreaks: contentParts.flatMap((cp) => cp.paragraphBreaks),
|
|
151
|
+
zip,
|
|
152
|
+
documentXmlPath,
|
|
153
|
+
contentParts,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Apply text replacements to the docx XML, preserving all formatting.
|
|
158
|
+
* Takes the original extraction and a list of replacements (sorted by position),
|
|
159
|
+
* and modifies the XML in-place. Also scrubs document metadata and relationship
|
|
160
|
+
* targets that can leak identities (authors, revision names, hyperlink emails).
|
|
161
|
+
*
|
|
162
|
+
* Returns a new .docx file as a Blob.
|
|
163
|
+
*/
|
|
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
|
+
}
|
|
174
|
+
// Serialize all modified XML documents back to the zip
|
|
175
|
+
const serializer = new XMLSerializer();
|
|
176
|
+
for (const part of extraction.contentParts) {
|
|
177
|
+
sanitizeContentPartAttributes(part.xmlDoc, valueReplacements);
|
|
178
|
+
const xmlStr = serializer.serializeToString(part.xmlDoc);
|
|
179
|
+
extraction.zip.file(part.path, xmlStr);
|
|
180
|
+
}
|
|
181
|
+
await sanitizeDocxMetadata(extraction.zip, valueReplacements);
|
|
182
|
+
return extraction.zip.generateAsync({ type: 'blob', mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' });
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Scrub identity-bearing attributes inside a content part:
|
|
186
|
+
* - w:author / w:initials on tracked changes (w:ins, w:del) and comments
|
|
187
|
+
* - alt text and object names on drawings (docPr, cNvPr)
|
|
188
|
+
* - field instructions held in attributes (w:fldSimple @w:instr)
|
|
189
|
+
*/
|
|
190
|
+
function sanitizeContentPartAttributes(xmlDoc, valueReplacements) {
|
|
191
|
+
const all = xmlDoc.getElementsByTagName('*');
|
|
192
|
+
for (let i = 0; i < all.length; i++) {
|
|
193
|
+
const el = all[i];
|
|
194
|
+
const attrs = el.attributes;
|
|
195
|
+
for (let a = 0; a < attrs.length; a++) {
|
|
196
|
+
const attr = attrs[a];
|
|
197
|
+
if (attr.localName === 'author') {
|
|
198
|
+
attr.value = 'Redacted';
|
|
199
|
+
}
|
|
200
|
+
else if (attr.localName === 'initials') {
|
|
201
|
+
attr.value = 'R';
|
|
202
|
+
}
|
|
203
|
+
else if (attr.localName === 'instr') {
|
|
204
|
+
attr.value = replaceValues(attr.value, valueReplacements, false);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (el.localName === 'docPr' || el.localName === 'cNvPr') {
|
|
208
|
+
if (el.hasAttribute('descr'))
|
|
209
|
+
el.removeAttribute('descr');
|
|
210
|
+
if (el.hasAttribute('title'))
|
|
211
|
+
el.removeAttribute('title');
|
|
212
|
+
if (el.hasAttribute('name'))
|
|
213
|
+
el.setAttribute('name', 'Object');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Replace every occurrence of each sensitive value (case-insensitive) in a string.
|
|
219
|
+
* Uses a replacer function so '$' sequences in values or placeholders are inert.
|
|
220
|
+
*/
|
|
221
|
+
function replaceValues(input, valueReplacements, urlEncode) {
|
|
222
|
+
let result = input;
|
|
223
|
+
for (const { value, replacement } of valueReplacements) {
|
|
224
|
+
if (!value)
|
|
225
|
+
continue;
|
|
226
|
+
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
227
|
+
const substitute = urlEncode ? encodeURIComponent(replacement) : replacement;
|
|
228
|
+
result = result.replace(new RegExp(escaped, 'gi'), () => substitute);
|
|
229
|
+
}
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Scrub metadata parts of the package that survive text replacement untouched:
|
|
234
|
+
* document properties, comment author registry, revision fingerprints,
|
|
235
|
+
* mail-merge sources, external relationship targets, and the package thumbnail.
|
|
236
|
+
*/
|
|
237
|
+
async function sanitizeDocxMetadata(zip, valueReplacements) {
|
|
238
|
+
const parser = new DOMParser();
|
|
239
|
+
const serializer = new XMLSerializer();
|
|
240
|
+
const readXml = async (path) => {
|
|
241
|
+
const file = zip.file(path);
|
|
242
|
+
if (!file)
|
|
243
|
+
return null;
|
|
244
|
+
const xmlStr = await file.async('string');
|
|
245
|
+
const doc = parser.parseFromString(xmlStr, 'application/xml');
|
|
246
|
+
if (doc.getElementsByTagName('parsererror').length > 0)
|
|
247
|
+
return null;
|
|
248
|
+
return doc;
|
|
249
|
+
};
|
|
250
|
+
const writeXml = (path, doc) => {
|
|
251
|
+
zip.file(path, serializer.serializeToString(doc));
|
|
252
|
+
};
|
|
253
|
+
const blankElementsByLocalName = (doc, names) => {
|
|
254
|
+
const all = doc.getElementsByTagName('*');
|
|
255
|
+
for (let i = 0; i < all.length; i++) {
|
|
256
|
+
if (names.has(all[i].localName))
|
|
257
|
+
all[i].textContent = '';
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
// Core properties: author identities and content-describing fields
|
|
261
|
+
const core = await readXml('docProps/core.xml');
|
|
262
|
+
if (core) {
|
|
263
|
+
blankElementsByLocalName(core, new Set(['creator', 'lastModifiedBy', 'title', 'subject', 'description', 'keywords', 'category']));
|
|
264
|
+
writeXml('docProps/core.xml', core);
|
|
265
|
+
}
|
|
266
|
+
// Extended properties: organization and template/path hints
|
|
267
|
+
const app = await readXml('docProps/app.xml');
|
|
268
|
+
if (app) {
|
|
269
|
+
blankElementsByLocalName(app, new Set(['Company', 'Manager', 'HyperlinkBase', 'Template']));
|
|
270
|
+
writeXml('docProps/app.xml', app);
|
|
271
|
+
}
|
|
272
|
+
// Custom properties: blank all values, keep the property names/structure
|
|
273
|
+
const custom = await readXml('docProps/custom.xml');
|
|
274
|
+
if (custom) {
|
|
275
|
+
const props = custom.getElementsByTagName('*');
|
|
276
|
+
for (let i = 0; i < props.length; i++) {
|
|
277
|
+
const el = props[i];
|
|
278
|
+
if (el.localName !== 'property' && el.childElementCount === 0 && (el.textContent ?? '') !== '') {
|
|
279
|
+
el.textContent = '';
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
writeXml('docProps/custom.xml', custom);
|
|
283
|
+
}
|
|
284
|
+
// Comment author registry (word/people.xml): names and provider user ids
|
|
285
|
+
const people = await readXml('word/people.xml');
|
|
286
|
+
if (people) {
|
|
287
|
+
const all = people.getElementsByTagName('*');
|
|
288
|
+
for (let i = 0; i < all.length; i++) {
|
|
289
|
+
const attrs = all[i].attributes;
|
|
290
|
+
for (let a = 0; a < attrs.length; a++) {
|
|
291
|
+
if (attrs[a].localName === 'author' || attrs[a].localName === 'userId') {
|
|
292
|
+
attrs[a].value = 'Redacted';
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
writeXml('word/people.xml', people);
|
|
297
|
+
}
|
|
298
|
+
// Settings: drop mail-merge data sources, revision-save ids, attached template
|
|
299
|
+
const settings = await readXml('word/settings.xml');
|
|
300
|
+
if (settings) {
|
|
301
|
+
for (const name of ['mailMerge', 'rsids', 'attachedTemplate']) {
|
|
302
|
+
const els = settings.getElementsByTagNameNS(W_NS, name);
|
|
303
|
+
for (let i = els.length - 1; i >= 0; i--) {
|
|
304
|
+
els[i].parentNode?.removeChild(els[i]);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
writeXml('word/settings.xml', settings);
|
|
308
|
+
}
|
|
309
|
+
// Relationship files: scrub sensitive values from external targets
|
|
310
|
+
// (hyperlink mailto:/URLs keep the raw value even when display text is redacted)
|
|
311
|
+
// and detach the document template relationship.
|
|
312
|
+
const relsPaths = [];
|
|
313
|
+
zip.forEach((relativePath) => {
|
|
314
|
+
if (/^word\/(?:glossary\/)?_rels\/[^/]+\.rels$/.test(relativePath)) {
|
|
315
|
+
relsPaths.push(relativePath);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
for (const relsPath of relsPaths.sort()) {
|
|
319
|
+
const rels = await readXml(relsPath);
|
|
320
|
+
if (!rels)
|
|
321
|
+
continue;
|
|
322
|
+
const relationships = rels.getElementsByTagName('Relationship');
|
|
323
|
+
for (let i = relationships.length - 1; i >= 0; i--) {
|
|
324
|
+
const rel = relationships[i];
|
|
325
|
+
const type = rel.getAttribute('Type') ?? '';
|
|
326
|
+
if (type.endsWith('/attachedTemplate')) {
|
|
327
|
+
rel.parentNode?.removeChild(rel);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (rel.getAttribute('TargetMode') === 'External') {
|
|
331
|
+
const target = rel.getAttribute('Target') ?? '';
|
|
332
|
+
const scrubbed = replaceValues(target, valueReplacements, true);
|
|
333
|
+
if (scrubbed !== target)
|
|
334
|
+
rel.setAttribute('Target', scrubbed);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
writeXml(relsPath, rels);
|
|
338
|
+
}
|
|
339
|
+
// Package thumbnail: a rendered image of page one leaks content wholesale.
|
|
340
|
+
// Remove the part plus its package relationship and content-type override.
|
|
341
|
+
const thumbnailPaths = [];
|
|
342
|
+
zip.forEach((relativePath) => {
|
|
343
|
+
if (/^docProps\/thumbnail\.[a-z]+$/i.test(relativePath)) {
|
|
344
|
+
thumbnailPaths.push(relativePath);
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
if (thumbnailPaths.length > 0) {
|
|
348
|
+
for (const path of thumbnailPaths)
|
|
349
|
+
zip.remove(path);
|
|
350
|
+
const rootRels = await readXml('_rels/.rels');
|
|
351
|
+
if (rootRels) {
|
|
352
|
+
const relationships = rootRels.getElementsByTagName('Relationship');
|
|
353
|
+
for (let i = relationships.length - 1; i >= 0; i--) {
|
|
354
|
+
const type = relationships[i].getAttribute('Type') ?? '';
|
|
355
|
+
if (type.endsWith('/thumbnail')) {
|
|
356
|
+
relationships[i].parentNode?.removeChild(relationships[i]);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
writeXml('_rels/.rels', rootRels);
|
|
360
|
+
}
|
|
361
|
+
const contentTypes = await readXml('[Content_Types].xml');
|
|
362
|
+
if (contentTypes) {
|
|
363
|
+
const overrides = contentTypes.getElementsByTagName('Override');
|
|
364
|
+
for (let i = overrides.length - 1; i >= 0; i--) {
|
|
365
|
+
const partName = overrides[i].getAttribute('PartName') ?? '';
|
|
366
|
+
if (thumbnailPaths.some((p) => partName === '/' + p)) {
|
|
367
|
+
overrides[i].parentNode?.removeChild(overrides[i]);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
writeXml('[Content_Types].xml', contentTypes);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Apply a single text replacement across potentially multiple <w:t> elements.
|
|
376
|
+
*/
|
|
377
|
+
function applyReplacement(textNodes, replStart, replEnd, replacement) {
|
|
378
|
+
// Find all text nodes that overlap with this replacement range
|
|
379
|
+
const affectedNodes = [];
|
|
380
|
+
for (const node of textNodes) {
|
|
381
|
+
if (node.flatEnd > replStart && node.flatStart < replEnd) {
|
|
382
|
+
affectedNodes.push(node);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (affectedNodes.length === 0)
|
|
386
|
+
return;
|
|
387
|
+
if (affectedNodes.length === 1) {
|
|
388
|
+
// Simple case: replacement is within a single <w:t> element
|
|
389
|
+
const node = affectedNodes[0];
|
|
390
|
+
const currentText = node.element.textContent ?? '';
|
|
391
|
+
const localStart = replStart - node.flatStart;
|
|
392
|
+
const localEnd = replEnd - node.flatStart;
|
|
393
|
+
const newText = currentText.slice(0, localStart) + replacement + currentText.slice(localEnd);
|
|
394
|
+
node.element.textContent = newText;
|
|
395
|
+
// Ensure space preservation
|
|
396
|
+
node.element.setAttribute('xml:space', 'preserve');
|
|
397
|
+
// Update flat positions for this node and all subsequent nodes
|
|
398
|
+
const lengthDiff = replacement.length - (replEnd - replStart);
|
|
399
|
+
node.flatEnd += lengthDiff;
|
|
400
|
+
updateSubsequentNodes(textNodes, node, lengthDiff);
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
// Multi-node case: replacement spans multiple <w:t> elements
|
|
404
|
+
// Put all replacement text in the first affected node, clear the rest
|
|
405
|
+
const firstNode = affectedNodes[0];
|
|
406
|
+
const lastNode = affectedNodes[affectedNodes.length - 1];
|
|
407
|
+
const firstText = firstNode.element.textContent ?? '';
|
|
408
|
+
const lastText = lastNode.element.textContent ?? '';
|
|
409
|
+
const keepBefore = firstText.slice(0, replStart - firstNode.flatStart);
|
|
410
|
+
const keepAfter = lastText.slice(replEnd - lastNode.flatStart);
|
|
411
|
+
// Set first node to: text-before-replacement + replacement + text-after-replacement-in-last-node
|
|
412
|
+
firstNode.element.textContent = keepBefore + replacement + keepAfter;
|
|
413
|
+
firstNode.element.setAttribute('xml:space', 'preserve');
|
|
414
|
+
// Clear intermediate and last nodes
|
|
415
|
+
for (let i = 1; i < affectedNodes.length; i++) {
|
|
416
|
+
affectedNodes[i].element.textContent = '';
|
|
417
|
+
}
|
|
418
|
+
// Recalculate flat positions
|
|
419
|
+
const newFirstLength = (keepBefore + replacement + keepAfter).length;
|
|
420
|
+
const oldSpanLength = lastNode.flatEnd - firstNode.flatStart;
|
|
421
|
+
const lengthDiff = newFirstLength - oldSpanLength;
|
|
422
|
+
// For cleared intermediate/last nodes, collapse their ranges
|
|
423
|
+
firstNode.flatEnd = firstNode.flatStart + newFirstLength;
|
|
424
|
+
for (let i = 1; i < affectedNodes.length; i++) {
|
|
425
|
+
affectedNodes[i].flatStart = firstNode.flatEnd;
|
|
426
|
+
affectedNodes[i].flatEnd = firstNode.flatEnd;
|
|
427
|
+
}
|
|
428
|
+
updateSubsequentNodes(textNodes, lastNode, lengthDiff);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Update flat positions for all text nodes after the given node.
|
|
433
|
+
*/
|
|
434
|
+
function updateSubsequentNodes(textNodes, afterNode, lengthDiff) {
|
|
435
|
+
if (lengthDiff === 0)
|
|
436
|
+
return;
|
|
437
|
+
let found = false;
|
|
438
|
+
for (const node of textNodes) {
|
|
439
|
+
if (found) {
|
|
440
|
+
node.flatStart += lengthDiff;
|
|
441
|
+
node.flatEnd += lengthDiff;
|
|
442
|
+
}
|
|
443
|
+
else if (node === afterNode) {
|
|
444
|
+
found = true;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Get the file extension from a filename.
|
|
450
|
+
*/
|
|
451
|
+
export function getFileExtension(filename) {
|
|
452
|
+
return filename.split('.').pop()?.toLowerCase() ?? '';
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Check if a file is a supported Word document (.doc or .docx).
|
|
456
|
+
*/
|
|
457
|
+
export function isWordFile(filename) {
|
|
458
|
+
const ext = getFileExtension(filename);
|
|
459
|
+
return ext === 'docx' || ext === 'doc';
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Check if a file is a legacy .doc format (not .docx).
|
|
463
|
+
*/
|
|
464
|
+
export function isLegacyDoc(filename) {
|
|
465
|
+
return getFileExtension(filename) === 'doc';
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Check if a file is any supported document format.
|
|
469
|
+
*/
|
|
470
|
+
export function isSupportedFile(filename) {
|
|
471
|
+
return isWordFile(filename);
|
|
472
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @doccloak/core/dom - DOM-dependent submodule.
|
|
3
|
+
*
|
|
4
|
+
* Code that requires DOM APIs (DOMParser/XMLSerializer for docx, canvas for
|
|
5
|
+
* OCR) is quarantined behind this export path so importing the main entry
|
|
6
|
+
* never pulls in DOM references (architecture doc 4.1 principle 4). OCR
|
|
7
|
+
* additionally runs in DOM-less worker contexts via OffscreenCanvas and
|
|
8
|
+
* createImageBitmap (feature-detected).
|
|
9
|
+
*/
|
|
10
|
+
export * from './docx.js';
|
|
11
|
+
export * from './ocr.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @doccloak/core/dom - DOM-dependent submodule.
|
|
3
|
+
*
|
|
4
|
+
* Code that requires DOM APIs (DOMParser/XMLSerializer for docx, canvas for
|
|
5
|
+
* OCR) is quarantined behind this export path so importing the main entry
|
|
6
|
+
* never pulls in DOM references (architecture doc 4.1 principle 4). OCR
|
|
7
|
+
* additionally runs in DOM-less worker contexts via OffscreenCanvas and
|
|
8
|
+
* createImageBitmap (feature-detected).
|
|
9
|
+
*/
|
|
10
|
+
export * from "./docx.js";
|
|
11
|
+
export * from "./ocr.js";
|