@ansonlai/docx-redline-js 0.4.0 → 0.5.1

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 (104) hide show
  1. package/AGENTS.md +646 -288
  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/redline-validation.js +11 -5
  11. package/core/revision-cloning.js +38 -0
  12. package/core/types.js +64 -10
  13. package/core/word-xml.js +43 -15
  14. package/dist/docx-redline-js.esm.js +3145 -505
  15. package/dist/docx-redline-js.esm.js.map +4 -4
  16. package/dist/docx-redline-js.esm.min.js +88 -76
  17. package/dist/docx-redline-js.esm.min.js.map +4 -4
  18. package/docs/TESTING.md +342 -23
  19. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  20. package/docs/plans/2026-09-08-cross-author-revision-slicing.md +505 -0
  21. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  22. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  23. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  24. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  25. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  26. package/docs/schemas/document-operations.schema.json +109 -0
  27. package/docs/test-comparison-dashboard.html +4250 -7
  28. package/engine/formatting-removal.js +11 -2
  29. package/engine/oxml-engine.js +508 -336
  30. package/engine/reconstruction-mode.js +15 -14
  31. package/engine/reconstruction-writer.js +247 -142
  32. package/engine/route-selection.js +35 -0
  33. package/engine/rpr-helpers.js +334 -35
  34. package/engine/run-builders.js +239 -196
  35. package/engine/surgical-diff-application.js +407 -50
  36. package/engine/surgical-mode.js +142 -6
  37. package/engine/surgical-run-splitting.js +103 -0
  38. package/engine/surgical-spans.js +52 -1
  39. package/engine/table-cell-context.js +3 -6
  40. package/engine/table-mode.js +1 -1
  41. package/index.d.ts +234 -6
  42. package/index.js +24 -1
  43. package/node/cli.js +322 -0
  44. package/node/docx-document.js +302 -0
  45. package/node/index.d.ts +31 -0
  46. package/node/index.js +2 -0
  47. package/node/zip-archive.js +52 -0
  48. package/orchestration/list-markdown.js +10 -16
  49. package/orchestration/list-parsing.js +7 -12
  50. package/orchestration/list-structural-fallback.js +21 -10
  51. package/package.json +123 -102
  52. package/pipeline/content-analysis.js +12 -17
  53. package/pipeline/ingestion-export.js +3 -31
  54. package/pipeline/ingestion-paragraph.js +10 -5
  55. package/pipeline/list-generation.js +150 -55
  56. package/pipeline/list-markers.js +70 -3
  57. package/pipeline/serialization.js +4 -2
  58. package/pipeline/structured-content.js +160 -0
  59. package/scripts/apply_changes.mjs +27 -0
  60. package/scripts/benchmark-operation-session.mjs +137 -0
  61. package/scripts/benchmark-targeting-browser.html +74 -0
  62. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  63. package/scripts/benchmark-test-runner.mjs +59 -0
  64. package/scripts/build-test-dashboard.mjs +23 -0
  65. package/scripts/export-lane1-fixtures.mjs +380 -0
  66. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  67. package/scripts/export-validation-fixtures.mjs +1 -1
  68. package/scripts/extract_text.mjs +7 -0
  69. package/scripts/generate-cross-author-slicing-fixtures.ps1 +256 -0
  70. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  71. package/scripts/generate-test-dashboard.mjs +362 -11
  72. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  73. package/scripts/profile-route-selection.mjs +19 -0
  74. package/scripts/render-agenda-multilevel.mjs +0 -5
  75. package/scripts/render-multilevel-cases.mjs +0 -1
  76. package/scripts/run-tests.mjs +107 -35
  77. package/scripts/word-com-corpus-suite.ps1 +3 -0
  78. package/scripts/word-com-differential.ps1 +64 -4
  79. package/scripts/word-com-suite.ps1 +3 -0
  80. package/services/batch-operation-orchestrator.js +513 -0
  81. package/services/capture-engine.js +226 -0
  82. package/services/comment-builders.js +23 -6
  83. package/services/comment-engine.js +108 -47
  84. package/services/comment-locator.js +187 -82
  85. package/services/comment-replies.js +95 -0
  86. package/services/document-inspection.js +258 -0
  87. package/services/document-operation-applier.js +372 -0
  88. package/services/document-operation-contract.js +345 -0
  89. package/services/document-operation-mutations.js +1749 -0
  90. package/services/document-operation-session.js +258 -0
  91. package/services/numbering-service.js +14 -5
  92. package/services/operation-heuristics.js +173 -0
  93. package/services/operation-preflight.js +390 -0
  94. package/services/receipt-collector.js +288 -0
  95. package/services/revision-comment-management.js +77 -5
  96. package/services/revision-token.js +290 -0
  97. package/services/standalone-docx-plumbing.js +123 -8
  98. package/services/standalone-operation-runner.d.ts +296 -0
  99. package/services/standalone-operation-runner.js +10 -1455
  100. package/services/table-reconciliation.js +15 -6
  101. package/docs/VALIDATION.md +0 -183
  102. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  103. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  104. /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Public document-operation normalization and validation.
