DEV Community

kyungju lee (Benjie)
kyungju lee (Benjie)

Posted on

Two versions of pdf.js in one bundle: what SVG export actually costs

A PDF-compatible .ai file is a PDF container, so pdf.js can open it. I use it for exactly that: artboard thumbnails, a preview, PNG export. That path runs on pdf.js 6.2.108.

The SVG export button on the same page runs on pdf.js 3.11.174, loaded under an npm alias:

"pdfjs-dist": "^6.2.108",
"pdfjs-legacy": "npm:pdfjs-dist@3.11.174"
Enter fullscreen mode Exit fullscreen mode

Two copies of the same library, in the same app, at the same time. That is not a decision anyone makes cheerfully, so here is the whole reasoning — plus the three constructor options the 3.x build needs before SVG export produces anything usable, and what shipping the second copy cost in bytes.

I build ArtboardLab, a free browser-based set of design tools where files are never uploaded — everything below came out of making the .ai tools there work on real files.

Why the old version at all

The SVG backend is SVGGraphics, and it only exists on the old line. Grep the shipped bundles:

$ grep -c SVGGraphics node_modules/pdfjs-dist/build/pdf.mjs   # 6.2.108
0
$ grep -c SVGGraphics node_modules/pdfjs-legacy/build/pdf.js  # 3.11.174
7
Enter fullscreen mode Exit fullscreen mode

3.11.174 also still exports it from its type definitions. v6 has no equivalent — there is no "render this page as vector markup" API to migrate to. Either you write a PDF-operator-to-SVG translator yourself, or you keep a build that already has one.

So src/lib/ai/svg-export.ts is the only module in the codebase allowed to import pdfjs-legacy, and it is reached only through await import(...). Everything else — open, thumbnails, preview, PNG — stays on v6.

The three options 3.x needs

Getting SVGGraphics to emit markup for a real designer's .ai file took three constructor options that the sample snippets don't mention. All three are non-optional in the sense that removing any one of them breaks a specific, reproducible class of file:

const task = legacy.getDocument({
  data: new Uint8Array(await file.arrayBuffer()),
  isEvalSupported: false,
  fontExtraProperties: true,
  isOffscreenCanvasSupported: false,
  cMapUrl: '/pdf-assets/cmaps/',
  cMapPacked: true,
  standardFontDataUrl: '/pdf-assets/standard_fonts/',
});
Enter fullscreen mode Exit fullscreen mode

isEvalSupported: false — this build predates the fix for CVE-2024-4367, the font-program-to-eval() path. Nothing about the SVG output depends on this flag; it is here because the input is a stranger's file and font programs must never reach eval(). Worth noting the direction the library went: isEvalSupported appears ten times in the 3.11.174 bundle and zero times in the 6.2.108 one. On the current version there is no flag to forget. On the pinned one there is, and forgetting it is the entire risk of pinning.

fontExtraProperties: true — without it, any file with an embedded font throws in SVGGraphics' font-embedding pass with addFontStyle: No font data available. The translated font data is dropped before the SVG writer can ask for it. Since embedded fonts are the normal case in artwork, this is effectively "SVG export doesn't work" rather than an edge case.

isOffscreenCanvasSupported: false — this one only shows up on files with placed photos. With OffscreenCanvas available, the worker hands images back as ImageBitmap and leaves imgData.data null. SVGGraphics predates bitmaps and reads .data unconditionally, so the first raster image throws null.subarray inside paintInlineImageXObject. I found this on a 24 MB two-artboard file full of placed photos; every text-only test file had passed.

Two more settings live on the SVG path rather than on getDocument:

const gfx = new legacy.SVGGraphics(page.commonObjs, page.objs, true);
gfx.embedFonts = true;
Enter fullscreen mode Exit fullscreen mode

That third positional argument is forceDataSchema. With it, fonts and images are inlined as base64 data: URIs; without it the generated markup referenced blob: URLs scoped to the page session — which look perfect in the tab that produced them and are dead references the moment the .svg is on disk. embedFonts = true inlines the font programs as @font-face so text still renders on a machine that doesn't have the original typeface.

