DEV Community

sunshey
sunshey

Posted on

How to Remove Clickable Links from PDFs in the Browser with Vue 3 and pdf-lib

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 /URI action (open a web URL) or a /GoTo action (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]
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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:

  1. Loading the PDF with PDFDocument.load()
  2. Iterating pages and accessing each page's /Annots array via page.node.lookup(PDFName.of('Annots'))
  3. Filtering out annotations where Subtype === PDFName.of('Link')
  4. Preserving all other annotation types (highlights, notes, stamps)
  5. 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)