@ansonlai/docx-redline-js 0.8.4 → 0.9.0
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/ARCHITECTURE.md +221 -12
- package/CHANGELOG.md +176 -0
- package/README.md +125 -12
- package/core/current-properties.js +136 -0
- package/core/paragraph-text.js +211 -209
- package/core/types.js +9 -3
- package/core/word-revision-semantics.js +256 -0
- package/dist/docx-redline-js.esm.js +4189 -767
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +111 -108
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +4292 -870
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +4292 -870
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_FAST_START.md +2 -0
- package/docs/AGENT_KNOWLEDGE_BASE.md +19 -3
- package/docs/SKILL_AUTHORING.md +165 -150
- package/docs/TESTING.md +1142 -1059
- package/docs/schemas/document-operations.schema.json +54 -1
- package/document/docx-document.js +1022 -1010
- package/engine/oxml-engine.js +736 -749
- package/engine/paragraph-mark-revisions.js +292 -0
- package/engine/reconstruction-mapper.js +312 -311
- package/engine/reconstruction-writer.js +67 -10
- package/engine/rpr-helpers.js +42 -0
- package/engine/run-builders.js +0 -73
- package/engine/surgical-diff-application.js +604 -605
- package/engine/table-mode.js +63 -63
- package/index.d.ts +47 -1
- package/index.js +11 -2
- package/node/cli-help.js +1 -0
- package/node/cli.js +992 -975
- package/orchestration/list-structural-fallback.js +67 -9
- package/package.json +1 -1
- package/pipeline/diff-engine.js +130 -1
- package/pipeline/ingestion-export.js +227 -243
- package/pipeline/ingestion-paragraph.js +93 -98
- package/pipeline/list-generation.js +415 -419
- package/pipeline/pipeline.js +206 -204
- package/scripts/export-validation-fixtures.mjs +19 -6
- package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
- package/scripts/lib/word-coverage-catalogue.mjs +7 -2
- package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
- package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
- package/scripts/word-com-differential.ps1 +443 -335
- package/scripts/word-com-visual-suite.ps1 +2 -2
- package/scripts/word-revision-semantics-matrix.ps1 +108 -0
- package/services/batch-operation-orchestrator.js +23 -3
- package/services/capture-engine.js +228 -226
- package/services/document-operation-applier.js +213 -0
- package/services/document-operation-contract.js +75 -0
- package/services/document-operation-mutations.js +3346 -2721
- package/services/error-recovery.js +17 -0
- package/services/list-conversion-diagnostics.js +296 -0
- package/services/list-format-change.js +586 -0
- package/services/list-format-mutation.js +416 -0
- package/services/list-format.js +305 -0
- package/services/numbering-allocation.js +642 -0
- package/services/numbering-helpers.js +354 -524
- package/services/operation-batch-compiler.js +183 -11
- package/services/operation-preflight.js +1 -0
- package/services/receipt-collector.js +288 -288
- package/services/revision-comment-management.js +38 -5
- package/services/revision-fidelity-verifier.js +498 -0
- package/services/standalone-docx-plumbing.js +627 -631
- package/services/standalone-operation-runner.d.ts +182 -5
- package/services/table-reconciliation.js +59 -7
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
7
7
|
import { getXmlParseError } from '../core/xml-query.js';
|
|
8
|
-
import { NS_W } from '../core/types.js';
|
|
8
|
+
import { NS_W, createRevisionMetadata } from '../core/types.js';
|
|
9
|
+
import { clonePropertiesWithoutRevisionHistory } from '../core/revision-cloning.js';
|
|
10
|
+
import { getCurrentNumIdElements } from '../core/current-properties.js';
|
|
9
11
|
import { createWordElement } from '../core/word-xml.js';
|
|
10
12
|
import {
|
|
11
13
|
getDocumentParagraphNodes,
|
|
@@ -62,12 +64,47 @@ export function stripSingleLineListMarkerPrefix(text) {
|
|
|
62
64
|
return String(candidate.contentText || '').trim();
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
const ROMAN_VALUES = { i: 1, v: 5, x: 10, l: 50, c: 100, d: 500, m: 1000 };
|
|
68
|
+
|
|
69
|
+
function parseRomanNumeral(text) {
|
|
70
|
+
const letters = String(text || '').toLowerCase();
|
|
71
|
+
if (!/^[ivxlcdm]+$/.test(letters)) return null;
|
|
72
|
+
let total = 0;
|
|
73
|
+
for (let i = 0; i < letters.length; i++) {
|
|
74
|
+
const value = ROMAN_VALUES[letters[i]];
|
|
75
|
+
const next = ROMAN_VALUES[letters[i + 1]] || 0;
|
|
76
|
+
total += value < next ? -value : value;
|
|
77
|
+
}
|
|
78
|
+
return total;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Derives the numeric start a literal marker implies: `7.` -> 7, `C.` -> 3, `iv.` -> 4.
|
|
83
|
+
* Decimal markers keep every positive value (including 1). Letter and roman markers report only
|
|
84
|
+
* values above 1, because the default start (1) needs no override and keeps the shared-list behavior.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} marker
|
|
87
|
+
* @param {string} numberingStyle
|
|
88
|
+
* @returns {number|null}
|
|
89
|
+
*/
|
|
90
|
+
export function parseMarkerStart(marker, numberingStyle) {
|
|
91
|
+
const text = String(marker || '').trim();
|
|
92
|
+
let value = null;
|
|
93
|
+
if (numberingStyle === 'decimal') {
|
|
94
|
+
const match = text.match(/^(\d+)\.?$/);
|
|
95
|
+
value = match ? Number.parseInt(match[1], 10) : null;
|
|
96
|
+
} else if (numberingStyle === 'lowerRoman' || numberingStyle === 'upperRoman') {
|
|
97
|
+
const match = text.match(/^([A-Za-z]+)[.)]?$/);
|
|
98
|
+
value = match ? parseRomanNumeral(match[1]) : null;
|
|
99
|
+
} else if (numberingStyle === 'lowerAlpha' || numberingStyle === 'upperAlpha') {
|
|
100
|
+
const match = text.match(/^([A-Za-z])[.)]?$/);
|
|
101
|
+
value = match ? match[1].toLowerCase().charCodeAt(0) - 96 : null;
|
|
102
|
+
} else {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
if (!Number.isFinite(value) || value < 1) return null;
|
|
106
|
+
if (numberingStyle !== 'decimal' && value === 1) return null;
|
|
107
|
+
return value;
|
|
71
108
|
}
|
|
72
109
|
|
|
73
110
|
/**
|
|
@@ -199,7 +236,8 @@ function getDirectWordChild(element, localName) {
|
|
|
199
236
|
* numId: string|number,
|
|
200
237
|
* ilvl?: number,
|
|
201
238
|
* clearParagraphPropertyChanges?: boolean,
|
|
202
|
-
* removeListPropertyNode?: boolean
|
|
239
|
+
* removeListPropertyNode?: boolean,
|
|
240
|
+
* trackPropertyChange?: { author?: string, revisionIdAllocator?: object|null }|null
|
|
203
241
|
* }} options - Binding options
|
|
204
242
|
* @returns {number} Number of paragraph nodes updated
|
|
205
243
|
*/
|
|
@@ -209,6 +247,7 @@ export function enforceListBindingOnParagraphNodes(nodes, options = {}) {
|
|
|
209
247
|
const ilvl = Number.isInteger(options?.ilvl) ? Math.max(0, options.ilvl) : 0;
|
|
210
248
|
const clearParagraphPropertyChanges = options?.clearParagraphPropertyChanges !== false;
|
|
211
249
|
const removeListPropertyNode = options?.removeListPropertyNode !== false;
|
|
250
|
+
const trackPropertyChange = options?.trackPropertyChange || null;
|
|
212
251
|
|
|
213
252
|
const paragraphs = (Array.isArray(nodes) ? nodes : [])
|
|
214
253
|
.filter(node => node && node.nodeType === 1 && node.localName === 'p');
|
|
@@ -224,6 +263,9 @@ export function enforceListBindingOnParagraphNodes(nodes, options = {}) {
|
|
|
224
263
|
paragraph.insertBefore(pPr, paragraph.firstChild);
|
|
225
264
|
}
|
|
226
265
|
|
|
266
|
+
// Snapshot the pre-binding pPr so Reject All restores the unnumbered paragraph.
|
|
267
|
+
const priorPPr = trackPropertyChange ? clonePropertiesWithoutRevisionHistory(pPr) : null;
|
|
268
|
+
|
|
227
269
|
if (clearParagraphPropertyChanges) {
|
|
228
270
|
const pPrChange = getDirectWordChild(pPr, 'pPrChange');
|
|
229
271
|
if (pPrChange) pPr.removeChild(pPrChange);
|
|
@@ -253,6 +295,20 @@ export function enforceListBindingOnParagraphNodes(nodes, options = {}) {
|
|
|
253
295
|
numPr.appendChild(numIdEl);
|
|
254
296
|
}
|
|
255
297
|
numIdEl.setAttribute('w:val', String(numId));
|
|
298
|
+
|
|
299
|
+
if (trackPropertyChange && !getDirectWordChild(pPr, 'pPrChange')) {
|
|
300
|
+
const metadata = createRevisionMetadata(
|
|
301
|
+
trackPropertyChange.author,
|
|
302
|
+
trackPropertyChange.revisionIdAllocator || ownerDoc,
|
|
303
|
+
'pPrChange'
|
|
304
|
+
);
|
|
305
|
+
const pPrChange = createWordElement(ownerDoc, 'w:pPrChange');
|
|
306
|
+
pPrChange.setAttribute('w:id', String(metadata.id));
|
|
307
|
+
pPrChange.setAttribute('w:author', metadata.author);
|
|
308
|
+
pPrChange.setAttribute('w:date', metadata.date);
|
|
309
|
+
pPrChange.appendChild(priorPPr || createWordElement(ownerDoc, 'w:pPr'));
|
|
310
|
+
pPr.appendChild(pPrChange);
|
|
311
|
+
}
|
|
256
312
|
updated++;
|
|
257
313
|
}
|
|
258
314
|
|
|
@@ -311,7 +367,8 @@ function extractFirstParagraphNumIdFromOxml(oxml) {
|
|
|
311
367
|
// Tracked-change output leads with a deleted-original paragraph that carries
|
|
312
368
|
// no numPr, so the generated list binding is the first paragraph that has one.
|
|
313
369
|
for (const paragraph of paragraphs) {
|
|
314
|
-
|
|
370
|
+
// Current numIds only; a pPrChange snapshot must not supply the generated list binding.
|
|
371
|
+
const numIdNodes = getCurrentNumIdElements(paragraph);
|
|
315
372
|
for (const numIdNode of numIdNodes) {
|
|
316
373
|
const numId = getElementId(numIdNode, ['w:val', 'val']);
|
|
317
374
|
if (numId != null) return String(numId);
|
|
@@ -433,6 +490,7 @@ export function buildSingleLineListStructuralFallbackPlan(options = {}) {
|
|
|
433
490
|
numberingKey: `${modifiedCandidate.type}:${modifiedCandidate.numberingStyle}:single`,
|
|
434
491
|
originalText,
|
|
435
492
|
wasListParagraph: !!existingListInfo,
|
|
493
|
+
numberingStyle: modifiedCandidate.numberingStyle,
|
|
436
494
|
startAt: modifiedCandidate.startAt
|
|
437
495
|
};
|
|
438
496
|
}
|
package/package.json
CHANGED
package/pipeline/diff-engine.js
CHANGED
|
@@ -41,9 +41,15 @@ export function createDiffEngine(options = {}) {
|
|
|
41
41
|
return engine;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// Opening and closing punctuation that hugs a word. Splitting it into its own
|
|
45
|
+
// token lets "agents and attorneys" -> "agents, attorneys," keep both words
|
|
46
|
+
// unchanged instead of replacing the whole phrase.
|
|
47
|
+
const LEADING_PUNCTUATION = /^[("'\[{“‘]+/u;
|
|
48
|
+
const TRAILING_PUNCTUATION = /[)"'\]}”’,.;:!?]+$/u;
|
|
49
|
+
|
|
44
50
|
function tokenize(text, atomicChars = null) {
|
|
45
51
|
const tokens = [];
|
|
46
|
-
const
|
|
52
|
+
const pushPiece = word => {
|
|
47
53
|
if (!atomicChars || atomicChars.size === 0) {
|
|
48
54
|
tokens.push(word);
|
|
49
55
|
return;
|
|
@@ -59,6 +65,20 @@ function tokenize(text, atomicChars = null) {
|
|
|
59
65
|
}
|
|
60
66
|
if (start < word.length) tokens.push(word.slice(start));
|
|
61
67
|
};
|
|
68
|
+
const pushWord = word => {
|
|
69
|
+
const leading = word.match(LEADING_PUNCTUATION)?.[0] || '';
|
|
70
|
+
const rest = word.slice(leading.length);
|
|
71
|
+
const trailing = rest.match(TRAILING_PUNCTUATION)?.[0] || '';
|
|
72
|
+
const core = rest.slice(0, rest.length - trailing.length);
|
|
73
|
+
// A token made only of punctuation stays whole.
|
|
74
|
+
if (!core) {
|
|
75
|
+
pushPiece(word);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const char of leading) pushPiece(char);
|
|
79
|
+
pushPiece(core);
|
|
80
|
+
for (const char of trailing) pushPiece(char);
|
|
81
|
+
};
|
|
62
82
|
const leading = text.match(/^\s+/);
|
|
63
83
|
if (leading) tokens.push(leading[0]);
|
|
64
84
|
|
|
@@ -243,11 +263,120 @@ export function computeWordDiffs(originalText, newText, options = {}) {
|
|
|
243
263
|
const charDiffs = dmp.diff_main(encodeTokenIdsForDmp(tokenIds1), encodeTokenIdsForDmp(tokenIds2));
|
|
244
264
|
if (cleanupSemantic) {
|
|
245
265
|
dmp.diff_cleanupSemantic(charDiffs);
|
|
266
|
+
refineReplacementsAroundAnchors(dmp, charDiffs, wordArray);
|
|
246
267
|
}
|
|
247
268
|
|
|
248
269
|
return decodeBmpDiffs(charDiffs, wordArray);
|
|
249
270
|
}
|
|
250
271
|
|
|
272
|
+
// A token that is a substantive word: four or more letters or digits. Short
|
|
273
|
+
// function words ("a", "the", "and") and punctuation never anchor on their own.
|
|
274
|
+
const ANCHOR_TOKEN = /^[\p{L}\p{N}][\p{L}\p{N}'’-]{3,}$/u;
|
|
275
|
+
|
|
276
|
+
function isAnchorEquality(chars, wordArray) {
|
|
277
|
+
for (let index = 0; index < chars.length; index++) {
|
|
278
|
+
if (ANCHOR_TOKEN.test(wordArray[bmpCharToTokenId(chars.charCodeAt(index))])) return true;
|
|
279
|
+
}
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const MAX_ANCHOR_REFINEMENT_CELLS = 250000;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Token LCS that weights substantive words far above whitespace, punctuation
|
|
287
|
+
* and function words. A plain LCS would rather keep " and " (three tokens)
|
|
288
|
+
* than "attorneys" (one token), which is the opposite of a useful redline.
|
|
289
|
+
* Returns null when the replacement is too large to refine cheaply.
|
|
290
|
+
*
|
|
291
|
+
* @param {string} deleted - BMP-encoded deleted tokens
|
|
292
|
+
* @param {string} inserted - BMP-encoded inserted tokens
|
|
293
|
+
* @param {string[]} wordArray
|
|
294
|
+
* @returns {Array<[number, string]>|null}
|
|
295
|
+
*/
|
|
296
|
+
function anchorWeightedDiff(deleted, inserted, wordArray) {
|
|
297
|
+
const rows = deleted.length;
|
|
298
|
+
const cols = inserted.length;
|
|
299
|
+
if (!rows || !cols || (rows + 1) * (cols + 1) > MAX_ANCHOR_REFINEMENT_CELLS) return null;
|
|
300
|
+
|
|
301
|
+
const weight = char => {
|
|
302
|
+
const token = wordArray[bmpCharToTokenId(char.charCodeAt(0))];
|
|
303
|
+
return ANCHOR_TOKEN.test(token) ? token.length * 100 : token.length;
|
|
304
|
+
};
|
|
305
|
+
const width = cols + 1;
|
|
306
|
+
const scores = new Float64Array((rows + 1) * width);
|
|
307
|
+
for (let row = rows - 1; row >= 0; row--) {
|
|
308
|
+
for (let col = cols - 1; col >= 0; col--) {
|
|
309
|
+
const skip = Math.max(scores[(row + 1) * width + col], scores[row * width + col + 1]);
|
|
310
|
+
scores[row * width + col] = deleted[row] === inserted[col]
|
|
311
|
+
? Math.max(skip, weight(deleted[row]) + scores[(row + 1) * width + col + 1])
|
|
312
|
+
: skip;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const result = [];
|
|
317
|
+
const push = (op, char) => {
|
|
318
|
+
const last = result[result.length - 1];
|
|
319
|
+
if (last?.[0] === op) last[1] += char;
|
|
320
|
+
else result.push([op, char]);
|
|
321
|
+
};
|
|
322
|
+
let row = 0;
|
|
323
|
+
let col = 0;
|
|
324
|
+
while (row < rows && col < cols) {
|
|
325
|
+
const here = scores[row * width + col];
|
|
326
|
+
if (deleted[row] === inserted[col]
|
|
327
|
+
&& here === weight(deleted[row]) + scores[(row + 1) * width + col + 1]) {
|
|
328
|
+
push(0, deleted[row]);
|
|
329
|
+
row++;
|
|
330
|
+
col++;
|
|
331
|
+
} else if (scores[(row + 1) * width + col] >= scores[row * width + col + 1]) {
|
|
332
|
+
push(-1, deleted[row++]);
|
|
333
|
+
} else {
|
|
334
|
+
push(1, inserted[col++]);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
while (row < rows) push(-1, deleted[row++]);
|
|
338
|
+
while (col < cols) push(1, inserted[col++]);
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Semantic cleanup measures equalities in tokens, so a single unchanged word
|
|
344
|
+
* between two edits ("agents[ and] attorneys[, accountants ...]") is folded
|
|
345
|
+
* into one whole-phrase replacement. Re-diff each replacement and keep any
|
|
346
|
+
* unchanged run that contains a substantive word, so the redline only marks
|
|
347
|
+
* the text that actually changed.
|
|
348
|
+
*/
|
|
349
|
+
function refineReplacementsAroundAnchors(dmp, diffs, wordArray) {
|
|
350
|
+
for (let index = 0; index < diffs.length; index++) {
|
|
351
|
+
let deleteIndex = -1;
|
|
352
|
+
let insertIndex = -1;
|
|
353
|
+
if (diffs[index][0] === -1 && diffs[index + 1]?.[0] === 1) {
|
|
354
|
+
deleteIndex = index;
|
|
355
|
+
insertIndex = index + 1;
|
|
356
|
+
} else if (diffs[index][0] === 1 && diffs[index + 1]?.[0] === -1) {
|
|
357
|
+
insertIndex = index;
|
|
358
|
+
deleteIndex = index + 1;
|
|
359
|
+
} else {
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const inner = anchorWeightedDiff(diffs[deleteIndex][1], diffs[insertIndex][1], wordArray);
|
|
364
|
+
if (!inner?.some(([op, chars]) => op === 0 && isAnchorEquality(chars, wordArray))) continue;
|
|
365
|
+
|
|
366
|
+
const refined = [];
|
|
367
|
+
for (const [op, chars] of inner) {
|
|
368
|
+
if (op === 0 && !isAnchorEquality(chars, wordArray)) {
|
|
369
|
+
refined.push([-1, chars], [1, chars]);
|
|
370
|
+
} else {
|
|
371
|
+
refined.push([op, chars]);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
dmp.diff_cleanupMerge(refined);
|
|
375
|
+
diffs.splice(index, 2, ...refined);
|
|
376
|
+
index += refined.length - 1;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
251
380
|
/**
|
|
252
381
|
* Computes a character-local diff without semantic cleanup. This is used to
|
|
253
382
|
* refine whitespace-only substitutions that a word-level token groups with
|