@adeu/core 1.28.0 → 1.29.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adeu/core",
3
- "version": "1.28.0",
3
+ "version": "1.29.0",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/diff.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { trim_common_context, generate_edits_from_text, create_word_patch_diff } from './diff.js';
2
+ import { trim_common_context, generate_edits_from_text, create_word_patch_diff, generate_edits_via_paragraph_alignment } from './diff.js';
3
3
 
4
4
  describe('Diff Logic & Context Trimming', () => {
5
5
  it('handles basic prefix and suffix', () => {
@@ -71,4 +71,11 @@ describe('Diff Logic & Context Trimming', () => {
71
71
  expect(diff).toContain("+ Corporation");
72
72
  expect(diff).toContain(" This agreement is made between the"); // Within 40-char context window so no truncation
73
73
  });
74
+
75
+ it('handles trailing newline and whitespace safely in paragraph alignment', () => {
76
+ const original = "This is a single paragraph of the draft.";
77
+ const modified = "This is a single paragraph of the draft.\n";
78
+ const edits = generate_edits_via_paragraph_alignment(original, modified);
79
+ expect(edits.length).toBe(0);
80
+ });
74
81
  });
package/src/diff.ts CHANGED
@@ -544,6 +544,12 @@ export function generate_edits_via_paragraph_alignment(
544
544
  original_text: string,
545
545
  modified_text: string,
546
546
  ): ModifyText[] {
547
+ // Normalize trailing whitespace/newlines in modified_text to match original_text
548
+ const orig_stripped = original_text.trimEnd();
549
+ const orig_ws = original_text.slice(orig_stripped.length);
550
+ const mod_stripped = modified_text.trimEnd();
551
+ modified_text = mod_stripped + orig_ws;
552
+
547
553
  const orig_paragraphs = original_text.split("\n\n");
548
554
  const mod_paragraphs = modified_text.split("\n\n");
549
555
 
@@ -568,4 +568,53 @@ describe("Resolved Bugs Core Engine Verification", () => {
568
568
  expect(caught.name).toBe("BatchValidationError");
569
569
  expect(caught.message).toContain("Invalid change format");
570
570
  });
571
+
572
+ it("BUG-REPRO: accept_all_revisions returns counts of accepted changes and removed comments", async () => {
573
+ const doc = await createTestDocument();
574
+ addParagraph(doc, "This is a test document.");
575
+ const engine = new RedlineEngine(doc);
576
+
577
+ engine.process_batch([
578
+ {
579
+ type: "modify",
580
+ target_text: "document",
581
+ new_text: "dossier",
582
+ comment: "Review note to be stripped",
583
+ },
584
+ ]);
585
+
586
+ const stats = engine.accept_all_revisions() as any;
587
+
588
+ expect(stats).toBeDefined();
589
+ expect(stats.accepted_insertions).toBe(1);
590
+ expect(stats.accepted_deletions).toBe(1);
591
+ expect(stats.accepted_formatting).toBe(0);
592
+ // Counted from comment bodies this call actually deleted, not from the
593
+ // document's comment total — the two coincide here, but see below.
594
+ expect(stats.removed_comments).toBe(1);
595
+ });
596
+
597
+ it("BUG-REPRO: removed_comments does not count comments it deliberately keeps", async () => {
598
+ const doc = await createTestDocument();
599
+ addParagraph(doc, "This is a test document.");
600
+ const engine = new RedlineEngine(doc);
601
+
602
+ engine.process_batch([
603
+ {
604
+ type: "modify",
605
+ target_text: "document",
606
+ new_text: "dossier",
607
+ comment: "Review note",
608
+ },
609
+ ]);
610
+
611
+ // A different reviewer now opens the redlined document. The existing
612
+ // comment is foreign to them, so it keeps its BODY by design (only the
613
+ // anchor is detached) and must not be reported as removed.
614
+ const other = new RedlineEngine(doc);
615
+ other.author = "Someone Else";
616
+ const stats = other.accept_all_revisions() as any;
617
+
618
+ expect(stats.removed_comments).toBe(0);
619
+ });
571
620
  });
@@ -265,4 +265,35 @@ describe("Table Interop & Engine (Node.js Port)", () => {
265
265
  expect(errors[0]).toContain('Did you mean the literal "( x )"');
266
266
  expect(errors[0]).toContain("drop the ^/$ anchors");
267
267
  });
268
+
269
+ it("compiled report includes type for structural table operations", async () => {
270
+ const doc = await createTestDocument();
271
+ const tbl = addTable(doc, 2, 2);
272
+ setCellText(tbl, 0, 0, "Row1 Col1");
273
+ setCellText(tbl, 0, 1, "Row1 Col2");
274
+ setCellText(tbl, 1, 0, "Row2 Col1");
275
+ setCellText(tbl, 1, 1, "Row2 Col2");
276
+
277
+ const buf = await doc.save();
278
+ const midDoc = await DocumentObject.load(buf);
279
+ const engine = new RedlineEngine(midDoc);
280
+
281
+ const stats = engine.process_batch([
282
+ {
283
+ type: "insert_row",
284
+ target_text: "Row1 Col1",
285
+ position: "below",
286
+ cells: ["NewRow Col1", "NewRow Col2"],
287
+ } as InsertTableRow,
288
+ {
289
+ type: "delete_row",
290
+ target_text: "Row2 Col1",
291
+ } as DeleteTableRow,
292
+ ]);
293
+
294
+ expect(stats.edits_applied).toBe(2);
295
+ expect(stats.edits).toHaveLength(2);
296
+ expect(stats.edits[0].type).toBe("insert_row");
297
+ expect(stats.edits[1].type).toBe("delete_row");
298
+ });
268
299
  });
