DEV Community

sunshey
sunshey

Posted on

How to Split PDF by Bookmarks in the Browser with Vue 3 and pdf-lib

Splitting a PDF into separate files is a common need — distribute chapters, email specific sections, organize by topic. But manual splitting (specifying page ranges for each section) is tedious when your document has 20+ chapters. If the PDF has bookmarks, you can automate the entire process.

Here's how to build a browser-based "Split by Bookmarks" tool with Vue 3 and pdf-lib.

The bookmark structure

PDF bookmarks are stored in the document outline (/Outlines dictionary). Each bookmark entry contains:

  • Title: Display text (e.g., "Chapter 1: Introduction")
  • Destination: A reference to the target page
  • Left/Right: Sibling bookmark references
  • First/Last: Child bookmark references (for nested structures)

The outline is a binary tree — each bookmark can have children, creating a hierarchical TOC.

The stack

  • Vue 3 with Composition API
  • pdf-lib for PDF manipulation
  • Vite for bundling

The core implementation

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

const file = ref<File | null>(null)
const bookmarks = ref<BookmarkNode[]>([])
const splitDepth = ref<number>(1) // 1 = top-level only
const splitting = ref(false)
const results = ref<Record<string, Uint8Array>>({})

interface BookmarkNode {
  title: string
  page: number
  children: BookmarkNode[]
}

// Recursively traverse the outline tree
function extractBookmarks(node: PDFIndirectObject, depth: number = 0): BookmarkNode[] {
  const title = node.getTitle?.() ?? 'Untitled'
  const destination = node.getDestination()
  const page = destination?.pageIndex ?? 0

  const children: BookmarkNode[] = []
  let child = node.getFirstChild()
  while (child) {
    children.push(...extractBookmarks(child, depth + 1))
    child = node.getNextSibling()
  }

  return [{ title, page, children }]
}

// Group bookmarks by split depth
function groupByDepth(bookmarks: BookmarkNode[], maxDepth: number): Map<number, BookmarkNode[]> {
  const groups = new Map<number, BookmarkNode[]>()

  function traverse(nodes: BookmarkNode[], currentDepth: number) {
    for (const node of nodes) {
      if (currentDepth <= maxDepth) {
        if (!groups.has(currentDepth)) groups.set(currentDepth, [])
        groups.get(currentDepth)!.push(node)
      }
      traverse(node.children, currentDepth + 1)
    }
  }

  traverse(bookmarks, 0)
  return groups
}

// Split PDF based on bookmark groups
async function splitPdf() {
  if (!file.value) return
  splitting.value = true

  const arrayBuffer = await file.value.arrayBuffer()
  const pdf = await PDFDocument.load(arrayBuffer)
  const outline = pdf.getOutline()

  // Extract bookmark tree
  const bookmarkList = extractBookmarks(outline)
  bookmarks.value = bookmarkList

  // Group by selected depth
  const groups = groupByDepth(bookmarkList, splitDepth.value)
  const splits = Array.from(groups.values()).flat()

  // Create split files
  const results: Record<string, Uint8Array> = {}
  const pageCount = pdf.getPageCount()

  for (let i = 0; i < splits.length; i++) {
    const startPage = splits[i].page
    const endPage = i < splits.length - 1 
      ? splits[i + 1].page 
      : pageCount

    const newPdf = await PDFDocument.create()
    const [copiedPages] = await newPdf.copyPages(pdf, 
      Array.from({ length: endPage - startPage }, (_, j) => startPage + j)
    )
    copiedPages.forEach(page => newPdf.addPage(page))

    // Clean up the title for filename
    const filename = splits[i].title
      .replace(/[<>:"/\\|?*\x00-\x1f]/g, '-')
      .trim()
      .substring(0, 100)

    results[filename] = await newPdf.save()
  }

  // Package as ZIP
  const zip = await createZip(results)
  downloadZip(zip, 'split-by-bookmarks.zip')
  splitting.value = false
}
</script>
Enter fullscreen mode Exit fullscreen mode

Key implementation details

1. Recursive bookmark traversal

The PDF outline is a tree, not a flat list. We traverse it recursively to collect all bookmarks:

function extractBookmarks(node: PDFIndirectObject): BookmarkNode[] {
  const title = node.getTitle()
  const destination = node.getDestination()
  const page = destination?.pageIndex ?? 0

  const children: BookmarkNode[] = []
  let child = node.getFirstChild()
  while (child) {
    children.push(...extractBookmarks(child))
    child = child.getNextSibling()
  }

  return [{ title, page, children }]
}
Enter fullscreen mode Exit fullscreen mode

2. Grouping by split depth

To support "top-level only" vs. "all levels" splitting, we group bookmarks by their depth in the tree:

function groupByDepth(bookmarks: BookmarkNode[], maxDepth: number): Map<number, BookmarkNode[]> {
  const groups = new Map<number, BookmarkNode[]>()

  function traverse(nodes: BookmarkNode[], depth: number) {
    for (const node of nodes) {
      if (depth <= maxDepth) {
        if (!groups.has(depth)) groups.set(depth, [])
        groups.get(depth)!.push(node)
      }
      traverse(node.children, depth + 1)
    }
  }

  traverse(bookmarks, 0)
  return groups
}
Enter fullscreen mode Exit fullscreen mode

3. Creating split PDFs

For each group of bookmarks, we create a new PDF containing the pages between consecutive bookmarks:

for (let i = 0; i < splits.length; i++) {
  const startPage = splits[i].page
  const endPage = i < splits.length - 1 
    ? splits[i + 1].page 
    : pageCount

  const newPdf = await PDFDocument.create()
  const pageIndices = Array.from(
    { length: endPage - startPage }, 
    (_, j) => startPage + j
  )
  const [copiedPages] = await newPdf.copyPages(pdf, pageIndices)
  copiedPages.forEach(page => newPdf.addPage(page))

  results[filename] = await newPdf.save()
}
Enter fullscreen mode Exit fullscreen mode

4. ZIP packaging

Browser-based ZIP creation uses the JSZip library:

async function createZip(files: Record<string, Uint8Array>): Promise<Blob> {
  const zip = new JSZip()
  for (const [name, data] of Object.entries(files)) {
    zip.file(`${name}.pdf`, data)
  }
  return await zip.generateAsync({ type: 'blob' })
}
Enter fullscreen mode Exit fullscreen mode

Edge cases

  • No bookmarks: If the PDF has no outline, show a clear error message suggesting "Split by Page Range" instead.
  • Shallow bookmarks: A PDF with only one level of bookmarks (no children) works fine — it just produces fewer, larger files.
  • Bookmark page references are 0-indexed: pdf-lib uses 0-based page indexing, but we display 1-based numbers to users.
  • Duplicate titles: If multiple bookmarks have the same title (e.g., "Appendix"), the ZIP filenames will conflict. The tool should append a number to disambiguate.
  • Very large documents: Splitting a 500-page PDF into 50 files may take time in the browser. Show a progress indicator.

Summary

Building a browser-based "Split by Bookmarks" tool involves:

  1. Loading the PDF and extracting the outline tree
  2. Recursively traversing bookmarks to build a structured list
  3. Grouping bookmarks by split depth (all levels vs. top-level only)
  4. Creating a new PDF for each group using copyPages()
  5. Packaging all outputs as a ZIP file

The result: a large document split into logical chapters, respecting the author's original structure. Try it at en.sotool.top/split-by-bookmarks.

Top comments (0)