@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
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import { getAllReferencedNumIdElements } from '../core/current-properties.js';
|
|
2
|
+
import { projectWordRevisionView } from '../core/word-revision-semantics.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Post-apply revision fidelity invariant.
|
|
6
|
+
*
|
|
7
|
+
* A tracked operation only adds revisions on top of the source, so Word's Reject All of
|
|
8
|
+
* the output must equal Word's Reject All of the input: same paragraphs in the same
|
|
9
|
+
* order, same text (breaks and tabs included), same properties and numbering, and the
|
|
10
|
+
* same inline wrapper membership (hyperlinks, simple fields, content controls). Both
|
|
11
|
+
* sides reject every author's revisions, which keeps other reviewers' pre-existing
|
|
12
|
+
* revisions comparable without filtering by revision id.
|
|
13
|
+
*
|
|
14
|
+
* Works on live DOMs and never serializes.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const REVISION_FIDELITY_MODES = Object.freeze(['enforce', 'warn', 'off']);
|
|
18
|
+
export const DEFAULT_REVISION_FIDELITY_MODE = 'enforce';
|
|
19
|
+
const MAX_REPORTED_VIOLATIONS = 5;
|
|
20
|
+
const EXCERPT_LENGTH = 120;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Per-invariant explanation and retry guidance. Agents read `explanation` to understand the
|
|
24
|
+
* failure and `retry` to choose a different operation; the same arguments always fail again.
|
|
25
|
+
*/
|
|
26
|
+
export const REVISION_FIDELITY_INVARIANTS = Object.freeze({
|
|
27
|
+
REJECT_PARAGRAPH_COUNT: {
|
|
28
|
+
explanation: 'Reject All of the output has a different number of paragraphs than the source: a paragraph was created or removed without a tracked paragraph mark.',
|
|
29
|
+
retry: 'Target one paragraph at a time; add new paragraphs with a separate insertion instead of folding them into a replacement; do not merge or split paragraphs in the same operation as a text edit.'
|
|
30
|
+
},
|
|
31
|
+
REJECT_PARAGRAPH_TEXT: {
|
|
32
|
+
explanation: 'Reject All of the output shows different paragraph text than the source: text, a tab or a manual line break changed without being tracked, or was reordered.',
|
|
33
|
+
retry: 'Use replacements (find/replace) for a small literal change, keep manual line breaks and tabs where they are, and retry with the exact current paragraph text from a fresh extraction.'
|
|
34
|
+
},
|
|
35
|
+
REJECT_PARAGRAPH_PROPS: {
|
|
36
|
+
explanation: 'Reject All of the output shows different paragraph properties (style, alignment, spacing, indentation) than the source: a property changed without a tracked w:pPrChange.',
|
|
37
|
+
retry: 'Change paragraph formatting with a separate paragraph-format operation and keep the text edit free of style or list changes.'
|
|
38
|
+
},
|
|
39
|
+
REJECT_NUMBERING_REFS: {
|
|
40
|
+
explanation: 'Reject All of the output puts a source paragraph in a different list or list level (numId/ilvl) than the source, so the restored document would number differently.',
|
|
41
|
+
retry: 'Edit list item text without changing list markers or indentation, and make list membership or level changes in their own operation.'
|
|
42
|
+
},
|
|
43
|
+
ACCEPT_NEIGHBORS: {
|
|
44
|
+
explanation: 'An untracked edit changed a paragraph outside its target, so the change could not be reviewed or undone.',
|
|
45
|
+
retry: 'Re-extract the target and retry against exactly that paragraph; if the edit must span several paragraphs, use a tracked operation with an explicit targetEnd range.'
|
|
46
|
+
},
|
|
47
|
+
UNTRACKED_CREATED_REVISION: {
|
|
48
|
+
explanation: 'An untracked edit (generateRedlines: false) created a tracked revision (w:ins, w:del, a move, a property change or a paragraph-mark revision) that was not in the source, so the edit is not the plain change the caller asked for.',
|
|
49
|
+
retry: 'Retry the same edit against the current extraction; if it is refused again the untracked path wrote a tracked revision, so report the operation instead of working around it, or make the edit tracked (generateRedlines: true) if a reviewable change is acceptable.'
|
|
50
|
+
},
|
|
51
|
+
ACCEPT_INTENT: {
|
|
52
|
+
explanation: 'Accept All of the target of the operation would not read as the complete replacement text the operation declared: content was lost, duplicated or left out of the accepted result.',
|
|
53
|
+
retry: 'Re-extract the target paragraph(s) and retry with the exact current text as the basis; for a small literal change use replacements instead of complete modified text, and keep list, table and Markdown structure out of a plain-text edit.'
|
|
54
|
+
},
|
|
55
|
+
REJECT_WRAPPERS: {
|
|
56
|
+
explanation: 'Reject All of the output moves text into or out of a hyperlink, field or content control compared with the source.',
|
|
57
|
+
retry: 'Keep the change entirely inside or entirely outside the hyperlink, field or content control, for example with replacements that do not cross its boundary.'
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function excerpt(value) {
|
|
62
|
+
if (value == null) return null;
|
|
63
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
|
64
|
+
return text.length > EXCERPT_LENGTH ? `${text.slice(0, EXCERPT_LENGTH - 3)}...` : text;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function describeRecord(record) {
|
|
68
|
+
return record ? {
|
|
69
|
+
sourceParagraphIndex: record.sourceIndex + 1,
|
|
70
|
+
container: record.container,
|
|
71
|
+
text: excerpt(record.text)
|
|
72
|
+
} : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Session-level memo of one document's derived views. `document` identifies the DOM it was computed on;
|
|
77
|
+
* `accepted`/`rejected` are projections, `revisionSignatures` the revision-id to signature map and `numberingRefs` the
|
|
78
|
+
* numbering references. Fields fill lazily so a verifier computes each at most once per DOM, and the
|
|
79
|
+
* after-state's entry becomes the next operation's baseline.
|
|
80
|
+
*/
|
|
81
|
+
export function createFidelityViews(document) {
|
|
82
|
+
return { document, accepted: null, rejected: null, revisionSignatures: null, numberingRefs: null };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function memoView(views, documentNode, view) {
|
|
86
|
+
if (!views) return projectWordRevisionView(documentNode, view);
|
|
87
|
+
return views[view] || (views[view] = projectWordRevisionView(documentNode, view));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Projects the Reject All view of a document DOM; exposed so callers can cache it. */
|
|
91
|
+
export function projectRejectedView(documentNode) {
|
|
92
|
+
return projectWordRevisionView(documentNode, 'rejected');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Compares two Reject All projections.
|
|
97
|
+
*
|
|
98
|
+
* @param {Object} input
|
|
99
|
+
* @param {Array|Document} input.before - savepoint DOM or its cached projection
|
|
100
|
+
* @param {Array|Document} input.after - post-mutation DOM or its projection
|
|
101
|
+
* @param {'rejected'|'accepted'} [input.baselineView='rejected'] - 'accepted' for policies that
|
|
102
|
+
* accept existing revisions in the target before editing: the changed region is compared with
|
|
103
|
+
* the source's accepted view, everything else with its rejected view
|
|
104
|
+
* @returns {{ ok: boolean, violations?: Array<{ invariant: string, paragraph: number, expected: string|null,
|
|
105
|
+
* actual: string|null, explanation: string, source: Object|null, output: Object|null }>,
|
|
106
|
+
* afterProjection: Array }} `afterProjection` lets a session reuse it as the next operation's baseline
|
|
107
|
+
*/
|
|
108
|
+
export function verifyRevisionFidelity({ before, after, baselineView = 'rejected', acceptedScope = null, beforeViews = null, afterViews = null }) {
|
|
109
|
+
const actual = Array.isArray(after) ? after : memoView(afterViews, after, 'rejected');
|
|
110
|
+
const rejectedBefore = Array.isArray(before) ? before : memoView(beforeViews, before, 'rejected');
|
|
111
|
+
const expected = baselineView === 'accepted' && !Array.isArray(before)
|
|
112
|
+
? spliceAcceptedRegion(rejectedBefore, memoView(beforeViews, before, 'accepted'), actual, acceptedScope)
|
|
113
|
+
: rejectedBefore;
|
|
114
|
+
const violations = [];
|
|
115
|
+
const push = (invariant, paragraph, expectedValue, actualValue, sourceRecord, outputRecord) => {
|
|
116
|
+
violations.push({
|
|
117
|
+
invariant,
|
|
118
|
+
paragraph,
|
|
119
|
+
expected: excerpt(expectedValue),
|
|
120
|
+
actual: excerpt(actualValue),
|
|
121
|
+
explanation: REVISION_FIDELITY_INVARIANTS[invariant].explanation,
|
|
122
|
+
source: describeRecord(sourceRecord),
|
|
123
|
+
output: describeRecord(outputRecord)
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
if (expected.length !== actual.length) {
|
|
128
|
+
let first = 0;
|
|
129
|
+
while (first < expected.length && first < actual.length && sameRecord(expected[first], actual[first])) first++;
|
|
130
|
+
push('REJECT_PARAGRAPH_COUNT', first + 1, `${expected.length} paragraphs`, `${actual.length} paragraphs`,
|
|
131
|
+
expected[first] || expected[expected.length - 1], actual[first] || actual[actual.length - 1]);
|
|
132
|
+
return { ok: false, violations, afterProjection: actual };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (let i = 0; i < expected.length && violations.length < MAX_REPORTED_VIOLATIONS; i++) {
|
|
136
|
+
const source = expected[i];
|
|
137
|
+
const output = actual[i];
|
|
138
|
+
const paragraph = i + 1;
|
|
139
|
+
if (source.container !== output.container) {
|
|
140
|
+
push('REJECT_PARAGRAPH_COUNT', paragraph, `paragraph in ${source.container}`, `paragraph in ${output.container}`, source, output);
|
|
141
|
+
} else if (source.text !== output.text) {
|
|
142
|
+
push('REJECT_PARAGRAPH_TEXT', paragraph, source.text, output.text, source, output);
|
|
143
|
+
} else if ((source.numPr?.numId ?? null) !== (output.numPr?.numId ?? null)
|
|
144
|
+
|| (source.numPr?.ilvl ?? null) !== (output.numPr?.ilvl ?? null)) {
|
|
145
|
+
push('REJECT_NUMBERING_REFS', paragraph, source.numPr, output.numPr, source, output);
|
|
146
|
+
} else if (source.props !== output.props) {
|
|
147
|
+
push('REJECT_PARAGRAPH_PROPS', paragraph, source.props, output.props, source, output);
|
|
148
|
+
} else if (source.wrappers.join('|') !== output.wrappers.join('|')) {
|
|
149
|
+
push('REJECT_WRAPPERS', paragraph, source.wrappers, output.wrappers, source, output);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return violations.length
|
|
153
|
+
? { ok: false, violations, afterProjection: actual }
|
|
154
|
+
: { ok: true, afterProjection: actual };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const sameRecord = (a, b) => a.text === b.text && a.props === b.props && a.container === b.container
|
|
158
|
+
&& (a.numPr?.numId ?? null) === (b.numPr?.numId ?? null) && (a.numPr?.ilvl ?? null) === (b.numPr?.ilvl ?? null)
|
|
159
|
+
&& a.wrappers.join('|') === b.wrappers.join('|');
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Untracked operations (generateRedlines: false) have no Reject All to protect, so their
|
|
163
|
+
* guarantee is locality: every paragraph before and after the single resolved target is
|
|
164
|
+
* unchanged in both the Accept All and Reject All views.
|
|
165
|
+
*
|
|
166
|
+
* @param {Object} input
|
|
167
|
+
* @param {Document} input.before - savepoint DOM
|
|
168
|
+
* @param {Document} input.after - post-mutation DOM
|
|
169
|
+
* @param {number} input.targetSourceIndex - zero-based body paragraph position of the target in `before`
|
|
170
|
+
*/
|
|
171
|
+
export function verifyUntrackedLocality({ before, after, targetSourceIndex, beforeViews = null, afterViews = null }) {
|
|
172
|
+
const violations = [];
|
|
173
|
+
for (const view of ['accepted', 'rejected']) {
|
|
174
|
+
const expected = memoView(beforeViews, before, view);
|
|
175
|
+
const actual = memoView(afterViews, after, view);
|
|
176
|
+
const targetPosition = expected.findIndex(record => record.sourceIndex >= targetSourceIndex);
|
|
177
|
+
if (targetPosition < 0) continue;
|
|
178
|
+
const needSuffix = expected.length - targetPosition - 1;
|
|
179
|
+
let prefix = 0;
|
|
180
|
+
while (prefix < targetPosition && prefix < actual.length && sameRecord(expected[prefix], actual[prefix])) prefix++;
|
|
181
|
+
let suffix = 0;
|
|
182
|
+
while (suffix < needSuffix && suffix < actual.length - prefix
|
|
183
|
+
&& sameRecord(expected[expected.length - 1 - suffix], actual[actual.length - 1 - suffix])) suffix++;
|
|
184
|
+
if (prefix < targetPosition) {
|
|
185
|
+
violations.push(neighborViolation(prefix + 1, expected[prefix], actual[prefix], view));
|
|
186
|
+
} else if (suffix < needSuffix) {
|
|
187
|
+
const index = expected.length - 1 - suffix;
|
|
188
|
+
violations.push(neighborViolation(index + 1, expected[index], actual[actual.length - 1 - suffix], view));
|
|
189
|
+
}
|
|
190
|
+
if (violations.length) break;
|
|
191
|
+
}
|
|
192
|
+
return violations.length ? { ok: false, violations } : { ok: true };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const numberingDefinitionCache = new Map();
|
|
196
|
+
const NUMBERING_DEFINITION_CACHE_SIZE = 8;
|
|
197
|
+
|
|
198
|
+
/** Parses one numbering part's abstractNum levels and num -> abstractNum links; memoized by the part text. */
|
|
199
|
+
function parseNumberingDefinitions(text) {
|
|
200
|
+
const cached = numberingDefinitionCache.get(text);
|
|
201
|
+
if (cached) return cached;
|
|
202
|
+
const abstractLevels = new Map();
|
|
203
|
+
for (const match of text.matchAll(/<(?:\w+:)?abstractNum([^>]*)>([\s\S]*?)<\/(?:\w+:)?abstractNum>/g)) {
|
|
204
|
+
const id = /abstractNumId="([^"]*)"/.exec(match[1])?.[1];
|
|
205
|
+
if (id == null) continue;
|
|
206
|
+
const levels = abstractLevels.get(id) || new Set();
|
|
207
|
+
for (const lvl of match[2].matchAll(/<(?:\w+:)?lvl[^>]*?ilvl="([^"]*)"/g)) levels.add(lvl[1]);
|
|
208
|
+
abstractLevels.set(id, levels);
|
|
209
|
+
}
|
|
210
|
+
const numToAbstract = new Map();
|
|
211
|
+
for (const match of text.matchAll(/<(?:\w+:)?num([^>]*)>([\s\S]*?)<\/(?:\w+:)?num>/g)) {
|
|
212
|
+
const id = /numId="([^"]*)"/.exec(match[1])?.[1];
|
|
213
|
+
const abstractId = /abstractNumId[^>]*?val="([^"]*)"/.exec(match[2])?.[1];
|
|
214
|
+
if (id != null && abstractId != null) numToAbstract.set(id, abstractId);
|
|
215
|
+
}
|
|
216
|
+
const definitions = { abstractLevels, numToAbstract };
|
|
217
|
+
if (numberingDefinitionCache.size >= NUMBERING_DEFINITION_CACHE_SIZE) numberingDefinitionCache.delete(numberingDefinitionCache.keys().next().value);
|
|
218
|
+
numberingDefinitionCache.set(text, definitions);
|
|
219
|
+
return definitions;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Numbering-part existence (REJECT_NUMBERING_REFS, part 2). Every numId the output binds or that
|
|
224
|
+
* Reject All would restore (current numPr and the historical numPr inside w:pPrChange) and that the
|
|
225
|
+
* source did not already reference must resolve to a w:num -> w:abstractNum -> w:lvl in the numbering
|
|
226
|
+
* parts available at the seam (source numbering plus parts generated in the session and by this operation).
|
|
227
|
+
* Ids the source already used are the source's responsibility and are not re-checked.
|
|
228
|
+
*
|
|
229
|
+
* @param {Object} input
|
|
230
|
+
* @param {Document} input.before - savepoint DOM
|
|
231
|
+
* @param {Document} input.after - post-mutation DOM
|
|
232
|
+
* @param {Array<string|null|undefined>} input.numberingXmls - numbering part texts available at the seam
|
|
233
|
+
*/
|
|
234
|
+
export function verifyNumberingReferences({ before, after, numberingXmls, beforeViews = null, afterViews = null }) {
|
|
235
|
+
const references = documentNode => {
|
|
236
|
+
const found = new Map();
|
|
237
|
+
for (const numIdNode of getAllReferencedNumIdElements(documentNode)) {
|
|
238
|
+
const numId = numIdNode.getAttribute('w:val');
|
|
239
|
+
if (numId === null || numId === '' || numId === '0') continue;
|
|
240
|
+
const ilvlNode = Array.from(numIdNode.parentNode?.childNodes || []).find(child => child.nodeType === 1 && nameOf(child) === 'ilvl');
|
|
241
|
+
const ilvl = ilvlNode?.getAttribute('w:val') || '0';
|
|
242
|
+
if (!found.has(numId)) found.set(numId, new Set());
|
|
243
|
+
found.get(numId).add(ilvl);
|
|
244
|
+
}
|
|
245
|
+
return found;
|
|
246
|
+
};
|
|
247
|
+
const used = references(after);
|
|
248
|
+
if (used.size === 0) return { ok: true };
|
|
249
|
+
const known = references(before);
|
|
250
|
+
const text = (numberingXmls || []).filter(Boolean).join('\n');
|
|
251
|
+
const abstractLevels = new Map();
|
|
252
|
+
for (const match of text.matchAll(/<(?:\w+:)?abstractNum\b([^>]*)>([\s\S]*?)<\/(?:\w+:)?abstractNum>/g)) {
|
|
253
|
+
const id = /abstractNumId="([^"]*)"/.exec(match[1])?.[1];
|
|
254
|
+
if (id == null) continue;
|
|
255
|
+
const levels = abstractLevels.get(id) || new Set();
|
|
256
|
+
for (const lvl of match[2].matchAll(/<(?:\w+:)?lvl\b[^>]*?ilvl="([^"]*)"/g)) levels.add(lvl[1]);
|
|
257
|
+
abstractLevels.set(id, levels);
|
|
258
|
+
}
|
|
259
|
+
const numToAbstract = new Map();
|
|
260
|
+
for (const match of text.matchAll(/<(?:\w+:)?num\b([^>]*)>([\s\S]*?)<\/(?:\w+:)?num>/g)) {
|
|
261
|
+
const id = /numId="([^"]*)"/.exec(match[1])?.[1];
|
|
262
|
+
const abstractId = /abstractNumId[^>]*?val="([^"]*)"/.exec(match[2])?.[1];
|
|
263
|
+
if (id != null && abstractId != null) numToAbstract.set(id, abstractId);
|
|
264
|
+
}
|
|
265
|
+
const violations = [];
|
|
266
|
+
for (const [numId, levels] of used) {
|
|
267
|
+
if (known.has(numId)) continue;
|
|
268
|
+
const abstractId = numToAbstract.get(numId);
|
|
269
|
+
const available = abstractId != null ? abstractLevels.get(abstractId) : null;
|
|
270
|
+
let actual = null;
|
|
271
|
+
if (abstractId == null) actual = `numId ${numId} has no w:num in the output numbering`;
|
|
272
|
+
else if (!available) actual = `numId ${numId} points at missing w:abstractNum ${abstractId}`;
|
|
273
|
+
else {
|
|
274
|
+
const missing = [...levels].find(level => !available.has(level));
|
|
275
|
+
if (missing !== undefined) actual = `numId ${numId} level ${missing} is not defined by w:abstractNum ${abstractId}`;
|
|
276
|
+
}
|
|
277
|
+
if (actual === null) continue;
|
|
278
|
+
violations.push({
|
|
279
|
+
invariant: 'REJECT_NUMBERING_REFS',
|
|
280
|
+
paragraph: null,
|
|
281
|
+
expected: `numId ${numId} defined in the output numbering`,
|
|
282
|
+
actual,
|
|
283
|
+
explanation: 'The output (or what Reject All restores) references a list numId that no numbering definition in the output provides, so Word would drop or renumber the list.',
|
|
284
|
+
source: null,
|
|
285
|
+
output: null
|
|
286
|
+
});
|
|
287
|
+
if (violations.length >= MAX_REPORTED_VIOLATIONS) break;
|
|
288
|
+
}
|
|
289
|
+
return violations.length ? { ok: false, violations } : { ok: true };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const STRUCTURE_CHARACTERS = /\+\+|==|[*_#>`|~\[\]\\]/;
|
|
293
|
+
const LIST_MARKER_LINE = /^\s*(?:[-+•●]|[A-Za-z0-9]{1,5}[.)]|\d[0-9.]*[.)]?|\(\w{1,4}\))\s/m;
|
|
294
|
+
|
|
295
|
+
/** True when `modified` is plain text whose Accept All result is derivable verbatim (no Markdown, list or table syntax). */
|
|
296
|
+
export function isPlainTextIntent(modified) {
|
|
297
|
+
return typeof modified === 'string' && modified.length > 0
|
|
298
|
+
&& !STRUCTURE_CHARACTERS.test(modified) && !LIST_MARKER_LINE.test(modified);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* ACCEPT_INTENT: Accept All of a plain-text redline's target range must equal the declared complete
|
|
303
|
+
* `modified` text (accepted-view paragraph texts joined by a newline). The range is located in the
|
|
304
|
+
* accepted view of the output by the untouched paragraphs before and after it.
|
|
305
|
+
*
|
|
306
|
+
* @param {Object} input
|
|
307
|
+
* @param {Document} input.before - savepoint DOM
|
|
308
|
+
* @param {Document} input.after - post-mutation DOM
|
|
309
|
+
* @param {{ from: number, to: number }} input.range - zero-based document positions of the target range in `before`
|
|
310
|
+
* @param {string} input.modified - declared complete replacement text
|
|
311
|
+
*/
|
|
312
|
+
export function verifyAcceptIntent({ before, after, range, modified, beforeViews = null, afterViews = null }) {
|
|
313
|
+
const sourceView = memoView(beforeViews, before, 'accepted');
|
|
314
|
+
const outputView = memoView(afterViews, after, 'accepted');
|
|
315
|
+
const prefix = sourceView.filter(record => record.sourceIndex < range.from).length;
|
|
316
|
+
const suffix = sourceView.filter(record => record.sourceIndex > range.to).length;
|
|
317
|
+
const targetRecords = sourceView.filter(record => record.sourceIndex >= range.from && record.sourceIndex <= range.to);
|
|
318
|
+
// Skip when the target is not a plain run of body paragraphs in the accepted view (a foreign deleted
|
|
319
|
+
// paragraph mark merges paragraphs there, so the target cannot be located by position).
|
|
320
|
+
if (targetRecords.length !== range.to - range.from + 1 || targetRecords.some(record => record.container !== 'body')) {
|
|
321
|
+
return { ok: true, skipped: 'target not positionally comparable' };
|
|
322
|
+
}
|
|
323
|
+
const region = outputView.slice(prefix, Math.max(prefix, outputView.length - suffix));
|
|
324
|
+
const actual = region.map(record => record.text).join('\n');
|
|
325
|
+
// Paragraph boundaries may differ (a list adjacency heuristic splits a concatenated edit into items);
|
|
326
|
+
// the text itself must be exactly what was declared.
|
|
327
|
+
const flat = value => value.replace(/\r?\n/g, '');
|
|
328
|
+
if (actual === modified || flat(actual) === flat(modified)) return { ok: true };
|
|
329
|
+
return {
|
|
330
|
+
ok: false,
|
|
331
|
+
violations: [{
|
|
332
|
+
invariant: 'ACCEPT_INTENT',
|
|
333
|
+
paragraph: null,
|
|
334
|
+
expected: modified,
|
|
335
|
+
actual,
|
|
336
|
+
explanation: REVISION_FIDELITY_INVARIANTS.ACCEPT_INTENT.explanation,
|
|
337
|
+
source: targetRecords[0] ? describeRecord(targetRecords[0]) : null,
|
|
338
|
+
output: region[0] ? describeRecord(region[0]) : null
|
|
339
|
+
}]
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const REVISION_ELEMENTS = new Set([
|
|
344
|
+
'ins', 'del', 'moveFrom', 'moveTo', 'rPrChange', 'pPrChange', 'sectPrChange', 'tblPrChange',
|
|
345
|
+
'tblGridChange', 'tblPrExChange', 'trPrChange', 'tcPrChange', 'numberingChange',
|
|
346
|
+
'cellIns', 'cellDel', 'cellMerge'
|
|
347
|
+
]);
|
|
348
|
+
|
|
349
|
+
const nameOf = node => node.localName || String(node.nodeName).replace(/^.*:/, '');
|
|
350
|
+
|
|
351
|
+
function collectRevisionIds(documentNode) {
|
|
352
|
+
const ids = new Map();
|
|
353
|
+
const stack = [documentNode];
|
|
354
|
+
while (stack.length) {
|
|
355
|
+
const node = stack.pop();
|
|
356
|
+
if (node.nodeType === 1 && REVISION_ELEMENTS.has(nameOf(node))) {
|
|
357
|
+
const id = node.getAttribute('w:id');
|
|
358
|
+
if (id !== null && !ids.has(id)) ids.set(id, node);
|
|
359
|
+
}
|
|
360
|
+
for (let child = node.firstChild; child; child = child.nextSibling) stack.push(child);
|
|
361
|
+
}
|
|
362
|
+
return ids;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* An untracked operation (generateRedlines: false) must not create any revision: every revision
|
|
367
|
+
* id (w:id on a revision element) in the output must already exist in the source, or be a fragment
|
|
368
|
+
* of a source revision sliced around the edit (same element, author and date).
|
|
369
|
+
*
|
|
370
|
+
* @param {Object} input
|
|
371
|
+
* @param {Document} input.before - savepoint DOM
|
|
372
|
+
* @param {Document} input.after - post-mutation DOM
|
|
373
|
+
*/
|
|
374
|
+
export function verifyNoCreatedRevisions({ before, after, beforeViews = null, afterViews = null }) {
|
|
375
|
+
// Slicing a foreign revision around an untracked edit gives the fragments fresh ids but keeps the
|
|
376
|
+
// original element, author and date; that preserves the reviewer's revision rather than creating one.
|
|
377
|
+
const signature = node => `${nameOf(node)}|${node.getAttribute('w:author')}|${node.getAttribute('w:date')}`;
|
|
378
|
+
const signaturesOf = ids => new Map([...ids].map(([id, node]) => [id, signature(node)]));
|
|
379
|
+
// Cached as plain strings: the cached map's nodes may belong to a DOM that has since been mutated.
|
|
380
|
+
let existing = beforeViews?.revisionSignatures;
|
|
381
|
+
if (!existing) {
|
|
382
|
+
existing = signaturesOf(collectRevisionIds(before));
|
|
383
|
+
if (beforeViews) beforeViews.revisionSignatures = existing;
|
|
384
|
+
}
|
|
385
|
+
const afterIds = collectRevisionIds(after);
|
|
386
|
+
if (afterViews) afterViews.revisionSignatures = signaturesOf(afterIds);
|
|
387
|
+
const sliceable = new Set(existing.values());
|
|
388
|
+
const violations = [];
|
|
389
|
+
for (const [id, node] of afterIds) {
|
|
390
|
+
if (existing.has(id) || sliceable.has(signature(node))) continue;
|
|
391
|
+
const name = nameOf(node);
|
|
392
|
+
const author = node.getAttribute('w:author');
|
|
393
|
+
const text = excerpt(node.textContent || '');
|
|
394
|
+
violations.push({
|
|
395
|
+
invariant: 'UNTRACKED_CREATED_REVISION',
|
|
396
|
+
paragraph: null,
|
|
397
|
+
expected: 'no new revisions',
|
|
398
|
+
actual: `w:${name} w:id=${id}${author ? ` by ${author}` : ''}${text ? ` ("${text}")` : ''}`,
|
|
399
|
+
explanation: REVISION_FIDELITY_INVARIANTS.UNTRACKED_CREATED_REVISION.explanation,
|
|
400
|
+
source: null,
|
|
401
|
+
output: null
|
|
402
|
+
});
|
|
403
|
+
if (violations.length >= MAX_REPORTED_VIOLATIONS) break;
|
|
404
|
+
}
|
|
405
|
+
return violations.length ? { ok: false, violations } : { ok: true };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function neighborViolation(paragraph, sourceRecord, outputRecord, view) {
|
|
409
|
+
return {
|
|
410
|
+
invariant: 'ACCEPT_NEIGHBORS',
|
|
411
|
+
paragraph,
|
|
412
|
+
view,
|
|
413
|
+
expected: excerpt(sourceRecord?.text ?? null),
|
|
414
|
+
actual: excerpt(outputRecord?.text ?? null),
|
|
415
|
+
explanation: REVISION_FIDELITY_INVARIANTS.ACCEPT_NEIGHBORS.explanation,
|
|
416
|
+
source: describeRecord(sourceRecord),
|
|
417
|
+
output: describeRecord(outputRecord)
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Accept-first policies accept only the target's revisions. With a known target scope
|
|
423
|
+
* (`{ from, to }`, zero-based body paragraph positions) those source paragraphs take the accepted
|
|
424
|
+
* view and all others the rejected view. Without one, the unchanged prefix and suffix keep the
|
|
425
|
+
* rejected view and the paragraphs between them take the accepted view (weaker: a lost revision
|
|
426
|
+
* adjacent to the target is absorbed).
|
|
427
|
+
*/
|
|
428
|
+
function spliceAcceptedRegion(rejected, accepted, actual, scope) {
|
|
429
|
+
if (scope && Number.isInteger(scope.from) && Number.isInteger(scope.to)) {
|
|
430
|
+
const inScope = record => record.sourceIndex >= scope.from && record.sourceIndex <= scope.to;
|
|
431
|
+
return [...rejected.filter(record => !inScope(record)), ...accepted.filter(inScope)]
|
|
432
|
+
.sort((a, b) => a.sourceIndex - b.sourceIndex);
|
|
433
|
+
}
|
|
434
|
+
let prefix = 0;
|
|
435
|
+
while (prefix < rejected.length && prefix < actual.length && sameRecord(rejected[prefix], actual[prefix])) prefix++;
|
|
436
|
+
let suffix = 0;
|
|
437
|
+
while (suffix < rejected.length - prefix && suffix < actual.length - prefix
|
|
438
|
+
&& sameRecord(rejected[rejected.length - 1 - suffix], actual[actual.length - 1 - suffix])) suffix++;
|
|
439
|
+
if (prefix + suffix === rejected.length && prefix + suffix === actual.length) return rejected;
|
|
440
|
+
const low = prefix > 0 ? rejected[prefix - 1].sourceIndex : -1;
|
|
441
|
+
const high = suffix > 0 ? rejected[rejected.length - suffix].sourceIndex : Infinity;
|
|
442
|
+
const middle = accepted.filter(record => record.sourceIndex > low && record.sourceIndex < high);
|
|
443
|
+
return [...rejected.slice(0, prefix), ...middle, ...rejected.slice(rejected.length - suffix)];
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Builds the structured error payload for a violation result. The message alone is enough for a
|
|
448
|
+
* log line; `violations` and `recovery` carry the machine-readable detail for a retry.
|
|
449
|
+
*/
|
|
450
|
+
export function buildRevisionFidelityError(violations, context = {}) {
|
|
451
|
+
const first = violations[0];
|
|
452
|
+
const where = first.source
|
|
453
|
+
? `source paragraph ${first.source.sourceParagraphIndex}${first.source.text ? ` ("${first.source.text}")` : ''}`
|
|
454
|
+
: `paragraph ${first.paragraph} of the Reject All view`;
|
|
455
|
+
const invariants = [...new Set(violations.map(violation => violation.invariant))];
|
|
456
|
+
const mode = context.mode || DEFAULT_REVISION_FIDELITY_MODE;
|
|
457
|
+
return {
|
|
458
|
+
code: 'REVISION_FIDELITY_VIOLATION',
|
|
459
|
+
stage: 'verification',
|
|
460
|
+
message: `${mode === 'warn' ? 'Applied with a fidelity warning' : 'Refused, document unchanged'}: `
|
|
461
|
+
+ (first.invariant === 'UNTRACKED_CREATED_REVISION'
|
|
462
|
+
? `this untracked operation created a tracked revision (${first.invariant}: `
|
|
463
|
+
: first.invariant === 'ACCEPT_NEIGHBORS'
|
|
464
|
+
? `this untracked operation changed a paragraph outside its target (${first.invariant} in the ${first.view} view at ${where}: `
|
|
465
|
+
: first.invariant === 'ACCEPT_INTENT'
|
|
466
|
+
? `Accept All of this operation's target would not read as the requested text (${first.invariant} at ${where}: `
|
|
467
|
+
: `Reject All of this operation's output would not restore the source (${first.invariant} at ${where}: `)
|
|
468
|
+
+ (first.invariant === 'UNTRACKED_CREATED_REVISION'
|
|
469
|
+
? `found ${first.actual}). `
|
|
470
|
+
: `expected ${JSON.stringify(first.expected)}, got ${JSON.stringify(first.actual)}). `)
|
|
471
|
+
+ `${first.explanation} `
|
|
472
|
+
+ `Retry: ${REVISION_FIDELITY_INVARIANTS[first.invariant].retry}`,
|
|
473
|
+
violations,
|
|
474
|
+
invariants,
|
|
475
|
+
verificationMode: mode,
|
|
476
|
+
...(context.baselineView === 'accepted' ? { baselineView: 'accepted' } : {}),
|
|
477
|
+
recovery: {
|
|
478
|
+
hint: REVISION_FIDELITY_INVARIANTS[first.invariant].retry,
|
|
479
|
+
retryGuidance: invariants.map(invariant => ({ invariant, retry: REVISION_FIDELITY_INVARIANTS[invariant].retry })),
|
|
480
|
+
reportIfNarrowRetryFails: 'If a narrower operation on the same paragraph is also refused, the shape is unsupported: report the violations with the operation; do not disable verification to force it through.'
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Baseline view for an existing-revisions policy: accept-first policies edit the accepted source. */
|
|
486
|
+
export function revisionFidelityBaselineView(existingRevisions) {
|
|
487
|
+
return String(existingRevisions || '').startsWith('accept-all-first') ? 'accepted' : 'rejected';
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Resolves the effective mode. An explicit option wins; otherwise the diagnostic
|
|
492
|
+
* DOCX_REDLINE_REVISION_FIDELITY environment variable (test lanes); otherwise 'enforce'.
|
|
493
|
+
*/
|
|
494
|
+
export function resolveRevisionFidelityMode(value) {
|
|
495
|
+
if (REVISION_FIDELITY_MODES.includes(value)) return value;
|
|
496
|
+
const fromEnvironment = globalThis.process?.env?.DOCX_REDLINE_REVISION_FIDELITY;
|
|
497
|
+
return REVISION_FIDELITY_MODES.includes(fromEnvironment) ? fromEnvironment : DEFAULT_REVISION_FIDELITY_MODE;
|
|
498
|
+
}
|