Files
PubScript/packages/exporters/src/determinism.ts
T
Schwitzz 978858362d feat(export): Chunk #6 Export Pipeline — 7 exporters, ExportDialog & tests
Add the @pubscript/exporters package exporting a flattened ProseMirror
project to Markdown, HTML, EPUB, DOCX, ODT and PDF, plus a deterministic
generatedAt timestamp threaded through every format (ZIP entry dates,
PDF /ID, DOCX dc:created/modified, EPUB id, HTML/XML footers) so golden
files are byte-stable.

Wire an ExportDialog into the web app (Topbar trigger, App route) with
format selection, theme pick, and download helpers.

Add round-trip and golden-file determinism tests (22) covering
non-empty output, byte-identical re-exports, Markdown round-trip through
the editor, and EPUB/ODT ZIP + mimetype + XML validation.
2026-10-26 23:30:47 -05:00

100 lines
3.8 KiB
TypeScript

/**
* Determinism helpers.
*
* Some exporters embed time-varying bytes that are irrelevant to the document
* content — ZIP entry timestamps (EPUB/DOCX/ODT) and the jsPDF `/ID` hash
* (PDF). These normalize those bytes so repeated exports of the same input are
* byte-identical, which is what golden-file stability requires. The normalized
* bytes are semantically unchanged: a ZIP entry's modification time and a PDF
* document identifier carry no content meaning here.
*/
import * as JSZipNs from "jszip";
// jszip is a CommonJS module whose `module.exports` is the constructor. Vite's
// ESM interop exposes it either as the default export or on the namespace, so
// accept whichever form is present.
const JSZip = (JSZipNs as unknown as { default?: typeof JSZipNs }).default ?? JSZipNs;
/**
* Zero the last-modification time/date fields in every ZIP header (both the
* local file headers `PK\x03\x04` and the central directory headers
* `PK\x01\x02`). The fields sit at offset 14/15 (time) and 16/17 (date) from
* each signature; zeroing them yields the canonical ZIP date 1980-01-01.
*/
export function normalizeZipDates(buffer: Uint8Array): Uint8Array {
const out = new Uint8Array(buffer);
const signatures: number[][] = [
[0x50, 0x4b, 0x03, 0x04], // local file header
[0x50, 0x4b, 0x01, 0x02], // central directory header
];
for (let i = 0; i + 17 <= out.length; i++) {
for (const sig of signatures) {
if (
out[i] === sig[0] &&
out[i + 1] === sig[1] &&
out[i + 2] === sig[2] &&
out[i + 3] === sig[3]
) {
out[i + 14] = 0;
out[i + 15] = 0;
out[i + 16] = 0;
out[i + 17] = 0;
}
}
}
return out;
}
/** Minimal FNV-1a 32-bit hash, hex-encoded (lowercase). */
function fnv1a(input: string): string {
let hash = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
}
return (hash >>> 0).toString(16).padStart(8, "0");
}
/**
* Replace the PDF `/ID [ <…> <…> ]` trailer entry with a deterministic value
* derived from the rest of the document, so the identifier no longer depends on
* jsPDF's internal `Date.now()`/`Math.random()` seed.
*/
export function normalizePdfId(buffer: Uint8Array): Uint8Array {
const text = new TextDecoder().decode(buffer);
const idRegex = /\/ID\s*\[\s*<[0-9a-fA-F]+>\s*<[0-9a-fA-F]+>\s*\]/;
if (!idRegex.test(text)) return buffer;
const withoutId = text.replace(idRegex, "");
const seed = fnv1a(withoutId);
const first = fnv1a(withoutId).padEnd(16, "0");
const second = fnv1a(withoutId + seed).padEnd(16, "0");
const normalized = text.replace(idRegex, `/ID [ <${first}> <${second}> ]`);
return new TextEncoder().encode(normalized);
}
/**
* Neutralize the time-varying bytes inside a DOCX archive produced by the
* `docx` library. That library stamps `docProps/core.xml` with `dcterms:created`
* and `dcterms:modified` timestamps taken from `new Date()`, which change on
* every export. We decompress the archive, rewrite every W3CDTF timestamp to the
* caller-supplied `generatedAt`, and re-compress with normalized ZIP dates. The
* document content is unchanged — only the metadata timestamps are pinned.
*/
export async function normalizeDocx(
buffer: Uint8Array,
generatedAt: string
): Promise<Uint8Array> {
const zip = await JSZip.loadAsync(buffer);
const core = zip.file("docProps/core.xml");
if (core) {
const xml = await core.async("string");
const fixed = xml.replace(
/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2}))/g,
generatedAt
);
zip.file("docProps/core.xml", fixed);
}
return normalizeZipDates(await zip.generateAsync({ type: "uint8array" }));
}