@ansonlai/docx-redline-js 0.5.3 → 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.
@@ -28,6 +28,16 @@
28
28
  "additionalProperties": false
29
29
  },
30
30
  "operationArray": { "type": "array", "items": { "$ref": "#/$defs/operation" } },
31
+ "rejectedTextInsertionAnchor": {
32
+ "type": "object",
33
+ "required": ["exactText", "offset"],
34
+ "properties": {
35
+ "exactText": { "type": "string", "minLength": 1 },
36
+ "occurrence": { "type": "integer", "minimum": 1 },
37
+ "offset": { "type": "integer", "minimum": 0 }
38
+ },
39
+ "additionalProperties": false
40
+ },
31
41
  "target": {
32
42
  "oneOf": [
33
43
  { "type": "string", "minLength": 1 },
@@ -86,7 +96,7 @@
86
96
  "additionalProperties": false
87
97
  },
88
98
  {
89
- "allOf": [ { "$ref": "#/$defs/base" }, { "type": "object", "required": ["modified"], "properties": { "type": { "enum": ["redline", "replace", "format", "list-change", "table-reconciliation", "insert"] }, "modified": { "type": "string" }, "structuredContent": { "type": "boolean" }, "targetEnd": { "$ref": "#/$defs/target" }, "targetEndRef": { "type": ["integer", "string", "null"] } } } ]
99
+ "allOf": [ { "$ref": "#/$defs/base" }, { "type": "object", "required": ["modified"], "properties": { "type": { "enum": ["redline", "replace", "format", "list-change", "table-reconciliation", "insert"] }, "modified": { "type": "string" }, "structuredContent": { "type": "boolean" }, "targetEnd": { "$ref": "#/$defs/target" }, "targetEndRef": { "type": ["integer", "string", "null"] }, "anchor": { "$ref": "#/$defs/rejectedTextInsertionAnchor" } } } ]
90
100
  },
91
101
  {
92
102
  "allOf": [ { "$ref": "#/$defs/base" }, { "type": "object", "required": ["modified"], "properties": { "type": { "const": "restore" }, "modified": { "oneOf": [ { "type": "string", "minLength": 1 }, { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } } ] }, "targetEnd": { "$ref": "#/$defs/target" }, "targetEndRef": { "type": ["integer", "string", "null"] } } } ]
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { getApplicableFormatHints } from '../pipeline/markdown-processor.js';
9
- import { computeInsertionOnlyDiffs, computeWordDiffs } from '../pipeline/diff-engine.js';
9
+ import { computeCharacterDiffs, computeInsertionOnlyDiffs, computeWordDiffs } from '../pipeline/diff-engine.js';
10
10
  import { getDocumentParagraphs } from './format-extraction.js';
11
11
  import { buildSpanIndex, buildSurgicalTextSpans, forEachOverlappingSpan } from './surgical-spans.js';
12
12
  import {
@@ -115,7 +115,10 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
115
115
  const insertionOnlyDiffs = options.existingRevisions === 'slice-cross-author'
116
116
  ? computeInsertionOnlyDiffs(fullText, modifiedText)
117
117
  : null;
118
- const diffs = insertionOnlyDiffs || computeWordDiffs(fullText, modifiedText, diffOptions);
118
+ const diffs = insertionOnlyDiffs || refineSpaceEquivalentReplacements(
119
+ computeWordDiffs(fullText, modifiedText, diffOptions),
120
+ diffOptions
121
+ );
119
122
  const spanIndex = buildSpanIndex(textSpans);
120
123
  const pairReplacements = options.pairReplacements === true;
121
124
  const warnings = [];
@@ -157,6 +160,84 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
157
160
  }
158
161
  if (insertResult === true) hasChanges = true;
159
162
  }
