@ansonlai/docx-redline-js 0.8.4 → 0.9.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 (68) hide show
  1. package/ARCHITECTURE.md +221 -12
  2. package/CHANGELOG.md +176 -0
  3. package/README.md +125 -12
  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/dist/docx-redline-js.esm.js +4189 -767
  9. package/dist/docx-redline-js.esm.js.map +4 -4
  10. package/dist/docx-redline-js.esm.min.js +111 -108
  11. package/dist/docx-redline-js.esm.min.js.map +4 -4
  12. package/dist/docx-redline.bundle.cjs +4292 -870
  13. package/dist/docx-redline.bundle.cjs.map +4 -4
  14. package/dist/docx-redline.bundle.js +4292 -870
  15. package/dist/docx-redline.bundle.js.map +4 -4
  16. package/docs/AGENT_FAST_START.md +2 -0
  17. package/docs/AGENT_KNOWLEDGE_BASE.md +19 -3
  18. package/docs/SKILL_AUTHORING.md +165 -150
  19. package/docs/TESTING.md +1142 -1059
  20. package/docs/schemas/document-operations.schema.json +54 -1
  21. package/document/docx-document.js +1022 -1010
  22. package/engine/oxml-engine.js +736 -749
  23. package/engine/paragraph-mark-revisions.js +292 -0
  24. package/engine/reconstruction-mapper.js +312 -311
  25. package/engine/reconstruction-writer.js +67 -10
  26. package/engine/rpr-helpers.js +42 -0
  27. package/engine/run-builders.js +0 -73
  28. package/engine/surgical-diff-application.js +604 -605
  29. package/engine/table-mode.js +63 -63
  30. package/index.d.ts +47 -1
  31. package/index.js +11 -2
  32. package/node/cli-help.js +1 -0
  33. package/node/cli.js +992 -975
  34. package/orchestration/list-structural-fallback.js +67 -9
  35. package/package.json +1 -1
  36. package/pipeline/diff-engine.js +130 -1
  37. package/pipeline/ingestion-export.js +227 -243
  38. package/pipeline/ingestion-paragraph.js +93 -98
  39. package/pipeline/list-generation.js +415 -419
  40. package/pipeline/pipeline.js +206 -204
  41. package/scripts/export-validation-fixtures.mjs +19 -6
  42. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  43. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  44. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  45. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  46. package/scripts/word-com-differential.ps1 +443 -335
  47. package/scripts/word-com-visual-suite.ps1 +2 -2
  48. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  49. package/services/batch-operation-orchestrator.js +23 -3
  50. package/services/capture-engine.js +228 -226
  51. package/services/document-operation-applier.js +213 -0
  52. package/services/document-operation-contract.js +75 -0
  53. package/services/document-operation-mutations.js +3346 -2721
  54. package/services/error-recovery.js +17 -0
  55. package/services/list-conversion-diagnostics.js +296 -0
  56. package/services/list-format-change.js +586 -0
  57. package/services/list-format-mutation.js +416 -0
  58. package/services/list-format.js +305 -0
  59. package/services/numbering-allocation.js +642 -0
  60. package/services/numbering-helpers.js +354 -524
  61. package/services/operation-batch-compiler.js +183 -11
  62. package/services/operation-preflight.js +1 -0
  63. package/services/receipt-collector.js +288 -288
  64. package/services/revision-comment-management.js +38 -5
  65. package/services/revision-fidelity-verifier.js +498 -0
  66. package/services/standalone-docx-plumbing.js +627 -631
  67. package/services/standalone-operation-runner.d.ts +182 -5
  68. 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)) {