Every PDF carries hidden information — metadata, embedded files, JavaScript, invisible text, tracking links. When you receive a PDF from an unknown source or prepare to share your own document, you need to know what's inside.
Building a browser-based PDF privacy inspector involves extracting and categorizing all hidden information from a PDF without uploading it anywhere. Here's how to do it with Vue 3 and pdf-lib.
The approach
The inspector reads the PDF structure and extracts information from multiple layers:
- Document info dictionary — standard metadata (author, title, creator, etc.)
- XMP metadata — extended metadata including camera settings, GPS, editing history
- Embedded files — attachments stored within the PDF
- JavaScript — embedded scripts that execute on open
- Links and actions — URI actions, GoTo actions, embedded URLs
- Form data — filled form fields with personal information
- Custom properties — non-standard metadata fields
The stack
- Vue 3 with Composition API
- pdf-lib for PDF structure analysis
- PDF.js for content-level analysis (hidden text, images)
- Vite for bundling
The core implementation
<script setup lang="ts">
import { ref } from 'vue'
import { PDFDocument } from 'pdf-lib'
import * as pdfjsLib from 'pdfjs-dist'
const file = ref<File | null>(null)
const report = ref<PrivacyReport | null>(null)
const inspecting = ref(false)
interface PrivacyReport {
fileName: string
fileSize: number
fileSizeFormatted: string
pageCount: number
isEncrypted: boolean
hasPassword: boolean
metadata: DocumentMetadata
xmpMetadata: Record<string, string>
embeddedFiles: EmbeddedFileInfo[]
hasJavaScript: boolean
javascriptSources: string[]
linkCount: number
linkUrls: string[]
formFields: FormFieldInfo[]
hiddenTextCount: number
fontCount: number
imageCount: number
riskScore: number
riskLevel: 'low' | 'medium' | 'high' | 'critical'
recommendations: string[]
}
async function inspectPdf() {
if (!file.value) return
inspecting.value = true
try {
const arrayBuffer = await file.value.arrayBuffer()
const pdf = await PDFDocument.load(arrayBuffer, {
ignoreEncryption: true,
updateMetadata: false,
})
const pdfJsDoc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise
const report: PrivacyReport = {
fileName: file.value.name,
fileSize: file.value.size,
fileSizeFormatted: formatFileSize(file.value.size),
pageCount: pdf.getPageCount(),
isEncrypted: pdf.isEncrypted(),
hasPassword: false,
metadata: extractMetadata(pdf),
xmpMetadata: await extractXMP(arrayBuffer),
embeddedFiles: await extractEmbeddedFiles(pdf),
hasJavaScript: await checkJavaScript(pdf),
javascriptSources: [],
linkCount: 0,
linkUrls: [],
formFields: await extractFormFields(pdf),
hiddenTextCount: await countHiddenText(pdfJsDoc),
fontCount: 0,
imageCount: 0,
riskScore: 0,
riskLevel: 'low',
recommendations: [],
}
// Calculate risk score and level
report.riskScore = calculateRiskScore(report)
report.riskLevel = determineRiskLevel(report.riskScore)
report.recommendations = generateRecommendations(report)
report.value = report
} catch (e) {
console.error('Inspection failed:', e)
} finally {
inspecting.value = false
}
}
function extractMetadata(pdf: PDFDocument): DocumentMetadata {
return {
title: pdf.getTitle() || '(not set)',
author: pdf.getAuthor() || '(not set)',
subject: pdf.getSubject() || '(not set)',
keywords: pdf.getKeywords() || '(not set)',
creator: pdf.getCreator() || '(not set)',
producer: pdf.getProducer() || '(not set)',
creationDate: pdf.getCreationDate()?.toISOString() || '(not set)',
modificationDate: pdf.getModificationDate()?.toISOString() || '(not set)',
}
}
</script>
Key implementation details
1. XMP metadata extraction
XMP metadata is stored as an XML stream within the PDF. We extract it directly from the PDF's binary data:
async function extractXMP(arrayBuffer: ArrayBuffer): Promise<Record<string, string>> {
const xmpData: Record<string, string> = {}
const uint8Array = new Uint8Array(arrayBuffer)
const text = new TextDecoder('utf-8').decode(uint8Array)
// Look for XMP packet
const xmpMatch = text.match(/<x:xmpmeta[^>]*>([\s\S]*?)<\/x:xmpmeta>/)
if (xmpMatch) {
const xmpXml = xmpMatch[1]
// Parse XMP tags
const fields = ['dc:title', 'dc:creator', 'dc:description',
'pdf:Author', 'pdf:Keywords', 'pdf:Producer',
'xmp:CreatorTool', 'xmp:CreateDate', 'xmp:ModifyDate']
for (const field of fields) {
const match = xmpXml.match(new RegExp(`<${field}[^>]*>([^<]+)</${field}>`))
if (match) xmpData[field] = match[1].trim()
}
}
return xmpData
}
2. JavaScript detection
PDFs can embed JavaScript that executes when opened. This is the highest privacy risk:
async function checkJavaScript(pdf: PDFDocument): Promise<boolean> {
// Check for JS in the PDF structure
const pages = pdf.getPages()
for (const page of pages) {
const annotations = page.node.Annots()
if (annotations) {
const annots = annotations.asArray()
for (const annotRef of annots) {
const annot = page.doc.context.lookup(annotRef)
if (annot instanceof PDFDict) {
const subtype = annot.get(PDFName.of('Subtype'))
const action = annot.get(PDFName.of('A'))
if (action instanceof PDFDict) {
const s = action.get(PDFName.of('S'))
if (s === PDFName.of('JavaScript')) {
return true
}
}
}
}
}
}
return false
}
3. Hidden text detection
Invisible text (zero opacity, white-on-white) is commonly used for tracking:
async function countHiddenText(pdfJsDoc: pdfjsLib.DocumentProxy): Promise<number> {
let hiddenCount = 0
for (let i = 1; i <= pdfJsDoc.numPages; i++) {
const page = await pdfJsDoc.getPage(i)
const textContent = await page.getTextContent()
for (const item of textContent.items) {
if ('str' in item) {
const str = item.str.trim()
if (str.length > 0) {
// Check if the text is invisible (zero height or zero opacity)
const transform = item.transform
const fontSize = Math.abs(transform[3]) // Y-scale component
if (fontSize < 0.1 || str.length === 0) {
hiddenCount++
}
}
}
}
}
return hiddenCount
}
4. Risk scoring
A simple scoring system helps users quickly understand the privacy profile:
function calculateRiskScore(report: PrivacyReport): number {
let score = 0
// Metadata risks
if (report.metadata.author !== '(not set)') score += 1
if (report.metadata.creator !== '(not set)') score += 1
if (report.metadata.producer !== '(not set)') score += 1
// Critical risks
if (report.hasJavaScript) score += 5
if (report.hiddenTextCount > 0) score += report.hiddenTextCount * 2
if (report.embeddedFiles.length > 0) score += report.embeddedFiles.length * 3
// Link risks
if (report.linkCount > 0) score += Math.min(report.linkCount, 10)
return Math.min(score, 20) // Cap at 20
}
function determineRiskLevel(score: number): 'low' | 'medium' | 'high' | 'critical' {
if (score >= 15) return 'critical'
if (score >= 10) return 'high'
if (score >= 5) return 'medium'
return 'low'
}
Summary
Building a browser-based PDF privacy inspector involves:
- Extracting document-level metadata from the PDF info dictionary
- Parsing XMP metadata for extended information
- Detecting embedded JavaScript (highest risk)
- Finding hidden/invisible text
- Listing embedded files and links
- Analyzing form field data
- Calculating a risk score with recommendations
The result: a comprehensive privacy report that shows exactly what information your PDF is carrying. Try it at en.sotool.top/pdf-privacy-inspector.
Top comments (0)