@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,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
|
+
}
|