@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.
@@ -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 baselineErrors = new Set(baseline.issues.filter(i => i.severity === 'error').map(i => `${i.code}:${i.message}`));
182
- const introduced = generated.issues.filter(i => i.severity === 'error' && !baselineErrors.has(`${i.code}:${i.message}`));
183
- if (introduced.length) {
184
- const codes = [...new Set(introduced.map(issue => issue.code))].join(', ');
185
- throw Object.assign(
186
- new Error(`Applied operations introduced invalid revision markup (${codes}); these are generated-output issues, not pre-existing input issues.`),
187
- { issues: introduced }
188
- );
189
- }
190
- await validateDocxPackage(zip);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ansonlai/docx-redline-js",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- applyToParagraphByExactText
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 (operation.operationKind !== 'comment_reply' && operation.targetDescriptor?.revisionView === 'rejected') {
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
- session.markMutationCommitted(operation.operationKind !== 'comment_reply');
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 = isRecord(source.targetEnd)
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 (normalized.operationKind !== 'comment_reply' && !nonEmptyString(target.text) && target.index == null && !target.paragraphId && !target.captureRef) {
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,