try: readonly(registry),
ingestFiles
}
}
**Architecture Rationale:** We store both `rawBuffer` and `parsedDoc` separately. `pdf-lib` mutates internal structures during parsing, so keeping the original buffer allows safe re-initialization if a user removes a file and re-adds it. The `registryId` decouples UI state from file metadata, preventing cross-contamination when the same file is uploaded multiple times.
### 2. Visual Reordering & Thumbnail Generation
`pdf-lib` manipulates PDF structures but cannot rasterize pages. Thumbnails require a separate rendering pipeline. We use `pdfjs-dist` for preview generation and flatten the document registry into a unified composition queue.
```typescript
import { ref } from 'vue'
import * as pdfjsLib from 'pdfjs-dist'
export interface CompositionSlot {
slotId: string
sourceRegistryId: string
pageIndex: number
previewUrl: string | null
}
export function usePdfComposer(registry: ReturnType<typeof usePdfRegistry>['registry']) {
const compositionQueue = ref<CompositionSlot[]>([])
async function generatePreviews() {
compositionQueue.value = []
for (const doc of registry.value) {
for (let idx = 0; idx < doc.pageCount; idx++) {
const previewUrl = await renderPagePreview(doc.rawBuffer, idx)
compositionQueue.value.push({
slotId: `${doc.registryId}-${idx}`,
sourceRegistryId: doc.registryId,
pageIndex: idx,
previewUrl
})
}
}
}
async function renderPagePreview(buffer: ArrayBuffer, pageIndex: number): Promise<string | null> {
try {
const pdf = await pdfjsLib.getDocument({ data: buffer }).promise
const page = await pdf.getPage(pageIndex + 1)
const viewport = page.getViewport({ scale: 0.3 })
const canvas = document.createElement('canvas')
canvas.width = viewport.width
canvas.height = viewport.height
const ctx = canvas.getContext('2d')
await page.render({ canvasContext: ctx!, viewport }).promise
return canvas.toDataURL('image/png')
} catch {
return null
}
}
return { compositionQueue, generatePreviews }
}
Architecture Rationale: Thumbnail generation runs asynchronously and independently of the merge logic. We use a low scale factor (0.3) to minimize memory pressure. The compositionQueue flattens multi-document structures into a single draggable array, enabling cross-document page reordering without complex nested state.
3. Binary Composition & Export
Once the queue is finalized, we construct a new PDF document and copy pages in the exact order defined by the UI. pdf-lib's copyPages method preserves vector data, fonts, and original page dimensions without rasterization.
import { PDFDocument } from 'pdf-lib'
export async function assembleComposition(
registry: ReturnType<typeof usePdfRegistry>['registry'],
queue: ReturnType<typeof usePdfComposer>['compositionQueue']
): Promise<Blob> {
const outputDoc = await PDFDocument.create()
for (const slot of queue.value) {
const source = registry.value.find(d => d.registryId === slot.sourceRegistryId)
if (!source) continue
const [copiedPage] = await outputDoc.copyPages(source.parsedDoc, [slot.pageIndex])
outputDoc.addPage(copiedPage)
}
const serialized = await outputDoc.save()
return new Blob([serialized], { type: 'application/pdf' })
}
export function triggerFileDownload(blob: Blob, fileName: string) {
const objectUrl = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = objectUrl
anchor.download = fileName
anchor.style.display = 'none'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(objectUrl)
}
Architecture Rationale: copyPages is the critical operation. It extracts page dictionaries and content streams directly, avoiding quality loss from re-rendering. The download helper uses a temporary anchor element to trigger the native save dialog, then immediately revokes the object URL to prevent memory leaks.
Pitfall Guide
1. Memory Exhaustion on Large Documents
Explanation: pdf-lib loads entire PDFs into RAM. A 100MB scanned document can consume 300β500MB of heap space during parsing. Browsers may terminate the tab if memory limits are exceeded.
Fix: Implement a memory usage estimator based on file size. Warn users when total queue size exceeds 150MB. Provide a "Clear All" action that explicitly nullifies references and calls performance.memory (in Chromium) to verify garbage collection.
Explanation: copyPages extracts visual content and page dictionaries, but interactive form fields (AcroForms) are often flattened into static content. The resulting PDF loses input capabilities.
Fix: If form preservation is required, use PDFDocument.copyDocument() instead of page-level copying, or explicitly document the limitation. For hybrid workflows, extract form data with pdf-lib's form API before merging and reapply it post-assembly.
3. Inconsistent Page Dimensions
Explanation: Source PDFs frequently mix A4, Letter, and landscape orientations. Assuming uniform canvas sizes breaks thumbnail rendering and causes layout shifts in the UI.
Fix: Never force a fixed viewport scale. Use page.getMediaBox() to retrieve original dimensions. In the UI, render thumbnails with object-fit: contain and display the actual dimensions as metadata.
4. Mutable State Corruption
Explanation: Reusing PDFDocument instances across multiple uploads or drag operations causes cross-contamination. Mutating one document inadvertently affects others sharing the same reference.
Fix: Treat all parsedDoc instances as immutable after ingestion. Never modify them in place. If transformations are needed, clone the document first using PDFDocument.load(doc.save()) or maintain a strict read-only policy until the final assembly step.
5. Main Thread Blocking During Preview Generation
Explanation: Synchronous Canvas rendering for dozens of pages blocks the UI thread, causing dropped frames and unresponsive drag interactions.
Fix: Batch thumbnail generation using requestIdleCallback or offload to a Web Worker. Render previews incrementally as the user scrolls, rather than generating all at once.
6. Blob URL Memory Leaks
Explanation: Forgetting to call URL.revokeObjectURL() after triggering a download leaves object references in memory. Repeated merges accumulate detached blobs.
Fix: Always pair createObjectURL with revokeObjectURL in a finally block or immediately after the click event. Use a wrapper function that guarantees cleanup.
7. MIME Type Bypass & Invalid Files
Explanation: Users can rename arbitrary files to .pdf and bypass client-side validation. pdf-lib will throw parsing errors that crash the UI if unhandled.
Fix: Validate file.type === 'application/pdf' and verify the first 5 bytes contain %PDF-. Wrap PDFDocument.load() in a try/catch that surfaces user-friendly errors instead of stack traces.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Privacy-critical documents (contracts, medical) | Client-Side Merge | Zero data leaves the user's device; eliminates compliance overhead | Infrastructure cost drops to $0 |
| High-volume batch processing (>50 files) | Server-Side Queue | Client RAM becomes a bottleneck; server scales horizontally | Cloud compute/storage costs increase |
| Mixed formats (PDF + images + DOCX) | Hybrid Pipeline | Client handles PDF; server converts other formats via LibreOffice | Moderate infrastructure cost |
| Low-end devices / Mobile browsers | Client-Side with Chunking | Prevents tab crashes; degrades gracefully with progress indicators | No infrastructure cost |
Configuration Template
// composables/usePdfComposer.ts
import { ref, readonly, computed } from 'vue'
import { PDFDocument } from 'pdf-lib'
import * as pdfjsLib from 'pdfjs-dist'
export interface DocumentEntry {
entryId: string
fileName: string
pageCount: number
buffer: ArrayBuffer
instance: PDFDocument
}
export interface PageSlot {
slotId: string
entryId: string
index: number
thumbnail: string | null
}
export function usePdfComposer() {
const entries = ref<DocumentEntry[]>([])
const slots = ref<PageSlot[]>([])
const isProcessing = ref(false)
const totalPageCount = computed(() => slots.value.length)
const estimatedMemoryMB = computed(() => {
return entries.value.reduce((acc, e) => acc + e.buffer.byteLength, 0) / 1024 / 1024
})
async function loadFiles(files: FileList) {
isProcessing.value = true
try {
for (const file of Array.from(files)) {
if (file.type !== 'application/pdf') continue
const buf = await file.arrayBuffer()
const doc = await PDFDocument.load(buf)
entries.value.push({
entryId: crypto.randomUUID(),
fileName: file.name,
pageCount: doc.getPageCount(),
buffer: buf,
instance: doc
})
}
await rebuildSlots()
} finally {
isProcessing.value = false
}
}
async function rebuildSlots() {
slots.value = []
for (const entry of entries.value) {
for (let i = 0; i < entry.pageCount; i++) {
const thumb = await generateThumbnail(entry.buffer, i)
slots.value.push({
slotId: `${entry.entryId}-${i}`,
entryId: entry.entryId,
index: i,
thumbnail: thumb
})
}
}
}
async function generateThumbnail(buffer: ArrayBuffer, pageIndex: number): Promise<string | null> {
try {
const pdf = await pdfjsLib.getDocument({ data: buffer }).promise
const page = await pdf.getPage(pageIndex + 1)
const vp = page.getViewport({ scale: 0.25 })
const c = document.createElement('canvas')
c.width = vp.width
c.height = vp.height
const ctx = c.getContext('2d')!
await page.render({ canvasContext: ctx, viewport: vp }).promise
return c.toDataURL('image/png')
} catch {
return null
}
}
async function exportMergedPdf(filename: string = 'merged.pdf'): Promise<void> {
isProcessing.value = true
try {
const output = await PDFDocument.create()
for (const slot of slots.value) {
const source = entries.value.find(e => e.entryId === slot.entryId)
if (!source) continue
const [page] = await output.copyPages(source.instance, [slot.index])
output.addPage(page)
}
const bytes = await output.save()
const blob = new Blob([bytes], { type: 'application/pdf' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
} finally {
isProcessing.value = false
}
}
function clearAll() {
entries.value = []
slots.value = []
}
return {
entries: readonly(entries),
slots: readonly(slots),
isProcessing,
totalPageCount,
estimatedMemoryMB,
loadFiles,
exportMergedPdf,
clearAll
}
}
Quick Start Guide
- Install Dependencies: Run
npm install pdf-lib pdfjs-dist vue-draggable-next in your Vue 3 project.
- Mount the Composable: Import
usePdfComposer in your component and call it inside setup(). Destructure loadFiles, slots, exportMergedPdf, and clearAll.
- Wire the UI: Bind a file input to
loadFiles, render slots with vue-draggable-next, and attach exportMergedPdf to a primary action button.
- Test with Mixed Documents: Upload two PDFs with different page sizes and orientations. Verify that thumbnails render correctly, drag-and-drop reorders slots, and the exported file preserves original dimensions.
- Validate Memory Behavior: Open browser DevTools β Memory panel. Load a 40MB PDF, merge it, then call
clearAll(). Confirm that heap usage returns to baseline within 2β3 seconds.