@ansonlai/docx-redline-js 0.5.2 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +67 -12
- package/ARCHITECTURE.md +38 -3
- package/CHANGELOG.md +19 -1
- package/README.md +52 -12
- package/core/paragraph-revision-safety.js +215 -0
- package/core/paragraph-targeting.js +19 -0
- package/core/redline-validation.js +20 -4
- package/core/revision-cloning.js +21 -0
- package/core/validation-delta.js +23 -0
- package/dist/docx-redline-js.esm.js +493 -99
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +84 -84
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/plans/2026-09-08-cross-author-revision-slicing.md +861 -37
- package/docs/schemas/document-operations.schema.json +14 -1
- package/engine/oxml-engine.js +24 -3
- package/engine/surgical-diff-application.js +35 -1
- package/engine/surgical-mode.js +203 -4
- package/engine/surgical-run-splitting.js +19 -7
- package/engine/surgical-spans.js +2 -1
- package/index.d.ts +12 -0
- package/node/cli.js +128 -13
- package/node/docx-document.js +17 -14
- package/package.json +1 -1
- package/pipeline/diff-engine.js +15 -0
- package/services/document-operation-applier.js +73 -9
- package/services/document-operation-contract.js +77 -2
- package/services/document-operation-mutations.js +846 -49
- package/services/operation-preflight.js +72 -8
- package/services/standalone-operation-runner.d.ts +26 -0
package/node/docx-document.js
CHANGED
|
@@ -5,7 +5,8 @@ import { inspectDocumentParts } from '../services/document-inspection.js';
|
|
|
5
5
|
import { applyOperationsToDocumentXml, preflightOperations } from '../services/standalone-operation-runner.js';
|
|
6
6
|
import { createDynamicNumberingIdState, mergeNumberingXmlBySchemaOrder } from '../services/numbering-helpers.js';
|
|
7
7
|
import { ensureCommentsArtifactsInZip, ensureCommentsExtendedArtifactsInZip, ensureNumberingArtifactsInZip, validateDocxPackage } from '../services/standalone-docx-plumbing.js';
|
|
8
|
-
import { validateRedlineOoxml } from '../core/redline-validation.js';
|
|
8
|
+
import { validateRedlineOoxml } from '../core/redline-validation.js';
|
|
9
|
+
import { subtractValidationIssueMultiset, validationErrors } from '../core/validation-delta.js';
|
|
9
10
|
import { acceptTrackedChangesInOoxml, rejectTrackedChangesInOoxml, deleteCommentsByAuthorInOoxml } from '../services/revision-comment-management.js';
|
|
10
11
|
import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
11
12
|
import { createHash } from 'node:crypto';
|
|
@@ -176,19 +177,21 @@ export class DocxDocument {
|
|
|
176
177
|
await ensureCommentsExtendedArtifactsInZip(zip, commentsExtendedXmlForPackaging, {
|
|
177
178
|
replaceExisting: result.commentsExtendedXmlMode === 'replace' || (!result.commentsExtendedXml && !!existingCommentsExtendedXml)
|
|
178
179
|
});
|
|
179
|
-
if (options.validate !== false) {
|
|
180
|
-
const generated = validateRedlineOoxml(result.documentXml);
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
180
|
+
if (options.validate !== false) {
|
|
181
|
+
const generated = validateRedlineOoxml(result.documentXml);
|
|
182
|
+
const outputIssues = generated.issues.map(issue => ({ source: 'word/document.xml', ...issue }));
|
|
183
|
+
try { await validateDocxPackage(zip); }
|
|
184
|
+
catch (error) { outputIssues.push({ source: 'package', code: 'PACKAGE_VALIDATION', severity: 'error', message: error.message }); }
|
|
185
|
+
const introduced = subtractValidationIssueMultiset(outputIssues, originalIssues);
|
|
186
|
+
const introducedErrors = validationErrors(introduced);
|
|
187
|
+
if (introducedErrors.length) {
|
|
188
|
+
const codes = [...new Set(introducedErrors.map(issue => issue.code))].join(', ');
|
|
189
|
+
throw Object.assign(
|
|
190
|
+
new Error(`Applied operations introduced invalid revision markup (${codes}); these are generated-output issues, not pre-existing input issues.`),
|
|
191
|
+
{ issues: introducedErrors }
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
192
195
|
this.entries = working;
|
|
193
196
|
const output = this.toBuffer();
|
|
194
197
|
this.originalBuffer = Buffer.from(output);
|
package/package.json
CHANGED
package/pipeline/diff-engine.js
CHANGED
|
@@ -232,6 +232,21 @@ export function computeWordDiffs(originalText, newText, options = {}) {
|
|
|
232
232
|
return decodeBmpDiffs(charDiffs, wordArray);
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Computes a character-local diff without semantic cleanup. This is used to
|
|
237
|
+
* refine whitespace-only substitutions that a word-level token groups with
|
|
238
|
+
* adjacent unchanged content (for example, an NBSP beside a hyperlink).
|
|
239
|
+
*
|
|
240
|
+
* @param {string} originalText
|
|
241
|
+
* @param {string} newText
|
|
242
|
+
* @param {{ diffTimeoutSeconds?: number }} [options={}]
|
|
243
|
+
* @returns {Array<[number, string]>}
|
|
244
|
+
*/
|
|
245
|
+
export function computeCharacterDiffs(originalText, newText, options = {}) {
|
|
246
|
+
if (originalText === newText) return [[0, originalText]];
|
|
247
|
+
return createDiffEngine(options).diff_main(originalText, newText);
|
|
248
|
+
}
|
|
249
|
+
|
|
235
250
|
/**
|
|
236
251
|
* Returns a character-local diff only when the modified string can be made
|
|
237
252
|
* solely by inserting into the original. This prevents word-token cleanup
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getDefaultAuthor } from '../adapters/config.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
3
|
normalizeDocumentOperation,
|
|
4
4
|
resolveDocumentOperationAuthor,
|
|
5
5
|
validateDocumentOperation
|
|
@@ -14,8 +14,10 @@ import {
|
|
|
14
14
|
applyCommentToParagraphByExactText,
|
|
15
15
|
applyFormattingToParagraphByExactText,
|
|
16
16
|
applyHighlightToParagraphByExactText,
|
|
17
|
-
applyParagraphFormatToParagraphByExactText,
|
|
18
|
-
|
|
17
|
+
applyParagraphFormatToParagraphByExactText,
|
|
18
|
+
insertIntoRejectedDeletedText,
|
|
19
|
+
restoreDeletedParagraphByExactText,
|
|
20
|
+
applyToParagraphByExactText
|
|
19
21
|
} from './document-operation-mutations.js';
|
|
20
22
|
import { applyCommentReplyToParts } from './comment-replies.js';
|
|
21
23
|
import {
|
|
@@ -25,7 +27,9 @@ import {
|
|
|
25
27
|
import {
|
|
26
28
|
createEmptyReceipt,
|
|
27
29
|
reconcileReceiptsAgainstOutput
|
|
28
|
-
} from './receipt-collector.js';
|
|
30
|
+
} from './receipt-collector.js';
|
|
31
|
+
import { validateRedlineOoxml } from '../core/redline-validation.js';
|
|
32
|
+
import { subtractValidationIssueMultiset, validationErrors } from '../core/validation-delta.js';
|
|
29
33
|
|
|
30
34
|
export function normalizeOperationError(error) {
|
|
31
35
|
return {
|
|
@@ -58,7 +62,15 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
58
62
|
const operation = validation.operation || normalizeDocumentOperation(op);
|
|
59
63
|
const authorUsed = resolveDocumentOperationAuthor(operation, author, getDefaultAuthor());
|
|
60
64
|
|
|
61
|
-
if (
|
|
65
|
+
if (
|
|
66
|
+
operation.operationKind !== 'comment_reply'
|
|
67
|
+
&& operation.operationKind !== 'rejected-insert'
|
|
68
|
+
&& operation.operationKind !== 'restore'
|
|
69
|
+
&& (
|
|
70
|
+
operation.targetDescriptor?.revisionView === 'rejected'
|
|
71
|
+
|| operation.targetEndDescriptor?.revisionView === 'rejected'
|
|
72
|
+
)
|
|
73
|
+
) {
|
|
62
74
|
return {
|
|
63
75
|
documentXml,
|
|
64
76
|
hasChanges: false,
|
|
@@ -159,7 +171,8 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
159
171
|
pairReplacements: typeof operation.pairReplacements === 'boolean' ? operation.pairReplacements : (options.pairReplacements !== false),
|
|
160
172
|
...(operation.insertionAffinity ? { insertionAffinity: operation.insertionAffinity } : {}),
|
|
161
173
|
...(operation.formattingRevisionPolicy ? { formattingRevisionPolicy: operation.formattingRevisionPolicy } : {}),
|
|
162
|
-
targetDescriptor: operation.targetDescriptor,
|
|
174
|
+
targetDescriptor: operation.targetDescriptor,
|
|
175
|
+
targetEndDescriptor: operation.targetEndDescriptor,
|
|
163
176
|
_resolutionCapture: resolutionCapture,
|
|
164
177
|
_revisionIdAllocator: session.revisionIdAllocator,
|
|
165
178
|
_documentOperationSession: session,
|
|
@@ -242,7 +255,29 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
242
255
|
runtimeContext,
|
|
243
256
|
operationOptions
|
|
244
257
|
);
|
|
245
|
-
} else {
|
|
258
|
+
} else if (operation.operationKind === 'rejected-insert') {
|
|
259
|
+
result = await insertIntoRejectedDeletedText(
|
|
260
|
+
documentXml,
|
|
261
|
+
operation.target,
|
|
262
|
+
operation.anchor,
|
|
263
|
+
operation.modified,
|
|
264
|
+
authorUsed,
|
|
265
|
+
operation.targetRef,
|
|
266
|
+
runtimeContext,
|
|
267
|
+
operationOptions
|
|
268
|
+
);
|
|
269
|
+
} else if (operation.operationKind === 'restore') {
|
|
270
|
+
result = await restoreDeletedParagraphByExactText(
|
|
271
|
+
documentXml,
|
|
272
|
+
operation.target,
|
|
273
|
+
operation.modified,
|
|
274
|
+
authorUsed,
|
|
275
|
+
operation.targetRef,
|
|
276
|
+
operation.targetEndRef,
|
|
277
|
+
runtimeContext,
|
|
278
|
+
operationOptions
|
|
279
|
+
);
|
|
280
|
+
} else {
|
|
246
281
|
result = await applyToParagraphByExactText(
|
|
247
282
|
documentXml,
|
|
248
283
|
operation.target,
|
|
@@ -292,8 +327,37 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
292
327
|
operationReceipt.warnings.push(String(w));
|
|
293
328
|
}
|
|
294
329
|
}
|
|
295
|
-
} else {
|
|
296
|
-
|
|
330
|
+
} else {
|
|
331
|
+
const beforeValidation = validateRedlineOoxml(savepoint.document);
|
|
332
|
+
const afterValidation = validateRedlineOoxml(session.document);
|
|
333
|
+
const generatedIssues = subtractValidationIssueMultiset(afterValidation.issues, beforeValidation.issues);
|
|
334
|
+
const generatedErrors = validationErrors(generatedIssues);
|
|
335
|
+
if (generatedErrors.length > 0) {
|
|
336
|
+
session.restoreSavepoint(savepoint);
|
|
337
|
+
operationReceipt = createEmptyReceipt(
|
|
338
|
+
operationIndex,
|
|
339
|
+
operation.operationId,
|
|
340
|
+
authorUsed,
|
|
341
|
+
'refused'
|
|
342
|
+
);
|
|
343
|
+
const codes = [...new Set(generatedErrors.map(issue => issue.code))].join(', ');
|
|
344
|
+
return {
|
|
345
|
+
documentXml,
|
|
346
|
+
hasChanges: false,
|
|
347
|
+
status: 'error',
|
|
348
|
+
error: {
|
|
349
|
+
code: 'GENERATED_OOXML_INVALID',
|
|
350
|
+
stage: 'validation',
|
|
351
|
+
message: `Operation introduced invalid OOXML (${codes}).`,
|
|
352
|
+
generatedIssues: generatedErrors
|
|
353
|
+
},
|
|
354
|
+
operationType: operation.operationKind,
|
|
355
|
+
authorUsed,
|
|
356
|
+
receipt: operationReceipt,
|
|
357
|
+
...resolutionCapture
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
session.markMutationCommitted(operation.operationKind !== 'comment_reply');
|
|
297
361
|
if (operation.captureKey && session.captureTable) {
|
|
298
362
|
session.captureTable.set(
|
|
299
363
|
operation.captureKey,
|
|
@@ -14,6 +14,7 @@ const SUPPORTED_OPERATION_TYPES = new Set([
|
|
|
14
14
|
'list-change',
|
|
15
15
|
'table-reconciliation',
|
|
16
16
|
'insert',
|
|
17
|
+
'restore',
|
|
17
18
|
'delete',
|
|
18
19
|
'comment',
|
|
19
20
|
'comment_reply',
|
|
@@ -42,6 +43,8 @@ function nonEmptyString(value) {
|
|
|
42
43
|
|
|
43
44
|
export function getCanonicalOperationType(operation) {
|
|
44
45
|
const type = operation?.type;
|
|
46
|
+
if (type === 'insert' && operation?.target?.revisionView === 'rejected') return 'rejected-insert';
|
|
47
|
+
if (type === 'restore') return 'restore';
|
|
45
48
|
if (type === 'comment' || type === 'comment_reply' || type === 'highlight') return type;
|
|
46
49
|
if (type === 'paragraph-format') return 'paragraph-format';
|
|
47
50
|
if (type === 'character-format' || (type === 'format' && (operation?.textToFormat != null || operation?.properties != null))) return 'format';
|
|
@@ -83,7 +86,7 @@ export function normalizeTargetDescriptor(target, legacyTargetRef = null) {
|
|
|
83
86
|
export function normalizeDocumentOperation(operation) {
|
|
84
87
|
const source = isRecord(operation) ? operation : {};
|
|
85
88
|
const targetDescriptor = normalizeTargetDescriptor(source.target, source.targetRef);
|
|
86
|
-
const targetEndDescriptor =
|
|
89
|
+
const targetEndDescriptor = source.targetEnd != null
|
|
87
90
|
? normalizeTargetDescriptor(source.targetEnd, source.targetEndRef)
|
|
88
91
|
: null;
|
|
89
92
|
const kind = getCanonicalOperationType(source);
|
|
@@ -93,7 +96,14 @@ export function normalizeDocumentOperation(operation) {
|
|
|
93
96
|
operationId: nonEmptyString(source.operationId) ? source.operationId.trim() : null,
|
|
94
97
|
captureKey: nonEmptyString(source.captureKey) ? source.captureKey.trim() : null,
|
|
95
98
|
operationKind: kind,
|
|
99
|
+
anchor: isRecord(source.anchor) ? {
|
|
100
|
+
exactText: typeof source.anchor.exactText === 'string' ? source.anchor.exactText : '',
|
|
101
|
+
occurrence: Number.isInteger(source.anchor.occurrence) && source.anchor.occurrence > 0 ? source.anchor.occurrence : 1,
|
|
102
|
+
occurrenceExplicit: Number.isInteger(source.anchor.occurrence) && source.anchor.occurrence > 0,
|
|
103
|
+
offset: Number.isInteger(source.anchor.offset) ? source.anchor.offset : null
|
|
104
|
+
} : null,
|
|
96
105
|
targetDescriptor,
|
|
106
|
+
targetEndDescriptor,
|
|
97
107
|
target: targetDescriptor.text,
|
|
98
108
|
targetRef: targetDescriptor.index,
|
|
99
109
|
targetEndRef: targetEndDescriptor?.index ?? source.targetEndRef ?? null,
|
|
@@ -174,7 +184,14 @@ export function validateDocumentOperation(operation) {
|
|
|
174
184
|
}
|
|
175
185
|
|
|
176
186
|
const target = normalized.targetDescriptor;
|
|
177
|
-
if (
|
|
187
|
+
if (
|
|
188
|
+
normalized.operationKind !== 'comment_reply'
|
|
189
|
+
&& !nonEmptyString(target.text)
|
|
190
|
+
&& target.index == null
|
|
191
|
+
&& !target.paragraphId
|
|
192
|
+
&& !target.fingerprint
|
|
193
|
+
&& !target.captureRef
|
|
194
|
+
) {
|
|
178
195
|
return {
|
|
179
196
|
valid: false,
|
|
180
197
|
error: {
|
|
@@ -201,6 +218,64 @@ export function validateDocumentOperation(operation) {
|
|
|
201
218
|
};
|
|
202
219
|
}
|
|
203
220
|
|
|
221
|
+
if (normalized.operationKind === 'rejected-insert') {
|
|
222
|
+
if (!nonEmptyString(normalized.modified)) {
|
|
223
|
+
return {
|
|
224
|
+
valid: false,
|
|
225
|
+
error: { code: 'INVALID_OPERATION', message: 'Rejected-view insert operations require non-empty string "modified" text.' }
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (
|
|
229
|
+
!normalized.anchor
|
|
230
|
+
|| !nonEmptyString(normalized.anchor.exactText)
|
|
231
|
+
|| !Number.isInteger(normalized.anchor.offset)
|
|
232
|
+
|| normalized.anchor.offset < 0
|
|
233
|
+
|| normalized.anchor.offset > normalized.anchor.exactText.length
|
|
234
|
+
) {
|
|
235
|
+
return {
|
|
236
|
+
valid: false,
|
|
237
|
+
error: {
|
|
238
|
+
code: 'INVALID_OPERATION',
|
|
239
|
+
message: 'Rejected-view insert operations require anchor.exactText, a positive occurrence, and an offset within the anchor text.'
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (normalized.existingRevisions !== 'slice-cross-author') {
|
|
244
|
+
return {
|
|
245
|
+
valid: false,
|
|
246
|
+
error: {
|
|
247
|
+
code: 'INVALID_OPERATION',
|
|
248
|
+
message: 'Rejected-view insert operations require existingRevisions: "slice-cross-author".'
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (normalized.operationKind === 'restore') {
|
|
255
|
+
const validSingle = nonEmptyString(normalized.modified);
|
|
256
|
+
const validRange = Array.isArray(normalized.modified)
|
|
257
|
+
&& normalized.modified.length > 0
|
|
258
|
+
&& normalized.modified.every(nonEmptyString);
|
|
259
|
+
if (!validSingle && !validRange) {
|
|
260
|
+
return {
|
|
261
|
+
valid: false,
|
|
262
|
+
error: {
|
|
263
|
+
code: 'INVALID_OPERATION',
|
|
264
|
+
message: 'Restore operations require a non-empty string or non-empty string array in "modified".'
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
if (normalized.generateRedlines === false) {
|
|
269
|
+
return {
|
|
270
|
+
valid: false,
|
|
271
|
+
error: {
|
|
272
|
+
code: 'INVALID_OPERATION',
|
|
273
|
+
message: 'Restore operations require tracked changes and cannot set generateRedlines to false.'
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
204
279
|
if (normalized.structuredContent != null && typeof normalized.structuredContent !== 'boolean') {
|
|
205
280
|
return {
|
|
206
281
|
valid: false,
|