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>
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 }]
}
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
}
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()
}
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' })
}
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:
- Loading the PDF and extracting the outline tree
- Recursively traversing bookmarks to build a structured list
- Grouping bookmarks by split depth (all levels vs. top-level only)
- Creating a new PDF for each group using
copyPages() - 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)