@ansonlai/docx-redline-js 0.8.4 → 0.10.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 (72) hide show
  1. package/ARCHITECTURE.md +679 -460
  2. package/CHANGELOG.md +208 -1
  3. package/README.md +152 -16
  4. package/core/current-properties.js +136 -0
  5. package/core/paragraph-text.js +211 -209
  6. package/core/types.js +9 -3
  7. package/core/word-revision-semantics.js +256 -0
  8. package/core/word-xml.js +160 -118
  9. package/dist/docx-redline-js.esm.js +4338 -796
  10. package/dist/docx-redline-js.esm.js.map +4 -4
  11. package/dist/docx-redline-js.esm.min.js +111 -108
  12. package/dist/docx-redline-js.esm.min.js.map +4 -4
  13. package/dist/docx-redline.bundle.cjs +4463 -921
  14. package/dist/docx-redline.bundle.cjs.map +4 -4
  15. package/dist/docx-redline.bundle.js +4463 -921
  16. package/dist/docx-redline.bundle.js.map +4 -4
  17. package/docs/AGENT_FAST_START.md +2 -0
  18. package/docs/AGENT_KNOWLEDGE_BASE.md +49 -7
  19. package/docs/SKILL_AUTHORING.md +169 -150
  20. package/docs/TESTING.md +1146 -1059
  21. package/docs/schemas/document-operations.schema.json +55 -2
  22. package/document/docx-document.js +1022 -1010
  23. package/engine/oxml-engine.js +21 -22
  24. package/engine/paragraph-mark-revisions.js +292 -0
  25. package/engine/reconstruction-mapper.js +312 -311
  26. package/engine/reconstruction-writer.js +67 -10
  27. package/engine/rpr-helpers.js +42 -0
  28. package/engine/run-builders.js +0 -73
  29. package/engine/surgical-diff-application.js +604 -605
  30. package/engine/table-mode.js +63 -63
  31. package/index.d.ts +53 -2
  32. package/index.js +13 -2
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +21 -3
  35. package/orchestration/list-structural-fallback.js +67 -9
  36. package/package.json +1 -1
  37. package/pipeline/diff-engine.js +130 -1
  38. package/pipeline/ingestion-export.js +227 -243
  39. package/pipeline/ingestion-paragraph.js +93 -98
  40. package/pipeline/list-generation.js +415 -419
  41. package/pipeline/markdown-processor.js +5 -2
  42. package/pipeline/pipeline.js +206 -204
  43. package/scripts/export-validation-fixtures.mjs +19 -6
  44. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  45. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  46. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  47. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  48. package/scripts/word-com-differential.ps1 +443 -335
  49. package/scripts/word-com-visual-suite.ps1 +2 -2
  50. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  51. package/services/batch-operation-orchestrator.js +23 -3
  52. package/services/capture-engine.js +228 -226
  53. package/services/comment-replies.js +1 -1
  54. package/services/document-operation-applier.js +214 -0
  55. package/services/document-operation-contract.js +76 -0
  56. package/services/document-operation-mutations.js +688 -51
  57. package/services/error-recovery.js +17 -0
  58. package/services/inspection-comparison.js +82 -0
  59. package/services/list-conversion-diagnostics.js +296 -0
  60. package/services/list-format-change.js +586 -0
  61. package/services/list-format-mutation.js +416 -0
  62. package/services/list-format.js +305 -0
  63. package/services/numbering-allocation.js +642 -0
  64. package/services/numbering-helpers.js +354 -524
  65. package/services/operation-batch-compiler.js +183 -11
  66. package/services/operation-preflight.js +43 -33
  67. package/services/receipt-collector.js +288 -288
  68. package/services/revision-comment-management.js +38 -5
  69. package/services/revision-fidelity-verifier.js +484 -0
  70. package/services/standalone-docx-plumbing.js +627 -631
  71. package/services/standalone-operation-runner.d.ts +182 -5
  72. package/services/table-reconciliation.js +59 -7
