Processing PDF Files in the Browser Without a Backend
Most web applications still handle PDF manipulation by uploading the user's file to a remote server, invoking Ghostscript or QPDF in a container, and streaming the result back. For contracts, tax filings, medical records, or identity documents, that round-trip introduces unnecessary storage liability and network latency.
When building the browser-side PDF tools on Sunasty, the goal was to execute every operation—merging, page reordering, stream compression, deskewing, and metadata removal—inside the user's tab using Web Workers, ArrayBuffer transfers, and WebAssembly. An architectural overview of why these pipelines belong on the client is also covered in our note on running a full PDF toolkit in the browser.
1. Zero-Copy Buffer Transfer to Web Workers
Parsing a 80 MB scanned PDF on the main UI thread will freeze scrolling and input events for several seconds. When a user selects a file via an <input type="file"> or drag-and-drop target, reading it as an ArrayBuffer and passing it in the transfer list of Worker.postMessage moves ownership of the memory block in constant time without duplicating bytes in RAM.
// Main thread: transfer the underlying ArrayBuffer to the PDF worker
async function dispatchPdfTask(file, operation, options = {}) {
const buffer = await file.arrayBuffer();
const worker = new Worker(new URL('./pdf-worker.js', import.meta.url), {
type: 'module'
});
return new Promise((resolve, reject) => {
worker.onmessage = (event) => {
worker.terminate();
if (event.data.error) {
reject(new Error(event.data.error));
} else {
resolve(new Blob([event.data.outputBuffer], { type: 'application/pdf' }));
}
};
worker.onerror = (err) => {
worker.terminate();
reject(err);
};
// Passing [buffer] in the second argument detaches it from the main thread
worker.postMessage({ operation, buffer, options }, [buffer]);
});
}
2. Merging Object Graphs Without Corrupting Cross-References
A PDF file is a directed graph of numbered indirect objects (obj ... endobj) indexed by a cross-reference table (xref) or cross-reference stream at the tail of the file. Concatenating two PDFs requires copying the page tree nodes (/Type /Page) and their transitive dependencies (fonts, form XObjects, color spaces, content streams) into a target document and assigning fresh object IDs so references do not collide.
In our client-side PDF merger, we iterate over each source file inside the Worker, copy the requested page indices into a unified document catalog, and serialize with object streams enabled (useObjectStreams: true) so repeated structural dictionaries are compressed together:
import { PDFDocument } from 'pdf-lib';
export async function mergePdfBuffers(sourceBuffers) {
const mergedPdf = await PDFDocument.create();
for (const buffer of sourceBuffers) {
const sourceDoc = await PDFDocument.load(buffer, {
ignoreEncryption: false,
updateMetadata: false
});
const pageIndices = sourceDoc.getPageIndices();
const copiedPages = await mergedPdf.copyPages(sourceDoc, pageIndices);
for (const page of copiedPages) {
mergedPdf.addPage(page);
}
}
const bytes = await mergedPdf.save({ useObjectStreams: true });
return bytes.buffer;
}
3. Inspecting and Purging /Info and XMP Metadata
PDF files store provenance in two distinct structures:
-
The Trailer
/InfoDictionary: Legacy key-value strings such as/Author,/Creator,/Producer,/CreationDate, and/ModDate. -
The Catalog
/MetadataStream: An uncompressed or Flate-encoded XML packet following the Adobe XMP specification (<x:xmpmeta>), which often retains the original Author name, operating system, and document history even after a user clears the standard properties dialog.
When sanitizing a document—for example in the PDF Metadata Inspector and Remover—modifying only the /Info dictionary leaves the XMP stream intact. Both references must be cleared from the root catalog and trailer before writing a clean cross-reference table:
import { PDFDocument, PDFName } from 'pdf-lib';
export async function stripPdfMetadata(inputBuffer) {
const pdfDoc = await PDFDocument.load(inputBuffer, { updateMetadata: false });
// 1. Clear standard Info dictionary keys
pdfDoc.setTitle('');
pdfDoc.setAuthor('');
pdfDoc.setSubject('');
pdfDoc.setKeywords([]);
pdfDoc.setProducer('');
pdfDoc.setCreator('');
// 2. Delete the XMP /Metadata stream reference from the document Catalog
const catalog = pdfDoc.catalog;
if (catalog.has(PDFName.of('Metadata'))) {
catalog.delete(PDFName.of('Metadata'));
}
// 3. Remove custom non-standard keys from the Info dictionary
const infoRef = pdfDoc.context.trailerInfo.Info;
if (infoRef) {
const infoDict = pdfDoc.context.lookup(infoRef);
if (infoDict && typeof infoDict.keys === 'function') {
for (const key of infoDict.keys()) {
infoDict.delete(key);
}
}
}
return (await pdfDoc.save({ useObjectStreams: true })).buffer;
}
4. Client-Side Compression and Image Downsampling
Why do 5-page PDFs sometimes exceed 25 MB? In almost every case, the culprit is either high-resolution RGB images embedded as raw Flate streams instead of baseline JPEG (/DCTDecode), or unsubsetted TrueType fonts duplicated across pages.
There are two distinct ways to reduce PDF size in the browser, both implemented in the Sunasty PDF Compressor and detailed practically in our guide on compressing PDF files for email attachments:
-
Structural & Stream Recompression: Parsing indirect objects, removing unreferenced orphan objects (such as leftover incremental update revisions from signatures or annotations), and packing non-stream objects into compressed
/ObjStmcontainers. -
Raster Downsampling (150 DPI / 200 DPI): For scanned documents where text is already rasterized, rendering each page at a controlled scale onto an
OffscreenCanvas, encoding to progressive JPEG at quality0.75, and rebuilding a clean PDF container around the compressed image streams.
5. Deskewing Scanned Pages and Applying Electronic Signatures
Scanned sheets fed through an automatic document feeder often arrive tilted by 1° to 4°. By rendering a low-resolution grayscale preview of the page on a canvas and computing horizontal projection profiles across candidate angles between -5° and +5°, we can find the angle that maximizes variance between text lines and whitespace gaps. The PDF Deskew utility applies the inverse rotation matrix directly to the page content stream or raster image without uploading the scan.
Similarly, placing a drawn or transparent PNG signature onto a contract does not require a cloud signing SaaS when a visual signature is sufficient: you can composite the PNG image as an XObject at exact PDF point coordinates (where 1 pt = 1/72 inch and the origin (0, 0) is at the bottom-left corner of the CropBox). We walked through the complete coordinate math and user workflow in how to sign a PDF without printing it.