# Pdfstudio Errors PDF Corruption

> Use when debugging PDF corruption, broken annotations, or save failures in open-pdf-studio. Provides a diagnostic decision tree for common failures: buffer detachment, coordinate misalignment, lost annotations, form field corruption, and file locking errors. Keywords: PDF corruption, save failure, buffer detachment, annotation lost, coordinate error, form field, file lock, originalBytesCache, CropBox, saved PDF broken, annotations disappeared, PDF unreadable after save.

- Skill: `impertio-studio/pdfstudio-errors-pdf-corruption` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add impertio-studio/pdfstudio-errors-pdf-corruption`
- Raw SKILL.md: https://api.skillmd.com/api/skills/impertio-studio/pdfstudio-errors-pdf-corruption/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-errors-pdf-corruption

---


# PDF Corruption and Save Failure Diagnostics

## Architecture Context

Open PDF Studio uses TWO independent PDF libraries that NEVER share parsed state:

| Library | Role | Key Operations |
|---------|------|----------------|
| **PDF.js** (pdfjs-dist 5.4) | Read-only rendering, text extraction, annotation parsing, form field display | `getDocument()`, `getAnnotations()`, `render()` |
| **pdf-lib** (1.17) | PDF creation, modification, saving with annotations | `PDFDocument.load()`, `.save()`, form field persistence |

The **bridge** between them is `originalBytesCache` — a `Map<filePath, Uint8Array>` holding raw PDF bytes that pdf-lib reads during save.

### Key Files for Debugging

| File | Purpose |
|------|---------|
| `js/pdf/loader.js` | PDF loading orchestrator, `originalBytesCache`, dual-library init |
| `js/pdf/saver.js` | Save pipeline: loads from cache, applies annotations, writes to disk |
| `js/pdf/loader/annotation-converter.js` | PDF.js annotations to app model (coordinate conversion) |
| `js/pdf/loader/color-extraction.js` | pdf-lib color extraction (async, races with annotation loading) |
| `js/pdf/renderer.js` | PDF.js page rendering to canvas |
| `js/annotations/rendering.js` | App annotation overlay drawing (Canvas2D) |
| `js/core/platform.js` | Tauri FS wrapper, file locking |

## Diagnostic Decision Tree

```
SYMPTOM: What do you observe?
│
├─► Saved PDF is blank or 0 bytes
│   → Go to: ISSUE 1 (Buffer Detachment)
│
├─► Annotations appear in wrong position after save
│   → Go to: ISSUE 2 (Coordinate Misalignment)
│
├─► Annotations visible on screen but missing in saved file
│   → Go to: ISSUE 3 (Lost Annotations)
│
├─► Form field values not preserved after save
│   → Go to: ISSUE 4 (Form Field Corruption)
│
├─► Save operation fails with error
│   → Go to: ISSUE 5 (File Locking / Write Errors)
│
├─► Application uses excessive memory with large PDFs
│   → Go to: ISSUE 6 (Dual Parse Memory Pressure)
│
└─► Annotation colors wrong or missing after load
    → Go to: ISSUE 7 (Async Color Extraction Race)