@@ -1,288 +1,288 @@
1
- /**
2
- * Internal mutation receipt collector for tracking allocated revision,
3
- * comment, numbering, and relationship IDs per operation.
4
- */
5
-
6
- export class ReceiptCollector {
7
- constructor() {
8
- this.receipts = [];
9
- this.activeReceipt = null;
10
- }
11
-
12
- beginOperation(operationIndex, operationId = null, authorUsed = null) {
13
- this.activeReceipt = {
14
- operationIndex: typeof operationIndex === 'number' ? operationIndex : 1,
15
- ...(operationId ? { operationId: String(operationId) } : {}),
16
- attemptedDisposition: 'applied',
17
- finalDisposition: 'applied',
18
- committed: true,
19
- ...(authorUsed ? { authorUsed: String(authorUsed) } : {}),
20
- revisionItems: [],
21
- commentIds: [],
22
- numberingIds: [],
23
- relationshipIds: [],
24
- affectedTargets: [],
25
- warnings: []
26
- };
27
- }
28
-
29
- recordRevision(id, kind = 'structural', partName = 'word/document.xml') {
30
- if (!this.activeReceipt || id == null) return;
31
- const strId = String(id);
32
- if (!this.activeReceipt.revisionItems.some(item => item.id === strId && item.kind === kind && item.partName === partName)) {
33
- this.activeReceipt.revisionItems.push({
34
- id: strId,
35
- kind,
36
- partName
37
- });
38
- }
39
- }
40
-
41
- recordComment(id, _partName = 'word/comments.xml') {
42
- if (!this.activeReceipt || id == null) return;
43
- const strId = String(id);
44
- if (!this.activeReceipt.commentIds.includes(strId)) {
45
- this.activeReceipt.commentIds.push(strId);
46
- }
47
- }
48
-
49
- recordNumbering(id, _partName = 'word/numbering.xml') {
50
- if (!this.activeReceipt || id == null) return;
51
- const strId = String(id);
52
- if (!this.activeReceipt.numberingIds.includes(strId)) {
53
- this.activeReceipt.numberingIds.push(strId);
54
- }
55
- }
56
-
57
- recordRelationship(id, _partName = 'word/_rels/document.xml.rels') {
58
- if (!this.activeReceipt || id == null) return;
59
- const strId = String(id);
60
- if (!this.activeReceipt.relationshipIds.includes(strId)) {
61
- this.activeReceipt.relationshipIds.push(strId);
62
- }
63
- }
64
-
65
- recordAffectedTarget(target) {
66
- if (!this.activeReceipt || !target) return;
67
- this.activeReceipt.affectedTargets.push(JSON.parse(JSON.stringify(target)));
68
- }
69
-
70
- recordWarning(warning) {
71
- if (!this.activeReceipt || !warning) return;
72
- this.activeReceipt.warnings.push(String(warning));
73
- }
74
-
75
- commitOperation(disposition = 'applied') {
76
- if (!this.activeReceipt) return null;
77
- this.activeReceipt.attemptedDisposition = disposition;
78
- this.activeReceipt.finalDisposition = disposition;
79
- this.activeReceipt.committed = disposition === 'applied';
80
- const committed = JSON.parse(JSON.stringify(this.activeReceipt));
81
- this.receipts.push(committed);
82
- this.activeReceipt = null;
83
- return committed;
84
- }
85
-
86
- abortOperation(disposition = 'refused') {
87
- if (!this.activeReceipt) return null;
88
- this.activeReceipt.attemptedDisposition = disposition;
89
- this.activeReceipt.finalDisposition = disposition;
90
- this.activeReceipt.committed = false;
91
- const aborted = JSON.parse(JSON.stringify(this.activeReceipt));
92
- this.activeReceipt = null;
93
- return aborted;
94
- }
95
-
96
- createSavepoint() {
97
- return {
98
- receipts: JSON.parse(JSON.stringify(this.receipts)),
99
- activeReceipt: this.activeReceipt ? JSON.parse(JSON.stringify(this.activeReceipt)) : null
100
- };
101
- }
102
-
103
- restoreSavepoint(savepoint) {
104
- if (!savepoint) return;
105
- this.receipts = Array.isArray(savepoint.receipts)
106
- ? JSON.parse(JSON.stringify(savepoint.receipts))
107
- : [];
108
- this.activeReceipt = savepoint.activeReceipt
109
- ? JSON.parse(JSON.stringify(savepoint.activeReceipt))
110
- : null;
111
- }
112
-
113
- clear() {
114
- this.receipts = [];
115
- this.activeReceipt = null;
116
- }
117
-
118
- markRolledBack() {
119
- for (const receipt of this.receipts) {
120
- if (receipt.attemptedDisposition === 'applied') {
121
- receipt.finalDisposition = 'rolled_back';
122
- receipt.committed = false;
123
- }
124
- }
125
- this.activeReceipt = null;
126
- }
127
-
128
- getReceipts() {
129
- return JSON.parse(JSON.stringify(this.receipts));
130
- }
131
-
132
- getCurrentReceipt() {
133
- return this.activeReceipt ? JSON.parse(JSON.stringify(this.activeReceipt)) : null;
134
- }
135
- }
136
-
137
- /**
138
- * Creates an empty receipt structure for unattempted, refused, or no-op operations.
139
- *
140
- * @param {number} operationIndex
141
- * @param {string|null} [operationId]
142
- * @param {string|null} [authorUsed]
143
- * @param {'applied'|'no_change'|'refused'|'not_attempted'} [disposition]
144
- * @returns {Object}
145
- */
146
- export function createEmptyReceipt(operationIndex, operationId = null, authorUsed = null, disposition = 'not_attempted') {
147
- return {
148
- operationIndex: typeof operationIndex === 'number' ? operationIndex : 1,
149
- ...(operationId ? { operationId: String(operationId) } : {}),
150
- attemptedDisposition: disposition,
151
- finalDisposition: disposition,
152
- committed: false,
153
- ...(authorUsed ? { authorUsed: String(authorUsed) } : {}),
154
- revisionItems: [],
155
- commentIds: [],
156
- numberingIds: [],
157
- relationshipIds: [],
158
- affectedTargets: [],
159
- warnings: []
160
- };
161
- }
162
-
163
- /**
164
- * Reconciles committed mutation receipts against the generated/committed output parts.
165
- *
166
- * @param {Object} parts
167
- * @param {string} parts.documentXml - Serialized word/document.xml
168
- * @param {string|null} [parts.commentsXml] - Serialized word/comments.xml
169
- * @param {string|null} [parts.numberingXml] - Serialized word/numbering.xml
170
- * @param {string[]} [parts.numberingXmlParts] - Additional numbering XML parts
171
- * @param {string|null} [parts.relationshipsXml] - Serialized word/_rels/document.xml.rels
172
- * @param {Array<Object>} receipts - Array of MutationReceipt objects
173
- * @returns {{ valid: boolean, error?: { code: string, message: string } }}
174
- */
175
- export function reconcileReceiptsAgainstOutput(parts, receipts) {
176
- if (!Array.isArray(receipts) || receipts.length === 0) {
177
- return { valid: true };
178
- }
179
-
180
- const committedReceipts = receipts.filter(r => r && r.committed === true && r.finalDisposition === 'applied');
181
- if (committedReceipts.length === 0) {
182
- return { valid: true };
183
- }
184
-
185
- const revisionIdSet = new Set();
186
- if (parts?.documentXml && typeof parts.documentXml === 'string') {
187
- const revRegex = /<(?:w:)?(?:ins|del|rPrChange|pPrChange|moveFrom|moveTo)\b[^>]*?\b(?:w:)?id="([^"]+)"/g;
188
- let m;
189
- while ((m = revRegex.exec(parts.documentXml)) !== null) {
190
- revisionIdSet.add(m[1]);
191
- }
192
- }
193
-
194
- const commentIdSet = new Set();
195
- if (parts?.commentsXml && typeof parts.commentsXml === 'string') {
196
- const comRegex = /<(?:w:)?comment\b[^>]*?\b(?:w:)?id="([^"]+)"/g;
197
- let m;
198
- while ((m = comRegex.exec(parts.commentsXml)) !== null) {
199
- commentIdSet.add(m[1]);
200
- }
201
- }
202
-
203
- const numberingIdSet = new Set();
204
- const combinedNumbering = [parts?.numberingXml, ...(parts?.numberingXmlParts || [])].filter(Boolean).join('\n');
205
- if (combinedNumbering) {
206
- const numRegex = /<(?:w:)?num\b[^>]*?\b(?:w:)?numId="([^"]+)"/g;
207
- let m;
208
- while ((m = numRegex.exec(combinedNumbering)) !== null) {
209
- numberingIdSet.add(m[1]);
210
- }
211
- }
212
- if (parts?.documentXml && typeof parts.documentXml === 'string') {
213
- const docNumRegex = /<(?:w:)?numId\b[^>]*?\b(?:w:)?val="([^"]+)"/g;
214
- let m;
215
- while ((m = docNumRegex.exec(parts.documentXml)) !== null) {
216
- numberingIdSet.add(m[1]);
217
- }
218
- }
219
-
220
- const relIdSet = new Set();
221
- if (parts?.relationshipsXml && typeof parts.relationshipsXml === 'string') {
222
- const relRegex = /<Relationship\b[^>]*?\bId="([^"]+)"/g;
223
- let m;
224
- while ((m = relRegex.exec(parts.relationshipsXml)) !== null) {
225
- relIdSet.add(m[1]);
226
- }
227
- }
228
-
229
- for (const receipt of committedReceipts) {
230
- if (Array.isArray(receipt.revisionItems)) {
231
- for (const item of receipt.revisionItems) {
232
- if (item.partName === 'word/document.xml' && !revisionIdSet.has(String(item.id))) {
233
- return {
234
- valid: false,
235
- error: {
236
- code: 'RECEIPT_RECONCILIATION_FAILED',
237
- message: `Committed revision id '${item.id}' (kind: ${item.kind}) was not found in word/document.xml.`
238
- }
239
- };
240
- }
241
- }
242
- }
243
-
244
- if (Array.isArray(receipt.commentIds)) {
245
- for (const id of receipt.commentIds) {
246
- if (!commentIdSet.has(String(id))) {
247
- return {
248
- valid: false,
249
- error: {
250
- code: 'RECEIPT_RECONCILIATION_FAILED',
251
- message: `Committed comment id '${id}' was not found in word/comments.xml.`
252
- }
253
- };
254
- }
255
- }
256
- }
257
-
258
- if (Array.isArray(receipt.numberingIds)) {
259
- for (const id of receipt.numberingIds) {
260
- if (!numberingIdSet.has(String(id))) {
261
- return {
262
- valid: false,
263
- error: {
264
- code: 'RECEIPT_RECONCILIATION_FAILED',
265
- message: `Committed numbering id '${id}' was not found in numbering parts or document.`
266
- }
267
- };
268
- }
269
- }
270
- }
271
-
272
- if (parts?.relationshipsXml && Array.isArray(receipt.relationshipIds)) {
273
- for (const id of receipt.relationshipIds) {
274
- if (!relIdSet.has(String(id))) {
275
- return {
276
- valid: false,
277
- error: {
278
- code: 'RECEIPT_RECONCILIATION_FAILED',
279
- message: `Committed relationship id '${id}' was not found in document.xml.rels.`
280
- }
281
- };
282
- }
283
- }
284
- }
285
- }
286
-
287
- return { valid: true };
288
- }
1
+ /**
2
+ * Internal mutation receipt collector for tracking allocated revision,
3
+ * comment, numbering, and relationship IDs per operation.
4
+ */
5
+
6
+ export class ReceiptCollector {
7
+ constructor() {
8
+ this.receipts = [];
9
+ this.activeReceipt = null;
10
+ }
11
+
12
+ beginOperation(operationIndex, operationId = null, authorUsed = null) {
13
+ this.activeReceipt = {
14
+ operationIndex: typeof operationIndex === 'number' ? operationIndex : 1,
15
+ ...(operationId ? { operationId: String(operationId) } : {}),
16
+ attemptedDisposition: 'applied',
17
+ finalDisposition: 'applied',
18
+ committed: true,
19
+ ...(authorUsed ? { authorUsed: String(authorUsed) } : {}),
20
+ revisionItems: [],
21
+ commentIds: [],
22
+ numberingIds: [],
23
+ relationshipIds: [],
24
+ affectedTargets: [],
25
+ warnings: []
26
+ };
27
+ }
28
+
29
+ recordRevision(id, kind = 'structural', partName = 'word/document.xml') {
30
+ if (!this.activeReceipt || id == null) return;
31
+ const strId = String(id);
32
+ if (!this.activeReceipt.revisionItems.some(item => item.id === strId && item.kind === kind && item.partName === partName)) {
33
+ this.activeReceipt.revisionItems.push({
34
+ id: strId,
35
+ kind,
36
+ partName
37
+ });
38
+ }
39
+ }
40
+
41
+ recordComment(id, _partName = 'word/comments.xml') {
42
+ if (!this.activeReceipt || id == null) return;
43
+ const strId = String(id);
44
+ if (!this.activeReceipt.commentIds.includes(strId)) {
45
+ this.activeReceipt.commentIds.push(strId);
46
+ }
47
+ }
48
+
49
+ recordNumbering(id, _partName = 'word/numbering.xml') {
50
+ if (!this.activeReceipt || id == null) return;
51
+ const strId = String(id);
52
+ if (!this.activeReceipt.numberingIds.includes(strId)) {
53
+ this.activeReceipt.numberingIds.push(strId);
54
+ }
55
+ }
56
+
57
+ recordRelationship(id, _partName = 'word/_rels/document.xml.rels') {
58
+ if (!this.activeReceipt || id == null) return;
59
+ const strId = String(id);
60
+ if (!this.activeReceipt.relationshipIds.includes(strId)) {
61
+ this.activeReceipt.relationshipIds.push(strId);
62
+ }
63
+ }
64
+
65
+ recordAffectedTarget(target) {
66
+ if (!this.activeReceipt || !target) return;
67
+ this.activeReceipt.affectedTargets.push(JSON.parse(JSON.stringify(target)));
68
+ }
69
+
70
+ recordWarning(warning) {
71
+ if (!this.activeReceipt || !warning) return;
72
+ this.activeReceipt.warnings.push(String(warning));
73
+ }
74
+
75
+ commitOperation(disposition = 'applied') {
76
+ if (!this.activeReceipt) return null;
77
+ this.activeReceipt.attemptedDisposition = disposition;
78
+ this.activeReceipt.finalDisposition = disposition;
79
+ this.activeReceipt.committed = disposition === 'applied';
80
+ const committed = JSON.parse(JSON.stringify(this.activeReceipt));
81
+ this.receipts.push(committed);
82
+ this.activeReceipt = null;
83
+ return committed;
84
+ }
85
+
86
+ abortOperation(disposition = 'refused') {
87
+ if (!this.activeReceipt) return null;
88
+ this.activeReceipt.attemptedDisposition = disposition;
89
+ this.activeReceipt.finalDisposition = disposition;
90
+ this.activeReceipt.committed = false;
91
+ const aborted = JSON.parse(JSON.stringify(this.activeReceipt));
92
+ this.activeReceipt = null;
93
+ return aborted;
94
+ }
95
+
96
+ createSavepoint() {
97
+ return {
98
+ receipts: JSON.parse(JSON.stringify(this.receipts)),
99
+ activeReceipt: this.activeReceipt ? JSON.parse(JSON.stringify(this.activeReceipt)) : null
100
+ };
101
+ }
102
+
103
+ restoreSavepoint(savepoint) {
104
+ if (!savepoint) return;
105
+ this.receipts = Array.isArray(savepoint.receipts)
106
+ ? JSON.parse(JSON.stringify(savepoint.receipts))
107
+ : [];
108
+ this.activeReceipt = savepoint.activeReceipt
109
+ ? JSON.parse(JSON.stringify(savepoint.activeReceipt))
110
+ : null;
111
+ }
112
+
113
+ clear() {
114
+ this.receipts = [];
115
+ this.activeReceipt = null;
116
+ }
117
+
118
+ markRolledBack() {
119
+ for (const receipt of this.receipts) {
120
+ if (receipt.attemptedDisposition === 'applied') {
121
+ receipt.finalDisposition = 'rolled_back';
122
+ receipt.committed = false;
123
+ }
124
+ }
125
+ this.activeReceipt = null;
126
+ }
127
+
128
+ getReceipts() {
129
+ return JSON.parse(JSON.stringify(this.receipts));
130
+ }
131
+
132
+ getCurrentReceipt() {
133
+ return this.activeReceipt ? JSON.parse(JSON.stringify(this.activeReceipt)) : null;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Creates an empty receipt structure for unattempted, refused, or no-op operations.
139
+ *
140
+ * @param {number} operationIndex
141
+ * @param {string|null} [operationId]
142
+ * @param {string|null} [authorUsed]
143
+ * @param {'applied'|'no_change'|'refused'|'not_attempted'} [disposition]
144
+ * @returns {Object}
145
+ */
146
+ export function createEmptyReceipt(operationIndex, operationId = null, authorUsed = null, disposition = 'not_attempted') {
147
+ return {
148
+ operationIndex: typeof operationIndex === 'number' ? operationIndex : 1,
149
+ ...(operationId ? { operationId: String(operationId) } : {}),
150
+ attemptedDisposition: disposition,
151
+ finalDisposition: disposition,
152
+ committed: false,
153
+ ...(authorUsed ? { authorUsed: String(authorUsed) } : {}),
154
+ revisionItems: [],
155
+ commentIds: [],
156
+ numberingIds: [],
157
+ relationshipIds: [],
158
+ affectedTargets: [],
159
+ warnings: []
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Reconciles committed mutation receipts against the generated/committed output parts.
165
+ *
166
+ * @param {Object} parts
167
+ * @param {string} parts.documentXml - Serialized word/document.xml
168
+ * @param {string|null} [parts.commentsXml] - Serialized word/comments.xml
169
+ * @param {string|null} [parts.numberingXml] - Serialized word/numbering.xml
170
+ * @param {string[]} [parts.numberingXmlParts] - Additional numbering XML parts
171
+ * @param {string|null} [parts.relationshipsXml] - Serialized word/_rels/document.xml.rels
172
+ * @param {Array<Object>} receipts - Array of MutationReceipt objects
173
+ * @returns {{ valid: boolean, error?: { code: string, message: string } }}
174
+ */
175
+ export function reconcileReceiptsAgainstOutput(parts, receipts) {
176
+ if (!Array.isArray(receipts) || receipts.length === 0) {
177
+ return { valid: true };
178
+ }
179
+
180
+ const committedReceipts = receipts.filter(r => r && r.committed === true && r.finalDisposition === 'applied');
181
+ if (committedReceipts.length === 0) {
182
+ return { valid: true };
183
+ }
184
+
185
+ const revisionIdSet = new Set();
186
+ if (parts?.documentXml && typeof parts.documentXml === 'string') {
187
+ const revRegex = /<(?:w:)?(?:ins|del|rPrChange|pPrChange|moveFrom|moveTo)\b[^>]*?\b(?:w:)?id="([^"]+)"/g;
188
+ let m;
189
+ while ((m = revRegex.exec(parts.documentXml)) !== null) {
190
+ revisionIdSet.add(m[1]);
191
+ }
192
+ }
193
+
194
+ const commentIdSet = new Set();
195
+ if (parts?.commentsXml && typeof parts.commentsXml === 'string') {
196
+ const comRegex = /<(?:w:)?comment\b[^>]*?\b(?:w:)?id="([^"]+)"/g;
197
+ let m;
198
+ while ((m = comRegex.exec(parts.commentsXml)) !== null) {
199
+ commentIdSet.add(m[1]);
200
+ }
201
+ }
202
+
203
+ const numberingIdSet = new Set();
204
+ const combinedNumbering = [parts?.numberingXml, ...(parts?.numberingXmlParts || [])].filter(Boolean).join('\n');
205
+ if (combinedNumbering) {
206
+ const numRegex = /<(?:w:)?num\b[^>]*?\b(?:w:)?numId="([^"]+)"/g;
207
+ let m;
208
+ while ((m = numRegex.exec(combinedNumbering)) !== null) {
209
+ numberingIdSet.add(m[1]);
210
+ }
211
+ }
212
+ if (parts?.documentXml && typeof parts.documentXml === 'string') {
213
+ const docNumRegex = /<(?:w:)?numId\b[^>]*?\b(?:w:)?val="([^"]+)"/g;
214
+ let m;
215
+ while ((m = docNumRegex.exec(parts.documentXml)) !== null) {
216
+ numberingIdSet.add(m[1]);
217
+ }
218
+ }
219
+
220
+ const relIdSet = new Set();
221
+ if (parts?.relationshipsXml && typeof parts.relationshipsXml === 'string') {
222
+ const relRegex = /<Relationship\b[^>]*?\bId="([^"]+)"/g;
223
+ let m;
224
+ while ((m = relRegex.exec(parts.relationshipsXml)) !== null) {
225
+ relIdSet.add(m[1]);
226
+ }
227
+ }
228
+
229
+ for (const receipt of committedReceipts) {
230
+ if (Array.isArray(receipt.revisionItems)) {
231
+ for (const item of receipt.revisionItems) {
232
+ if (item.partName === 'word/document.xml' && !revisionIdSet.has(String(item.id))) {
233
+ return {
234
+ valid: false,
235
+ error: {
236
+ code: 'RECEIPT_RECONCILIATION_FAILED',
237
+ message: `Committed revision id '${item.id}' (kind: ${item.kind}) was not found in word/document.xml.`
238
+ }
239
+ };
240
+ }
241
+ }
242
+ }
243
+
244
+ if (Array.isArray(receipt.commentIds)) {
245
+ for (const id of receipt.commentIds) {
246
+ if (!commentIdSet.has(String(id))) {
247
+ return {
248
+ valid: false,
249
+ error: {
250
+ code: 'RECEIPT_RECONCILIATION_FAILED',
251
+ message: `Committed comment id '${id}' was not found in word/comments.xml.`
252
+ }
253
+ };
254
+ }
255
+ }
256
+ }
257
+
258
+ if (Array.isArray(receipt.numberingIds)) {
259
+ for (const id of receipt.numberingIds) {
260
+ if (!numberingIdSet.has(String(id))) {
261
+ return {
262
+ valid: false,
263
+ error: {
264
+ code: 'RECEIPT_RECONCILIATION_FAILED',
265
+ message: `Committed numbering id '${id}' was not found in numbering parts or document.`
266
+ }
267
+ };
268
+ }
269
+ }
270
+ }
271
+
272
+ if (parts?.relationshipsXml && Array.isArray(receipt.relationshipIds)) {
273
+ for (const id of receipt.relationshipIds) {
274
+ if (!relIdSet.has(String(id))) {
275
+ return {
276
+ valid: false,
277
+ error: {
278
+ code: 'RECEIPT_RECONCILIATION_FAILED',
279
+ message: `Committed relationship id '${id}' was not found in document.xml.rels.`
280
+ }
281
+ };
282
+ }
283
+ }
284
+ }
285
+ }
286
+
287
+ return { valid: true };
288
+ }
@@ -176,12 +176,23 @@ function getNextWordParagraph(paragraph) {
176
176
  return null;
177
177
  }
