How to Remove PDF Annotations in the Browser with Vue 3 and pdf-lib

sunshey
2026-08-03T12:54:13Z
After a week of collaborative review, your 50-page PDF is covered in highlights, sticky notes, strikethroughs, and stamps. The content is final — but the markup makes it look like a work-in-progress. Before sending it to a client or publishing it, you need a clean copy.
Manually deleting each annotation is impractical. A dedicated tool can strip all non-widget annotations in one operation while preserving form fields. Here's how to build it with Vue 3 and pdf-lib.
Why preserve form widgets?
Not all annotations are markup. Form fields — text inputs, checkboxes, dropdowns, signature fields — are technically a subtype of annotation called widget annotations (Subtype: /Widget). If you blindly delete all annotations, you destroy every fillable form field in the document.
The smart approach: filter by annotation subtype. Remove everything that's not a widget.
The stack
- Vue 3 with Composition API
- pdf-lib for low-level PDF manipulation
- Vite for bundling
Understanding annotation types in PDF
Every page has an /Annots array containing all interactive elements. Each annotation has a Subtype:
| Subtype | Category | Action |
|---|---|---|
/Text |
Comment | ❌ Remove |
/Highlight |
Markup | ❌ Remove |
/StrikeOut |
Markup | ❌ Remove |
/Underline |
Markup | ❌ Remove |
/Ink |
Drawing | ❌ Remove |
/Stamp |
Approval | ❌ Remove |
/Link |
Navigation | ❌ Remove |
/Widget |
Form field | ✅ Keep |
The core implementation
<script setup lang="ts">
import { ref } from 'vue'
import { PDFDocument, PDFName, PDFArray, PDFDict } from 'pdf-lib'
const file = ref<File | null>(null)
const annotationCount = ref(0)
const removing = ref(false)
const result = ref<Uint8Array | null>(null)
async function removeAnnotations() {
if (!file.value) return
removing.value = true
const arrayBuffer = await file.value.arrayBuffer()
const pdf = await PDFDocument.load(arrayBuffer)
const pages = pdf.getPages()
let totalRemoved = 0
for (const page of pages) {
const annots = page.node.lookup(PDFName.of('Annots'))
if (!annots || !(annots instanceof PDFArray)) continue
const kept = PDFArray.withContext(page.doc.context)
const existing = annots.asArray()
for (const annotRef of existing) {
const annot = page.doc.context.lookup(annotRef)
if (!(annot instanceof PDFDict)) {
kept.push(annotRef) // Can't identify — keep it
continue
}
const subtype = annot.get(PDFName.of('Subtype'))
// Widget annotations are form fields — keep them
if (subtype === PDFName.of('Widget')) {
kept.push(annotRef)
continue
}
// Everything else: Text, Highlight, StrikeOut, Underline,
// Ink, Stamp, Link, etc. — remove
totalRemoved++
}
// Write back filtered annotations
if (kept.size() === 0) {
page.node.delete(PDFName.of('Annots'))
} else {
page.node.set(PDFName.of('Annots'), kept)
}
}
annotationCount.value = totalRemoved
const cleaned = await pdf.save()
result.value = cleaned
removing.value = false
}
</script>
Key implementation details
1. The widget whitelist approach
Instead of listing every annotation type to remove (and risking missing one), we use a whitelist strategy: keep widgets, remove everything else:
// ✅ Whitelist: only Widget survives
if (subtype === PDFName.of('Widget')) {
kept.push(annotRef) // Keep form fields
} else {
totalRemoved++ // Remove everything else
}
// ❌ Blacklist: easy to miss annotation types
const REMOVE = ['Text', 'Highlight', 'StrikeOut', 'Underline', 'Ink', 'Stamp', 'Link', ...]
if (REMOVE.includes(subtype)) { ... }
The whitelist approach is more robust because new or uncommon annotation subtypes (like /Caret, /FileAttachment, /Sound, /FreeText, /Polygon, /PolyLine, /Redact, /Watermark) are automatically caught and removed without needing to be individually enumerated.
2. Preserving form field functionality
Widget annotations are the visual representation of AcroForm fields. Their appearance is controlled by the field's value and the field's appearance stream — not by the annotation itself. By preserving widgets, form fields remain fully functional after annotation removal:
// Before removal: page has 3 highlights, 2 text annotations, 5 form widgets
// After removal: page has only 5 form widgets — all still interactive
3. Indirect references and lookup
As with other PDF object manipulation, references in the /Annots array are often indirect. We must resolve each reference before inspecting its dictionary:
const annotRef = existing[i] // This is a reference, not the actual object
const annot = page.doc.context.lookup(annotRef) // Resolve it
If lookup fails or returns a non-dictionary, we keep the reference — it's safer to preserve an unknown element than to accidentally delete something important.
4. What about annotation appearance streams?
Some annotations have appearance streams that are drawn into the page content. Simply removing the annotation from /Annots also removes its appearance — because the appearance stream is referenced from the annotation object, not embedded in the page content stream.
After removal, the visual effect is immediate: highlights disappear, sticky note icons vanish, and stamps are gone. The underlying text and page content remain untouched.
5. Edge case: locked or read-only annotations
Some PDFs have annotations marked as read-only (/F flag bit). These are still annotations — they're still removed by our filter. Read-only is a permission flag, not a content type distinction.
Summary
Building a browser-based PDF annotation removal tool involves:
- Loading the PDF with
PDFDocument.load() - Iterating pages and accessing
/Annotsarrays - Filtering with a widget whitelist — keep
/Widget, remove everything else - Resolving indirect references with
context.lookup() - Saving with
pdf.save()
The result: a clean PDF with all markup stripped, but form fields still functional. Try it at en.sotool.top/remove-annotations.