163
+ } else if (formatHints.length === 0) {
164
+ const editOperations = collectTextEditOperations(diffs);
165
+ for (const operation of editOperations.reverse()) {
166
+ const liveSpanIndex = buildSpanIndex(buildSurgicalTextSpans(allParagraphs).textSpans);
167
+ if (operation.type === 'insert') {
168
+ const inserted = processInsert(
169
+ xmlDoc,
170
+ liveSpanIndex,
171
+ operation.start,
172
+ operation.text.replace(/\n/g, ' '),
173
+ author,
174
+ formatHints,
175
+ operation.newPos,
176
+ generateRedlines,
177
+ allParagraphs[0] || null,
178
+ null,
179
+ options?.insertionAffinity || null,
180
+ options?.existingRevisions || 'merge-same-author'
181
+ );
182
+ if (inserted && typeof inserted === 'object' && inserted.error) {
183
+ return withOoxmlSourceType({
184
+ oxml: serializer.serializeToString(xmlDoc),
185
+ hasChanges: false,
186
+ status: 'error',
187
+ error: inserted.error
188
+ });
189
+ }
190
+ if (inserted === true) hasChanges = true;
191
+ continue;
192
+ }
193
+
194
+ let delMetadata = null;
195
+ let insMetadata = null;
196
+ if (operation.type === 'replace' && pairReplacements && generateRedlines && operation.text.replace(/\n/g, ' ')) {
197
+ const checkResult = checkSafeAdjacencyForPairing(
198
+ liveSpanIndex,
199
+ operation.start,
200
+ operation.end,
201
+ options?.existingRevisions === 'slice-cross-author'
202
+ );
203
+ if (checkResult.safe) {
204
+ const event = createReplacementRevisionEvent(author, xmlDoc);
205
+ delMetadata = { id: event.deletionId, author: event.author, date: event.date };
206
+ insMetadata = { id: event.insertionId, author: event.author, date: event.date };
207
+ } else if (checkResult.structuralBoundary) {
208
+ warnings.push('PAIRING_SKIPPED_STRUCTURAL_BOUNDARY');
209
+ }
210
+ }
211
+
212
+ if (processDelete(xmlDoc, liveSpanIndex, operation.start, operation.end, author, generateRedlines, delMetadata)) {
213
+ hasChanges = true;
214
+ }
215
+ if (operation.type === 'replace') {
216
+ const inserted = processInsert(
217
+ xmlDoc,
218
+ liveSpanIndex,
219
+ operation.end,
220
+ operation.text.replace(/\n/g, ' '),
221
+ author,
222
+ formatHints,
223
+ operation.newPos,
224
+ generateRedlines,
225
+ allParagraphs[0] || null,
226
+ insMetadata,
227
+ options?.insertionAffinity || null,
228
+ options?.existingRevisions || 'merge-same-author'
229
+ );
230
+ if (inserted && typeof inserted === 'object' && inserted.error) {
231
+ return withOoxmlSourceType({
232
+ oxml: serializer.serializeToString(xmlDoc),
233
+ hasChanges: false,
234
+ status: 'error',
235
+ error: inserted.error
236
+ });
237
+ }
238
+ if (inserted === true) hasChanges = true;
239
+ }
240
+ }
160
241
  } else {
161
242
 
162
243
  for (let i = 0; i < diffs.length; i++) {
@@ -267,7 +348,9 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
267
348
  message: 'Generated OOXML accepted-view text does not match the requested modified text; the mutation was rejected.',
268
349
  mismatchOffset,
269
350
  expectedExcerpt: excerptAt(expectedText, mismatchOffset),
270
- actualExcerpt: excerptAt(actualText, mismatchOffset)
351
+ actualExcerpt: excerptAt(actualText, mismatchOffset),
352
+ expectedCodePoint: codePointAtOffset(expectedText, mismatchOffset),
353
+ actualCodePoint: codePointAtOffset(actualText, mismatchOffset)
271
354
  },
272
355
  ...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
273
356
  });
@@ -294,6 +377,68 @@ function excerptAt(text, offset, radius = 40) {
294
377
  return text.slice(start, end);
295
378
  }
296
379
 
