@ansonlai/docx-redline-js 0.2.1 → 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.
Files changed (86) hide show
  1. package/AGENTS.md +36 -10
  2. package/README.md +83 -6
  3. package/adapters/xml-adapter.js +73 -10
  4. package/core/list-targeting.js +3 -0
  5. package/core/paragraph-targeting.js +33 -7
  6. package/core/redline-validation.js +22 -0
  7. package/core/types.js +122 -27
  8. package/core/xml-query.js +3 -1
  9. package/dist/docx-redline-js.esm.js +1124 -545
  10. package/dist/docx-redline-js.esm.js.map +4 -4
  11. package/dist/docx-redline-js.esm.min.js +79 -78
  12. package/dist/docx-redline-js.esm.min.js.map +4 -4
  13. package/docs/TESTING.md +687 -0
  14. package/docs/VALIDATION.md +81 -2
  15. package/docs/WORD-MANUAL-REVIEW.md +138 -0
  16. package/docs/plans/2026-08-30-reliability-testing-improvements.md +488 -0
  17. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +210 -0
  18. package/docs/plans/{2026-03-01-release-0.1.4-design.md → completed/2026-03-01-release-0.1.4-design.md} +2 -0
  19. package/docs/plans/{2026-03-01-release-0.1.4.md → completed/2026-03-01-release-0.1.4.md} +5 -3
  20. package/docs/plans/{2026-05-31-architectural changes.md → completed/2026-05-31-architectural changes.md } +2 -0
  21. package/docs/plans/completed/2026-08-02-reliability-improvements.md +1155 -0
  22. package/docs/test-comparison-dashboard.html +95 -0
  23. package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +22 -0
  24. package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +24 -0
  25. package/docs/validation-reports/2026-08-30-phase-3-coverage.md +73 -0
  26. package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +82 -0
  27. package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +114 -0
  28. package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +79 -0
  29. package/engine/format-extraction.js +1 -1
  30. package/engine/formatting-removal.js +84 -99
  31. package/engine/oxml-engine.js +176 -83
  32. package/engine/reconstruction-mapper.js +276 -79
  33. package/engine/reconstruction-mode.js +20 -6
  34. package/engine/reconstruction-writer.js +117 -72
  35. package/engine/run-builders.js +3 -3
  36. package/engine/surgical-mode.js +3 -2
  37. package/engine/table-mode.js +27 -16
  38. package/index.d.ts +95 -3
  39. package/index.js +14 -13
  40. package/orchestration/list-structural-fallback.js +16 -39
  41. package/package.json +22 -4
  42. package/pipeline/diff-engine.js +174 -55
  43. package/pipeline/ingestion-export.js +39 -24
  44. package/pipeline/ingestion-paragraph.js +7 -5
  45. package/pipeline/list-generation.js +27 -18
  46. package/pipeline/patching.js +2 -3
  47. package/pipeline/pipeline.js +65 -36
  48. package/pipeline/serialization.js +13 -5
  49. package/scripts/build-test-dashboard.mjs +43 -0
  50. package/scripts/check-types.mjs +16 -24
  51. package/scripts/export-validation-fixtures.mjs +191 -45
  52. package/scripts/fetch-superdoc-corpus.mjs +61 -0
  53. package/scripts/generate-test-dashboard.mjs +199 -0
  54. package/scripts/inspect-visual-evidence.mjs +271 -0
  55. package/scripts/lib/minimal-zip.mjs +199 -18
  56. package/scripts/lib/word-coverage-catalogue.mjs +207 -0
  57. package/scripts/lib/word-coverage-metadata.mjs +93 -0
  58. package/scripts/lib/zip-reader.mjs +64 -0
  59. package/scripts/package-superdoc-word-fixtures.ps1 +64 -0
  60. package/scripts/prepare-corpus-word-visual-review.mjs +84 -0
  61. package/scripts/prepare-superdoc-word-corpus.mjs +284 -0
  62. package/scripts/prepare-word-review.mjs +77 -0
  63. package/scripts/prepare-word-visual-review.mjs +90 -0
  64. package/scripts/render-agenda-multilevel.mjs +70 -0
  65. package/scripts/render-case22.mjs +73 -0
  66. package/scripts/render-case40.ps1 +35 -0
  67. package/scripts/render-multilevel-bullet-images.py +58 -0
  68. package/scripts/render-multilevel-bullet-visual.ps1 +32 -0
  69. package/scripts/render-multilevel-cases.mjs +80 -0
  70. package/scripts/report-coverage-gaps.mjs +103 -0
  71. package/scripts/report-word-coverage.mjs +71 -0
  72. package/scripts/sample-multimodal-visual-check.mjs +221 -0
  73. package/scripts/test-multilevel-bullet-visual.mjs +187 -0
  74. package/scripts/word-com-corpus-suite.ps1 +43 -0
  75. package/scripts/word-com-corpus-visual-suite.ps1 +116 -0
  76. package/scripts/word-com-differential.ps1 +158 -16
  77. package/scripts/word-com-suite.ps1 +19 -0
  78. package/scripts/word-com-visual-suite.ps1 +132 -0
  79. package/services/comment-engine.js +51 -46
  80. package/services/comment-locator.js +0 -1
  81. package/services/comment-package.js +11 -10
  82. package/services/numbering-service.js +1 -1
  83. package/services/revision-comment-management.js +31 -10
  84. package/services/standalone-docx-plumbing.js +45 -34
  85. package/services/standalone-operation-runner.js +315 -75
  86. package/services/table-reconciliation.js +23 -11
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { deflateRawSync } from 'zlib';
11
+ import { DOMParser } from '@xmldom/xmldom';
11
12
 
