Splitting a PDF at blank pages is a practical operation for documents that use blank pages as section dividers. Unlike splitting by page count (which cuts at arbitrary points) or by bookmarks (which requires a structured outline), blank-page splitting respects the document's physical structure.
Here's how to build a browser-based blank-page splitter with Vue 3 and pdf-lib.
The blank page detection challenge
Detecting blank pages is deceptively difficult. A page might appear blank to the eye but contain:
- Invisible text (zero-opacity, white-on-white)
- Empty content streams with metadata
- Faint watermarks or background graphics
- Single-pixel transparent pixels
A single heuristic would produce false positives or false negatives. The solution: multiple independent checks.
The stack
- Vue 3 with Composition API
-
PDF.js (
pdfjs-dist) for page rendering and content analysis - pdf-lib for PDF manipulation
- Vite for bundling
The core implementation
<script setup lang="ts">
import { ref } from 'vue'
import * as pdfjsLib from 'pdfjs-dist'
import { PDFDocument } from 'pdf-lib'
const file = ref<File | null>(null)
const blankPages = ref<number[]>([])
const splitting = ref(false)
const progress = ref(0)
const progressTotal = ref(0)
// Multi-heuristic blank page detection
async function isBlankPage(
pdfPage: pdfjsLib.PageProxy,
tolerance: number = 2
): Promise<boolean> {
const viewport = pdfPage.getViewport({ scale: 1 })
// Heuristic 1: Pixel analysis
const canvas = document.createElement('canvas')
canvas.width = viewport.width
canvas.height = viewport.height
const ctx = canvas.getContext('2d', { willReadFrequently: true })!
await pdfPage.render({ canvasContext: ctx, viewport }).promise
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const data = imageData.data
let nonWhitePixels = 0
const totalPixels = data.length / 4
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], b = data[i + 2], a = data[i + 3]
// Skip fully transparent pixels
if (a < 10) continue
// Count non-white/non-black pixels
if (r > tolerance && g > tolerance && b > tolerance &&
!(r < 256 - tolerance && g < 256 - tolerance && b < 256 - tolerance)) {
nonWhitePixels++
}
}
const pixelRatio = nonWhitePixels / totalPixels
if (pixelRatio > 0.01) return false // More than 1% non-white pixels
// Heuristic 2: Text content check
try {
const textContent = await pdfPage.getTextContent()
const hasText = textContent.items.some(
item => ('str' in item) && item.str.trim().length > 0
)
if (hasText) return false
} catch {
// Text extraction failed — skip this heuristic
}
// Heuristic 3: Image count
try {
const ops = await pdfPage.getOperatorList()
const imageCount = ops.fnArray.filter(
fn => fn === pdfjsLib.OPS.paintImageXObject ||
fn === pdfjsLib.OPS.paintInlineImageXObject
).length
if (imageCount > 0) return false
} catch {
// Skip if operator list unavailable
}
// All heuristics passed — page is blank
return true
}
async function splitAtBlankPages() {
if (!file.value) return
splitting.value = true
const arrayBuffer = await file.value.arrayBuffer()
const pdfJsDoc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise
const pdfLibDoc = await PDFDocument.load(arrayBuffer)
const totalPages = pdfJsDoc.numPages
progressTotal.value = totalPages
// Step 1: Detect blank pages
const blankIndices: number[] = []
for (let i = 1; i <= totalPages; i++) {
progress.value = i
const page = await pdfJsDoc.getPage(i)
if (await isBlankPage(page)) {
blankIndices.push(i - 1) // 0-based index
}
}
blankPages.value = blankIndices
if (blankIndices.length === 0) {
splitting.value = false
return // No blank pages found
}
// Step 2: Split at blank pages
const boundaries = [0, ...blankIndices, totalPages]
const results: Record<string, Uint8Array> = {}
for (let i = 0; i < boundaries.length - 1; i++) {
const start = boundaries[i]
const end = boundaries[i + 1]
const newPdf = await PDFDocument.create()
const pageIndices = Array.from(
{ length: end - start },
(_, j) => start + j
)
const [copiedPages] = await newPdf.copyPages(pdfLibDoc, pageIndices)
copiedPages.forEach(page => newPdf.addPage(page))
const filename = `section-${i + 1}.pdf`
results[filename] = await newPdf.save()
}
// Package as ZIP
const zip = await createZip(results)
downloadZip(zip, 'split-at-blank-pages.zip')
splitting.value = false
}
</script>
Key implementation details
1. Multi-heuristic detection
Each heuristic independently checks for content. A page is only considered blank if all heuristics pass:
// If ANY heuristic finds content, the page is NOT blank
if (pixelRatio > 0.01) return false // Has visible pixels
if (hasText) return false // Has text
if (imageCount > 0) return false // Has images
return true // All clear — it's blank
This conservative approach minimizes false positives (misidentifying content pages as blank).
2. Pixel tolerance
The tolerance parameter (default 2) controls how strict the blank detection is:
-
tolerance = 0: Only perfectly white (255,255,255) pixels count as blank -
tolerance = 2: Near-white pixels (253-255) also count as blank - Higher values: More aggressive blank detection
For most documents, tolerance = 2 works well. It accounts for JPEG compression artifacts and scanner noise while still catching truly blank pages.
3. Boundary calculation
The split boundaries are computed from blank page indices:
const boundaries = [0, ...blankIndices, totalPages]
// Example: blank pages at indices [3, 7, 12] in a 15-page PDF
// boundaries = [0, 3, 7, 12, 15]
// Sections: [0-3), [3-7), [7-12), [12-15)
// → 4 sections
Note that the blank pages themselves are included in the preceding section (not excluded). If you want blank pages to be excluded, adjust the boundaries:
const boundaries = [0, ...blankIndices.map(i => i + 1), totalPages]
// Blank page at index 3 → section ends at page 4 (exclusive)
4. Performance consideration
Rendering every page to a canvas for pixel analysis is expensive for large PDFs. For 100+ page documents:
- Use
scale: 0.5for faster rendering (lower resolution is sufficient for blank detection) - Show a progress indicator
- Consider a "quick scan" mode that only uses text/image heuristics (no rendering)
5. Edge case: consecutive blank pages
If two or more blank pages appear consecutively, they create an empty section. Handle this:
- Option A: Skip empty sections (don't create a file)
- Option B: Merge consecutive blank pages into a single separator
- Option C: Include empty sections (user choice)
The tool should let the user choose the behavior.
Summary
Building a browser-based blank-page splitter involves:
- Rendering each page to detect blank pages (multi-heuristic)
- Computing split boundaries from blank page indices
- Extracting page ranges between boundaries
- Packaging all sections as a ZIP
The multi-heuristic approach ensures reliable blank page detection even for tricky cases like invisible text or faint watermarks. Try it at en.sotool.top/split-at-blank-pages.
Top comments (0)