```

---

## ISSUE 1: Buffer Detachment (Blank PDF After Save)

### Root Cause

PDF.js uses `Transferable` to send the `ArrayBuffer` to its web worker. After transfer, the original `Uint8Array` becomes zero-length (`.byteLength === 0`). If `originalBytesCache` stores a reference to the transferred buffer instead of a clone, the save operation reads zero bytes.

### Where It Happens

```javascript
// js/pdf/loader.js
originalBytesCache.set(filePath, typedArray.slice());  // .slice() creates the clone
```

The `.slice()` call is the critical safeguard. It creates an independent copy BEFORE PDF.js transfers the original buffer.

### Symptoms

- Saved file is 0 bytes or contains only PDF headers
- `getCachedPdfBytes()` returns a `Uint8Array` with `byteLength === 0`
- `PDFDocument.load()` throws "no PDF header found" or similar

### Diagnostic Steps

1. Check that `originalBytesCache.set()` uses `.slice()` — NEVER pass the original `typedArray` directly
2. Verify the cache is populated BEFORE `pdfjsLib.getDocument({ data: typedArray })` is called
3. Check that nothing else calls `.set()` with a reference to a buffer that gets transferred

### Fix

ALWAYS clone bytes before caching:
```javascript
originalBytesCache.set(filePath, typedArray.slice());
```

NEVER do this:
```javascript
originalBytesCache.set(filePath, typedArray);  // WILL be detached by PDF.js worker
```

---

## ISSUE 2: Coordinate Misalignment (Annotations in Wrong Position)

### Root Cause

Three coordinate systems interact, and conversion errors cause annotations to appear shifted, flipped, or scaled incorrectly:

1. **PDF coordinates**: Bottom-left origin, measured in points (1 point = 1/72 inch)
2. **Viewport coordinates**: Top-left origin, scaled by zoom factor (from `page.getViewport({ scale })`)
3. **App annotation coordinates**: Top-left origin, scale=1 (stored in `state.annotations`)

### Where Conversion Happens

**Loading** (PDF to App) — `js/pdf/loader/annotation-converter.js`:
```javascript
const convertPoint = (pdfX, pdfY) => viewport.convertToViewportPoint(pdfX, pdfY);
```

**Saving** (App to PDF) — `js/pdf/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
```

### Symptoms

- Annotations appear shifted horizontally or vertically after save and reload
- Annotations are mirrored (Y-axis flip error)
- Annotations correct on-screen but wrong when opened in another PDF viewer
- Position errors scale with page zoom level

### Diagnostic Steps

1. **Check CropBox**: Does the page have a non-zero CropBox origin? `page.getCropBox()` returns `{ x, y, width, height }`. If `x` or `y` is non-zero, the offset calculation must account for it.
2. **Check page rotation**: Rotated pages (90, 180, 270 degrees) change which axis is which. The saver handles rotation via `getPageRotation()`.
3. **Check the fuzzy matching**: `annotation-converter.js` uses a fuzzy rect match (tolerance of 8 points) because PDF.js may expand rects by border width. If annotations are nearly but not exactly aligned, this is the cause.
4. **Compare viewport scale**: The save path assumes annotations at scale=1. If annotations were stored at a different scale, the coordinates will be wrong.

### Fix

ALWAYS use `CropBox` (not `MediaBox`) for coordinate conversion. The CropBox defines the visible area:
```javascript
const cropBox = page.getCropBox();
// NOT page.getMediaBox()
```

---

## ISSUE 3: Lost Annotations (Visible On-Screen But Missing After Save)

### Root Cause

The save pipeline in `saver.js` converts app annotations to PDF annotation dictionaries. If an annotation type is not handled by the saver, it is silently dropped.

### Where It Happens

```javascript
// js/pdf/saver.js — annotation type dispatch
// Only types explicitly handled are saved. Unknown types are skipped.
```

The saver also strips existing annotations of handled types from each page's `/Annots` array before writing new ones. If the stripping logic removes an annotation but the writing logic fails to recreate it, the annotation is lost.

### Symptoms

- Annotations visible in the app but not present when file is opened in another viewer
- Specific annotation types disappear (e.g., stamps, custom types)
- Annotations on one page survive, but another page's annotations vanish

### Diagnostic Steps

1. **Check annotation type support**: Verify the annotation type is handled in `saver.js`. Look for the type string (e.g., `'highlight'`, `'square'`, `'textbox'`) in the save function's type dispatch.
2. **Check the strip logic**: The saver strips existing annotations by subtype (`/Highlight`, `/Square`, etc.) before adding app annotations. If a new type was added to the strip list but not the write list, annotations of that type are deleted without replacement.
3. **Check `state.annotations` content**: Confirm the annotation exists in the state at save time. Timing issues (e.g., save triggered before annotation is fully committed to state) can cause missing annotations.

### Fix

When adding a new annotation type:
1. Add rendering support in `annotations/rendering.js`
2. Add save support in `pdf/saver.js` (create PDF annotation dict)
3. Add the PDF subtype to the strip list in `saver.js` ONLY if the type is also fully handled in the write logic

---

## ISSUE 4: Form Field Corruption

### Root Cause

Form field values are persisted through a two-step process:
1. PDF.js manages an `AnnotationStorage` for interactive form editing
2. On save, `saver.js` reads `AnnotationStorage` values and writes them to pdf-lib form fields

Field types (PDFTextField, PDFCheckBox, PDFDropdown, PDFRadioGroup, PDFOptionList) each require different handling. Type mismatches cause silent failures.

### Where It Happens

```javascript
// js/pdf/saver.js
const storage = getAnnotationStorage();
const fieldNameMap = getAnnotIdToFieldName();
// ... maps PDF.js annotation IDs to pdf-lib field names
```

### Symptoms

- Text field values revert to original after save
- Checkboxes uncheck themselves
- Dropdown selections not preserved
- Form appears read-only after save in other viewers

### Diagnostic Steps

1. **Check AnnotationStorage population**: Is `storage.size > 0`? If the user edited fields but storage is empty, the form-layer is not tracking changes.
2. **Check fieldNameMap**: Is `fieldNameMap.size > 0`? This maps PDF.js annotation IDs to pdf-lib field names. If the map is empty, the field names were never resolved.
3. **Check field type matching**: The saver uses `instanceof` checks. If a field returns the wrong type, the value write is silently skipped.
4. **Check for read-only fields**: Fields marked read-only in the PDF will throw when `setText()` or `check()` is called. The saver catches and ignores these errors.

### Fix

ALWAYS verify the mapping chain: `PDF.js annotationId` to `fieldName` to `pdf-lib field instance`. A break anywhere in this chain causes silent data loss.

---

## ISSUE 5: File Locking / Write Errors

### Root Cause

The app uses Tauri Rust commands for file locking:
1. `lock_file(path)` — Acquires a shared-read-only lock (other apps can read but not write)
2. `unlock_file(path)` — Releases the lock
3. Save sequence: unlock, write, re-lock

### Where It Happens

```javascript
// js/pdf/saver.js — save sequence
await unlockFile(filePath);          // Release lock
await writeBinaryFile(filePath, pdfBytes);  // Write new content
await lockFile(filePath);            // Re-acquire lock
```

### Symptoms

- "Failed to write file" error on save
- File is read-only after a crash (lock not released)
- Save succeeds but file content is from before the edit
- "Access denied" errors

### Diagnostic Steps

1. **Check file lock state**: If the app crashed, the Rust process may have released the lock (process exit cleans up), but Windows may still hold the file. Check Task Manager for orphan processes.
2. **Check write permissions**: The target directory may be read-only (e.g., Program Files, system directories).
3. **Check disk space**: `writeBinaryFile` will fail silently or with a generic error on full disks.
4. **Check concurrent access**: Another application (antivirus, cloud sync) may be holding the file.

### Fix

After a crash, restart the application. The Rust file lock is tied to the process — when the process dies, the OS releases the lock. If the file is still locked, another process is holding it.

---

## ISSUE 6: Dual Parse Memory Pressure

### Root Cause

Every opened PDF is parsed independently by BOTH PDF.js and pdf-lib:
- PDF.js: Renders pages, extracts text, parses annotations
- pdf-lib: Extracts colors, used for save operations

Plus `originalBytesCache` holds a copy of the raw bytes. For a 50MB PDF, this means approximately 150MB of memory per document (raw bytes + PDF.js parsed DOM + pdf-lib parsed objects).

### Where It Happens

```javascript
// js/pdf/loader.js
originalBytesCache.set(filePath, typedArray.slice());    // Copy 1: raw bytes
const pdfDoc = await pdfjsLib.getDocument({ data: typedArray }); // Copy 2: PDF.js
const pdfLibDoc = await PDFDocument.load(pdfBytes);      // Copy 3: pdf-lib
```

### Symptoms

- Browser/WebView crashes on large PDFs (100MB+)
- Slow performance after opening multiple documents
- "Out of memory" errors in DevTools console
- Tab becoming unresponsive

### Diagnostic Steps

1. **Check document count**: How many PDFs are open simultaneously? Each one triples its file size in memory.
2. **Check `_sharedPdfLibDoc`**: Is it being retained after it is no longer needed? The pdf-lib doc is cached on the document object.
3. **Check `originalBytesCache`**: Is it cleaned up when documents are closed? Call `clearCachedPdfBytes(filePath)` on document close.

### Mitigation

There is no architectural fix short of removing the dual-library approach. Mitigate by:
- Closing unused documents to free their caches
- Avoiding opening many large PDFs simultaneously

---

## ISSUE 7: Async Color Extraction Race Condition

### Root Cause

Color extraction via pdf-lib runs in parallel with PDF.js annotation loading. If pdf-lib is not ready when annotations are loaded, colors are missing. The app queues these pages in `doc._pagesNeedingColorUpdate` for later reprocessing.

### Where It Happens

```javascript
// js/pdf/loader.js
// pdf-lib doc may not be ready when annotations are first loaded
// Pages are queued in doc._pagesNeedingColorUpdate
```

### Symptoms

- Annotations load with default/wrong colors, then suddenly correct themselves
- Some pages have correct colors, others do not (timing-dependent)
- Colors correct after manual page navigation (triggers reprocessing)

### Diagnostic Steps

1. **Check `_sharedPdfLibDoc` readiness**: Is the pdf-lib document loaded when `extractAnnotationColors()` is called?
2. **Check `_pagesNeedingColorUpdate`**: Are queued pages being reprocessed after pdf-lib loads?
3. **Check `loadId` staleness**: The loader uses `loadId` to detect if the document was reloaded. A stale `loadId` causes the color update to be silently aborted.

### Fix

ALWAYS check `isClosed()` and `loadId` after every `await` in the annotation loading pipeline. The staleness check pattern:
```javascript
const loadId = ++doc._annotationLoadId;
// ... async work ...
if (loadId !== doc._annotationLoadId) return;  // Stale — abort
```

---

## Quick Reference: Error to Root Cause

| Error Message / Symptom | Most Likely Cause | File to Check |
|--------------------------|-------------------|---------------|
| Saved PDF is blank | Buffer detachment | `loader.js` — `.slice()` call |
| "No PDF header found" | Empty bytes in cache | `loader.js` — `originalBytesCache` |
| Annotations shifted | CropBox offset ignored | `saver.js` — `getCropBox()` |
| Annotations mirrored | Y-axis flip error | `saver.js` — `convertY()` |
| Annotations missing after save | Type not handled in saver | `saver.js` — type dispatch |
| Form values lost | AnnotationStorage empty | `saver.js` + `form-layer.js` |
| File write error | Lock not released | `platform.js` — `unlockFile()` |
| High memory usage | Dual parse + byte cache | `loader.js` — 3x memory per doc |
| Wrong annotation colors | pdf-lib not ready | `color-extraction.js` |
| Fuzzy rect match failures | PDF.js border expansion | `annotation-converter.js` |

