| name | pdfstudio-errors-pdf-corruption |
| description | 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.
|
| license | MIT |
| compatibility | Designed for Claude Code. Specific to open-pdf-studio. |
| metadata | {"author":"OpenAEC-Foundation","version":"1.0"} |
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
originalBytesCache.set(filePath, typedArray.slice());
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
- Check that
originalBytesCache.set() uses .slice() — NEVER pass the original typedArray directly
- Verify the cache is populated BEFORE
pdfjsLib.getDocument({ data: typedArray }) is called
- Check that nothing else calls
.set() with a reference to a buffer that gets transferred
Fix
ALWAYS clone bytes before caching:
originalBytesCache.set(filePath, typedArray.slice());
NEVER do this:
originalBytesCache.set(filePath, typedArray);
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:
- PDF coordinates: Bottom-left origin, measured in points (1 point = 1/72 inch)
- Viewport coordinates: Top-left origin, scaled by zoom factor (from
page.getViewport({ scale }))
- 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:
const convertPoint = (pdfX, pdfY) => viewport.convertToViewportPoint(pdfX, pdfY);
Saving (App to PDF) — js/pdf/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;
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
- 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.
- Check page rotation: Rotated pages (90, 180, 270 degrees) change which axis is which. The saver handles rotation via
getPageRotation().
- 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.
- 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:
const cropBox = page.getCropBox();
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
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
- 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.
- 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.
- 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:
- Add rendering support in
annotations/rendering.js
- Add save support in
pdf/saver.js (create PDF annotation dict)
- 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:
- PDF.js manages an
AnnotationStorage for interactive form editing
- 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
const storage = getAnnotationStorage();
const fieldNameMap = getAnnotIdToFieldName();
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
- Check AnnotationStorage population: Is
storage.size > 0? If the user edited fields but storage is empty, the form-layer is not tracking changes.
- 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.
- Check field type matching: The saver uses
instanceof checks. If a field returns the wrong type, the value write is silently skipped.
- 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:
lock_file(path) — Acquires a shared-read-only lock (other apps can read but not write)
unlock_file(path) — Releases the lock
- Save sequence: unlock, write, re-lock
Where It Happens
await unlockFile(filePath);
await writeBinaryFile(filePath, pdfBytes);
await lockFile(filePath);
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
- 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.
- Check write permissions: The target directory may be read-only (e.g., Program Files, system directories).
- Check disk space:
writeBinaryFile will fail silently or with a generic error on full disks.
- 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
originalBytesCache.set(filePath, typedArray.slice());
const pdfDoc = await pdfjsLib.getDocument({ data: typedArray });
const pdfLibDoc = await PDFDocument.load(pdfBytes);
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
- Check document count: How many PDFs are open simultaneously? Each one triples its file size in memory.
- Check
_sharedPdfLibDoc: Is it being retained after it is no longer needed? The pdf-lib doc is cached on the document object.
- 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
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
- Check
_sharedPdfLibDoc readiness: Is the pdf-lib document loaded when extractAnnotationColors() is called?
- Check
_pagesNeedingColorUpdate: Are queued pages being reprocessed after pdf-lib loads?
- 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:
const loadId = ++doc._annotationLoadId;
if (loadId !== doc._annotationLoadId) return;
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 |