DEV Community

sunshey
sunshey

Posted on

How to Build a PDF Upload Checker with Vue 3 and pdf-lib

Upload rejection messages are frustrating. The user sees "File too large" or "Upload failed" only after submitting a PDF to a portal. A better experience is to check the file locally first, then explain exactly what is wrong before the user ever clicks submit.

This post walks through a PDF Upload Checker built with Vue 3 and pdf-lib. It runs entirely in the browser, so files are never sent to a server.

Why check before upload?

PDFs can fail for reasons that are not obvious from the file icon:

  • The file is 5.1 MB on a portal that limits uploads to 5 MB
  • The PDF is encrypted or password-protected
  • Mixed page sizes confuse a layout system
  • Form fields make the document behave strangely in online readers
  • Metadata leaks author names or creation software
  • JavaScript or launch actions trigger security warnings

A checker that reads the PDF locally can catch these issues in seconds.

The stack

  • Vue 3 with Composition API
  • pdf-lib for PDF structure analysis
  • Native FileReader / ArrayBuffer for local file access

Minimal implementation

<script setup lang="ts">
import { ref } from 'vue'
import { PDFDocument } from 'pdf-lib'

const file = ref<File | null>(null)
const checks = ref<any[]>([])
const loading = ref(false)

const uploadLimits = [
  { value: '25', label: 'Email / Gmail - 25 MB' },
  { value: '10', label: 'Common web form - 10 MB' },
  { value: '5', label: 'Strict portal - 5 MB' },
  { value: '2', label: 'Tiny portal - 2 MB' },
]

const selectedLimit = ref('10')

async function handleFile(selected: File) {
  file.value = selected
  loading.value = true
  checks.value = []

  try {
    const bytes = await selected.arrayBuffer()
    const pdf = await PDFDocument.load(bytes, { ignoreEncryption: true, updateMetadata: false })
    checks.value = analyzePdf(selected, pdf, Number(selectedLimit.value))
  } catch (e) {
    checks.value = [{
      key: 'readable',
      severity: 'fail',
      title: 'PDF could not be read',
      description: 'The file may be corrupted, encrypted in an unsupported way, or not a valid PDF.',
    }]
  } finally {
    loading.value = false
  }
}

function analyzePdf(file: File, pdf: PDFDocument, limitMb: number) {
  const sizeMb = file.size / (1024 * 1024)
  const pages = pdf.getPageCount()
  const pageSizes = pdf.getPages().map(p => {
    const { width, height } = p.getSize()
    return `${Math.round(width)}x${Math.round(height)}`
  })
  const uniqueSizes = new Set(pageSizes)
  const formFields = pdf.getForm()?.getFields()?.length ?? 0

  return [
    checkFileSize(sizeMb, limitMb),
    checkEncryption(pdf),
    checkPageCount(pages),
    checkFormFields(formFields),
    checkPageSizes(uniqueSizes.size),
  ]
}

function checkFileSize(sizeMb: number, limitMb: number) {
  if (sizeMb > limitMb) {
    return {
      key: 'file_size',
      severity: 'fail',
      title: 'Over the upload limit',
      description: `The file is ${sizeMb.toFixed(1)} MB, which is above the ${limitMb} MB limit.`,
    }
  }
  if (sizeMb > limitMb * 0.8) {
    return {
      key: 'file_size',
      severity: 'warn',
      title: 'Close to the limit',
      description: `The file uses more than 80% of the ${limitMb} MB limit. Compressing lowers rejection risk.`,
    }
  }
  return {
    key: 'file_size',
    severity: 'pass',
    title: 'File size within limit',
    description: `The file is below the ${limitMb} MB limit.`,
  }
}

