/** * 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 { 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" })); }