# Parsing Adobe Illustrator .ai files in the browser with pdf.js — four things the docs don't tell you

> Source: <https://dev.to/kyungju_leebenjie_519b/parsing-adobe-illustrator-ai-files-in-the-browser-with-pdfjs-four-things-the-docs-dont-tell-you-17nk>
> Published: 2026-08-21 04:42:22+00:00

An `.ai`

file is not really an Illustrator format. If it was saved with **Create PDF Compatible File** turned on (the default since Illustrator 9), the bytes on disk are a PDF container — every artboard is a page. That single fact is what makes a browser-side `.ai`

viewer possible at all: you don't need to reverse-engineer a proprietary format, you point pdf.js at it and it opens.

Detection is a byte-signature check on the head of the file, not the extension:

``` js
export function detectAiKind(bytes: Uint8Array): AiFileKind {
  const head = new TextDecoder('latin1').decode(bytes.subarray(0, 1024));
  if (head.includes('%PDF-')) return 'pdf';       // modern, PDF-compatible
  if (head.includes('%!PS-Adobe')) return 'postscript'; // Illustrator 8 and older
  return 'unknown';
}
```

`%PDF-`

is searched for rather than matched at offset 0, because the PDF spec tolerates leading bytes before the header. Illustrator 8 and older wrote PostScript, and no browser will read those — that path gets an honest error message instead of a spinner.

