@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
|
@@ -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,10 @@
|
|
|
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" } } } ]
|
|
100
|
+
},
|
|
101
|
+
{
|
|
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"] } } } ]
|
|
90
103
|
},
|
|
91
104
|
{
|
|
92
105
|
"allOf": [ { "$ref": "#/$defs/base" }, { "type": "object", "properties": { "type": { "const": "delete" }, "modified": { "const": "" } } } ]
|
package/engine/oxml-engine.js
CHANGED
|
@@ -40,6 +40,7 @@ import { getDocumentParagraphs } from './format-extraction.js';
|
|
|
40
40
|
import { isDiffTokenLimitError } from '../pipeline/diff-engine.js';
|
|
41
41
|
import { NumberingService } from '../services/numbering-service.js';
|
|
42
42
|
import { recordRouteSelection } from './route-selection.js';
|
|
43
|
+
import { inspectForeignDeletedParagraphTarget } from '../core/paragraph-revision-safety.js';
|
|
43
44
|
|
|
44
45
|
function getCommentIdsInOoxml(node) {
|
|
45
46
|
const ids = new Set();
|
|
@@ -138,9 +139,29 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
138
139
|
const revisionIdAllocator = options?._revisionIdAllocator instanceof RevisionIdAllocator
|
|
139
140
|
? options._revisionIdAllocator
|
|
140
141
|
: new RevisionIdAllocator();
|
|
141
|
-
seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
|
|
143
|
+
|
|
144
|
+
const inputParagraphs = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || '').toLowerCase() === 'p'
|
|
145
|
+
? [xmlDoc.documentElement]
|
|
146
|
+
: getDocumentParagraphs(xmlDoc);
|
|
147
|
+
if (inputParagraphs.length === 1 && modifiedText.length > 0) {
|
|
148
|
+
const resurrectionTarget = inspectForeignDeletedParagraphTarget(inputParagraphs[0], author);
|
|
149
|
+
if (resurrectionTarget.matches) {
|
|
150
|
+
const ownerAuthor = resurrectionTarget.ownerAuthor || 'unattributed';
|
|
151
|
+
return finalize({
|
|
152
|
+
oxml: inputOoxml,
|
|
153
|
+
hasChanges: false,
|
|
154
|
+
status: 'error',
|
|
155
|
+
error: {
|
|
156
|
+
code: 'FOREIGN_PARAGRAPH_MARK_DELETION',
|
|
157
|
+
message: `Refusing to add visible text to a paragraph whose paragraph mark is deleted by another author (${ownerAuthor}). Use explicit paragraph restoration when supported.`,
|
|
158
|
+
ownerAuthor
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (containsTrackedChanges(xmlDoc)) {
|
|
144
165
|
if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
|
|
145
166
|
const authors = getTrackedChangeAuthors(xmlDoc);
|
|
146
167
|
const currentAuthor = String(author || '').trim().toLowerCase();
|
|
@@ -161,7 +161,18 @@ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, gener
|
|
|
161
161
|
if (delWrapper && record.deletedPieces.length > 0) {
|
|
162
162
|
delWrapper.appendChild(createRunFromPieces(xmlDoc, record.deletedPieces, record.rPr));
|
|
163
163
|
}
|
|
164
|
-
insertRunPiecesBefore(xmlDoc, parent, runElement, record.afterPieces, record.rPr);
|
|
164
|
+
const afterRun = insertRunPiecesBefore(xmlDoc, parent, runElement, record.afterPieces, record.rPr);
|
|
165
|
+
if (
|
|
166
|
+
record.globalEnd === endPos
|
|
167
|
+
&& !isWordElement(parent, 'ins')
|
|
168
|
+
) {
|
|
169
|
+
if (!spanIndex.replacementInsertionAnchors) spanIndex.replacementInsertionAnchors = new Map();
|
|
170
|
+
spanIndex.replacementInsertionAnchors.set(endPos, {
|
|
171
|
+
parent,
|
|
172
|
+
referenceNode: afterRun || runElement.nextSibling,
|
|
173
|
+
rPr: record.rPr
|
|
174
|
+
});
|
|
175
|
+
}
|
|
165
176
|
parent.removeChild(runElement);
|
|
166
177
|
changed = true;
|
|
167
178
|
}
|
|
@@ -209,6 +220,29 @@ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints
|
|
|
209
220
|
);
|
|
210
221
|
}
|
|
211
222
|
|
|
223
|
+
const replacementAnchor = spanIndex.replacementInsertionAnchors?.get(pos) || null;
|
|
224
|
+
if (
|
|
225
|
+
replacementAnchor
|
|
226
|
+
&& !affinity
|
|
227
|
+
&& isConnected(replacementAnchor.parent)
|
|
228
|
+
&& (!replacementAnchor.referenceNode || replacementAnchor.referenceNode.parentNode === replacementAnchor.parent)
|
|
229
|
+
) {
|
|
230
|
+
spanIndex.replacementInsertionAnchors.delete(pos);
|
|
231
|
+
insertTextRuns(
|
|
232
|
+
xmlDoc,
|
|
233
|
+
replacementAnchor.parent,
|
|
234
|
+
replacementAnchor.referenceNode,
|
|
235
|
+
text,
|
|
236
|
+
replacementAnchor.rPr,
|
|
237
|
+
author,
|
|
238
|
+
formatHints,
|
|
239
|
+
insertOffset,
|
|
240
|
+
generateRedlines,
|
|
241
|
+
revisionMetadata
|
|
242
|
+
);
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
|
|
212
246
|
if (!affinity) {
|
|
213
247
|
let targetSpan = findContainingSpan(spanIndex, pos);
|
|
214
248
|
|
package/engine/surgical-mode.js
CHANGED
|
@@ -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 ||
|
|
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 = [];
|
|
@@ -124,7 +127,120 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
124
127
|
let newPos = 0;
|
|
125
128
|
let hasChanges = false;
|
|
126
129
|
|
|
127
|
-
|
|
130
|
+
const insertionOperations = insertionOnlyDiffs
|
|
131
|
+
? collectInsertionOperations(insertionOnlyDiffs)
|
|
132
|
+
: [];
|
|
133
|
+
if (insertionOperations.length > 1 && formatHints.length === 0) {
|
|
134
|
+
for (const operation of insertionOperations.slice().reverse()) {
|
|
135
|
+
const liveSpans = buildSurgicalTextSpans(allParagraphs).textSpans;
|
|
136
|
+
const liveSpanIndex = buildSpanIndex(liveSpans);
|
|
137
|
+
const textWithoutNewlines = operation.text.replace(/\n/g, ' ');
|
|
138
|
+
if (textWithoutNewlines.length === 0) continue;
|
|
139
|
+
const insertResult = processInsert(
|
|
140
|
+
xmlDoc,
|
|
141
|
+
liveSpanIndex,
|
|
142
|
+
operation.originalPos,
|
|
143
|
+
textWithoutNewlines,
|
|
144
|
+
author,
|
|
145
|
+
formatHints,
|
|
146
|
+
operation.newPos,
|
|
147
|
+
generateRedlines,
|
|
148
|
+
allParagraphs[0] || null,
|
|
149
|
+
null,
|
|
150
|
+
options?.insertionAffinity || null,
|
|
151
|
+
options?.existingRevisions || 'merge-same-author'
|
|
152
|
+
);
|
|
153
|
+
if (insertResult && typeof insertResult === 'object' && insertResult.error) {
|
|
154
|
+
return withOoxmlSourceType({
|
|
155
|
+
oxml: serializer.serializeToString(xmlDoc),
|
|
156
|
+
hasChanges: false,
|
|
157
|
+
status: 'error',
|
|
158
|
+
error: insertResult.error
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (insertResult === true) hasChanges = true;
|
|
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
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
|
|
243
|
+
for (let i = 0; i < diffs.length; i++) {
|
|
128
244
|
const [op, text] = diffs[i];
|
|
129
245
|
if (op === 0) {
|
|
130
246
|
const len = text.length;
|
|
@@ -216,6 +332,7 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
216
332
|
}
|
|
217
333
|
newPos += text.length;
|
|
218
334
|
}
|
|
335
|
+
}
|
|
219
336
|
}
|
|
220
337
|
|
|
221
338
|
const actualText = allParagraphs.map(paragraph => extractCanonicalParagraphText(paragraph)).join('\n');
|
|
@@ -231,7 +348,9 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
231
348
|
message: 'Generated OOXML accepted-view text does not match the requested modified text; the mutation was rejected.',
|
|
232
349
|
mismatchOffset,
|
|
233
350
|
expectedExcerpt: excerptAt(expectedText, mismatchOffset),
|
|
234
|
-
actualExcerpt: excerptAt(actualText, mismatchOffset)
|
|
351
|
+
actualExcerpt: excerptAt(actualText, mismatchOffset),
|
|
352
|
+
expectedCodePoint: codePointAtOffset(expectedText, mismatchOffset),
|
|
353
|
+
actualCodePoint: codePointAtOffset(actualText, mismatchOffset)
|
|
235
354
|
},
|
|
236
355
|
...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
|
|
237
356
|
});
|
|
@@ -257,3 +376,83 @@ function excerptAt(text, offset, radius = 40) {
|
|
|
257
376
|
const end = Math.min(text.length, offset + radius);
|
|
258
377
|
return text.slice(start, end);
|
|
259
378
|
}
|
|
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
|
+
|
|
442
|
+
function collectInsertionOperations(diffs) {
|
|
443
|
+
const operations = [];
|
|
444
|
+
let originalPos = 0;
|
|
445
|
+
let newPos = 0;
|
|
446
|
+
for (const [op, text] of diffs) {
|
|
447
|
+
if (op === 0) {
|
|
448
|
+
originalPos += text.length;
|
|
449
|
+
newPos += text.length;
|
|
450
|
+
} else if (op === -1) {
|
|
451
|
+
originalPos += text.length;
|
|
452
|
+
} else if (op === 1) {
|
|
453
|
+
operations.push({ originalPos, newPos, text });
|
|
454
|
+
newPos += text.length;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return operations;
|
|
458
|
+
}
|
|
@@ -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
|
|
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
|
|
133
|
-
const
|
|
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;
|
package/engine/surgical-spans.js
CHANGED
|
@@ -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/index.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ export type {
|
|
|
29
29
|
OperationPreflightResult,
|
|
30
30
|
ParagraphTargetDescriptor,
|
|
31
31
|
RedlineDocumentOperation,
|
|
32
|
+
RestoreDocumentOperation,
|
|
32
33
|
ResolvedCommentAnchor,
|
|
33
34
|
ResolvedDocumentTarget,
|
|
34
35
|
StandaloneRunnerOptions
|
|
@@ -43,6 +44,13 @@ export interface RedlineError {
|
|
|
43
44
|
| 'UNSUPPORTED_REVISION_VIEW_MUTATION'
|
|
44
45
|
| 'UNSUPPORTED_INSERTION_AFFINITY'
|
|
45
46
|
| 'UNSAFE_PARAGRAPH_BOUNDARY'
|
|
47
|
+
| 'FOREIGN_PARAGRAPH_MARK_DELETION'
|
|
48
|
+
| 'RESTORATION_STATE_REQUIRED'
|
|
49
|
+
| 'RESTORATION_COUNT_MISMATCH'
|
|
50
|
+
| 'UNSAFE_DELETED_TABLE_ROW'
|
|
51
|
+
| 'UNSUPPORTED_MOVE_REVISION'
|
|
52
|
+
| 'SECTION_BREAK_PARAGRAPH'
|
|
53
|
+
| 'UNSAFE_PARAGRAPH_PLACEMENT'
|
|
46
54
|
| 'TARGET_INDEX_MISMATCH'
|
|
47
55
|
| 'REVISION_MISMATCH'
|
|
48
56
|
| 'REVISION_TOKEN_SCOPE_MISMATCH'
|
|
@@ -54,6 +62,10 @@ export interface RedlineError {
|
|
|
54
62
|
| 'CAPTURE_STALE'
|
|
55
63
|
| string;
|
|
56
64
|
message: string;
|
|
65
|
+
ownerAuthor?: string;
|
|
66
|
+
stage?: string;
|
|
67
|
+
expected?: unknown;
|
|
68
|
+
actual?: unknown;
|
|
57
69
|
commentIds?: string[];
|
|
58
70
|
comments?: Array<{ id: string; author: string; text: string }>;
|
|
59
71
|
}
|
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 =
|
|
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
|
|