@ansonlai/docx-redline-js 0.1.4 → 0.2.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 +53 -4
- package/ARCHITECTURE.md +75 -11
- package/README.md +62 -3
- package/core/redline-validation.js +156 -0
- package/core/types.js +35 -8
- package/core/word-xml.js +90 -0
- package/dist/docx-redline-js.esm.js +3195 -2592
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +71 -67
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/VALIDATION.md +104 -0
- package/docs/plans/2026-03-01-release-0.1.4-design.md +31 -0
- package/docs/plans/2026-03-01-release-0.1.4.md +108 -0
- package/docs/plans/2026-05-31-architectural changes.md +591 -0
- package/engine/format-application.js +13 -14
- package/engine/format-span-application.js +7 -6
- package/engine/formatting-removal.js +15 -12
- package/engine/oxml-engine.js +146 -55
- package/engine/reconstruction-mapper.js +35 -8
- package/engine/reconstruction-mode.js +14 -13
- package/engine/reconstruction-writer.js +97 -78
- package/engine/rpr-helpers.js +34 -32
- package/engine/run-builders.js +150 -39
- package/engine/surgical-diff-application.js +216 -0
- package/engine/surgical-mode.js +84 -519
- package/engine/surgical-run-splitting.js +96 -0
- package/engine/surgical-spans.js +169 -0
- package/engine/table-cell-context.js +15 -13
- package/engine/table-mode.js +39 -35
- package/index.d.ts +172 -0
- package/index.js +50 -47
- package/package.json +10 -2
- package/pipeline/ingestion-export.js +1 -0
- package/pipeline/ingestion-paragraph.js +37 -12
- package/pipeline/ingestion-table.js +11 -8
- package/scripts/build.mjs +40 -0
- package/scripts/check-types.mjs +29 -0
- package/scripts/export-validation-fixtures.mjs +125 -0
- package/scripts/lib/minimal-zip.mjs +155 -0
- package/scripts/run-tests.mjs +43 -0
- package/scripts/validate-fixtures-xsd.sh +37 -0
- package/scripts/word-com-differential.ps1 +133 -0
- package/scripts/word-com-smoke.ps1 +48 -0
- package/services/comment-locator.js +10 -9
- package/services/revision-comment-management.js +115 -1
- package/services/standalone-operation-runner.js +119 -69
- package/services/table-reconciliation.js +7 -8
package/index.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Standalone reconciliation entrypoint (no Word JS API dependencies).
|
|
3
|
-
*/
|
|
4
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Standalone reconciliation entrypoint (no Word JS API dependencies).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
5
|
// Adapters
|
|
6
6
|
export { configureXmlProvider } from './adapters/xml-adapter.js';
|
|
7
7
|
export { configureLogger } from './adapters/logger.js';
|
|
8
8
|
export { setDefaultAuthor, getDefaultAuthor, setPlatform, getPlatform } from './adapters/config.js';
|
|
9
|
-
|
|
10
|
-
// Engine
|
|
9
|
+
|
|
10
|
+
// Engine
|
|
11
11
|
import {
|
|
12
12
|
applyRedlineToOxml as applyRedlineToOxmlEngine,
|
|
13
13
|
sanitizeAiResponse,
|
|
@@ -25,27 +25,30 @@ import {
|
|
|
25
25
|
enforceListBindingOnParagraphNodes,
|
|
26
26
|
stripSingleLineListMarkerPrefix
|
|
27
27
|
} from './orchestration/list-structural-fallback.js';
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
28
|
+
import { withOoxmlSourceType } from './core/word-xml.js';
|
|
29
|
+
export { containsTrackedChanges } from './core/word-xml.js';
|
|
30
|
+
export { validateRedlineOoxml } from './core/redline-validation.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Standalone-safe redline wrapper.
|
|
34
|
+
*
|
|
35
|
+
* In non-Word runtimes, the engine can return `{ useNativeApi: true, hasChanges: true }`
|
|
36
|
+
* without an OOXML payload for some format-only operations. Standalone callers cannot
|
|
37
|
+
* complete that native fallback path, so normalize to a no-op with warnings.
|
|
38
|
+
*/
|
|
36
39
|
export async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}) {
|
|
37
40
|
const result = await applyRedlineToOxmlEngine(oxml, originalText, modifiedText, options);
|
|
38
41
|
if (result?.useNativeApi && typeof result?.oxml !== 'string') {
|
|
39
42
|
const existingWarnings = Array.isArray(result?.warnings) ? result.warnings : [];
|
|
40
|
-
return {
|
|
41
|
-
...result,
|
|
42
|
-
oxml,
|
|
43
|
-
hasChanges: false,
|
|
44
|
-
warnings: [
|
|
45
|
-
...existingWarnings,
|
|
46
|
-
'Standalone mode cannot execute native Word API fallback for this operation.'
|
|
47
|
-
]
|
|
48
|
-
};
|
|
43
|
+
return withOoxmlSourceType({
|
|
44
|
+
...result,
|
|
45
|
+
oxml,
|
|
46
|
+
hasChanges: false,
|
|
47
|
+
warnings: [
|
|
48
|
+
...existingWarnings,
|
|
49
|
+
'Standalone mode cannot execute native Word API fallback for this operation.'
|
|
50
|
+
]
|
|
51
|
+
});
|
|
49
52
|
}
|
|
50
53
|
return result;
|
|
51
54
|
}
|
|
@@ -97,9 +100,9 @@ export async function reconcileMarkdownTableOoxml(oxml, originalText, markdownTa
|
|
|
97
100
|
};
|
|
98
101
|
}
|
|
99
102
|
|
|
100
|
-
export { resolveParagraphRangeByRefs } from './core/paragraph-targeting.js';
|
|
101
|
-
export { inferTableReplacementParagraphBlock, isLikelyStructuredTableSourceParagraph } from './core/table-targeting.js';
|
|
102
|
-
|
|
103
|
+
export { resolveParagraphRangeByRefs } from './core/paragraph-targeting.js';
|
|
104
|
+
export { inferTableReplacementParagraphBlock, isLikelyStructuredTableSourceParagraph } from './core/table-targeting.js';
|
|
105
|
+
|
|
103
106
|
/**
|
|
104
107
|
* Applies redline reconciliation, then forces single-line structural list
|
|
105
108
|
* conversion when the redline is a no-op on marker-prefixed list text.
|
|
@@ -137,14 +140,14 @@ export async function applyRedlineToOxmlWithListFallback(oxml, originalText, mod
|
|
|
137
140
|
numberingXml: fallbackResult.numberingXml
|
|
138
141
|
});
|
|
139
142
|
const fallbackWarnings = Array.isArray(fallbackResult?.warnings) ? fallbackResult.warnings : [];
|
|
140
|
-
return {
|
|
143
|
+
return withOoxmlSourceType({
|
|
141
144
|
oxml: wrappedOxml,
|
|
142
145
|
hasChanges: true,
|
|
143
146
|
warnings: fallbackWarnings,
|
|
144
147
|
listStructuralFallbackApplied: true,
|
|
145
148
|
listStructuralFallbackKey: fallbackResult.listStructuralFallbackKey || null,
|
|
146
149
|
listStructuralFallbackNumberingXml: fallbackResult.numberingXml || null
|
|
147
|
-
};
|
|
150
|
+
});
|
|
148
151
|
}
|
|
149
152
|
preflightFallbackWarnings = Array.isArray(fallbackResult?.warnings) ? fallbackResult.warnings : [];
|
|
150
153
|
}
|
|
@@ -202,7 +205,7 @@ export async function applyRedlineToOxmlWithListFallback(oxml, originalText, mod
|
|
|
202
205
|
const existingWarnings = Array.isArray(baseResult?.warnings) ? baseResult.warnings : [];
|
|
203
206
|
const fallbackWarnings = Array.isArray(fallbackResult?.warnings) ? fallbackResult.warnings : [];
|
|
204
207
|
|
|
205
|
-
return {
|
|
208
|
+
return withOoxmlSourceType({
|
|
206
209
|
...baseResult,
|
|
207
210
|
oxml: wrappedOxml,
|
|
208
211
|
hasChanges: true,
|
|
@@ -210,7 +213,7 @@ export async function applyRedlineToOxmlWithListFallback(oxml, originalText, mod
|
|
|
210
213
|
listStructuralFallbackApplied: true,
|
|
211
214
|
listStructuralFallbackKey: fallbackResult.listStructuralFallbackKey || null,
|
|
212
215
|
listStructuralFallbackNumberingXml: fallbackResult.numberingXml || null
|
|
213
|
-
};
|
|
216
|
+
});
|
|
214
217
|
}
|
|
215
218
|
|
|
216
219
|
export { sanitizeAiResponse, parseOoxml, serializeOoxml };
|
|
@@ -232,8 +235,8 @@ export { ingestOoxml } from './pipeline/ingestion.js';
|
|
|
232
235
|
export { ingestWordOoxmlToPlainText, ingestWordOoxmlToMarkdown } from './pipeline/ingestion-export.js';
|
|
233
236
|
export { preprocessMarkdown } from './pipeline/markdown-processor.js';
|
|
234
237
|
export { serializeToOoxml, wrapInDocumentFragment } from './pipeline/serialization.js';
|
|
235
|
-
|
|
236
|
-
// Comment engine
|
|
238
|
+
|
|
239
|
+
// Comment engine
|
|
237
240
|
export {
|
|
238
241
|
injectCommentsIntoOoxml,
|
|
239
242
|
injectCommentsIntoPackage,
|
|
@@ -245,15 +248,15 @@ export {
|
|
|
245
248
|
rejectTrackedChangesInOoxml,
|
|
246
249
|
deleteCommentsByAuthorInOoxml
|
|
247
250
|
} from './services/revision-comment-management.js';
|
|
248
|
-
|
|
251
|
+
|
|
249
252
|
// Formatting removal utilities
|
|
250
253
|
export {
|
|
251
254
|
removeFormattingFromRPr,
|
|
252
255
|
applyFormattingRemovalToOoxml,
|
|
253
256
|
applyHighlightToOoxml
|
|
254
257
|
} from './engine/formatting-removal.js';
|
|
255
|
-
|
|
256
|
-
// Table/list tools
|
|
258
|
+
|
|
259
|
+
// Table/list tools
|
|
257
260
|
export { generateTableOoxml } from './services/table-reconciliation.js';
|
|
258
261
|
export { NumberingService } from './services/numbering-service.js';
|
|
259
262
|
export {
|
|
@@ -280,20 +283,20 @@ export {
|
|
|
280
283
|
enforceListBindingOnParagraphNodes,
|
|
281
284
|
stripSingleLineListMarkerPrefix
|
|
282
285
|
} from './orchestration/list-structural-fallback.js';
|
|
283
|
-
|
|
284
|
-
// Core types/constants
|
|
285
|
-
export { DiffOp, RunKind, ContainerKind, ContentType, NS_W, escapeXml } from './core/types.js';
|
|
286
|
-
export { extractParagraphIdFromOoxml } from './core/ooxml-identifiers.js';
|
|
286
|
+
|
|
287
|
+
// Core types/constants
|
|
288
|
+
export { DiffOp, RunKind, ContainerKind, ContentType, NS_W, escapeXml } from './core/types.js';
|
|
289
|
+
export { extractParagraphIdFromOoxml } from './core/ooxml-identifiers.js';
|
|
287
290
|
export {
|
|
288
291
|
WORD_MAIN_NS,
|
|
289
292
|
getParagraphText,
|
|
290
293
|
getDocumentParagraphNodes,
|
|
291
294
|
normalizeWhitespaceForTargeting,
|
|
292
|
-
isMarkdownTableText,
|
|
293
|
-
parseParagraphReference,
|
|
294
|
-
stripLeadingParagraphMarker,
|
|
295
|
-
splitLeadingParagraphMarker,
|
|
296
|
-
findContainingWordElement,
|
|
295
|
+
isMarkdownTableText,
|
|
296
|
+
parseParagraphReference,
|
|
297
|
+
stripLeadingParagraphMarker,
|
|
298
|
+
splitLeadingParagraphMarker,
|
|
299
|
+
findContainingWordElement,
|
|
297
300
|
findParagraphByReference,
|
|
298
301
|
findParagraphByStrictText,
|
|
299
302
|
findParagraphByBestTextMatch,
|
|
@@ -301,7 +304,7 @@ export {
|
|
|
301
304
|
buildTargetReferenceSnapshot,
|
|
302
305
|
resolveTargetParagraphWithSnapshot
|
|
303
306
|
} from './core/paragraph-targeting.js';
|
|
304
|
-
export { synthesizeTableMarkdownFromMultilineCellEdit } from './core/table-targeting.js';
|
|
307
|
+
export { synthesizeTableMarkdownFromMultilineCellEdit } from './core/table-targeting.js';
|
|
305
308
|
export {
|
|
306
309
|
getParagraphListInfo,
|
|
307
310
|
collectContiguousListParagraphBlock,
|
|
@@ -309,5 +312,5 @@ export {
|
|
|
309
312
|
planListInsertionOnlyEdit,
|
|
310
313
|
stripRedundantLeadingListMarkers
|
|
311
314
|
} from './core/list-targeting.js';
|
|
312
|
-
|
|
313
|
-
|
|
315
|
+
|
|
316
|
+
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ansonlai/docx-redline-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./index.js",
|
|
8
8
|
"module": "./index.js",
|
|
9
|
+
"types": "./index.d.ts",
|
|
9
10
|
"exports": {
|
|
10
11
|
".": {
|
|
12
|
+
"types": "./index.d.ts",
|
|
11
13
|
"import": "./index.js",
|
|
12
14
|
"default": "./index.js"
|
|
13
15
|
},
|
|
@@ -27,7 +29,10 @@
|
|
|
27
29
|
"pipeline/",
|
|
28
30
|
"services/",
|
|
29
31
|
"orchestration/",
|
|
32
|
+
"scripts/",
|
|
33
|
+
"docs/",
|
|
30
34
|
"index.js",
|
|
35
|
+
"index.d.ts",
|
|
31
36
|
"dist/",
|
|
32
37
|
"ARCHITECTURE.md",
|
|
33
38
|
"AGENTS.md",
|
|
@@ -51,6 +56,9 @@
|
|
|
51
56
|
},
|
|
52
57
|
"scripts": {
|
|
53
58
|
"build": "node scripts/build.mjs",
|
|
59
|
+
"check:types": "node scripts/check-types.mjs",
|
|
60
|
+
"smoke:word": "powershell -File scripts/word-com-smoke.ps1",
|
|
61
|
+
"smoke:word:diff": "powershell -File scripts/word-com-differential.ps1",
|
|
54
62
|
"test": "node scripts/run-tests.mjs",
|
|
55
63
|
"test:isolation": "node tests/no_word_api_index_check.mjs && node tests/core_dependency_graph_check.mjs",
|
|
56
64
|
"prepublishOnly": "npm run test:isolation && npm run build"
|
|
@@ -71,6 +79,6 @@
|
|
|
71
79
|
"url": "https://github.com/AnsonLai/docx-redline-js.git"
|
|
72
80
|
},
|
|
73
81
|
"engines": {
|
|
74
|
-
"node": ">=
|
|
82
|
+
"node": ">=20.0.0"
|
|
75
83
|
}
|
|
76
84
|
}
|
|
@@ -112,6 +112,7 @@ function collectParagraphSegments(paragraph) {
|
|
|
112
112
|
const runs = Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, 'r') || []);
|
|
113
113
|
for (const run of runs) {
|
|
114
114
|
if (hasWordAncestorWithin(run, 'del', paragraph)) continue;
|
|
115
|
+
if (hasWordAncestorWithin(run, 'moveFrom', paragraph)) continue;
|
|
115
116
|
const text = readRunText(run);
|
|
116
117
|
if (!text) continue;
|
|
117
118
|
segments.push({
|
|
@@ -217,13 +217,36 @@ function getNodeHandlers(runModel) {
|
|
|
217
217
|
return contentResult;
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
-
handlers.set('del', (child, offset) => {
|
|
221
|
-
const deletionEntry = processDeletion(child, offset);
|
|
222
|
-
if (deletionEntry) {
|
|
223
|
-
runModel.push(deletionEntry);
|
|
224
|
-
}
|
|
225
|
-
return { offset, text: '' };
|
|
226
|
-
});
|
|
220
|
+
handlers.set('del', (child, offset) => {
|
|
221
|
+
const deletionEntry = processDeletion(child, offset);
|
|
222
|
+
if (deletionEntry) {
|
|
223
|
+
runModel.push(deletionEntry);
|
|
224
|
+
}
|
|
225
|
+
return { offset, text: '' };
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
handlers.set('moveFrom', (child, offset) => {
|
|
229
|
+
const deletionEntry = processDeletion(child, offset);
|
|
230
|
+
if (deletionEntry) {
|
|
231
|
+
runModel.push(deletionEntry);
|
|
232
|
+
}
|
|
233
|
+
return { offset, text: '' };
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
handlers.set('moveTo', (child, offset) => processNodeRecursive(child, offset, runModel));
|
|
237
|
+
|
|
238
|
+
for (const markerName of ['moveFromRangeStart', 'moveFromRangeEnd', 'moveToRangeStart', 'moveToRangeEnd']) {
|
|
239
|
+
handlers.set(markerName, (child, offset) => {
|
|
240
|
+
runModel.push({
|
|
241
|
+
kind: RunKind.BOOKMARK,
|
|
242
|
+
nodeXml: serializeXml(child),
|
|
243
|
+
startOffset: offset,
|
|
244
|
+
endOffset: offset,
|
|
245
|
+
text: ''
|
|
246
|
+
});
|
|
247
|
+
return { offset, text: '' };
|
|
248
|
+
});
|
|
249
|
+
}
|
|
227
250
|
|
|
228
251
|
handlers.set('bookmarkStart', (child, offset) => {
|
|
229
252
|
runModel.push({
|
|
@@ -320,11 +343,13 @@ function processRun(runElement, startOffset) {
|
|
|
320
343
|
};
|
|
321
344
|
}
|
|
322
345
|
|
|
323
|
-
function processDeletion(delElement, offset) {
|
|
324
|
-
const author = delElement.getAttribute('w:author') || '';
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
346
|
+
function processDeletion(delElement, offset) {
|
|
347
|
+
const author = delElement.getAttribute('w:author') || '';
|
|
348
|
+
|
|
349
|
+
// Deleted text is retained as a zero-width deletion model entry for revision-aware callers.
|
|
350
|
+
// It is intentionally not added to acceptedText by the w:del handler above.
|
|
351
|
+
let text = '';
|
|
352
|
+
const delTexts = getElementsByTagNS(delElement, NS_W, 'delText');
|
|
328
353
|
for (const delText of delTexts) {
|
|
329
354
|
text += delText.textContent || '';
|
|
330
355
|
}
|
|
@@ -20,13 +20,16 @@ import { ingestParagraphElement } from './ingestion-paragraph.js';
|
|
|
20
20
|
* @param {Element} tableNode - w:tbl element
|
|
21
21
|
* @returns {Object}
|
|
22
22
|
*/
|
|
23
|
-
export function ingestTableToVirtualGrid(tableNode) {
|
|
24
|
-
const tblGrid = getFirstElementByTagNS(tableNode, NS_W, 'tblGrid');
|
|
25
|
-
const gridCols = tblGrid ? getElementsByTagNS(tblGrid, NS_W, 'gridCol') : [];
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
23
|
+
export function ingestTableToVirtualGrid(tableNode) {
|
|
24
|
+
const tblGrid = getFirstElementByTagNS(tableNode, NS_W, 'tblGrid');
|
|
25
|
+
const gridCols = tblGrid ? getElementsByTagNS(tblGrid, NS_W, 'gridCol') : [];
|
|
26
|
+
const trElements = getElementsByTagNSOrTag(tableNode, NS_W, 'tr');
|
|
27
|
+
const rowCount = trElements.length;
|
|
28
|
+
const inferredColCount = trElements.reduce((max, tr) => {
|
|
29
|
+
const tcElements = getElementsByTagNSOrTag(tr, NS_W, 'tc');
|
|
30
|
+
return Math.max(max, tcElements.length);
|
|
31
|
+
}, 0);
|
|
32
|
+
const colCount = gridCols.length || inferredColCount;
|
|
30
33
|
|
|
31
34
|
const grid = Array.from({ length: rowCount }, () =>
|
|
32
35
|
Array.from({ length: colCount }, () => null)
|
|
@@ -36,7 +39,7 @@ export function ingestTableToVirtualGrid(tableNode) {
|
|
|
36
39
|
|
|
37
40
|
for (let rowIdx = 0; rowIdx < trElements.length; rowIdx++) {
|
|
38
41
|
const tr = trElements[rowIdx];
|
|
39
|
-
const tcElements =
|
|
42
|
+
const tcElements = getElementsByTagNSOrTag(tr, NS_W, 'tc');
|
|
40
43
|
let gridCol = 0;
|
|
41
44
|
|
|
42
45
|
for (let tcIdx = 0; tcIdx < tcElements.length; tcIdx++) {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { build } from 'esbuild';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { dirname, resolve } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
7
|
+
const entryPoint = resolve(repoRoot, 'index.js');
|
|
8
|
+
const distDir = resolve(repoRoot, 'dist');
|
|
9
|
+
const pkg = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8'));
|
|
10
|
+
|
|
11
|
+
// ESM bundle with diff-match-patch inlined (for CDN/browser <script type="module">)
|
|
12
|
+
await build({
|
|
13
|
+
entryPoints: [entryPoint],
|
|
14
|
+
bundle: true,
|
|
15
|
+
format: 'esm',
|
|
16
|
+
outfile: resolve(distDir, 'docx-redline-js.esm.js'),
|
|
17
|
+
platform: 'neutral', // no Node builtins assumed
|
|
18
|
+
target: 'es2020',
|
|
19
|
+
minify: false, // keep readable for debugging
|
|
20
|
+
sourcemap: true,
|
|
21
|
+
banner: {
|
|
22
|
+
js: `// @ansonlai/docx-redline-js v${pkg.version} — https://github.com/AnsonLai/docx-redline-js`
|
|
23
|
+
},
|
|
24
|
+
external: ['@xmldom/xmldom'] // never bundle the Node-only XML parser
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Minified version for production CDN use
|
|
28
|
+
await build({
|
|
29
|
+
entryPoints: [entryPoint],
|
|
30
|
+
bundle: true,
|
|
31
|
+
format: 'esm',
|
|
32
|
+
outfile: resolve(distDir, 'docx-redline-js.esm.min.js'),
|
|
33
|
+
platform: 'neutral',
|
|
34
|
+
target: 'es2020',
|
|
35
|
+
minify: true,
|
|
36
|
+
sourcemap: true,
|
|
37
|
+
external: ['@xmldom/xmldom']
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
console.log('Build complete: dist/docx-redline-js.esm.js, dist/docx-redline-js.esm.min.js');
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
|
|
3
|
+
const dts = readFileSync(new URL('../index.d.ts', import.meta.url), 'utf8');
|
|
4
|
+
|
|
5
|
+
const requiredSnippets = [
|
|
6
|
+
'export interface RedlineOptions',
|
|
7
|
+
'export interface RedlineResult',
|
|
8
|
+
'export function applyRedlineToOxml',
|
|
9
|
+
'export function acceptTrackedChangesInOoxml',
|
|
10
|
+
'export function rejectTrackedChangesInOoxml',
|
|
11
|
+
'export function deleteCommentsByAuthorInOoxml',
|
|
12
|
+
'export function validateRedlineOoxml'
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
for (const snippet of requiredSnippets) {
|
|
16
|
+
if (!dts.includes(snippet)) {
|
|
17
|
+
throw new Error(`Missing declaration snippet: ${snippet}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let balance = 0;
|
|
22
|
+
for (const char of dts) {
|
|
23
|
+
if (char === '{') balance += 1;
|
|
24
|
+
if (char === '}') balance -= 1;
|
|
25
|
+
if (balance < 0) throw new Error('index.d.ts has unbalanced braces');
|
|
26
|
+
}
|
|
27
|
+
if (balance !== 0) throw new Error('index.d.ts has unbalanced braces');
|
|
28
|
+
|
|
29
|
+
console.log('PASS: index.d.ts declaration smoke check');
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
import { configureXmlProvider } from '../adapters/xml-adapter.js';
|
|
5
|
+
import { validateRedlineOoxml } from '../core/redline-validation.js';
|
|
6
|
+
import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
|
|
7
|
+
import { applyOperationToDocumentXml } from '../services/standalone-operation-runner.js';
|
|
8
|
+
import { buildMinimalDocx } from './lib/minimal-zip.mjs';
|
|
9
|
+
|
|
10
|
+
const { DOMParser, XMLSerializer } = await import('@xmldom/xmldom');
|
|
11
|
+
configureXmlProvider({ DOMParser, XMLSerializer });
|
|
12
|
+
|
|
13
|
+
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
14
|
+
const outputDir = join(process.cwd(), 'tmp', 'validation-docx');
|
|
15
|
+
mkdirSync(outputDir, { recursive: true });
|
|
16
|
+
|
|
17
|
+
const baseDocument = text => `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
18
|
+
<w:document xmlns:w="${NS_W}">
|
|
19
|
+
<w:body>
|
|
20
|
+
<w:p><w:r><w:t xml:space="preserve">${text}</w:t></w:r></w:p>
|
|
21
|
+
<w:sectPr/>
|
|
22
|
+
</w:body>
|
|
23
|
+
</w:document>`;
|
|
24
|
+
|
|
25
|
+
const cases = [
|
|
26
|
+
{
|
|
27
|
+
name: 'simple-redline',
|
|
28
|
+
original: 'The old sentence.',
|
|
29
|
+
modified: 'The new sentence.'
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'paragraph-insert',
|
|
33
|
+
original: 'one',
|
|
34
|
+
modified: 'one\ntwo'
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'format-only',
|
|
38
|
+
original: 'Make word bold',
|
|
39
|
+
modified: 'Make **word** bold'
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'whitespace-heavy',
|
|
43
|
+
original: 'Alpha beta gamma delta.',
|
|
44
|
+
modified: 'Alpha beta REPLACED delta.'
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'unicode-replace',
|
|
48
|
+
original: 'Term 条款 applies to café.',
|
|
49
|
+
modified: 'Term 合同 applies to café 🚀.'
|
|
50
|
+
}
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
let failures = 0;
|
|
54
|
+
|
|
55
|
+
for (const testCase of cases) {
|
|
56
|
+
const result = await applyOperationToDocumentXml(
|
|
57
|
+
baseDocument(testCase.original),
|
|
58
|
+
{ type: 'redline', target: testCase.original, modified: testCase.modified },
|
|
59
|
+
'Validation',
|
|
60
|
+
null,
|
|
61
|
+
{ generateRedlines: true }
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
if (!result?.hasChanges || result?.status === 'error') {
|
|
65
|
+
console.error(`FAIL ${testCase.name}: redline did not apply (status=${result?.status}, error=${result?.error?.message})`);
|
|
66
|
+
failures++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const validation = validateRedlineOoxml(result.documentXml);
|
|
71
|
+
const validationErrors = validation.issues.filter(issue => issue.severity === 'error');
|
|
72
|
+
if (validationErrors.length > 0) {
|
|
73
|
+
console.error(`FAIL ${testCase.name}: validateRedlineOoxml reported ${validationErrors.map(issue => issue.code).join(', ')}`);
|
|
74
|
+
failures++;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
writeFileSync(join(outputDir, `${testCase.name}.document.xml`), result.documentXml, 'utf8');
|
|
79
|
+
if (result.numberingXml) {
|
|
80
|
+
writeFileSync(join(outputDir, `${testCase.name}.numbering.xml`), result.numberingXml, 'utf8');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const docx = buildMinimalDocx(result.documentXml, { numberingXml: result.numberingXml || null });
|
|
84
|
+
writeFileSync(join(outputDir, `${testCase.name}.docx`), docx);
|
|
85
|
+
|
|
86
|
+
// Expected text is derived from edit *intent*, not from this library's
|
|
87
|
+
// accept/reject transforms, so external consumers (Word COM, LibreOffice)
|
|
88
|
+
// act as independent oracles.
|
|
89
|
+
const expected = {
|
|
90
|
+
name: testCase.name,
|
|
91
|
+
expectedAcceptedText: preprocessMarkdown(testCase.modified).cleanText,
|
|
92
|
+
expectedRejectedText: testCase.original
|
|
93
|
+
};
|
|
94
|
+
writeFileSync(join(outputDir, `${testCase.name}.expected.json`), `${JSON.stringify(expected, null, 2)}\n`, 'utf8');
|
|
95
|
+
|
|
96
|
+
console.log(`wrote ${testCase.name}: .document.xml, .docx, .expected.json`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
writeFileSync(join(outputDir, 'README.md'), `# Validation Fixtures
|
|
100
|
+
|
|
101
|
+
Generated by \`node scripts/export-validation-fixtures.mjs\`.
|
|
102
|
+
|
|
103
|
+
Each case produces:
|
|
104
|
+
|
|
105
|
+
- \`<name>.document.xml\` — the generated \`word/document.xml\` payload (for
|
|
106
|
+
XSD validation and manual inspection).
|
|
107
|
+
- \`<name>.docx\` — a minimal package assembled by release tooling only (the
|
|
108
|
+
published library still has no zip dependency).
|
|
109
|
+
- \`<name>.expected.json\` — the accept-all / reject-all plain-text outcomes
|
|
110
|
+
derived from edit intent, used by external-consumer differential checks.
|
|
111
|
+
|
|
112
|
+
Validation entry points:
|
|
113
|
+
|
|
114
|
+
- Word (differential accept/reject): \`npm run smoke:word:diff\`
|
|
115
|
+
- LibreOffice parse check: \`soffice --headless --convert-to pdf *.docx\`
|
|
116
|
+
- Schema check: \`xmllint --noout --schema wml.xsd *.document.xml\`
|
|
117
|
+
(transitional schemas from ECMA-376 Part 4; see docs/VALIDATION.md)
|
|
118
|
+
`, 'utf8');
|
|
119
|
+
|
|
120
|
+
if (failures > 0) {
|
|
121
|
+
console.error(`\n${failures} fixture case(s) failed.`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
console.log(`Wrote validation fixtures to ${outputDir}`);
|