@adeu/core 1.6.7 → 1.6.9

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/src/engine.ts CHANGED
@@ -1,15 +1,26 @@
1
- import { DocumentObject } from './docx/bridge.js';
2
- import { Paragraph, Table, Run, DocxEvent } from './docx/primitives.js';
3
- import { DocumentMapper, TextSpan } from './mapper.js';
4
- import { CommentsManager } from './comments.js';
5
- import {
6
- ModifyText, InsertTableRow, DeleteTableRow, AcceptChange, RejectChange, ReplyComment, DocumentChange
7
- } from './models.js';
8
- import { trim_common_context } from './diff.js';
9
- import { findChild, findAllDescendants, serializeXml } from './docx/dom.js';
10
- import {
11
- is_heading_paragraph, is_native_heading, get_run_style_markers, get_run_text, apply_formatting_to_segments
12
- } from './utils/docx.js';
1
+ import { DocumentObject } from "./docx/bridge.js";
2
+ import { Paragraph, Table, Run, DocxEvent } from "./docx/primitives.js";
3
+ import { DocumentMapper, TextSpan } from "./mapper.js";
4
+ import { CommentsManager } from "./comments.js";
5
+ import {
6
+ ModifyText,
7
+ InsertTableRow,
8
+ DeleteTableRow,
9
+ AcceptChange,
10
+ RejectChange,
11
+ ReplyComment,
12
+ DocumentChange,
13
+ } from "./models.js";
14
+ import { trim_common_context } from "./diff.js";
15
+ import { findChild, findAllDescendants, serializeXml } from "./docx/dom.js";
16
+ import {
17
+ is_heading_paragraph,
18
+ is_native_heading,
19
+ get_run_style_markers,
20
+ get_run_text,
21
+ apply_formatting_to_segments,
22
+ } from "./utils/docx.js";
23
+ import { format_ambiguity_error } from "./markup.js";
13
24
 
14
25
  // --- DOM Mutation Helpers for xmldom ---
