DEV Community

sunshey
sunshey

Posted on

How to Build a PDF Privacy Inspector in the Browser with Vue 3 and pdf-lib

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:

  1. Document info dictionary — standard metadata (author, title, creator, etc.)
  2. XMP metadata — extended metadata including camera settings, GPS, editing history
  3. Embedded files — attachments stored within the PDF
  4. JavaScript — embedded scripts that execute on open
  5. Links and actions — URI actions, GoTo actions, embedded URLs
  6. Form data — filled form fields with personal information
  7. 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>
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode

Summary

Building a browser-based PDF privacy inspector involves:

  1. Extracting document-level metadata from the PDF info dictionary
  2. Parsing XMP metadata for extended information
  3. Detecting embedded JavaScript (highest risk)
  4. Finding hidden/invisible text
  5. Listing embedded files and links
  6. Analyzing form field data
  7. 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)