978858362d
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.
100 lines
3.8 KiB
TypeScript
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" }));
|
|
}
|