DEV Community

sunshey
sunshey

Posted on

How to Split PDF at Blank Pages in the Browser with Vue 3 and pdf-lib

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

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

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

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

4. Performance consideration

Rendering every page to a canvas for pixel analysis is expensive for large PDFs. For 100+ page documents:

  • Use scale: 0.5 for 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:

  1. Rendering each page to detect blank pages (multi-heuristic)
  2. Computing split boundaries from blank page indices
  3. Extracting page ranges between boundaries
  4. 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)