Reading and Writing PDFs in JavaScript: pdf-lib vs PDF.js
A technical explainer on the division of labor between pdf-lib and PDF.js for reading and writing PDFs in JavaScript, grounded in loopaloo's real client-side PDF processor.
If you only know one thing about PDFs in JavaScript, it should be this: there are two libraries, they do almost completely different jobs, and you will usually want both. PDF.js (pdfjs-dist) reads a PDF. It can parse the page tree, pull text out, render a page onto a canvas, and walk the operator list to recover embedded images. pdf-lib edits the document model. It can copy pages between documents, reorder them, rotate them, embed an image, draw text, and set the crop box, all without ever turning a page into pixels.
The confusion comes from the fact that both libraries can "open a PDF." But what they do after opening is the opposite. PDF.js gives you a read-oriented view optimized for display and extraction. pdf-lib gives you a mutable object graph that it can serialize back into a valid PDF file. PDF.js cannot save your edits back to a PDF; pdf-lib cannot render a page to a bitmap. Once you internalize that split, the right tool for any task becomes obvious.
This post walks through how each library models a PDF, why operations like merge and split preserve content while "compress by rasterizing" throws information away, and where the page tree fits into all of it. Every code sample maps to real functions in our src/lib/processors/pdf/pdfProcessor.ts, which imports pdf-lib (^1.17.1) and pdfjs-dist (^5.4.449) and runs entirely in the browser.
A PDF is a graph of objects, not a stream of pages
A PDF file is a collection of numbered objects: dictionaries, arrays, strings, numbers, and streams (compressed blobs of content). Those objects reference each other by number. At the top sits the catalog, the catalog points at a Pages node, and that node is the root of the page tree: a tree of Pages (intermediate) and Page (leaf) nodes. Each leaf Page carries its size (the MediaBox), its rotation, references to the fonts and images it uses, and a content stream of drawing operators.
The important consequence: a "page" is not a self-contained chunk of bytes you can slice out. It is a node that references shared resources (fonts, color spaces, embedded images) that may be used by other pages too. Any tool that moves pages around has to chase those references and copy what is needed. This is exactly what pdf-lib's copyPages does under the hood, and it is why merging is more subtle than concatenating files.
When you load a document with pdf-lib, you get a live representation of that object graph that you can mutate and re-serialize:
import { PDFDocument } from 'pdf-lib';
const pdf = await PDFDocument.load(arrayBuffer);
const count = pdf.getPageCount();
const indices = pdf.getPageIndices(); // [0, 1, 2, ...]
PDF.js loads the same file but hands you a document proxy whose API is shaped around reading: getPage(n), page.getViewport(), page.getTextContent(), page.render(), pdf.getMetadata(). There is no addPage, no save. That asymmetry is the whole story.
pdf-lib: editing the document model
Merge and split preserve content
Merging is the cleanest demonstration of working at the object level. You create an empty document, then for each input you copy its pages into the new document and append them:
export async function mergePDFs(files: File[]): Promise<Uint8Array> {
const mergedPdf = await PDFDocument.create();
for (const file of files) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer);
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
copiedPages.forEach((page) => mergedPdf.addPage(page));
}
return mergedPdf.save();
}
copyPages does the heavy lifting. It deep-copies each page node and every object it transitively references (fonts, image XObjects, content streams) into the destination document, rewriting object numbers so nothing collides. The text stays text, vector graphics stay vectors, fonts stay embedded fonts. Nothing is rasterized, so the result is lossless: a merged page carries the same drawing instructions as the source, just living in a new file with renumbered objects.
Splitting is the mirror image. Create one new document per page, copy that single page in, and save:
export async function splitPDF(file: File): Promise<Uint8Array[]> {
const pdf = await PDFDocument.load(await file.arrayBuffer());
const pageCount = pdf.getPageCount();
const splitPdfs: Uint8Array[] = [];
for (let i = 0; i < pageCount; i++) {
const newPdf = await PDFDocument.create();
const [copiedPage] = await newPdf.copyPages(pdf, [i]);
newPdf.addPage(copiedPage);
splitPdfs.push(await newPdf.save());
}
return splitPdfs;
}
Extract, remove, and reorder are all variations on the same pattern: decide which indices to keep and in what order, then copyPages them in that order. Reordering, for example, is just feeding copyPages a permuted list of indices. Because the unit of work is the page node, "reorder" and "extract" are not special operations, they are different argument lists to the same copy primitive.
Rotation flips a number, it does not redraw
Rotating a page is a good example of editing metadata rather than content. The page's rotation is a single entry in its dictionary (/Rotate), constrained to multiples of 90. pdf-lib exposes it directly:
import { degrees } from 'pdf-lib';
const page = pdf.getPage(pageNumber - 1);
page.setRotation(degrees(rotationDegrees)); // 90 | 180 | 270
No pixels move. The viewer reads the rotation flag and turns the page when it displays it. The content stream is untouched, so this is lossless and essentially free regardless of how complex the page is.
Drawing new content: watermarks, headers, footers
pdf-lib can also append to a page's content stream. To watermark, you embed a font, then draw text on top of every page:
import { rgb, degrees, StandardFonts } from 'pdf-lib';
const font = await pdf.embedFont(StandardFonts.HelveticaBold);
for (const page of pdf.getPages()) {
const { width, height } = page.getSize();
const textWidth = font.widthOfTextAtSize(text, fontSize);
page.drawText(text, {
x: (width - textWidth) / 2,
y: height / 2,
size: fontSize,
font,
color: rgb(r, g, b),
opacity,
rotate: degrees(rotation),
});
}
Two details matter here. First, pdf-lib's coordinate origin is the bottom-left corner, and y increases upward, which is the PDF spec's convention, not the top-left, y-down convention you know from the DOM and canvas. That is why our footer is drawn at y: 20 and our header at y: height - 30. Second, the standard 14 fonts (Helvetica, Times, Courier, and friends) are not embedded as font files; they are assumed to exist in every PDF viewer, so StandardFonts.HelveticaBold adds almost nothing to file size. If you need a custom font you embed the actual font bytes, and they ride along in the file.
Cropping is similarly model-level. page.setCropBox(left, bottom, width, height) changes the visible rectangle without removing any drawing operators:
const { width, height } = page.getSize();
page.setCropBox(
margins.left,
margins.bottom,
width - margins.left - margins.right,
height - margins.top - margins.bottom,
);
The cropped-out content still exists in the file, it is just not displayed.
PDF.js: rendering and parsing
PDF.js is what you reach for when you need to see or read the content, not restructure it. In our code it always runs with a worker so the heavy parsing happens off the main thread:
const pdfjsLib = await import('pdfjs-dist');
if (typeof window !== 'undefined') {
pdfjsLib.GlobalWorkerOptions.workerSrc = '/workers/pdf.worker.min.mjs';
}
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
const pdf = await loadingTask.promise;
Reading page dimensions and metadata
For a page-info panel you ask PDF.js for the viewport of each page and read the document's info dictionary:
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale: 1 });
pages.push({ pageNumber: i, width: viewport.width, height: viewport.height });
}
const metadata = await pdf.getMetadata();
const info = metadata.info as Record<string, unknown>;
const title = info.Title as string | undefined;
const author = info.Author as string | undefined;
You could read sizes from pdf-lib too (page.getSize()), but PDF.js's parser is more tolerant of malformed or unusual files, which is why we use it for the read-only info path.
Extracting text is reconstruction, not retrieval
A PDF does not store paragraphs. It stores instructions like "set font F1 at 11pt, move to (72, 700), show these glyphs." Text extraction means collecting every such fragment with its position and reassembling reading order yourself. PDF.js gives you the fragments via getTextContent(), each with a transform matrix whose entry at index 4 is the x position and entry at index 5 is the y position:
const textContent = await page.getTextContent();
let lastY: number | null = null;
let lastX: number | null = null;
let pageText = '';
for (const item of textContent.items) {
const t = item as { str: string; transform: number[]; width: number };
if (!t.str) continue;
const currentY = t.transform[5];
const currentX = t.transform[4];
if (lastY !== null) {
if (Math.abs(currentY - lastY) > 5) {
pageText += '\n'; // big vertical jump means a new line
} else if (lastX !== null && currentX - lastX > 10) {
pageText += ' '; // same line, wide gap means a space
}
}
pageText += t.str;
lastY = currentY;
lastX = currentX + (t.width || 0);
}
The heuristics (a y-delta over 5 units starts a new line, an x-gap over 10 units inserts a space) are ours, not the library's. PDF.js hands you positioned glyph runs; turning them back into lines and words is an inherently lossy guess. This is also why text extraction fails entirely on scanned PDFs: there are no text fragments, only an image, and you would need OCR.
Rendering and recovering embedded images
To rasterize a page you size a canvas to the viewport and call render:
const viewport = page.getViewport({ scale: options.scale });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({ canvasContext: context, viewport, canvas }).promise;
The scale is the lever: scale 2 produces a canvas with twice the pixels per axis, sharper but four times as many pixels (and roughly four times the memory). This is the entry point for PDF-to-image export.
PDF.js can also recover the images that are embedded inside a PDF (the original photos and logos, not a render of the page). You walk the page's operator list looking for OPS.paintImageXObject ops, then pull the named image object out of page.objs. Depending on the source, the object PDF.js hands back exposes a bitmap property (an ImageBitmap or VideoFrame) that you can drawImage directly; if that path is unavailable, it exposes raw pixel data that you pack into an ImageData yourself. There is a grayscale-versus-color branch here: when kind === 1, the source is one byte per pixel, so each byte is copied into the R, G and B channels with alpha forced to 255; otherwise the source bytes are read as RGB or RGBA. That operator-list walk is firmly read-side work: it parses content, it does not change it.
Why compression is the awkward middle case
Here is where the two libraries collide, and where it pays to understand the tradeoff. "Make this PDF smaller" has no single correct answer, because PDF size comes from different sources: embedded font subsets, high-resolution images, redundant objects, uncompressed object streams.
The genuinely lossless lever pdf-lib offers is re-saving with object streams, which packs many small objects together and compresses them. In our compressor this is the path taken at the lowest level:
const pdfBytes = await pdf.save({ useObjectStreams: true, addDefaultPage: false });
That can shrink a bloated file with no quality loss, but it does nothing for a PDF whose bulk is a few giant images, which is the common case for scanned documents.
To actually attack image-heavy files, our compressor takes the blunt route at higher levels: render each page to a canvas with PDF.js at a reduced scale (1.5), encode that canvas as a JPEG at reduced quality, and rebuild a fresh PDF where each page is just that one JPEG, using pdf-lib's embedJpg:
const viewport = page.getViewport({ scale: 1.5 });
// ... render to canvas ...
const imageBlob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('blob failed'))), 'image/jpeg', quality);
});
const embeddedImage = await compressedPdf.embedJpg(new Uint8Array(await imageBlob.arrayBuffer()));
const newPage = compressedPdf.addPage([viewport.width, viewport.height]);
newPage.drawImage(embeddedImage, { x: 0, y: 0, width: viewport.width, height: viewport.height });
This is lossy, and irreversibly so. The moment you render to a canvas you have flattened everything into pixels:
| Property | Before (vector PDF) | After rasterize + JPEG |
|---|---|---|
| Selectable / searchable text | Yes | No (it is now an image) |
| Sharpness when zoomed | Resolution-independent | Fixed at the render scale |
| File size on text-heavy pages | Smaller | Often larger |
| File size on photo-heavy pages | Larger | Smaller |
| Reversible | n/a | No |
That table is the whole reason compression deserves caution. Rasterizing a crisp text document to JPEG usually makes the file bigger and destroys text search, while rasterizing a high-resolution scanned brochure can shrink it dramatically. The technique is right for one input and wrong for the other, which is why the level matters and why the operation is fundamentally different from merge or split. Merge and split move real objects, so they preserve everything; compress-by-rasterize manufactures new pixel-based objects and discards the originals.
A side effect worth noting: because the render path uses the canvas API and document.createElement('canvas'), it only works in the browser, not in a plain Node worker. pdf-lib's page-model operations have no such dependency.
Choosing between them
| Task | Library | Lossless? |
|---|---|---|
| Merge, split, extract, reorder, remove pages | pdf-lib (copyPages) | Yes |
| Rotate pages | pdf-lib (setRotation) | Yes |
| Watermark, headers/footers, crop | pdf-lib (drawText, setCropBox) | Yes (adds content) |
| Re-save smaller without quality loss | pdf-lib (useObjectStreams) | Yes |
| Read page sizes and document metadata | PDF.js (getViewport, getMetadata) | Read-only |
| Extract text | PDF.js (getTextContent) | Read-only, heuristic |
| Render page to image / PDF-to-image | PDF.js (render) | Lossy (rasterizes) |
| Pull out embedded images | PDF.js (operator list + page.objs) | Read-only |
| Shrink an image-heavy/scanned PDF | PDF.js render + pdf-lib embedJpg | Lossy |
The mental model that keeps you out of trouble: if the task restructures or annotates the document while keeping its content as content, it belongs to pdf-lib and it can be lossless. If the task needs to interpret content (read text, draw pixels, see what a page looks like), it belongs to PDF.js. The only operations that need both are the ones that deliberately convert content into pixels, and those are exactly the operations where you should expect to lose something. (Our booklet imposition is a happy exception: it uses pdf-lib's embedPdf and drawPage to place two real source pages side by side on each sheet, so it stays in the object model and never rasterizes.)
Try it in the browser
Everything above runs client-side in our PDF tools, no upload required. The PDF Merger and PDF Splitter use pdf-lib's copyPages path, so they preserve text, fonts, and vectors. The PDF Compressor uses the PDF.js render plus pdf-lib embedJpg path for image-heavy files and the lossless object-stream re-save at the lowest level, so you can pick the tradeoff that fits your document. If your PDF is mostly searchable text, reach for the Merger and Splitter before the Compressor; if it is a fat scan, compression is where the savings live.
Related Tools
Related Articles
Try Our Free Tools
200+ browser-based tools for developers and creators. No uploads, complete privacy.
Explore All Tools