@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,256 @@
|
|
|
1
|
+
import { NS_W } from './types.js';
|
|
2
|
+
import { readCanonicalRunText } from './paragraph-text.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Word-semantics Reject All / Accept All projection over a WordprocessingML DOM.
|
|
6
|
+
*
|
|
7
|
+
* The projection is a list of paragraph records describing what Word shows after
|
|
8
|
+
* rejecting or accepting every tracked revision. It never mutates the input and
|
|
9
|
+
* never serializes. Rules (Word behavior):
|
|
10
|
+
* - a paragraph mark in w:pPr/w:rPr/w:ins (or moveTo) is removed on Reject; the
|
|
11
|
+
* paragraph's remaining content merges into the FOLLOWING paragraph of the same
|
|
12
|
+
* container, whose mark and properties survive. A deleted mark (w:del/moveFrom)
|
|
13
|
+
* is removed on Accept with the same merge rule.
|
|
14
|
+
* - at the end of a container: Reject keeps a table cell's last paragraph; otherwise a
|
|
15
|
+
* trailing paragraph left empty by the removed mark disappears.
|
|
16
|
+
* - w:ins/moveTo content is removed on Reject, w:del/moveFrom restored; Accept is
|
|
17
|
+
* the converse. Rows whose w:trPr carries w:ins (Reject) or w:del (Accept) vanish.
|
|
18
|
+
* - Reject restores the w:pPrChange snapshot as the paragraph's properties.
|
|
19
|
+
* - a w:body whose last block is a table gets one untracked empty paragraph from Word on
|
|
20
|
+
* open (a body must end with a paragraph). It is not a revision, so it survives Reject and
|
|
21
|
+
* Accept alike: such a body counts one extra paragraph in both views even when Reject
|
|
22
|
+
* removes the whole table (Word 16 COM evidence, R5).
|
|
23
|
+
*
|
|
24
|
+
* Shared by the post-apply fidelity verifier and the reject/accept tests.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const CONTAINER_NAMES = new Set(['body', 'tc', 'txbxContent', 'footnote', 'endnote', 'comment', 'hdr', 'ftr']);
|
|
28
|
+
const WRAPPER_NAMES = new Set(['hyperlink', 'fldSimple', 'sdt', 'smartTag', 'customXml']);
|
|
29
|
+
const PROPERTY_SKIP = new Set(['rPr', 'pPrChange', 'sectPr']);
|
|
30
|
+
|
|
31
|
+
const localName = node => {
|
|
32
|
+
const local = node?.localName;
|
|
33
|
+
if (local) return local.indexOf(':') < 0 ? local : local.replace(/^.*:/, '');
|
|
34
|
+
return String(node?.nodeName || '').replace(/^.*:/, '');
|
|
35
|
+
};
|
|
36
|
+
const isWordElement = node => node?.nodeType === 1 && (!node.namespaceURI || node.namespaceURI === NS_W);
|
|
37
|
+
const attr = (node, name) => node?.getAttribute?.(`w:${name}`) || node?.getAttribute?.(name) || '';
|
|
38
|
+
|
|
39
|
+
/** Document-order w:<name> descendants of `root`; a plain walk avoids the live node list's per-call cost. */
|
|
40
|
+
function descendantWordElements(root, name) {
|
|
41
|
+
const found = [];
|
|
42
|
+
if (!root) return found;
|
|
43
|
+
const stack = [];
|
|
44
|
+
for (let child = root.lastChild; child; child = child.previousSibling) stack.push(child);
|
|
45
|
+
while (stack.length) {
|
|
46
|
+
const node = stack.pop();
|
|
47
|
+
if (node.nodeType !== 1) continue;
|
|
48
|
+
if (node.namespaceURI === NS_W && node.localName === name) found.push(node);
|
|
49
|
+
for (let child = node.lastChild; child; child = child.previousSibling) stack.push(child);
|
|
50
|
+
}
|
|
51
|
+
return found;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function directChild(node, name) {
|
|
55
|
+
for (let child = node?.firstChild; child; child = child.nextSibling) {
|
|
56
|
+
if (isWordElement(child) && localName(child) === name) return child;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Revision kind on the paragraph mark: 'ins' | 'del' | null (moveTo/moveFrom map to ins/del). */
|
|
62
|
+
export function paragraphMarkRevision(paragraph) {
|
|
63
|
+
const rPr = directChild(directChild(paragraph, 'pPr'), 'rPr');
|
|
64
|
+
if (!rPr) return null;
|
|
65
|
+
for (let child = rPr.firstChild; child; child = child.nextSibling) {
|
|
66
|
+
if (!isWordElement(child)) continue;
|
|
67
|
+
const name = localName(child);
|
|
68
|
+
if (name === 'ins' || name === 'moveTo') return 'ins';
|
|
69
|
+
if (name === 'del' || name === 'moveFrom') return 'del';
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function containerOf(paragraph) {
|
|
75
|
+
for (let cur = paragraph.parentNode; cur; cur = cur.parentNode) {
|
|
76
|
+
if (CONTAINER_NAMES.has(localName(cur))) return cur;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function hiddenByStructure(paragraph, view) {
|
|
82
|
+
const revisionName = view === 'rejected' ? 'ins' : 'del';
|
|
83
|
+
for (let cur = paragraph.parentNode; cur; cur = cur.parentNode) {
|
|
84
|
+
const name = localName(cur);
|
|
85
|
+
if (name === 'tr') {
|
|
86
|
+
if (directChild(directChild(cur, 'trPr'), revisionName)) return true;
|
|
87
|
+
} else if (name === 'tc') {
|
|
88
|
+
const cellRevision = view === 'rejected' ? 'cellIns' : 'cellDel';
|
|
89
|
+
if (directChild(directChild(cur, 'tcPr'), cellRevision)) return true;
|
|
90
|
+
} else if (name === 'body') {
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function canonicalElement(node) {
|
|
98
|
+
const attrs = Array.from(node.attributes || [])
|
|
99
|
+
.filter(a => !/^xmlns(:|$)/.test(a.name) && !/(^|:)(rsid\w*|paraId|textId)$/.test(a.name))
|
|
100
|
+
.map(a => `${a.name.replace(/^.*:/, '')}=${a.value}`)
|
|
101
|
+
.sort();
|
|
102
|
+
const children = Array.from(node.childNodes || [])
|
|
103
|
+
.filter(isWordElement)
|
|
104
|
+
.map(canonicalElement)
|
|
105
|
+
.sort();
|
|
106
|
+
return `${localName(node)}[${attrs.join(',')}]{${children.join('')}}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Properties a paragraph carries in a view (Reject restores the pPrChange snapshot). */
|
|
110
|
+
function viewProperties(paragraph, view) {
|
|
111
|
+
const pPr = directChild(paragraph, 'pPr');
|
|
112
|
+
let source = pPr;
|
|
113
|
+
if (view === 'rejected') {
|
|
114
|
+
const change = directChild(pPr, 'pPrChange');
|
|
115
|
+
if (change) source = directChild(change, 'pPr');
|
|
116
|
+
}
|
|
117
|
+
let numPr = null;
|
|
118
|
+
const children = [];
|
|
119
|
+
for (const child of Array.from(source?.childNodes || [])) {
|
|
120
|
+
if (!isWordElement(child) || PROPERTY_SKIP.has(localName(child))) continue;
|
|
121
|
+
if (localName(child) === 'numPr') {
|
|
122
|
+
numPr = {
|
|
123
|
+
numId: attr(directChild(child, 'numId'), 'val') || null,
|
|
124
|
+
ilvl: attr(directChild(child, 'ilvl'), 'val') || '0'
|
|
125
|
+
};
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
children.push(canonicalElement(child));
|
|
129
|
+
}
|
|
130
|
+
return { props: children.sort().join(''), numPr };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function wrapperKey(node) {
|
|
134
|
+
const name = localName(node);
|
|
135
|
+
if (name === 'hyperlink') {
|
|
136
|
+
return `hyperlink:${node.getAttribute?.('r:id') || attr(node, 'anchor') || ''}`;
|
|
137
|
+
}
|
|
138
|
+
if (name === 'fldSimple') return `fldSimple:${attr(node, 'instr').trim()}`;
|
|
139
|
+
if (name === 'sdt') {
|
|
140
|
+
const sdtPr = directChild(node, 'sdtPr');
|
|
141
|
+
return `sdt:${attr(directChild(sdtPr, 'id'), 'val')}:${attr(directChild(sdtPr, 'tag'), 'val')}`;
|
|
142
|
+
}
|
|
143
|
+
return `${name}:${attr(node, 'element') || attr(node, 'uri')}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Visible text and inline wrapper spans of one paragraph in a view. */
|
|
147
|
+
function paragraphContent(paragraph, view) {
|
|
148
|
+
let text = '';
|
|
149
|
+
const spans = [];
|
|
150
|
+
const walk = (node, wrappers) => {
|
|
151
|
+
for (let child = node.firstChild; child; child = child.nextSibling) {
|
|
152
|
+
if (!isWordElement(child)) continue;
|
|
153
|
+
const name = localName(child);
|
|
154
|
+
if (name === 'pPr') continue;
|
|
155
|
+
if (view === 'rejected' && (name === 'ins' || name === 'moveTo')) continue;
|
|
156
|
+
if (view === 'accepted' && (name === 'del' || name === 'moveFrom')) continue;
|
|
157
|
+
if (name === 'r') {
|
|
158
|
+
const start = text.length;
|
|
159
|
+
text += readCanonicalRunText(child, { revisionView: view, boundary: paragraph });
|
|
160
|
+
if (text.length > start) {
|
|
161
|
+
for (const key of wrappers) spans.push({ key, start, end: text.length });
|
|
162
|
+
}
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
walk(child, WRAPPER_NAMES.has(name) ? [...wrappers, wrapperKey(child)] : wrappers);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
walk(paragraph, []);
|
|
169
|
+
return { text, spans };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function mergeSpans(spans) {
|
|
173
|
+
const byKey = new Map();
|
|
174
|
+
for (const span of spans) {
|
|
175
|
+
const list = byKey.get(span.key) || [];
|
|
176
|
+
const last = list[list.length - 1];
|
|
177
|
+
if (last && last.end >= span.start) last.end = Math.max(last.end, span.end);
|
|
178
|
+
else list.push({ ...span });
|
|
179
|
+
byKey.set(span.key, list);
|
|
180
|
+
}
|
|
181
|
+
return [...byKey.values()].flat()
|
|
182
|
+
.sort((a, b) => a.start - b.start || a.end - b.end || (a.key < b.key ? -1 : 1))
|
|
183
|
+
.map(span => `${span.key}@${span.start}-${span.end}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Projects a document (or any element containing paragraphs) into the paragraph list Word
|
|
188
|
+
* shows after Reject All (`view: 'rejected'`) or Accept All (`view: 'accepted'`).
|
|
189
|
+
*
|
|
190
|
+
* @returns {Array<{ text: string, props: string, numPr: {numId: string|null, ilvl: string}|null,
|
|
191
|
+
* wrappers: string[], container: string, sourceIndex: number }>}
|
|
192
|
+
*/
|
|
193
|
+
export function projectWordRevisionView(root, view = 'rejected') {
|
|
194
|
+
if (view !== 'rejected' && view !== 'accepted') throw new Error(`Unknown revision view '${view}'.`);
|
|
195
|
+
const paragraphs = descendantWordElements(root, 'p');
|
|
196
|
+
const removedMark = view === 'rejected' ? 'ins' : 'del';
|
|
197
|
+
const records = [];
|
|
198
|
+
const pending = new Map();
|
|
199
|
+
paragraphs.forEach((paragraph, sourceIndex) => {
|
|
200
|
+
if (hiddenByStructure(paragraph, view)) return;
|
|
201
|
+
const container = containerOf(paragraph);
|
|
202
|
+
const carried = pending.get(container) || null;
|
|
203
|
+
pending.delete(container);
|
|
204
|
+
const { text, spans } = paragraphContent(paragraph, view);
|
|
205
|
+
const offset = carried ? carried.text.length : 0;
|
|
206
|
+
const merged = {
|
|
207
|
+
text: (carried?.text || '') + text,
|
|
208
|
+
spans: [...(carried?.spans || []), ...spans.map(s => ({ ...s, start: s.start + offset, end: s.end + offset }))]
|
|
209
|
+
};
|
|
210
|
+
if (paragraphMarkRevision(paragraph) === removedMark) {
|
|
211
|
+
pending.set(container, { ...merged, paragraph, sourceIndex });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
records.push(finishRecord(paragraph, merged, view, container, sourceIndex));
|
|
215
|
+
});
|
|
216
|
+
// A removed mark on the last paragraph of a container (Word 16 COM evidence,
|
|
217
|
+
// tests/fixtures/word-revision-semantics/expectations.json): Reject keeps a cell's last
|
|
218
|
+
// paragraph; otherwise an emptied trailing paragraph disappears and the previous mark becomes
|
|
219
|
+
// the container's last. Non-empty trailing content is kept (not yet pinned by COM evidence).
|
|
220
|
+
for (const [container, carried] of pending) {
|
|
221
|
+
const keepCellParagraph = view === 'rejected' && localName(container) === 'tc';
|
|
222
|
+
if (!keepCellParagraph && carried.text.length === 0) continue;
|
|
223
|
+
records.push(finishRecord(carried.paragraph, carried, view, container, carried.sourceIndex));
|
|
224
|
+
}
|
|
225
|
+
records.sort((a, b) => a.sourceIndex - b.sourceIndex);
|
|
226
|
+
appendImplicitBodyParagraphs(root, paragraphs, records);
|
|
227
|
+
return records;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Word adds an untracked empty paragraph after a table that ends a body. */
|
|
231
|
+
function appendImplicitBodyParagraphs(root, paragraphs, records) {
|
|
232
|
+
const bodies = localName(root) === 'body' ? [root] : descendantWordElements(root, 'body');
|
|
233
|
+
for (const body of bodies) {
|
|
234
|
+
const blocks = Array.from(body.childNodes || []).filter(node => isWordElement(node) && localName(node) !== 'sectPr');
|
|
235
|
+
const last = blocks[blocks.length - 1];
|
|
236
|
+
if (!last || localName(last) !== 'tbl') continue;
|
|
237
|
+
records.push({ text: '', props: '', numPr: null, wrappers: [], container: 'body', sourceIndex: paragraphs.length, implicit: true });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function finishRecord(paragraph, content, view, container, sourceIndex) {
|
|
242
|
+
const { props, numPr } = viewProperties(paragraph, view);
|
|
243
|
+
return {
|
|
244
|
+
text: content.text,
|
|
245
|
+
props,
|
|
246
|
+
numPr,
|
|
247
|
+
wrappers: mergeSpans(content.spans),
|
|
248
|
+
container: container ? localName(container) : 'root',
|
|
249
|
+
sourceIndex
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Plain text per paragraph after Word Reject All / Accept All. */
|
|
254
|
+
export function wordRevisionParagraphTexts(root, view = 'rejected') {
|
|
255
|
+
return projectWordRevisionView(root, view).map(record => record.text);
|
|
256
|
+
}
|