178
178
 
179
- function mergeParagraphIntoNextAndRemove(paragraph) {
179
+ /**
180
+ * Resolves a paragraph whose mark is being removed. `view` is 'accept' or 'reject'.
181
+ * A table cell must keep its last paragraph: Word keeps it on Reject even when the
182
+ * cell has other paragraphs, and on Accept only when it is the cell's sole paragraph
183
+ * (otherwise the previous paragraph absorbs the mark). `keptCellParagraphs`
184
+ * collects the sole kept paragraphs so Accept can drop fully-deleted tables.
185
+ */
186
+ function mergeParagraphIntoNextAndRemove(paragraph, view = 'accept', keptCellParagraphs = null) {
180
187
  if (!paragraph?.parentNode) return false;
181
188
  const nextParagraph = getNextWordParagraph(paragraph);
182
189
  if (!nextParagraph) {
183
- if (isWordElement(paragraph.parentNode, 'tc') && getWordElementsByLocalName(paragraph.parentNode, 'p').length <= 1) {
184
- return false;
190
+ if (isWordElement(paragraph.parentNode, 'tc')) {
191
+ const sole = getWordElementsByLocalName(paragraph.parentNode, 'p').length <= 1;
192
+ if (view === 'reject' || sole) {
193
+ if (sole && keptCellParagraphs) keptCellParagraphs.add(paragraph);
194
+ return false;
195
+ }
185
196
  }
186
197
  return removeNode(paragraph);
187
198
  }
@@ -227,6 +238,7 @@ export function acceptTrackedChangesInOoxml(oxml, options = {}) {
227
238
  const { xmlDoc, serializer } = parseResult;
228
239
  warnings.push(...(parseResult.warnings || []));
229
240
  let acceptedCount = 0;
241
+ const keptCellParagraphs = new Set();
230
242
 
231
243
  for (const insNode of getWordElementsByLocalName(xmlDoc, 'ins')) {
232
244
  if (!insNode.parentNode || !authorMatchesNode(insNode, filter)) continue;
@@ -245,7 +257,8 @@ export function acceptTrackedChangesInOoxml(oxml, options = {}) {
245
257
  if (!delNode.parentNode || !authorMatchesNode(delNode, filter)) continue;
246
258
  const paragraphMark = getContainingParagraphMarkRevision(delNode);
247
259
  if (paragraphMark) {
248
- if (mergeParagraphIntoNextAndRemove(paragraphMark)) acceptedCount += 1;
260
+ if (mergeParagraphIntoNextAndRemove(paragraphMark, 'accept', keptCellParagraphs)) acceptedCount += 1;
261
+ else if (removeNode(delNode)) acceptedCount += 1;
249
262
  continue;
250
263
  }
251
264
  if (isTableRowRevisionMarker(delNode)) {
@@ -256,6 +269,19 @@ export function acceptTrackedChangesInOoxml(oxml, options = {}) {
256
269
  if (removeNode(delNode)) acceptedCount += 1;
257
270
  }
258
271
 
272
+ // Word removes a table outright when accepting leaves every cell with only a
273
+ // deleted, content-free paragraph.
274
+ for (const tableNode of getWordElementsByLocalName(xmlDoc, 'tbl')) {
275
+ if (!tableNode.parentNode) continue;
276
+ const cells = getWordElementsByLocalName(tableNode, 'tc');
277
+ const allDeleted = cells.length > 0 && cells.every(cell => {
278
+ const paragraphs = getWordElementsByLocalName(cell, 'p');
279
+ return paragraphs.length === 1 && keptCellParagraphs.has(paragraphs[0])
280
+ && !Array.from(paragraphs[0].childNodes || []).some(child => !isWordElement(child, 'pPr'));
281
+ });
282
+ if (allDeleted && removeNode(tableNode)) acceptedCount += 1;
283
+ }
284
+
259
285
  for (const moveFromNode of getWordElementsByLocalName(xmlDoc, 'moveFrom')) {
260
286
  if (!moveFromNode.parentNode || !authorMatchesNode(moveFromNode, filter)) continue;
261
287
  if (removeNode(moveFromNode)) acceptedCount += 1;
@@ -328,6 +354,11 @@ function rejectPropertyChangeNode(changeNode, localName) {
328
354
  }
329
355
 
330
356
  const toAppend = Array.from(historicalNode.childNodes || []);
357
+ // pPrChange records only paragraph properties: the paragraph-mark rPr and sectPr
358
+ // live in the same pPr but are not part of the recorded change, so they survive.
359
+ const keepNames = baseLocalName.toLowerCase() === 'ppr' ? ['rpr', 'sectpr'] : [];
360
+ const kept = Array.from(parent.childNodes || []).filter(child => child.nodeType === 1
361
+ && child.namespaceURI === NS_W && keepNames.includes(String(child.localName || '').toLowerCase()));
331
362
  while (parent.firstChild) {
332
363
  parent.removeChild(parent.firstChild);
333
364
  }
@@ -335,6 +366,7 @@ function rejectPropertyChangeNode(changeNode, localName) {
335
366
  const clone = xmlDocImportNode(parent.ownerDocument, node);
336
367
  parent.appendChild(clone);
337
368
  }
369
+ for (const node of kept) parent.appendChild(node);
338
370
  return true;
339
371
  }
340
372
 
@@ -415,7 +447,8 @@ export function rejectTrackedChangesInOoxml(oxml, options = {}) {
415
447
  if (!insNode.parentNode || !authorMatchesNode(insNode, filter)) continue;
416
448
  const paragraphMark = getContainingParagraphMarkRevision(insNode);
417
449
  if (paragraphMark) {
418
- if (mergeParagraphIntoNextAndRemove(paragraphMark)) rejectedCount += 1;
450
+ if (mergeParagraphIntoNextAndRemove(paragraphMark, 'reject')) rejectedCount += 1;
451
+ else if (removeNode(insNode)) rejectedCount += 1;
419
452
  continue;
420
453
  }
421
454
  if (isTableRowRevisionMarker(insNode)) {