@ansonlai/docx-redline-js 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/AGENTS.md +589 -287
  2. package/ARCHITECTURE.md +215 -9
  3. package/CHANGELOG.md +319 -0
  4. package/README.md +604 -360
  5. package/adapters/config.js +45 -43
  6. package/bin/docx-redline.js +3 -0
  7. package/core/list-targeting.js +101 -110
  8. package/core/paragraph-targeting.js +501 -61
  9. package/core/paragraph-text.js +209 -0
  10. package/core/revision-cloning.js +38 -0
  11. package/core/types.js +64 -10
  12. package/core/word-xml.js +43 -15
  13. package/dist/docx-redline-js.esm.js +2849 -466
  14. package/dist/docx-redline-js.esm.js.map +4 -4
  15. package/dist/docx-redline-js.esm.min.js +87 -76
  16. package/dist/docx-redline-js.esm.min.js.map +4 -4
  17. package/docs/TESTING.md +342 -23
  18. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  19. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  20. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  21. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  22. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  23. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  24. package/docs/schemas/document-operations.schema.json +109 -0
  25. package/docs/test-comparison-dashboard.html +4250 -7
  26. package/engine/formatting-removal.js +11 -2
  27. package/engine/oxml-engine.js +491 -336
  28. package/engine/reconstruction-mode.js +15 -14
  29. package/engine/reconstruction-writer.js +247 -142
  30. package/engine/route-selection.js +35 -0
  31. package/engine/rpr-helpers.js +334 -35
  32. package/engine/run-builders.js +239 -196
  33. package/engine/surgical-diff-application.js +222 -37
  34. package/engine/surgical-mode.js +134 -6
  35. package/engine/surgical-spans.js +52 -1
  36. package/engine/table-cell-context.js +3 -6
  37. package/engine/table-mode.js +1 -1
  38. package/index.d.ts +234 -6
  39. package/index.js +24 -1
  40. package/node/cli.js +317 -0
  41. package/node/docx-document.js +302 -0
  42. package/node/index.d.ts +31 -0
  43. package/node/index.js +2 -0
  44. package/node/zip-archive.js +52 -0
  45. package/orchestration/list-markdown.js +10 -16
  46. package/orchestration/list-parsing.js +7 -12
  47. package/orchestration/list-structural-fallback.js +21 -10
  48. package/package.json +24 -3
  49. package/pipeline/content-analysis.js +12 -17
  50. package/pipeline/ingestion-export.js +3 -31
  51. package/pipeline/ingestion-paragraph.js +10 -5
  52. package/pipeline/list-generation.js +150 -55
  53. package/pipeline/list-markers.js +70 -3
  54. package/pipeline/serialization.js +4 -2
  55. package/pipeline/structured-content.js +160 -0
  56. package/scripts/apply_changes.mjs +27 -0
  57. package/scripts/benchmark-operation-session.mjs +137 -0
  58. package/scripts/benchmark-targeting-browser.html +74 -0
  59. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  60. package/scripts/benchmark-test-runner.mjs +59 -0
  61. package/scripts/build-test-dashboard.mjs +23 -0
  62. package/scripts/export-lane1-fixtures.mjs +380 -0
  63. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  64. package/scripts/export-validation-fixtures.mjs +1 -1
  65. package/scripts/extract_text.mjs +7 -0
  66. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  67. package/scripts/generate-test-dashboard.mjs +362 -11
  68. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  69. package/scripts/profile-route-selection.mjs +19 -0
  70. package/scripts/render-agenda-multilevel.mjs +0 -5
  71. package/scripts/render-multilevel-cases.mjs +0 -1
  72. package/scripts/run-tests.mjs +107 -35
  73. package/scripts/word-com-corpus-suite.ps1 +3 -0
  74. package/scripts/word-com-differential.ps1 +64 -4
  75. package/scripts/word-com-suite.ps1 +3 -0
  76. package/services/batch-operation-orchestrator.js +494 -0
  77. package/services/capture-engine.js +226 -0
  78. package/services/comment-builders.js +23 -6
  79. package/services/comment-engine.js +108 -47
  80. package/services/comment-locator.js +187 -82
  81. package/services/comment-replies.js +95 -0
  82. package/services/document-inspection.js +258 -0
  83. package/services/document-operation-applier.js +372 -0
  84. package/services/document-operation-contract.js +323 -0
  85. package/services/document-operation-mutations.js +1733 -0
  86. package/services/document-operation-session.js +258 -0
  87. package/services/numbering-service.js +14 -5
  88. package/services/operation-heuristics.js +173 -0
  89. package/services/operation-preflight.js +366 -0
  90. package/services/receipt-collector.js +288 -0
  91. package/services/revision-comment-management.js +37 -5
  92. package/services/revision-token.js +290 -0
  93. package/services/standalone-docx-plumbing.js +123 -8
  94. package/services/standalone-operation-runner.d.ts +296 -0
  95. package/services/standalone-operation-runner.js +10 -1455
  96. package/services/table-reconciliation.js +15 -6
  97. package/docs/VALIDATION.md +0 -183
  98. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  99. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  100. /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