The CJK story is the same as on v6: cMapUrl plus cMapPacked, or Korean, Japanese and Chinese text loses every glyph silently — no error, just empty artwork.

The two copies never touch

The legacy document is opened from scratch: its own module instance, its own GlobalWorkerOptions.workerSrc, its own worker, its own PDFDocumentProxy. Nothing is shared with the v6 pipeline, and that isolation is deliberate — a PDFPageProxy from one build has no business crossing into the other.

One consequence catches you immediately. The SVG path re-reads the File from disk:

data: new Uint8Array(await file.arrayBuffer()),
Enter fullscreen mode Exit fullscreen mode

It cannot reuse the buffer already in memory, because that buffer was transferred to the v6 worker when the document was opened and is therefore detached. Keeping the File handle around and re-reading is the fix; there is nothing clever to do about it.

Concurrent renders on the same page proxy corrupt output

This one belongs to the v6 side, and it is the bug I'd most want to have known in advance.

Thumbnails, the preview render, and the warning scan all want page data at the same time, and the naive version fires them off in parallel. What comes back is all-black canvases — no exception, no console warning, just wrong pixels. Overlapping render() / getOperatorList() calls on the same PDFPageProxy corrupt the output.

The fix is unglamorous: one promise chain, everything through it.

let pageWork: Promise<void> = Promise.resolve();

function enqueue(work: () => Promise<void>): Promise<void> {
  pageWork = pageWork.then(work).catch(() => {});
  return pageWork;
}
Enter fullscreen mode Exit fullscreen mode

The .catch(() => {}) keeps one bad page from breaking the chain for everything queued behind it — which has a consequence worth stating out loud, because it bit me: the queue swallows rejections, so any caller that needs to know whether its work failed has to record that inside the enqueued closure. A try/catch around the await enqueue(...) never fires. PNG export failures were silently invisible for a while for exactly this reason.

The SVG export deliberately stays outside this chain, for both halves of the same reason: it shares no PDFPageProxy with the v6 pipeline, and it needs its rejections to actually surface.

v6 paints the transparent background opaque

Also on the v6 side, and also worth writing down. Passing background: 'rgba(0,0,0,0)' to page.render() produces solid black. So does 'transparent'. So does a transparent CanvasPattern.

Transparency is recovered the old-fashioned way — render the page twice, once on white and once on black, derive per-pixel alpha from the difference, then un-premultiply the black composite:

const diff = (w[i] - b[i] + (w[i+1] - b[i+1]) + (w[i+2] - b[i+2])) / 3;
const alpha = 255 - Math.max(0, Math.min(255, Math.round(diff)));
Enter fullscreen mode Exit fullscreen mode

It doubles the render cost of every thumbnail and every preview, which is why the render queue above matters as much as it does.

Paying for the second copy only when it's used

A second pdf.js is affordable only if it stays out of the initial chunk. Two rules do that: the import is dynamic, and it lives in a module nothing imports eagerly.

The build output shows it working. The svg-export chunk that the island actually references is 1,139 bytes — just the loader. The real weight sits in sibling chunks that no one fetches until the button is clicked:

chunk bytes
svg-export.*.js (loader) 1,139
legacy main bundle 305,393
legacy worker 1,087,212
v6 worker 1,262,398

So a visitor who opens a file and exports PNG never downloads the 1.39 MB legacy pair at all. A visitor who clicks Export SVG pays for it once, on click, with the button already in its loading state.

One last cost that surprised me, since it's downstream of forceDataSchema: base64-inlined images make the markup enormous and highly compressible. The multi-file ZIP path defaults to store-only (level 0), which is correct for PNG and WebP entries and badly wrong here. That photo-heavy two-artboard file produced a ~62 MB store-only ZIP; at DEFLATE level 6 the same two SVGs zipped to 10.2 MB. The ZIP helper now takes an optional level and the SVG path passes 6.

Would I do it again

Yes, but with the pin treated as a liability rather than a dependency. One module may import it, it loads on click only, and the security flag the old build needs is commented at the call site with its CVE number, because the whole hazard of pinning is that the reason gets forgotten before the pin does.

Top comments (0)