@adeu/core 1.6.8 → 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/dist/index.cjs +1833 -540
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +75 -1
- package/dist/index.d.ts +75 -1
- package/dist/index.js +1832 -540
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/consistency.test.ts +134 -0
- package/src/diff.test.ts +13 -1
- package/src/diff.ts +189 -70
- package/src/docx/bridge.ts +99 -57
- package/src/docx/dom.ts +66 -7
- package/src/engine.bugs.test.ts +481 -0
- package/src/engine.ts +1346 -192
- package/src/index.ts +1 -1
- package/src/markup.ts +160 -53
- package/src/outline.ts +199 -69
- package/src/sanitize/core.ts +26 -0
- package/src/sanitize/report.ts +1 -1
- package/src/sanitize/sanitize.test.ts +47 -2
- package/src/sanitize/transforms.ts +87 -0
- package/src/utils/docx.ts +282 -157
package/src/engine.ts
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
|
-
import { DocumentObject } from
|
|
2
|
-
import { Paragraph, Table, Run, DocxEvent } from
|
|
3
|
-
import { DocumentMapper, TextSpan } from
|
|
4
|
-
import { CommentsManager } from
|
|
5
|
-
import {
|
|
6
|
-
ModifyText,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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(
|
|
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 (
|
|
73
|
-
|
|
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 (
|
|
81
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 (
|
|
98
|
-
|
|
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 =
|
|
104
|
-
const n_xrefs =
|
|
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(
|
|
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(
|
|
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(
|
|
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 (
|
|
124
|
-
|
|
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 ===
|
|
131
|
-
const lines = n_text.split(
|
|
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(/^#+/,
|
|
136
|
-
if (
|
|
137
|
-
|
|
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 (
|
|
145
|
-
|
|
146
|
-
|
|
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$/,
|
|
168
|
-
|
|
169
|
-
const w16du_ns =
|
|
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 (
|
|
172
|
-
|
|
173
|
-
|
|
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 [
|
|
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(
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
if (
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
for (const
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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,
|
|
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(
|
|
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,
|
|
314
|
-
if (!b) {
|
|
315
|
-
|
|
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,
|
|
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,
|
|
323
|
-
if (!i) {
|
|
324
|
-
|
|
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,
|
|
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[
|
|
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)
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =>
|
|
380
|
-
|
|
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
|
-
|
|
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[
|
|
389
|
-
if (this.clean_mapper) this.clean_mapper[
|
|
1183
|
+
this.mapper["_build_map"]();
|
|
1184
|
+
if (this.clean_mapper) this.clean_mapper["_build_map"]();
|
|
390
1185
|
}
|
|
391
1186
|
}
|
|
392
1187
|
|
|
393
|
-
|
|
394
|
-
|
|
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 (
|
|
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 ===
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 ===
|
|
1265
|
+
if (edit.type === "modify") {
|
|
462
1266
|
success = this._apply_single_edit_indexed(edit, orig_new, false);
|
|
463
|
-
} else if (edit.type ===
|
|
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(
|
|
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 ===
|
|
486
|
-
const cid = action.target_id.replace(
|
|
487
|
-
this.comments_manager.addComment(
|
|
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(
|
|
493
|
-
const all_ins = findAllDescendants(this.doc.element,
|
|
494
|
-
|
|
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(
|
|
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 ===
|
|
505
|
-
const parent_tag = node.parentNode
|
|
506
|
-
|
|
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 ===
|
|
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)
|
|
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 ===
|
|
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(
|
|
1358
|
+
const delTexts = Array.from(
|
|
1359
|
+
node.getElementsByTagName("w:delText"),
|
|
1360
|
+
);
|
|
533
1361
|
for (const dt of delTexts) {
|
|
534
|
-
const t = dt.ownerDocument!.createElement(
|
|
1362
|
+
const t = dt.ownerDocument!.createElement("w:t");
|
|
535
1363
|
t.textContent = dt.textContent;
|
|
536
|
-
if (dt.hasAttribute(
|
|
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)
|
|
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(
|
|
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 !==
|
|
1394
|
+
while (tr && tr.tagName !== "w:tr") tr = tr.parentNode as Element;
|
|
562
1395
|
if (!tr) return false;
|
|
563
1396
|
|
|
564
|
-
if (edit.type ===
|
|
565
|
-
let trPr = findChild(tr,
|
|
1397
|
+
if (edit.type === "delete_row") {
|
|
1398
|
+
let trPr = findChild(tr, "w:trPr");
|
|
566
1399
|
if (!trPr) {
|
|
567
|
-
trPr = tr.ownerDocument!.createElement(
|
|
1400
|
+
trPr = tr.ownerDocument!.createElement("w:trPr");
|
|
568
1401
|
tr.insertBefore(trPr, tr.firstChild);
|
|
569
1402
|
}
|
|
570
|
-
trPr.appendChild(this._create_track_change_tag(
|
|
1403
|
+
trPr.appendChild(this._create_track_change_tag("w:del"));
|
|
571
1404
|
return true;
|
|
572
|
-
} else if (edit.type ===
|
|
573
|
-
const new_tr = tr.ownerDocument!.createElement(
|
|
574
|
-
const trPr = tr.ownerDocument!.createElement(
|
|
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(
|
|
1409
|
+
trPr.appendChild(this._create_track_change_tag("w:ins"));
|
|
577
1410
|
for (const cellText of edit.cells) {
|
|
578
|
-
const tc = tr.ownerDocument!.createElement(
|
|
579
|
-
const p = tr.ownerDocument!.createElement(
|
|
580
|
-
const r = tr.ownerDocument!.createElement(
|
|
581
|
-
const t = tr.ownerDocument!.createElement(
|
|
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)
|
|
584
|
-
|
|
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 ===
|
|
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)
|
|
601
|
-
|
|
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(
|
|
609
|
-
|
|
610
|
-
|
|
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(
|
|
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(
|
|
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 = [
|
|
664
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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(
|
|
698
|
-
|
|
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(
|
|
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(
|
|
706
|
-
tNodes.forEach(t => {
|
|
707
|
-
const delText = new_run.ownerDocument!.createElement(
|
|
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(
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
+
}
|