12
13
  const CRC_TABLE = (() => {
13
14
  const table = new Uint32Array(256);
@@ -120,36 +121,216 @@ const ROOT_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
120
121
  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
121
122
  </Relationships>`;
122
123
 
124
+ const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
125
+ const NS_R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
126
+ const REL_BASE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/';
127
+ const CONTENT_TYPES = {
128
+ numbering: 'application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml',
129
+ comments: 'application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml',
130
+ footnotes: 'application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml',
131
+ endnotes: 'application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml',
132
+ header: 'application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml',
133
+ footer: 'application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml'
134
+ };
135
+
136
+ function escapeXmlAttribute(value) {
137
+ return String(value)
138
+ .replace(/&/g, '&amp;')
139
+ .replace(/"/g, '&quot;')
140
+ .replace(/</g, '&lt;')
141
+ .replace(/>/g, '&gt;');
142
+ }
143
+
144
+ function parsePartXml(xml, label) {
145
+ const errors = [];
146
+ const document = new DOMParser({
147
+ onError: (_level, message) => errors.push(message)
148
+ }).parseFromString(String(xml), 'application/xml');
149
+ if (!document?.documentElement || errors.length > 0 || document.getElementsByTagName('parsererror').length > 0) {
150
+ throw new Error(`${label} is not well-formed XML${errors[0] ? `: ${errors[0]}` : ''}`);
151
+ }
152
+ return document;
153
+ }
154
+
155
+ function wordIds(document, localName) {
156
+ return Array.from(document.getElementsByTagNameNS(NS_W, localName), node =>
157
+ node.getAttributeNS?.(NS_W, 'id') || node.getAttribute('w:id') || node.getAttribute('id')
158
+ );
159
+ }
160
+
161
+ function relationshipIds(document, localName) {
162
+ return Array.from(document.getElementsByTagNameNS(NS_W, localName), node =>
163
+ node.getAttributeNS?.(NS_R, 'id') || node.getAttribute('r:id') || node.getAttribute('id')
164
+ ).filter(Boolean);
165
+ }
166
+
167
+ function requireRoot(document, localName, label) {
168
+ if (document.documentElement.namespaceURI !== NS_W || document.documentElement.localName !== localName) {
169
+ throw new Error(`${label} must have w:${localName} as its document element`);
170
+ }
171
+ }
172
+
173
+ function validateReferencedIds(documentXml, parts) {
174
+ const document = parsePartXml(documentXml, 'word/document.xml');
175
+ requireRoot(document, 'document', 'word/document.xml');
176
+
177
+ const documentCommentIds = [
178
+ ...wordIds(document, 'commentRangeStart'),
179
+ ...wordIds(document, 'commentRangeEnd'),
180
+ ...wordIds(document, 'commentReference')
181
+ ];
182
+ if (documentCommentIds.length > 0 && !parts.commentsXml) {
183
+ throw new Error('document comment anchors require word/comments.xml');
184
+ }
185
+ if (parts.commentsXml) {
186
+ const comments = parsePartXml(parts.commentsXml, 'word/comments.xml');
187
+ requireRoot(comments, 'comments', 'word/comments.xml');
188
+ const defined = new Set(wordIds(comments, 'comment'));
189
+ const starts = wordIds(document, 'commentRangeStart');
190
+ const ends = wordIds(document, 'commentRangeEnd');
191
+ const references = wordIds(document, 'commentReference');
192
+ for (const id of new Set([...starts, ...ends, ...references])) {
193
+ if (!defined.has(id)) throw new Error(`word/comments.xml does not define referenced comment ID ${id}`);
194
+ }
195
+ if (starts.length === 0 || JSON.stringify(starts) !== JSON.stringify(ends) || JSON.stringify(starts) !== JSON.stringify(references)) {
196
+ throw new Error('comment start/end/reference IDs must be present and ordered identically');
197
+ }
198
+ }
199
+
200
+ for (const family of ['footnote', 'endnote']) {
201
+ const partKey = `${family}sXml`;
202
+ const referenceIds = wordIds(document, `${family}Reference`);
203
+ if (referenceIds.length > 0 && !parts[partKey]) {
204
+ throw new Error(`document ${family} references require word/${family}s.xml`);
205
+ }
206
+ if (!parts[partKey]) continue;
207
+ const partName = `word/${family}s.xml`;
208
+ const notes = parsePartXml(parts[partKey], partName);
209
+ requireRoot(notes, `${family}s`, partName);
210
+ const defined = new Set(wordIds(notes, family));
211
+ if (!defined.has('-1') || !defined.has('0')) {
212
+ throw new Error(`${partName} must define separator ID -1 and continuation separator ID 0`);
213
+ }
214
+ for (const id of referenceIds) {
215
+ if (!defined.has(id)) throw new Error(`${partName} does not define referenced ${family} ID ${id}`);
216
+ }
217
+ }
218
+
219
+ const headerIds = new Set((parts.headers || []).map(header => header.relationshipId));
220
+ const footerIds = new Set((parts.footers || []).map(footer => footer.relationshipId));
221
+ for (const id of relationshipIds(document, 'headerReference')) {
222
+ if (!headerIds.has(id)) throw new Error(`document headerReference ${id} has no configured header relationship`);
223
+ }
224
+ for (const id of relationshipIds(document, 'footerReference')) {
225
+ if (!footerIds.has(id)) throw new Error(`document footerReference ${id} has no configured footer relationship`);
226
+ }
227
+
228
+ const hyperlinkIds = new Set((parts.externalHyperlinks || []).map(link => link.relationshipId));
229
+ for (const id of relationshipIds(document, 'hyperlink')) {
230
+ if (!hyperlinkIds.has(id)) throw new Error(`external hyperlink ${id} has no configured relationship`);
231
+ }
232
+ }
233
+
234
+ function normalizeRelatedParts(parts) {
235
+ const normalized = {
236
+ ...parts,
237
+ headers: (parts.headers || []).map((header, index) => ({
238
+ partName: header.partName || `header${index + 1}.xml`,
239
+ relationshipId: header.relationshipId || `rIdHeader${index + 1}`,
240
+ xml: header.xml
241
+ })),
242
+ footers: (parts.footers || []).map((footer, index) => ({
243
+ partName: footer.partName || `footer${index + 1}.xml`,
244
+ relationshipId: footer.relationshipId || `rIdFooter${index + 1}`,
245
+ xml: footer.xml
246
+ })),
247
+ externalHyperlinks: parts.externalHyperlinks || []
248
+ };
249
+
250
+ const relationshipIds = [];
251
+ if (normalized.numberingXml) relationshipIds.push('rIdNum1');
252
+ if (normalized.commentsXml) relationshipIds.push('rIdComments1');
253
+ if (normalized.footnotesXml) relationshipIds.push('rIdFootnotes1');
254
+ if (normalized.endnotesXml) relationshipIds.push('rIdEndnotes1');
255
+ relationshipIds.push(...normalized.headers.map(item => item.relationshipId));
256
+ relationshipIds.push(...normalized.footers.map(item => item.relationshipId));
257
+ relationshipIds.push(...normalized.externalHyperlinks.map(item => item.relationshipId));
258
+ if (relationshipIds.some(id => !/^rId[A-Za-z0-9._-]+$/.test(String(id)))) {
259
+ throw new Error('every relationship ID must use a non-empty rId-prefixed token');
260
+ }
261
+ if (new Set(relationshipIds).size !== relationshipIds.length) throw new Error('document relationship IDs must be unique');
262
+
263
+ for (const [family, items, pattern] of [
264
+ ['header', normalized.headers, /^header[1-9][0-9]*\.xml$/],
265
+ ['footer', normalized.footers, /^footer[1-9][0-9]*\.xml$/]
266
+ ]) {
267
+ for (const item of items) {
268
+ if (!pattern.test(item.partName)) throw new Error(`${family} part name must match ${family}<number>.xml`);
269
+ if (typeof item.xml !== 'string' || item.xml.length === 0) throw new Error(`${family} ${item.partName} requires XML content`);
270
+ const xml = parsePartXml(item.xml, `word/${item.partName}`);
271
+ requireRoot(xml, family === 'header' ? 'hdr' : 'ftr', `word/${item.partName}`);
272
+ }
273
+ }
274
+ const partNames = [...normalized.headers, ...normalized.footers].map(item => item.partName);
275
+ if (new Set(partNames).size !== partNames.length) throw new Error('header/footer part names must be unique');
276
+
277
+ for (const link of normalized.externalHyperlinks) {
278
+ if (!link.relationshipId || !link.target) throw new Error('external hyperlinks require relationshipId and target');
279
+ let url;
280
+ try { url = new URL(link.target); } catch { throw new Error(`invalid external hyperlink target: ${link.target}`); }
281
+ if (!url.protocol || url.protocol === 'file:') throw new Error(`unsupported external hyperlink target: ${link.target}`);
282
+ }
283
+ return normalized;
284
+ }
285
+
123
286
  /**
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
287
+ * Creates and validates the deterministic entry set used by buildMinimalDocx.
288
+ * Exported for package-integrity tests; this remains script-only tooling.
129
289
  */
130
- export function buildMinimalDocx(documentXml, parts = {}) {
290
+ export function buildMinimalDocxEntries(documentXml, parts = {}) {
291
+ const normalized = normalizeRelatedParts(parts);
292
+ validateReferencedIds(documentXml, normalized);
131
293
  const overrides = [];
132
294
  const documentRels = [];
133
295
  const entries = [];
134
296
 
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
- }
297
+ const addXmlPart = (partName, contentType, relationshipId, relationshipType, xml) => {
298
+ overrides.push(` <Override PartName="/word/${partName}" ContentType="${contentType}"/>\n`);
299
+ documentRels.push(` <Relationship Id="${escapeXmlAttribute(relationshipId)}" Type="${REL_BASE}${relationshipType}" Target="${escapeXmlAttribute(partName)}"/>`);
300
+ entries.push({ name: `word/${partName}`, data: xml });
301
+ };
139
302
 
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({
303
+ if (normalized.numberingXml) addXmlPart('numbering.xml', CONTENT_TYPES.numbering, 'rIdNum1', 'numbering', normalized.numberingXml);
304
+ if (normalized.commentsXml) addXmlPart('comments.xml', CONTENT_TYPES.comments, 'rIdComments1', 'comments', normalized.commentsXml);
305
+ if (normalized.footnotesXml) addXmlPart('footnotes.xml', CONTENT_TYPES.footnotes, 'rIdFootnotes1', 'footnotes', normalized.footnotesXml);
306
+ if (normalized.endnotesXml) addXmlPart('endnotes.xml', CONTENT_TYPES.endnotes, 'rIdEndnotes1', 'endnotes', normalized.endnotesXml);
307
+ normalized.headers.forEach(item => addXmlPart(item.partName, CONTENT_TYPES.header, item.relationshipId, 'header', item.xml));
308
+ normalized.footers.forEach(item => addXmlPart(item.partName, CONTENT_TYPES.footer, item.relationshipId, 'footer', item.xml));
309
+ normalized.externalHyperlinks.forEach(link => {
310
+ documentRels.push(` <Relationship Id="${escapeXmlAttribute(link.relationshipId)}" Type="${REL_BASE}hyperlink" Target="${escapeXmlAttribute(link.target)}" TargetMode="External"/>`);
311
+ });
312
+
313
+ entries.unshift({ name: 'word/document.xml', data: documentXml });
314
+ entries.unshift({
143
315
  name: 'word/_rels/document.xml.rels',
144
316
  data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
145
317
  <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
146
318
  ${documentRels.join('\n')}
147
319
  </Relationships>`
148
320
  });
