PDF hyperlinks are convenient — until they become a liability. Public archives don't want broken external URLs. Security-conscious teams don't want unverified links circulating internally. And link rot means today's working URL is tomorrow's dead end.
Building a browser-based "remove all links" tool involves understanding PDF link annotations, the document's annotation array, and how to strip one annotation type while preserving others. Here's how I built it with Vue 3 and pdf-lib.
Why dedicated link removal?
Most PDF editors let you delete annotations one at a time. But a single document can contain hundreds of links — link annotations on every table-of-contents entry, every footnote reference, every inline citation. Manually deleting them is impractical. A dedicated tool removes all link annotations in one operation.
The stack
- Vue 3 with Composition API
- pdf-lib for PDF manipulation
- Vite for bundling
Understanding PDF link annotations
In the PDF spec, links are a type of annotation. Every page has an /Annots array that holds all annotations for that page — links, highlights, text notes, stamps, form widgets, etc.
A link annotation has:
-
Subtype:
/Link— distinguishes it from other annotation types -
Action: What happens when clicked — typically a
/URIaction (open a web URL) or a/GoToaction (jump to another page) - Rect: The clickable area on the page
To remove only links (not other annotations), we filter the /Annots array by subtype:
Before: [/Link, /Link, /Highlight, /Link, /Text]
After: [/Highlight, /Text]
The core implementation
<script setup lang="ts">
import { ref } from 'vue'
import { PDFDocument } from 'pdf-lib'
const file = ref<File | null>(null)
const totalPages = ref(0)
const linkCount = ref(0)
const removing = ref(false)
const result = ref<Uint8Array | null>(null)
async function removeLinks() {
if (!file.value) return
removing.value = true
const arrayBuffer = await file.value.arrayBuffer()
const pdf = await PDFDocument.load(arrayBuffer)
const pages = pdf.getPages()
totalPages.value = pages.length
let totalLinks = 0
for (const page of pages) {
const annots = page.node.lookup(PDFName.of('Annots'))
if (!annots || !(annots instanceof PDFArray)) continue
const filtered = 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)) {
filtered.push(annotRef)
continue
}
const subtype = annot.get(PDFName.of('Subtype'))
// Skip link annotations — don't add them to the filtered array
if (subtype === PDFName.of('Link')) {
totalLinks++
continue
}
// Keep all other annotation types
filtered.push(annotRef)
}
page.node.set(PDFName.of('Annots'), filtered)
}
linkCount.value = totalLinks
const cleaned = await pdf.save()
result.value = cleaned
removing.value = false
}
</script>
Key implementation details
1. Low-level PDF manipulation
pdf-lib doesn't have a built-in removeLinks() method, so we work directly with the PDF object model. This requires importing PDF primitives:
import { PDFDocument, PDFName, PDFArray, PDFDict } from 'pdf-lib'
The approach: iterate pages → access each page's /Annots array → rebuild the array excluding /Link entries → write back.
2. Preserving other annotations
The filtering is deliberately exclusive — we only remove annotations with Subtype: /Link. Everything else stays:
-
/Highlight— text highlights -
/Text— sticky notes and text annotations -
/Stamp— approval stamps -
/Ink— freehand drawings -
/Widget— form fields
This is the key differentiator from "Remove Annotations" — we're doing a targeted removal of one annotation type only.
3. Handling nested annotation references
Some PDFs store annotation references indirectly. The /Annots array contains reference objects, not inline dictionaries. We use page.doc.context.lookup(annotRef) to resolve each reference before inspecting its subtype:
const annot = page.doc.context.lookup(annotRef)
if (!(annot instanceof PDFDict)) {
// Not a dictionary we can filter — keep it
filtered.push(annotRef)
continue
}
const subtype = annot.get(PDFName.of('Subtype'))
if (subtype === PDFName.of('Link')) {
totalLinks++
continue // Skip — don't add to filtered array
}
4. What about the visible text?
Link annotations create a clickable rectangle on the page. They do NOT contain the visible text — the text (usually blue and underlined) is part of the page content stream, not the annotation. So when we remove the link annotation:
- The clickable area is gone — clicking does nothing
- The text still looks exactly the same (blue, underlined)
- The visual appearance of the page is unchanged
If you also want to remove the visual blue underline styling, that requires editing the page content stream itself — a much more complex operation. For most use cases (archive, security, clean distribution), just removing the click action is sufficient.
5. Edge case: empty /Annots array
If a page has no annotations at all (or only links and after filtering the array is empty), we should remove the /Annots key entirely rather than keeping an empty array:
if (filtered.size() === 0) {
page.node.delete(PDFName.of('Annots'))
} else {
page.node.set(PDFName.of('Annots'), filtered)
}
Alternative approach: page.node.Annots()
pdf-lib provides a convenience method page.node.Annots() which returns the annotations array directly. However, this returns a PDFArray | undefined — and we need the raw reference-level access for filtering. The convenience method is useful for reading annotations but the manual filtering approach above gives us write-level control.
Summary
Building a browser-based "remove PDF links" tool involves:
- Loading the PDF with
PDFDocument.load() - Iterating pages and accessing each page's
/Annotsarray viapage.node.lookup(PDFName.of('Annots')) - Filtering out annotations where
Subtype === PDFName.of('Link') - Preserving all other annotation types (highlights, notes, stamps)
- Saving with
pdf.save()
The result: a visually identical PDF where nothing is clickable. Try it at en.sotool.top/remove-pdf-links.
Top comments (0)