@@ -1,10 +1,10 @@
1
- /**
2
- * OOXML run/track-change builders.
3
- *
4
- * This module centralizes creation of `w:r`, `w:ins`, `w:del`, `w:rPrChange`,
5
- * and `w:pPrChange` elements used by surgical and reconstruction modes.
6
- */
7
-
1
+ /**
2
+ * OOXML run/track-change builders.
3
+ *
4
+ * This module centralizes creation of `w:r`, `w:ins`, `w:del`, `w:rPrChange`,
5
+ * and `w:pPrChange` elements used by surgical and reconstruction modes.
6
+ */
7
+
8
8
  import { extractFormatFromRPr, RPR_SCHEMA_ORDER } from './rpr-helpers.js';
9
9
  import { createRevisionMetadata } from '../core/types.js';
10
10
  import { getFirstElementByTag } from '../core/xml-query.js';
@@ -12,23 +12,24 @@ import { createWordElement } from '../core/word-xml.js';
12
12
 
13
13
  /**
14
14
  * Creates an insertion/deletion wrapper.
15
- *
16
- * @param {Document} xmlDoc - XML document
17
- * @param {'ins'|'del'} type - Wrapper type
18
- * @param {Element|null} run - Optional run to append
19
- * @param {string} author - Change author
20
- * @returns {Element}
21
- */
22
- export function createTrackChange(xmlDoc, type, run, author) {
15
+ *
16
+ * @param {Document} xmlDoc - XML document
17
+ * @param {'ins'|'del'} type - Wrapper type
18
+ * @param {Element|null} run - Optional run to append
19
+ * @param {string} author - Change author
20
+ * @param {Object|null} [revisionMetadata=null] - Optional explicit revision metadata
21
+ * @returns {Element}
22
+ */
23
+ export function createTrackChange(xmlDoc, type, run, author, revisionMetadata = null) {
23
24
  const wrapper = createWordElement(xmlDoc, type === 'ins' ? 'w:ins' : 'w:del');
24
- const metadata = createRevisionMetadata(author, xmlDoc);
25
- wrapper.setAttribute('w:id', String(metadata.id));
26
- wrapper.setAttribute('w:author', metadata.author);
27
- wrapper.setAttribute('w:date', metadata.date);
28
- if (run) {
29
- wrapper.appendChild(run);
30
- }
31
- return wrapper;
25
+ const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc, type === 'ins' ? 'ins' : 'del');
26
+ wrapper.setAttribute('w:id', String(metadata.id));
27
+ wrapper.setAttribute('w:author', metadata.author);
28
+ wrapper.setAttribute('w:date', metadata.date);
29
+ if (run) {
30
+ wrapper.appendChild(run);
31
+ }
32
+ return wrapper;
32
33
  }
33
34
 