149
- entries.push({ name: 'word/document.xml', data: documentXml });
150
- if (parts.numberingXml) {
151
- entries.push({ name: 'word/numbering.xml', data: parts.numberingXml });
152
- }
321
+ entries.unshift({ name: '_rels/.rels', data: ROOT_RELS });
322
+ entries.unshift({ name: '[Content_Types].xml', data: CONTENT_TYPES_BASE.replace('%OVERRIDES%', overrides.join('')) });
323
+ return entries;
324
+ }
153
325
 
154
- return buildZip(entries);
326
+ /**
327
+ * Assembles a minimal .docx package around a word/document.xml payload.
328
+ *
329
+ * @param {string} documentXml - Complete word/document.xml content
330
+ * @param {Object} [parts] - Optional numbering/comments/notes/header/footer/
331
+ * external-hyperlink package parts. This helper is development-only.
332
+ * @returns {Buffer} - .docx bytes
333
+ */
334
+ export function buildMinimalDocx(documentXml, parts = {}) {
335
+ return buildZip(buildMinimalDocxEntries(documentXml, parts));
155
336
  }
@@ -0,0 +1,207 @@
1
+ import { readFileSync } from 'fs';
2
+
3
+ import { WORD_TASK_CASES } from '../../tests/fixtures/word-task-cases.mjs';
4
+ import {
5
+ COVERAGE_ORACLES,
6
+ COVERAGE_STRUCTURES,
7
+ COVERAGE_TASKS,
8
+ sortCoverageMetadata,
9
+ validateCoverageMetadata
10
+ } from './word-coverage-metadata.mjs';
11
+
12
+ const corpusCatalogue = JSON.parse(readFileSync(
13
+ new URL('../../tests/corpus/superdoc-word-scenarios.json', import.meta.url),
14
+ 'utf8'
15
+ ));
16
+ const corpusManifest = JSON.parse(readFileSync(
17
+ new URL('../../tests/corpus/superdoc-english-legal-administrative.json', import.meta.url),
18
+ 'utf8'
19
+ ));
20
+ const corpusCoverage = JSON.parse(readFileSync(
21
+ new URL('../../tests/corpus/superdoc-word-coverage.json', import.meta.url),
22
+ 'utf8'
23
+ ));
24
+ const priorities = JSON.parse(readFileSync(
25
+ new URL('../../tests/fixtures/coverage-matrix-priorities.json', import.meta.url),
26
+ 'utf8'
27
+ ));
28
+
29
+ const manifestById = new Map(corpusManifest.documents.map(document => [document.id, document]));
30
+
31
+ export function validateScenarioIdentities(cases) {
32
+ const identities = new Set();
33
+ for (const item of cases) {
34
+ if (!item.identity || identities.has(item.identity)) {
35
+ throw new Error(`Duplicate or missing scenario identity: ${item.identity || '<missing>'}`);
36
+ }
37
+ identities.add(item.identity);
38
+ }
39
+ }
40
+
41
+ function elementCount(xml, localName) {
42
+ return (xml.match(new RegExp(`<w:${localName}(?:\\s|/|>)`, 'g')) || []).length;
43
+ }
44
+
45
+ function assertSyntheticClaim(testCase, structure) {
46
+ const xml = testCase.sourceDocumentXml || '';
47
+ const rules = {
48
+ 'plain-paragraph': () => !testCase.sourceText?.includes('\n') && !testCase.original.includes('\n'),
49
+ 'multi-paragraph': () => elementCount(xml, 'p') > 1 || testCase.sourceText?.includes('\n')
50
+ || testCase.original.includes('\n') || testCase.modified.includes('\n'),
51
+ 'formatted-runs': () => /<(?:w:)?(?:b|i|u)(?:\s|\/|>)/.test(xml) || /(?:\*\*|\*|\+\+)/.test(testCase.modified),
52
+ list: () => elementCount(xml, 'numPr') > 0,
53
+ table: () => elementCount(xml, 'tbl') > 0,
54
+ bookmark: () => elementCount(xml, 'bookmarkStart') > 0 && elementCount(xml, 'bookmarkEnd') > 0,
55
+ hyperlink: () => elementCount(xml, 'hyperlink') > 0,
56
+ 'content-control': () => elementCount(xml, 'sdt') > 0,
57
+ 'tab-break': () => elementCount(xml, 'tab') > 0 || elementCount(xml, 'br') > 0,
58
+ field: () => elementCount(xml, 'fldChar') > 0,
59
+ comment: () => elementCount(xml, 'commentRangeStart') > 0 && Boolean(testCase.packageParts?.commentsXml),
60
+ note: () => (elementCount(xml, 'footnoteReference') > 0 && Boolean(testCase.packageParts?.footnotesXml))
61
+ || (elementCount(xml, 'endnoteReference') > 0 && Boolean(testCase.packageParts?.endnotesXml)),
62
+ 'header-footer': () => (elementCount(xml, 'headerReference') > 0 && Boolean(testCase.packageParts?.headers))
63
+ || (elementCount(xml, 'footerReference') > 0 && Boolean(testCase.packageParts?.footers)),
64
+ 'section-boundary': () => elementCount(xml, 'sectPr') > 0,
65
+ 'prior-revisions': () => elementCount(xml, 'ins') > 0 || elementCount(xml, 'del') > 0
66
+ };
67
+ if (!rules[structure]()) {
68
+ throw new Error(`synthetic:${testCase.name}: ${structure} is not supported by the fixture`);
69
+ }
70
+ }
71
+
72
+ function syntheticTask(testCase) {
73
+ if (testCase.operation?.type === 'list-change') return 'list-change';
74
+ if (testCase.operation?.type === 'table-reconciliation') return 'table-reconciliation';
75
+ if (testCase.expectAtomicRollback) return 'mixed-batch';
76
+ if (testCase.expectNoOp) return 'accept-reject';
77
+ if (testCase.task.startsWith('apply-')) return 'format';
78
+ if (testCase.task.includes('deletion') || testCase.task.startsWith('delete-')) return 'delete';
79
+ if (testCase.task.includes('insertion') || testCase.task.startsWith('insert-')
80
+ || testCase.task === 'preserve-dollar-delimiters') return 'insert';
81
+ return 'replace';
82
+ }
83
+
84
+ function corpusStructures(scenario) {
85
+ const labels = new Set([scenario.shape, ...scenario.coverage]);
86
+ const structures = new Set();
87
+ if (scenario.shape === 'list') structures.add('list');
88
+ if (scenario.shape === 'table-form') structures.add('table');
89
+ if (scenario.shape === 'body-paragraph' || scenario.shape === 'administrative-layout' || scenario.shape === 'legal-apparatus') {
90
+ structures.add('plain-paragraph');
91
+ }
92
+ const mappings = [
93
+ [['table', 'tables', 'table-cell'], 'table'],
94
+ [['numbering', 'numbered-list'], 'list'],
95
+ [['bookmark', 'bookmarks', 'bookmark-adjacency'], 'bookmark'],
96
+ [['header', 'headers', 'footer', 'footers'], 'header-footer'],
97
+ [['footnotes-part'], 'note'],
98
+ [['tabs'], 'tab-break'],
99
+ [['field-adjacency'], 'field'],
100
+ [['multi-section', 'section-properties'], 'section-boundary'],
101
+ [['format-only', 'formatting', 'formatted-runs'], 'formatted-runs']
102
+ ];
103
+ for (const [sourceLabels, structure] of mappings) {
104
+ if (sourceLabels.some(label => labels.has(label))) structures.add(structure);
105
+ }
106
+ return [...structures];
107
+ }
108
+
109
+ function corpusScenarioKey(scenario) {
110
+ return scenario.key || scenario.id;
111
+ }
112
+
113
+ function corpusSourceId(scenario) {
114
+ return scenario.sourceId || scenario.id;
115
+ }
116
+
117
+ function corpusOperations(scenario) {
118
+ return scenario.operations || [scenario.operation];
119
+ }
120
+
121
+ export function loadCoverageCatalogue() {
122
+ const synthetic = WORD_TASK_CASES.map(testCase => {
123
+ const identity = `synthetic:${testCase.name}`;
124
+ const metadata = sortCoverageMetadata(validateCoverageMetadata(testCase.coverageMetadata, identity));
125
+ if (metadata.task !== syntheticTask(testCase)) {
126
+ throw new Error(`${identity}: task ${metadata.task} is unsupported by fixture task ${testCase.task}`);
127
+ }
128
+ for (const structure of metadata.structures) assertSyntheticClaim(testCase, structure);
129
+ return { identity, lane: 'synthetic', category: testCase.category, detail: testCase.task, metadata };
130
+ });
131
+
132
+ const corpus = corpusCatalogue.scenarios.map(scenario => {
133
+ const scenarioKey = corpusScenarioKey(scenario);
134
+ const sourceId = corpusSourceId(scenario);
135
+ const identity = `superdoc:${scenarioKey}`;
136
+ const source = manifestById.get(sourceId);
137
+ if (!source) throw new Error(`${identity}: source is absent from the pinned corpus manifest`);
138
+ const operations = corpusOperations(scenario);
139
+ const supportedOperationTypes = new Set(['replace', 'format', 'list-change', 'table-reconciliation', 'insert', 'delete']);
140
+ for (const operation of operations) {
141
+ if (!supportedOperationTypes.has(operation?.type)) {
142
+ throw new Error(`${identity}: unsupported corpus operation type ${operation?.type}`);
143
+ }
144
+ }
145
+ const declaredStructures = corpusCoverage.structuresByScenario[scenarioKey];
146
+ const supportedStructures = corpusStructures(scenario).sort();
147
+ if (!declaredStructures || JSON.stringify([...declaredStructures].sort()) !== JSON.stringify(supportedStructures)) {
148
+ throw new Error(`${identity}: declared structures do not match reviewed fixture labels`);
149
+ }
150
+ const declaredTask = corpusCoverage.tasksByScenario?.[scenarioKey]
151
+ || (operations.length > 1 ? 'mixed-batch' : (operations[0]?.type || 'replace'));
152
+ const metadata = sortCoverageMetadata(validateCoverageMetadata({
153
+ ...corpusCoverage.defaults,
154
+ task: declaredTask,
155
+ structures: declaredStructures
156
+ }, identity));
157
+ const expectedTask = operations.length > 1 ? 'mixed-batch' : (operations[0]?.type || 'replace');
158
+ if (metadata.task !== expectedTask) {
159
+ throw new Error(`${identity}: task ${metadata.task} does not match operation type ${expectedTask}`);
160
+ }
161
+ return { identity, lane: 'superdoc', category: source.type, detail: scenario.shape, metadata };
162
+ });
163
+ const cases = [...synthetic, ...corpus];
164
+ if (Object.keys(corpusCoverage.structuresByScenario).length !== corpus.length) {
165
+ throw new Error('SuperDoc coverage metadata contains a missing or unknown scenario identity');
166
+ }
167
+ validateScenarioIdentities(cases);
168
+ return { cases, priorities };
169
+ }
170
+
171
+ export function buildCoverageMatrix(cases) {
172
+ const cells = new Map();
173
+ for (const task of COVERAGE_TASKS) {
174
+ for (const structure of COVERAGE_STRUCTURES) cells.set(`${task}/${structure}`, []);
175
+ }
176
+ for (const testCase of cases) {
177
+ for (const structure of testCase.metadata.structures) {
178
+ cells.get(`${testCase.metadata.task}/${structure}`).push(testCase.identity);
179
+ }
180
+ }
181
+ return cells;
182
+ }
183
+
184
+ export function validateCoveragePriorities(cases, priorityConfig = priorities) {
185
+ const cells = buildCoverageMatrix(cases);
186
+ const dispositions = new Map(priorityConfig.emptyCellDispositions.map(item => [`${item.task}/${item.structure}`, item]));
187
+ for (const item of [...priorityConfig.highPriorityCells, ...priorityConfig.emptyCellDispositions]) {
188
+ if (!COVERAGE_TASKS.includes(item.task) || !COVERAGE_STRUCTURES.includes(item.structure)) {
189
+ throw new Error(`Unknown priority cell: ${item.task}/${item.structure}`);
190
+ }
191
+ }
192
+ for (const cell of priorityConfig.highPriorityCells) {
193
+ const key = `${cell.task}/${cell.structure}`;
194
+ if (cells.get(key).length === 0 && !dispositions.has(key)) {
195
+ throw new Error(`High-priority empty cell lacks a disposition: ${key}`);
196
+ }
197
+ }
198
+ for (const [key, disposition] of dispositions) {
199
+ if (!['planned', 'excluded'].includes(disposition.status) || !disposition.reason || !disposition.dependency) {
200
+ throw new Error(`${key}: disposition requires status, reason, and dependency`);
201
+ }
202
+ if (cells.get(key).length > 0) throw new Error(`${key}: obsolete empty-cell disposition is still present`);
203
+ }
204
+ return cells;
205
+ }
206
+
207
+ export { COVERAGE_ORACLES, COVERAGE_STRUCTURES, COVERAGE_TASKS };
@@ -0,0 +1,93 @@
1
+ export const COVERAGE_TASKS = Object.freeze([
2
+ 'replace',
3
+ 'insert',
4
+ 'delete',
5
+ 'format',
6
+ 'comment',
7
+ 'accept-reject',
8
+ 'list-change',
9
+ 'table-reconciliation',
10
+ 'mixed-batch'
11
+ ]);
12
+
13
+ export const COVERAGE_STRUCTURES = Object.freeze([
14
+ 'plain-paragraph',
15
+ 'multi-paragraph',
16
+ 'formatted-runs',
17
+ 'list',
18
+ 'table',
19
+ 'bookmark',
20
+ 'hyperlink',
21
+ 'content-control',
22
+ 'tab-break',
23
+ 'field',
24
+ 'comment',
25
+ 'note',
26
+ 'header-footer',
27
+ 'section-boundary',
28
+ 'prior-revisions'
29
+ ]);
30
+
31
+ export const COVERAGE_ORACLES = Object.freeze([
32
+ 'js-exact-round-trip',
33
+ 'runtime-validator',
34
+ 'xsd',
35
+ 'libreoffice',
36
+ 'synthetic-word',
37
+ 'real-document-word',
38
+ 'ai-word-visual-preflight',
39
+ 'human-word-visual-review'
40
+ ]);
41
+
42
+ export const MANUAL_REVIEW_STATES = Object.freeze([
43
+ 'missing',
44
+ 'current',
45
+ 'stale'
46
+ ]);
47
+
48
+ const vocabularySets = {
49
+ task: new Set(COVERAGE_TASKS),
50
+ structures: new Set(COVERAGE_STRUCTURES),
51
+ oracles: new Set(COVERAGE_ORACLES)
52
+ };
53
+
54
+ export function validateCoverageMetadata(metadata, identity) {
55
+ if (!metadata || typeof metadata !== 'object') {
56
+ throw new Error(`${identity}: coverage metadata is missing`);
57
+ }
58
+ if (!vocabularySets.task.has(metadata.task)) {
59
+ throw new Error(`${identity}: unknown coverage task ${JSON.stringify(metadata.task)}`);
60
+ }
61
+ for (const field of ['structures', 'oracles']) {
62
+ if (!Array.isArray(metadata[field]) || metadata[field].length === 0) {
63
+ throw new Error(`${identity}: ${field} must be a non-empty array`);
64
+ }
65
+ if (new Set(metadata[field]).size !== metadata[field].length) {
66
+ throw new Error(`${identity}: ${field} contains duplicates`);
67
+ }
68
+ for (const label of metadata[field]) {
69
+ if (!vocabularySets[field].has(label)) {
70
+ throw new Error(`${identity}: unknown ${field} label ${JSON.stringify(label)}`);
71
+ }
72
+ }
73
+ }
74
+ if (!MANUAL_REVIEW_STATES.includes(metadata.manualReview?.status)) {
75
+ throw new Error(`${identity}: manualReview.status must be missing, current, or stale`);
76
+ }
77
+ if (metadata.manualReview.status !== 'missing' && !metadata.manualReview.reviewedAt) {
78
+ throw new Error(`${identity}: reviewedAt is required for a ${metadata.manualReview.status} review`);
79
+ }
80
+ return metadata;
81
+ }
82
+
83
+ export function sortCoverageMetadata(metadata) {
84
+ return {
85
+ ...metadata,
86
+ structures: [...metadata.structures].sort(
87
+ (a, b) => COVERAGE_STRUCTURES.indexOf(a) - COVERAGE_STRUCTURES.indexOf(b)
88
+ ),
89
+ oracles: [...metadata.oracles].sort(
90
+ (a, b) => COVERAGE_ORACLES.indexOf(a) - COVERAGE_ORACLES.indexOf(b)
91
+ )
92
+ };
93
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Small read-only ZIP helper for validation tooling.
3
+ *
4
+ * This intentionally stays outside the runtime package path. It supports the
5
+ * stored and deflated entries used by DOCX packages without adding a ZIP
6
+ * dependency to the library.
7
+ */
8
+
9
+ import { inflateRawSync } from 'zlib';
10
+
11
+ const EOCD_SIGNATURE = 0x06054b50;
12
+ const CENTRAL_DIR_SIGNATURE = 0x02014b50;
13
+ const LOCAL_FILE_SIGNATURE = 0x04034b50;
14
+
15
+ function findEndOfCentralDirectory(zipBuffer) {
16
+ const minOffset = Math.max(0, zipBuffer.length - 0xffff - 22);
17
+ for (let offset = zipBuffer.length - 22; offset >= minOffset; offset -= 1) {
18
+ if (zipBuffer.readUInt32LE(offset) === EOCD_SIGNATURE) return offset;
19
+ }
20
+ throw new Error('Invalid zip: End of central directory not found');
21
+ }
22
+
23
+ function readEntryData(zipBuffer, localHeaderOffset, compressedSize, compressionMethod) {
24
+ if (zipBuffer.readUInt32LE(localHeaderOffset) !== LOCAL_FILE_SIGNATURE) {
25
+ throw new Error('Invalid zip: local file header signature mismatch');
26
+ }
27
+
28
+ const fileNameLength = zipBuffer.readUInt16LE(localHeaderOffset + 26);
29
+ const extraLength = zipBuffer.readUInt16LE(localHeaderOffset + 28);
30
+ const dataStart = localHeaderOffset + 30 + fileNameLength + extraLength;
31
+ const compressed = zipBuffer.subarray(dataStart, dataStart + compressedSize);
32
+
33
+ if (compressionMethod === 0) return Buffer.from(compressed);
34
+ if (compressionMethod === 8) return inflateRawSync(compressed);
35
+ throw new Error(`Unsupported compression method: ${compressionMethod}`);
36
+ }
37
+
38
+ /** @returns {Map<string, Buffer>} */
39
+ export function unzipEntries(zipBuffer) {
40
+ const eocdOffset = findEndOfCentralDirectory(zipBuffer);
41
+ const totalEntries = zipBuffer.readUInt16LE(eocdOffset + 10);
42
+ const centralDirOffset = zipBuffer.readUInt32LE(eocdOffset + 16);
43
+ const entries = new Map();
44
+ let offset = centralDirOffset;
45
+
46
+ for (let index = 0; index < totalEntries; index += 1) {
47
+ if (zipBuffer.readUInt32LE(offset) !== CENTRAL_DIR_SIGNATURE) {
48
+ throw new Error('Invalid zip: central directory signature mismatch');
49
+ }
50
+
51
+ const compressionMethod = zipBuffer.readUInt16LE(offset + 10);
52
+ const compressedSize = zipBuffer.readUInt32LE(offset + 20);
53
+ const fileNameLength = zipBuffer.readUInt16LE(offset + 28);
54
+ const extraLength = zipBuffer.readUInt16LE(offset + 30);
55
+ const commentLength = zipBuffer.readUInt16LE(offset + 32);
56
+ const localHeaderOffset = zipBuffer.readUInt32LE(offset + 42);
57
+ const name = zipBuffer.toString('utf8', offset + 46, offset + 46 + fileNameLength);
58
+
59
+ entries.set(name, readEntryData(zipBuffer, localHeaderOffset, compressedSize, compressionMethod));
60
+ offset += 46 + fileNameLength + extraLength + commentLength;
61
+ }
62
+
63
+ return entries;
64
+ }
@@ -0,0 +1,64 @@
1
+ param(
2
+ [string]$FixturesDir = "tmp/superdoc-word-fixtures",
3
+ [string]$SourcesDir = "tmp/superdoc-corpus"
4
+ )
5
+
6
+ $ErrorActionPreference = 'Stop'
7
+ Add-Type -AssemblyName System.IO.Compression
8
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
9
+
10
+ function Get-UntouchedPartHashes([string]$path, [string]$changedPart) {
11
+ $hashes = @{}
12
+ $zip = [IO.Compression.ZipFile]::OpenRead($path)
13
+ try {
14
+ foreach ($entry in $zip.Entries) {
15
+ if ($entry.FullName -eq $changedPart) { continue }
16
+ $stream = $entry.Open()
17
+ $sha = [Security.Cryptography.SHA256]::Create()
18
+ try {
19
+ $hashes[$entry.FullName] = [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-', '').ToLowerInvariant()
20
+ }
21
+ finally {
22
+ $sha.Dispose()
23
+ $stream.Dispose()
24
+ }
25
+ }
26
+ }
27
+ finally { $zip.Dispose() }
28
+ return $hashes
29
+ }
30
+
31
+ $resolvedFixtures = (Resolve-Path -LiteralPath $FixturesDir).Path
32
+ $resolvedSources = (Resolve-Path -LiteralPath $SourcesDir).Path
33
+ $suite = Get-Content -LiteralPath (Join-Path $resolvedFixtures 'suite.json') -Raw -Encoding UTF8 | ConvertFrom-Json
34
+
35
+ foreach ($case in $suite.cases) {
36
+ $sourcePath = Join-Path $resolvedSources "$($case.sourceId).docx"
37
+ $outputPath = Join-Path $resolvedFixtures "$($case.name).docx"
38
+ $xmlPath = Join-Path $resolvedFixtures "$($case.name).document.xml"
39
+ $revisionPart = if ($case.revisionPart) { [string]$case.revisionPart } else { 'word/document.xml' }
40
+ $before = Get-UntouchedPartHashes $sourcePath $revisionPart
41
+
42
+ Copy-Item -LiteralPath $sourcePath -Destination $outputPath -Force
43
+ $zip = [IO.Compression.ZipFile]::Open($outputPath, [IO.Compression.ZipArchiveMode]::Update)
44
+ try {
45
+ $oldEntry = $zip.GetEntry($revisionPart)
46
+ if ($null -eq $oldEntry) { throw "Missing $revisionPart in $sourcePath" }
47
+ $oldEntry.Delete()
48
+ $newEntry = $zip.CreateEntry($revisionPart, [IO.Compression.CompressionLevel]::Optimal)
49
+ $stream = $newEntry.Open()
50
+ try {
51
+ $bytes = [Text.UTF8Encoding]::new($false).GetBytes((Get-Content -LiteralPath $xmlPath -Raw -Encoding UTF8))
52
+ $stream.Write($bytes, 0, $bytes.Length)
53
+ }
54
+ finally { $stream.Dispose() }
55
+ }
56
+ finally { $zip.Dispose() }
57
+
58
+ $after = Get-UntouchedPartHashes $outputPath $revisionPart
59
+ if ($before.Count -ne $after.Count) { throw "$($case.name): package part count changed" }
60
+ foreach ($name in $before.Keys) {
61
+ if ($after[$name] -ne $before[$name]) { throw "$($case.name): untouched package part changed: $name" }
62
+ }
63
+ Write-Output "Packaged $($case.name) (verified $($before.Count) untouched parts)"
64
+ }