package/src/engine.ts CHANGED
@@ -905,10 +905,30 @@ export class RedlineEngine {
905
905
  }
906
906
  }
907
907
 
908
+ // Pre-count revisions before mutating. Unit is REVISION ELEMENTS, matching
909
+ // the Python engine and sanitize's count_tracked_changes so no two surfaces
910
+ // report different totals for one document.
911
+ let accepted_insertions = 0;
912
+ let accepted_deletions = 0;
913
+ let accepted_formatting = 0;
914
+ for (const root_element of parts_to_process) {
915
+ accepted_insertions += findAllDescendants(root_element, "w:ins").length;
916
+ accepted_deletions += findAllDescendants(root_element, "w:del").length;
917
+ for (const tag of ["w:rPrChange", "w:pPrChange", "w:sectPrChange"]) {
918
+ accepted_formatting += findAllDescendants(root_element, tag).length;
919
+ }
920
+ }
921
+
922
+ // Counted as it happens below, not pre-read from the comments part: this
923
+ // method only deletes the bodies of OUR OWN comments wrapping a resolved
924
+ // revision (foreign ones keep their body by design), so the document's
925
+ // comment total would claim removals that never happened.
926
+ let removed_comments = 0;
927
+
908
928
  for (const root_element of parts_to_process) {
909
929
  const insNodes = findAllDescendants(root_element, "w:ins");
910
930
  for (const ins of insNodes) {
911
- this._clean_wrapping_comments(ins);
931
+ removed_comments += this._clean_wrapping_comments(ins);
912
932
  const parent = ins.parentNode as Element | null;
913
933
  if (!parent) continue;
914
934
 
@@ -956,8 +976,8 @@ export class RedlineEngine {
956
976
  if (has_content) {
957
977
  rPr.removeChild(delMark);
958
978
  } else {
959
- this._clean_wrapping_comments(p);
960
- this._delete_comments_in_element(p);
979
+ removed_comments += this._clean_wrapping_comments(p);
980
+ removed_comments += this._delete_comments_in_element(p);
961
981
  if (p.parentNode) {
962
982
  p.parentNode.removeChild(p);
963
983
  }
@@ -968,8 +988,8 @@ export class RedlineEngine {
968
988
 
969
989
  const delNodes = findAllDescendants(root_element, "w:del");
970
990
  for (const d of delNodes) {
971
- this._clean_wrapping_comments(d);
972
- this._delete_comments_in_element(d);
991
+ removed_comments += this._clean_wrapping_comments(d);
992
+ removed_comments += this._delete_comments_in_element(d);
973
993
  const parent = d.parentNode as Element | null;
974
994
  if (parent) {
975
995
  if (parent.tagName === "w:trPr") {
@@ -1089,6 +1109,13 @@ export class RedlineEngine {
1089
1109
  }
1090
1110
  }
1091
1111
  }
1112
+
1113
+ return {
1114
+ accepted_insertions,
1115
+ accepted_deletions,
1116
+ accepted_formatting,
1117
+ removed_comments,
1118
+ };
1092
1119
  }
1093
1120
 
1094
1121
  /**
@@ -1985,7 +2012,10 @@ export class RedlineEngine {
1985
2012
  }
1986
2013
  return null;
1987
2014
  }
1988
- private _clean_wrapping_comments(element: Element) {
2015
+ /** Returns how many comment BODIES were actually deleted (see below: only
2016
+ * our own are; foreign ones keep their body and lose only the anchor). */
2017
+ private _clean_wrapping_comments(element: Element): number {
2018
+ let deleted = 0;
1989
2019
  let first_node: Element = element;
1990
2020
  while (true) {
1991
2021
  const prev = getPreviousElement(first_node);
@@ -2071,6 +2101,7 @@ export class RedlineEngine {
2071
2101
  const is_own = author !== null && author === this.author;
2072
2102
  if (is_own) {
2073
2103
  this.comments_manager.deleteComment(c_id);
2104
+ deleted++;
2074
2105
  }
2075
2106
  if (s.parentNode) s.parentNode.removeChild(s);
2076
2107
  for (const e of ends_to_remove) {
@@ -2088,14 +2119,18 @@ export class RedlineEngine {
2088
2119
  }
2089
2120
  }
2090
2121
  }
2122
+ return deleted;
2091
2123
  }
2092
2124
 
2093
- private _delete_comments_in_element(element: Element) {
2125
+ /** Returns how many comment bodies were deleted. */
2126
+ private _delete_comments_in_element(element: Element): number {
2127
+ let deleted = 0;
2094
2128
  const refs = findAllDescendants(element, "w:commentReference");
2095
2129
  for (const ref of refs) {
2096
2130
  const c_id = ref.getAttribute("w:id");
2097
2131
  if (c_id) {
2098
2132
  this.comments_manager.deleteComment(c_id);
2133
+ deleted++;
2099
2134
  for (const tag of ["w:commentRangeStart", "w:commentRangeEnd"]) {
2100
2135
  const nodes = findAllDescendants(this.doc.element, tag);
2101
2136
  for (const node of nodes) {
@@ -2106,6 +2141,7 @@ export class RedlineEngine {
2106
2141
  }
2107
2142
  }
2108
2143
  }
2144
+ return deleted;
2109
2145
  }
2110
2146
 
2111
2147
  public validate_edits(edits: any[], index_offset: number = 0): string[] {
@@ -2893,6 +2929,7 @@ export class RedlineEngine {
2893
2929
  );
2894
2930
  reports_by_input[i] = {
2895
2931
  status: "failed",
2932
+ type: (edit as any).type || "modify",
2896
2933
  target_text: truncate_middle((edit as any).target_text || "", REPORT_ECHO_CAP),
2897
2934
  new_text: truncate_middle(RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
2898
2935
  warning: warning,
@@ -2908,6 +2945,7 @@ export class RedlineEngine {
2908
2945
  const previews = this._build_edit_context_previews(edit);
2909
2946
  reports_by_input[i] = {
2910
2947
  status: "applied",
2948
+ type: (edit as any).type || "modify",
2911
2949
  target_text: truncate_middle((edit as any).target_text || "", REPORT_ECHO_CAP),
2912
2950
  new_text: truncate_middle(RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
2913
2951
  warning: null,
@@ -2932,6 +2970,7 @@ export class RedlineEngine {
2932
2970
  );
2933
2971
  reports_by_input[i] = {
2934
2972
  status: "failed",
2973
+ type: (edit as any).type || "modify",
2935
2974
  target_text: truncate_middle((edit as any).target_text || "", REPORT_ECHO_CAP),
2936
2975
  new_text: truncate_middle(RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
2937
2976
  warning: warning,
@@ -2954,6 +2993,7 @@ export class RedlineEngine {
2954
2993
  if (report.status === "applied") {
2955
2994
  reports_by_input[i] = {
2956
2995
  status: "failed",
2996
+ type: report.type || "modify",
2957
2997
  target_text: report.target_text,
2958
2998
  new_text: report.new_text,
2959
2999
  warning: null,
@@ -3030,6 +3070,7 @@ export class RedlineEngine {
3030
3070
  }
3031
3071
  edits_reports.push({
3032
3072
  status: success ? "applied" : "failed",
3073
+ type: (edit as any).type || "modify",
3033
3074
  target_text: truncate_middle((edit as any).target_text || "", REPORT_ECHO_CAP),
3034
3075
  new_text: truncate_middle(RedlineEngine._report_new_text(edit), REPORT_ECHO_CAP),
3035
3076
  warning: warning,
@@ -3122,13 +3163,64 @@ export class RedlineEngine {
3122
3163
  }
3123
3164
 
3124
3165
  if (matches.length > 0) {
3125
- // Record WHICH mapper produced the offset: a clean-view index
3126
- // resolved against the raw mapper lands rows at the wrong position
3127
- // once earlier edits in the batch put tracked changes in the
3128
- // anchor row.
3129
- edit._resolved_start_idx = matches[0][0];
3130
- edit._active_mapper_ref = resolved_mapper;
3131
- resolved_edits.push([edit, null]);
3166
+ const match_mode = edit.match_mode || "strict";
3167
+
3168
+ // We need to resolve matches to unique w:tr elements to deduplicate them.
3169
+ const unique_matches: [number, number][] = [];
3170
+ const seen_trs = new Set<any>();
3171
+
3172
+ for (const match of matches) {
3173
+ const start_idx = match[0];
3174
+ const [anchor_run, anchor_para] = resolved_mapper.get_insertion_anchor(start_idx, false);
3175
+ let target_element: Element | null = null;
3176
+ if (anchor_run) target_element = anchor_run._element;
3177
+ else if (anchor_para) target_element = anchor_para._element;
3178
+
3179
+ let tr: Element | null = target_element;
3180
+ while (tr && tr.tagName !== "w:tr") tr = tr.parentNode as Element;
3181
+
3182
+ if (tr) {
3183
+ if (!seen_trs.has(tr)) {
3184
+ seen_trs.add(tr);
3185
+ unique_matches.push(match);
3186
+ }
3187
+ }
3188
+ }
3189
+
3190
+ if (unique_matches.length > 0) {
3191
+ let matches_to_apply = unique_matches;
3192
+ if (match_mode === "strict" || match_mode === "first") {
3193
+ matches_to_apply = unique_matches.slice(0, 1);
3194
+ }
3195
+
3196
+ if (match_mode === "all" || matches_to_apply.length > 1) {
3197
+ // Create sub-edits for each match so that they are processed as independent operations,
3198
+ // and the occurrences_modified and applied_status are tracked correctly on the parent.
3199
+ for (const m of matches_to_apply) {
3200
+ const sub_edit = {
3201
+ ...edit,
3202
+ _resolved_start_idx: m[0],
3203
+ _active_mapper_ref: resolved_mapper,
3204
+ _parent_edit_ref: edit,
3205
+ };
3206
+ resolved_edits.push([sub_edit, null]);
3207
+ }
3208
+ } else {
3209
+ // Single match case for non-"all" modes
3210
+ edit._resolved_start_idx = matches_to_apply[0][0];
3211
+ edit._active_mapper_ref = resolved_mapper;
3212
+ resolved_edits.push([edit, null]);
3213
+ }
3214
+ } else {
3215
+ skipped++;
3216
+ edit._applied_status = false;
3217
+ const target_snippet = (edit.target_text || "")
3218
+ .trim()
3219
+ .substring(0, 40);
3220
+ const msg = `- Failed to locate row target: '${target_snippet}...'`;
3221
+ this.skipped_details.push(msg);
3222
+ edit._error_msg = msg;
3223
+ }
3132
3224
  } else {
3133
3225
  skipped++;
3134
3226
  edit._applied_status = false;
@@ -3498,8 +3590,22 @@ export class RedlineEngine {
3498
3590
  let already_resolved = 0;
3499
3591
  const resolved_history = new Map<string, string>(); // id -> resolving action type
3500
3592
 
3501
- for (let pos = 0; pos < actions.length; pos++) {
3502
- const action = actions[pos];
3593
+ // Sort actions internally: non-destructive metadata operations (ReplyComment) first,
3594
+ // followed by destructive structural operations (AcceptChange, RejectChange).
3595
+ // Stable sort preserves the original relative ordering, and we preserve `pos`
3596
+ // so diagnostic messages refer to the original array indexes.
3597
+ const sortedActions = actions
3598
+ .map((action, pos) => ({ action, pos }))
3599
+ .sort((a, b) => {
3600
+ const aPri = a.action.type === "reply" ? 0 : 1;
3601
+ const bPri = b.action.type === "reply" ? 0 : 1;
3602
+ if (aPri !== bPri) {
3603
+ return aPri - bPri;
3604
+ }
3605
+ return a.pos - b.pos;
3606
+ });
3607
+
3608
+ for (const { action, pos } of sortedActions) {
3503
3609
  const type = action.type;
3504
3610
  if (type === "reply") {
3505
3611
  const cid = action.target_id.replace("Com:", "");
package/src/ingest.ts CHANGED
@@ -254,10 +254,27 @@ export function extract_table(
254
254
  // via the mapper). We key on the cell's first paragraph w14:paraId, which
255
255
  // Word assigns and keeps stable across reads.
256
256
  if (!cleanView) {
257
- const firstP = cell._element.getElementsByTagName("w:p")[0] as
257
+ let firstP = cell._element.getElementsByTagName("w:p")[0] as
258
258
  | Element
259
259
  | undefined;
260
- const paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
260
+ let paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
261
+ if (!paraId && (!cell_content || cell_content.trim() === "")) {
262
+ if (!firstP) {
263
+ const xmlDoc = cell._element.ownerDocument!;
264
+ firstP = xmlDoc.createElement("w:p");
265
+ cell._element.appendChild(firstP);
266
+ }
267
+ const allPs = Array.from(cell._element.ownerDocument!.getElementsByTagName("w:p"));
268
+ const index = allPs.indexOf(firstP);
269
+ let hash = 2166136261;
270
+ const str = `fallback-paraId-${index}`;
271
+ for (let i = 0; i < str.length; i++) {
272
+ hash ^= str.charCodeAt(i);
273
+ hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
274
+ }
275
+ paraId = (hash >>> 0).toString(16).toUpperCase().padStart(8, '0');
276
+ firstP.setAttribute("w14:paraId", paraId);
277
+ }
261
278
  if (paraId) {
262
279
  const space_pad = cell_content ? " " : "";
263
280
  const anchor = `${space_pad}{#cell:${paraId}}`;
package/src/mapper.ts CHANGED
@@ -348,10 +348,28 @@ export class DocumentMapper {
348
348
  // can resolve "write into this cell" even when the cell is empty
349
349
  // (pPr-only paragraph with no run).
350
350
  if (!this.clean_view && !this.original_view) {
351
- const firstP = cell._element.getElementsByTagName("w:p")[0] as
351
+ let firstP = cell._element.getElementsByTagName("w:p")[0] as
352
352
  | Element
353
353
  | undefined;
354
- const paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
354
+ let paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
355
+ const is_empty = current === cell_start;
356
+ if (!paraId && is_empty) {
357
+ if (!firstP) {
358
+ const xmlDoc = cell._element.ownerDocument!;
359
+ firstP = xmlDoc.createElement("w:p");
360
+ cell._element.appendChild(firstP);
361
+ }
362
+ const allPs = Array.from(cell._element.ownerDocument!.getElementsByTagName("w:p"));
363
+ const index = allPs.indexOf(firstP);
364
+ let hash = 2166136261;
365
+ const str = `fallback-paraId-${index}`;
366
+ for (let i = 0; i < str.length; i++) {
367
+ hash ^= str.charCodeAt(i);
368
+ hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
369
+ }
370
+ paraId = (hash >>> 0).toString(16).toUpperCase().padStart(8, '0');
371
+ firstP.setAttribute("w14:paraId", paraId);
372
+ }
355
373
  if (paraId && firstP) {
356
374
  // Zero-width span bound to the empty cell paragraph: gives
357
375
  // get_insertion_anchor a paragraph to land on. Placed at the anchor
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createTestDocument, addTable, setCellText } from "./test-utils.js";
3
+ import { extractTextFromBuffer } from "./ingest.js";
4
+ import { RedlineEngine } from "./engine.js";
5
+
6
+ describe("QA Regression Test - Finding F1: Missing cell anchors for empty table cells", () => {
7
+ it("should generate and render stable cell anchors for empty table cells", async () => {
8
+ // 1. Build a document containing a table with empty/blank cells.
9
+ // In this document, the cells are constructed programmatically without any pre-existing w14:paraId.
10
+ const doc = await createTestDocument();
11
+ const tbl = addTable(doc, 1, 2);
12
+ setCellText(tbl, 0, 0, "Hello");
13
+ // Cell (0,1) is left completely empty, with no pre-existing w14:paraId attribute on its w:p element.
14
+
15
+ const buf = await doc.save();
16
+
17
+ // 2. Ingest/extract text from the document.
18
+ const text = await extractTextFromBuffer(buf, false);
19
+
20
+ // 3. Assert correct behavior.
21
+ // The empty cell must still render a trailing {#cell:<id>} anchor so that it can be targeted for edits.
22
+ // We expect the output to be formatted like: Hello | {#cell:<id>}
23
+ const cellAnchorRegex = /Hello \| \{#cell:[0-9a-fA-F]{8}\}/;
24
+ expect(text).toSatisfy((val: string) => {
25
+ return cellAnchorRegex.test(val);
26
+ }, `Expected extracted text to contain a cell anchor for the empty cell, but got:\n"${text}"`);
27
+ });
28
+ });
29
+
30
+ describe("QA Regression Test - Top Bug: match_mode='all' fails to affect multiple matches for table row edits", () => {
31
+ it("should delete all rows containing target_text when match_mode is all", async () => {
32
+ const doc = await createTestDocument();
33
+ const tbl = addTable(doc, 3, 3);
34
+ setCellText(tbl, 0, 0, "ID");
35
+ setCellText(tbl, 0, 1, "Name");
36
+ setCellText(tbl, 0, 2, "Notes");
37
+
38
+ setCellText(tbl, 1, 0, "1");
39
+ setCellText(tbl, 1, 1, "Alice");
40
+ setCellText(tbl, 1, 2, "First record");
41
+
42
+ setCellText(tbl, 2, 0, "2");
43
+ setCellText(tbl, 2, 1, "");
44
+ setCellText(tbl, 2, 2, "Second record with empty name");
45
+
46
+ const buf = await doc.save();
47
+ const midDoc = await (doc.constructor as any).load(buf);
48
+
49
+ const engine = new RedlineEngine(midDoc);
50
+ const stats = engine.process_batch([
51
+ {
52
+ type: "delete_row",
53
+ target_text: "record",
54
+ match_mode: "all",
55
+ } as any,
56
+ ]);
57
+
58
+ expect(stats.edits_applied).toBe(1);
59
+ expect(stats.occurrences_modified).toBe(2);
60
+
61
+ engine.accept_all_revisions();
62
+ const finalBuf = await midDoc.save();
63
+ const clean_text = await extractTextFromBuffer(finalBuf, true);
64
+
65
+ expect(clean_text).not.toContain("First record");
66
+ expect(clean_text).not.toContain("Second record with empty name");
67
+ });
68
+
69
+ it("should insert rows adjacent to all rows containing target_text when match_mode is all", async () => {
70
+ const doc = await createTestDocument();
71
+ const tbl = addTable(doc, 3, 3);
72
+ setCellText(tbl, 0, 0, "ID");
73
+ setCellText(tbl, 0, 1, "Name");
74
+ setCellText(tbl, 0, 2, "Notes");
75
+
76
+ setCellText(tbl, 1, 0, "1");
77
+ setCellText(tbl, 1, 1, "Alice");
78
+ setCellText(tbl, 1, 2, "First record");
79
+
80
+ setCellText(tbl, 2, 0, "2");
81
+ setCellText(tbl, 2, 1, "");
82
+ setCellText(tbl, 2, 2, "Second record with empty name");
83
+
84
+ const buf = await doc.save();
85
+ const midDoc = await (doc.constructor as any).load(buf);
86
+
87
+ const engine = new RedlineEngine(midDoc);
88
+ const stats = engine.process_batch([
89
+ {
90
+ type: "insert_row",
91
+ target_text: "record",
92
+ match_mode: "all",
93
+ position: "below",
94
+ cells: ["NEW_ID", "NEW_NAME", "NEW_NOTES"],
95
+ } as any,
96
+ ]);
97
+
98
+ expect(stats.edits_applied).toBe(1);
99
+ expect(stats.occurrences_modified).toBe(2);
100
+
101
+ engine.accept_all_revisions();
102
+ const finalBuf = await midDoc.save();
103
+ const clean_text = await extractTextFromBuffer(finalBuf, true);
104
+
105
+ const occurrences = (clean_text.match(/NEW_ID \| NEW_NAME \| NEW_NOTES/g) || []).length;
106
+ expect(occurrences).toBe(2);
107
+ });
108
+ });
@@ -29,7 +29,7 @@ export async function finalize_document(doc: DocumentObject, options: FinalizeOp
29
29
 
30
30
  if (total > 0 && !options.accept_all) {
31
31
  report.status = 'blocked';
32
- report.blocked_reason = `Document contains ${total} unresolved tracked changes (${counts[0]} insertions, ${counts[1]} deletions, ${counts[2]} formatting). Review in Word first, or set accept_all=true.`;
32
+ report.blocked_reason = `Document contains ${total} unresolved tracked changes (${counts[0]} insertions, ${counts[1]} deletions, ${counts[2]} formatting). Review in Word first, set accept_all=true, or set sanitize_mode='keep-markup'.`;
33
33
  return { reportText: report.render() };
34
34
  }
35
35
 
@@ -220,6 +220,7 @@ describe("Finalize Document (Core)", () => {
220
220
 
221
221
  expect(res.reportText).toContain("BLOCKED:");
222
222
  expect(res.reportText).toContain("unresolved tracked changes");
223
+ expect(res.reportText).toContain("sanitize_mode='keep-markup'");
223
224
  });
224
225
 
225
226
  describe("Resolved Bugs Sanitize Parity Verification", () => {