xt and scanning for PDF spec markers provides a lightweight security heuristic without requiring a full parser.
4. updateMetadata: false: Prevents accidental metadata writes during load. This is critical for compliance workflows where document provenance must remain untouched.
Implementation
// composables/usePdfValidator.ts
import { ref, type Ref } from 'vue'
import { PDFDocument } from 'pdf-lib'
export interface ValidationRule {
id: string
status: 'pass' | 'warn' | 'fail'
label: string
detail: string
}
export interface ValidationConfig {
maxFileSizeMB: number
warnThresholdPercent: number
}
export function usePdfValidator(config: ValidationConfig) {
const isProcessing: Ref<boolean> = ref(false)
const results: Ref<ValidationRule[]> = ref([])
async function execute(file: File): Promise<ValidationRule[]> {
isProcessing.value = true
results.value = []
try {
const buffer = await file.arrayBuffer()
const doc = await PDFDocument.load(buffer, {
ignoreEncryption: true,
updateMetadata: false
})
const rawFlags = scanBinaryMarkers(buffer)
const structuralChecks = runStructuralSuite(file, doc, config)
results.value = [...structuralChecks, ...rawFlags]
return results.value
} catch (err) {
results.value = [{
id: 'parse_failure',
status: 'fail',
label: 'Document unreadable',
detail: 'The file is corrupted, uses unsupported encryption, or lacks valid PDF headers.'
}]
return results.value
} finally {
isProcessing.value = false
}
}
function runStructuralSuite(file: File, doc: PDFDocument, cfg: ValidationConfig): ValidationRule[] {
const sizeMB = file.size / (1024 * 1024)
const pageCount = doc.getPageCount()
const dimensions = doc.getPages().map(p => {
const { width, height } = p.getSize()
return `${Math.round(width)}x${Math.round(height)}`
})
const uniqueDims = new Set(dimensions)
const formCount = doc.getForm()?.getFields()?.length ?? 0
return [
evaluateSize(sizeMB, cfg),
evaluateEncryption(doc),
evaluatePages(pageCount),
evaluateForms(formCount),
evaluateDimensions(uniqueDims.size)
]
}
function evaluateSize(mb: number, cfg: ValidationConfig): ValidationRule {
const threshold = cfg.maxFileSizeMB * (cfg.warnThresholdPercent / 100)
if (mb > cfg.maxFileSizeMB) {
return { id: 'size_limit', status: 'fail', label: 'Exceeds upload cap', detail: `File is ${mb.toFixed(1)} MB. Maximum allowed: ${cfg.maxFileSizeMB} MB.` }
}
if (mb > threshold) {
return { id: 'size_warning', status: 'warn', label: 'Approaching limit', detail: `Consumes >${cfg.warnThresholdPercent}% of the ${cfg.maxFileSizeMB} MB allowance. Compression recommended.` }
}
return { id: 'size_ok', status: 'pass', label: 'Within size bounds', detail: `File size (${mb.toFixed(1)} MB) is compliant.` }
}
function evaluateEncryption(doc: PDFDocument): ValidationRule {
const isLocked = doc.isEncrypted ?? false
return isLocked
? { id: 'encryption_flag', status: 'fail', label: 'Protected document', detail: 'Password-protected or encrypted PDFs are rejected by most ingestion pipelines.' }
: { id: 'encryption_clear', status: 'pass', label: 'No encryption detected', detail: 'Document structure is accessible.' }
}
function evaluatePages(count: number): ValidationRule {
return count < 1
? { id: 'page_empty', status: 'fail', label: 'Zero pages', detail: 'No valid page objects found in the document tree.' }
: { id: 'page_count', status: 'pass', label: 'Page count valid', detail: `${count} page(s) detected.` }
}
function evaluateForms(count: number): ValidationRule {
return count > 0
? { id: 'form_layers', status: 'warn', label: 'Interactive fields present', detail: 'Form annotations may render inconsistently across viewers. Flatten if static output is required.' }
: { id: 'form_clear', status: 'pass', label: 'No form fields', detail: 'Document contains no interactive annotation layers.' }
}
function evaluateDimensions(uniqueCount: number): ValidationRule {
return uniqueCount > 1
? { id: 'mixed_sizes', status: 'warn', label: 'Inconsistent page dimensions', detail: 'Varying page sizes can break layout engines. Standardize to a single format before submission.' }
: { id: 'size_uniform', status: 'pass', label: 'Uniform page size', detail: 'All pages share identical dimensions.' }
}
function scanBinaryMarkers(buffer: ArrayBuffer): ValidationRule[] {
const text = new TextDecoder().decode(buffer)
const patterns = {
js: /\/JS\b|\\/JavaScript\b/i,
launch: /\\/Launch\b/i,
openAction: /\\/OpenAction\b/i,
embedded: /\\/EmbeddedFiles\b/i,
uri: /\\/URI\b|\\/URI\s*\(/i,
metadata: /\\/Type\\/Metadata|\\/Metadata\b/i
}
return Object.entries(patterns).map(([key, regex]) => {
const detected = regex.test(text)
return {
id: `binary_${key}`,
status: detected ? 'warn' : 'pass',
label: detected ? `${key.toUpperCase()} marker found` : `No ${key.toUpperCase()} marker`,
detail: detected ? `Binary scan detected ${key} operator. Verify intent before upload.` : `Clean.`
}
})
}
return { isProcessing, results, execute }
}
<!-- components/DocumentUploader.vue -->
<script setup lang="ts">
import { usePdfValidator } from '../composables/usePdfValidator'
const { isProcessing, results, execute } = usePdfValidator({
maxFileSizeMB: 10,
warnThresholdPercent: 80
})
async function onFileSelect(event: Event) {
const target = event.target as HTMLInputElement
const selectedFile = target.files?.[0]
if (selectedFile) {
await execute(selectedFile)
}
}
</script>
<template>
<div class="upload-container">
<input type="file" accept="application/pdf" @change="onFileSelect" :disabled="isProcessing" />
<div v-if="isProcessing" class="status-indicator">Analyzing structure...</div>
<ul v-else-if="results.length" class="validation-report">
<li v-for="rule in results" :key="rule.id" :class="rule.status">
<strong>{{ rule.label }}</strong>
<span>{{ rule.detail }}</span>
</li>
</ul>
</div>
</template>
Pitfall Guide
Client-side PDF validation introduces subtle engineering challenges. The following pitfalls are drawn from production deployments and highlight where implementations typically fail.
| Pitfall | Explanation | Fix |
|---|
| Memory Exhaustion on Large Files | pdf-lib loads the entire document into RAM. Files >50MB can trigger OOM crashes in browser environments, especially on mobile. | Enforce a hard size cap (e.g., 25MB) before calling PDFDocument.load(). Use file.size to reject oversized files immediately without parsing. |
| False Positives in Raw Byte Scanning | Regex on decoded text matches comments, unused streams, or non-executable PDF objects. A /JS string in a harmless comment triggers a security warning. | Context-aware scanning: verify markers appear outside % comment lines. Alternatively, treat raw scans as informational warnings, not hard failures. |
Misinterpreting ignoreEncryption | Developers assume this flag decrypts content. It only bypasses the encryption check, allowing structural reads. Encrypted payloads remain unreadable. | Explicitly document the limitation. If content extraction is required, route to a server-side decryption pipeline with proper key management. |
| Main Thread Blocking | Parsing and regex scanning run synchronously on the UI thread. Large documents freeze the interface, degrading UX. | Offload to a Web Worker. Pass ArrayBuffer via postMessage, run validation in the worker, and return results via onmessage. |
| Treating Client Validation as Security | Client-side checks are easily bypassed. Relying on them for security compliance creates a false sense of safety. | Maintain identical validation rules on the server. Client checks are for UX and bandwidth optimization, not security enforcement. |
| Ignoring PDF Version Compatibility | Older PDFs (1.2-1.4) may lack modern object structures. pdf-lib handles most, but edge cases cause silent failures. | Wrap PDFDocument.load() in a try/catch with fallback messaging. Log version headers when available for debugging. |
| Accidental Metadata Mutation | Forgetting updateMetadata: false causes pdf-lib to rewrite creation/modification timestamps during load. | Always set updateMetadata: false explicitly. Audit build configs to ensure the flag isn't stripped during minification. |
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-volume public upload portal | Client-side pre-check + Server validation | Cuts bandwidth waste, improves conversion, maintains security | Reduces egress costs by ~40-60% |
| Internal compliance workflow | Server-side only | Requires cryptographic verification, audit trails, and legal metadata preservation | Higher compute cost, lower risk |
| Mobile-first document capture | Client-side with Web Worker | Prevents UI freezes on constrained devices, respects data caps | Slight dev overhead, major UX gain |
| Legacy PDF ingestion pipeline | Hybrid (client warn, server reject) | Older formats may fail client parsing; server acts as fallback | Minimal infrastructure change |
Configuration Template
// config/pdfValidationRules.ts
export const DEFAULT_VALIDATION_PROFILE = {
maxFileSizeMB: 10,
warnThresholdPercent: 80,
allowedVersions: ['1.4', '1.5', '1.6', '1.7', '2.0'],
securityFlags: {
blockJavaScript: true,
blockLaunchActions: true,
warnEmbeddedFiles: true,
warnExternalURIs: true
},
ui: {
showWarnings: true,
autoRejectOnFail: true,
compressRecommendation: true
}
} as const
export type ValidationProfile = typeof DEFAULT_VALIDATION_PROFILE
Quick Start Guide
- Install dependencies:
npm install pdf-lib vue@latest
- Create the composable: Copy
usePdfValidator.ts into your composables/ directory. Adjust ValidationConfig to match your pipeline limits.
- Wire the input: Add a file input with
accept="application/pdf" and bind the @change event to execute(file).
- Render feedback: Map
results to a list component. Apply CSS classes based on rule.status (pass, warn, fail).
- Test edge cases: Upload encrypted PDFs, mixed-dimension documents, and files exceeding your size cap. Verify that
isProcessing toggles correctly and no unhandled exceptions bubble to the console.
Client-side PDF validation transforms upload friction into deterministic feedback. By leveraging pdf-lib for structural inspection and raw byte scanning for feature detection, you build a zero-latency gate that protects your infrastructure and respects user time. The architecture scales cleanly, remains framework-agnostic, and integrates seamlessly into modern ingestion pipelines without compromising privacy or performance.