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.
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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" }));
|
||||
}
|
||||
Reference in New Issue
Block a user