@adeu/core 1.25.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 +298 -74
- 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 +296 -73
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/diff.ts +129 -4
- package/src/engine.ts +135 -18
- package/src/index.ts +1 -1
- package/src/ingest.ts +11 -2
- package/src/mapper.ts +99 -47
- package/src/repro_qa_report_v7.test.ts +380 -0
- package/src/sanitize/core.ts +3 -0
- package/src/sanitize/transforms.ts +29 -0
- package/src/utils/docx.ts +30 -2
package/src/mapper.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
get_run_style_markers,
|
|
10
10
|
get_run_text,
|
|
11
11
|
is_heading_paragraph,
|
|
12
|
+
split_boundary_whitespace,
|
|
12
13
|
is_native_heading,
|
|
13
14
|
iter_block_items,
|
|
14
15
|
iter_document_parts_with_kind,
|
|
@@ -31,6 +32,12 @@ export interface TextSpan {
|
|
|
31
32
|
part_index: number;
|
|
32
33
|
// True for the read-only image marker projection .
|
|
33
34
|
is_image_marker?: boolean;
|
|
35
|
+
// Character offset of this span's text within its run's projected text.
|
|
36
|
+
// One run may back several spans: hoisting boundary whitespace outside
|
|
37
|
+
// style markers (QA 2026-07-19 F-03) projects a bold "The Supplier " run
|
|
38
|
+
// as core + trailing-space spans, and only the first starts at run
|
|
39
|
+
// offset 0. All span->run local-offset arithmetic must add this.
|
|
40
|
+
run_offset?: number;
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
export function renumber_snapshot_ids(
|
|
@@ -411,10 +418,12 @@ export class DocumentMapper {
|
|
|
411
418
|
let current_wrappers: [string, string] = ["", ""];
|
|
412
419
|
let current_style: [string, string] = ["", ""];
|
|
413
420
|
let active_hyperlink_id: string | null = null;
|
|
421
|
+
// [kind, text, run, run_offset, ins_id, del_id, comment_ids]
|
|
414
422
|
let pending_runs: [
|
|
415
423
|
string,
|
|
416
424
|
string,
|
|
417
425
|
Run | null,
|
|
426
|
+
number,
|
|
418
427
|
string | null,
|
|
419
428
|
string | null,
|
|
420
429
|
string[],
|
|
@@ -427,7 +436,7 @@ export class DocumentMapper {
|
|
|
427
436
|
this._add_virtual_text(s_tok, current, paragraph);
|
|
428
437
|
current += s_tok.length;
|
|
429
438
|
}
|
|
430
|
-
for (const [kind, txt, r_obj, i_id, d_id, c_ids] of pending_runs) {
|
|
439
|
+
for (const [kind, txt, r_obj, r_off, i_id, d_id, c_ids] of pending_runs) {
|
|
431
440
|
if (kind === "virtual") {
|
|
432
441
|
this._add_virtual_text(txt, current, paragraph, active_hyperlink_id);
|
|
433
442
|
} else {
|
|
@@ -442,6 +451,7 @@ export class DocumentMapper {
|
|
|
442
451
|
hyperlink_id: active_hyperlink_id || undefined,
|
|
443
452
|
comment_ids: c_ids.length > 0 ? c_ids : undefined,
|
|
444
453
|
part_index: this._current_part_index,
|
|
454
|
+
run_offset: r_off,
|
|
445
455
|
};
|
|
446
456
|
this.spans.push(s);
|
|
447
457
|
this._text_chunks.push(txt);
|
|
@@ -473,7 +483,8 @@ export class DocumentMapper {
|
|
|
473
483
|
|
|
474
484
|
if (item instanceof Run) {
|
|
475
485
|
const [prefix, suffix] = get_run_style_markers(item, native_heading);
|
|
476
|
-
|
|
486
|
+
// [kind, text, run, run_offset]
|
|
487
|
+
const run_parts: [string, string, Run | null, number][] = [];
|
|
477
488
|
const text = get_run_text(item);
|
|
478
489
|
|
|
479
490
|
if (leading_strip_active) {
|
|
@@ -481,20 +492,50 @@ export class DocumentMapper {
|
|
|
481
492
|
leading_strip_active = false;
|
|
482
493
|
}
|
|
483
494
|
|
|
495
|
+
// run_local tracks each real part's offset within the run's projected
|
|
496
|
+
// text, so spans resolve back to exact run positions even when one
|
|
497
|
+
// run backs several spans.
|
|
498
|
+
let run_local = 0;
|
|
499
|
+
const append_wrapped = (segment: string): void => {
|
|
500
|
+
// Boundary whitespace stays OUTSIDE the style markers —
|
|
501
|
+
// `**The Supplier **` is malformed Markdown (QA 2026-07-19 F-03).
|
|
502
|
+
// Must mirror apply_formatting_to_segments exactly (the Virtual
|
|
503
|
+
// Text contract).
|
|
504
|
+
const [lead, core, trail] = split_boundary_whitespace(segment);
|
|
505
|
+
if (!core) {
|
|
506
|
+
run_parts.push(["real", segment, item, run_local]);
|
|
507
|
+
run_local += segment.length;
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (lead) {
|
|
511
|
+
run_parts.push(["real", lead, item, run_local]);
|
|
512
|
+
run_local += lead.length;
|
|
513
|
+
}
|
|
514
|
+
if (prefix) run_parts.push(["virtual", prefix, null, 0]);
|
|
515
|
+
run_parts.push(["real", core, item, run_local]);
|
|
516
|
+
run_local += core.length;
|
|
517
|
+
if (suffix) run_parts.push(["virtual", suffix, null, 0]);
|
|
518
|
+
if (trail) {
|
|
519
|
+
run_parts.push(["real", trail, item, run_local]);
|
|
520
|
+
run_local += trail.length;
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
|
|
484
524
|
if (text.includes("\n") && (prefix || suffix)) {
|
|
485
525
|
const parts = text.split("\n");
|
|
486
526
|
for (let idx = 0; idx < parts.length; idx++) {
|
|
487
|
-
if (idx > 0)
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
run_parts.push(["real", parts[idx], item]);
|
|
491
|
-
if (suffix) run_parts.push(["virtual", suffix, null]);
|
|
527
|
+
if (idx > 0) {
|
|
528
|
+
run_parts.push(["real", "\n", item, run_local]);
|
|
529
|
+
run_local += 1;
|
|
492
530
|
}
|
|
531
|
+
if (parts[idx]) append_wrapped(parts[idx]);
|
|
493
532
|
}
|
|
533
|
+
} else if ((prefix || suffix) && text) {
|
|
534
|
+
append_wrapped(text);
|
|
494
535
|
} else {
|
|
495
|
-
if (prefix) run_parts.push(["virtual", prefix, null]);
|
|
496
|
-
if (text) run_parts.push(["real", text, item]);
|
|
497
|
-
if (suffix) run_parts.push(["virtual", suffix, null]);
|
|
536
|
+
if (prefix) run_parts.push(["virtual", prefix, null, 0]);
|
|
537
|
+
if (text) run_parts.push(["real", text, item, 0]);
|
|
538
|
+
if (suffix) run_parts.push(["virtual", suffix, null, 0]);
|
|
498
539
|
}
|
|
499
540
|
|
|
500
541
|
if (this.clean_view && Object.keys(active_del).length > 0) {
|
|
@@ -542,7 +583,7 @@ export class DocumentMapper {
|
|
|
542
583
|
}
|
|
543
584
|
|
|
544
585
|
const curr_comment_ids = Array.from(active_ids);
|
|
545
|
-
for (const [kind, txt, r_obj] of run_parts) {
|
|
586
|
+
for (const [kind, txt, r_obj, r_off] of run_parts) {
|
|
546
587
|
if (
|
|
547
588
|
skip_leading_prefix &&
|
|
548
589
|
kind === "virtual" &&
|
|
@@ -555,6 +596,7 @@ export class DocumentMapper {
|
|
|
555
596
|
kind,
|
|
556
597
|
txt,
|
|
557
598
|
r_obj,
|
|
599
|
+
r_off,
|
|
558
600
|
curr_ins_id,
|
|
559
601
|
curr_del_id,
|
|
560
602
|
curr_comment_ids,
|
|
@@ -566,11 +608,12 @@ export class DocumentMapper {
|
|
|
566
608
|
current_wrappers = new_wrappers;
|
|
567
609
|
current_style = new_style;
|
|
568
610
|
const curr_comment_ids = Array.from(active_ids);
|
|
569
|
-
for (const [kind, txt, r_obj] of run_parts) {
|
|
611
|
+
for (const [kind, txt, r_obj, r_off] of run_parts) {
|
|
570
612
|
pending_runs.push([
|
|
571
613
|
kind,
|
|
572
614
|
txt,
|
|
573
615
|
r_obj,
|
|
616
|
+
r_off,
|
|
574
617
|
curr_ins_id,
|
|
575
618
|
curr_del_id,
|
|
576
619
|
curr_comment_ids,
|
|
@@ -1108,50 +1151,59 @@ export class DocumentMapper {
|
|
|
1108
1151
|
);
|
|
1109
1152
|
if (affected_spans.length === 0) return [];
|
|
1110
1153
|
|
|
1111
|
-
const
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1154
|
+
const real_spans = affected_spans.filter((s) => s.run !== null);
|
|
1155
|
+
if (real_spans.length === 0) return [];
|
|
1156
|
+
|
|
1157
|
+
// One run may back several spans (boundary whitespace hoisted outside
|
|
1158
|
+
// style markers projects a run as lead/core/trail spans, QA 2026-07-19
|
|
1159
|
+
// F-03): deduplicate by identity or the run would be split and wrapped
|
|
1160
|
+
// once per span.
|
|
1161
|
+
const working_runs: Run[] = [];
|
|
1162
|
+
for (const s of real_spans) {
|
|
1163
|
+
if (!working_runs.some((r) => r === s.run)) {
|
|
1164
|
+
working_runs.push(s.run!);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1115
1167
|
|
|
1116
1168
|
let dom_modified = false;
|
|
1117
1169
|
|
|
1118
|
-
|
|
1170
|
+
// 1. Start Split — all local offsets are run-relative: span-relative
|
|
1171
|
+
// position plus the span's own offset within the run.
|
|
1172
|
+
const first_real_span = real_spans[0];
|
|
1119
1173
|
let start_split_adjustment = 0;
|
|
1120
1174
|
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
start_split_adjustment = local_start;
|
|
1175
|
+
const local_start =
|
|
1176
|
+
start_idx - first_real_span.start + (first_real_span.run_offset || 0);
|
|
1177
|
+
if (local_start > 0) {
|
|
1178
|
+
const split_source = working_runs[0];
|
|
1179
|
+
const [, right_run] = this._split_run_at_index(
|
|
1180
|
+
split_source,
|
|
1181
|
+
local_start,
|
|
1182
|
+
);
|
|
1183
|
+
for (let w = 0; w < working_runs.length; w++) {
|
|
1184
|
+
if (working_runs[w] === split_source) working_runs[w] = right_run;
|
|
1132
1185
|
}
|
|
1186
|
+
dom_modified = true;
|
|
1187
|
+
start_split_adjustment = local_start;
|
|
1133
1188
|
}
|
|
1134
1189
|
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
let overlap_end = Math.min(last_real_span.end, end_idx);
|
|
1143
|
-
let local_end = overlap_end - last_real_span.start;
|
|
1190
|
+
// 2. End Split
|
|
1191
|
+
const last_real_span = real_spans[real_spans.length - 1];
|
|
1192
|
+
const is_same_run = first_real_span.run === last_real_span.run;
|
|
1193
|
+
const run_to_split = working_runs[working_runs.length - 1];
|
|
1194
|
+
const overlap_end = Math.min(last_real_span.end, end_idx);
|
|
1195
|
+
let local_end =
|
|
1196
|
+
overlap_end - last_real_span.start + (last_real_span.run_offset || 0);
|
|
1144
1197
|
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1198
|
+
if (is_same_run && start_split_adjustment > 0) {
|
|
1199
|
+
local_end -= start_split_adjustment;
|
|
1200
|
+
}
|
|
1148
1201
|
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
}
|
|
1202
|
+
const run_text = get_run_text(run_to_split);
|
|
1203
|
+
if (local_end > 0 && local_end < run_text.length) {
|
|
1204
|
+
const [left_run] = this._split_run_at_index(run_to_split, local_end);
|
|
1205
|
+
working_runs[working_runs.length - 1] = left_run;
|
|
1206
|
+
dom_modified = true;
|
|
1155
1207
|
}
|
|
1156
1208
|
|
|
1157
1209
|
if (dom_modified && rebuild_map) {
|
|
@@ -1206,7 +1258,7 @@ export class DocumentMapper {
|
|
|
1206
1258
|
}
|
|
1207
1259
|
return [null, span.paragraph];
|
|
1208
1260
|
} else {
|
|
1209
|
-
const offset = index - span.start;
|
|
1261
|
+
const offset = index - span.start + (span.run_offset || 0);
|
|
1210
1262
|
const [left] = this._split_run_at_index(span.run, offset);
|
|
1211
1263
|
if (rebuild_map) this._build_map();
|
|
1212
1264
|
return [left, span.paragraph];
|
|
@@ -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
|
@@ -78,6 +78,9 @@ export async function finalize_document(doc: DocumentObject, options: FinalizeOp
|
|
|
78
78
|
for (const w of transforms.detect_watermarks(doc)) report.warnings.push(w);
|
|
79
79
|
|
|
80
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));
|
|
81
84
|
|
|
82
85
|
// Protection (Settings injection)
|
|
83
86
|
if (options.protection_mode === 'read_only' || options.protection_mode === 'encrypt') {
|
|
@@ -417,6 +417,35 @@ export function normalize_change_dates(doc: DocumentObject): string[] {
|
|
|
417
417
|
return count ? [`Track change timestamps: ${count} normalized`] : [];
|
|
418
418
|
}
|
|
419
419
|
|
|
420
|
+
/**
|
|
421
|
+
* Normalize timestamps on RETAINED comments to the fixed sanitize date.
|
|
422
|
+
* Keep-markup sanitize normalized core and tracked-change timestamps but
|
|
423
|
+
* left the original comment timestamps in word/comments.xml (w:date) and
|
|
424
|
+
* word/commentsExtensible.xml (w16cex:dateUtc) — visible through any
|
|
425
|
+
* extraction and carrying exactly the when-did-they-work signal the other
|
|
426
|
+
* normalizations remove (QA 2026-07-19 F-09).
|
|
427
|
+
*/
|
|
428
|
+
export function normalize_comment_dates(doc: DocumentObject): string[] {
|
|
429
|
+
let count = 0;
|
|
430
|
+
const fixed = "2025-01-01T00:00:00Z";
|
|
431
|
+
for (const part of doc.pkg.parts) {
|
|
432
|
+
if (!part.contentType.includes('comments')) continue;
|
|
433
|
+
for (const el of findAllDescendants(part._element, 'w:comment')) {
|
|
434
|
+
if (el.hasAttribute('w:date')) {
|
|
435
|
+
el.setAttribute('w:date', fixed);
|
|
436
|
+
count++;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
for (const el of findAllDescendants(part._element, 'w16cex:commentExtensible')) {
|
|
440
|
+
if (el.hasAttribute('w16cex:dateUtc')) {
|
|
441
|
+
el.setAttribute('w16cex:dateUtc', fixed);
|
|
442
|
+
count++;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return count ? [`Comment timestamps: ${count} normalized`] : [];
|
|
447
|
+
}
|
|
448
|
+
|
|
420
449
|
export function scrub_doc_properties(doc: DocumentObject): string[] {
|
|
421
450
|
const lines: string[] = [];
|
|
422
451
|
const corePart = doc.pkg.getPartByPath('docProps/core.xml');
|