Utilities for PDF extraction and rendering across all JavaScript runtimes โ Node.js, Bun, the browser, and serverless environments like Cloudflare Workers. Especially useful for AI applications that need to summarize or analyze PDF documents.
Ships with a serverless build of Mozilla's PDF.js, optimized for edge environments. If you're coming from pdf-parse, unpdf is a modern, actively maintained alternative with broader runtime support.
- ๐๏ธ Works in Node.js 24+, Bun, browsers, and serverless environments (Cloudflare Workers example)
- ๐ชญ Includes serverless build of PDF.js (
unpdf/pdfjs) - ๐ฌ Extract text, images as PNG, links, and metadata โ no native dependencies
- ๐ผ๏ธ Render pages as PNG, JPEG, or WebP
- ๐ Spot scanned pages before sending them to OCR
- ๐งฑ Opt-in to a custom PDF.js build
# pnpm
pnpm add unpdf
# npm
npm install unpdfEvery method takes binary PDF data (Uint8Array, ArrayBuffer) or a document proxy from getDocumentProxy. Pass a proxy to run several methods on one document without parsing it again.
import { extractText, getDocumentProxy } from 'unpdf'
const buffer = await fetch('https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf')
.then(res => res.arrayBuffer())
const pdf = await getDocumentProxy(new Uint8Array(buffer))
const { totalPages, text } = await extractText(pdf, { mergePages: true })Without mergePages, text holds one string per page. For coordinates and font sizes, e.g. to detect tables, use extractTextItems.
PNG encoding needs no canvas and no native dependency, so it also runs in Cloudflare Workers:
import { writeFile } from 'node:fs/promises'
import { extractImages } from 'unpdf'
const images = await extractImages(pdf, 1, { format: 'png' })
for (const [index, image] of images.entries())
await writeFile(`image-${index + 1}.png`, image.data)Without format, data holds raw RGBA pixels. Each image carries its bounds on the page.
In browsers, a document proxy decodes some images into bitmaps, which extractImages skips. Pass binary data instead, or create the proxy with isOffscreenCanvasSupported: false and isImageDecoderSupported: false.
Browsers render on a DOM canvas, which a Web Worker lacks. Node.js and Bun need @napi-rs/canvas, passed as canvasImport. Serverless runtimes like Cloudflare Workers have no canvas.
import { renderPageAsImage } from 'unpdf'
const image = await renderPageAsImage(pdf, 1, {
canvasImport: () => import('@napi-rs/canvas'),
scale: 2,
format: 'jpeg',
quality: 0.8,
})inspectPages measures text and images per page โ where to draw the line between a text page and a scan depends on your documents:
import { inspectPages } from 'unpdf'
const { pages } = await inspectPages(pdf)
// Hidden OCR layers count as invisible text, so subtract them.
const scannedPageNumbers = pages
.map((page, index) => ({ ...page, pageNumber: index + 1 }))
.filter(page => page.textLength - page.invisibleTextLength < 20 && page.imageCoverage > 0.8)
.map(page => page.pageNumber)PDF.js does not execute scripts embedded in PDFs, but resource limits are your job:
- Image decoding:
maxImageSizeis unlimited by default โ pass e.g.getDocumentProxy(data, { maxImageSize: 16_777_216 })(~16 MP) so a single declared image can't allocate gigabytes. - Page fan-out:
extractText,extractTextItems,extractLinks, andinspectPagesprocess all pages in one call โ checkpdf.numPagesagainst a limit first. - Timeouts: with the serverless build, parsing runs on your event loop (no worker) โ race extraction calls against a timeout.
data is binary PDF data or a PDFDocumentProxy throughout.
Creates a PDFDocumentProxy with useSystemFonts: true. In Node.js with pdfjs-dist installed, it uses that package's standard fonts instead of system fonts and its CJK character maps (cMapUrl). A PDF.js build other than the serverless one also gets that package's image decoders (wasmUrl). Cloudflare Workers have no local pdfjs-dist โ to extract text from CJK fonts without embedded mappings, pass cMapUrl: 'https://unpkg.com/[email protected]/cmaps/', cMapPacked: true, and useWorkerFetch: true. Your options override the defaults.
function getDocumentProxy(data: Uint8Array | ArrayBuffer, options?: DocumentInitParameters): Promise<PDFDocumentProxy>Extracts the text of every page, or one string with mergePages: true โ line breaks kept, at most one blank line in a row.
function extractText(data, options?: { mergePages?: boolean }): Promise<{ totalPages: number, text: string | string[] }>Extracts positioned text items per page: str, x and y in PDF space (origin bottom-left), width, height, fontSize, fontFamily, dir, hasEOL.
function extractTextItems(data): Promise<{ totalPages: number, items: StructuredTextItem[][] }>Extracts the URLs of link annotations per page.
function extractLinks(data): Promise<{ totalPages: number, links: string[][] }>Extracts the images painted on a page โ image XObjects, inline images, and image masks โ as RGBA pixels or PNG bytes, each with width, height, and bounds in PDF space.
function extractImages(data, pageNumber: number, options?: { format?: 'rgba' | 'png' }): Promise<ExtractedImage[]>Renders a page as image bytes, or as a data URL with toDataURL: true. width or height scale the page to that size.
function renderPageAsImage(data, pageNumber: number, options?: {
canvasImport?: () => Promise<typeof import('@napi-rs/canvas')>
scale?: number // Default: 1
width?: number
height?: number
format?: 'png' | 'jpeg' | 'webp' // Default: 'png'
quality?: number // From 0 to 1, for JPEG and WebP
toDataURL?: boolean
}): Promise<Uint8Array | string>Measures each page: textLength, invisibleTextLength (text drawn invisibly, as by OCR layers), imageCount, and imageCoverage (share of the page covered by images, from 0 to 1).
function inspectPages(data): Promise<{ totalPages: number, pages: PageInspection[] }>Extracts the /Info dictionary and the XMP metadata (null without). parseDates: true turns the dates into Date objects.
function getMeta(data, options?: { parseDates?: boolean }): Promise<{ info: PDFInfo, metadata: Metadata | null }>Sets the PDF.js build every other method uses. It loads on first use.
function definePDFJSModule(pdfjs: () => Promise<PDFJS>): voidReturns the resolved PDF.js module โ the serverless build, unless definePDFJSModule set another โ to call the PDF.js API directly.
function getResolvedPDFJS(): Promise<PDFJS>Registers PDF.js' JBIG2 and OpenJPEG decoders, which CCITT, JBIG2, and JPEG 2000 images need, with the serverless PDF.js build. extractImages, renderPageAsImage, and inspectPages call it themselves โ call it before decoding these images through PDF.js directly, such as on the pages of a getDocumentProxy document. Another build set through definePDFJSModule, such as pdfjs-dist, ignores it and loads its decoders from wasmUrl, which getDocumentProxy sets in Node.js.
function registerImageDecoders(): voidunpdf uses its serverless build unless you define another one, e.g. to pin a different PDF.js version. Install pdfjs-dist and call definePDFJSModule before any other method:
import { definePDFJSModule } from 'unpdf'
definePDFJSModule(() => import('pdfjs-dist/legacy/build/pdf.mjs'))In Node.js, import the legacy build as shown. The default pdfjs-dist entry targets current browsers โ it fails on Node.js 24 and warns on newer versions. renderPageAsImage still requires canvasImport, though PDF.js draws with the @napi-rs/canvas that pdfjs-dist installs as an optional dependency.
Note
The serverless PDF.js bundle is built from PDF.js v6.4.299. It requires the global Iterator constructor (Safari 18.4+); without it, importing the bundle throws.
Heart and soul of this package is the pdfjs.rolldown.config.ts file. It uses Rolldown to bundle PDF.js for serverless environments. The key techniques:
- String replacements strip browser-specific references from the PDF.js source.
- Worker inlining embeds the PDF.js worker directly into the main bundle, since serverless runtimes can't load separate worker files.
- Bundled image decoders ship PDF.js' JBIG2 and OpenJPEG decoders, WASM and JS fallback, as lazy chunks, so CCITT, JBIG2, and JPEG 2000 images decode without
pdfjs-distor awasmUrl.extractImages,renderPageAsImage, andinspectPagesload them, so a bundle that only extracts text leaves them out. PDF.js called directly, throughgetResolvedPDFJSorunpdf/pdfjs, skips these images with warnings untilregisterImageDecodershas run. Bundlers that resolve theworkerdcondition, like Wrangler, ship only the JS fallbacks, since workerd can't compile WASM at runtime. - Global polyfills fill in newer APIs like
Promise.tryandMap.prototype.getOrInsertfor runtimes that lack them, plusFinalizationRegistryfor Cloudflare Workers.
MIT License ยฉ 2023-PRESENT Johann Schopplich