So far, so easy. Everything after that was a series of failures that produced no error message at all. I built [ArtboardLab](https://artboardlab.com/tools/ai-to-svg/), a free browser-based tool set where nothing gets uploaded, and these four lessons all come from its `.ai`

viewer/converter.

`cMapUrl`

and CJK text silently disappears
The first Korean-language `.ai`

file I tested rendered perfectly — except every single glyph of Korean text was gone. No exception, no console warning, no fallback box. Vector shapes fine, Latin text fine, Hangul just not there.

CID-keyed fonts (Korean, Japanese, Chinese, and other multi-byte encodings) need Adobe's CMap tables to map character codes to glyph IDs. pdf.js ships those tables, but it does not bundle them into the library — it fetches them at runtime from wherever `cMapUrl`

points. If you don't set the option, there's nothing to fetch, and the failure mode is silence.

``` js
const task = pdfjs.getDocument({
  data: buffer,
  // CJK (and other CID) fonts need Adobe CMap data — without cMapUrl,
  // Korean/Japanese/Chinese text silently drops every glyph.
  cMapUrl: '/pdf-assets/cmaps/',
  cMapPacked: true,
  // substitutes for non-embedded standard fonts
  standardFontDataUrl: '/pdf-assets/standard_fonts/',
  // JPX (JPEG 2000) image decoding
  wasmUrl: '/pdf-assets/wasm/',
});
```

Two related options have the same shape of problem. `standardFontDataUrl`

supplies substitutes for the standard fonts a document references but doesn't embed, and `wasmUrl`

points at the WASM used for JPX (JPEG 2000) images, which show up in print-oriented artwork more often than you'd expect.

All three directories are copied into the site's static assets and served from the same origin — 198 files, mostly `.bcmap`

tables, about 3.3 MB. Nothing goes to a CDN. Check that those assets actually shipped: a missing CMap directory doesn't break the page, it just quietly deletes other people's languages.

Exporting a PNG with a transparent background sounds like a one-liner. pdf.js's `render()`

takes a `background`

option, so pass a transparent color and you're done.

It comes out solid black. `'rgba(0,0,0,0)'`

, the string `'transparent'`

, even a transparent `CanvasPattern`

— all of them paint opaque black in pdf.js v6. I verified each variant before accepting the conclusion, because it feels too basic to be broken.

The fix is the classic compositing trick: render the page twice, once on white and once on black, and derive alpha from the difference between the two. A fully opaque pixel looks identical on both backgrounds; a fully transparent one differs by the full range; anything in between lands proportionally in the middle. The black composite is already premultiplied by that alpha, so dividing it back out recovers the color.

``` js
const white = await renderToImageData(page, viewport, '#ffffff');
const black = await renderToImageData(page, viewport, '#000000');
const w = white.data;
const b = black.data;

for (let i = 0; i < w.length; i += 4) {
  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)));
  if (alpha === 0) {
    b[i] = b[i+1] = b[i+2] = b[i+3] = 0;
  } else {
    b[i]   = Math.min(255, Math.round((b[i]   * 255) / alpha));
    b[i+1] = Math.min(255, Math.round((b[i+1] * 255) / alpha));
    b[i+2] = Math.min(255, Math.round((b[i+2] * 255) / alpha));
    b[i+3] = alpha;
  }
}
```

The cost is what it looks like: every transparent render is two renders. Thumbnails and the preview want real alpha too — so the UI checkerboard shows what's actually transparent — which means the doubling applies across the whole pipeline, not just export.

Two practical notes. Read pixels back with `getContext('2d', { willReadFrequently: true })`

— this is a full-surface `getImageData`

on every render. And zero out `canvas.width`

/`canvas.height`

when done: at export scale these backing stores are enormous. There's also a hard cap of 8192 px on the long edge, with the scale reduced proportionally and a notice shown, rather than letting a huge artboard hit the canvas limit.

pdf.js used to have an SVG backend, `SVGGraphics`

. It was removed after the 3.x line. If you want an `.ai`

→ SVG path that keeps paths as vectors, you either write a PDF-operator-to-SVG converter yourself or you keep an old build around. I kept the old build: `pdfjs-legacy`

is an npm alias for `pdfjs-dist@3.11.174`

, living alongside v6 in the same `package.json`

.

That's a real cost, so it's paid only on demand: the legacy module is imported with `await import(...)`

inside the export handler, never at module scope — its worker file alone is about 1.1 MB minified. It runs as a fully separate instance: its own worker, its own `GlobalWorkerOptions`

, its own document. Nothing is shared with the v6 pipeline.

Three options are not optional on that build, and each fixes a different failure:

``` js
const task = legacy.getDocument({
  data: new Uint8Array(await file.arrayBuffer()),
  // 3.11.174 predates the CVE-2024-4367 fix — font programs must never
  // be able to reach eval().
  isEvalSupported: false,
  // without this, pdf.js drops the translated font data and SVGGraphics'
  // embedFonts pass throws `addFontStyle: No font data available`
  fontExtraProperties: true,
  // with OffscreenCanvas the worker returns images as ImageBitmap and leaves
  // imgData.data null; SVGGraphics predates bitmaps and reads .data
  // unconditionally, so any raster image throws `null.subarray`.
  isOffscreenCanvasSupported: false,
  cMapUrl: '/pdf-assets/cmaps/',
  cMapPacked: true,
  standardFontDataUrl: '/pdf-assets/standard_fonts/',
});
```

`isEvalSupported: false`

`eval()`

. Running a stranger's `.ai`

file through a known-vulnerable parser is exactly the scenario the CVE describes, so this one is non-negotiable. (v6 removed the option entirely; font programs can't get to `eval`

there at all.)`fontExtraProperties: true`

`SVGGraphics.embedFonts`

needs that data to write `@font-face`

rules, and without it you get `addFontStyle: No font data available`

.`isOffscreenCanvasSupported: false`

`ImageBitmap`

with `imgData.data`

left null. `SVGGraphics`

was written before bitmaps existed and reads `.data`

unconditionally, so any placed raster image throws `null.subarray`

.Two smaller things in the same file. `SVGGraphics`

is constructed with `forceDataSchema = true`

so fonts and images are inlined as base64 `data:`

URIs — the default `blob:`

URLs die with the page session, which would leave the downloaded `.svg`

full of unresolvable references the moment the user closes the tab. And the 3.x TypeScript types claim `getSVG()`

resolves to `void`

; it actually resolves to the SVG root element, which needs a cast.

One non-obvious detail: the SVG path re-reads the original `File`

from disk instead of reusing the buffer. The `ArrayBuffer`

handed to the v6 worker at open time was transferred, so it's detached. Same for the PDF export path.

`PDFPageProxy`

produce black output
Opening a file kicks off three things at once: render the preview, build thumbnails for every artboard, and scan the document for warnings. Run them concurrently and output starts coming back all black — not an exception, just wrong pixels, and not reliably reproducible.

`render()`

and `getOperatorList()`

on the *same* `PDFPageProxy`

don't tolerate overlapping. And note that trap 2 doubled the number of renders per page, which makes the overlap far easier to hit.

The fix is unglamorous: one promise chain that every piece of page work goes through.

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

function enqueue(work: () => Promise<void>): Promise<void> {
  pageWork = pageWork.then(work).catch(() => {});
  return pageWork;
}
```

Two consequences worth stating, since both bit me later:

The `.catch(() => {})`

is deliberate — one broken page must not poison the chain for everything queued behind it. But that means a caller can't learn about failure from the returned promise, so anything that needs to report an error records it inside its own task and reads it afterwards.

And the SVG export deliberately does *not* go through this chain. It opens its own document on its own worker, so it shares no `PDFPageProxy`

with the v6 pipeline — and routing it through `enqueue`

would swallow exactly the errors the UI needs to show.

There's also an `openToken`

counter, bumped on every open/reset, checked before each state write. Async work belonging to a file the user already replaced bails out instead of writing stale thumbnails into the new document's UI.

Some of this is solved. Some is as good as it gets in a browser, and the UI says so rather than pretending:

`commonObjs._objs`

), wrapped in a try/catch — if the internal shape changes, the warning is skipped silently rather than breaking the page.The general lesson, if there is one: pdf.js's hardest bugs here didn't throw. Missing glyphs, black canvases, and silently corrupted renders all look like a working app until someone opens a file in a language you don't read.
