@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.
Files changed (68) hide show
  1. package/ARCHITECTURE.md +221 -12
  2. package/CHANGELOG.md +176 -0
  3. package/README.md +125 -12
  4. package/core/current-properties.js +136 -0
  5. package/core/paragraph-text.js +211 -209
  6. package/core/types.js +9 -3
  7. package/core/word-revision-semantics.js +256 -0
  8. package/dist/docx-redline-js.esm.js +4189 -767
  9. package/dist/docx-redline-js.esm.js.map +4 -4
  10. package/dist/docx-redline-js.esm.min.js +111 -108
  11. package/dist/docx-redline-js.esm.min.js.map +4 -4
  12. package/dist/docx-redline.bundle.cjs +4292 -870
  13. package/dist/docx-redline.bundle.cjs.map +4 -4
  14. package/dist/docx-redline.bundle.js +4292 -870
  15. package/dist/docx-redline.bundle.js.map +4 -4
  16. package/docs/AGENT_FAST_START.md +2 -0
  17. package/docs/AGENT_KNOWLEDGE_BASE.md +19 -3
  18. package/docs/SKILL_AUTHORING.md +165 -150
  19. package/docs/TESTING.md +1142 -1059
  20. package/docs/schemas/document-operations.schema.json +54 -1
  21. package/document/docx-document.js +1022 -1010
  22. package/engine/oxml-engine.js +736 -749
  23. package/engine/paragraph-mark-revisions.js +292 -0
  24. package/engine/reconstruction-mapper.js +312 -311
  25. package/engine/reconstruction-writer.js +67 -10
  26. package/engine/rpr-helpers.js +42 -0
  27. package/engine/run-builders.js +0 -73
  28. package/engine/surgical-diff-application.js +604 -605
  29. package/engine/table-mode.js +63 -63
  30. package/index.d.ts +47 -1
  31. package/index.js +11 -2
  32. package/node/cli-help.js +1 -0
  33. package/node/cli.js +992 -975
  34. package/orchestration/list-structural-fallback.js +67 -9
  35. package/package.json +1 -1
  36. package/pipeline/diff-engine.js +130 -1
  37. package/pipeline/ingestion-export.js +227 -243
  38. package/pipeline/ingestion-paragraph.js +93 -98
  39. package/pipeline/list-generation.js +415 -419
  40. package/pipeline/pipeline.js +206 -204
  41. package/scripts/export-validation-fixtures.mjs +19 -6
  42. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  43. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  44. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  45. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  46. package/scripts/word-com-differential.ps1 +443 -335
  47. package/scripts/word-com-visual-suite.ps1 +2 -2
  48. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  49. package/services/batch-operation-orchestrator.js +23 -3
  50. package/services/capture-engine.js +228 -226
  51. package/services/document-operation-applier.js +213 -0
  52. package/services/document-operation-contract.js +75 -0
  53. package/services/document-operation-mutations.js +3346 -2721
  54. package/services/error-recovery.js +17 -0
  55. package/services/list-conversion-diagnostics.js +296 -0
  56. package/services/list-format-change.js +586 -0
  57. package/services/list-format-mutation.js +416 -0
  58. package/services/list-format.js +305 -0
  59. package/services/numbering-allocation.js +642 -0
  60. package/services/numbering-helpers.js +354 -524
  61. package/services/operation-batch-compiler.js +183 -11
  62. package/services/operation-preflight.js +1 -0
  63. package/services/receipt-collector.js +288 -288
  64. package/services/revision-comment-management.js +38 -5
  65. package/services/revision-fidelity-verifier.js +498 -0
  66. package/services/standalone-docx-plumbing.js +627 -631
  67. package/services/standalone-operation-runner.d.ts +182 -5
  68. 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
+ }