Skip to content

Instantly share code, notes, and snippets.

@barendb
Created June 23, 2026 22:11
Show Gist options
  • Select an option

  • Save barendb/9960e0e14f765d11e81405b433072cb0 to your computer and use it in GitHub Desktop.

Select an option

Save barendb/9960e0e14f765d11e81405b433072cb0 to your computer and use it in GitHub Desktop.
superdoc-docx-roundtrip-test
#!/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