@ansonlai/docx-redline-js 0.2.0 → 0.4.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/AGENTS.md +36 -10
- package/README.md +83 -6
- package/adapters/xml-adapter.js +73 -10
- package/core/list-targeting.js +3 -0
- package/core/paragraph-targeting.js +33 -7
- package/core/redline-validation.js +22 -0
- package/core/types.js +122 -27
- package/core/xml-query.js +3 -1
- package/dist/docx-redline-js.esm.js +1148 -572
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +79 -78
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +687 -0
- package/docs/VALIDATION.md +81 -2
- package/docs/WORD-MANUAL-REVIEW.md +138 -0
- package/docs/plans/2026-08-30-reliability-testing-improvements.md +488 -0
- package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +210 -0
- package/docs/plans/{2026-03-01-release-0.1.4-design.md → completed/2026-03-01-release-0.1.4-design.md} +2 -0
- package/docs/plans/{2026-03-01-release-0.1.4.md → completed/2026-03-01-release-0.1.4.md} +5 -3
- package/docs/plans/{2026-05-31-architectural changes.md → completed/2026-05-31-architectural changes.md } +2 -0
- package/docs/plans/completed/2026-08-02-reliability-improvements.md +1155 -0
- package/docs/test-comparison-dashboard.html +95 -0
- package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +22 -0
- package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +24 -0
- package/docs/validation-reports/2026-08-30-phase-3-coverage.md +73 -0
- package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +82 -0
- package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +114 -0
- package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +79 -0
- package/engine/format-extraction.js +1 -1
- package/engine/formatting-removal.js +95 -104
- package/engine/oxml-engine.js +176 -83
- package/engine/reconstruction-mapper.js +276 -79
- package/engine/reconstruction-mode.js +20 -6
- package/engine/reconstruction-writer.js +117 -72
- package/engine/run-builders.js +17 -13
- package/engine/surgical-diff-application.js +7 -21
- package/engine/surgical-mode.js +3 -2
- package/engine/table-mode.js +27 -16
- package/index.d.ts +95 -3
- package/index.js +14 -13
- package/orchestration/list-structural-fallback.js +16 -39
- package/package.json +23 -5
- package/pipeline/diff-engine.js +174 -55
- package/pipeline/ingestion-export.js +39 -24
- package/pipeline/ingestion-paragraph.js +7 -5
- package/pipeline/list-generation.js +27 -18
- package/pipeline/patching.js +2 -3
- package/pipeline/pipeline.js +65 -36
- package/pipeline/serialization.js +13 -5
- package/scripts/build-test-dashboard.mjs +43 -0
- package/scripts/check-types.mjs +16 -24
- package/scripts/export-validation-fixtures.mjs +191 -45
- package/scripts/fetch-superdoc-corpus.mjs +61 -0
- package/scripts/generate-test-dashboard.mjs +199 -0
- package/scripts/inspect-visual-evidence.mjs +271 -0
- package/scripts/lib/minimal-zip.mjs +199 -18
- package/scripts/lib/word-coverage-catalogue.mjs +207 -0
- package/scripts/lib/word-coverage-metadata.mjs +93 -0
- package/scripts/lib/zip-reader.mjs +64 -0
- package/scripts/package-superdoc-word-fixtures.ps1 +64 -0
- package/scripts/prepare-corpus-word-visual-review.mjs +84 -0
- package/scripts/prepare-superdoc-word-corpus.mjs +284 -0
- package/scripts/prepare-word-review.mjs +77 -0
- package/scripts/prepare-word-visual-review.mjs +90 -0
- package/scripts/render-agenda-multilevel.mjs +70 -0
- package/scripts/render-case22.mjs +73 -0
- package/scripts/render-case40.ps1 +35 -0
- package/scripts/render-multilevel-bullet-images.py +58 -0
- package/scripts/render-multilevel-bullet-visual.ps1 +32 -0
- package/scripts/render-multilevel-cases.mjs +80 -0
- package/scripts/report-coverage-gaps.mjs +103 -0
- package/scripts/report-word-coverage.mjs +71 -0
- package/scripts/sample-multimodal-visual-check.mjs +221 -0
- package/scripts/test-multilevel-bullet-visual.mjs +187 -0
- package/scripts/word-com-corpus-suite.ps1 +43 -0
- package/scripts/word-com-corpus-visual-suite.ps1 +116 -0
- package/scripts/word-com-differential.ps1 +158 -16
- package/scripts/word-com-suite.ps1 +19 -0
- package/scripts/word-com-visual-suite.ps1 +132 -0
- package/services/comment-engine.js +51 -46
- package/services/comment-locator.js +0 -1
- package/services/comment-package.js +11 -10
- package/services/numbering-service.js +1 -1
- package/services/revision-comment-management.js +31 -10
- package/services/standalone-docx-plumbing.js +45 -34
- package/services/standalone-operation-runner.js +315 -75
- package/services/table-reconciliation.js +23 -11
|
@@ -11,8 +11,9 @@ import {
|
|
|
11
11
|
markParagraphMarkDeleted,
|
|
12
12
|
markParagraphMarkInserted
|
|
13
13
|
} from './run-builders.js';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { getFirstElementByTagNSOrTag } from '../core/xml-query.js';
|
|
15
|
+
import { NS_W } from '../core/types.js';
|
|
16
|
+
import { createWordElement, isWordElement } from '../core/word-xml.js';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Applies diffs to reconstruction context and writes updated XML.
|
|
@@ -28,9 +29,8 @@ import { createWordElement } from '../core/word-xml.js';
|
|
|
28
29
|
*/
|
|
29
30
|
export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, author, formatHints, generateRedlines = true) {
|
|
30
31
|
const {
|
|
31
|
-
paragraphs,
|
|
32
|
-
|
|
33
|
-
containerFragments,
|
|
32
|
+
paragraphs,
|
|
33
|
+
containerFragments,
|
|
34
34
|
sentinelMapByStart,
|
|
35
35
|
referenceMap,
|
|
36
36
|
replacementContainers,
|
|
@@ -55,12 +55,23 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
|
|
|
55
55
|
|
|
56
56
|
let currentOriginalIndex = 0;
|
|
57
57
|
let currentInsertOffset = 0;
|
|
58
|
+
// A replacement is represented as one or more deletions followed by an
|
|
59
|
+
// insertion. Remember where that deleted range began so replacement text
|
|
60
|
+
// inherits the formatting at the start of the range, rather than the last
|
|
61
|
+
// deleted character (which may be a superscript ordinal, footnote-style
|
|
62
|
+
// run, or another narrow formatting boundary).
|
|
63
|
+
let pendingReplacementStart = null;
|
|
58
64
|
const emittedCommentMarkers = new WeakSet();
|
|
59
|
-
|
|
60
|
-
for (const [op, text] of diffs) {
|
|
61
|
-
if (op === 0 || op === -1) {
|
|
62
|
-
const type = op === 0 ? 'equal' : 'delete';
|
|
63
|
-
|
|
65
|
+
|
|
66
|
+
for (const [op, text] of diffs) {
|
|
67
|
+
if (op === 0 || op === -1) {
|
|
68
|
+
const type = op === 0 ? 'equal' : 'delete';
|
|
69
|
+
if (op === 0) {
|
|
70
|
+
pendingReplacementStart = null;
|
|
71
|
+
} else if (pendingReplacementStart === null) {
|
|
72
|
+
pendingReplacementStart = currentOriginalIndex;
|
|
73
|
+
}
|
|
74
|
+
let offset = 0;
|
|
64
75
|
|
|
65
76
|
while (offset < text.length) {
|
|
66
77
|
const chunkStart = currentOriginalIndex + offset;
|
|
@@ -99,11 +110,14 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
|
|
|
99
110
|
currentOriginalIndex += text.length;
|
|
100
111
|
continue;
|
|
101
112
|
}
|
|
102
|
-
|
|
103
|
-
if (op === 1) {
|
|
104
|
-
const
|
|
105
|
-
?
|
|
106
|
-
:
|
|
113
|
+
|
|
114
|
+
if (op === 1) {
|
|
115
|
+
const propertyIndex = pendingReplacementStart !== null
|
|
116
|
+
? pendingReplacementStart
|
|
117
|
+
: (currentOriginalIndex > 0 && !isParagraphStart(currentOriginalIndex)
|
|
118
|
+
? currentOriginalIndex - 1
|
|
119
|
+
: currentOriginalIndex);
|
|
120
|
+
const properties = getRunProperties(propertyIndex);
|
|
107
121
|
|
|
108
122
|
const appendResult = appendTextToCurrent(
|
|
109
123
|
xmlDoc,
|
|
@@ -127,69 +141,93 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
|
|
|
127
141
|
);
|
|
128
142
|
currentParagraph = appendResult.currentParagraph;
|
|
129
143
|
currentInsertOffset += text.length;
|
|
144
|
+
pendingReplacementStart = null;
|
|
130
145
|
}
|
|
131
146
|
}
|
|
132
147
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
target
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
148
|
+
const paragraphSet = new Set(paragraphs);
|
|
149
|
+
const insertionAnchors = new Map();
|
|
150
|
+
paragraphs.forEach(paragraph => {
|
|
151
|
+
const container = paragraph.parentNode;
|
|
152
|
+
if (!container || insertionAnchors.has(container)) return;
|
|
153
|
+
let anchor = paragraph.nextSibling;
|
|
154
|
+
while (anchor && paragraphSet.has(anchor)) anchor = anchor.nextSibling;
|
|
155
|
+
insertionAnchors.set(container, anchor);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
paragraphs.forEach(paragraph => {
|
|
159
|
+
if (paragraph.parentNode) {
|
|
160
|
+
paragraph.parentNode.removeChild(paragraph);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
let hasDocumentTarget = false;
|
|
165
|
+
let serializedDocumentOutput = '';
|
|
166
|
+
|
|
167
|
+
containerFragments.forEach((fragment, container) => {
|
|
168
|
+
const replacement = replacementContainers.get(container);
|
|
169
|
+
const target = replacement || container;
|
|
170
|
+
|
|
171
|
+
if (target.nodeType === 9) {
|
|
172
|
+
hasDocumentTarget = true;
|
|
173
|
+
if (fragment.childNodes.length === 1) {
|
|
174
|
+
target.appendChild(fragment.firstChild);
|
|
175
|
+
} else {
|
|
176
|
+
serializedDocumentOutput = Array.from(fragment.childNodes)
|
|
177
|
+
.map(node => serializer.serializeToString(node))
|
|
178
|
+
.join('');
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const anchor = replacement ? null : insertionAnchors.get(container);
|
|
184
|
+
if (anchor && anchor.parentNode === target) {
|
|
185
|
+
target.insertBefore(fragment, anchor);
|
|
186
|
+
} else {
|
|
187
|
+
target.appendChild(fragment);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const oxml = (hasDocumentTarget && serializedDocumentOutput)
|
|
192
|
+
? serializedDocumentOutput
|
|
193
|
+
: serializer.serializeToString(xmlDoc);
|
|
194
|
+
|
|
195
|
+
return { oxml, hasChanges: true };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function appendTextToCurrent(
|
|
199
|
+
xmlDoc,
|
|
200
|
+
text,
|
|
201
|
+
type,
|
|
202
|
+
rPr,
|
|
203
|
+
wrapper,
|
|
204
|
+
baseIndex,
|
|
205
|
+
currentParagraphRef,
|
|
206
|
+
containerFragments,
|
|
207
|
+
sentinelMapByStart,
|
|
208
|
+
referenceMap,
|
|
209
|
+
replacementContainers,
|
|
210
|
+
getParagraphInfo,
|
|
211
|
+
createNewParagraph,
|
|
212
|
+
author,
|
|
213
|
+
formatHints = [],
|
|
214
|
+
insertOffset = 0,
|
|
177
215
|
generateRedlines = true,
|
|
178
216
|
emittedCommentMarkers = new WeakSet()
|
|
179
217
|
) {
|
|
180
|
-
let localBaseIndex = baseIndex;
|
|
181
|
-
let localInsertOffset = insertOffset;
|
|
182
|
-
let localParagraph = currentParagraphRef;
|
|
183
|
-
|
|
184
|
-
const parts = text.split(/([\n\uFFFC]|[\uE000-\uF8FF])/);
|
|
185
|
-
|
|
186
|
-
parts.forEach(part => {
|
|
187
|
-
const sentinelsAtOffset = sentinelMapByStart.get(localBaseIndex) || [];
|
|
218
|
+
let localBaseIndex = baseIndex;
|
|
219
|
+
let localInsertOffset = insertOffset;
|
|
220
|
+
let localParagraph = currentParagraphRef;
|
|
221
|
+
|
|
222
|
+
const parts = text.split(/([\n\uFFFC]|[\uE000-\uF8FF])/);
|
|
223
|
+
|
|
224
|
+
parts.forEach(part => {
|
|
225
|
+
const sentinelsAtOffset = sentinelMapByStart.get(localBaseIndex) || [];
|
|
188
226
|
const commentMarkers = sentinelsAtOffset.filter(sentinel => sentinel.isCommentMarker && !emittedCommentMarkers.has(sentinel.node));
|
|
189
227
|
|
|
190
228
|
commentMarkers.forEach(marker => {
|
|
191
229
|
emittedCommentMarkers.add(marker.node);
|
|
192
|
-
if (marker.node
|
|
230
|
+
if (isWordElement(marker.node, 'commentReference')) {
|
|
193
231
|
const run = createWordElement(xmlDoc, 'w:r');
|
|
194
232
|
run.appendChild(marker.node.cloneNode(true));
|
|
195
233
|
localParagraph.appendChild(run);
|
|
@@ -202,9 +240,9 @@ function appendTextToCurrent(
|
|
|
202
240
|
const info = getParagraphInfo(localBaseIndex + 1);
|
|
203
241
|
const nextParagraph = createNewParagraph(info.pPr);
|
|
204
242
|
if (generateRedlines && type === 'insert') {
|
|
205
|
-
markParagraphMarkInserted(xmlDoc,
|
|
243
|
+
markParagraphMarkInserted(xmlDoc, localParagraph, author);
|
|
206
244
|
} else if (generateRedlines && type === 'delete') {
|
|
207
|
-
markParagraphMarkDeleted(xmlDoc,
|
|
245
|
+
markParagraphMarkDeleted(xmlDoc, localParagraph, author);
|
|
208
246
|
}
|
|
209
247
|
|
|
210
248
|
const fragment = containerFragments.get(info.container);
|
|
@@ -222,13 +260,20 @@ function appendTextToCurrent(
|
|
|
222
260
|
if (sentinel) {
|
|
223
261
|
const clone = sentinel.node.cloneNode(true);
|
|
224
262
|
if (sentinel.isTextBox && sentinel.originalContainer) {
|
|
225
|
-
const newContainer =
|
|
263
|
+
const newContainer = getFirstElementByTagNSOrTag(clone, NS_W, 'txbxContent');
|
|
226
264
|
if (newContainer) {
|
|
227
265
|
while (newContainer.firstChild) newContainer.removeChild(newContainer.firstChild);
|
|
228
266
|
replacementContainers.set(sentinel.originalContainer, newContainer);
|
|
229
267
|
}
|
|
230
268
|
}
|
|
231
|
-
|
|
269
|
+
if (sentinel.wrapInRun) {
|
|
270
|
+
const run = createWordElement(xmlDoc, 'w:r');
|
|
271
|
+
if (sentinel.rPr) run.appendChild(sentinel.rPr.cloneNode(true));
|
|
272
|
+
run.appendChild(clone);
|
|
273
|
+
localParagraph.appendChild(run);
|
|
274
|
+
} else {
|
|
275
|
+
localParagraph.appendChild(clone);
|
|
276
|
+
}
|
|
232
277
|
}
|
|
233
278
|
localBaseIndex++;
|
|
234
279
|
if (type !== 'delete') localInsertOffset++;
|
package/engine/run-builders.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* and `w:pPrChange` elements used by surgical and reconstruction modes.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { RPR_SCHEMA_ORDER } from './rpr-helpers.js';
|
|
8
|
+
import { extractFormatFromRPr, RPR_SCHEMA_ORDER } from './rpr-helpers.js';
|
|
9
9
|
import { createRevisionMetadata } from '../core/types.js';
|
|
10
10
|
import { getFirstElementByTag } from '../core/xml-query.js';
|
|
11
11
|
import { createWordElement } from '../core/word-xml.js';
|
|
@@ -21,7 +21,7 @@ import { createWordElement } from '../core/word-xml.js';
|
|
|
21
21
|
*/
|
|
22
22
|
export function createTrackChange(xmlDoc, type, run, author) {
|
|
23
23
|
const wrapper = createWordElement(xmlDoc, type === 'ins' ? 'w:ins' : 'w:del');
|
|
24
|
-
const metadata = createRevisionMetadata(author);
|
|
24
|
+
const metadata = createRevisionMetadata(author, xmlDoc);
|
|
25
25
|
wrapper.setAttribute('w:id', String(metadata.id));
|
|
26
26
|
wrapper.setAttribute('w:author', metadata.author);
|
|
27
27
|
wrapper.setAttribute('w:date', metadata.date);
|
|
@@ -70,7 +70,7 @@ function markParagraphMark(xmlDoc, paragraph, author, type) {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
const marker = createWordElement(xmlDoc, type === 'ins' ? 'w:ins' : 'w:del');
|
|
73
|
-
const metadata = createRevisionMetadata(author);
|
|
73
|
+
const metadata = createRevisionMetadata(author, xmlDoc);
|
|
74
74
|
marker.setAttribute('w:id', String(metadata.id));
|
|
75
75
|
marker.setAttribute('w:author', metadata.author);
|
|
76
76
|
marker.setAttribute('w:date', metadata.date);
|
|
@@ -140,8 +140,8 @@ export function createTextRun(xmlDoc, text, rPr, isDelete) {
|
|
|
140
140
|
* @param {boolean} [generateRedlines] - Whether to create rPrChange
|
|
141
141
|
* @returns {Element[]}
|
|
142
142
|
*/
|
|
143
|
-
export function createFormattedRuns(xmlDoc, text, baseRPr, formatHints, baseOffset, author, generateRedlines) {
|
|
144
|
-
if (!text) return [];
|
|
143
|
+
export function createFormattedRuns(xmlDoc, text, baseRPr, formatHints, baseOffset, author, generateRedlines) {
|
|
144
|
+
if (!text) return [];
|
|
145
145
|
|
|
146
146
|
const breaks = new Set([0, text.length]);
|
|
147
147
|
for (const hint of formatHints) {
|
|
@@ -167,13 +167,17 @@ export function createFormattedRuns(xmlDoc, text, baseRPr, formatHints, baseOffs
|
|
|
167
167
|
h.start <= segmentBaseOffset && h.end >= segmentEndOffset
|
|
168
168
|
);
|
|
169
169
|
|
|
170
|
-
const combinedFormat = {};
|
|
171
|
-
applicableHints.forEach(h => {
|
|
172
|
-
if (h.format) Object.assign(combinedFormat, h.format);
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
170
|
+
const combinedFormat = { ...extractFormatFromRPr(baseRPr) };
|
|
171
|
+
applicableHints.forEach(h => {
|
|
172
|
+
if (h.format) Object.assign(combinedFormat, h.format);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// During a text edit, missing Markdown is not an instruction to clear
|
|
176
|
+
// the source run's formatting. Only synchronize explicitly hinted spans.
|
|
177
|
+
const formattedRPr = applicableHints.length > 0
|
|
178
|
+
? injectFormattingToRPr(xmlDoc, baseRPr, combinedFormat, author, generateRedlines)
|
|
179
|
+
: baseRPr?.cloneNode(true) || null;
|
|
180
|
+
runs.push(createTextRunWithRPrElement(xmlDoc, segment, formattedRPr, false));
|
|
177
181
|
}
|
|
178
182
|
|
|
179
183
|
return runs;
|
|
@@ -307,7 +311,7 @@ export function injectFormattingToRPr(xmlDoc, baseRPr, format, author, generateR
|
|
|
307
311
|
*/
|
|
308
312
|
export function snapshotAndAttachRPrChange(xmlDoc, rPr, author, dateStr, sourceNode) {
|
|
309
313
|
const rPrChange = createWordElement(xmlDoc, 'w:rPrChange');
|
|
310
|
-
const metadata = createRevisionMetadata(author);
|
|
314
|
+
const metadata = createRevisionMetadata(author, xmlDoc);
|
|
311
315
|
rPrChange.setAttribute('w:id', String(metadata.id));
|
|
312
316
|
rPrChange.setAttribute('w:author', metadata.author);
|
|
313
317
|
rPrChange.setAttribute('w:date', dateStr || metadata.date);
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { getApplicableFormatHints } from '../pipeline/markdown-processor.js';
|
|
2
|
-
import { isWordElement } from '../core/word-xml.js';
|
|
3
2
|
import {
|
|
4
3
|
createTrackChange,
|
|
5
4
|
createTextRun,
|
|
@@ -20,30 +19,17 @@ import {
|
|
|
20
19
|
findLastSpanEndingBeforeOrAt,
|
|
21
20
|
forEachOverlappingSpan
|
|
22
21
|
} from './surgical-spans.js';
|
|
22
|
+
import { extractFormatFromRPr } from './rpr-helpers.js';
|
|
23
23
|
|
|
24
24
|
export function reconcileFormattingForTextSpan(xmlDoc, span, start, end, applicableHints, author, generateRedlines) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
25
|
+
// Plain modified text carries no negative formatting instruction. Preserve
|
|
26
|
+
// unchanged source formatting unless Markdown explicitly targets this span.
|
|
27
|
+
if (applicableHints.length === 0) return false;
|
|
29
28
|
|
|
30
29
|
const rPr = span.rPr;
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (isWordElement(node, localName)) {
|
|
35
|
-
return true;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
return false;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const existingFormat = {
|
|
42
|
-
bold: hasElement('b'),
|
|
43
|
-
italic: hasElement('i'),
|
|
44
|
-
underline: hasElement('u'),
|
|
45
|
-
strikethrough: hasElement('strike')
|
|
46
|
-
};
|
|
30
|
+
const existingFormat = extractFormatFromRPr(rPr);
|
|
31
|
+
const desiredFormat = { ...existingFormat };
|
|
32
|
+
applicableHints.forEach(h => Object.assign(desiredFormat, h.format));
|
|
47
33
|
|
|
48
34
|
const formatsToCheck = ['bold', 'italic', 'underline', 'strikethrough'];
|
|
49
35
|
const changesNeeded = formatsToCheck.some(f => !!desiredFormat[f] !== existingFormat[f]);
|
package/engine/surgical-mode.js
CHANGED
|
@@ -27,9 +27,10 @@ import { withOoxmlSourceType } from '../core/word-xml.js';
|
|
|
27
27
|
* @param {Array} formatHints - Format hints
|
|
28
28
|
* @param {boolean} [generateRedlines=true] - Track change toggle
|
|
29
29
|
* @param {Element|null} [targetParagraph=null] - Optional scope paragraph
|
|
30
|
+
* @param {{ diffTimeoutSeconds?: number }} [diffOptions={}] - Diff configuration
|
|
30
31
|
* @returns {{ oxml: string, hasChanges: boolean, sourceType?: 'package'|'document'|'fragment' }}
|
|
31
32
|
*/
|
|
32
|
-
export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, targetParagraph = null) {
|
|
33
|
+
export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, targetParagraph = null, diffOptions = {}) {
|
|
33
34
|
void originalText;
|
|
34
35
|
|
|
35
36
|
const allParagraphs = targetParagraph
|
|
@@ -37,7 +38,7 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
37
38
|
: getDocumentParagraphs(xmlDoc);
|
|
38
39
|
|
|
39
40
|
const { fullText, textSpans } = buildSurgicalTextSpans(allParagraphs);
|
|
40
|
-
const diffs = computeWordDiffs(fullText, modifiedText);
|
|
41
|
+
const diffs = computeWordDiffs(fullText, modifiedText, diffOptions);
|
|
41
42
|
const spanIndex = buildSpanIndex(textSpans);
|
|
42
43
|
|
|
43
44
|
let originalPos = 0;
|
package/engine/table-mode.js
CHANGED
|
@@ -2,16 +2,20 @@
|
|
|
2
2
|
* Table-specific reconciliation and transformation flows.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
NS_W,
|
|
7
|
+
createRevisionMetadata,
|
|
8
|
+
getRevisionIdAllocatorForDocument,
|
|
9
|
+
setRevisionIdAllocatorForDocument
|
|
10
|
+
} from '../core/types.js';
|
|
6
11
|
import {
|
|
7
12
|
getElementsByTagNS,
|
|
8
13
|
getElementsByTagNSOrTag,
|
|
9
|
-
getFirstElementByTag,
|
|
10
14
|
getFirstElementByTagNS,
|
|
11
15
|
getFirstElementByTagNSOrTag,
|
|
12
16
|
getXmlParseError
|
|
13
17
|
} from '../core/xml-query.js';
|
|
14
|
-
import {
|
|
18
|
+
import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
15
19
|
import { log, error } from '../adapters/logger.js';
|
|
16
20
|
import { diffTablesWithVirtualGrid, serializeVirtualGridToOoxml, generateTableOoxml } from '../services/table-reconciliation.js';
|
|
17
21
|
import { parseTable } from '../pipeline/pipeline.js';
|
|
@@ -51,11 +55,15 @@ export function applyTableReconciliation(xmlDoc, modifiedText, serializer, parse
|
|
|
51
55
|
return noChanges(serializer, xmlDoc);
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
const options = {
|
|
58
|
+
const options = {
|
|
59
|
+
generateRedlines,
|
|
60
|
+
author,
|
|
61
|
+
revisionIdAllocator: getRevisionIdAllocatorForDocument(xmlDoc)
|
|
62
|
+
};
|
|
55
63
|
const reconciledOxml = serializeVirtualGridToOoxml(oldGrid, operations, options);
|
|
56
64
|
const wrappedOxml = `<root xmlns:w="${NS_W}">${reconciledOxml}</root>`;
|
|
57
|
-
const
|
|
58
|
-
|
|
65
|
+
const reconciledDoc = parseOoxmlSafe(wrappedOxml, 'application/xml').doc;
|
|
66
|
+
if (!reconciledDoc) return noChanges(serializer, xmlDoc);
|
|
59
67
|
|
|
60
68
|
const parseError = getXmlParseError(reconciledDoc);
|
|
61
69
|
if (parseError) {
|
|
@@ -86,16 +94,17 @@ export function applyTableReconciliation(xmlDoc, modifiedText, serializer, parse
|
|
|
86
94
|
* @param {boolean} generateRedlines - Track change toggle
|
|
87
95
|
* @returns {{ oxml: string, hasChanges: boolean }}
|
|
88
96
|
*/
|
|
89
|
-
export function applyTextToTableTransformation(xmlDoc, modifiedText, serializer, parser, author, generateRedlines) {
|
|
97
|
+
export function applyTextToTableTransformation(xmlDoc, modifiedText, serializer, parser, author, generateRedlines) {
|
|
98
|
+
const revisionIdAllocator = getRevisionIdAllocatorForDocument(xmlDoc);
|
|
90
99
|
const tableData = parseTable(modifiedText);
|
|
91
100
|
if (!tableData || (tableData.rows.length === 0 && tableData.headers.length === 0)) {
|
|
92
101
|
log('[OxmlEngine] Failed to parse table data from Markdown');
|
|
93
102
|
return noChanges(serializer, xmlDoc);
|
|
94
103
|
}
|
|
95
104
|
|
|
96
|
-
const tableOoxml = generateTableOoxml(tableData, { generateRedlines, author });
|
|
97
|
-
const
|
|
98
|
-
|
|
105
|
+
const tableOoxml = generateTableOoxml(tableData, { generateRedlines, author, revisionIdAllocator });
|
|
106
|
+
const tableDoc = parseOoxmlSafe(`<root xmlns:w="${NS_W}">${tableOoxml}</root>`, 'application/xml').doc;
|
|
107
|
+
if (!tableDoc) return noChanges(serializer, xmlDoc);
|
|
99
108
|
|
|
100
109
|
const tableParseError = getXmlParseError(tableDoc);
|
|
101
110
|
if (tableParseError) {
|
|
@@ -123,14 +132,16 @@ export function applyTextToTableTransformation(xmlDoc, modifiedText, serializer,
|
|
|
123
132
|
let parent = firstParagraph.parentNode;
|
|
124
133
|
|
|
125
134
|
if (parent && parent.nodeType === 9) {
|
|
126
|
-
const wrappedDoc =
|
|
127
|
-
`<w:document xmlns:w="${NS_W}"><w:body/></w:document>`,
|
|
128
|
-
'application/xml'
|
|
129
|
-
);
|
|
135
|
+
const wrappedDoc = parseOoxmlSafe(
|
|
136
|
+
`<w:document xmlns:w="${NS_W}"><w:body/></w:document>`,
|
|
137
|
+
'application/xml'
|
|
138
|
+
).doc;
|
|
139
|
+
if (!wrappedDoc) return noChanges(serializer, workingDoc);
|
|
130
140
|
const wrappedBody = getFirstElementByTagNS(wrappedDoc, NS_W, 'body');
|
|
131
141
|
paragraphs.forEach(p => wrappedBody.appendChild(wrappedDoc.importNode(p, true)));
|
|
132
142
|
|
|
133
|
-
workingDoc = wrappedDoc;
|
|
143
|
+
workingDoc = wrappedDoc;
|
|
144
|
+
setRevisionIdAllocatorForDocument(workingDoc, revisionIdAllocator);
|
|
134
145
|
paragraphs = getElementsByTagNS(workingDoc, NS_W, 'p');
|
|
135
146
|
firstParagraph = paragraphs[0];
|
|
136
147
|
parent = firstParagraph.parentNode;
|
|
@@ -154,7 +165,7 @@ export function applyTextToTableTransformation(xmlDoc, modifiedText, serializer,
|
|
|
154
165
|
});
|
|
155
166
|
|
|
156
167
|
const del = createWordElement(workingDoc, 'w:del');
|
|
157
|
-
const metadata = createRevisionMetadata(author);
|
|
168
|
+
const metadata = createRevisionMetadata(author, workingDoc);
|
|
158
169
|
del.setAttribute('w:id', String(metadata.id));
|
|
159
170
|
del.setAttribute('w:author', metadata.author);
|
|
160
171
|
del.setAttribute('w:date', metadata.date);
|
package/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export type OoxmlSourceType = 'package' | 'document' | 'fragment';
|
|
2
2
|
export type RedlineStatus = 'ok' | 'no-op' | 'error';
|
|
3
|
-
export type ExistingRevisionsPolicy = 'reject-input' | 'accept-all-first';
|
|
3
|
+
export type ExistingRevisionsPolicy = 'reject-input' | 'accept-all-first' | 'accept-all-first-keep-normalized';
|
|
4
4
|
|
|
5
5
|
export interface RedlineError {
|
|
6
|
-
code: 'PARSE_ERROR' | 'TARGET_NOT_FOUND' | 'EXISTING_REVISIONS' | string;
|
|
6
|
+
code: 'PARSE_ERROR' | 'TARGET_NOT_FOUND' | 'EXISTING_REVISIONS' | 'DIFF_TOKEN_LIMIT' | string;
|
|
7
7
|
message: string;
|
|
8
8
|
}
|
|
9
9
|
|
|
@@ -12,6 +12,8 @@ export interface RedlineOptions {
|
|
|
12
12
|
author?: string;
|
|
13
13
|
targetParagraphId?: string | null;
|
|
14
14
|
existingRevisions?: ExistingRevisionsPolicy;
|
|
15
|
+
removeFormatting?: boolean;
|
|
16
|
+
sanitizeInput?: boolean;
|
|
15
17
|
[key: string]: unknown;
|
|
16
18
|
}
|
|
17
19
|
|
|
@@ -46,6 +48,8 @@ export interface AcceptTrackedChangesResult {
|
|
|
46
48
|
hasChanges: boolean;
|
|
47
49
|
acceptedCount: number;
|
|
48
50
|
warnings: string[];
|
|
51
|
+
status?: RedlineStatus;
|
|
52
|
+
error?: RedlineError;
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
export interface RejectTrackedChangesResult {
|
|
@@ -53,6 +57,8 @@ export interface RejectTrackedChangesResult {
|
|
|
53
57
|
hasChanges: boolean;
|
|
54
58
|
rejectedCount: number;
|
|
55
59
|
warnings: string[];
|
|
60
|
+
status?: RedlineStatus;
|
|
61
|
+
error?: RedlineError;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
export interface DeleteCommentsResult {
|
|
@@ -61,6 +67,15 @@ export interface DeleteCommentsResult {
|
|
|
61
67
|
commentsRemoved: number;
|
|
62
68
|
referencesRemoved: number;
|
|
63
69
|
warnings: string[];
|
|
70
|
+
status?: RedlineStatus;
|
|
71
|
+
error?: RedlineError;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface IngestionTextResult {
|
|
75
|
+
text: string;
|
|
76
|
+
status: 'ok' | 'error';
|
|
77
|
+
error?: RedlineError;
|
|
78
|
+
warnings?: string[];
|
|
64
79
|
}
|
|
65
80
|
|
|
66
81
|
export interface XmlProvider {
|
|
@@ -85,6 +100,7 @@ export interface DocumentOperationResult {
|
|
|
85
100
|
}
|
|
86
101
|
|
|
87
102
|
export function configureXmlProvider(provider: XmlProvider): void;
|
|
103
|
+
export function parseOoxmlSafe(oxml: unknown, contentType?: string): { doc: Document | null; error: RedlineError | null; warnings: string[] };
|
|
88
104
|
export function configureLogger(logger: LoggerConfig): void;
|
|
89
105
|
export function setDefaultAuthor(name: string): void;
|
|
90
106
|
export function getDefaultAuthor(): string;
|
|
@@ -114,6 +130,8 @@ export function reconcileMarkdownTableOoxml(
|
|
|
114
130
|
|
|
115
131
|
export function ingestWordOoxmlToPlainText(oxml: string): string;
|
|
116
132
|
export function ingestWordOoxmlToMarkdown(oxml: string): string;
|
|
133
|
+
export function ingestWordOoxmlToPlainTextResult(oxml: unknown): IngestionTextResult;
|
|
134
|
+
export function ingestWordOoxmlToMarkdownResult(oxml: unknown): IngestionTextResult;
|
|
117
135
|
export function ingestOoxml(oxml: string): unknown;
|
|
118
136
|
export function preprocessMarkdown(text: string): { cleanText: string; formatHints: unknown[] };
|
|
119
137
|
export function serializeToOoxml(runModel: unknown[], pPrXml?: string | null, formatHints?: unknown[], options?: Record<string, unknown>): string;
|
|
@@ -158,7 +176,7 @@ export function ensureNumberingArtifactsInZip(zip: unknown, numberingXml: string
|
|
|
158
176
|
export function ensureCommentsArtifactsInZip(zip: unknown, commentsXml: string): Promise<unknown> | unknown;
|
|
159
177
|
export function createDynamicNumberingIdState(numberingXml?: string): unknown;
|
|
160
178
|
|
|
161
|
-
export function parseOoxml(ooxml: string): Document;
|
|
179
|
+
export function parseOoxml(ooxml: string): Document | null;
|
|
162
180
|
export function serializeOoxml(node: Node): string;
|
|
163
181
|
export function sanitizeAiResponse(text: string): string;
|
|
164
182
|
|
|
@@ -170,3 +188,77 @@ export class ReconciliationPipeline {
|
|
|
170
188
|
export class NumberingService {
|
|
171
189
|
constructor(...args: unknown[]);
|
|
172
190
|
}
|
|
191
|
+
|
|
192
|
+
// Supporting public utilities. These declarations intentionally expose stable
|
|
193
|
+
// boundary shapes while leaving internal planning records extensible.
|
|
194
|
+
export const NS_W: string;
|
|
195
|
+
export const WORD_MAIN_NS: string;
|
|
196
|
+
export const DiffOp: Readonly<Record<'EQUAL' | 'DELETE' | 'INSERT', string>>;
|
|
197
|
+
export const RunKind: Readonly<Record<string, string>>;
|
|
198
|
+
export const ContainerKind: Readonly<Record<string, string>>;
|
|
199
|
+
export const ContentType: Readonly<Record<string, string>>;
|
|
200
|
+
export const RoutePlanKind: Readonly<Record<string, string>>;
|
|
201
|
+
|
|
202
|
+
export function escapeXml(value: unknown): string;
|
|
203
|
+
export function applyFormattingRemovalToOoxml(oxml: string, options?: Record<string, unknown>): string;
|
|
204
|
+
export function removeFormattingFromRPr(rPr: Element, options?: Record<string, unknown>): Element;
|
|
205
|
+
|
|
206
|
+
export function buildCommentElement(comment: Record<string, unknown>, id?: string | number): string;
|
|
207
|
+
export function buildCommentsPartXml(comments: unknown[]): string;
|
|
208
|
+
|
|
209
|
+
export function reserveNextNumberingId(state: unknown): number;
|
|
210
|
+
export function reserveNextNumberingIdPair(state: unknown): { abstractNumId: number; numId: number };
|
|
211
|
+
export function overwriteParagraphNumIds(oxml: string, numId: string | number): string;
|
|
212
|
+
export function extractFirstParagraphNumId(oxml: string): string | null;
|
|
213
|
+
export function buildExplicitDecimalMultilevelNumberingXml(options?: Record<string, unknown>): string;
|
|
214
|
+
export function remapNumberingPayloadForDocument(numberingXml: string, state: unknown): unknown;
|
|
215
|
+
export function mergeNumberingXmlBySchemaOrder(baseXml: string, incomingXml: string): string;
|
|
216
|
+
|
|
217
|
+
export function buildListMarkdown(items: unknown[], options?: Record<string, unknown>): string;
|
|
218
|
+
export function inferNumberingStyleFromMarker(marker: string): string;
|
|
219
|
+
export function normalizeListItemsWithLevels(items: unknown[]): unknown[];
|
|
220
|
+
export function parseMarkdownListContent(text: string): unknown;
|
|
221
|
+
export function hasListItems(parsed: unknown): boolean;
|
|
222
|
+
|
|
223
|
+
export function buildReconciliationPlan(params?: Record<string, unknown>): Record<string, unknown>;
|
|
224
|
+
export function normalizeContentEscapesForRouting(content: string): string;
|
|
225
|
+
export function buildSingleLineListStructuralFallbackPlan(options?: Record<string, unknown>): Record<string, unknown> | null;
|
|
226
|
+
export function executeSingleLineListStructuralFallback(plan: unknown, options?: Record<string, unknown>): Promise<RedlineResult>;
|
|
227
|
+
export function resolveSingleLineListFallbackNumberingAction(plan: unknown, sequenceState?: unknown): Record<string, unknown>;
|
|
228
|
+
export function recordSingleLineListFallbackExplicitSequence(sequenceState: unknown, numberingKey: string | null, numId: string | number | null, startAt: number | null): void;
|
|
229
|
+
export function clearSingleLineListFallbackExplicitSequence(sequenceState: unknown, numberingKey: string | null): void;
|
|
230
|
+
export function enforceListBindingOnParagraphNodes(nodes: Node[], options?: Record<string, unknown>): number;
|
|
231
|
+
export function stripSingleLineListMarkerPrefix(text: string): string;
|
|
232
|
+
|
|
233
|
+
export function getParagraphText(paragraph: Element | null | undefined): string;
|
|
234
|
+
export function getDocumentParagraphNodes(xmlDoc: Document | Element | null | undefined): Element[];
|
|
235
|
+
export function normalizeWhitespaceForTargeting(text: string): string;
|
|
236
|
+
export function isMarkdownTableText(text: string): boolean;
|
|
237
|
+
export function parseParagraphReference(reference: unknown): unknown;
|
|
238
|
+
export function stripLeadingParagraphMarker(text: string): string;
|
|
239
|
+
export function splitLeadingParagraphMarker(text: string): Record<string, unknown>;
|
|
240
|
+
export function findContainingWordElement(node: Node | null, localName: string): Element | null;
|
|
241
|
+
export function findParagraphByReference(xmlDoc: Document | Element, reference: unknown): Element | null;
|
|
242
|
+
export function findParagraphByStrictText(xmlDoc: Document | Element, text: string): Element | null;
|
|
243
|
+
export function findParagraphByBestTextMatch(xmlDoc: Document | Element, text: string): Element | null;
|
|
244
|
+
export function resolveTargetParagraph(xmlDoc: Document | Element, options?: Record<string, unknown>): Element | null;
|
|
245
|
+
export function buildTargetReferenceSnapshot(paragraph: Element, options?: Record<string, unknown>): Record<string, unknown>;
|
|
246
|
+
export function resolveTargetParagraphWithSnapshot(xmlDoc: Document | Element, snapshot: unknown): Element | null;
|
|
247
|
+
export function resolveParagraphRangeByRefs(xmlDoc: Document | Element, references: unknown[]): Element[];
|
|
248
|
+
export function extractParagraphIdFromOoxml(oxml: string): string | null;
|
|
249
|
+
|
|
250
|
+
export function getParagraphListInfo(paragraph: Element): Record<string, unknown> | null;
|
|
251
|
+
export function collectContiguousListParagraphBlock(paragraph: Element): Element[];
|
|
252
|
+
export function synthesizeExpandedListScopeEdit(options: Record<string, unknown>): Record<string, unknown> | null;
|
|
253
|
+
export function planListInsertionOnlyEdit(options: Record<string, unknown>): Record<string, unknown> | null;
|
|
254
|
+
export function stripRedundantLeadingListMarkers(text: string): string;
|
|
255
|
+
|
|
256
|
+
export function inferTableReplacementParagraphBlock(options: Record<string, unknown>): Record<string, unknown> | null;
|
|
257
|
+
export function isLikelyStructuredTableSourceParagraph(paragraph: Element): boolean;
|
|
258
|
+
export function synthesizeTableMarkdownFromMultilineCellEdit(options: Record<string, unknown>): string | null;
|
|
259
|
+
|
|
260
|
+
export function getBodyElementFromDocument(xmlDoc: Document): Element | null;
|
|
261
|
+
export function insertBodyElementBeforeSectPr(body: Element, element: Element): Element;
|
|
262
|
+
export function normalizeBodySectionOrderStandalone(documentXml: string): string;
|
|
263
|
+
export function sanitizeNestedParagraphsInTables(documentXml: string): string;
|
|
264
|
+
export function getPackagePartName(part: unknown): string | null;
|