3
+ *
4
+ * Keep compatibility aliases at this boundary so the runner and preflight
5
+ * logic can operate on one internal shape.
6
+ */
7
+
8
+ const SUPPORTED_OPERATION_TYPES = new Set([
9
+ 'redline',
10
+ 'replace',
11
+ 'format',
12
+ 'character-format',
13
+ 'paragraph-format',
14
+ 'list-change',
15
+ 'table-reconciliation',
16
+ 'insert',
17
+ 'delete',
18
+ 'comment',
19
+ 'comment_reply',
20
+ 'highlight'
21
+ ]);
22
+
23
+ export const EXISTING_REVISIONS_POLICIES = Object.freeze([
24
+ 'merge-same-author',
25
+ 'slice-cross-author',
26
+ 'reject-input',
27
+ 'accept-all-first',
28
+ 'accept-all-first-keep-normalized'
29
+ ]);
30
+
31
+ export function isExistingRevisionsPolicy(value) {
32
+ return EXISTING_REVISIONS_POLICIES.includes(value);
33
+ }
34
+
35
+ function isRecord(value) {
36
+ return !!value && typeof value === 'object' && !Array.isArray(value);
37
+ }
38
+
39
+ function nonEmptyString(value) {
40
+ return typeof value === 'string' && value.trim().length > 0;
41
+ }
42
+
43
+ export function getCanonicalOperationType(operation) {
44
+ const type = operation?.type;
45
+ if (type === 'comment' || type === 'comment_reply' || type === 'highlight') return type;
46
+ if (type === 'paragraph-format') return 'paragraph-format';
47
+ if (type === 'character-format' || (type === 'format' && (operation?.textToFormat != null || operation?.properties != null))) return 'format';
48
+ return 'redline';
49
+ }
50
+
51
+ export function normalizeTargetDescriptor(target, legacyTargetRef = null) {
52
+ if (!isRecord(target)) {
53
+ return {
54
+ text: typeof target === 'string' ? target : '',
55
+ index: legacyTargetRef ?? null,
56
+ paragraphId: null,
57
+ occurrence: null,
58
+ inTable: null,
59
+ fingerprint: null,
60
+ revisionView: 'accepted'
61
+ };
62
+ }
63
+
64
+ return {
65
+ text: typeof target.exactText === 'string'
66
+ ? target.exactText
67
+ : (typeof target.text === 'string' ? target.text : ''),
68
+ index: target.index ?? target.paragraphIndex ?? legacyTargetRef ?? null,
69
+ paragraphId: nonEmptyString(target.paragraphId) ? target.paragraphId.trim() : null,
70
+ occurrence: Number.isInteger(target.occurrence) && target.occurrence > 0
71
+ ? target.occurrence
72
+ : null,
73
+ inTable: typeof target.inTable === 'boolean' ? target.inTable : null,
74
+ fingerprint: nonEmptyString(target.fingerprint)
75
+ ? target.fingerprint.trim()
76
+ : (nonEmptyString(target.sourceFingerprint) ? target.sourceFingerprint.trim() : null),
77
+ revisionView: target.revisionView === 'rejected' ? 'rejected' : 'accepted',
78
+ captureRef: nonEmptyString(target.captureRef) ? target.captureRef.trim() : null,
79
+ select: typeof target.select === 'string' ? target.select : null
80
+ };
81
+ }
82
+
83
+ export function normalizeDocumentOperation(operation) {
84
+ const source = isRecord(operation) ? operation : {};
85
+ const targetDescriptor = normalizeTargetDescriptor(source.target, source.targetRef);
86
+ const targetEndDescriptor = isRecord(source.targetEnd)
87
+ ? normalizeTargetDescriptor(source.targetEnd, source.targetEndRef)
88
+ : null;
89
+ const kind = getCanonicalOperationType(source);
90
+
91
+ return {
92
+ ...source,
93
+ operationId: nonEmptyString(source.operationId) ? source.operationId.trim() : null,
94
+ captureKey: nonEmptyString(source.captureKey) ? source.captureKey.trim() : null,
95
+ operationKind: kind,
96
+ targetDescriptor,
97
+ target: targetDescriptor.text,
98
+ targetRef: targetDescriptor.index,
99
+ targetEndRef: targetEndDescriptor?.index ?? source.targetEndRef ?? null,
100
+ ...(source.type === 'delete' && source.modified == null ? { modified: '' } : {})
101
+ };
102
+ }
103
+
104
+ export function validateDocumentOperation(operation) {
105
+ if (!isRecord(operation)) {
106
+ return {
107
+ valid: false,
108
+ error: { code: 'INVALID_OPERATION', message: 'Operation must be an object.' }
109
+ };
110
+ }
111
+
112
+ const rawType = operation.type;
113
+ if (rawType != null && rawType !== '' && !SUPPORTED_OPERATION_TYPES.has(rawType)) {
114
+ return {
115
+ valid: false,
116
+ error: {
117
+ code: 'INVALID_OPERATION',
118
+ message: `Unsupported operation type: "${String(rawType)}".`
119
+ }
120
+ };
121
+ }
122
+
123
+ const normalized = normalizeDocumentOperation(operation);
124
+ if (isRecord(operation.target) && operation.target.revisionView != null) {
125
+ if (operation.target.revisionView !== 'accepted' && operation.target.revisionView !== 'rejected') {
126
+ return {
127
+ valid: false,
128
+ error: {
129
+ code: 'INVALID_OPERATION',
130
+ message: 'Target revisionView must be "accepted" or "rejected" when provided.'
131
+ }
132
+ };
133
+ }
134
+ }
135
+
136
+ if (isRecord(operation.targetEnd) && operation.targetEnd.revisionView != null) {
137
+ if (operation.targetEnd.revisionView !== 'accepted' && operation.targetEnd.revisionView !== 'rejected') {
138
+ return {
139
+ valid: false,
140
+ error: {
141
+ code: 'INVALID_OPERATION',
142
+ message: 'Target revisionView must be "accepted" or "rejected" when provided.'
143
+ }
144
+ };
145
+ }
146
+ }
147
+
148
+ if (operation.operationId != null && (!nonEmptyString(operation.operationId) || operation.operationId.trim().length > 256)) {
149
+ return {
150
+ valid: false,
151
+ error: { code: 'INVALID_OPERATION', message: 'operationId must be a non-empty string under 256 characters when provided.' }
152
+ };
153
+ }
154
+
155
+ if (operation.captureKey != null && (!nonEmptyString(operation.captureKey) || operation.captureKey.trim().length > 256)) {
156
+ return {
157
+ valid: false,
158
+ error: { code: 'INVALID_OPERATION', message: 'captureKey must be a non-empty string under 256 characters when provided.' }
159
+ };
160
+ }
161
+
162
+ if (isRecord(operation.target) && operation.target.captureRef != null && (!nonEmptyString(operation.target.captureRef) || operation.target.captureRef.trim().length > 256)) {
163
+ return {
164
+ valid: false,
165
+ error: { code: 'INVALID_OPERATION', message: 'target.captureRef must be a non-empty string under 256 characters when provided.' }
166
+ };
167
+ }
168
+
169
+ if (isRecord(operation.target) && operation.target.select != null && typeof operation.target.select !== 'string') {
170
+ return {
171
+ valid: false,
172
+ error: { code: 'INVALID_OPERATION', message: 'target.select must be a string when provided.' }
173
+ };
174
+ }
175
+
176
+ const target = normalized.targetDescriptor;
177
+ if (normalized.operationKind !== 'comment_reply' && !nonEmptyString(target.text) && target.index == null && !target.paragraphId && !target.captureRef) {
178
+ return {
179
+ valid: false,
180
+ error: {
181
+ code: 'INVALID_OPERATION',
182
+ message: 'Operation target must provide text, a paragraph index, a paragraphId, or a captureRef.'
183
+ }
184
+ };
185
+ }
186
+
187
+ if (target.occurrence != null && !nonEmptyString(target.text)) {
188
+ return {
189
+ valid: false,
190
+ error: {
191
+ code: 'INVALID_OPERATION',
192
+ message: 'Target occurrence requires target text.'
193
+ }
194
+ };
195
+ }
196
+
197
+ if (normalized.operationKind === 'redline' && typeof normalized.modified !== 'string') {
198
+ return {
199
+ valid: false,
200
+ error: { code: 'INVALID_OPERATION', message: 'Redline operations require a string "modified" field.' }
201
+ };
202
+ }
203
+
204
+ if (normalized.structuredContent != null && typeof normalized.structuredContent !== 'boolean') {
205
+ return {
206
+ valid: false,
207
+ error: { code: 'INVALID_OPERATION', message: 'structuredContent must be a boolean when provided.' }
208
+ };
209
+ }
210
+
211
+ if (normalized.pairReplacements != null && typeof normalized.pairReplacements !== 'boolean') {
212
+ return {
213
+ valid: false,
214
+ error: { code: 'INVALID_OPERATION', message: 'pairReplacements must be a boolean when provided.' }
215
+ };
216
+ }
217
+
218
+ if (normalized.existingRevisions != null && !isExistingRevisionsPolicy(normalized.existingRevisions)) {
219
+ return {
220
+ valid: false,
221
+ error: {
222
+ code: 'INVALID_OPERATION',
223
+ message: `existingRevisions must be one of: ${EXISTING_REVISIONS_POLICIES.join(', ')}.`
224
+ }
225
+ };
226
+ }
227
+
228
+ if (normalized.insertionAffinity != null) {
229
+ if (!isRecord(normalized.insertionAffinity)) {
230
+ return {
231
+ valid: false,
232
+ error: { code: 'INVALID_OPERATION', message: 'insertionAffinity must be an object when provided.' }
233
+ };
234
+ }
235
+ const { formatting, hyperlink, revision, bookmark, comment } = normalized.insertionAffinity;
236
+ if (formatting != null && !['left', 'right', 'none'].includes(formatting)) {
237
+ return {
238
+ valid: false,
239
+ error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.formatting must be "left", "right", or "none".' }
240
+ };
241
+ }
242
+ if (hyperlink != null && !['inside', 'outside', 'preserve'].includes(hyperlink)) {
243
+ return {
244
+ valid: false,
245
+ error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.hyperlink must be "inside", "outside", or "preserve".' }
246
+ };
247
+ }
248
+ if (revision != null && !['coalesce_same_author', 'separate'].includes(revision)) {
249
+ return {
250
+ valid: false,
251
+ error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.revision must be "coalesce_same_author" or "separate".' }
252
+ };
253
+ }
254
+ if (bookmark != null && !['inside', 'outside'].includes(bookmark)) {
255
+ return {
256
+ valid: false,
257
+ error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.bookmark must be "inside" or "outside".' }
258
+ };
259
+ }
260
+ if (comment != null && !['inside', 'outside'].includes(comment)) {
261
+ return {
262
+ valid: false,
263
+ error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.comment must be "inside" or "outside".' }
264
+ };
265
+ }
266
+ }
267
+
268
+ if (normalized.operationKind === 'comment') {
269
+ if (!nonEmptyString(normalized.commentContent)) {
270
+ return {
271
+ valid: false,
272
+ error: { code: 'INVALID_OPERATION', message: 'Comment operations require a non-empty "commentContent" field.' }
273
+ };
274
+ }
275
+ }
276
+
277
+ if (normalized.operationKind === 'comment_reply') {
278
+ if (!nonEmptyString(String(normalized.parentCommentId ?? ''))) {
279
+ return { valid: false, error: { code: 'INVALID_OPERATION', message: 'Comment reply operations require a "parentCommentId".' } };
280
+ }
281
+ if (!nonEmptyString(normalized.commentContent)) {
282
+ return { valid: false, error: { code: 'INVALID_OPERATION', message: 'Comment reply operations require a non-empty "commentContent" field.' } };
283
+ }
284
+ }
285
+
286
+ if (normalized.operationKind === 'highlight') {
287
+ if (!nonEmptyString(normalized.textToHighlight)) {
288
+ return {
289
+ valid: false,
290
+ error: { code: 'INVALID_OPERATION', message: 'Highlight operations require a non-empty "textToHighlight" field.' }
291
+ };
292
+ }
293
+ if (normalized.color != null && !nonEmptyString(normalized.color)) {
294
+ return {
295
+ valid: false,
296
+ error: { code: 'INVALID_OPERATION', message: 'Highlight color must be a non-empty string when provided.' }
297
+ };
298
+ }
299
+ }
300
+
301
+ if (normalized.operationKind === 'format') {
302
+ if (!nonEmptyString(normalized.textToFormat)) {
303
+ return {
304
+ valid: false,
305
+ error: { code: 'INVALID_OPERATION', message: 'Format operations require a non-empty "textToFormat" field.' }
306
+ };
307
+ }
308
+ if (!isRecord(normalized.properties)) {
309
+ return {
310
+ valid: false,
311
+ error: { code: 'INVALID_OPERATION', message: 'Format operations require a "properties" object.' }
312
+ };
313
+ }
314
+ if (normalized.formattingRevisionPolicy != null && !['always', 'coalesce-own-insertion'].includes(normalized.formattingRevisionPolicy)) {
315
+ return {
316
+ valid: false,
317
+ error: { code: 'INVALID_OPERATION', message: 'formattingRevisionPolicy must be "always" or "coalesce-own-insertion".' }
318
+ };
319
+ }
320
+ }
321
+
322
+ if (normalized.operationKind === 'paragraph-format') {
323
+ if (!isRecord(normalized.properties)) {
324
+ return {
325
+ valid: false,
326
+ error: { code: 'INVALID_OPERATION', message: 'Paragraph-format operations require a "properties" object.' }
327
+ };
328
+ }
329
+ }
330
+
331
+ if (normalized.author != null && !nonEmptyString(normalized.author)) {
332
+ return {
333
+ valid: false,
334
+ error: { code: 'INVALID_OPERATION', message: 'Operation author must be a non-empty string when provided.' }
335
+ };
336
+ }
337
+
338
+ return { valid: true, operation: normalized };
339
+ }
340
+
341
+ export function resolveDocumentOperationAuthor(operation, batchAuthor, fallbackAuthor) {
342
+ if (nonEmptyString(operation?.author)) return operation.author.trim();
343
+ if (nonEmptyString(batchAuthor)) return batchAuthor.trim();
344
+ return nonEmptyString(fallbackAuthor) ? fallbackAuthor.trim() : 'Author';
345
+ }