@ansonlai/docx-redline-js 0.8.3 → 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 (71) hide show
  1. package/ARCHITECTURE.md +221 -12
  2. package/CHANGELOG.md +803 -586
  3. package/README.md +1018 -901
  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 +4551 -967
  9. package/dist/docx-redline-js.esm.js.map +4 -4
  10. package/dist/docx-redline-js.esm.min.js +112 -106
  11. package/dist/docx-redline-js.esm.min.js.map +4 -4
  12. package/dist/docx-redline.bundle.cjs +4690 -1106
  13. package/dist/docx-redline.bundle.cjs.map +4 -4
  14. package/dist/docx-redline.bundle.js +4690 -1106
  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 +23 -4
  18. package/docs/SKILL_AUTHORING.md +165 -150
  19. package/docs/TESTING.md +1142 -1059
  20. package/docs/schemas/document-operations.schema.json +221 -168
  21. package/document/docx-document.js +1022 -1010
  22. package/engine/oxml-engine.js +567 -560
  23. package/engine/paragraph-mark-revisions.js +292 -0
  24. package/engine/reconstruction-mapper.js +312 -311
  25. package/engine/reconstruction-mode.js +183 -183
  26. package/engine/reconstruction-writer.js +67 -10
  27. package/engine/rpr-helpers.js +42 -0
  28. package/engine/run-builders.js +0 -73
  29. package/engine/surgical-diff-application.js +604 -605
  30. package/engine/table-mode.js +63 -63
  31. package/index.d.ts +51 -1
  32. package/index.js +47 -3
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +992 -974
  35. package/orchestration/list-structural-fallback.js +67 -9
  36. package/package.json +1 -1
  37. package/pipeline/diff-engine.js +130 -1
  38. package/pipeline/ingestion-export.js +227 -243
  39. package/pipeline/ingestion-paragraph.js +93 -98
  40. package/pipeline/list-generation.js +415 -419
  41. package/pipeline/pipeline.js +206 -204
  42. package/pipeline/serialization.js +111 -94
  43. package/scripts/export-validation-fixtures.mjs +19 -6
  44. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  45. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  46. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  47. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  48. package/scripts/word-com-differential.ps1 +443 -335
  49. package/scripts/word-com-visual-suite.ps1 +2 -2
  50. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  51. package/services/batch-operation-orchestrator.js +26 -0
  52. package/services/capture-engine.js +228 -226
  53. package/services/document-operation-applier.js +215 -0
  54. package/services/document-operation-contract.js +628 -537
  55. package/services/document-operation-mutations.js +789 -46
  56. package/services/error-recovery.js +17 -0
  57. package/services/list-conversion-diagnostics.js +296 -0
  58. package/services/list-format-change.js +586 -0
  59. package/services/list-format-mutation.js +416 -0
  60. package/services/list-format.js +305 -0
  61. package/services/numbering-allocation.js +642 -0
  62. package/services/numbering-helpers.js +354 -524
  63. package/services/operation-batch-compiler.js +612 -436
  64. package/services/operation-heuristics.js +197 -173
  65. package/services/operation-preflight.js +1 -0
  66. package/services/receipt-collector.js +288 -288
  67. package/services/revision-comment-management.js +634 -594
  68. package/services/revision-fidelity-verifier.js +498 -0
  69. package/services/standalone-docx-plumbing.js +627 -631
  70. package/services/standalone-operation-runner.d.ts +606 -427
  71. package/services/table-reconciliation.js +103 -49
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Paragraph-mark revisions: the single place that constructs a tracked `w:ins` / `w:del` under
3
+ * `w:pPr/w:rPr` (the paragraph mark). Every write path (reconstruction, list generation, table
4
+ * generation, document-level shortcuts) goes through this module so that no path can forget a rule
5
+ * the others have (see docs/plans revision-fidelity-invariants, WP-6).
6
+ *
7
+ * Word semantics this module encodes:
8
+ * - an inserted mark (`w:ins`) is removed by Reject All, merging the paragraph into the next one;
9
+ * - a deleted mark (`w:del`) is restored by Reject All, so a deleted source paragraph keeps its own
10
+ * deleted paragraph (never collapse N deleted paragraphs into one);
11
+ * - Reject All of a correct output has exactly as many paragraphs as the source.
12
+ *
13
+ * Depends only on `core/`. Both DOM callers and string-OOXML callers share one implementation:
14
+ * the DOM entry points are `markParagraphInserted` / `markParagraphDeleted` /
15
+ * `setParagraphPropertiesMarkRevision`; string builders are `paragraphMarkRevisionXml` and
16
+ * `paragraphMarkPPrXml` (both format the same metadata the DOM path writes).
17
+ */
18
+
19
+ import { createRevisionMetadata, escapeXml } from '../core/types.js';
20
+ import { createWordElement } from '../core/word-xml.js';
21
+
22
+ /** @typedef {'ins'|'del'} ParagraphMarkKind */
23
+
24
+ /**
25
+ * Revision context shared by every entry point.
26
+ *
27
+ * @typedef {Object} ParagraphMarkContext
28
+ * @property {string} [author] - Revision author (falls back to the configured default author)
29
+ * @property {string} [date] - Optional ISO timestamp override (default: current revision timestamp)
30
+ * @property {Object} [revisionIdAllocator] - RevisionIdAllocator, or a document/node whose allocator
31
+ * is used (default: the paragraph's owner document, then the process-wide allocator). Ids are
32
+ * allocated through this so receipts record them.
33
+ * @property {{id: (string|number), author: string, date: string}} [metadata] - Pre-allocated metadata
34
+ * (skips allocation; used when the caller already reserved the id)
35
+ */
36
+
37
+ function localNameOf(node) {
38
+ if (!node || node.nodeType !== 1) return '';
39
+ return node.localName || String(node.nodeName || '').replace(/^.*:/, '');
40
+ }
41
+
42
+ function directWordChild(node, localName) {
43
+ for (const child of Array.from(node?.childNodes || [])) {
44
+ if (child.nodeType === 1 && localNameOf(child) === localName) return child;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ function normalizeKind(kind) {
50
+ if (kind === 'ins' || kind === 'del') return kind;
51
+ throw new Error(`Unsupported paragraph-mark revision kind: ${kind}`);
52
+ }
53
+
54
+ function allocateMetadata(kind, ctx, fallbackNode) {
55
+ const context = ctx || {};
56
+ if (context.metadata) return context.metadata;
57
+ const allocatorOrNode = context.revisionIdAllocator
58
+ || (fallbackNode?.nodeType === 9 ? fallbackNode : fallbackNode?.ownerDocument)
59
+ || null;
60
+ const metadata = createRevisionMetadata(context.author, allocatorOrNode, kind);
61
+ if (context.date) metadata.date = context.date;
62
+ return metadata;
63
+ }
64
+
65
+ function buildMarker(xmlDoc, kind, metadata) {
66
+ const marker = createWordElement(xmlDoc, kind === 'ins' ? 'w:ins' : 'w:del');
67
+ marker.setAttribute('w:id', String(metadata.id));
68
+ marker.setAttribute('w:author', metadata.author);
69
+ marker.setAttribute('w:date', metadata.date);
70
+ return marker;
71
+ }
72
+
73
+ function removeMarkers(rPr) {
74
+ for (const child of Array.from(rPr.childNodes || [])) {
75
+ const name = localNameOf(child);
76
+ if (name === 'ins' || name === 'del') rPr.removeChild(child);
77
+ }
78
+ }
79
+
80
+ function ensureParagraphProperties(xmlDoc, paragraph) {
81
+ let pPr = directWordChild(paragraph, 'pPr');
82
+ if (!pPr) {
83
+ pPr = createWordElement(xmlDoc, 'w:pPr');
84
+ paragraph.insertBefore(pPr, paragraph.firstChild || null);
85
+ } else if (paragraph.firstChild !== pPr) {
86
+ paragraph.insertBefore(pPr, paragraph.firstChild || null);
87
+ }
88
+ return pPr;
89
+ }
90
+
91
+ // CT_PPr order: ... rPr, sectPr, pPrChange. A new mark rPr goes before them; an existing one stays put.
92
+ function ensureMarkRunProperties(xmlDoc, pPr) {
93
+ let rPr = directWordChild(pPr, 'rPr');
94
+ if (!rPr) {
95
+ rPr = createWordElement(xmlDoc, 'w:rPr');
96
+ const successor = directWordChild(pPr, 'sectPr') || directWordChild(pPr, 'pPrChange');
97
+ pPr.insertBefore(rPr, successor || null);
98
+ }
99
+ return rPr;
100
+ }
101
+
102
+ /**
103
+ * Sets (replacing any previous ins/del) the paragraph-mark revision on a `w:pPr` element. Used by
104
+ * callers that hold detached paragraph properties (list generation parses a `pPr` string).
105
+ *
106
+ * @param {Element} pPr - `w:pPr` element
107
+ * @param {ParagraphMarkKind} kind
108
+ * @param {ParagraphMarkContext} [ctx]
109
+ * @returns {Element} The marker element
110
+ */
111
+ export function setParagraphPropertiesMarkRevision(pPr, kind, ctx = {}) {
112
+ const resolved = normalizeKind(kind);
113
+ const xmlDoc = pPr.ownerDocument;
114
+ const metadata = allocateMetadata(resolved, ctx, pPr);
115
+ const rPr = ensureMarkRunProperties(xmlDoc, pPr);
116
+ removeMarkers(rPr);
117
+ const marker = buildMarker(xmlDoc, resolved, metadata);
118
+ rPr.appendChild(marker);
119
+ return marker;
120
+ }
121
+
122
+ function markParagraph(paragraph, kind, ctx) {
123
+ const xmlDoc = paragraph.ownerDocument;
124
+ const pPr = ensureParagraphProperties(xmlDoc, paragraph);
125
+ return setParagraphPropertiesMarkRevision(pPr, kind, {
126
+ ...ctx,
127
+ revisionIdAllocator: ctx?.revisionIdAllocator || xmlDoc
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Tracks the paragraph's mark as inserted (`pPr/rPr/ins`). Reject All removes the mark and merges
133
+ * the paragraph into the next one.
134
+ *
135
+ * @param {Element} paragraph - `w:p`
136
+ * @param {ParagraphMarkContext} [ctx]
137
+ * @returns {Element} The `w:ins` marker
138
+ */
139
+ export function markParagraphInserted(paragraph, ctx = {}) {
140
+ return markParagraph(paragraph, 'ins', ctx);
141
+ }
142
+
143
+ /**
144
+ * Tracks the paragraph's mark as deleted (`pPr/rPr/del`). The paragraph content is expected to be
145
+ * moved into `w:del` wrappers by the caller; Reject All restores the paragraph.
146
+ *
147
+ * @param {Element} paragraph - `w:p`
148
+ * @param {ParagraphMarkContext} [ctx]
149
+ * @returns {Element} The `w:del` marker
150
+ */
151
+ export function markParagraphDeleted(paragraph, ctx = {}) {
152
+ return markParagraph(paragraph, 'del', ctx);
153
+ }
154
+
155
+ /**
156
+ * Removes any tracked ins/del from the paragraph mark (and the `w:rPr` when that leaves it empty).
157
+ *
158
+ * @param {Element} paragraph - `w:p`
159
+ * @returns {boolean} True when a marker was removed
160
+ */
161
+ export function clearParagraphMarkRevision(paragraph) {
162
+ const pPr = directWordChild(paragraph, 'pPr');
163
+ const rPr = directWordChild(pPr, 'rPr');
164
+ if (!rPr) return false;
165
+ const before = rPr.childNodes.length;
166
+ removeMarkers(rPr);
167
+ const removed = rPr.childNodes.length !== before;
168
+ if (removed && !Array.from(rPr.childNodes).some(child => child.nodeType === 1)) {
169
+ pPr.removeChild(rPr);
170
+ }
171
+ return removed;
172
+ }
173
+
174
+ /**
175
+ * Re-applies a previously captured inserted-mark identity (id/author/date kept verbatim) when the
176
+ * paragraph does not carry an `ins` mark any more. Does not allocate a new id.
177
+ *
178
+ * @param {Element} paragraph - `w:p`
179
+ * @param {{id?: (string|number), author: string, date?: string}} metadata
180
+ * @returns {Element|null} The added marker, or null when an inserted mark already exists
181
+ */
182
+ export function restoreParagraphMarkInserted(paragraph, metadata) {
183
+ const xmlDoc = paragraph.ownerDocument;
184
+ const pPr = ensureParagraphProperties(xmlDoc, paragraph);
185
+ const rPr = directWordChild(pPr, 'rPr') || (() => {
186
+ const created = createWordElement(xmlDoc, 'w:rPr');
187
+ pPr.appendChild(created);
188
+ return created;
189
+ })();
190
+ if (directWordChild(rPr, 'ins')) return null;
191
+ const marker = createWordElement(xmlDoc, 'w:ins');
192
+ if (metadata.id) marker.setAttribute('w:id', String(metadata.id));
193
+ marker.setAttribute('w:author', metadata.author);
194
+ if (metadata.date) marker.setAttribute('w:date', metadata.date);
195
+ rPr.appendChild(marker);
196
+ return marker;
197
+ }
198
+
199
+ /**
200
+ * String form of the marker, for callers that build OOXML strings. Allocation and formatting are the
201
+ * same as the DOM path (allocator from `ctx.revisionIdAllocator`, receipts record the id).
202
+ *
203
+ * @param {ParagraphMarkKind} kind
204
+ * @param {ParagraphMarkContext} [ctx]
205
+ * @returns {string} e.g. `<w:ins w:id="7" w:author="A" w:date="..."/>`
206
+ */
207
+ export function paragraphMarkRevisionXml(kind, ctx = {}) {
208
+ const resolved = normalizeKind(kind);
209
+ const metadata = allocateMetadata(resolved, ctx, null);
210
+ return `<w:${resolved} w:id="${metadata.id}" w:author="${escapeXml(metadata.author)}" w:date="${metadata.date}"/>`;
211
+ }
212
+
213
+ /**
214
+ * Paragraph properties carrying only the paragraph-mark revision, for `serializeToOoxml(..., pPr)`.
215
+ *
216
+ * @param {ParagraphMarkKind} kind
217
+ * @param {ParagraphMarkContext} [ctx]
218
+ * @returns {string} `<w:pPr><w:rPr>...</w:rPr></w:pPr>`
219
+ */
220
+ export function paragraphMarkPPrXml(kind, ctx = {}) {
221
+ return `<w:pPr><w:rPr>${paragraphMarkRevisionXml(kind, ctx)}</w:rPr></w:pPr>`;
222
+ }
223
+
224
+ /**
225
+ * @typedef {Object} ParagraphMarkAlignmentEntry
226
+ * @property {number|null} [sourceIndex] - Index into sourceParagraphs, or null/undefined for a new paragraph
227
+ * @property {number|null} [targetIndex] - Index into targetParagraphs, or null/undefined for a deleted paragraph
228
+ * @property {boolean} [propertyChange] - Aligned paragraph whose properties change (tracked as pPrChange)
229
+ */
230
+
231
+ /**
232
+ * @typedef {Object} ParagraphMarkPlanEntry
233
+ * @property {'keep'|'insertMark'|'deleteMark'|'propertyChange'} action
234
+ * @property {number|null} sourceIndex
235
+ * @property {number|null} targetIndex
236
+ */
237
+
238
+ /**
239
+ * Plans what each paragraph's mark must be so that Reject All restores exactly the source
240
+ * paragraphs. Pure: it inspects no DOM, it only checks that the alignment accounts for every source
241
+ * and target paragraph exactly once and returns the action per aligned entry in alignment order.
242
+ *
243
+ * Guarantees (throws a descriptive Error when the alignment violates them):
244
+ * - the untracked source-side marks (keep + propertyChange + deleteMark restored by Reject All)
245
+ * equal the number of source paragraphs;
246
+ * - every target paragraph without a source paragraph carries an inserted mark;
247
+ * - one deleted paragraph per deleted source paragraph (never N collapsed into one).
248
+ *
249
+ * @param {{sourceParagraphs: Array, targetParagraphs: Array, alignment: ParagraphMarkAlignmentEntry[]}} input
250
+ * @param {ParagraphMarkContext} [_ctx] Reserved: the plan does not allocate ids (the mark helpers do)
251
+ * @returns {ParagraphMarkPlanEntry[]}
252
+ */
253
+ export function planParagraphMarks({ sourceParagraphs = [], targetParagraphs = [], alignment = [] } = {}, _ctx = {}) {
254
+ const seenSource = new Set();
255
+ const seenTarget = new Set();
256
+ const plan = alignment.map((entry, position) => {
257
+ const sourceIndex = Number.isInteger(entry?.sourceIndex) ? entry.sourceIndex : null;
258
+ const targetIndex = Number.isInteger(entry?.targetIndex) ? entry.targetIndex : null;
259
+ if (sourceIndex === null && targetIndex === null) {
260
+ throw new Error(`Paragraph-mark plan entry ${position} has neither a source nor a target paragraph.`);
261
+ }
262
+ if (sourceIndex !== null) {
263
+ if (sourceIndex < 0 || sourceIndex >= sourceParagraphs.length || seenSource.has(sourceIndex)) {
264
+ throw new Error(`Paragraph-mark plan references source paragraph ${sourceIndex} out of range or more than once.`);
265
+ }
266
+ seenSource.add(sourceIndex);
267
+ }
268
+ if (targetIndex !== null) {
269
+ if (targetIndex < 0 || targetIndex >= targetParagraphs.length || seenTarget.has(targetIndex)) {
270
+ throw new Error(`Paragraph-mark plan references target paragraph ${targetIndex} out of range or more than once.`);
271
+ }
272
+ seenTarget.add(targetIndex);
273
+ }
274
+ let action;
275
+ if (sourceIndex === null) action = 'insertMark';
276
+ else if (targetIndex === null) action = 'deleteMark';
277
+ else action = entry.propertyChange ? 'propertyChange' : 'keep';
278
+ return { action, sourceIndex, targetIndex };
279
+ });
280
+
281
+ if (seenSource.size !== sourceParagraphs.length) {
282
+ throw new Error(`Paragraph-mark plan drops ${sourceParagraphs.length - seenSource.size} source paragraph(s): every source paragraph needs a keep or a deleted mark.`);
283
+ }
284
+ if (seenTarget.size !== targetParagraphs.length) {
285
+ throw new Error(`Paragraph-mark plan leaves ${targetParagraphs.length - seenTarget.size} target paragraph(s) without a source or an inserted mark.`);
286
+ }
287
+ const sourceSideMarks = plan.filter(entry => entry.action !== 'insertMark').length;
288
+ if (sourceSideMarks !== sourceParagraphs.length) {
289
+ throw new Error('Paragraph-mark plan would change the paragraph count seen by Reject All.');
290
+ }
291
+ return plan;
292
+ }