15
26
  function getNextElement(el: Element): Element | null {
@@ -43,7 +54,9 @@ function insertBefore(newNode: Node, refNode: Element) {
43
54
  }
44
55
 
45
56
  function insertAtIndex(parent: Element, index: number, child: Node) {
46
- const children = Array.from(parent.childNodes).filter(n => n.nodeType === 1);
57
+ const children = Array.from(parent.childNodes).filter(
58
+ (n) => n.nodeType === 1,
59
+ );
47
60
  if (index >= children.length) {
48
61
  parent.appendChild(child);
49
62
  } else {
@@ -69,18 +82,36 @@ export function validate_edit_strings(edits: any[]): string[] {
69
82
  const t_text = edit.target_text || "";
70
83
  const n_text = edit.new_text || "";
71
84
 
72
- if (n_text.includes("{++") || n_text.includes("{--") || n_text.includes("{>>") || n_text.includes("{==")) {
73
- errors.push(`- Edit ${i + 1} Failed: Do not manually write CriticMarkup tags ({++, {--, {>>, {==) in \`new_text\`. The engine handles redlining automatically. To add a comment, use the \`comment\` parameter.`);
85
+ if (
86
+ n_text.includes("{++") ||
87
+ n_text.includes("{--") ||
88
+ n_text.includes("{>>") ||
89
+ n_text.includes("{==")
90
+ ) {
91
+ errors.push(
92
+ `- Edit ${i + 1} Failed: Do not manually write CriticMarkup tags ({++, {--, {>>, {==) in \`new_text\`. The engine handles redlining automatically. To add a comment, use the \`comment\` parameter.`,
93
+ );
74
94
  }
75
95
 
76
96
  if (t_text.includes("[^") || n_text.includes("[^")) {
77
97
  const t_fns = (t_text.match(/\[\^(?:fn|en)-[^\]]+\]/g) || []).sort();
78
98
  const n_fns = (n_text.match(/\[\^(?:fn|en)-[^\]]+\]/g) || []).sort();
79
99
  if (JSON.stringify(t_fns) !== JSON.stringify(n_fns)) {
80
- if (n_fns.length > t_fns.length || n_fns.some((f: string) => n_fns.filter((x: string) => x===f).length > t_fns.filter((x: string) => x===f).length)) {
81
- errors.push(`- Edit ${i + 1} Failed: Cannot insert footnote/endnote markers via text replace. Markers like \`[^fn-N]\` are read-only projections. Use Word's References menu.`);
100
+ if (
101
+ n_fns.length > t_fns.length ||
102
+ n_fns.some(
103
+ (f: string) =>
104
+ n_fns.filter((x: string) => x === f).length >
105
+ t_fns.filter((x: string) => x === f).length,
106
+ )
107
+ ) {
108
+ errors.push(
109
+ `- Edit ${i + 1} Failed: Cannot insert footnote/endnote markers via text replace. Markers like \`[^fn-N]\` are read-only projections. Use Word's References menu.`,
110
+ );
82
111
  } else {
83
- errors.push(`- Edit ${i + 1} Failed: Cannot delete footnote/endnote references via text replace. The marker corresponds to a structural XML element.`);
112
+ errors.push(
113
+ `- Edit ${i + 1} Failed: Cannot delete footnote/endnote references via text replace. The marker corresponds to a structural XML element.`,
114
+ );
84
115
  }
85
116
  }
86
117
  }
@@ -90,28 +121,43 @@ export function validate_edit_strings(edits: any[]): string[] {
90
121
  const n_links = (n_text.match(/\[(?!~)[^\]]+\]\([^)]+\)/g) || []).sort();
91
122
  if (t_links.length !== n_links.length) {
92
123
  if (n_links.length > t_links.length) {
93
- errors.push(`- Edit ${i + 1} Failed: Cannot insert hyperlinks via text replace. Use a dedicated structural operation.`);
124
+ errors.push(
125
+ `- Edit ${i + 1} Failed: Cannot insert hyperlinks via text replace. Use a dedicated structural operation.`,
126
+ );
94
127
  } else {
95
- errors.push(`- Edit ${i + 1} Failed: Cannot delete hyperlinks via text replace. The marker corresponds to a structural XML element.`);
128
+ errors.push(
129
+ `- Edit ${i + 1} Failed: Cannot delete hyperlinks via text replace. The marker corresponds to a structural XML element.`,
130
+ );
96
131
  }
97
- } else if (t_links.length > 1 && JSON.stringify(t_links) !== JSON.stringify(n_links)) {
98
- errors.push(`- Edit ${i + 1} Failed: Can only edit or retarget one hyperlink per text replacement. Please split into multiple edits.`);
132
+ } else if (
133
+ t_links.length > 1 &&
134
+ JSON.stringify(t_links) !== JSON.stringify(n_links)
135
+ ) {
136
+ errors.push(
137
+ `- Edit ${i + 1} Failed: Can only edit or retarget one hyperlink per text replacement. Please split into multiple edits.`,
138
+ );
99
139
  }
100
140
  }
101
141
 
102
142
  if (t_text.includes("[~") || n_text.includes("[~")) {
103
- const t_xrefs = (t_text.match(/\[~[^~]+~\]\(#[^\)]+\)/g) || []);
104
- const n_xrefs = (n_text.match(/\[~[^~]+~\]\(#[^\)]+\)/g) || []);
143
+ const t_xrefs = t_text.match(/\[~[^~]+~\]\(#[^\)]+\)/g) || [];
144
+ const n_xrefs = n_text.match(/\[~[^~]+~\]\(#[^\)]+\)/g) || [];
105
145
  if (t_xrefs.length !== n_xrefs.length) {
106
146
  if (n_xrefs.length > t_xrefs.length) {
107
- errors.push(`- Edit ${i + 1} Failed: Cannot insert cross-references via text replace. Markers are read-only projections.`);
147
+ errors.push(
148
+ `- Edit ${i + 1} Failed: Cannot insert cross-references via text replace. Markers are read-only projections.`,
149
+ );
108
150
  } else {
109
- errors.push(`- Edit ${i + 1} Failed: Cannot delete cross-references via text replace. The marker corresponds to a structural XML element.`);
151
+ errors.push(
152
+ `- Edit ${i + 1} Failed: Cannot delete cross-references via text replace. The marker corresponds to a structural XML element.`,
153
+ );
110
154
  }
111
155
  } else {
112
156
  // Advanced XREF validation simplified for port scope
113
157
  if (JSON.stringify(t_xrefs) !== JSON.stringify(n_xrefs)) {
114
- errors.push(`- Edit ${i + 1} Failed: Modifying or retargeting cross-reference markers is disallowed to prevent dependency corruption.`);
158
+ errors.push(
159
+ `- Edit ${i + 1} Failed: Modifying or retargeting cross-reference markers is disallowed to prevent dependency corruption.`,
160
+ );
115
161
  }
116
162
  }
117
163
  }
@@ -120,30 +166,46 @@ export function validate_edit_strings(edits: any[]): string[] {
120
166
  const t_anchors = t_text.match(/\{#[^\}]+\}/g) || [];
121
167
  const n_anchors = n_text.match(/\{#[^\}]+\}/g) || [];
122
168
  for (const a of n_anchors) {
123
- if (n_anchors.filter((x: string) => x===a).length > t_anchors.filter((x: string) => x===a).length) {
124
- errors.push(`- Edit ${i + 1} Failed: Cannot modify or insert internal anchor markers (\`{#...}\`). These represent structural XML bookmarks.`);
169
+ if (
170
+ n_anchors.filter((x: string) => x === a).length >
171
+ t_anchors.filter((x: string) => x === a).length
172
+ ) {
173
+ errors.push(
174
+ `- Edit ${i + 1} Failed: Cannot modify or insert internal anchor markers (\`{#...}\`). These represent structural XML bookmarks.`,
175
+ );
125
176
  break;
126
177
  }
127
178
  }
128
179
  }
129
180
 
130
- if (edit.type === 'modify' && n_text) {
131
- const lines = n_text.split('\n');
181
+ if (edit.type === "modify" && n_text) {
182
+ const lines = n_text.split(/[\r\n]+/);
132
183
  for (const line of lines) {
133
184
  const stripped = line.trimStart();
134
185
  if (stripped.startsWith("#######")) {
135
- const level = stripped.length - stripped.replace(/^#+/, '').length;
136
- if (stripped.substring(level).startsWith(' ') || stripped.substring(level) === '') {
137
- errors.push(`- Edit ${i + 1} Failed: Heading level ${level} is not supported (maximum is 6).`);
186
+ const level = stripped.length - stripped.replace(/^#+/, "").length;
187
+ if (
188
+ stripped.substring(level).startsWith(" ") ||
189
+ stripped.substring(level) === ""
190
+ ) {
191
+ errors.push(
192
+ `- Edit ${i + 1} Failed: Heading level ${level} is not supported (maximum is 6).`,
193
+ );
138
194
  break;
139
195
  }
140
196
  }
141
197
  }
142
198
  }
143
199
 
144
- if (t_text.includes("READONLY_BOUNDARY_START") || n_text.includes("READONLY_BOUNDARY_START") ||
145
- t_text.includes("# Document Structure (Read-Only)") || n_text.includes("# Document Structure (Read-Only)")) {
146
- errors.push(`- Edit ${i + 1} Failed: Modification targets the read-only boundary (Structural Appendix). This section cannot be edited.`);
200
+ if (
201
+ t_text.includes("READONLY_BOUNDARY_START") ||
202
+ n_text.includes("READONLY_BOUNDARY_START") ||
203
+ t_text.includes("# Document Structure (Read-Only)") ||
204
+ n_text.includes("# Document Structure (Read-Only)")
205
+ ) {
206
+ errors.push(
207
+ `- Edit ${i + 1} Failed: Modification targets the read-only boundary (Structural Appendix). This section cannot be edited.`,
208
+ );
147
209
  }
148
210
  }
149
211
 
@@ -164,13 +226,18 @@ export class RedlineEngine {
164
226
  constructor(doc: DocumentObject, author: string = "Adeu AI (TS)") {
165
227
  this.doc = doc;
166
228
  this.author = author;
167
- this.timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
168
-
169
- const w16du_ns = "http://schemas.microsoft.com/office/word/2023/wordml/word16du";
229
+ this.timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
230
+
231
+ const w16du_ns =
232
+ "http://schemas.microsoft.com/office/word/2023/wordml/word16du";
170
233
  for (const part of this.doc.pkg.parts) {
171
- if (part === this.doc.part || (part.contentType.includes('wordprocessingml') && part.contentType.endsWith('+xml'))) {
172
- if (!part._element.hasAttribute('xmlns:w16du')) {
173
- part._element.setAttribute('xmlns:w16du', w16du_ns);
234
+ if (
235
+ part === this.doc.part ||
236
+ (part.contentType.includes("wordprocessingml") &&
237
+ part.contentType.endsWith("+xml"))
238
+ ) {
239
+ if (!part._element.hasAttribute("xmlns:w16du")) {
240
+ part._element.setAttribute("xmlns:w16du", w16du_ns);
174
241
  }
175
242
  }
176
243
  }
@@ -182,10 +249,10 @@ export class RedlineEngine {
182
249
 
183
250
  private _scan_existing_ids(): number {
184
251
  let maxId = 0;
185
- for (const tag of ['w:ins', 'w:del']) {
252
+ for (const tag of ["w:ins", "w:del"]) {
186
253
  const elements = findAllDescendants(this.doc.element, tag);
187
254
  for (const el of elements) {
188
- const val = parseInt(el.getAttribute('w:id') || '0', 10);
255
+ const val = parseInt(el.getAttribute("w:id") || "0", 10);
189
256
  if (!isNaN(val) && val > maxId) maxId = val;
190
257
  }
191
258
  }
@@ -193,34 +260,136 @@ export class RedlineEngine {
193
260
  }
194
261
 
195
262
  public accept_all_revisions() {
196
- const dels = findAllDescendants(this.doc.element, 'w:del');
197
- for (const d of dels) {
198
- const parent = d.parentNode as Element | null;
199
- if (parent?.tagName === 'w:trPr') {
200
- const tr = parent.parentNode;
201
- tr?.parentNode?.removeChild(tr);
202
- } else {
203
- parent?.removeChild(d);
263
+ const parts_to_process: Element[] = [this.doc.element];
264
+
265
+ for (const part of this.doc.pkg.parts) {
266
+ if (part === this.doc.part) continue;
267
+ if (
268
+ part.contentType.includes("wordprocessingml") &&
269
+ part.contentType.endsWith("+xml")
270
+ ) {
271
+ parts_to_process.push(part._element);
204
272
  }
205
273
  }
206
- const insNodes = findAllDescendants(this.doc.element, 'w:ins');
207
- for (const i of insNodes) {
208
- const parent = i.parentNode as Element | null;
209
- if (parent?.tagName === 'w:trPr') {
210
- parent.removeChild(i);
211
- } else {
212
- while (i.firstChild) parent?.insertBefore(i.firstChild, i);
213
- parent?.removeChild(i);
274
+
275
+ for (const root_element of parts_to_process) {
276
+ const insNodes = findAllDescendants(root_element, "w:ins");
277
+ for (const ins of insNodes) {
278
+ this._clean_wrapping_comments(ins);
279
+ const parent = ins.parentNode as Element | null;
280
+ if (!parent) continue;
281
+
282
+ if (parent.tagName === "w:trPr") {
283
+ parent.removeChild(ins);
284
+ continue;
285
+ }
286
+
287
+ while (ins.firstChild) {
288
+ parent.insertBefore(ins.firstChild, ins);
289
+ }
290
+ parent.removeChild(ins);
291
+ }
292
+
293
+ const pNodes = findAllDescendants(root_element, "w:p");
294
+ for (const p of pNodes) {
295
+ const pPr = findChild(p, "w:pPr");
296
+ if (pPr) {
297
+ const rPr = findChild(pPr, "w:rPr");
298
+ const delMark = rPr ? findChild(rPr, "w:del") : null;
299
+ if (rPr && delMark) {
300
+ let has_content = false;
301
+ for (const tag of ["w:t", "w:tab", "w:br"]) {
302
+ for (const child of findAllDescendants(p, tag)) {
303
+ if (tag === "w:t" && !child.textContent) continue;
304
+
305
+ let is_deleted = false;
306
+ let curr = child.parentNode as Element | null;
307
+ while (curr && curr !== p) {
308
+ if (curr.tagName === "w:del") {
309
+ is_deleted = true;
310
+ break;
311
+ }
312
+ curr = curr.parentNode as Element | null;
313
+ }
314
+ if (!is_deleted) {
315
+ has_content = true;
316
+ break;
317
+ }
318
+ }
319
+ if (has_content) {
320
+ break;
321
+ }
322
+ }
323
+ if (has_content) {
324
+ rPr.removeChild(delMark);
325
+ } else {
326
+ this._clean_wrapping_comments(p);
327
+ this._delete_comments_in_element(p);
328
+ if (p.parentNode) {
329
+ p.parentNode.removeChild(p);
330
+ }
331
+ }
332
+ }
333
+ }
334
+ }
335
+
336
+ const delNodes = findAllDescendants(root_element, "w:del");
337
+ for (const d of delNodes) {
338
+ this._clean_wrapping_comments(d);
339
+ this._delete_comments_in_element(d);
340
+ const parent = d.parentNode as Element | null;
341
+ if (parent) {
342
+ if (parent.tagName === "w:trPr") {
343
+ const row = parent.parentNode as Element | null;
344
+ if (row && row.parentNode) {
345
+ row.parentNode.removeChild(row);
346
+ }
347
+ } else {
348
+ parent.removeChild(d);
349
+ }
350
+ }
351
+ }
352
+ }
353
+
354
+ // Final pass: remove any free-standing comments
355
+ const comment_ids = new Set<string>();
356
+ for (const tag of [
357
+ "w:commentRangeStart",
358
+ "w:commentRangeEnd",
359
+ "w:commentReference",
360
+ ]) {
361
+ for (const node of findAllDescendants(this.doc.element, tag)) {
362
+ const cid = node.getAttribute("w:id");
363
+ if (cid) comment_ids.add(cid);
214
364
  }
215
365
  }
216
- }
217
366
 
367
+ const comments_part = this.doc.pkg.parts.find(
368
+ (p) =>
369
+ p.contentType ===
370
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
371
+ );
372
+ if (comments_part) {
373
+ for (const c of findAllDescendants(comments_part._element, "w:comment")) {
374
+ const cid = c.getAttribute("w:id");
375
+ if (cid) comment_ids.add(cid);
376
+ }
377
+ }
378
+
379
+ for (const cid of comment_ids) {
380
+ this.comments_manager.deleteComment(cid);
381
+ }
382
+ }
218
383
  private _getNextId(): string {
219
384
  this.current_id++;
220
385
  return this.current_id.toString();
221
386
  }
222
387
 
223
- private _create_track_change_tag(tagName: string, author: string = "", reuseId: string | null = null): Element {
388
+ private _create_track_change_tag(
389
+ tagName: string,
390
+ author: string = "",
391
+ reuseId: string | null = null,
392
+ ): Element {
224
393
  const xmlDoc = this.doc.part._element.ownerDocument!;
225
394
  const tag = xmlDoc.createElement(tagName);
226
395
  const wid = reuseId !== null ? reuseId : this._getNextId();
@@ -238,6 +407,349 @@ export class RedlineEngine {
238
407
  }
239
408
  }
240
409
 
410
+ /**
411
+ * Attaches a comment that wraps a contiguous range within a single paragraph.
412
+ * start_element and end_element must both be direct children of parent_element
413
+ * and start_element must come before (or equal) end_element in document order.
414
+ * Ported from Python `RedlineEngine._attach_comment`.
415
+ */
416
+ private _attach_comment(
417
+ parent_element: Element,
418
+ start_element: Element,
419
+ end_element: Element,
420
+ text: string,
421
+ ) {
422
+ if (!text) return;
423
+
424
+ const comment_id = this.comments_manager.addComment(this.author, text);
425
+ const xmlDoc = parent_element.ownerDocument!;
426
+
427
+ const range_start = xmlDoc.createElement("w:commentRangeStart");
428
+ range_start.setAttribute("w:id", comment_id);
429
+
430
+ const range_end = xmlDoc.createElement("w:commentRangeEnd");
431
+ range_end.setAttribute("w:id", comment_id);
432
+
433
+ const ref_run = xmlDoc.createElement("w:r");
434
+ const rPr = xmlDoc.createElement("w:rPr");
435
+ const rStyle = xmlDoc.createElement("w:rStyle");
436
+ rStyle.setAttribute("w:val", "CommentReference");
437
+ rPr.appendChild(rStyle);
438
+ ref_run.appendChild(rPr);
439
+
440
+ const ref = xmlDoc.createElement("w:commentReference");
441
+ ref.setAttribute("w:id", comment_id);
442
+ ref_run.appendChild(ref);
443
+
444
+ // Insert <w:commentRangeStart> immediately before start_element.
445
+ // Insert <w:commentRangeEnd> immediately after end_element.
446
+ // Insert <w:r><w:commentReference/></w:r> immediately after the range end.
447
+ parent_element.insertBefore(range_start, start_element);
448
+
449
+ // After insertBefore above, sibling positions shifted. Re-find end_element's next sibling.
450
+ const after_end = end_element.nextSibling;
451
+ if (after_end) {
452
+ parent_element.insertBefore(range_end, after_end);
453
+ parent_element.insertBefore(ref_run, range_end.nextSibling);
454
+ } else {
455
+ parent_element.appendChild(range_end);
456
+ parent_element.appendChild(ref_run);
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Attaches a comment that spans across two different paragraphs (or other block
462
+ * containers). start_element lives inside start_p, end_element lives inside end_p,
463
+ * and the comment is open from start_element through end_element.
464
+ * Ported from Python `RedlineEngine._attach_comment_spanning`.
465
+ */
466
+ private _attach_comment_spanning(
467
+ start_p: Element,
468
+ start_el: Element,
469
+ end_p: Element,
470
+ end_el: Element,
471
+ text: string,
472
+ ) {
473
+ if (!text) return;
474
+
475
+ const comment_id = this.comments_manager.addComment(this.author, text);
476
+ const xmlDocStart = start_p.ownerDocument!;
477
+ const xmlDocEnd = end_p.ownerDocument!;
478
+
479
+ const range_start = xmlDocStart.createElement("w:commentRangeStart");
480
+ range_start.setAttribute("w:id", comment_id);
481
+
482
+ const range_end = xmlDocEnd.createElement("w:commentRangeEnd");
483
+ range_end.setAttribute("w:id", comment_id);
484
+
485
+ const ref_run = xmlDocEnd.createElement("w:r");
486
+ const rPr = xmlDocEnd.createElement("w:rPr");
487
+ const rStyle = xmlDocEnd.createElement("w:rStyle");
488
+ rStyle.setAttribute("w:val", "CommentReference");
489
+ rPr.appendChild(rStyle);
490
+ ref_run.appendChild(rPr);
491
+
492
+ const ref = xmlDocEnd.createElement("w:commentReference");
493
+ ref.setAttribute("w:id", comment_id);
494
+ ref_run.appendChild(ref);
495
+
496
+ // Place range start before start_el.
497
+ start_p.insertBefore(range_start, start_el);
498
+
499
+ // Place range end + reference run after end_el.
500
+ const after_end = end_el.nextSibling;
501
+ if (after_end) {
502
+ end_p.insertBefore(range_end, after_end);
503
+ end_p.insertBefore(ref_run, range_end.nextSibling);
504
+ } else {
505
+ end_p.appendChild(range_end);
506
+ end_p.appendChild(ref_run);
507
+ }
508
+ } /**
509
+ * Inserts `text` as one or more tracked paragraphs anchored relative to
510
+ * either an existing run or a paragraph. Returns:
511
+ * { first_node, last_p, last_ins, used_block_mode }
512
+ * where:
513
+ * - first_node: the first <w:ins> (for inline mode) OR the first new <w:p>
514
+ * (for block mode). The caller uses this for splicing into the DOM and
515
+ * for anchoring comments.
516
+ * - last_p: the last new <w:p> created, if any. null when entirely inline.
517
+ * - last_ins: the last <w:ins> created (inside the last new <w:p>, or the
518
+ * sole inline ins). Used as the comment's end anchor.
519
+ * - used_block_mode: true when the first line carried a heading/list style
520
+ * marker and we created a new paragraph for it (rather than inlining it).
521
+ *
522
+ * Multi-paragraph rules (only when text contains '\n'):
523
+ * - Each additional line becomes a new <w:p>, inserted after the anchor
524
+ * paragraph in document order.
525
+ * - Each new <w:p> gets a copy of the anchor paragraph's <w:pPr> (so list
526
+ * numbering / indentation are preserved) unless the line itself starts
527
+ * with a markdown heading or list marker, which overrides the style.
528
+ * - Each new <w:p> carries a tracked paragraph-break marker
529
+ * (<w:pPr><w:rPr><w:ins/></w:rPr></w:pPr>) so Word natively tracks the
530
+ * paragraph break.
531
+ * - Each new <w:p>'s content is wrapped in a <w:ins>, with inline bold/
532
+ * italic markdown parsed via _parse_inline_markdown.
533
+ *
534
+ * The first line:
535
+ * - If it carries a heading / list marker AND we have a paragraph anchor,
536
+ * we drop into "block mode": no inline <w:ins>; the first line itself
537
+ * becomes the first new <w:p>.
538
+ * - Otherwise we emit a single inline <w:ins> for the first line (current
539
+ * behaviour) and treat the remaining lines as block extensions.
540
+ *
541
+ * Does NOT attach comments; callers handle that.
542
+ */
543
+ private _track_insert_multiline(
544
+ text: string,
545
+ anchor_run: Run | null,
546
+ anchor_paragraph: Paragraph | null,
547
+ reuse_id: string,
548
+ ): {
549
+ first_node: Element | null;
550
+ last_p: Element | null;
551
+ last_ins: Element | null;
552
+ used_block_mode: boolean;
553
+ } {
554
+ if (!text) {
555
+ return {
556
+ first_node: null,
557
+ last_p: null,
558
+ last_ins: null,
559
+ used_block_mode: false,
560
+ };
561
+ }
562
+
563
+ const xmlDoc = this.doc.part._element.ownerDocument!;
564
+ const lines = text.split(/[\r\n]+/);
565
+
566
+ // Resolve the containing <w:p> (current_p) for the anchor.
567
+ let current_p: Element | null = null;
568
+ if (anchor_paragraph !== null) {
569
+ current_p = anchor_paragraph._element;
570
+ } else if (anchor_run !== null) {
571
+ let walker: Element | null = anchor_run._element;
572
+ while (walker && walker.tagName !== "w:p") {
573
+ walker = walker.parentNode as Element | null;
574
+ }
575
+ current_p = walker;
576
+ }
577
+
578
+ // Drop trailing empty line. "foo\n\nbar\n\n" splits to
579
+ // ['foo', '', 'bar', '']; that trailing empty is just a terminator, not
580
+ // a real empty paragraph.
581
+ while (lines.length > 1 && lines[lines.length - 1] === "") {
582
+ lines.pop();
583
+ }
584
+ if (lines.length === 0) {
585
+ return {
586
+ first_node: null,
587
+ last_p: null,
588
+ last_ins: null,
589
+ used_block_mode: false,
590
+ };
591
+ }
592
+
593
+ // Inspect the first line for heading/list markers.
594
+ const [first_clean, first_style] = this._parse_markdown_style(lines[0]);
595
+ const have_paragraph_context = current_p !== null;
596
+ const block_mode = first_style !== null && have_paragraph_context;
597
+
598
+ let first_node: Element | null = null;
599
+ let inline_ins: Element | null = null;
600
+
601
+ // ---- INLINE PATH for the first line (when NOT in block mode) ----
602
+ if (!block_mode) {
603
+ inline_ins = this._build_tracked_ins_for_line(
604
+ first_clean === lines[0] ? lines[0] : lines[0],
605
+ anchor_run,
606
+ reuse_id,
607
+ xmlDoc,
608
+ );
609
+ first_node = inline_ins;
610
+ // Caller will attach `inline_ins` to the DOM later — keep it for now.
611
+ }
612
+
613
+ // ---- BLOCK PATH for the first line (when in block mode) ----
614
+ // Block-mode first line is just the first extension paragraph below.
615
+ const remaining_lines = block_mode ? lines : lines.slice(1);
616
+
617
+ // If there's nothing to do beyond inline, we're done.
618
+ if (remaining_lines.length === 0) {
619
+ return {
620
+ first_node,
621
+ last_p: null,
622
+ last_ins: inline_ins,
623
+ used_block_mode: false,
624
+ };
625
+ }
626
+
627
+ if (!current_p) {
628
+ // Multi-paragraph insertion needs a paragraph context. Without one, fall
629
+ // back to the inline result we already built.
630
+ return {
631
+ first_node,
632
+ last_p: null,
633
+ last_ins: inline_ins,
634
+ used_block_mode: false,
635
+ };
636
+ }
637
+
638
+ const parent_body = current_p.parentNode as Element | null;
639
+ if (!parent_body) {
640
+ return {
641
+ first_node,
642
+ last_p: null,
643
+ last_ins: inline_ins,
644
+ used_block_mode: false,
645
+ };
646
+ }
647
+
648
+ const insertAfterEl = (newNode: Element, ref: Element) => {
649
+ parent_body.insertBefore(newNode, ref.nextSibling);
650
+ };
651
+
652
+ let last_p: Element | null = null;
653
+ let last_ins: Element | null = null;
654
+ let after: Element = current_p;
655
+
656
+ for (let i = 0; i < remaining_lines.length; i++) {
657
+ const raw_line = remaining_lines[i];
658
+ const [clean_text, style_name] = this._parse_markdown_style(raw_line);
659
+
660
+ const new_p = xmlDoc.createElement("w:p");
661
+
662
+ if (style_name) {
663
+ // Heading or list style was explicitly authored: replace pPr entirely.
664
+ this._set_paragraph_style(new_p, style_name);
665
+ } else {
666
+ // Inherit pPr from the anchor paragraph (preserves list numbering).
667
+ const existing_pPr = findChild(current_p, "w:pPr");
668
+ if (existing_pPr) {
669
+ new_p.appendChild(existing_pPr.cloneNode(true));
670
+ }
671
+ }
672
+
673
+ // Track the paragraph break itself as an insertion.
674
+ let pPr = findChild(new_p, "w:pPr");
675
+ if (!pPr) {
676
+ pPr = xmlDoc.createElement("w:pPr");
677
+ new_p.insertBefore(pPr, new_p.firstChild);
678
+ }
679
+ let rPr = findChild(pPr, "w:rPr");
680
+ if (!rPr) {
681
+ rPr = xmlDoc.createElement("w:rPr");
682
+ pPr.appendChild(rPr);
683
+ }
684
+ const ins_mark = this._create_track_change_tag("w:ins", "", reuse_id);
685
+ rPr.appendChild(ins_mark);
686
+
687
+ // Build the content <w:ins>.
688
+ const content_ins = this._build_tracked_ins_for_line(
689
+ clean_text,
690
+ anchor_run,
691
+ reuse_id,
692
+ xmlDoc,
693
+ );
694
+ if (content_ins) {
695
+ new_p.appendChild(content_ins);
696
+ }
697
+
698
+ insertAfterEl(new_p, after);
699
+ after = new_p;
700
+ last_p = new_p;
701
+ last_ins = content_ins;
702
+
703
+ // In block mode (or if the inline line was completely empty), the first new paragraph IS first_node.
704
+ if (!first_node) {
705
+ first_node = new_p;
706
+ }
707
+ }
708
+
709
+ return { first_node, last_p, last_ins, used_block_mode: block_mode };
710
+ }
711
+
712
+ /**
713
+ * Builds a single tracked-insert wrapper (<w:ins>) containing one or more
714
+ * <w:r> elements representing the inline markdown segments of `line_text`.
715
+ * Returns null if line_text is empty.
716
+ */
717
+ private _build_tracked_ins_for_line(
718
+ line_text: string,
719
+ anchor_run: Run | null,
720
+ reuse_id: string,
721
+ xmlDoc: Document,
722
+ ): Element | null {
723
+ if (!line_text && line_text !== "") return null;
724
+ const ins = this._create_track_change_tag("w:ins", "", reuse_id);
725
+ const segments = this._parse_inline_markdown(line_text);
726
+ if (segments.length === 0) {
727
+ return null;
728
+ }
729
+ for (const [segText, segProps] of segments) {
730
+ const r = xmlDoc.createElement("w:r");
731
+ // Inherit run formatting (e.g. bold from a heading style) only when we
732
+ // have an anchor run AND we are not overriding via segment props.
733
+ if (anchor_run && anchor_run._element) {
734
+ const anchor_rPr = findChild(anchor_run._element, "w:rPr");
735
+ if (anchor_rPr) {
736
+ const clone = anchor_rPr.cloneNode(true) as Element;
737
+ // Strip vanish / strike to avoid invisible inserts.
738
+ for (const tag of ["w:vanish", "w:strike", "w:dstrike"]) {
739
+ const found = findChild(clone, tag);
740
+ if (found) clone.removeChild(found);
741
+ }
742
+ r.appendChild(clone);
743
+ }
744
+ }
745
+ this._apply_run_props(r, segProps, false);
746
+ const t = xmlDoc.createElement("w:t");
747
+ this._set_text_content(t, segText);
748
+ r.appendChild(t);
749
+ ins.appendChild(r);
750
+ }
751
+ return ins;
752
+ }
241
753
  private _parse_markdown_style(text: string): [string, string | null] {
242
754
  const stripped_text = text.trimStart();
243
755
 
@@ -263,7 +775,10 @@ export class RedlineEngine {
263
775
  return [text, null];
264
776
  }
265
777
 
266
- private _parse_inline_markdown(text: string, baseStyle: any = {}): [string, any][] {
778
+ private _parse_inline_markdown(
779
+ text: string,
780
+ baseStyle: any = {},
781
+ ): [string, any][] {
267
782
  if (!text) return [];
268
783
 
269
784
  const tokenPattern = /(\*\*.*?\*\*)|(_.*?_)/;
@@ -275,8 +790,10 @@ export class RedlineEngine {
275
790
  const raw = match[0];
276
791
  const end = start + raw.length;
277
792
 
278
- const isBold = raw.startsWith('**');
279
- const innerContent = isBold ? raw.substring(2, raw.length - 2) : raw.substring(1, raw.length - 1);
793
+ const isBold = raw.startsWith("**");
794
+ const innerContent = isBold
795
+ ? raw.substring(2, raw.length - 2)
796
+ : raw.substring(1, raw.length - 1);
280
797
 
281
798
  const preText = text.substring(0, start);
282
799
  const postText = text.substring(end);
@@ -294,44 +811,244 @@ export class RedlineEngine {
294
811
  return results;
295
812
  }
296
813
 
297
- private _apply_run_props(runElement: Element, props: any, suppressInherited: boolean = false) {
814
+ private _apply_run_props(
815
+ runElement: Element,
816
+ props: any,
817
+ suppressInherited: boolean = false,
818
+ ) {
298
819
  if (!props) {
299
820
  if (!suppressInherited) return;
300
821
  props = {};
301
822
  }
302
823
 
303
- let rPr = findChild(runElement, 'w:rPr');
824
+ let rPr = findChild(runElement, "w:rPr");
304
825
  if (!rPr && (props.bold || props.italic || suppressInherited)) {
305
826
  const doc = runElement.ownerDocument!;
306
- rPr = doc.createElement('w:rPr');
827
+ rPr = doc.createElement("w:rPr");
307
828
  runElement.appendChild(rPr);
308
829
  }
309
830
 
310
831
  if (rPr) {
311
832
  const doc = runElement.ownerDocument!;
312
833
  if (props.bold) {
313
- let b = findChild(rPr, 'w:b');
314
- if (!b) { b = doc.createElement('w:b'); rPr.appendChild(b); }
315
- b.setAttribute('w:val', '1');
834
+ let b = findChild(rPr, "w:b");
835
+ if (!b) {
836
+ b = doc.createElement("w:b");
837
+ rPr.appendChild(b);
838
+ }
839
+ b.setAttribute("w:val", "1");
316
840
  } else if (suppressInherited) {
317
- const b = findChild(rPr, 'w:b');
841
+ const b = findChild(rPr, "w:b");
318
842
  if (b) rPr.removeChild(b);
319
843
  }
320
844
 
321
845
  if (props.italic) {
322
- let i = findChild(rPr, 'w:i');
323
- if (!i) { i = doc.createElement('w:i'); rPr.appendChild(i); }
324
- i.setAttribute('w:val', '1');
846
+ let i = findChild(rPr, "w:i");
847
+ if (!i) {
848
+ i = doc.createElement("w:i");
849
+ rPr.appendChild(i);
850
+ }
851
+ i.setAttribute("w:val", "1");
325
852
  } else if (suppressInherited) {
326
- const i = findChild(rPr, 'w:i');
853
+ const i = findChild(rPr, "w:i");
327
854
  if (i) rPr.removeChild(i);
328
855
  }
329
856
  }
330
857
  }
858
+ /**
859
+ * Replaces (or creates) a paragraph's <w:pPr> with a single <w:pStyle> entry
860
+ * pointing at `style_name`. Strips any existing pPr to avoid layering a new
861
+ * heading style on top of a previous list/heading configuration.
862
+ *
863
+ * In Python, the style id is resolved via doc.styles[style_name].style_id and
864
+ * falls back to stripping spaces. Node has no equivalent style cache exposed
865
+ * on `doc`, so we always use the simple "strip spaces" fallback: "Heading 1"
866
+ * becomes the style id "Heading1", "List Number" becomes "ListNumber", etc.
867
+ * This matches python-docx's default style-id convention for the built-in
868
+ * paragraph styles and is what Word writes by default.
869
+ */
870
+ private _set_paragraph_style(p_element: Element, style_name: string) {
871
+ const xmlDoc = p_element.ownerDocument!;
872
+
873
+ const existing_pPr = findChild(p_element, "w:pPr");
874
+ if (existing_pPr) {
875
+ p_element.removeChild(existing_pPr);
876
+ }
877
+
878
+ const pPr = xmlDoc.createElement("w:pPr");
879
+ const pStyle = xmlDoc.createElement("w:pStyle");
880
+ const style_id = style_name.replace(/\s+/g, "");
881
+ pStyle.setAttribute("w:val", style_id);
882
+ pPr.appendChild(pStyle);
883
+
884
+ // pPr is the first child of <w:p> per OOXML schema.
885
+ p_element.insertBefore(pPr, p_element.firstChild);
886
+ }
887
+ private _anchor_reply_comment(parent_id: string, new_id: string) {
888
+ const docEl = this.doc.part._element.ownerDocument!;
889
+
890
+ const starts = findAllDescendants(
891
+ this.doc.element,
892
+ "w:commentRangeStart",
893
+ ).filter((n) => n.getAttribute("w:id") === parent_id);
894
+ if (starts.length === 0) return;
895
+ const parent_start = starts[0];
896
+
897
+ const new_start = docEl.createElement("w:commentRangeStart");
898
+ new_start.setAttribute("w:id", new_id);
899
+ insertAfter(new_start, parent_start);
900
+
901
+ const ends = findAllDescendants(
902
+ this.doc.element,
903
+ "w:commentRangeEnd",
904
+ ).filter((n) => n.getAttribute("w:id") === parent_id);
905
+ if (ends.length === 0) return;
906
+ const parent_end = ends[0];
907
+
908
+ const parent_refs = findAllDescendants(
909
+ this.doc.element,
910
+ "w:commentReference",
911
+ ).filter((n) => n.getAttribute("w:id") === parent_id);
912
+
913
+ let insertion_point = parent_end;
914
+ if (parent_refs.length > 0) {
915
+ const ref_el = parent_refs[0];
916
+ if (
917
+ ref_el.parentNode &&
918
+ (ref_el.parentNode as Element).tagName === "w:r"
919
+ ) {
920
+ insertion_point = ref_el.parentNode as Element;
921
+ }
922
+ }
923
+
924
+ const new_end = docEl.createElement("w:commentRangeEnd");
925
+ new_end.setAttribute("w:id", new_id);
926
+ insertAfter(new_end, insertion_point);
927
+
928
+ const ref_run = docEl.createElement("w:r");
929
+ const rPr = docEl.createElement("w:rPr");
930
+ const rStyle = docEl.createElement("w:rStyle");
931
+ rStyle.setAttribute("w:val", "CommentReference");
932
+ rPr.appendChild(rStyle);
933
+ ref_run.appendChild(rPr);
934
+
935
+ const ref = docEl.createElement("w:commentReference");
936
+ ref.setAttribute("w:id", new_id);
937
+ ref_run.appendChild(ref);
938
+
939
+ insertAfter(ref_run, new_end);
940
+ }
941
+ private _clean_wrapping_comments(element: Element) {
942
+ let first_node: Element = element;
943
+ while (true) {
944
+ const prev = getPreviousElement(first_node);
945
+ if (prev && (prev.tagName === "w:ins" || prev.tagName === "w:del")) {
946
+ first_node = prev;
947
+ } else {
948
+ break;
949
+ }
950
+ }
951
+
952
+ let last_node: Element = element;
953
+ while (true) {
954
+ const nxt = getNextElement(last_node);
955
+ if (nxt && (nxt.tagName === "w:ins" || nxt.tagName === "w:del")) {
956
+ last_node = nxt;
957
+ } else {
958
+ break;
959
+ }
960
+ }
961
+
962
+ const starts_to_remove: Element[] = [];
963
+ let prev = getPreviousElement(first_node);
964
+ while (prev) {
965
+ if (prev.tagName === "w:commentRangeStart") {
966
+ starts_to_remove.push(prev);
967
+ prev = getPreviousElement(prev);
968
+ } else if (prev.tagName === "w:rPr" || prev.tagName === "w:pPr") {
969
+ prev = getPreviousElement(prev);
970
+ } else {
971
+ break;
972
+ }
973
+ }
974
+
975
+ const ends_to_remove: Element[] = [];
976
+ let nxt = getNextElement(last_node);
977
+ while (nxt) {
978
+ if (nxt.tagName === "w:commentRangeEnd") {
979
+ ends_to_remove.push(nxt);
980
+ nxt = getNextElement(nxt);
981
+ } else if (
982
+ nxt.tagName === "w:r" &&
983
+ findAllDescendants(nxt, "w:commentReference").length > 0
984
+ ) {
985
+ ends_to_remove.push(nxt);
986
+ nxt = getNextElement(nxt);
987
+ } else if (nxt.tagName === "w:commentReference") {
988
+ ends_to_remove.push(nxt);
989
+ nxt = getNextElement(nxt);
990
+ } else {
991
+ break;
992
+ }
993
+ }
994
+
995
+ const end_ids = new Set<string>();
996
+ for (const e of ends_to_remove) {
997
+ if (e.tagName === "w:commentRangeEnd") {
998
+ const eid = e.getAttribute("w:id");
999
+ if (eid) end_ids.add(eid);
1000
+ } else {
1001
+ let ref = findAllDescendants(e, "w:commentReference")[0];
1002
+ if (!ref && e.tagName === "w:commentReference") ref = e;
1003
+ if (ref) {
1004
+ const eid = ref.getAttribute("w:id");
1005
+ if (eid) end_ids.add(eid);
1006
+ }
1007
+ }
1008
+ }
1009
+
1010
+ for (const s of starts_to_remove) {
1011
+ const c_id = s.getAttribute("w:id");
1012
+ if (c_id && end_ids.has(c_id)) {
1013
+ this.comments_manager.deleteComment(c_id);
1014
+ if (s.parentNode) s.parentNode.removeChild(s);
1015
+ for (const e of ends_to_remove) {
1016
+ let e_id: string | null = null;
1017
+ if (e.tagName === "w:commentRangeEnd") {
1018
+ e_id = e.getAttribute("w:id");
1019
+ } else {
1020
+ let ref = findAllDescendants(e, "w:commentReference")[0];
1021
+ if (!ref && e.tagName === "w:commentReference") ref = e;
1022
+ if (ref) e_id = ref.getAttribute("w:id");
1023
+ }
1024
+ if (e_id === c_id && e.parentNode) {
1025
+ e.parentNode.removeChild(e);
1026
+ }
1027
+ }
1028
+ }
1029
+ }
1030
+ }
331
1031
 
1032
+ private _delete_comments_in_element(element: Element) {
1033
+ const refs = findAllDescendants(element, "w:commentReference");
1034
+ for (const ref of refs) {
1035
+ const c_id = ref.getAttribute("w:id");
1036
+ if (c_id) {
1037
+ this.comments_manager.deleteComment(c_id);
1038
+ for (const tag of ["w:commentRangeStart", "w:commentRangeEnd"]) {
1039
+ const nodes = findAllDescendants(this.doc.element, tag);
1040
+ for (const node of nodes) {
1041
+ if (node.getAttribute("w:id") === c_id && node.parentNode) {
1042
+ node.parentNode.removeChild(node);
1043
+ }
1044
+ }
1045
+ }
1046
+ }
1047
+ }
1048
+ }
332
1049
  public validate_edits(edits: any[]): string[] {
333
1050
  const errors: string[] = [];
334
- if (!this.mapper.full_text) this.mapper['_build_map']();
1051
+ if (!this.mapper.full_text) this.mapper["_build_map"]();
335
1052
 
336
1053
  errors.push(...validate_edit_strings(edits));
337
1054
 
@@ -343,59 +1060,133 @@ export class RedlineEngine {
343
1060
  let activeText = this.mapper.full_text;
344
1061
 
345
1062
  if (matches.length === 0) {
346
- if (!this.clean_mapper) this.clean_mapper = new DocumentMapper(this.doc, true);
1063
+ if (!this.clean_mapper)
1064
+ this.clean_mapper = new DocumentMapper(this.doc, true);
347
1065
  matches = this.clean_mapper.find_all_match_indices(edit.target_text);
348
1066
  if (matches.length > 0) activeText = this.clean_mapper.full_text;
349
1067
  }
350
1068
 
351
1069
  if (matches.length === 0) {
352
- errors.push(`- Edit ${i + 1} Failed: Target text not found in document:\n "${edit.target_text}"`);
1070
+ errors.push(
1071
+ `- Edit ${i + 1} Failed: Target text not found in document:\n "${edit.target_text}"`,
1072
+ );
353
1073
  } else if (matches.length > 1) {
354
- errors.push(`- Edit ${i + 1} Failed: Target text is ambiguous. Found ${matches.length} matches.\nProvide more context.`);
1074
+ const positions: [number, number][] = matches.map(([start, length]) => [
1075
+ start,
1076
+ start + length,
1077
+ ]);
1078
+ errors.push(
1079
+ format_ambiguity_error(
1080
+ i + 1,
1081
+ edit.target_text,
1082
+ activeText,
1083
+ positions,
1084
+ ),
1085
+ );
355
1086
  }
356
1087
 
357
1088
  for (const [start, length] of matches) {
358
- const spans = this.mapper.spans.filter(s => s.end > start && s.start < start + length);
1089
+ const spans = this.mapper.spans.filter(
1090
+ (s) => s.end > start && s.start < start + length,
1091
+ );
359
1092
  const nestedAuthors = new Set<string>();
360
1093
  for (const s of spans) {
361
1094
  if (s.ins_id) {
362
- const insNodes = findAllDescendants(this.doc.element, 'w:ins').filter(n => n.getAttribute('w:id') === s.ins_id);
1095
+ const insNodes = findAllDescendants(
1096
+ this.doc.element,
1097
+ "w:ins",
1098
+ ).filter((n) => n.getAttribute("w:id") === s.ins_id);
363
1099
  if (insNodes.length > 0) {
364
- const auth = insNodes[0].getAttribute('w:author');
1100
+ const auth = insNodes[0].getAttribute("w:author");
365
1101
  if (auth && auth !== this.author) nestedAuthors.add(auth);
366
1102
  }
367
1103
  }
368
1104
  }
369
1105
  if (nestedAuthors.size > 0) {
370
- errors.push(`- Edit ${i + 1} Failed: Modification targets an active insertion from another author (${Array.from(nestedAuthors).join(', ')}).`);
1106
+ errors.push(
1107
+ `- Edit ${i + 1} Failed: Modification targets an active insertion from another author (${Array.from(nestedAuthors).join(", ")}). Accept that change first or scope your edit outside of it.`,
1108
+ );
371
1109
  }
372
1110
  }
373
1111
  }
374
1112
  return errors;
375
1113
  }
1114
+ public validate_review_actions(actions: any[]): string[] {
1115
+ const errors: string[] = [];
1116
+ for (let i = 0; i < actions.length; i++) {
1117
+ const action = actions[i];
1118
+ const type = action.type;
376
1119
 
1120
+ if (type === "reply") {
1121
+ const cid = action.target_id.replace("Com:", "");
1122
+ let found = false;
1123
+ const part = this.doc.pkg.parts.find(
1124
+ (p) =>
1125
+ p.contentType ===
1126
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
1127
+ );
1128
+ if (part) {
1129
+ const comments = findAllDescendants(part._element, "w:comment");
1130
+ found = comments.some((c) => c.getAttribute("w:id") === cid);
1131
+ }
1132
+ if (!found) {
1133
+ errors.push(
1134
+ `- Action ${i + 1} Failed: Target comment ID ${action.target_id} not found.`,
1135
+ );
1136
+ }
1137
+ } else if (type === "accept" || type === "reject") {
1138
+ const target_id = action.target_id.replace("Chg:", "");
1139
+ const all_ins = findAllDescendants(this.doc.element, "w:ins").filter(
1140
+ (n) => n.getAttribute("w:id") === target_id,
1141
+ );
1142
+ const all_del = findAllDescendants(this.doc.element, "w:del").filter(
1143
+ (n) => n.getAttribute("w:id") === target_id,
1144
+ );
1145
+ if (all_ins.length === 0 && all_del.length === 0) {
1146
+ errors.push(
1147
+ `- Action ${i + 1} Failed: Target ID ${action.target_id} not found.`,
1148
+ );
1149
+ }
1150
+ }
1151
+ }
1152
+ return errors;
1153
+ }
377
1154
  public process_batch(changes: DocumentChange[]): any {
378
1155
  this.skipped_details = [];
379
- const actions = changes.filter(c => ['accept', 'reject', 'reply'].includes(c.type));
380
- const edits = changes.filter(c => !['accept', 'reject', 'reply'].includes(c.type));
1156
+ const actions = changes.filter((c) =>
1157
+ ["accept", "reject", "reply"].includes(c.type),
1158
+ );
1159
+ const edits = changes.filter(
1160
+ (c) => !["accept", "reject", "reply"].includes(c.type),
1161
+ );
1162
+
1163
+ const all_errors: string[] = [];
381
1164
 
382
- let applied_actions = 0, skipped_actions = 0;
1165
+ if (actions.length > 0) {
1166
+ all_errors.push(...this.validate_review_actions(actions));
1167
+ }
1168
+ if (edits.length > 0) {
1169
+ all_errors.push(...this.validate_edits(edits));
1170
+ }
1171
+
1172
+ if (all_errors.length > 0) {
1173
+ throw new BatchValidationError(all_errors);
1174
+ }
1175
+
1176
+ let applied_actions = 0,
1177
+ skipped_actions = 0;
383
1178
  if (actions.length > 0) {
384
1179
  const res = this.apply_review_actions(actions);
385
1180
  applied_actions = res[0];
386
1181
  skipped_actions = res[1];
387
1182
  if (applied_actions > 0) {
388
- this.mapper['_build_map']();
389
- if (this.clean_mapper) this.clean_mapper['_build_map']();
1183
+ this.mapper["_build_map"]();
1184
+ if (this.clean_mapper) this.clean_mapper["_build_map"]();
390
1185
  }
391
1186
  }
392
1187
 
393
- if (edits.length > 0) {
394
- const errors = this.validate_edits(edits);
395
- if (errors.length > 0) throw new BatchValidationError(errors);
396
- }
397
-
398
- let applied_edits = 0, skipped_edits = 0;
1188
+ let applied_edits = 0,
1189
+ skipped_edits = 0;
399
1190
  if (edits.length > 0) {
400
1191
  const res = this.apply_edits(edits as any[]);
401
1192
  applied_edits = res[0];
@@ -417,16 +1208,21 @@ export class RedlineEngine {
417
1208
  const resolved_edits: [any, string | null][] = [];
418
1209
 
419
1210
  for (const edit of edits) {
420
- if (edit._match_start_index !== undefined && edit._match_start_index !== null) {
1211
+ if (
1212
+ edit._match_start_index !== undefined &&
1213
+ edit._match_start_index !== null
1214
+ ) {
421
1215
  resolved_edits.push([edit, edit.new_text || null]);
422
- } else if (edit.type === 'insert_row' || edit.type === 'delete_row') {
1216
+ } else if (edit.type === "insert_row" || edit.type === "delete_row") {
423
1217
  const [idx] = this.mapper.find_match_index(edit.target_text);
424
1218
  if (idx !== -1) {
425
1219
  edit._match_start_index = idx;
426
1220
  resolved_edits.push([edit, null]);
427
1221
  } else {
428
1222
  skipped++;
429
- this.skipped_details.push(`- Failed to locate row target: '${(edit.target_text || '').substring(0, 40)}...'`);
1223
+ this.skipped_details.push(
1224
+ `- Failed to locate row target: '${(edit.target_text || "").substring(0, 40)}...'`,
1225
+ );
430
1226
  }
431
1227
  } else {
432
1228
  const resolved = this._pre_resolve_heuristic_edit(edit);
@@ -438,29 +1234,37 @@ export class RedlineEngine {
438
1234
  }
439
1235
  } else {
440
1236
  skipped++;
441
- this.skipped_details.push(`- Failed to apply edit targeting: '${(edit.target_text || 'insertion').substring(0, 40)}...'`);
1237
+ this.skipped_details.push(
1238
+ `- Failed to apply edit targeting: '${(edit.target_text || "insertion").substring(0, 40)}...'`,
1239
+ );
442
1240
  }
443
1241
  }
444
1242
  }
445
1243
 
446
- resolved_edits.sort((a, b) => (b[0]._match_start_index || 0) - (a[0]._match_start_index || 0));
1244
+ resolved_edits.sort(
1245
+ (a, b) => (b[0]._match_start_index || 0) - (a[0]._match_start_index || 0),
1246
+ );
447
1247
  const occupied_ranges: [number, number][] = [];
448
1248
 
449
1249
  for (const [edit, orig_new] of resolved_edits) {
450
1250
  const start = edit._match_start_index || 0;
451
1251
  const end = start + (edit.target_text ? edit.target_text.length : 0);
452
1252
 
453
- const overlaps = occupied_ranges.some(([occ_start, occ_end]) => start < occ_end && end > occ_start);
1253
+ const overlaps = occupied_ranges.some(
1254
+ ([occ_start, occ_end]) => start < occ_end && end > occ_start,
1255
+ );
454
1256
  if (overlaps) {
455
1257
  skipped++;
456
- this.skipped_details.push(`- Skipped overlapping edit targeting: '${(edit.target_text || 'insertion').substring(0, 40)}...'`);
1258
+ this.skipped_details.push(
1259
+ `- Skipped overlapping edit targeting: '${(edit.target_text || "insertion").substring(0, 40)}...'`,
1260
+ );
457
1261
  continue;
458
1262
  }
459
1263
 
460
1264
  let success = false;
461
- if (edit.type === 'modify') {
1265
+ if (edit.type === "modify") {
462
1266
  success = this._apply_single_edit_indexed(edit, orig_new, false);
463
- } else if (edit.type === 'insert_row' || edit.type === 'delete_row') {
1267
+ } else if (edit.type === "insert_row" || edit.type === "delete_row") {
464
1268
  success = this._apply_table_edit(edit, false);
465
1269
  }
466
1270
 
@@ -469,7 +1273,9 @@ export class RedlineEngine {
469
1273
  occupied_ranges.push([start, end]);
470
1274
  } else {
471
1275
  skipped++;
472
- this.skipped_details.push(`- Failed to apply edit targeting: '${(edit.target_text || 'insertion').substring(0, 40)}...'`);
1276
+ this.skipped_details.push(
1277
+ `- Failed to apply edit targeting: '${(edit.target_text || "insertion").substring(0, 40)}...'`,
1278
+ );
473
1279
  }
474
1280
  }
475
1281
 
@@ -482,37 +1288,54 @@ export class RedlineEngine {
482
1288
 
483
1289
  for (const action of actions) {
484
1290
  const type = action.type;
485
- if (type === 'reply') {
486
- const cid = action.target_id.replace('Com:', '');
487
- this.comments_manager.addComment(this.author, action.text, cid);
1291
+ if (type === "reply") {
1292
+ const cid = action.target_id.replace("Com:", "");
1293
+ const new_id = this.comments_manager.addComment(
1294
+ this.author,
1295
+ action.text,
1296
+ cid,
1297
+ );
1298
+ this._anchor_reply_comment(cid, new_id);
488
1299
  applied++;
489
1300
  continue;
490
1301
  }
491
1302
 
492
- const target_id = action.target_id.replace('Chg:', '');
493
- const all_ins = findAllDescendants(this.doc.element, 'w:ins').filter(n => n.getAttribute('w:id') === target_id);
494
- const all_del = findAllDescendants(this.doc.element, 'w:del').filter(n => n.getAttribute('w:id') === target_id);
1303
+ const target_id = action.target_id.replace("Chg:", "");
1304
+ const all_ins = findAllDescendants(this.doc.element, "w:ins").filter(
1305
+ (n) => n.getAttribute("w:id") === target_id,
1306
+ );
1307
+ const all_del = findAllDescendants(this.doc.element, "w:del").filter(
1308
+ (n) => n.getAttribute("w:id") === target_id,
1309
+ );
495
1310
  const all_nodes = [...all_ins, ...all_del];
496
1311
 
497
1312
  if (all_nodes.length === 0) {
498
1313
  skipped++;
499
- this.skipped_details.push(`- Failed to apply action: Target ID ${action.target_id} not found.`);
1314
+ this.skipped_details.push(
1315
+ `- Failed to apply action: Target ID ${action.target_id} not found.`,
1316
+ );
500
1317
  continue;
501
1318
  }
502
1319
 
503
1320
  for (const node of all_nodes) {
504
- const is_ins = node.tagName === 'w:ins';
505
- const parent_tag = node.parentNode ? (node.parentNode as Element).tagName : '';
506
- const is_trPr = parent_tag === 'w:trPr';
1321
+ const is_ins = node.tagName === "w:ins";
1322
+ const parent_tag = node.parentNode
1323
+ ? (node.parentNode as Element).tagName
1324
+ : "";
1325
+ const is_trPr = parent_tag === "w:trPr";
507
1326
 
508
- if (type === 'accept') {
1327
+ if (type === "accept") {
509
1328
  if (is_ins) {
1329
+ this._clean_wrapping_comments(node);
510
1330
  if (is_trPr) node.parentNode?.removeChild(node);
511
1331
  else {
512
- while (node.firstChild) node.parentNode?.insertBefore(node.firstChild, node);
1332
+ while (node.firstChild)
1333
+ node.parentNode?.insertBefore(node.firstChild, node);
513
1334
  node.parentNode?.removeChild(node);
514
1335
  }
515
1336
  } else {
1337
+ this._clean_wrapping_comments(node);
1338
+ this._delete_comments_in_element(node);
516
1339
  if (is_trPr) {
517
1340
  const tr = node.parentNode?.parentNode;
518
1341
  tr?.parentNode?.removeChild(tr);
@@ -520,23 +1343,30 @@ export class RedlineEngine {
520
1343
  node.parentNode?.removeChild(node);
521
1344
  }
522
1345
  }
523
- } else if (type === 'reject') {
1346
+ } else if (type === "reject") {
524
1347
  if (is_ins) {
1348
+ this._clean_wrapping_comments(node);
1349
+ this._delete_comments_in_element(node);
525
1350
  if (is_trPr) {
526
1351
  const tr = node.parentNode?.parentNode;
527
1352
  tr?.parentNode?.removeChild(tr);
528
1353
  } else node.parentNode?.removeChild(node);
529
1354
  } else {
1355
+ this._clean_wrapping_comments(node);
530
1356
  if (is_trPr) node.parentNode?.removeChild(node);
531
1357
  else {
532
- const delTexts = Array.from(node.getElementsByTagName('w:delText'));
1358
+ const delTexts = Array.from(
1359
+ node.getElementsByTagName("w:delText"),
1360
+ );
533
1361
  for (const dt of delTexts) {
534
- const t = dt.ownerDocument!.createElement('w:t');
1362
+ const t = dt.ownerDocument!.createElement("w:t");
535
1363
  t.textContent = dt.textContent;
536
- if (dt.hasAttribute('xml:space')) t.setAttribute('xml:space', 'preserve');
1364
+ if (dt.hasAttribute("xml:space"))
1365
+ t.setAttribute("xml:space", "preserve");
537
1366
  dt.parentNode?.replaceChild(t, dt);
538
1367
  }
539
- while (node.firstChild) node.parentNode?.insertBefore(node.firstChild, node);
1368
+ while (node.firstChild)
1369
+ node.parentNode?.insertBefore(node.firstChild, node);
540
1370
  node.parentNode?.removeChild(node);
541
1371
  }
542
1372
  }
@@ -549,8 +1379,11 @@ export class RedlineEngine {
549
1379
 
550
1380
  private _apply_table_edit(edit: any, rebuild_map: boolean): boolean {
551
1381
  const start_idx = edit._match_start_index || 0;
552
- const [anchor_run, anchor_para] = this.mapper.get_insertion_anchor(start_idx, rebuild_map);
553
-
1382
+ const [anchor_run, anchor_para] = this.mapper.get_insertion_anchor(
1383
+ start_idx,
1384
+ rebuild_map,
1385
+ );
1386
+
554
1387
  let target_element: Element | null = null;
555
1388
  if (anchor_run) target_element = anchor_run._element;
556
1389
  else if (anchor_para) target_element = anchor_para._element;
@@ -558,32 +1391,36 @@ export class RedlineEngine {
558
1391
  if (!target_element) return false;
559
1392
 
560
1393
  let tr: Element | null = target_element;
561
- while (tr && tr.tagName !== 'w:tr') tr = tr.parentNode as Element;
1394
+ while (tr && tr.tagName !== "w:tr") tr = tr.parentNode as Element;
562
1395
  if (!tr) return false;
563
1396
 
564
- if (edit.type === 'delete_row') {
565
- let trPr = findChild(tr, 'w:trPr');
1397
+ if (edit.type === "delete_row") {
1398
+ let trPr = findChild(tr, "w:trPr");
566
1399
  if (!trPr) {
567
- trPr = tr.ownerDocument!.createElement('w:trPr');
1400
+ trPr = tr.ownerDocument!.createElement("w:trPr");
568
1401
  tr.insertBefore(trPr, tr.firstChild);
569
1402
  }
570
- trPr.appendChild(this._create_track_change_tag('w:del'));
1403
+ trPr.appendChild(this._create_track_change_tag("w:del"));
571
1404
  return true;
572
- } else if (edit.type === 'insert_row') {
573
- const new_tr = tr.ownerDocument!.createElement('w:tr');
574
- const trPr = tr.ownerDocument!.createElement('w:trPr');
1405
+ } else if (edit.type === "insert_row") {
1406
+ const new_tr = tr.ownerDocument!.createElement("w:tr");
1407
+ const trPr = tr.ownerDocument!.createElement("w:trPr");
575
1408
  new_tr.appendChild(trPr);
576
- trPr.appendChild(this._create_track_change_tag('w:ins'));
1409
+ trPr.appendChild(this._create_track_change_tag("w:ins"));
577
1410
  for (const cellText of edit.cells) {
578
- const tc = tr.ownerDocument!.createElement('w:tc');
579
- const p = tr.ownerDocument!.createElement('w:p');
580
- const r = tr.ownerDocument!.createElement('w:r');
581
- const t = tr.ownerDocument!.createElement('w:t');
1411
+ const tc = tr.ownerDocument!.createElement("w:tc");
1412
+ const p = tr.ownerDocument!.createElement("w:p");
1413
+ const r = tr.ownerDocument!.createElement("w:r");
1414
+ const t = tr.ownerDocument!.createElement("w:t");
582
1415
  t.textContent = cellText;
583
- if (cellText.trim() !== cellText) t.setAttribute('xml:space', 'preserve');
584
- r.appendChild(t); p.appendChild(r); tc.appendChild(p); new_tr.appendChild(tc);
1416
+ if (cellText.trim() !== cellText)
1417
+ t.setAttribute("xml:space", "preserve");
1418
+ r.appendChild(t);
1419
+ p.appendChild(r);
1420
+ tc.appendChild(p);
1421
+ new_tr.appendChild(tc);
585
1422
  }
586
- if (edit.position === 'above') tr.parentNode?.insertBefore(new_tr, tr);
1423
+ if (edit.position === "above") tr.parentNode?.insertBefore(new_tr, tr);
587
1424
  else insertAfter(new_tr, tr);
588
1425
  return true;
589
1426
  }
@@ -597,17 +1434,26 @@ export class RedlineEngine {
597
1434
  let use_clean_map = false;
598
1435
 
599
1436
  if (start_idx === -1) {
600
- if (!this.clean_mapper) this.clean_mapper = new DocumentMapper(this.doc, true);
601
- [start_idx, match_len] = this.clean_mapper.find_match_index(edit.target_text);
1437
+ if (!this.clean_mapper)
1438
+ this.clean_mapper = new DocumentMapper(this.doc, true);
1439
+ [start_idx, match_len] = this.clean_mapper.find_match_index(
1440
+ edit.target_text,
1441
+ );
602
1442
  if (start_idx !== -1) use_clean_map = true;
603
1443
  else return null;
604
1444
  }
605
1445
 
606
1446
  const active_mapper = use_clean_map ? this.clean_mapper! : this.mapper;
607
1447
  const effective_new_text = edit.new_text || "";
608
- const actual_doc_text = this.mapper.full_text.substring(start_idx, start_idx + match_len);
609
-
610
- if (actual_doc_text === effective_new_text || edit.target_text === effective_new_text) {
1448
+ const actual_doc_text = this.mapper.full_text.substring(
1449
+ start_idx,
1450
+ start_idx + match_len,
1451
+ );
1452
+
1453
+ if (
1454
+ actual_doc_text === effective_new_text ||
1455
+ edit.target_text === effective_new_text
1456
+ ) {
611
1457
  return {
612
1458
  type: "modify",
613
1459
  target_text: actual_doc_text,
@@ -615,7 +1461,7 @@ export class RedlineEngine {
615
1461
  comment: edit.comment,
616
1462
  _match_start_index: start_idx,
617
1463
  _internal_op: "COMMENT_ONLY",
618
- _active_mapper_ref: active_mapper
1464
+ _active_mapper_ref: active_mapper,
619
1465
  };
620
1466
  }
621
1467
 
@@ -629,13 +1475,16 @@ export class RedlineEngine {
629
1475
  final_new = effective_new_text.substring(actual_doc_text.length);
630
1476
  effective_start_idx = start_idx + match_len;
631
1477
  } else {
632
- const [prefix_len, suffix_len] = trim_common_context(actual_doc_text, effective_new_text);
1478
+ const [prefix_len, suffix_len] = trim_common_context(
1479
+ actual_doc_text,
1480
+ effective_new_text,
1481
+ );
633
1482
  const t_end = actual_doc_text.length - suffix_len;
634
1483
  const n_end = effective_new_text.length - suffix_len;
635
1484
 
636
1485
  final_target = actual_doc_text.substring(prefix_len, t_end);
637
1486
  final_new = effective_new_text.substring(prefix_len, n_end);
638
- effective_start_idx = start_idx + prefix_len
1487
+ effective_start_idx = start_idx + prefix_len;
639
1488
 
640
1489
  if (!final_target && final_new) effective_op = "INSERTION";
641
1490
  else if (final_target && !final_new) effective_op = "DELETION";
@@ -650,86 +1499,391 @@ export class RedlineEngine {
650
1499
  comment: edit.comment,
651
1500
  _match_start_index: effective_start_idx,
652
1501
  _internal_op: effective_op,
653
- _active_mapper_ref: active_mapper
1502
+ _active_mapper_ref: active_mapper,
654
1503
  };
655
1504
  }
656
1505
 
657
- private _apply_single_edit_indexed(edit: any, orig_new: string | null, rebuild_map: boolean): boolean {
1506
+ private _apply_single_edit_indexed(
1507
+ edit: any,
1508
+ orig_new: string | null,
1509
+ rebuild_map: boolean,
1510
+ ): boolean {
658
1511
  let op = edit._internal_op;
659
1512
  const active_mapper = edit._active_mapper_ref || this.mapper;
660
1513
  const start_idx = edit._match_start_index || 0;
661
1514
  const length = edit.target_text ? edit.target_text.length : 0;
662
1515
 
663
- const del_id = ['DELETION', 'MODIFICATION'].includes(op) ? this._getNextId() : null;
664
- const ins_id = ['INSERTION', 'MODIFICATION'].includes(op) ? this._getNextId() : null;
1516
+ const del_id = ["DELETION", "MODIFICATION"].includes(op)
1517
+ ? this._getNextId()
1518
+ : null;
1519
+ const ins_id = ["INSERTION", "MODIFICATION"].includes(op)
1520
+ ? this._getNextId()
1521
+ : null;
665
1522
 
666
1523
  if (op === "COMMENT_ONLY") {
667
- // Mocked for Port limits, normally anchors to found runs
1524
+ // Resolve the runs covering [start_idx, start_idx+length) and attach a
1525
+ // comment around them. No tracked-change is produced.
1526
+ const target_runs = active_mapper.find_target_runs_by_index(
1527
+ start_idx,
1528
+ length,
1529
+ rebuild_map,
1530
+ );
1531
+ if (target_runs.length === 0) return false;
1532
+ if (!edit.comment) return true;
1533
+
1534
+ const first_el = target_runs[0]._element;
1535
+ const last_el = target_runs[target_runs.length - 1]._element;
1536
+
1537
+ // Walk up from the first/last run to their containing <w:p>.
1538
+ let start_p: Element | null = first_el;
1539
+ while (start_p && start_p.tagName !== "w:p")
1540
+ start_p = start_p.parentNode as Element;
1541
+ let end_p: Element | null = last_el;
1542
+ while (end_p && end_p.tagName !== "w:p")
1543
+ end_p = end_p.parentNode as Element;
1544
+ if (!start_p || !end_p) return false;
1545
+
1546
+ // first_el / last_el may live inside a <w:ins> or <w:del>. We need their
1547
+ // top-level child-of-paragraph ancestor so the comment markers become
1548
+ // siblings of those wrappers, not children.
1549
+ const ascend_to_paragraph_child = (el: Element, p: Element): Element => {
1550
+ let cur: Element = el;
1551
+ while (cur.parentNode && cur.parentNode !== p) {
1552
+ cur = cur.parentNode as Element;
1553
+ }
1554
+ return cur;
1555
+ };
1556
+ const first_anchor = ascend_to_paragraph_child(first_el, start_p);
1557
+ const last_anchor = ascend_to_paragraph_child(last_el, end_p);
1558
+
1559
+ if (start_p === end_p) {
1560
+ this._attach_comment(start_p, first_anchor, last_anchor, edit.comment);
1561
+ } else {
1562
+ this._attach_comment_spanning(
1563
+ start_p,
1564
+ first_anchor,
1565
+ end_p,
1566
+ last_anchor,
1567
+ edit.comment,
1568
+ );
1569
+ }
668
1570
  return true;
669
1571
  }
670
-
671
1572
  if (op === "INSERTION") {
672
- const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(start_idx, rebuild_map);
1573
+ const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
1574
+ start_idx,
1575
+ rebuild_map,
1576
+ );
673
1577
  if (!anchor_run && !anchor_para) return false;
674
1578
 
675
- const xmlDoc = this.doc.part._element.ownerDocument!;
676
- const ins = this._create_track_change_tag('w:ins', '', ins_id);
677
-
678
- const segments = this._parse_inline_markdown(edit.new_text || "");
679
- for (const [segText, segProps] of segments) {
680
- const r = xmlDoc.createElement('w:r');
681
- this._apply_run_props(r, segProps, false);
682
- const t = xmlDoc.createElement('w:t');
683
- this._set_text_content(t, segText);
684
- r.appendChild(t);
685
- ins.appendChild(r);
1579
+ const result = this._track_insert_multiline(
1580
+ edit.new_text || "",
1581
+ anchor_run,
1582
+ anchor_para,
1583
+ ins_id!,
1584
+ );
1585
+
1586
+ if (!result.first_node) return false;
1587
+
1588
+ // Place the inline <w:ins> (or block-mode first paragraph) into the DOM.
1589
+ // Block-mode first_node is already a freshly-inserted <w:p>; only the
1590
+ // inline case needs DOM splicing here.
1591
+ const is_inline_first = result.first_node.tagName === "w:ins";
1592
+ if (is_inline_first) {
1593
+ if (anchor_run) {
1594
+ insertAfter(result.first_node, anchor_run._element);
1595
+ } else if (anchor_para) {
1596
+ anchor_para._element.appendChild(result.first_node);
1597
+ }
686
1598
  }
687
1599
 
688
- if (anchor_run) {
689
- insertAfter(ins, anchor_run._element);
690
- } else if (anchor_para) {
691
- anchor_para._element.appendChild(ins);
692
- }
1600
+ // Attach the comment if requested. Anchor depends on whether we created
1601
+ // additional paragraphs.
1602
+ if (edit.comment) {
1603
+ const ascend_to_paragraph_child = (
1604
+ el: Element,
1605
+ p: Element,
1606
+ ): Element => {
1607
+ let cur: Element = el;
1608
+ while (cur.parentNode && cur.parentNode !== p) {
1609
+ cur = cur.parentNode as Element;
1610
+ }
1611
+ return cur;
1612
+ };
1613
+
1614
+ if (result.last_p && result.last_ins) {
1615
+ // Multi-paragraph: anchor from first_node (in its host paragraph)
1616
+ // through last_ins (inside last_p).
1617
+ let start_p: Element | null = result.first_node;
1618
+ while (start_p && start_p.tagName !== "w:p")
1619
+ start_p = start_p.parentNode as Element;
1620
+ if (start_p) {
1621
+ let first_anchor_target = result.first_node;
1622
+ if (result.first_node.tagName === "w:p") {
1623
+ first_anchor_target = findAllDescendants(result.first_node, "w:ins")[0] || result.first_node;
1624
+ }
1625
+ const start_anchor = ascend_to_paragraph_child(
1626
+ first_anchor_target,
1627
+ start_p,
1628
+ );
1629
+ const end_anchor = ascend_to_paragraph_child(
1630
+ result.last_ins,
1631
+ result.last_p,
1632
+ );
1633
+ this._attach_comment_spanning(
1634
+ start_p,
1635
+ start_anchor,
1636
+ result.last_p,
1637
+ end_anchor,
1638
+ edit.comment,
1639
+ );
1640
+ }
1641
+ } else {
1642
+ // Inline only: anchor around first_node in its host paragraph.
1643
+ let host_p: Element | null = result.first_node;
1644
+ while (host_p && host_p.tagName !== "w:p")
1645
+ host_p = host_p.parentNode as Element;
1646
+ if (host_p) {
1647
+ let first_anchor_target = result.first_node;
1648
+ if (result.first_node.tagName === "w:p") {
1649
+ first_anchor_target = findAllDescendants(result.first_node, "w:ins")[0] || result.first_node;
1650
+ }
1651
+ const anchor = ascend_to_paragraph_child(first_anchor_target, host_p);
1652
+ this._attach_comment(host_p, anchor, anchor, edit.comment);
1653
+ }
1654
+ }
1655
+ }
693
1656
  return true;
694
1657
  }
695
1658
 
696
1659
  // DELETION / MODIFICATION
697
- const target_runs = active_mapper.find_target_runs_by_index(start_idx, length, rebuild_map);
698
- if (target_runs.length === 0) return false;
1660
+ const target_runs = active_mapper.find_target_runs_by_index(
1661
+ start_idx,
1662
+ length,
1663
+ rebuild_map,
1664
+ );
1665
+ const virtual_spans = active_mapper.get_virtual_spans_in_range(start_idx, length);
1666
+
1667
+ if (target_runs.length === 0 && virtual_spans.length === 0) return false;
699
1668
 
1669
+ const affected_ps = new Set<Element>();
1670
+ for (const run of target_runs) {
1671
+ let p: Element | null = run._element.parentNode as Element;
1672
+ while (p && p.tagName !== "w:p") p = p.parentNode as Element;
1673
+ if (p) affected_ps.add(p);
1674
+ }
1675
+
1676
+ let first_del: Element | null = null;
700
1677
  let last_del: Element | null = null;
701
1678
  for (const run of target_runs) {
702
- const del_tag = this._create_track_change_tag('w:del', '', del_id);
1679
+ const del_tag = this._create_track_change_tag("w:del", "", del_id);
703
1680
  const new_run = run._element.cloneNode(true) as Element;
704
-
705
- const tNodes = Array.from(new_run.getElementsByTagName('w:t'));
706
- tNodes.forEach(t => {
707
- const delText = new_run.ownerDocument!.createElement('w:delText');
1681
+
1682
+ const tNodes = Array.from(new_run.getElementsByTagName("w:t"));
1683
+ tNodes.forEach((t) => {
1684
+ const delText = new_run.ownerDocument!.createElement("w:delText");
708
1685
  delText.textContent = t.textContent;
709
- if (t.hasAttribute('xml:space')) delText.setAttribute('xml:space', 'preserve');
1686
+ if (t.hasAttribute("xml:space"))
1687
+ delText.setAttribute("xml:space", "preserve");
710
1688
  new_run.replaceChild(delText, t);
711
1689
  });
712
1690
 
713
1691
  del_tag.appendChild(new_run);
714
1692
  run._element.parentNode?.replaceChild(del_tag, run._element);
1693
+ if (first_del === null) first_del = del_tag;
715
1694
  last_del = del_tag;
716
1695
  }
717
1696
 
1697
+ let ins_elem: Element | null = null;
1698
+ let mod_last_p: Element | null = null;
1699
+ let mod_last_ins: Element | null = null;
1700
+
718
1701
  if (op === "MODIFICATION" && edit.new_text && last_del) {
719
- const xmlDoc = this.doc.part._element.ownerDocument!;
720
- const ins = this._create_track_change_tag('w:ins', '', ins_id);
721
- const segments = this._parse_inline_markdown(edit.new_text);
722
- for (const [segText, segProps] of segments) {
723
- const r = xmlDoc.createElement('w:r');
724
- this._apply_run_props(r, segProps, false);
725
- const t = xmlDoc.createElement('w:t');
726
- this._set_text_content(t, segText);
727
- r.appendChild(t);
728
- ins.appendChild(r);
1702
+ // Resolve a paragraph anchor: the <w:p> hosting last_del.
1703
+ let mod_anchor_para_el: Element | null = last_del;
1704
+ while (mod_anchor_para_el && mod_anchor_para_el.tagName !== "w:p") {
1705
+ mod_anchor_para_el = mod_anchor_para_el.parentNode as Element | null;
1706
+ }
1707
+ const mod_anchor_para: Paragraph | null = mod_anchor_para_el
1708
+ ? new Paragraph(mod_anchor_para_el, null)
1709
+ : null;
1710
+
1711
+ // The "anchor run" for style inheritance is the run we just deleted; reuse
1712
+ // the deleted run's rPr by sourcing the original target run if available.
1713
+ const style_source_run: Run | null =
1714
+ target_runs.length > 0 ? target_runs[target_runs.length - 1] : null;
1715
+
1716
+ const result = this._track_insert_multiline(
1717
+ edit.new_text,
1718
+ style_source_run,
1719
+ mod_anchor_para,
1720
+ ins_id!,
1721
+ );
1722
+
1723
+ if (result.first_node) {
1724
+ const is_inline_first = result.first_node.tagName === "w:ins";
1725
+ if (is_inline_first) {
1726
+ // Inline: place the first <w:ins> immediately after last_del.
1727
+ insertAfter(result.first_node, last_del);
1728
+ ins_elem = result.first_node;
1729
+ } else {
1730
+ // Block-mode first paragraph was already inserted after the anchor
1731
+ // paragraph by the helper. We still need ins_elem for comment fallback.
1732
+ ins_elem = result.last_ins;
1733
+ }
1734
+ mod_last_p = result.last_p;
1735
+ mod_last_ins = result.last_ins;
1736
+ }
1737
+ }
1738
+
1739
+ // PHASE 2: OOXML Paragraph Merge Protocol
1740
+ if (op === "DELETION" || op === "MODIFICATION") {
1741
+ if (op === "MODIFICATION" && target_runs.length === 0 && virtual_spans.length > 0 && edit.new_text) {
1742
+ const first_span = virtual_spans[0];
1743
+ if (first_span.paragraph) {
1744
+ const p1_el = first_span.paragraph._element;
1745
+ const last_runs = findAllDescendants(p1_el, "w:r");
1746
+ const anchor = last_runs.length > 0 ? new Run(last_runs[last_runs.length - 1], first_span.paragraph) : null;
1747
+
1748
+ const result = this._track_insert_multiline(
1749
+ edit.new_text,
1750
+ anchor,
1751
+ first_span.paragraph,
1752
+ ins_id!
1753
+ );
1754
+ if (result.first_node) {
1755
+ p1_el.appendChild(result.first_node);
1756
+ }
1757
+ }
1758
+ }
1759
+
1760
+ for (const span of [...virtual_spans].reverse()) {
1761
+ if (span.paragraph) {
1762
+ const p1_element = span.paragraph._element;
1763
+ let p2_element = getNextElement(p1_element);
1764
+ while (p2_element && p2_element.tagName !== "w:p") {
1765
+ p2_element = getNextElement(p2_element);
1766
+ }
1767
+
1768
+ if (p2_element && p2_element.tagName === "w:p") {
1769
+ let pPr = findChild(p1_element, "w:pPr");
1770
+ if (!pPr) {
1771
+ pPr = p1_element.ownerDocument!.createElement("w:pPr") as Element;
1772
+ p1_element.insertBefore(pPr, p1_element.firstChild as Node | null);
1773
+ }
1774
+ let rPr = findChild(pPr!, "w:rPr");
1775
+ if (!rPr) {
1776
+ rPr = p1_element.ownerDocument!.createElement("w:rPr") as Element;
1777
+ pPr!.appendChild(rPr);
1778
+ }
1779
+ const del_mark = this._create_track_change_tag("w:del");
1780
+ rPr!.appendChild(del_mark);
1781
+
1782
+ const children = Array.from(p2_element.childNodes);
1783
+ for (const child of children) {
1784
+ if (child.nodeType === 1 && (child as Element).tagName === "w:pPr") {
1785
+ continue;
1786
+ }
1787
+ p1_element.appendChild(child);
1788
+ }
1789
+
1790
+ if (p2_element.parentNode) {
1791
+ p2_element.parentNode.removeChild(p2_element);
1792
+ }
1793
+ }
1794
+ }
1795
+ }
1796
+ }
1797
+
1798
+ // Attach comment around the modification or deletion if requested.
1799
+ if (edit.comment && first_del !== null) {
1800
+ // Resolve the comment END anchor. For multi-paragraph modifications,
1801
+ // the end anchor lives in the LAST inserted paragraph (mod_last_p);
1802
+ // otherwise it's the inline ins/del in the source paragraph.
1803
+ let end_anchor_el: Element;
1804
+ let end_p: Element | null;
1805
+
1806
+ if (mod_last_p && mod_last_ins) {
1807
+ end_anchor_el = mod_last_ins;
1808
+ end_p = mod_last_p;
1809
+ } else {
1810
+ const final_anchor: Element = ins_elem !== null ? ins_elem : last_del!;
1811
+ end_anchor_el = final_anchor;
1812
+ end_p = final_anchor;
1813
+ while (end_p && end_p.tagName !== "w:p")
1814
+ end_p = end_p.parentNode as Element | null;
1815
+ }
1816
+
1817
+ let start_p: Element | null = first_del;
1818
+ while (start_p && start_p.tagName !== "w:p")
1819
+ start_p = start_p.parentNode as Element | null;
1820
+ if (!start_p || !end_p) return true;
1821
+
1822
+ const ascend_to_paragraph_child = (el: Element, p: Element): Element => {
1823
+ let cur: Element = el;
1824
+ while (cur.parentNode && cur.parentNode !== p) {
1825
+ cur = cur.parentNode as Element;
1826
+ }
1827
+ return cur;
1828
+ };
1829
+ const start_anchor = ascend_to_paragraph_child(first_del, start_p);
1830
+ const end_anchor = ascend_to_paragraph_child(end_anchor_el, end_p);
1831
+
1832
+ if (start_p === end_p) {
1833
+ this._attach_comment(start_p, start_anchor, end_anchor, edit.comment);
1834
+ } else {
1835
+ this._attach_comment_spanning(
1836
+ start_p,
1837
+ start_anchor,
1838
+ end_p,
1839
+ end_anchor,
1840
+ edit.comment,
1841
+ );
1842
+ }
1843
+ }
1844
+
1845
+ // PHASE 2: Check for orphaned paragraphs with zero visible content remaining
1846
+ for (const p_elem of affected_ps) {
1847
+ let has_visible = false;
1848
+ for (const tag of ["w:t", "w:tab", "w:br"]) {
1849
+ const nodes = findAllDescendants(p_elem, tag);
1850
+ for (const node of nodes) {
1851
+ let is_deleted = false;
1852
+ let curr = node.parentNode as Element | null;
1853
+ while (curr && curr !== p_elem.parentNode) {
1854
+ if (curr.tagName === "w:del") {
1855
+ is_deleted = true;
1856
+ break;
1857
+ }
1858
+ curr = curr.parentNode as Element | null;
1859
+ }
1860
+ if (!is_deleted) {
1861
+ if (tag === "w:t" && !node.textContent) continue;
1862
+ has_visible = true;
1863
+ break;
1864
+ }
1865
+ }
1866
+ if (has_visible) break;
1867
+ }
1868
+
1869
+ if (!has_visible) {
1870
+ let pPr = findChild(p_elem, "w:pPr");
1871
+ if (!pPr) {
1872
+ pPr = p_elem.ownerDocument!.createElement("w:pPr") as Element;
1873
+ p_elem.insertBefore(pPr, p_elem.firstChild as Node | null);
1874
+ }
1875
+ let rPr = findChild(pPr!, "w:rPr");
1876
+ if (!rPr) {
1877
+ rPr = p_elem.ownerDocument!.createElement("w:rPr") as Element;
1878
+ pPr!.appendChild(rPr);
1879
+ }
1880
+ if (!findChild(rPr!, "w:del")) {
1881
+ const del_mark = this._create_track_change_tag("w:del");
1882
+ rPr!.appendChild(del_mark);
1883
+ }
729
1884
  }
730
- insertAfter(ins, last_del);
731
1885
  }
732
1886
 
733
1887
  return true;
734
1888
  }
735
- }
1889
+ }