function checkEncryption(pdf: PDFDocument) {
  const encrypted = pdf.isEncrypted || false
  return encrypted
    ? {
        key: 'encryption',
        severity: 'fail',
        title: 'Encrypted or protected PDF',
        description: 'Many portals reject password-protected or encrypted PDFs.',
      }
    : {
        key: 'encryption',
        severity: 'pass',
        title: 'No encryption detected',
        description: 'No encryption marker was detected in the file.',
      }
}

function checkPageCount(pages: number) {
  return {
    key: 'page_count',
    severity: pages < 1 ? 'fail' : 'pass',
    title: 'Page count',
    description: pages < 1 ? 'No pages were found in the PDF.' : `${pages} pages detected.`,
  }
}

function checkFormFields(count: number) {
  return count > 0
    ? {
        key: 'form_fields',
        severity: 'warn',
        title: 'Editable form fields',
        description: 'Form fields may display differently or be rejected by online readers. Consider flattening.',
      }
    : {
        key: 'form_fields',
        severity: 'pass',
        title: 'No form fields detected',
        description: 'The PDF contains no editable form fields.',
      }
}

function checkPageSizes(uniqueCount: number) {
  return uniqueCount > 1
    ? {
        key: 'page_sizes',
        severity: 'warn',
        title: 'Mixed page sizes',
        description: 'Different page sizes can confuse layout systems. Consider resizing to a single size.',
      }
    : {
        key: 'page_sizes',
        severity: 'pass',
        title: 'Consistent page size',
        description: 'All pages appear to be the same size.',
      }
}
</script>
Enter fullscreen mode Exit fullscreen mode

The key is PDFDocument.load(bytes, { ignoreEncryption: true, updateMetadata: false }). This lets you read basic structure even for some encrypted files, without triggering full decryption.

Scanning raw bytes for risky features

Some PDF features are not exposed cleanly by pdf-lib. You can scan the raw bytes for markers such as JavaScript, launch actions, embedded files, and external links:

function scanRawFlags(bytes: ArrayBuffer) {
  const text = new TextDecoder().decode(bytes)
  return {
    javascript: /\/JS\b|\\/JavaScript\b/i.test(text),
    launchAction: /\\/Launch\b/i.test(text),
    openAction: /\\/OpenAction\b/i.test(text),
    embeddedFiles: /\\/EmbeddedFiles\b/i.test(text),
    externalLinks: /\\/URI\b|\\/URI\s*\(/i.test(text),
    xmpMetadata: /\\/Type\\/Metadata|\\/Metadata\b/i.test(text),
  }
}
Enter fullscreen mode Exit fullscreen mode

This is not a full security audit, but it is good enough for a quick upload-readiness check.

UX tips from a live tool

At en.sotool.top/pdf-upload-checker, we learned a few things from real users:

  1. Preset upload limits reduce confusion. Users do not always know the exact MB limit. Giving them common presets makes the tool faster to use.
  2. Severity matters more than detail. Pass/warn/fail badges let users scan the report quickly. Full paragraph explanations come second.
  3. Link to the fix. When a check fails, show a button to the matching tool. A user who finds the file too large should be one click away from compression.
  4. Keep it local. Privacy-sensitive users trust the tool more when they know the file never leaves their browser.

Tracking the funnel

We use GA4 custom events to understand how the checker is used:

onFileUpload(file)
onToolAction('check-upload', { limit_mb: selectedLimit.value })
onToolCompleted('upload-checker', { status: overallStatus })
onToolAction('recommendation', { key: 'compress' })
Enter fullscreen mode Exit fullscreen mode

This shows whether users are finding actionable problems and following the recommended fixes.

Going further

For a simple upload checker, pdf-lib plus raw-byte scanning is enough. If you need deep security analysis, full metadata parsing, or PDF/A validation, you will want a server-side library or a dedicated compliance tool.

Want to see the full source? The site is built in public at github.com/sunshey/pdf-tool.


If you need desktop-grade PDF editing — batch processing, advanced metadata cleaning, or compliance workflows — check out Wondershare PDFelement.


Top comments (0)