@ansonlai/docx-redline-js 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) 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 +1148 -572
  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 +95 -104
  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 +17 -13
  36. package/engine/surgical-diff-application.js +7 -21
  37. package/engine/surgical-mode.js +3 -2
  38. package/engine/table-mode.js +27 -16
  39. package/index.d.ts +95 -3
  40. package/index.js +14 -13
  41. package/orchestration/list-structural-fallback.js +16 -39
  42. package/package.json +23 -5
  43. package/pipeline/diff-engine.js +174 -55
  44. package/pipeline/ingestion-export.js +39 -24
  45. package/pipeline/ingestion-paragraph.js +7 -5
  46. package/pipeline/list-generation.js +27 -18
  47. package/pipeline/patching.js +2 -3
  48. package/pipeline/pipeline.js +65 -36
  49. package/pipeline/serialization.js +13 -5
  50. package/scripts/build-test-dashboard.mjs +43 -0
  51. package/scripts/check-types.mjs +16 -24
  52. package/scripts/export-validation-fixtures.mjs +191 -45
  53. package/scripts/fetch-superdoc-corpus.mjs +61 -0
  54. package/scripts/generate-test-dashboard.mjs +199 -0
  55. package/scripts/inspect-visual-evidence.mjs +271 -0
  56. package/scripts/lib/minimal-zip.mjs +199 -18
  57. package/scripts/lib/word-coverage-catalogue.mjs +207 -0
  58. package/scripts/lib/word-coverage-metadata.mjs +93 -0
  59. package/scripts/lib/zip-reader.mjs +64 -0
  60. package/scripts/package-superdoc-word-fixtures.ps1 +64 -0
  61. package/scripts/prepare-corpus-word-visual-review.mjs +84 -0
  62. package/scripts/prepare-superdoc-word-corpus.mjs +284 -0
  63. package/scripts/prepare-word-review.mjs +77 -0
  64. package/scripts/prepare-word-visual-review.mjs +90 -0
  65. package/scripts/render-agenda-multilevel.mjs +70 -0
  66. package/scripts/render-case22.mjs +73 -0
  67. package/scripts/render-case40.ps1 +35 -0
  68. package/scripts/render-multilevel-bullet-images.py +58 -0
  69. package/scripts/render-multilevel-bullet-visual.ps1 +32 -0
  70. package/scripts/render-multilevel-cases.mjs +80 -0
  71. package/scripts/report-coverage-gaps.mjs +103 -0
  72. package/scripts/report-word-coverage.mjs +71 -0
  73. package/scripts/sample-multimodal-visual-check.mjs +221 -0
  74. package/scripts/test-multilevel-bullet-visual.mjs +187 -0
  75. package/scripts/word-com-corpus-suite.ps1 +43 -0
  76. package/scripts/word-com-corpus-visual-suite.ps1 +116 -0
  77. package/scripts/word-com-differential.ps1 +158 -16
  78. package/scripts/word-com-suite.ps1 +19 -0
  79. package/scripts/word-com-visual-suite.ps1 +132 -0
  80. package/services/comment-engine.js +51 -46
  81. package/services/comment-locator.js +0 -1
  82. package/services/comment-package.js +11 -10
  83. package/services/numbering-service.js +1 -1
  84. package/services/revision-comment-management.js +31 -10
  85. package/services/standalone-docx-plumbing.js +45 -34
  86. package/services/standalone-operation-runner.js +315 -75
  87. package/services/table-reconciliation.js +23 -11
@@ -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
+ }
@@ -0,0 +1,84 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { dirname, join, resolve } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const manifest = JSON.parse(readFileSync(
6
+ new URL('../tests/corpus/superdoc-english-legal-administrative.json', import.meta.url),
7
+ 'utf8'
8
+ ));
9
+ const catalogue = JSON.parse(readFileSync(
10
+ new URL('../tests/corpus/superdoc-word-scenarios.json', import.meta.url),
11
+ 'utf8'
12
+ ));
13
+ const sourceById = new Map(manifest.documents.map(item => [item.id, item]));
14
+ const VIEW_NAMES = Object.freeze(['allMarkup', 'acceptAll', 'rejectAll']);
15
+
16
+ export function selectCorpusVisualReviewCases(requestedKeys = []) {
17
+ const requested = new Set(requestedKeys);
18
+ const cases = catalogue.scenarios.map((scenario, index) => {
19
+ const scenarioKey = scenario.key || scenario.id;
20
+ const sourceId = scenario.sourceId || scenario.id;
21
+ const source = sourceById.get(sourceId);
22
+ const caseToken = scenario.key || sourceId.slice(0, 12);
23
+ return {
24
+ scenarioKey,
25
+ name: `${String(index + 1).padStart(2, '0')}-${source.type}-${caseToken}`,
26
+ category: source.type,
27
+ shape: scenario.shape,
28
+ operationCount: (scenario.operations || [scenario.operation]).length
29
+ };
30
+ });
31
+ if (requested.size > 0) {
32
+ const known = new Set(cases.map(item => item.scenarioKey));
33
+ const unknown = [...requested].filter(key => !known.has(key));
34
+ if (unknown.length) throw new Error(`Unknown corpus visual-review case(s): ${unknown.join(', ')}`);
35
+ return cases.filter(item => requested.has(item.scenarioKey));
36
+ }
37
+ return cases.filter(item => !/^[a-f0-9]{64}$/.test(item.scenarioKey));
38
+ }
39
+
40
+ export function buildCorpusVisualReviewManifest(requestedKeys = []) {
41
+ const selected = selectCorpusVisualReviewCases(requestedKeys);
42
+ return {
43
+ schemaVersion: 1,
44
+ reviewType: 'render-only-corpus-visual-preflight',
45
+ certification: {
46
+ status: 'pending',
47
+ reviewer: null,
48
+ reviewedAt: null,
49
+ note: 'Rendering proves that Word produced inspectable evidence; it does not certify visual correctness.'
50
+ },
51
+ word: { version: null, build: null },
52
+ cases: selected.map(item => ({
53
+ identity: `superdoc:${item.scenarioKey}`,
54
+ ...item,
55
+ renderStatus: 'pending',
56
+ views: Object.fromEntries(VIEW_NAMES.map(view => [view, {
57
+ status: 'pending',
58
+ pdf: `${item.name}-${view}.pdf`,
59
+ pages: null,
60
+ bytes: null
61
+ }]))
62
+ }))
63
+ };
64
+ }
65
+
66
+ function parseArgs(argv) {
67
+ const requestedKeys = argv.filter(value => value.startsWith('--case=')).map(value => value.slice(7));
68
+ const outputArgument = argv.find(value => value.startsWith('--output='));
69
+ return {
70
+ requestedKeys,
71
+ outputPath: outputArgument
72
+ ? resolve(process.cwd(), outputArgument.slice(9))
73
+ : join(process.cwd(), 'tmp', 'superdoc-word-visual-review', 'manifest.json')
74
+ };
75
+ }
76
+
77
+ const isCli = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
78
+ if (isCli) {
79
+ const { requestedKeys, outputPath } = parseArgs(process.argv.slice(2));
80
+ const review = buildCorpusVisualReviewManifest(requestedKeys);
81
+ mkdirSync(dirname(outputPath), { recursive: true });
82
+ writeFileSync(outputPath, `${JSON.stringify(review, null, 2)}\n`, 'utf8');
83
+ console.log(`Prepared ${review.cases.length} new SuperDoc scenarios for Word visual rendering: ${outputPath}`);
84
+ }