PDF.js / pdf-lib Bridge
Quick Reference
| Aspect | Detail |
|---|---|
| PDF.js role | Read-only rendering, text extraction, annotation parsing, form field display |
| pdf-lib role | PDF creation, modification, saving with annotations, form field persistence |
| Bridge mechanism | originalBytesCache (Map<filePath, Uint8Array>) + coordinate transforms |
| PDF.js version | pdfjs-dist 5.4.624 |
| pdf-lib version | 1.17.1 |
| Loader file | js/pdf/loader.js |
| Saver file | js/pdf/saver.js |
Technology Boundary
Side A: PDF.js (pdfjs-dist v5.4): Rendering & Parsing
PDF.js is the read-only side. It handles:
- Page rendering to canvas (
js/pdf/renderer.js) - Text layer extraction for selection
- Annotation object parsing via
page.getAnnotations() - Form field display and interaction via
AnnotationStorage - Link layer for clickable URLs
PDF.js renders annotations internally, but the app disables this (annotationMode: 0) and draws annotations on a separate overlay canvas instead. PDF.js NEVER modifies the PDF bytes.
Limitation: PDF.js does NOT expose all annotation properties. Interior colors (IC), appearance stream colors, and certain opacity values are inaccessible through PDF.js alone.
Side B: pdf-lib (v1.17): Creation & Saving
pdf-lib is the write side. It handles:
- Loading existing PDF bytes for modification
- Creating blank PDF documents
- Writing app annotations as PDF annotation dictionaries
- Persisting form field values from PDF.js
AnnotationStorage - Applying page rotations
- Stripping and replacing managed annotation types
pdf-lib NEVER renders anything. It operates purely on the PDF object model.
The Bridge: originalBytesCache + Coordinate Transforms
The two libraries NEVER share parsed state. They parse the same raw bytes independently. The bridge consists of:
originalBytesCache— AMap<string, Uint8Array>holding raw PDF bytes. Loader writes bytes in; saver reads bytes out.- Coordinate transforms — Loading converts PDF coordinates to app coordinates; saving converts back.
- Color extraction — pdf-lib fills gaps where PDF.js cannot expose color data.
- Form field persistence — PDF.js
AnnotationStoragevalues flow into pdf-lib form fields at save time.
Critical Rules
Buffer Management
- ALWAYS call
.slice()on theUint8ArrayBEFORE passing it topdfjsLib.getDocument(). PDF.js transfers theArrayBufferto its web worker viaTransferable, which detaches the original buffer (makes it zero-length). Without.slice(), the cached bytes become empty and saving fails silently. - ALWAYS store the cloned bytes in
originalBytesCacheBEFORE callingpdfjsLib.getDocument(). - NEVER assume the
Uint8Arraypassed to PDF.js remains valid after thegetDocument()call. - ALWAYS update
originalBytesCacheafter a successful save with the new bytes frompdfDocLib.save().
Dual-Parse Architecture
- ALWAYS treat PDF.js and pdf-lib as fully independent parsers. They have zero shared state.
- NEVER pass PDF.js objects (pages, annotations, refs) to pdf-lib or vice versa.
- ALWAYS use
originalBytesCacheas the single bridge for raw bytes between the two libraries. - ALWAYS load pdf-lib lazily in the background via
getSharedPdfLibDoc()— it is NOT needed for first paint.
Coordinate Transforms
- ALWAYS use CropBox-based transforms when converting between app and PDF coordinates.
- NEVER assume MediaBox equals CropBox. Use
page.getCropBox()from pdf-lib. - ALWAYS flip the Y-axis when converting: PDF uses bottom-left origin; the app uses top-left origin.
- NEVER apply viewport scale during save — app annotations are stored at scale=1.
Annotation Flow
- ALWAYS strip existing handled annotation subtypes from the page's
/Annotsarray before writing new ones. The app is the source of truth for managed types. - NEVER strip annotation types the app does not manage (widgets, links, popups). These MUST be preserved.
- ALWAYS use the
handledSubtypesset to determine which annotations to strip vs. keep.
Color Extraction
- NEVER rely solely on PDF.js for annotation colors. Interior colors (
IC), appearance stream colors, and fill colors require pdf-lib extraction. - ALWAYS handle the case where pdf-lib is not yet loaded when colors are needed — queue the page in
doc._pagesNeedingColorUpdateand reprocess later.
Form Field Persistence
- ALWAYS read form field values from PDF.js
AnnotationStorageand write them to pdf-lib form fields during save. - NEVER skip the
fieldNameMaplookup — PDF.js annotation IDs do NOT match pdf-lib field names directly.
Coordinate System Reference
Three Coordinate Systems
1. PDF Coordinates (in the PDF file)
- Origin: bottom-left of CropBox
- Units: points (1/72 inch)
- Y increases upward
- Used by: pdf-lib when reading/writing annotation Rect arrays
2. Viewport Coordinates (PDF.js rendering)
- Origin: top-left of rendered page
- Units: pixels (scaled by viewport scale factor)
- Y increases downward
- Used by: PDF.js page.getViewport(), convertToViewportPoint()
3. App Annotation Coordinates (state.annotations)
- Origin: top-left of page
- Units: pixels at scale=1 (unscaled)
- Y increases downward
- Used by: all annotation objects in doc.annotations[]
Transform Formulas
Loading (PDF to App) — in annotation-converter.js:
// PDF.js viewport handles the transform at scale=1
const viewport = page.getViewport({ scale: 1 });
const [appX, appY] = viewport.convertToViewportPoint(pdfX, pdfY);
const [x1, y1, x2, y2] = viewport.convertToViewportRectangle(pdfRect);
Saving (App to PDF) — in saver.js:
const cropBox = page.getCropBox();
const viewLeft = cropBox.x;
const viewTop = cropBox.y + cropBox.height;
const convertX = (canvasX) => canvasX + viewLeft;
const convertY = (canvasY) => viewTop - canvasY; // Y-axis flip
Coordinate Example
For a PDF with CropBox {x: 0, y: 0, width: 612, height: 792}:
- App annotation at
(100, 200)maps to PDF point(100, 592)— because792 - 200 = 592 - PDF annotation at
(100, 592)maps to app point(100, 200)— because792 - 592 = 200
Data Flow: Open PDF
File on disk
|
v
readBinaryFile(filePath) <-- Tauri FS plugin
|
v
typedArray = new Uint8Array(data)
|
+---> originalBytesCache.set(filePath, typedArray.slice())
| ^^^ CRITICAL: .slice() clones before PDF.js detaches buffer
|
+---> pdfjsLib.getDocument({ data: typedArray })
| --> doc.pdfDoc (rendering, text layers, annotation parsing)
|
+---> PDFDocument.load(pdfBytes) <-- Background, non-blocking
--> doc._sharedPdfLibDoc (color extraction only)
After first paint, annotations load per-page:
page.getAnnotations() <-- PDF.js: raw annotation objects
+
extractAnnotationColors(pageNum, pdfLibDoc) <-- pdf-lib: fill color gaps
+
extractStampImagesViaPdfJs(page, viewport) <-- PDF.js: render stamp regions
|
v
convertPdfAnnotation() <-- Merge into app model
|
v
doc.annotations.push(converted)
Data Flow: Save PDF
state.annotations (source of truth)
|
v
PDFDocument.load(existingPdfBytes) <-- from originalBytesCache
|
+-- Strip existing handled annotations from each page /Annots array
| (handledSubtypes: Highlight, Square, Circle, Line, Ink, etc.)
| Keep: Widget, Link, Popup, FileAttachment, etc.
|
+-- For each app annotation: convert to PDF annotation dict
| - convertX(canvasX) = canvasX + cropBox.x
| - convertY(canvasY) = (cropBox.y + cropBox.height) - canvasY
| - Build Rect, QuadPoints, AP streams as needed
|
+-- Persist form field values
| PDF.js AnnotationStorage --> pdf-lib form.getField() --> setText/check/select
|
+-- Apply page rotations (combine with existing PDF rotation)
|
v
pdfDocLib.save() --> Uint8Array --> writeBinaryFile()
|
v
originalBytesCache.set(filePath, newBytes) <-- Update cache for next save
Essential Patterns
Pattern 1: Safe Buffer Caching
// CORRECT: Clone before PDF.js consumes the buffer
const typedArray = new Uint8Array(data);
originalBytesCache.set(filePath, typedArray.slice()); // Clone first
const pdfDoc = await pdfjsLib.getDocument({ data: typedArray }).promise;
// WRONG: Cache after PDF.js — buffer is already detached
const pdfDoc = await pdfjsLib.getDocument({ data: typedArray }).promise;
originalBytesCache.set(filePath, typedArray); // typedArray.length === 0 here!
Pattern 2: Async Color Fallback Queue
// When pdf-lib is not ready during on-demand page load:
if (pdfLibDoc) {
annotColorMap = await extractAnnotationColors(pageNum, pdfLibDoc);
} else {
doc._pagesNeedingColorUpdate.add(pageNum); // Reprocess later
}
// After background loading completes, reprocess queued pages:
for (const pageNum of doc._pagesNeedingColorUpdate) {
// Remove old annotations, reload with full color data
doc.annotations = doc.annotations.filter(a => a.page !== pageNum);
// ... re-extract with pdfLibDoc available
}
Pattern 3: Staleness Guard for Async Operations
const loadId = ++doc._annotationLoadId;
// ... async work ...
if (loadId !== doc._annotationLoadId) return; // Document was reloaded
if (!state.documents.includes(doc)) return; // Document was closed
ALWAYS check both conditions after every await boundary.
Pattern 4: Form Field Value Round-Trip
// SAVE: PDF.js AnnotationStorage --> pdf-lib form fields
const storage = getAnnotationStorage();
const fieldNameMap = getAnnotIdToFieldName();
for (const [annotId, fieldName] of fieldNameMap.entries()) {
const storedValue = storage.getRawValue(annotId);
if (storedValue === undefined) continue;
const field = form.getField(fieldName);
if (field instanceof PDFTextField) {
field.setText(String(storedValue.value));
}
// ... handle PDFCheckBox, PDFDropdown, PDFRadioGroup, PDFOptionList
}
Key Files
| File | Role |
|---|---|
js/pdf/loader.js |
PDF loading orchestrator; manages originalBytesCache, PDF.js + pdf-lib instances |
js/pdf/loader/annotation-converter.js |
Converts PDF.js annotations to app model (PDF coords to app coords) |
js/pdf/loader/color-extraction.js |
Uses pdf-lib to extract colors PDF.js cannot expose |
js/pdf/loader/image-extraction.js |
Extracts stamp images via PDF.js rendering fallback |
js/pdf/saver.js |
Converts app annotations to PDF dicts via pdf-lib (app coords to PDF coords) |
js/pdf/renderer.js |
PDF.js page rendering to canvas; manages text/link/form layers |
js/pdf/form-layer.js |
PDF.js AnnotationStorage management for form field values |
js/annotations/rendering.js |
Draws app annotations on overlay canvas (Canvas2D, no PDF library) |