380
+ function codePointAtOffset(text, offset) {
381
+ if (offset >= text.length) return 'END';
382
+ return `U+${text.codePointAt(offset).toString(16).toUpperCase().padStart(4, '0')}`;
383
+ }
384
+
385
+ function refineSpaceEquivalentReplacements(diffs, diffOptions) {
386
+ const refined = [];
387
+ for (let index = 0; index < diffs.length; index++) {
388
+ const [op, text] = diffs[index];
389
+ const next = diffs[index + 1];
390
+ if (
391
+ op === -1
392
+ && next?.[0] === 1
393
+ && text !== next[1]
394
+ && text.length === next[1].length
395
+ && text.replace(/\u00a0/g, ' ') === next[1].replace(/\u00a0/g, ' ')
396
+ ) {
397
+ refined.push(...computeCharacterDiffs(text, next[1], diffOptions));
398
+ index++;
399
+ continue;
400
+ }
401
+ refined.push([op, text]);
402
+ }
403
+ return refined;
404
+ }
405
+
406
+ function collectTextEditOperations(diffs) {
407
+ const operations = [];
408
+ let originalPos = 0;
409
+ let newPos = 0;
410
+ for (let index = 0; index < diffs.length; index++) {
411
+ const [op, text] = diffs[index];
412
+ if (op === 0) {
413
+ originalPos += text.length;
414
+ newPos += text.length;
415
+ continue;
416
+ }
417
+ if (op === -1) {
418
+ const next = diffs[index + 1];
419
+ if (next?.[0] === 1) {
420
+ operations.push({
421
+ type: 'replace',
422
+ start: originalPos,
423
+ end: originalPos + text.length,
424
+ newPos,
425
+ text: next[1]
426
+ });
427
+ originalPos += text.length;
428
+ newPos += next[1].length;
429
+ index++;
430
+ } else {
431
+ operations.push({ type: 'delete', start: originalPos, end: originalPos + text.length, newPos, text: '' });
432
+ originalPos += text.length;
433
+ }
434
+ continue;
435
+ }
436
+ operations.push({ type: 'insert', start: originalPos, end: originalPos, newPos, text });
437
+ newPos += text.length;
438
+ }
439
+ return operations;
440
+ }
441
+
297
442
  function collectInsertionOperations(diffs) {
298
443
  const operations = [];
299
444
  let originalPos = 0;
@@ -8,7 +8,7 @@ import {
8
8
  import { refreshRunPropertyChangeIds } from '../core/revision-cloning.js';
9
9
  import { getRunChildText, isTextLikeRunChild } from './surgical-spans.js';
10
10
 
11
- const TRACK_CHANGE_CARRIERS = new Set(['ins']);
11
+ const TRACK_CHANGE_CARRIERS = new Set(['ins', 'del']);
12
12
 
13
13
  export function getRunContentPieces(runElement) {
14
14
  const pieces = [];
@@ -72,7 +72,7 @@ export function insertRunPiecesBefore(xmlDoc, parent, referenceNode, pieces, rPr
72
72
  }
73
73
 
74
74
  /**
75
- * Splits a run-level tracked-change carrier at an accepted-view character
75
+ * Splits a run-level tracked-change carrier at its visible-view character
76
76
  * offset without mutating the source carrier. The original revision ID stays
77
77
  * with the leading fragment; an interior trailing fragment receives a fresh,
78
78
  * document-scoped ID while all other carrier metadata remains unchanged.
@@ -86,7 +86,7 @@ export function insertRunPiecesBefore(xmlDoc, parent, referenceNode, pieces, rPr
86
86
  export function splitTrackChangeCarrier(xmlDoc, carrierElement, splitOffset, allocator = null) {
87
87
  const carrierName = getLocalName(carrierElement);
88
88
  if (!TRACK_CHANGE_CARRIERS.has(carrierName)) {
89
- throw new TypeError('splitTrackChangeCarrier requires a w:ins carrier.');
89
+ throw new TypeError('splitTrackChangeCarrier requires a w:ins or w:del carrier.');
90
90
  }
91
91
  if (!Number.isInteger(splitOffset) || splitOffset < 0) {
92
92
  throw new RangeError('splitOffset must be a non-negative integer.');
@@ -129,17 +129,18 @@ export function splitTrackChangeCarrier(xmlDoc, carrierElement, splitOffset, all
129
129
  } else {
130
130
  const localOffset = splitOffset - offset;
131
131
  const rPr = Array.from(child.childNodes || []).find(node => isWordElement(node, 'rPr')) || null;
132
- const leftPieces = sliceRunPieces(xmlDoc, pieces, 0, localOffset, false);
133
- const rightPieces = sliceRunPieces(xmlDoc, pieces, localOffset, runLength, false);
132
+ const asDeletedText = carrierName === 'del';
133
+ const leftPieces = sliceRunPieces(xmlDoc, pieces, 0, localOffset, asDeletedText);
134
+ const rightPieces = sliceRunPieces(xmlDoc, pieces, localOffset, runLength, asDeletedText);
134
135
  leftCarrier.appendChild(createRunFromPieces(xmlDoc, leftPieces, rPr));
135
136
  const rightRun = createRunFromPieces(xmlDoc, rightPieces, rPr);
136
- refreshRunPropertyChangeIds(rightRun, resolveAllocator(xmlDoc, allocator));
137
137
  rightCarrier.appendChild(rightRun);
138
138
  }
139
139
  offset = runEnd;
140
140
  }
141
141
 
142
142
  const resolvedAllocator = resolveAllocator(xmlDoc, allocator);
143
+ refreshRunPropertyChangeIds(rightCarrier, resolvedAllocator);
143
144
  const nextId = resolvedAllocator.next();
144
145
  setWordAttribute(rightCarrier, 'id', String(nextId));
145
146
  resolvedAllocator._receiptCollector?.recordRevision(nextId, carrierName);
@@ -166,7 +167,7 @@ function getLocalName(element) {
166
167
  }
167
168
 
168
169
  function cloneRunPiece(xmlDoc, sourceNode, text, asDeletedText) {
169
- if (asDeletedText) {
170
+ if (asDeletedText && (isWordElement(sourceNode, 'delText') || isWordElement(sourceNode, 't'))) {
170
171
  const delText = createWordElement(xmlDoc, 'w:delText');
171
172
  delText.setAttribute('xml:space', 'preserve');
172
173
  delText.textContent = text;
@@ -192,6 +193,17 @@ function cloneRunPiece(xmlDoc, sourceNode, text, asDeletedText) {
192
193
  return sourceNode.cloneNode(true);
193
194
  }
194
195
 
196
+ if (text === '\u00ad' && isWordElement(sourceNode, 'softHyphen')) {
197
+ return sourceNode.cloneNode(true);
198
+ }
199
+
200
+ if (asDeletedText) {
201
+ const delText = createWordElement(xmlDoc, 'w:delText');
202
+ delText.setAttribute('xml:space', 'preserve');
203
+ delText.textContent = text;
204
+ return delText;
205
+ }
206
+
195
207
  const textNode = createWordElement(xmlDoc, 'w:t');
196
208
  textNode.setAttribute('xml:space', 'preserve');
197
209
  textNode.textContent = text;
@@ -3,7 +3,7 @@ import { getFirstElementByTag } from '../core/xml-query.js';
3
3
  import { isWordElement } from '../core/word-xml.js';
4
4
 
5
5
  export function getRunChildText(child) {
6
- if (isWordElement(child, 't')) return child.textContent || '';
6
+ if (isWordElement(child, 't') || isWordElement(child, 'delText')) return child.textContent || '';
7
7
  if (isWordElement(child, 'br') || isWordElement(child, 'cr')) return '\n';
8
8
  if (isWordElement(child, 'tab')) return '\t';
9
9
  if (isWordElement(child, 'noBreakHyphen')) return '\u2011';
@@ -13,6 +13,7 @@ export function getRunChildText(child) {
13
13
 
14
14
  export function isTextLikeRunChild(child) {
15
15
  return isWordElement(child, 't')
16
+ || isWordElement(child, 'delText')
16
17
  || isWordElement(child, 'br')
17
18
  || isWordElement(child, 'cr')
18
19
  || isWordElement(child, 'tab')
package/node/cli.js CHANGED
@@ -8,13 +8,14 @@ import { configureLogger } from '../adapters/logger.js';
8
8
  import { isExistingRevisionsPolicy } from '../services/document-operation-contract.js';
9
9
 
10
10
  const suffixes = { apply: 'redlined', accept: 'accepted', reject: 'rejected', 'delete-comments': 'comments-removed' };
11
- const CLI_CONTRACT_VERSION = 2;
11
+ const CLI_CONTRACT_VERSION = 3;
12
12
  const CLI_CAPABILITIES = [
13
13
  'atomic-batch-results-on-package-failure',
14
14
  'baseline-aware-validation',
15
+ 'compact-mutation-results',
15
16
  'cross-author-revision-slicing',
16
17
  'document-scoped-list-revision-ids'
17
- ];
18
+ ];
18
19
  const commandOptions = {
19
20
  version: new Set(['help']),
20
21
  inspect: new Set(['help', 'search', 'revised', 'table', 'body', 'nonEmpty', 'index', 'indexes', 'range', 'view']),
@@ -162,10 +163,124 @@ async function writeMutation(command, input, flags, result) {
162
163
  await writeFile(destination, result.toBuffer());
163
164
  return { status: result.status || 'ok', ...result, outputPath: destination };
164
165
  }
165
- function serializable(value) {
166
- const { buffer: _buffer, toBuffer: _toBuffer, ...rest } = value || {};
167
- return rest;
168
- }
166
+ function serializable(value) {
167
+ const { buffer: _buffer, toBuffer: _toBuffer, ...rest } = value || {};
168
+ return rest;
169
+ }
170
+
171
+ function boundedText(value, limit = 512) {
172
+ const text = String(value ?? '');
173
+ return text.length > limit ? `${text.slice(0, limit)}…` : text;
174
+ }
175
+
176
+ function compactError(error) {
177
+ if (!error || typeof error !== 'object') return error;
178
+ const fields = [
179
+ 'code', 'stage', 'mismatchOffset', 'expectedExcerpt', 'actualExcerpt',
180
+ 'expectedCodePoint', 'actualCodePoint', 'ownerAuthor', 'commentIds'
181
+ ];
182
+ const compact = {};
183
+ for (const field of fields) {
184
+ if (error[field] !== undefined) compact[field] = error[field];
185
+ }
186
+ if (Array.isArray(error.comments)) {
187
+ compact.comments = error.comments.map(comment => ({
188
+ ...(comment?.id !== undefined ? { id: comment.id } : {}),
189
+ ...(comment?.author !== undefined ? { author: boundedText(comment.author, 160) } : {}),
190
+ ...(comment?.text !== undefined ? { text: boundedText(comment.text, 512) } : {})
191
+ }));
192
+ }
193
+ if (Array.isArray(error.candidates)) {
194
+ compact.candidates = error.candidates.map(compactResolvedTarget);
195
+ }
196
+ compact.message = boundedText(error.message || String(error));
197
+ return compact;
198
+ }
199
+
200
+ function compactResolvedTarget(target) {
201
+ if (!target || typeof target !== 'object') return target;
202
+ const { text: _text, exactText: _exactText, ...compact } = target;
203
+ return compact;
204
+ }
205
+
206
+ function compactReceipt(receipt) {
207
+ if (!receipt || typeof receipt !== 'object') return receipt;
208
+ return {
209
+ ...receipt,
210
+ affectedTargets: Array.isArray(receipt.affectedTargets)
211
+ ? receipt.affectedTargets.map(compactResolvedTarget)
212
+ : [],
213
+ warnings: Array.isArray(receipt.warnings) ? receipt.warnings.map(warning => boundedText(warning)) : []
214
+ };
215
+ }
216
+
217
+ function compactOperationResult(result) {
218
+ if (!result || typeof result !== 'object') return result;
219
+ return {
220
+ ...result,
221
+ ...(result.resolvedTarget ? { resolvedTarget: compactResolvedTarget(result.resolvedTarget) } : {}),
222
+ ...(result.resolvedAnchor ? { resolvedAnchor: compactResolvedTarget(result.resolvedAnchor) } : {}),
223
+ ...(result.error ? { error: compactError(result.error) } : {}),
224
+ ...(result.receipt ? { receipt: compactReceipt(result.receipt) } : {}),
225
+ ...(Array.isArray(result.warnings) ? { warnings: result.warnings.map(warning => boundedText(warning)) } : {})
226
+ };
227
+ }
228
+
229
+ function summarizeIssues(issues) {
230
+ const list = Array.isArray(issues) ? issues : [];
231
+ const grouped = new Map();
232
+ for (const issue of list) {
233
+ const code = issue?.code || 'UNKNOWN';
234
+ const source = issue?.source || 'unknown';
235
+ const severity = issue?.severity || 'error';
236
+ const key = `${source}:${severity}:${code}`;
237
+ const current = grouped.get(key) || { source, severity, code, count: 0 };
238
+ current.count++;
239
+ grouped.set(key, current);
240
+ }
241
+ return {
242
+ total: list.length,
243
+ errors: list.filter(issue => issue?.severity === 'error').length,
244
+ warnings: list.filter(issue => issue?.severity === 'warning').length,
245
+ byCode: Array.from(grouped.values()).sort((a, b) => (
246
+ a.source.localeCompare(b.source) || a.code.localeCompare(b.code) || a.severity.localeCompare(b.severity)
247
+ ))
248
+ };
249
+ }
250
+
251
+ function compactMutationResult(value) {
252
+ const serialized = serializable(value);
253
+ const {
254
+ documentXml: _documentXml,
255
+ oxml: _oxml,
256
+ commentsXml: _commentsXml,
257
+ commentsExtendedXml: _commentsExtendedXml,
258
+ numberingXml: _numberingXml,
259
+ numberingXmlParts: _numberingXmlParts,
260
+ inspection: _inspection,
261
+ issues: _issues,
262
+ ...compact
263
+ } = serialized;
264
+ const results = Array.isArray(compact.results) ? compact.results.map(compactOperationResult) : [];
265
+ const status = compact.status || 'ok';
266
+ return {
267
+ ...compact,
268
+ ...(Array.isArray(compact.results) ? { results } : {}),
269
+ ...(Array.isArray(compact.receipts) ? { receipts: compact.receipts.map(compactReceipt) } : {}),
270
+ ...(compact.error ? { error: compactError(compact.error) } : {}),
271
+ ...(Array.isArray(compact.warnings) ? { warnings: compact.warnings.map(warning => boundedText(warning)) } : {}),
272
+ ...(compact.validation ? {
273
+ validation: {
274
+ originalIssues: summarizeIssues(compact.validation.originalIssues),
275
+ generatedIssues: summarizeIssues(compact.validation.generatedIssues)
276
+ }
277
+ } : {}),
278
+ completion: compact.written === true
279
+ && status !== 'error'
280
+ && status !== 'partial'
281
+ && results.every(result => result?.status !== 'error')
282
+ };
283
+ }
169
284
 
170
285
  async function collectValidationIssues(buffer) {
171
286
  const entries = unzipDocx(buffer);
@@ -301,17 +416,17 @@ export async function executeCli(argv) {
301
416
  ...(expectedRevision ? { expectedRevision } : {})
302
417
  });
303
418
  const mutationResult = await writeMutation(command, input, flags, result);
304
- return {
305
- command,
306
- input,
307
- ...serializable(mutationResult),
308
- ...(result.status === 'error' || result.error ? { exitCode: 2 } : {})
309
- };
419
+ return compactMutationResult({
420
+ command,
421
+ input,
422
+ ...serializable(mutationResult),
423
+ ...(result.status === 'error' || result.error ? { exitCode: 2 } : {})
424
+ });
310
425
  }
311
426
  const filter = flags.allAuthors ? { allAuthors: true } : flags.author ? { author: String(flags.author) } : null;
312
427
  if (!filter) return cliError('AUTHOR_REQUIRED', 'Use --author <name> or --all-authors.');
313
428
  const result = command === 'delete-comments' ? await document.deleteComments(filter) : await document.resolveRevisions(command, filter);
314
- return { command, input, ...serializable(await writeMutation(command, input, flags, result)) };
429
+ return compactMutationResult({ command, input, ...serializable(await writeMutation(command, input, flags, result)) });
315
430
  } catch (error) { return cliError(error.code || 'CLI_FAILED', error.message); }
316
431
  }
317
432
 
@@ -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.3",
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
@@ -15,6 +15,7 @@ import {
15
15
  applyFormattingToParagraphByExactText,
16
16
  applyHighlightToParagraphByExactText,
17
17
  applyParagraphFormatToParagraphByExactText,
18
+ insertIntoRejectedDeletedText,
18
19
  restoreDeletedParagraphByExactText,
19
20
  applyToParagraphByExactText
20
21
  } from './document-operation-mutations.js';
@@ -26,7 +27,9 @@ import {
26
27
  import {
27
28
  createEmptyReceipt,
28
29
  reconcileReceiptsAgainstOutput
29
- } 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';
30
33
 
31
34
  export function normalizeOperationError(error) {
32
35
  return {
@@ -61,6 +64,8 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
61
64
 
62
65
  if (
63
66
  operation.operationKind !== 'comment_reply'
67
+ && operation.operationKind !== 'rejected-insert'
68
+ && operation.operationKind !== 'restore'
64
69
  && (
65
70
  operation.targetDescriptor?.revisionView === 'rejected'
66
71
  || operation.targetEndDescriptor?.revisionView === 'rejected'
@@ -250,6 +255,17 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
250
255
  runtimeContext,
251
256
  operationOptions
252
257
  );
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
+ );
253
269
  } else if (operation.operationKind === 'restore') {
254
270
  result = await restoreDeletedParagraphByExactText(
255
271
  documentXml,
@@ -311,8 +327,37 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
311
327
  operationReceipt.warnings.push(String(w));
312
328
  }
313
329
  }
314
- } else {
315
- 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');
316
361
  if (operation.captureKey && session.captureTable) {
317
362
  session.captureTable.set(
318
363
  operation.captureKey,