@adeu/core 1.23.0 → 1.26.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/dist/index.cjs +478 -122
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +24 -1
- package/dist/index.d.ts +24 -1
- package/dist/index.js +477 -122
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/diff.ts +152 -12
- package/src/engine.ts +267 -57
- package/src/index.ts +1 -1
- package/src/ingest.ts +11 -2
- package/src/mapper.ts +99 -47
- package/src/repro_qa_report_v6.test.ts +486 -0
- package/src/repro_qa_report_v7.test.ts +380 -0
- package/src/sanitize/core.ts +60 -1
- package/src/sanitize/report.ts +5 -3
- package/src/sanitize/sanitize.test.ts +5 -4
- package/src/sanitize/transforms.ts +123 -16
- package/src/utils/docx.ts +30 -2
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// FILE: node/packages/core/src/repro_qa_report_v7.test.ts
|
|
2
|
+
/**
|
|
3
|
+
* Node-engine repro tests for the 2026-07-19 black-box QA and UX report
|
|
4
|
+
* (adeu 1.25.0+35c8bb4). Mirrors python/tests/test_repro_qa_report_v7.py
|
|
5
|
+
* for the findings that live in the shared core engine:
|
|
6
|
+
*
|
|
7
|
+
* F-02 a formatting-only replacement (bold -> italic) inherits the
|
|
8
|
+
* replaced span's bold instead of realizing exactly the markers
|
|
9
|
+
* F-03 a bold run with boundary whitespace projects as malformed
|
|
10
|
+
* Markdown (`**The Supplier **`) in both ingest and mapper
|
|
11
|
+
* F-04 an alt-text-only image difference becomes an unappliable edit
|
|
12
|
+
* F-06 match_mode=all rebuilds the document map once per occurrence
|
|
13
|
+
* F-09 sanitize leaves original comment timestamps un-normalized
|
|
14
|
+
* F-13 an invalid regex target is reported as "not found" instead of
|
|
15
|
+
* as a regex syntax error
|
|
16
|
+
* F-21 edits_applied counts occurrences instead of change objects
|
|
17
|
+
*
|
|
18
|
+
* Every test fails against the commit preceding its fix.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect } from "vitest";
|
|
22
|
+
import { readFileSync } from "node:fs";
|
|
23
|
+
import { resolve, dirname } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { createTestDocument, addParagraph } from "./test-utils.js";
|
|
26
|
+
import { DocumentObject } from "./docx/bridge.js";
|
|
27
|
+
import { DocumentMapper } from "./mapper.js";
|
|
28
|
+
import { RedlineEngine, BatchValidationError } from "./engine.js";
|
|
29
|
+
import { _extractTextFromDoc, ExtractStructure } from "./ingest.js";
|
|
30
|
+
import {
|
|
31
|
+
generate_structured_edits,
|
|
32
|
+
collect_media_difference_warnings,
|
|
33
|
+
} from "./diff.js";
|
|
34
|
+
import { zipSync } from "fflate";
|
|
35
|
+
import { finalize_document } from "./sanitize/core.js";
|
|
36
|
+
import { qn } from "./docx/dom.js";
|
|
37
|
+
|
|
38
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
39
|
+
const __dirname = dirname(__filename);
|
|
40
|
+
|
|
41
|
+
function addStyledRun(
|
|
42
|
+
p: Element,
|
|
43
|
+
text: string,
|
|
44
|
+
style: { bold?: boolean; italic?: boolean } = {},
|
|
45
|
+
): Element {
|
|
46
|
+
const xmlDoc = p.ownerDocument!;
|
|
47
|
+
const r = xmlDoc.createElement("w:r");
|
|
48
|
+
if (style.bold || style.italic) {
|
|
49
|
+
const rPr = xmlDoc.createElement("w:rPr");
|
|
50
|
+
if (style.bold) rPr.appendChild(xmlDoc.createElement("w:b"));
|
|
51
|
+
if (style.italic) rPr.appendChild(xmlDoc.createElement("w:i"));
|
|
52
|
+
r.appendChild(rPr);
|
|
53
|
+
}
|
|
54
|
+
const t = xmlDoc.createElement("w:t");
|
|
55
|
+
t.textContent = text;
|
|
56
|
+
if (text !== text.trim()) t.setAttribute("xml:space", "preserve");
|
|
57
|
+
r.appendChild(t);
|
|
58
|
+
p.appendChild(r);
|
|
59
|
+
return r;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function addEmptyParagraph(doc: DocumentObject): Element {
|
|
63
|
+
const xmlDoc = doc.element.ownerDocument!;
|
|
64
|
+
const p = xmlDoc.createElement("w:p");
|
|
65
|
+
doc.element.appendChild(p);
|
|
66
|
+
return p;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Builds the QA report's F-02 fixture: "Normal before **Important phrase** normal after." */
|
|
70
|
+
async function buildBoldDoc(): Promise<DocumentObject> {
|
|
71
|
+
const doc = await createTestDocument();
|
|
72
|
+
const p = addEmptyParagraph(doc);
|
|
73
|
+
addStyledRun(p, "Normal before ");
|
|
74
|
+
addStyledRun(p, "Important phrase", { bold: true });
|
|
75
|
+
addStyledRun(p, " normal after.");
|
|
76
|
+
return doc;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cleanText(doc: DocumentObject): string {
|
|
80
|
+
return _extractTextFromDoc(doc, { cleanView: true, includeAppendix: false }) as string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// F-02: formatting-only replacements realize exactly the requested markers
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
describe("QA v7 F-02: formatting-only replacement fidelity", () => {
|
|
88
|
+
it("clears inherited bold when the replacement carries explicit italic markers", async () => {
|
|
89
|
+
const doc = await buildBoldDoc();
|
|
90
|
+
const engine = new RedlineEngine(doc);
|
|
91
|
+
engine.process_batch([
|
|
92
|
+
{
|
|
93
|
+
type: "modify",
|
|
94
|
+
target_text: "**Important phrase**",
|
|
95
|
+
new_text: "_Important phrase_",
|
|
96
|
+
},
|
|
97
|
+
]);
|
|
98
|
+
engine.accept_all_revisions();
|
|
99
|
+
|
|
100
|
+
const accepted = cleanText(doc);
|
|
101
|
+
expect(accepted).toContain("_Important phrase_");
|
|
102
|
+
expect(accepted).not.toContain("**");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("clears bold when the replacement removes the markers entirely", async () => {
|
|
106
|
+
const doc = await buildBoldDoc();
|
|
107
|
+
const engine = new RedlineEngine(doc);
|
|
108
|
+
engine.process_batch([
|
|
109
|
+
{
|
|
110
|
+
type: "modify",
|
|
111
|
+
target_text: "**Important phrase**",
|
|
112
|
+
new_text: "Important phrase",
|
|
113
|
+
},
|
|
114
|
+
]);
|
|
115
|
+
engine.accept_all_revisions();
|
|
116
|
+
|
|
117
|
+
const accepted = cleanText(doc);
|
|
118
|
+
expect(accepted).toContain("Normal before Important phrase normal after.");
|
|
119
|
+
expect(accepted).not.toContain("**");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("still inherits bold for unmarked plain-text replacements", async () => {
|
|
123
|
+
const doc = await buildBoldDoc();
|
|
124
|
+
const engine = new RedlineEngine(doc);
|
|
125
|
+
engine.process_batch([
|
|
126
|
+
{ type: "modify", target_text: "Important", new_text: "Critical" },
|
|
127
|
+
]);
|
|
128
|
+
engine.accept_all_revisions();
|
|
129
|
+
expect(cleanText(doc)).toContain("**Critical phrase**");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// F-03: styled-run boundary whitespace stays outside emphasis markers
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
describe("QA v7 F-03: styled-run boundary whitespace", () => {
|
|
138
|
+
async function buildBoundaryDoc(): Promise<DocumentObject> {
|
|
139
|
+
const doc = await createTestDocument();
|
|
140
|
+
const p = addEmptyParagraph(doc);
|
|
141
|
+
addStyledRun(p, "The Supplier ", { bold: true });
|
|
142
|
+
addStyledRun(p, "shall deliver");
|
|
143
|
+
addStyledRun(p, " the Services by 31 December 2026.", { italic: true });
|
|
144
|
+
return doc;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
it("extraction emits valid markdown with whitespace outside the markers", async () => {
|
|
148
|
+
const doc = await buildBoundaryDoc();
|
|
149
|
+
const text = (_extractTextFromDoc(doc, { cleanView: false }) as string).trim();
|
|
150
|
+
expect(text).toBe(
|
|
151
|
+
"**The Supplier** shall deliver _the Services by 31 December 2026._",
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("mapper projection matches ingest projection (virtual text contract)", async () => {
|
|
156
|
+
const doc = await buildBoundaryDoc();
|
|
157
|
+
const ingestText = (_extractTextFromDoc(doc, { cleanView: false }) as string).trim();
|
|
158
|
+
const mapper = new DocumentMapper(doc);
|
|
159
|
+
expect(mapper.full_text.trim()).toBe(ingestText);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("plain-text edits across the styled boundary still apply", async () => {
|
|
163
|
+
const doc = await buildBoundaryDoc();
|
|
164
|
+
const engine = new RedlineEngine(doc);
|
|
165
|
+
const stats = engine.process_batch([
|
|
166
|
+
{ type: "modify", target_text: "shall deliver", new_text: "must deliver" },
|
|
167
|
+
]);
|
|
168
|
+
expect(stats.edits_applied).toBe(1);
|
|
169
|
+
engine.accept_all_revisions();
|
|
170
|
+
expect(cleanText(doc)).toContain("must deliver");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("whitespace-only styled runs are not wrapped", async () => {
|
|
174
|
+
const doc = await createTestDocument();
|
|
175
|
+
const p = addEmptyParagraph(doc);
|
|
176
|
+
addStyledRun(p, "Alpha");
|
|
177
|
+
addStyledRun(p, " ", { bold: true });
|
|
178
|
+
addStyledRun(p, "Omega");
|
|
179
|
+
const text = (_extractTextFromDoc(doc, { cleanView: false }) as string).trim();
|
|
180
|
+
expect(text).toBe("Alpha Omega");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// F-04: an alt-text-only image difference must not become an edit
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
describe("QA v7 F-04: image alt-text diffs", () => {
|
|
189
|
+
it("drops hunks that reach inside a read-only image marker, with a warning", () => {
|
|
190
|
+
const text_orig = "Before image.\n\n\n\nAfter image.";
|
|
191
|
+
const text_mod = "Before image.\n\n\n\nAfter image.";
|
|
192
|
+
const struct = (text: string): ExtractStructure => ({
|
|
193
|
+
part_ranges: [[0, text.length, "body"]],
|
|
194
|
+
tables: [],
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const { edits, warnings } = generate_structured_edits(
|
|
198
|
+
text_orig,
|
|
199
|
+
struct(text_orig),
|
|
200
|
+
text_mod,
|
|
201
|
+
struct(text_mod),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(edits).toEqual([]);
|
|
205
|
+
expect(
|
|
206
|
+
warnings.some((w) => /alternative text|image/i.test(w)),
|
|
207
|
+
).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("QA v7 F-04: media byte differences", () => {
|
|
212
|
+
const makePkg = (mediaBytes: number[]): Uint8Array =>
|
|
213
|
+
zipSync({
|
|
214
|
+
"[Content_Types].xml": new TextEncoder().encode("<Types/>"),
|
|
215
|
+
"word/document.xml": new TextEncoder().encode("<w:document/>"),
|
|
216
|
+
"word/media/image1.png": new Uint8Array(mediaBytes),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("warns when embedded media bytes differ", () => {
|
|
220
|
+
const warnings = collect_media_difference_warnings(
|
|
221
|
+
makePkg([1, 2, 3]),
|
|
222
|
+
makePkg([4, 5, 6]),
|
|
223
|
+
);
|
|
224
|
+
expect(warnings.length).toBe(1);
|
|
225
|
+
expect(warnings[0]).toMatch(/embedded media differ/);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("stays silent when media are byte-identical", () => {
|
|
229
|
+
const warnings = collect_media_difference_warnings(
|
|
230
|
+
makePkg([1, 2, 3]),
|
|
231
|
+
makePkg([1, 2, 3]),
|
|
232
|
+
);
|
|
233
|
+
expect(warnings).toEqual([]);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// F-06: match_mode=all must not rebuild the map once per occurrence
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
describe("QA v7 F-06: all-match application scaling", () => {
|
|
242
|
+
it("keeps map rebuilds constant as occurrence count grows", async () => {
|
|
243
|
+
const n = 30;
|
|
244
|
+
const doc = await createTestDocument();
|
|
245
|
+
for (let i = 0; i < n; i++) {
|
|
246
|
+
addParagraph(
|
|
247
|
+
doc,
|
|
248
|
+
`Clause ${i}: The party PLACEHOLDER-${String(i).padStart(4, "0")} shall comply. REPLACEME token.`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
const engine = new RedlineEngine(doc);
|
|
252
|
+
|
|
253
|
+
const proto = DocumentMapper.prototype as any;
|
|
254
|
+
const originalBuild = proto._build_map;
|
|
255
|
+
let buildCount = 0;
|
|
256
|
+
proto._build_map = function (this: any) {
|
|
257
|
+
buildCount += 1;
|
|
258
|
+
return originalBuild.call(this);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
let stats: any;
|
|
262
|
+
try {
|
|
263
|
+
stats = engine.process_batch([
|
|
264
|
+
{
|
|
265
|
+
type: "modify",
|
|
266
|
+
target_text: "REPLACEME",
|
|
267
|
+
new_text: "SWAPPED",
|
|
268
|
+
regex: true,
|
|
269
|
+
match_mode: "all",
|
|
270
|
+
},
|
|
271
|
+
]);
|
|
272
|
+
} finally {
|
|
273
|
+
proto._build_map = originalBuild;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
expect(stats.edits_skipped).toBe(0);
|
|
277
|
+
expect(buildCount).toBeLessThan(n);
|
|
278
|
+
|
|
279
|
+
engine.accept_all_revisions();
|
|
280
|
+
const final = cleanText(doc);
|
|
281
|
+
expect(final.match(/SWAPPED/g)?.length).toBe(n);
|
|
282
|
+
expect(final).not.toContain("REPLACEME");
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// F-09: comment timestamps must be normalized alongside change timestamps
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
describe("QA v7 F-09: comment timestamp normalization", () => {
|
|
291
|
+
it("normalizes w:date and w16cex:dateUtc in retained comment parts", async () => {
|
|
292
|
+
const fixturePath = resolve(
|
|
293
|
+
__dirname,
|
|
294
|
+
"../../../../shared/fixtures/golden.docx",
|
|
295
|
+
);
|
|
296
|
+
const doc = await DocumentObject.load(readFileSync(fixturePath));
|
|
297
|
+
|
|
298
|
+
await finalize_document(doc, {
|
|
299
|
+
filename: "golden.docx",
|
|
300
|
+
sanitize_mode: "keep-markup",
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const commentParts = doc.pkg.parts.filter((p) =>
|
|
304
|
+
p.contentType.includes("comments"),
|
|
305
|
+
);
|
|
306
|
+
expect(commentParts.length).toBeGreaterThan(0);
|
|
307
|
+
|
|
308
|
+
const dates: string[] = [];
|
|
309
|
+
for (const part of commentParts) {
|
|
310
|
+
const xml = part._element.toString();
|
|
311
|
+
for (const m of xml.matchAll(/(?:w:date|w16cex:dateUtc)="([^"]+)"/g)) {
|
|
312
|
+
dates.push(m[1]);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
expect(dates.length).toBeGreaterThan(0);
|
|
316
|
+
for (const d of dates) {
|
|
317
|
+
expect(d.startsWith("2025-01-01")).toBe(true);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// F-13: invalid regex targets must be diagnosed as regex errors
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
describe("QA v7 F-13: invalid regex diagnosis", () => {
|
|
327
|
+
it("names the regex problem instead of 'not found'", async () => {
|
|
328
|
+
const doc = await createTestDocument();
|
|
329
|
+
addParagraph(doc, "alpha DUPTOKEN one.");
|
|
330
|
+
const engine = new RedlineEngine(doc);
|
|
331
|
+
|
|
332
|
+
let error: BatchValidationError | null = null;
|
|
333
|
+
try {
|
|
334
|
+
engine.process_batch([
|
|
335
|
+
{ type: "modify", target_text: "[", new_text: "x", regex: true },
|
|
336
|
+
]);
|
|
337
|
+
} catch (e) {
|
|
338
|
+
error = e as BatchValidationError;
|
|
339
|
+
}
|
|
340
|
+
expect(error).toBeInstanceOf(BatchValidationError);
|
|
341
|
+
const msg = (error as BatchValidationError).errors.join("\n");
|
|
342
|
+
expect(msg).toMatch(/regular expression/i);
|
|
343
|
+
expect(msg).not.toMatch(/not found/i);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("valid regexes still apply", async () => {
|
|
347
|
+
const doc = await createTestDocument();
|
|
348
|
+
addParagraph(doc, "alpha DUPTOKEN one.");
|
|
349
|
+
const engine = new RedlineEngine(doc);
|
|
350
|
+
const stats = engine.process_batch([
|
|
351
|
+
{ type: "modify", target_text: "DUP\\w+", new_text: "XTOKEN", regex: true },
|
|
352
|
+
]);
|
|
353
|
+
expect(stats.edits_applied).toBe(1);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// F-21: edits_applied counts change objects, not occurrences
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
describe("QA v7 F-21: edits_applied semantics", () => {
|
|
362
|
+
it("reports one applied edit with two modified occurrences", async () => {
|
|
363
|
+
const doc = await createTestDocument();
|
|
364
|
+
addParagraph(doc, "alpha DUPTOKEN one.");
|
|
365
|
+
addParagraph(doc, "beta DUPTOKEN two.");
|
|
366
|
+
const engine = new RedlineEngine(doc);
|
|
367
|
+
const stats = engine.process_batch([
|
|
368
|
+
{
|
|
369
|
+
type: "modify",
|
|
370
|
+
target_text: "DUPTOKEN",
|
|
371
|
+
new_text: "NEWTOKEN",
|
|
372
|
+
match_mode: "all",
|
|
373
|
+
},
|
|
374
|
+
]);
|
|
375
|
+
expect(stats.edits.length).toBe(1);
|
|
376
|
+
expect(stats.edits_applied).toBe(1);
|
|
377
|
+
expect(stats.occurrences_modified).toBe(2);
|
|
378
|
+
expect(stats.edits[0].occurrences_modified).toBe(2);
|
|
379
|
+
});
|
|
380
|
+
});
|
package/src/sanitize/core.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { strFromU8, unzipSync } from 'fflate';
|
|
1
2
|
import { DocumentObject } from '../docx/bridge.js';
|
|
2
3
|
import { SanitizeReport } from './report.js';
|
|
3
4
|
import * as transforms from './transforms.js';
|
|
4
|
-
import { findAllDescendants } from '../docx/dom.js';
|
|
5
|
+
import { findAllDescendants, parseXml } from '../docx/dom.js';
|
|
5
6
|
|
|
6
7
|
export interface FinalizeOptions {
|
|
7
8
|
filename: string;
|
|
@@ -67,6 +68,7 @@ export async function finalize_document(doc: DocumentObject, options: FinalizeOp
|
|
|
67
68
|
report.add_transform_lines(transforms.scrub_doc_properties(doc));
|
|
68
69
|
report.add_transform_lines(transforms.scrub_timestamps(doc));
|
|
69
70
|
report.add_transform_lines(transforms.strip_custom_xml(doc));
|
|
71
|
+
report.add_transform_lines(transforms.strip_custom_properties(doc));
|
|
70
72
|
report.add_transform_lines(transforms.strip_image_alt_text(doc));
|
|
71
73
|
|
|
72
74
|
const warnings = transforms.audit_hyperlinks(doc);
|
|
@@ -76,6 +78,9 @@ export async function finalize_document(doc: DocumentObject, options: FinalizeOp
|
|
|
76
78
|
for (const w of transforms.detect_watermarks(doc)) report.warnings.push(w);
|
|
77
79
|
|
|
78
80
|
report.add_transform_lines(transforms.normalize_change_dates(doc));
|
|
81
|
+
// Retained comments carry the same when-did-they-work signal in
|
|
82
|
+
// word/comments.xml as tracked changes do in the body (QA 2026-07-19 F-09).
|
|
83
|
+
report.add_transform_lines(transforms.normalize_comment_dates(doc));
|
|
79
84
|
|
|
80
85
|
// Protection (Settings injection)
|
|
81
86
|
if (options.protection_mode === 'read_only' || options.protection_mode === 'encrypt') {
|
|
@@ -130,5 +135,59 @@ export async function finalize_document(doc: DocumentObject, options: FinalizeOp
|
|
|
130
135
|
if (report.warnings.length > 0) report.status = 'clean_with_warnings';
|
|
131
136
|
|
|
132
137
|
const outBuffer = await doc.save();
|
|
138
|
+
verifySanitizedPackage(outBuffer);
|
|
133
139
|
return { reportText: report.render(), outBuffer };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Core-property elements the pipeline claims to scrub; the post-sanitize
|
|
143
|
+
// verification re-checks each one in the SAVED bytes.
|
|
144
|
+
const VERIFIED_CORE_FIELDS: Array<[string, string]> = [
|
|
145
|
+
['creator', 'author (dc:creator)'],
|
|
146
|
+
['lastModifiedBy', 'last modified by (cp:lastModifiedBy)'],
|
|
147
|
+
['identifier', 'identifier (dc:identifier)'],
|
|
148
|
+
['description', 'description (dc:description)'],
|
|
149
|
+
['keywords', 'keywords (cp:keywords)'],
|
|
150
|
+
['category', 'category (cp:category)'],
|
|
151
|
+
['subject', 'subject (dc:subject)'],
|
|
152
|
+
['contentStatus', 'content status (cp:contentStatus)'],
|
|
153
|
+
['language', 'language (dc:language)'],
|
|
154
|
+
['version', 'version (cp:version)'],
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Post-sanitize package scan: re-open the SAVED bytes —
|
|
159
|
+
* bypassing every in-memory caching layer — and verify the claims the report
|
|
160
|
+
* is about to make. A "Result: CLEAN" verdict over a package that still
|
|
161
|
+
* carries custom properties or an identifier is worse than no sanitizer.
|
|
162
|
+
*/
|
|
163
|
+
export function verifySanitizedPackage(outBuffer: Buffer): void {
|
|
164
|
+
const unzipped = unzipSync(new Uint8Array(outBuffer));
|
|
165
|
+
const names = Object.keys(unzipped);
|
|
166
|
+
const problems: string[] = [];
|
|
167
|
+
|
|
168
|
+
if (names.includes('docProps/custom.xml')) {
|
|
169
|
+
problems.push('docProps/custom.xml (custom document properties) is still in the package');
|
|
170
|
+
}
|
|
171
|
+
if (names.some(n => n.startsWith('customXml/'))) {
|
|
172
|
+
problems.push('customXml/* parts are still in the package');
|
|
173
|
+
}
|
|
174
|
+
if (names.includes('docProps/core.xml')) {
|
|
175
|
+
const core = parseXml(strFromU8(unzipped['docProps/core.xml']));
|
|
176
|
+
for (const [local, label] of VERIFIED_CORE_FIELDS) {
|
|
177
|
+
for (const el of transforms.findDescendantsByLocalName(core.documentElement! as unknown as Element, local)) {
|
|
178
|
+
if ((el.textContent || '').trim()) {
|
|
179
|
+
problems.push(`core property ${label} still contains a value`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (problems.length > 0) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
'Sanitize integrity check failed — the saved package still contains metadata this run ' +
|
|
188
|
+
'claims to remove:\n - ' +
|
|
189
|
+
problems.join('\n - ') +
|
|
190
|
+
'\nRefusing to report a clean document.',
|
|
191
|
+
);
|
|
192
|
+
}
|
|
134
193
|
}
|
package/src/sanitize/report.ts
CHANGED
|
@@ -38,9 +38,11 @@ export class SanitizeReport {
|
|
|
38
38
|
this.removed_comment_lines.push(line);
|
|
39
39
|
}
|
|
40
40
|
} else if (
|
|
41
|
-
lower.includes("author") || lower.includes("template") || lower.includes("company") ||
|
|
42
|
-
lower.includes("manager") || lower.includes("metadata") || lower.includes("timestamp") ||
|
|
43
|
-
lower.includes("custom xml") || lower.includes("
|
|
41
|
+
lower.includes("author") || lower.includes("template") || lower.includes("company") ||
|
|
42
|
+
lower.includes("manager") || lower.includes("metadata") || lower.includes("timestamp") ||
|
|
43
|
+
lower.includes("custom xml") || lower.includes("custom propert") || lower.includes("identifier") ||
|
|
44
|
+
lower.includes("language") || lower.includes("version") ||
|
|
45
|
+
lower.includes("last modified by") || lower.includes("revision count") || lower.includes("last printed")
|
|
44
46
|
) {
|
|
45
47
|
this.metadata_lines.push(line);
|
|
46
48
|
} else if (lower.includes("hyperlink") || lower.includes("warning")) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { zipSync } from "fflate";
|
|
2
3
|
import { DOMParser } from "@xmldom/xmldom";
|
|
3
4
|
import { readFileSync } from "node:fs";
|
|
4
5
|
import { resolve, dirname } from "node:path";
|
|
@@ -186,7 +187,7 @@ describe("Finalize Document (Core)", () => {
|
|
|
186
187
|
doc.pkg.parts.push(settingsPart);
|
|
187
188
|
|
|
188
189
|
// Mock the doc.save buffer return
|
|
189
|
-
doc.save = vi.fn().mockResolvedValue(Buffer.from(
|
|
190
|
+
doc.save = vi.fn().mockResolvedValue(Buffer.from(zipSync({})));
|
|
190
191
|
|
|
191
192
|
const res = await finalize_document(doc, {
|
|
192
193
|
filename: "test.docx",
|
|
@@ -231,7 +232,7 @@ describe("Finalize Document (Core)", () => {
|
|
|
231
232
|
</w:p>
|
|
232
233
|
`);
|
|
233
234
|
|
|
234
|
-
doc.save = vi.fn().mockResolvedValue(Buffer.from(
|
|
235
|
+
doc.save = vi.fn().mockResolvedValue(Buffer.from(zipSync({})));
|
|
235
236
|
|
|
236
237
|
await finalize_document(doc, {
|
|
237
238
|
filename: "test.docx",
|
|
@@ -258,7 +259,7 @@ describe("Finalize Document (Core)", () => {
|
|
|
258
259
|
"http://schemas.microsoft.com/office/word/2023/wordml/word16du",
|
|
259
260
|
);
|
|
260
261
|
|
|
261
|
-
doc.save = vi.fn().mockResolvedValue(Buffer.from(
|
|
262
|
+
doc.save = vi.fn().mockResolvedValue(Buffer.from(zipSync({})));
|
|
262
263
|
|
|
263
264
|
await finalize_document(doc, {
|
|
264
265
|
filename: "test.docx",
|
|
@@ -285,7 +286,7 @@ describe("Finalize Document (Core)", () => {
|
|
|
285
286
|
expect(original_xml).toContain("w:commentReference");
|
|
286
287
|
|
|
287
288
|
// Mock the doc.save buffer return
|
|
288
|
-
doc.save = vi.fn().mockResolvedValue(Buffer.from(
|
|
289
|
+
doc.save = vi.fn().mockResolvedValue(Buffer.from(zipSync({})));
|
|
289
290
|
|
|
290
291
|
// 2. Act: Finalize the document in full sanitize mode with accept_all: true
|
|
291
292
|
await finalize_document(doc, {
|