# Pdfstudio Core Pdfjs Pdflib Bridge

> Use when modifying PDF loading, saving, or annotation code in open-pdf-studio. Prevents the common mistake of breaking the originalBytesCache bridge between PDF.js (rendering) and pdf-lib (editing), or corrupting coordinate transforms between PDF coordinates, viewport coordinates, and app annotation coordinates. Covers dual-parse architecture, buffer management, color extraction bridge, and the three coordinate systems that interact during annotation save/load. Keywords: PDF.js, pdf-lib, originalBytesCache, annotation coordinates, CropBox, viewport, saver, loader, buffer detachment, dual parse, bridge between viewer and editor, coordinate mismatch, PDF save corrupted.

- Skill: `impertio-studio/pdfstudio-core-pdfjs-pdflib-bridge` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add impertio-studio/pdfstudio-core-pdfjs-pdflib-bridge`
- Raw SKILL.md: https://api.skillmd.com/api/skills/impertio-studio/pdfstudio-core-pdfjs-pdflib-bridge/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- License: MIT
- Author: Impertio-Studio (https://skillmd.com/u/impertio-studio)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/impertio-studio/pdfstudio-core-pdfjs-pdflib-bridge

---


# 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:

1. **`originalBytesCache`** — A `Map<string, Uint8Array>` holding raw PDF bytes. Loader writes bytes in; saver reads bytes out.
2. **Coordinate transforms** — Loading converts PDF coordinates to app coordinates; saving converts back.
3. **Color extraction** — pdf-lib fills gaps where PDF.js cannot expose color data.
4. **Form field persistence** — PDF.js `AnnotationStorage` values flow into pdf-lib form fields at save time.

---

## Critical Rules

### Buffer Management

- **ALWAYS** call `.slice()` on the `Uint8Array` BEFORE passing it to `pdfjsLib.getDocument()`. PDF.js transfers the `ArrayBuffer` to its web worker via `Transferable`, 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 `originalBytesCache` BEFORE calling `pdfjsLib.getDocument()`.
- **NEVER** assume the `Uint8Array` passed to PDF.js remains valid after the `getDocument()` call.
- **ALWAYS** update `originalBytesCache` after a successful save with the new bytes from `pdfDocLib.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 `originalBytesCache` as 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 `/Annots` array 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 `handledSubtypes` set 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._pagesNeedingColorUpdate` and reprocess later.

### Form Field Persistence

- **ALWAYS** read form field values from PDF.js `AnnotationStorage` and write them to pdf-lib form fields during save.
- **NEVER** skip the `fieldNameMap` lookup — 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`:
```javascript
// 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`:
```javascript
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)` — because `792 - 200 = 592`
- PDF annotation at `(100, 592)` maps to app point `(100, 200)` — because `792 - 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

```javascript
// 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

```javascript
// 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

```javascript
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

```javascript
// 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) |

---

## Reference Links

- [PDF.js API reference (Mozilla)](https://mozilla.github.io/pdf.js/api/)
- [pdf-lib documentation](https://pdf-lib.js.org/)
- [PDF specification — Annotation types (ISO 32000-2)](https://pdfa.org/resource/iso-32000-2/)
- [PDF coordinate system explained](https://www.prepressure.com/pdf/basics/coordinate-system)