34
35
  function getDirectWordChild(node, localName) {
@@ -58,7 +59,7 @@ function ensureParagraphMarkRunProperties(xmlDoc, pPr) {
58
59
  return rPr;
59
60
  }
60
61
 
61
- function markParagraphMark(xmlDoc, paragraph, author, type) {
62
+ function markParagraphMark(xmlDoc, paragraph, author, type, revisionMetadata = null) {
62
63
  const pPr = ensureParagraphProperties(xmlDoc, paragraph);
63
64
  const rPr = ensureParagraphMarkRunProperties(xmlDoc, pPr);
64
65
 
@@ -70,7 +71,7 @@ function markParagraphMark(xmlDoc, paragraph, author, type) {
70
71
  }
71
72
 
72
73
  const marker = createWordElement(xmlDoc, type === 'ins' ? 'w:ins' : 'w:del');
73
- const metadata = createRevisionMetadata(author, xmlDoc);
74
+ const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc, type === 'ins' ? 'ins' : 'del');
74
75
  marker.setAttribute('w:id', String(metadata.id));
75
76
  marker.setAttribute('w:author', metadata.author);
76
77
  marker.setAttribute('w:date', metadata.date);
@@ -84,10 +85,11 @@ function markParagraphMark(xmlDoc, paragraph, author, type) {
84
85
  * @param {Document} xmlDoc - XML document
85
86
  * @param {Element} paragraph - Paragraph to mark
86
87
  * @param {string} author - Change author
88
+ * @param {Object|null} [revisionMetadata=null] - Optional explicit revision metadata
87
89
  * @returns {Element}
88
90
  */
89
- export function markParagraphMarkInserted(xmlDoc, paragraph, author) {
90
- return markParagraphMark(xmlDoc, paragraph, author, 'ins');
91
+ export function markParagraphMarkInserted(xmlDoc, paragraph, author, revisionMetadata = null) {
92
+ return markParagraphMark(xmlDoc, paragraph, author, 'ins', revisionMetadata);
91
93
  }
92
94
 
93
95
  /**
@@ -96,21 +98,22 @@ export function markParagraphMarkInserted(xmlDoc, paragraph, author) {
96
98
  * @param {Document} xmlDoc - XML document
97
99
  * @param {Element} paragraph - Paragraph to mark
98
100
  * @param {string} author - Change author
101
+ * @param {Object|null} [revisionMetadata=null] - Optional explicit revision metadata
99
102
  * @returns {Element}
100
103
  */
101
- export function markParagraphMarkDeleted(xmlDoc, paragraph, author) {
102
- return markParagraphMark(xmlDoc, paragraph, author, 'del');
104
+ export function markParagraphMarkDeleted(xmlDoc, paragraph, author, revisionMetadata = null) {
105
+ return markParagraphMark(xmlDoc, paragraph, author, 'del', revisionMetadata);
103
106
  }
104
-
105
- /**
106
- * Creates a text run with optional formatting.
107
- *
108
- * @param {Document} xmlDoc - XML document
109
- * @param {string} text - Text content
110
- * @param {Element|null} rPr - Run properties
111
- * @param {boolean} isDelete - Use `w:delText` instead of `w:t`
112
- * @returns {Element}
113
- */
107
+
108
+ /**
109
+ * Creates a text run with optional formatting.
110
+ *
111
+ * @param {Document} xmlDoc - XML document
112
+ * @param {string} text - Text content
113
+ * @param {Element|null} rPr - Run properties
114
+ * @param {boolean} isDelete - Use `w:delText` instead of `w:t`
115
+ * @returns {Element}
116
+ */
114
117
  export function createTextRun(xmlDoc, text, rPr, isDelete) {
115
118
  const run = createWordElement(xmlDoc, 'w:r');
116
119
  if (rPr) run.appendChild(rPr.cloneNode(true));
@@ -123,50 +126,50 @@ export function createTextRun(xmlDoc, text, rPr, isDelete) {
123
126
  const textEl = createWordElement(xmlDoc, isDelete ? 'w:delText' : 'w:t');
124
127
  textEl.setAttribute('xml:space', 'preserve');
125
128
  textEl.textContent = text;
126
- run.appendChild(textEl);
127
-
128
- return run;
129
- }
130
-
131
- /**
132
- * Creates an array of runs with formatting applied from hints.
133
- *
134
- * @param {Document} xmlDoc - XML document
135
- * @param {string} text - Text to split and format
136
- * @param {Element|null} baseRPr - Base run properties
137
- * @param {Array} formatHints - Formatting hints
138
- * @param {number} baseOffset - Absolute base offset
139
- * @param {string} [author] - Change author
140
- * @param {boolean} [generateRedlines] - Whether to create rPrChange
141
- * @returns {Element[]}
142
- */
129
+ run.appendChild(textEl);
130
+
131
+ return run;
132
+ }
133
+
134
+ /**
135
+ * Creates an array of runs with formatting applied from hints.
136
+ *
137
+ * @param {Document} xmlDoc - XML document
138
+ * @param {string} text - Text to split and format
139
+ * @param {Element|null} baseRPr - Base run properties
140
+ * @param {Array} formatHints - Formatting hints
141
+ * @param {number} baseOffset - Absolute base offset
142
+ * @param {string} [author] - Change author
143
+ * @param {boolean} [generateRedlines] - Whether to create rPrChange
144
+ * @returns {Element[]}
145
+ */
143
146
  export function createFormattedRuns(xmlDoc, text, baseRPr, formatHints, baseOffset, author, generateRedlines) {
144
147
  if (!text) return [];
145
-
146
- const breaks = new Set([0, text.length]);
147
- for (const hint of formatHints) {
148
- const localStart = Math.max(0, hint.start - baseOffset);
149
- const localEnd = Math.min(text.length, hint.end - baseOffset);
150
- if (localStart >= 0 && localStart < text.length) breaks.add(localStart);
151
- if (localEnd > 0 && localEnd <= text.length) breaks.add(localEnd);
152
- }
153
-
154
- const sortedBreaks = Array.from(breaks).sort((a, b) => a - b);
155
- const runs = [];
156
-
157
- for (let i = 0; i < sortedBreaks.length - 1; i++) {
158
- const start = sortedBreaks[i];
159
- const end = sortedBreaks[i + 1];
160
- const segment = text.slice(start, end);
161
- if (!segment) continue;
162
-
163
- const segmentBaseOffset = baseOffset + start;
164
- const segmentEndOffset = baseOffset + end;
165
-
166
- const applicableHints = formatHints.filter(h =>
167
- h.start <= segmentBaseOffset && h.end >= segmentEndOffset
168
- );
169
-
148
+
149
+ const breaks = new Set([0, text.length]);
150
+ for (const hint of formatHints) {
151
+ const localStart = Math.max(0, hint.start - baseOffset);
152
+ const localEnd = Math.min(text.length, hint.end - baseOffset);
153
+ if (localStart >= 0 && localStart < text.length) breaks.add(localStart);
154
+ if (localEnd > 0 && localEnd <= text.length) breaks.add(localEnd);
155
+ }
156
+
157
+ const sortedBreaks = Array.from(breaks).sort((a, b) => a - b);
158
+ const runs = [];
159
+
160
+ for (let i = 0; i < sortedBreaks.length - 1; i++) {
161
+ const start = sortedBreaks[i];
162
+ const end = sortedBreaks[i + 1];
163
+ const segment = text.slice(start, end);
164
+ if (!segment) continue;
165
+
166
+ const segmentBaseOffset = baseOffset + start;
167
+ const segmentEndOffset = baseOffset + end;
168
+
169
+ const applicableHints = formatHints.filter(h =>
170
+ h.start <= segmentBaseOffset && h.end >= segmentEndOffset
171
+ );
172
+
170
173
  const combinedFormat = { ...extractFormatFromRPr(baseRPr) };
171
174
  applicableHints.forEach(h => {
172
175
  if (h.format) Object.assign(combinedFormat, h.format);
@@ -178,20 +181,20 @@ export function createFormattedRuns(xmlDoc, text, baseRPr, formatHints, baseOffs
178
181
  ? injectFormattingToRPr(xmlDoc, baseRPr, combinedFormat, author, generateRedlines)
179
182
  : baseRPr?.cloneNode(true) || null;
180
183
  runs.push(createTextRunWithRPrElement(xmlDoc, segment, formattedRPr, false));
181
- }
182
-
183
- return runs;
184
- }
185
-
186
- /**
187
- * Creates a text run with an existing rPr element (no clone).
188
- *
189
- * @param {Document} xmlDoc - XML document
190
- * @param {string} text - Text content
191
- * @param {Element|null} rPrElement - Run properties element
192
- * @param {boolean} isDelete - Use `w:delText` instead of `w:t`
193
- * @returns {Element}
194
- */
184
+ }
185
+
186
+ return runs;
187
+ }
188
+
189
+ /**
190
+ * Creates a text run with an existing rPr element (no clone).
191
+ *
192
+ * @param {Document} xmlDoc - XML document
193
+ * @param {string} text - Text content
194
+ * @param {Element|null} rPrElement - Run properties element
195
+ * @param {boolean} isDelete - Use `w:delText` instead of `w:t`
196
+ * @returns {Element}
197
+ */
195
198
  export function createTextRunWithRPrElement(xmlDoc, text, rPrElement, isDelete) {
196
199
  const run = createWordElement(xmlDoc, 'w:r');
197
200
  if (rPrElement) run.appendChild(rPrElement);
@@ -204,8 +207,8 @@ export function createTextRunWithRPrElement(xmlDoc, text, rPrElement, isDelete)
204
207
  const textEl = createWordElement(xmlDoc, isDelete ? 'w:delText' : 'w:t');
205
208
  textEl.setAttribute('xml:space', 'preserve');
206
209
  textEl.textContent = text;
207
- run.appendChild(textEl);
208
-
210
+ run.appendChild(textEl);
211
+
209
212
  return run;
210
213
  }
211
214
 
@@ -236,115 +239,155 @@ function appendVisibleTextPieces(xmlDoc, run, text) {
236
239
  run.appendChild(textEl);
237
240
  }
238
241
  }
239
-
240
- /**
241
- * Creates a new rPr synchronized to the requested core formatting flags.
242
- *
243
- * @param {Document} xmlDoc - XML document
244
- * @param {Element|null} baseRPr - Base run properties
245
- * @param {Object|null} format - Format flags
246
- * @param {string} [author] - Change author
247
- * @param {boolean} [generateRedlines] - Whether to create rPrChange
248
- * @returns {Element}
249
- */
242
+
243
+ /**
244
+ * Creates a new rPr synchronized to the requested core formatting flags.
245
+ *
246
+ * @param {Document} xmlDoc - XML document
247
+ * @param {Element|null} baseRPr - Base run properties
248
+ * @param {Object|null} format - Format flags
249
+ * @param {string} [author] - Change author
250
+ * @param {boolean} [generateRedlines] - Whether to create rPrChange
251
+ * @returns {Element}
252
+ */
250
253
  export function injectFormattingToRPr(xmlDoc, baseRPr, format, author, generateRedlines) {
251
254
  const rPr = createWordElement(xmlDoc, 'w:rPr');
252
-
253
- if (baseRPr) {
254
- Array.from(baseRPr.childNodes).forEach(child => {
255
- if (!['w:b', 'w:bCs', 'w:i', 'w:iCs', 'w:u', 'w:strike', 'w:rPrChange'].includes(child.nodeName)) {
256
- rPr.appendChild(child.cloneNode(true));
257
- }
258
- });
259
- }
260
-
261
- const activeFormat = format || { bold: false, italic: false, underline: false, strikethrough: false };
262
-
263
- if (author && generateRedlines) {
264
- createRPrChange(xmlDoc, rPr, author, baseRPr);
265
- }
266
-
255
+
256
+ if (baseRPr) {
257
+ Array.from(baseRPr.childNodes).forEach(child => {
258
+ if (!['w:b', 'w:bCs', 'w:i', 'w:iCs', 'w:u', 'w:strike', 'w:rPrChange'].includes(child.nodeName)) {
259
+ rPr.appendChild(child.cloneNode(true));
260
+ }
261
+ });
262
+ }
263
+
264
+ const activeFormat = format || { bold: false, italic: false, underline: false, strikethrough: false };
265
+
266
+ if (author && generateRedlines) {
267
+ createRPrChange(xmlDoc, rPr, author, baseRPr);
268
+ }
269
+
267
270
  const syncElement = (tagName, isOn, valOn = null, valOff = '0') => {
268
271
  const el = createWordElement(xmlDoc, tagName);
269
- if (isOn) {
270
- if (valOn) el.setAttribute('w:val', valOn);
271
- } else if (valOff) {
272
- el.setAttribute('w:val', valOff);
273
- }
274
-
275
- const myIndex = RPR_SCHEMA_ORDER.indexOf(tagName);
276
- const myPriority = myIndex === -1 ? 999 : myIndex;
277
-
278
- let inserted = false;
279
- for (const child of Array.from(rPr.childNodes)) {
280
- if (child.nodeType !== 1) continue;
281
- const childIndex = RPR_SCHEMA_ORDER.indexOf(child.nodeName);
282
- const childPriority = childIndex === -1 ? 999 : childIndex;
283
- if (childPriority > myPriority) {
284
- rPr.insertBefore(el, child);
285
- inserted = true;
286
- break;
287
- }
288
- }
289
- if (!inserted) rPr.appendChild(el);
290
- };
291
-
292
- syncElement('w:b', !!activeFormat.bold, '1', '0');
293
- syncElement('w:bCs', !!activeFormat.bold, '1', '0');
294
- syncElement('w:i', !!activeFormat.italic, '1', '0');
295
- syncElement('w:iCs', !!activeFormat.italic, '1', '0');
296
- syncElement('w:u', !!activeFormat.underline, 'single', 'none');
297
- syncElement('w:strike', !!activeFormat.strikethrough, '1', '0');
298
-
299
- return rPr;
300
- }
301
-
302
- /**
303
- * Creates and attaches a `w:rPrChange` snapshot.
304
- *
305
- * @param {Document} xmlDoc - XML document
306
- * @param {Element} rPr - Target run properties
307
- * @param {string} author - Change author
308
- * @param {string} dateStr - ISO date string
309
- * @param {Element} [sourceNode] - Optional source for previous state snapshot
310
- * @returns {Element}
311
- */
272
+ if (isOn) {
273
+ if (valOn) el.setAttribute('w:val', valOn);
274
+ } else if (valOff) {
275
+ el.setAttribute('w:val', valOff);
276
+ }
277
+
278
+ const myIndex = RPR_SCHEMA_ORDER.indexOf(tagName);
279
+ const myPriority = myIndex === -1 ? 999 : myIndex;
280
+
281
+ let inserted = false;
282
+ for (const child of Array.from(rPr.childNodes)) {
283
+ if (child.nodeType !== 1) continue;
284
+ const childIndex = RPR_SCHEMA_ORDER.indexOf(child.nodeName);
285
+ const childPriority = childIndex === -1 ? 999 : childIndex;
286
+ if (childPriority > myPriority) {
287
+ rPr.insertBefore(el, child);
288
+ inserted = true;
289
+ break;
290
+ }
291
+ }
292
+ if (!inserted) rPr.appendChild(el);
293
+ };
294
+
295
+ syncElement('w:b', !!activeFormat.bold, '1', '0');
296
+ syncElement('w:bCs', !!activeFormat.bold, '1', '0');
297
+ syncElement('w:i', !!activeFormat.italic, '1', '0');
298
+ syncElement('w:iCs', !!activeFormat.italic, '1', '0');
299
+ syncElement('w:u', !!activeFormat.underline, 'single', 'none');
300
+ syncElement('w:strike', !!activeFormat.strikethrough, '1', '0');
301
+
302
+ return rPr;
303
+ }
304
+
305
+ /**
306
+ * Creates and attaches a `w:rPrChange` snapshot.
307
+ *
308
+ * @param {Document} xmlDoc - XML document
309
+ * @param {Element} rPr - Target run properties
310
+ * @param {string} author - Change author
311
+ * @param {string} dateStr - ISO date string
312
+ * @param {Element} [sourceNode] - Optional source for previous state snapshot
313
+ * @returns {Element}
314
+ */
312
315
  export function snapshotAndAttachRPrChange(xmlDoc, rPr, author, dateStr, sourceNode) {
313
316
  const rPrChange = createWordElement(xmlDoc, 'w:rPrChange');
314
- const metadata = createRevisionMetadata(author, xmlDoc);
317
+ const metadata = createRevisionMetadata(author, xmlDoc, 'rPrChange');
315
318
  rPrChange.setAttribute('w:id', String(metadata.id));
316
319
  rPrChange.setAttribute('w:author', metadata.author);
317
320
  rPrChange.setAttribute('w:date', dateStr || metadata.date);
318
-
321
+
319
322
  const previousRPr = createWordElement(xmlDoc, 'w:rPr');
320
- const source = sourceNode || rPr;
321
-
322
- Array.from(source.childNodes).forEach(child => {
323
- if (child.nodeName !== 'w:rPrChange') {
324
- previousRPr.appendChild(child.cloneNode(true));
325
- }
326
- });
327
-
328
- rPrChange.appendChild(previousRPr);
329
-
330
- const existing = getFirstElementByTag(rPr, 'w:rPrChange');
331
- if (existing) {
332
- rPr.removeChild(existing);
333
- }
334
-
335
- rPr.appendChild(rPrChange);
336
- return rPrChange;
337
- }
338
-
339
- /**
340
- * Creates `w:rPrChange` for track formatting changes.
341
- *
342
- * @param {Document} xmlDoc - XML document
343
- * @param {Element} rPr - Run properties target
344
- * @param {string} author - Change author
345
- * @param {Element} [previousRPrArg] - Optional explicit previous-state source
346
- * @returns {void}
347
- */
323
+ const source = sourceNode || rPr;
324
+
325
+ Array.from(source.childNodes).forEach(child => {
326
+ if (child.nodeName !== 'w:rPrChange') {
327
+ previousRPr.appendChild(child.cloneNode(true));
328
+ }
329
+ });
330
+
331
+ rPrChange.appendChild(previousRPr);
332
+
333
+ const existing = getFirstElementByTag(rPr, 'w:rPrChange');
334
+ if (existing) {
335
+ rPr.removeChild(existing);
336
+ }
337
+
338
+ rPr.appendChild(rPrChange);
339
+ return rPrChange;
340
+ }
341
+
342
+ /**
343
+ * Creates `w:rPrChange` for track formatting changes.
344
+ *
345
+ * @param {Document} xmlDoc - XML document
346
+ * @param {Element} rPr - Run properties target
347
+ * @param {string} author - Change author
348
+ * @param {Element} [previousRPrArg] - Optional explicit previous-state source
349
+ * @returns {void}
350
+ */
348
351
  function createRPrChange(xmlDoc, rPr, author, previousRPrArg) {
349
352
  snapshotAndAttachRPrChange(xmlDoc, rPr, author, null, previousRPrArg || rPr);
350
353
  }
354
+
355
+ /**
356
+ * Creates and attaches a `w:pPrChange` snapshot to paragraph properties.
357
+ *
358
+ * @param {Document} xmlDoc - XML document
359
+ * @param {Element} pPr - Target paragraph properties
360
+ * @param {string} author - Change author
361
+ * @param {string} [dateStr] - ISO date string
362
+ * @param {Element} [sourceNode] - Optional source for previous state snapshot
363
+ * @returns {Element}
364
+ */
365
+ export function snapshotAndAttachPPrChange(xmlDoc, pPr, author, dateStr, sourceNode) {
366
+ const pPrChange = createWordElement(xmlDoc, 'w:pPrChange');
367
+ const metadata = createRevisionMetadata(author, xmlDoc, 'pPrChange');
368
+ pPrChange.setAttribute('w:id', String(metadata.id));
369
+ pPrChange.setAttribute('w:author', metadata.author);
370
+ pPrChange.setAttribute('w:date', dateStr || metadata.date);
371
+
372
+ const previousPPr = createWordElement(xmlDoc, 'w:pPr');
373
+ const source = sourceNode || pPr;
374
+
375
+ Array.from(source.childNodes).forEach(child => {
376
+ if (child.nodeType === 1 && child.nodeName !== 'w:pPrChange' && child.localName !== 'pPrChange') {
377
+ previousPPr.appendChild(child.cloneNode(true));
378
+ }
379
+ });
380
+
381
+ pPrChange.appendChild(previousPPr);
382
+
383
+ const existing = Array.from(pPr.childNodes).find(
384
+ c => c.nodeType === 1 && (c.nodeName === 'w:pPrChange' || c.localName === 'pPrChange')
385
+ );
386
+ if (existing) {
387
+ pPr.removeChild(existing);
388
+ }
389
+
390
+ pPr.appendChild(pPrChange);
391
+ return pPrChange;
392
+ }
393
+