Parsing Adobe Illustrator .ai files in the browser with pdf.js — four things the docs don't tell you A developer building ArtboardLab, a browser-based .ai file viewer and converter, discovered that Adobe Illustrator .ai files are actually PDF containers when saved with PDF compatibility enabled, allowing pdf.js to parse them. The developer encountered four undocumented pitfalls: CJK text disappears without setting cMapUrl, transparent PNG exports render as solid black, and other silent failures. The project, ArtboardLab, is a free tool that processes files entirely in the browser without uploading. 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