Created
June 23, 2026 22:11
-
-
Save barendb/9960e0e14f765d11e81405b433072cb0 to your computer and use it in GitHub Desktop.
superdoc-docx-roundtrip-test
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| /** | |
| * DOCX Round-Trip Diagnostic Harness | |
| * | |
| * Reproduces — headlessly, with no collab server, S3, or backend — exactly what | |
| * happens to a document's formatting when the app creates a new version: | |
| * | |
| * original.docx | |
| * --SuperDoc import (DOCX -> ProseMirror/Yjs schema)--> editor state | |
| * --editor.exportDocx() (ProseMirror -> DOCX)---------> roundtrip.docx | |
| * | |
| * "Add new version" / "Publish" / "Open in Word" all serialize the live editor | |
| * via `editor.exportDocx({ isFinalDoc: false, commentsType: 'clean' })` — the | |
| * SAME call used here. So any difference between the input and the output is the | |
| * round-trip fidelity loss introduced purely by SuperDoc's import+export cycle, | |
| * independent of any user edits. | |
| * | |
| * It writes: | |
| * <out>.docx the re-serialized document — open in Word to see the drift | |
| * <out>.roundtrip.xml the exported word/document.xml (pretty-ish) for text diffing | |
| * <out>.original.xml the original word/document.xml (when extractable) to diff against | |
| * | |
| * Or directly after building: | |
| * node tools/matter-mcp-server/build/cli/test-docx-roundtrip.mjs --input=in.docx --out=out | |
| */ | |
| import { | |
| Editor, | |
| getStarterExtensions, | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| } from '@harbour-enterprises/superdoc/super-editor'; | |
| import { JSDOM } from 'jsdom'; | |
| import * as fs from 'node:fs'; | |
| import * as path from 'node:path'; | |
| // SuperDoc's headless types are loose; mirror the existing tests/CLI and cast. | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| type AnyEditor = any; | |
| interface CliArgs { | |
| input: string; | |
| out: string; | |
| } | |
| function parseArgs(argv: string[]): CliArgs { | |
| const map = new Map<string, string>(); | |
| for (const arg of argv) { | |
| const match = /^--([^=]+)=(.*)$/.exec(arg); | |
| if (match) map.set(match[1], match[2]); | |
| } | |
| const input = map.get('input'); | |
| if (!input) { | |
| console.error( | |
| 'Usage: node test-docx-roundtrip.mjs --input=<path-to.docx> [--out=<output-prefix>]', | |
| ); | |
| process.exit(1); | |
| } | |
| const resolvedInput = path.resolve(input); | |
| // Default the output prefix next to the input: foo.docx -> foo.roundtrip(.docx/.xml) | |
| const defaultOut = path.join( | |
| path.dirname(resolvedInput), | |
| `${path.basename(resolvedInput, path.extname(resolvedInput))}.roundtrip`, | |
| ); | |
| return { | |
| input: resolvedInput, | |
| out: path.resolve(map.get('out') ?? defaultOut), | |
| }; | |
| } | |
| /** | |
| * Best-effort extraction of the original word/document.xml from the parsed | |
| * DOCX entries returned by Editor.loadXmlData(). The entry shape is loosely | |
| * typed, so probe defensively and only write when we recover a string body. | |
| */ | |
| function extractOriginalDocumentXml(xmlFiles: unknown): string | null { | |
| if (!Array.isArray(xmlFiles)) return null; | |
| for (const entry of xmlFiles) { | |
| if (!entry || typeof entry !== 'object') continue; | |
| const e = entry as Record<string, unknown>; | |
| const name = String(e.name ?? e.path ?? e.filename ?? ''); | |
| if (!name.endsWith('document.xml') || name.includes('/_rels/')) continue; | |
| const body = e.content ?? e.data ?? e.xml ?? e.text; | |
| if (typeof body === 'string') return body; | |
| } | |
| return null; | |
| } | |
| /** Insert newlines between tags so a text diff is readable, without reformatting attributes. */ | |
| function prettyXml(xml: string): string { | |
| return xml.replace(/></g, '>\n<'); | |
| } | |
| async function toBuffer(exported: unknown): Promise<Buffer> { | |
| // Headless export returns a Node Buffer; browser returns a Blob. Handle both. | |
| if (Buffer.isBuffer(exported)) return exported; | |
| if (exported instanceof Uint8Array) return Buffer.from(exported); | |
| const maybeBlob = exported as { arrayBuffer?: () => Promise<ArrayBuffer> }; | |
| if (typeof maybeBlob?.arrayBuffer === 'function') { | |
| return Buffer.from(await maybeBlob.arrayBuffer()); | |
| } | |
| throw new Error( | |
| `exportDocx returned an unexpected type: ${Object.prototype.toString.call(exported)}`, | |
| ); | |
| } | |
| async function main() { | |
| const { input, out } = parseArgs(process.argv.slice(2)); | |
| if (!fs.existsSync(input)) { | |
| console.error(`Input file not found: ${input}`); | |
| process.exit(1); | |
| } | |
| console.log('\n📄 DOCX round-trip diagnostic'); | |
| console.log(` input: ${input}`); | |
| console.log(` output: ${out}.docx\n`); | |
| const buffer = fs.readFileSync(input); | |
| // JSDOM is required for SuperDoc's schema/extensions even in headless mode. | |
| const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>'); | |
| const { window } = dom; | |
| // 1. IMPORT — parse the DOCX into SuperDoc's XML entries + media + fonts. | |
| console.log('→ importing (Editor.loadXmlData)…'); | |
| const [xmlFiles, , mediaBase64, fonts] = await ( | |
| Editor as AnyEditor | |
| ).loadXmlData(buffer, /* isNode */ true); | |
| // 2. BUILD the headless editor from the parsed content. This is the same | |
| // schema/extension set the app uses (getStarterExtensions), so the | |
| // ProseMirror representation matches what the editor holds. | |
| console.log('→ building headless editor…'); | |
| const editor: AnyEditor = new (Editor as AnyEditor)({ | |
| isHeadless: true, | |
| mode: 'docx', | |
| document: window.document, | |
| element: window.document.createElement('div'), | |
| content: xmlFiles, | |
| mediaFiles: mediaBase64, | |
| fonts, | |
| extensions: getStarterExtensions(), | |
| documentMode: 'editing', | |
| isNewFile: false, | |
| user: { name: 'Roundtrip Harness', email: '', image: '' }, | |
| }); | |
| // Let the editor finish initialising, then flush the BlockNode plugin so the | |
| // document is in the same shape the app exports from. NO content edits. | |
| await new Promise((resolve) => setTimeout(resolve, 50)); | |
| editor.dispatch(editor.state.tr); | |
| // 3. EXPORT — identical params to use-source-publish.ts (Add new version). | |
| console.log('→ exporting (editor.exportDocx)…'); | |
| const exported = await editor.exportDocx({ | |
| isFinalDoc: false, | |
| commentsType: 'clean', | |
| }); | |
| const outDocx = await toBuffer(exported); | |
| fs.writeFileSync(`${out}.docx`, outDocx); | |
| // 3b. Also grab the raw exported word/document.xml for a text diff. | |
| const roundtripXml: string = await editor.exportDocx({ | |
| isFinalDoc: false, | |
| commentsType: 'clean', | |
| exportXmlOnly: true, | |
| }); | |
| fs.writeFileSync(`${out}.roundtrip.xml`, prettyXml(String(roundtripXml))); | |
| // 3c. Write the original document.xml alongside it, when we can recover it. | |
| const originalXml = extractOriginalDocumentXml(xmlFiles); | |
| if (originalXml) { | |
| fs.writeFileSync(`${out}.original.xml`, prettyXml(originalXml)); | |
| } | |
| editor.destroy?.(); | |
| (window as unknown as { close?: () => void }).close?.(); | |
| console.log('\n✅ Done. Wrote:'); | |
| console.log( | |
| ` ${out}.docx (open in Word — this is the new version)`, | |
| ); | |
| console.log(` ${out}.roundtrip.xml (exported OOXML)`); | |
| if (originalXml) { | |
| console.log(` ${out}.original.xml (source OOXML)`); | |
| console.log('\nDiff the formatting drift with:'); | |
| console.log(` diff "${out}.original.xml" "${out}.roundtrip.xml"`); | |
| } else { | |
| console.log( | |
| '\n(Original document.xml not auto-extracted — unzip the input .docx and diff word/document.xml against the .roundtrip.xml.)', | |
| ); | |
| } | |
| console.log(''); | |
| } | |
| main().catch((err) => { | |
| console.error('\n❌ Round-trip failed:', err); | |
| process.exit(1); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment