Skip to content

export({ exportType: ['pdf'] }) silently produces an empty 22-byte zip #3896

Description

@ohcedar

What happened?

Expected: 'pdf' is a value the shipped typings declare — dist/superdoc/src/core/types/index.d.ts:1341 has export type ExportType = 'docx' | 'pdf' | 'html';, used for ExportParams.exportType at :1363. Passing it should either produce a PDF or throw a clear "not supported" error.

Actual: it resolves successfully and hands back (or downloads) a valid but completely empty zip — 22 bytes, the End-of-Central-Directory record and nothing else. No error, no warning. 'html' behaves identically.

Mechanism

dist/chunks/create-theme-WuK2f6I4.es.js, SuperDoc.export() at lines 24998–25017. The only place exportType selects content is line 25007:

if (exportType.includes("docx")) docxFiles.forEach((blob) => {
  blobsToZip.push(blob);
  filenames.push(`${baseFileName}.docx`);
});
if (blobsToZip.length === 1) { ... }              // 25011 — single-file fast path
const zip = await createZip(blobsToZip, filenames); // 25015
if (triggerDownload) return createDownload(zip, baseFileName, "zip"); // 25016

The function contains no pdf or html branch at all. With ['pdf'] (or ['html']) nothing is pushed, blobsToZip.length is 0 so the fast path is skipped, and createZip([], []) runs — dist/chunks/zipper-BxRAi0-5.es.js:4, a JSZip generateAsync over zero entries, which is exactly 22 bytes.

Either dropping 'pdf' and 'html' from ExportType or throwing on an exportType that contributes no files would be an improvement over emitting a corrupt archive.

Steps to reproduce

Test fixture: test_contract.docx — synthetic, no third-party content.

npm i @harbour-enterprises/superdoc@1.46.1 jsdom fflate
node repro.mjs test_contract.docx

repro.mjs:

import fs from "node:fs";
import { JSDOM } from "jsdom";

const dom = new JSDOM('<!doctype html><html><body><div id="sd"></div></body></html>', {
  url: "http://localhost/",
  pretendToBeVisual: true,
});
for (const k of Object.getOwnPropertyNames(dom.window)) {
  if (globalThis[k] === undefined) {
    try { globalThis[k] = dom.window[k]; } catch {}
  }
}
// Node 24 defines its own global Blob/File; they must not shadow jsdom's.
for (const k of ["Blob", "File", "FileReader"]) globalThis[k] = dom.window[k];
const store = new Map();
globalThis.localStorage = {
  getItem: (k) => store.get(k) ?? null,
  setItem: (k, v) => void store.set(k, String(v)),
  removeItem: (k) => void store.delete(k),
  clear: () => store.clear(),
};

const { SuperDoc } = await import("@harbour-enterprises/superdoc");
const bytes = fs.readFileSync(process.argv[2] ?? "test_contract.docx");
const file = new dom.window.File([bytes], "test_contract.docx", {
  type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
});

const superdoc = await new Promise((resolve, reject) => {
  const sd = new SuperDoc({
    selector: "#sd",
    document: file,
    documentMode: "editing",
    modules: { comments: false, toolbar: false },
    telemetry: { enabled: false },
    onReady: () => resolve(sd),
  });
  setTimeout(() => reject(new Error("timeout waiting for onReady")), 60000);
});

// triggerDownload:false returns exactly the blob that would otherwise be downloaded.
for (const type of ["docx", "pdf", "html"]) {
  const blob = await superdoc.export({ exportType: [type], triggerDownload: false });
  const magic = [...new Uint8Array((await blob.arrayBuffer()).slice(0, 4))]
    .map((b) => b.toString(16).padStart(2, "0")).join(" ");
  console.log(
    `exportType: ['${type}'] -> ${String(blob.size).padStart(5)} bytes  ` +
      `magic ${magic}  mime "${blob.type}"`,
  );
}
process.exit(0);

Output (stdout, verbatim except the elided docx byte count, which moves a few bytes between runs; the 22 is exact and stable):

🦋 🦸‍♀️ [superdoc] 🦋 [superdoc] Using SuperDoc version: 1.46.1
exportType: ['docx'] -> <varies> bytes  magic 50 4b 03 04  mime "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
exportType: ['pdf'] ->    22 bytes  magic 50 4b 05 06  mime "application/zip"
exportType: ['html'] ->    22 bytes  magic 50 4b 05 06  mime "application/zip"

50 4b 05 06 is an empty archive's EOCD signature. triggerDownload: false only returns the blob instead of downloading it — with the default triggerDownload: true the same 22 bytes are handed to the browser as a .zip.

stderr carries one error, [PresentationEditor] Layout error Error: [incrementalLayout] Failed to get 2D context from canvas, with a multi-frame stack: jsdom has no canvas, so pagination cannot measure. It does not affect this result — the docx arm of the same run still produces a real ~21 kB document, and export() has no pdf/html branch to reach in any environment.


SuperDoc version

1.46.1 (V1) — the V1 line, now on the v1 branch. Not tested against V2 on main.

Browser

None — headless Node 24 with jsdom. No browser involved.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions