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.
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
// 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
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
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();
// 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
// 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
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
// js/pdf/saver.jsconst 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
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
// js/pdf/saver.js — save sequenceawaitunlockFile(filePath); // Release lockawaitwriteBinaryFile(filePath, pdfBytes); // Write new contentawaitlockFile(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
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-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).
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
// 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
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;
// ... async work ...if (loadId !== doc._annotationLoadId) return; // Stale — abort