@ansonlai/docx-redline-js 0.1.6 → 0.2.1
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 +19 -3
- package/README.md +15 -0
- package/core/redline-validation.js +156 -0
- package/dist/docx-redline-js.esm.js +152 -31
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +62 -62
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/VALIDATION.md +79 -23
- package/docs/plans/2026-05-31-architectural changes.md +591 -0
- package/engine/formatting-removal.js +14 -8
- package/engine/run-builders.js +14 -10
- package/engine/surgical-diff-application.js +7 -21
- package/index.d.ts +24 -0
- package/index.js +35 -34
- package/package.json +5 -4
- package/scripts/build.mjs +10 -5
- package/scripts/check-types.mjs +2 -1
- package/scripts/export-validation-fixtures.mjs +73 -16
- package/scripts/lib/minimal-zip.mjs +155 -0
- package/scripts/validate-fixtures-xsd.sh +37 -0
- package/scripts/word-com-differential.ps1 +133 -0
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';
|
|
@@ -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;
|
|
@@ -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/index.d.ts
CHANGED
|
@@ -126,6 +126,30 @@ export function rejectTrackedChangesInOoxml(oxml: string, options?: RevisionFilt
|
|
|
126
126
|
export function deleteCommentsByAuthorInOoxml(oxml: string, options?: RevisionFilterOptions): DeleteCommentsResult;
|
|
127
127
|
export function containsTrackedChanges(xmlDoc: Document | Element): boolean;
|
|
128
128
|
|
|
129
|
+
export type RedlineValidationSeverity = 'error' | 'warning';
|
|
130
|
+
|
|
131
|
+
export interface RedlineValidationIssue {
|
|
132
|
+
code:
|
|
133
|
+
| 'PARSE_ERROR'
|
|
134
|
+
| 'NESTED_REVISION'
|
|
135
|
+
| 'DEL_CONTAINS_T'
|
|
136
|
+
| 'MISSING_REVISION_METADATA'
|
|
137
|
+
| 'DUPLICATE_REVISION_ID'
|
|
138
|
+
| 'MISSING_SPACE_PRESERVE'
|
|
139
|
+
| 'EMPTY_TEXT_ELEMENT'
|
|
140
|
+
| 'EMPTY_REVISION_WRAPPER'
|
|
141
|
+
| string;
|
|
142
|
+
severity: RedlineValidationSeverity;
|
|
143
|
+
message: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface RedlineValidationResult {
|
|
147
|
+
valid: boolean;
|
|
148
|
+
issues: RedlineValidationIssue[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function validateRedlineOoxml(oxml: string): RedlineValidationResult;
|
|
152
|
+
|
|
129
153
|
export function applyHighlightToOoxml(oxml: string, targetText: string, color: string, options?: Record<string, unknown>): string;
|
|
130
154
|
export function generateTableOoxml(headersOrData: unknown, rowsOrOptions?: unknown, options?: Record<string, unknown>): string;
|
|
131
155
|
export function extractReplacementNodesFromOoxml(oxml: string): unknown;
|
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,
|
|
@@ -27,14 +27,15 @@ import {
|
|
|
27
27
|
} from './orchestration/list-structural-fallback.js';
|
|
28
28
|
import { withOoxmlSourceType } from './core/word-xml.js';
|
|
29
29
|
export { containsTrackedChanges } from './core/word-xml.js';
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
|
|
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
|
+
*/
|
|
38
39
|
export async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}) {
|
|
39
40
|
const result = await applyRedlineToOxmlEngine(oxml, originalText, modifiedText, options);
|
|
40
41
|
if (result?.useNativeApi && typeof result?.oxml !== 'string') {
|
|
@@ -99,9 +100,9 @@ export async function reconcileMarkdownTableOoxml(oxml, originalText, markdownTa
|
|
|
99
100
|
};
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
export { resolveParagraphRangeByRefs } from './core/paragraph-targeting.js';
|
|
103
|
-
export { inferTableReplacementParagraphBlock, isLikelyStructuredTableSourceParagraph } from './core/table-targeting.js';
|
|
104
|
-
|
|
103
|
+
export { resolveParagraphRangeByRefs } from './core/paragraph-targeting.js';
|
|
104
|
+
export { inferTableReplacementParagraphBlock, isLikelyStructuredTableSourceParagraph } from './core/table-targeting.js';
|
|
105
|
+
|
|
105
106
|
/**
|
|
106
107
|
* Applies redline reconciliation, then forces single-line structural list
|
|
107
108
|
* conversion when the redline is a no-op on marker-prefixed list text.
|
|
@@ -234,8 +235,8 @@ export { ingestOoxml } from './pipeline/ingestion.js';
|
|
|
234
235
|
export { ingestWordOoxmlToPlainText, ingestWordOoxmlToMarkdown } from './pipeline/ingestion-export.js';
|
|
235
236
|
export { preprocessMarkdown } from './pipeline/markdown-processor.js';
|
|
236
237
|
export { serializeToOoxml, wrapInDocumentFragment } from './pipeline/serialization.js';
|
|
237
|
-
|
|
238
|
-
// Comment engine
|
|
238
|
+
|
|
239
|
+
// Comment engine
|
|
239
240
|
export {
|
|
240
241
|
injectCommentsIntoOoxml,
|
|
241
242
|
injectCommentsIntoPackage,
|
|
@@ -247,15 +248,15 @@ export {
|
|
|
247
248
|
rejectTrackedChangesInOoxml,
|
|
248
249
|
deleteCommentsByAuthorInOoxml
|
|
249
250
|
} from './services/revision-comment-management.js';
|
|
250
|
-
|
|
251
|
+
|
|
251
252
|
// Formatting removal utilities
|
|
252
253
|
export {
|
|
253
254
|
removeFormattingFromRPr,
|
|
254
255
|
applyFormattingRemovalToOoxml,
|
|
255
256
|
applyHighlightToOoxml
|
|
256
257
|
} from './engine/formatting-removal.js';
|
|
257
|
-
|
|
258
|
-
// Table/list tools
|
|
258
|
+
|
|
259
|
+
// Table/list tools
|
|
259
260
|
export { generateTableOoxml } from './services/table-reconciliation.js';
|
|
260
261
|
export { NumberingService } from './services/numbering-service.js';
|
|
261
262
|
export {
|
|
@@ -282,20 +283,20 @@ export {
|
|
|
282
283
|
enforceListBindingOnParagraphNodes,
|
|
283
284
|
stripSingleLineListMarkerPrefix
|
|
284
285
|
} from './orchestration/list-structural-fallback.js';
|
|
285
|
-
|
|
286
|
-
// Core types/constants
|
|
287
|
-
export { DiffOp, RunKind, ContainerKind, ContentType, NS_W, escapeXml } from './core/types.js';
|
|
288
|
-
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';
|
|
289
290
|
export {
|
|
290
291
|
WORD_MAIN_NS,
|
|
291
292
|
getParagraphText,
|
|
292
293
|
getDocumentParagraphNodes,
|
|
293
294
|
normalizeWhitespaceForTargeting,
|
|
294
|
-
isMarkdownTableText,
|
|
295
|
-
parseParagraphReference,
|
|
296
|
-
stripLeadingParagraphMarker,
|
|
297
|
-
splitLeadingParagraphMarker,
|
|
298
|
-
findContainingWordElement,
|
|
295
|
+
isMarkdownTableText,
|
|
296
|
+
parseParagraphReference,
|
|
297
|
+
stripLeadingParagraphMarker,
|
|
298
|
+
splitLeadingParagraphMarker,
|
|
299
|
+
findContainingWordElement,
|
|
299
300
|
findParagraphByReference,
|
|
300
301
|
findParagraphByStrictText,
|
|
301
302
|
findParagraphByBestTextMatch,
|
|
@@ -303,7 +304,7 @@ export {
|
|
|
303
304
|
buildTargetReferenceSnapshot,
|
|
304
305
|
resolveTargetParagraphWithSnapshot
|
|
305
306
|
} from './core/paragraph-targeting.js';
|
|
306
|
-
export { synthesizeTableMarkdownFromMultilineCellEdit } from './core/table-targeting.js';
|
|
307
|
+
export { synthesizeTableMarkdownFromMultilineCellEdit } from './core/table-targeting.js';
|
|
307
308
|
export {
|
|
308
309
|
getParagraphListInfo,
|
|
309
310
|
collectContiguousListParagraphBlock,
|
|
@@ -311,5 +312,5 @@ export {
|
|
|
311
312
|
planListInsertionOnlyEdit,
|
|
312
313
|
stripRedundantLeadingListMarkers
|
|
313
314
|
} from './core/list-targeting.js';
|
|
314
|
-
|
|
315
|
-
|
|
315
|
+
|
|
316
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ansonlai/docx-redline-js",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -51,13 +51,14 @@
|
|
|
51
51
|
}
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"
|
|
55
|
-
"
|
|
54
|
+
"@xmldom/xmldom": "^0.9.0",
|
|
55
|
+
"esbuild": "^0.28.1"
|
|
56
56
|
},
|
|
57
57
|
"scripts": {
|
|
58
58
|
"build": "node scripts/build.mjs",
|
|
59
59
|
"check:types": "node scripts/check-types.mjs",
|
|
60
60
|
"smoke:word": "powershell -File scripts/word-com-smoke.ps1",
|
|
61
|
+
"smoke:word:diff": "powershell -File scripts/word-com-differential.ps1",
|
|
61
62
|
"test": "node scripts/run-tests.mjs",
|
|
62
63
|
"test:isolation": "node tests/no_word_api_index_check.mjs && node tests/core_dependency_graph_check.mjs",
|
|
63
64
|
"prepublishOnly": "npm run test:isolation && npm run build"
|
|
@@ -78,6 +79,6 @@
|
|
|
78
79
|
"url": "https://github.com/AnsonLai/docx-redline-js.git"
|
|
79
80
|
},
|
|
80
81
|
"engines": {
|
|
81
|
-
"node": ">=
|
|
82
|
+
"node": ">=20.0.0"
|
|
82
83
|
}
|
|
83
84
|
}
|
package/scripts/build.mjs
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { build } from 'esbuild';
|
|
2
2
|
import { readFileSync } from 'fs';
|
|
3
|
+
import { dirname, resolve } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
3
5
|
|
|
4
|
-
const
|
|
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'));
|
|
5
10
|
|
|
6
11
|
// ESM bundle with diff-match-patch inlined (for CDN/browser <script type="module">)
|
|
7
12
|
await build({
|
|
8
|
-
entryPoints: [
|
|
13
|
+
entryPoints: [entryPoint],
|
|
9
14
|
bundle: true,
|
|
10
15
|
format: 'esm',
|
|
11
|
-
outfile: '
|
|
16
|
+
outfile: resolve(distDir, 'docx-redline-js.esm.js'),
|
|
12
17
|
platform: 'neutral', // no Node builtins assumed
|
|
13
18
|
target: 'es2020',
|
|
14
19
|
minify: false, // keep readable for debugging
|
|
@@ -21,10 +26,10 @@ await build({
|
|
|
21
26
|
|
|
22
27
|
// Minified version for production CDN use
|
|
23
28
|
await build({
|
|
24
|
-
entryPoints: [
|
|
29
|
+
entryPoints: [entryPoint],
|
|
25
30
|
bundle: true,
|
|
26
31
|
format: 'esm',
|
|
27
|
-
outfile: '
|
|
32
|
+
outfile: resolve(distDir, 'docx-redline-js.esm.min.js'),
|
|
28
33
|
platform: 'neutral',
|
|
29
34
|
target: 'es2020',
|
|
30
35
|
minify: true,
|
package/scripts/check-types.mjs
CHANGED
|
@@ -8,7 +8,8 @@ const requiredSnippets = [
|
|
|
8
8
|
'export function applyRedlineToOxml',
|
|
9
9
|
'export function acceptTrackedChangesInOoxml',
|
|
10
10
|
'export function rejectTrackedChangesInOoxml',
|
|
11
|
-
'export function deleteCommentsByAuthorInOoxml'
|
|
11
|
+
'export function deleteCommentsByAuthorInOoxml',
|
|
12
|
+
'export function validateRedlineOoxml'
|
|
12
13
|
];
|
|
13
14
|
|
|
14
15
|
for (const snippet of requiredSnippets) {
|
|
@@ -2,7 +2,10 @@ import { mkdirSync, writeFileSync } from 'fs';
|
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
|
|
4
4
|
import { configureXmlProvider } from '../adapters/xml-adapter.js';
|
|
5
|
+
import { validateRedlineOoxml } from '../core/redline-validation.js';
|
|
6
|
+
import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
|
|
5
7
|
import { applyOperationToDocumentXml } from '../services/standalone-operation-runner.js';
|
|
8
|
+
import { buildMinimalDocx } from './lib/minimal-zip.mjs';
|
|
6
9
|
|
|
7
10
|
const { DOMParser, XMLSerializer } = await import('@xmldom/xmldom');
|
|
8
11
|
configureXmlProvider({ DOMParser, XMLSerializer });
|
|
@@ -14,7 +17,7 @@ mkdirSync(outputDir, { recursive: true });
|
|
|
14
17
|
const baseDocument = text => `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
15
18
|
<w:document xmlns:w="${NS_W}">
|
|
16
19
|
<w:body>
|
|
17
|
-
<w:p><w:r><w:t>${text}</w:t></w:r></w:p>
|
|
20
|
+
<w:p><w:r><w:t xml:space="preserve">${text}</w:t></w:r></w:p>
|
|
18
21
|
<w:sectPr/>
|
|
19
22
|
</w:body>
|
|
20
23
|
</w:document>`;
|
|
@@ -22,47 +25,101 @@ const baseDocument = text => `<?xml version="1.0" encoding="UTF-8" standalone="y
|
|
|
22
25
|
const cases = [
|
|
23
26
|
{
|
|
24
27
|
name: 'simple-redline',
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
original: 'The old sentence.',
|
|
29
|
+
modified: 'The new sentence.'
|
|
27
30
|
},
|
|
28
31
|
{
|
|
29
32
|
name: 'paragraph-insert',
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
original: 'one',
|
|
34
|
+
modified: 'one\ntwo'
|
|
32
35
|
},
|
|
33
36
|
{
|
|
34
37
|
name: 'format-only',
|
|
35
|
-
|
|
36
|
-
|
|
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é 🚀.'
|
|
37
50
|
}
|
|
38
51
|
];
|
|
39
52
|
|
|
53
|
+
let failures = 0;
|
|
54
|
+
|
|
40
55
|
for (const testCase of cases) {
|
|
41
56
|
const result = await applyOperationToDocumentXml(
|
|
42
|
-
testCase.
|
|
43
|
-
testCase.
|
|
57
|
+
baseDocument(testCase.original),
|
|
58
|
+
{ type: 'redline', target: testCase.original, modified: testCase.modified },
|
|
44
59
|
'Validation',
|
|
45
60
|
null,
|
|
46
61
|
{ generateRedlines: true }
|
|
47
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
|
+
|
|
48
78
|
writeFileSync(join(outputDir, `${testCase.name}.document.xml`), result.documentXml, 'utf8');
|
|
49
79
|
if (result.numberingXml) {
|
|
50
80
|
writeFileSync(join(outputDir, `${testCase.name}.numbering.xml`), result.numberingXml, 'utf8');
|
|
51
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`);
|
|
52
97
|
}
|
|
53
98
|
|
|
54
99
|
writeFileSync(join(outputDir, 'README.md'), `# Validation Fixtures
|
|
55
100
|
|
|
56
|
-
|
|
101
|
+
Generated by \`node scripts/export-validation-fixtures.mjs\`.
|
|
102
|
+
|
|
103
|
+
Each case produces:
|
|
57
104
|
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
60
111
|
|
|
61
|
-
|
|
112
|
+
Validation entry points:
|
|
62
113
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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)
|
|
66
118
|
`, 'utf8');
|
|
67
119
|
|
|
120
|
+
if (failures > 0) {
|
|
121
|
+
console.error(`\n${failures} fixture case(s) failed.`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
68
125
|
console.log(`Wrote validation fixtures to ${outputDir}`);
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal zip writer for assembling validation .docx fixtures.
|
|
3
|
+
*
|
|
4
|
+
* Script-only helper (not part of the published API surface) so the package
|
|
5
|
+
* keeps its no-zip-dependency guarantee while release tooling can still emit
|
|
6
|
+
* real .docx files for Word/LibreOffice validation. Uses deflate via
|
|
7
|
+
* node:zlib and a fixed timestamp for deterministic output.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { deflateRawSync } from 'zlib';
|
|
11
|
+
|
|
12
|
+
const CRC_TABLE = (() => {
|
|
13
|
+
const table = new Uint32Array(256);
|
|
14
|
+
for (let n = 0; n < 256; n++) {
|
|
15
|
+
let c = n;
|
|
16
|
+
for (let k = 0; k < 8; k++) {
|
|
17
|
+
c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1;
|
|
18
|
+
}
|
|
19
|
+
table[n] = c >>> 0;
|
|
20
|
+
}
|
|
21
|
+
return table;
|
|
22
|
+
})();
|
|
23
|
+
|
|
24
|
+
function crc32(buffer) {
|
|
25
|
+
let crc = 0xFFFFFFFF;
|
|
26
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
27
|
+
crc = CRC_TABLE[(crc ^ buffer[i]) & 0xFF] ^ (crc >>> 8);
|
|
28
|
+
}
|
|
29
|
+
return (crc ^ 0xFFFFFFFF) >>> 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Fixed DOS date/time (2026-01-01 00:00:00) keeps fixture bytes deterministic.
|
|
33
|
+
const DOS_TIME = 0;
|
|
34
|
+
const DOS_DATE = ((2026 - 1980) << 9) | (1 << 5) | 1;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Builds a zip archive.
|
|
38
|
+
*
|
|
39
|
+
* @param {Array<{ name: string, data: Buffer|string }>} entries - Entry names
|
|
40
|
+
* must use forward slashes (OPC requirement for .docx parts).
|
|
41
|
+
* @returns {Buffer}
|
|
42
|
+
*/
|
|
43
|
+
export function buildZip(entries) {
|
|
44
|
+
const localParts = [];
|
|
45
|
+
const centralParts = [];
|
|
46
|
+
let offset = 0;
|
|
47
|
+
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
const nameBytes = Buffer.from(entry.name, 'utf8');
|
|
50
|
+
const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data, 'utf8');
|
|
51
|
+
const crc = crc32(data);
|
|
52
|
+
|
|
53
|
+
const deflated = deflateRawSync(data, { level: 9 });
|
|
54
|
+
const useDeflate = deflated.length < data.length;
|
|
55
|
+
const method = useDeflate ? 8 : 0;
|
|
56
|
+
const payload = useDeflate ? deflated : data;
|
|
57
|
+
|
|
58
|
+
const localHeader = Buffer.alloc(30);
|
|
59
|
+
localHeader.writeUInt32LE(0x04034B50, 0);
|
|
60
|
+
localHeader.writeUInt16LE(20, 4); // version needed
|
|
61
|
+
localHeader.writeUInt16LE(0, 6); // flags
|
|
62
|
+
localHeader.writeUInt16LE(method, 8);
|
|
63
|
+
localHeader.writeUInt16LE(DOS_TIME, 10);
|
|
64
|
+
localHeader.writeUInt16LE(DOS_DATE, 12);
|
|
65
|
+
localHeader.writeUInt32LE(crc, 14);
|
|
66
|
+
localHeader.writeUInt32LE(payload.length, 18);
|
|
67
|
+
localHeader.writeUInt32LE(data.length, 22);
|
|
68
|
+
localHeader.writeUInt16LE(nameBytes.length, 26);
|
|
69
|
+
localHeader.writeUInt16LE(0, 28); // extra length
|
|
70
|
+
|
|
71
|
+
localParts.push(localHeader, nameBytes, payload);
|
|
72
|
+
|
|
73
|
+
const centralHeader = Buffer.alloc(46);
|
|
74
|
+
centralHeader.writeUInt32LE(0x02014B50, 0);
|
|
75
|
+
centralHeader.writeUInt16LE(20, 4); // version made by
|
|
76
|
+
centralHeader.writeUInt16LE(20, 6); // version needed
|
|
77
|
+
centralHeader.writeUInt16LE(0, 8); // flags
|
|
78
|
+
centralHeader.writeUInt16LE(method, 10);
|
|
79
|
+
centralHeader.writeUInt16LE(DOS_TIME, 12);
|
|
80
|
+
centralHeader.writeUInt16LE(DOS_DATE, 14);
|
|
81
|
+
centralHeader.writeUInt32LE(crc, 16);
|
|
82
|
+
centralHeader.writeUInt32LE(payload.length, 20);
|
|
83
|
+
centralHeader.writeUInt32LE(data.length, 24);
|
|
84
|
+
centralHeader.writeUInt16LE(nameBytes.length, 28);
|
|
85
|
+
centralHeader.writeUInt16LE(0, 30); // extra length
|
|
86
|
+
centralHeader.writeUInt16LE(0, 32); // comment length
|
|
87
|
+
centralHeader.writeUInt16LE(0, 34); // disk number
|
|
88
|
+
centralHeader.writeUInt16LE(0, 36); // internal attrs
|
|
89
|
+
centralHeader.writeUInt32LE(0, 38); // external attrs
|
|
90
|
+
centralHeader.writeUInt32LE(offset, 42);
|
|
91
|
+
|
|
92
|
+
centralParts.push(centralHeader, nameBytes);
|
|
93
|
+
offset += localHeader.length + nameBytes.length + payload.length;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const centralDirectory = Buffer.concat(centralParts);
|
|
97
|
+
|
|
98
|
+
const endRecord = Buffer.alloc(22);
|
|
99
|
+
endRecord.writeUInt32LE(0x06054B50, 0);
|
|
100
|
+
endRecord.writeUInt16LE(0, 4); // disk number
|
|
101
|
+
endRecord.writeUInt16LE(0, 6); // central dir start disk
|
|
102
|
+
endRecord.writeUInt16LE(entries.length, 8);
|
|
103
|
+
endRecord.writeUInt16LE(entries.length, 10);
|
|
104
|
+
endRecord.writeUInt32LE(centralDirectory.length, 12);
|
|
105
|
+
endRecord.writeUInt32LE(offset, 16);
|
|
106
|
+
endRecord.writeUInt16LE(0, 20); // comment length
|
|
107
|
+
|
|
108
|
+
return Buffer.concat([...localParts, centralDirectory, endRecord]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const CONTENT_TYPES_BASE = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
112
|
+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
113
|
+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
114
|
+
<Default Extension="xml" ContentType="application/xml"/>
|
|
115
|
+
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
|
116
|
+
%OVERRIDES%</Types>`;
|
|
117
|
+
|
|
118
|
+
const ROOT_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
119
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
120
|
+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
|
121
|
+
</Relationships>`;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Assembles a minimal .docx package around a word/document.xml payload.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} documentXml - Complete word/document.xml content
|
|
127
|
+
* @param {{ numberingXml?: string|null }} [parts] - Optional extra parts
|
|
128
|
+
* @returns {Buffer} - .docx bytes
|
|
129
|
+
*/
|
|
130
|
+
export function buildMinimalDocx(documentXml, parts = {}) {
|
|
131
|
+
const overrides = [];
|
|
132
|
+
const documentRels = [];
|
|
133
|
+
const entries = [];
|
|
134
|
+
|
|
135
|
+
if (parts.numberingXml) {
|
|
136
|
+
overrides.push(' <Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>\n');
|
|
137
|
+
documentRels.push(' <Relationship Id="rIdNum1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/>');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
entries.push({ name: '[Content_Types].xml', data: CONTENT_TYPES_BASE.replace('%OVERRIDES%', overrides.join('')) });
|
|
141
|
+
entries.push({ name: '_rels/.rels', data: ROOT_RELS });
|
|
142
|
+
entries.push({
|
|
143
|
+
name: 'word/_rels/document.xml.rels',
|
|
144
|
+
data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
145
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
146
|
+
${documentRels.join('\n')}
|
|
147
|
+
</Relationships>`
|
|
148
|
+
});
|
|
149
|
+
entries.push({ name: 'word/document.xml', data: documentXml });
|
|
150
|
+
if (parts.numberingXml) {
|
|
151
|
+
entries.push({ name: 'word/numbering.xml', data: parts.numberingXml });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return buildZip(entries);
|
|
155
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Validates generated validation fixtures against the ECMA-376 transitional
|
|
3
|
+
# wordprocessingml XSD using xmllint. Used by the nightly validation workflow
|
|
4
|
+
# and runnable locally on any machine with curl, unzip, and xmllint.
|
|
5
|
+
#
|
|
6
|
+
# Usage: scripts/validate-fixtures-xsd.sh [fixtures-dir]
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
FIXTURES_DIR="${1:-tmp/validation-docx}"
|
|
10
|
+
CACHE_DIR="${OOXML_SCHEMA_DIR:-.cache/ooxml-schemas}"
|
|
11
|
+
ECMA_ZIP_URL="https://ecma-international.org/wp-content/uploads/ECMA-376-4_5th_edition_december_2016.zip"
|
|
12
|
+
XML_XSD_URL="https://www.w3.org/2001/xml.xsd"
|
|
13
|
+
|
|
14
|
+
command -v xmllint >/dev/null || { echo "xmllint not found (install libxml2-utils)"; exit 1; }
|
|
15
|
+
|
|
16
|
+
shopt -s nullglob
|
|
17
|
+
fixtures=("$FIXTURES_DIR"/*.document.xml)
|
|
18
|
+
if [ ${#fixtures[@]} -eq 0 ]; then
|
|
19
|
+
echo "No *.document.xml fixtures in $FIXTURES_DIR — run: node scripts/export-validation-fixtures.mjs"
|
|
20
|
+
exit 1
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
if [ ! -f "$CACHE_DIR/wml.xsd" ]; then
|
|
24
|
+
echo "Downloading ECMA-376 Part 4 transitional schemas..."
|
|
25
|
+
mkdir -p "$CACHE_DIR"
|
|
26
|
+
curl -sSL --retry 3 -o "$CACHE_DIR/ecma376-4.zip" "$ECMA_ZIP_URL"
|
|
27
|
+
unzip -o -q "$CACHE_DIR/ecma376-4.zip" "OfficeOpenXML-XMLSchema-Transitional.zip" -d "$CACHE_DIR"
|
|
28
|
+
unzip -o -q "$CACHE_DIR/OfficeOpenXML-XMLSchema-Transitional.zip" -d "$CACHE_DIR"
|
|
29
|
+
curl -sSL --retry 3 -o "$CACHE_DIR/xml.xsd" "$XML_XSD_URL"
|
|
30
|
+
# ECMA's published XSDs import the xml namespace without a schemaLocation;
|
|
31
|
+
# point them at the local copy so xmllint can compile offline.
|
|
32
|
+
sed -i.bak 's|<xsd:import namespace="http://www.w3.org/XML/1998/namespace"/>|<xsd:import namespace="http://www.w3.org/XML/1998/namespace" schemaLocation="xml.xsd"/>|' "$CACHE_DIR"/*.xsd
|
|
33
|
+
rm -f "$CACHE_DIR"/*.xsd.bak "$CACHE_DIR/ecma376-4.zip"
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
xmllint --noout --schema "$CACHE_DIR/wml.xsd" "${fixtures[@]}"
|
|
37
|
+
echo "XSD validation passed for ${#fixtures[@]} fixture(s)."
|