DEV Community

sunshey
sunshey

Posted on

How to Build a Visual PDF Page Organizer with Vue 3 and pdf-lib

Most PDF tools handle one operation at a time: merge, split, rotate, delete. But real document assembly rarely fits a single operation. You might need to reorder pages, insert blanks for printing, pull pages from a second file, duplicate a template, and rotate sideways scans — all in the same session.

Building a visual page organizer that combines all of these into one drag-and-drop interface requires careful state management, a flexible page plan model, and precise PDF manipulation. Here's how I built it with Vue 3 and pdf-lib.

The page plan model

Instead of modifying the PDF directly with each action, we use an intermediate page plan — an ordered list that represents what the final PDF will look like:

interface PagePlanItem {
  id: string           // Unique identifier for drag-and-drop
  type: 'original' | 'blank' | 'inserted'
  sourcePageIndex?: number  // Index in the original PDF (for 'original' type)
  sourceFileIndex?: number  // Which file (original = 0, inserted = 1+)
  rotation?: number         // 0 | 90 | 180 | 270
  pageSize?: { width: number; height: number }  // For blank pages
}
Enter fullscreen mode Exit fullscreen mode

Every action — reorder, duplicate, rotate, remove, add blank, insert from another PDF — is just a transformation on this plan array. Only when the user clicks "Organize PDF" do we execute the plan against the actual PDF documents.

The stack

  • Vue 3 with Composition API and reactive state
  • pdf-lib for PDF assembly
  • HTML5 Drag and Drop API for page reordering
  • Vite for bundling

Core implementation

1. The page plan as reactive state

const pagePlan = ref<PagePlanItem[]>([
  // Initialized from the uploaded PDF's pages
  { id: 'p1', type: 'original', sourcePageIndex: 0, sourceFileIndex: 0 },
  { id: 'p2', type: 'original', sourcePageIndex: 1, sourceFileIndex: 0 },
  // ...
])

// Every operation mutates this array
function addBlankPages(count: number, position: number) {
  const blanks: PagePlanItem[] = Array.from({ length: count }, (_, i) => ({
    id: `blank-${uuid()}`,
    type: 'blank',
    pageSize: blankPageSize.value,
    rotation: blankRotation.value,
  }))
  pagePlan.value.splice(position, 0, ...blanks)
}

function duplicateSelected(selectedIds: Set<string>) {
  const newItems: PagePlanItem[] = []
  for (const item of pagePlan.value) {
    newItems.push(item)
    if (selectedIds.has(item.id)) {
      newItems.push({ ...item, id: `dup-${uuid()}` })
    }
  }
  pagePlan.value = newItems
}
Enter fullscreen mode Exit fullscreen mode

2. Drag-and-drop reordering

Vue 3's reactivity works well with the HTML5 Drag and Drop API:

<template>
  <div
    v-for="item in pagePlan"
    :key="item.id"
    draggable="true"
    :class="{ 'opacity-50': dragId === item.id }"
    @dragstart="handleDragStart($event, item.id)"
    @dragover.prevent
    @drop="handleDrop($event, item.id)"
    @dragend="dragId = null"
  >
    <!-- Page card UI -->
  </div>
</template>

<script setup lang="ts">
const dragId = ref<string | null>(null)

function handleDragStart(e: DragEvent, id: string) {
  dragId.value = id
  e.dataTransfer!.effectAllowed = 'move'
}

function handleDrop(_e: DragEvent, targetId: string) {
  if (!dragId.value || dragId.value === targetId) return
  const from = pagePlan.value.findIndex(item => item.id === dragId.value)
  const to = pagePlan.value.findIndex(item => item.id === targetId)
  const [moved] = pagePlan.value.splice(from, 1)
  pagePlan.value.splice(to, 0, moved)
  dragId.value = null
}
</script>
Enter fullscreen mode Exit fullscreen mode

3. Inserting pages from another PDF

This is where the page plan model really shines. Inserting pages from a second PDF is just adding entries with a different sourceFileIndex:

const insertedFiles = ref<ArrayBuffer[]>([])

function insertFromPdf(fileIndex: number, position: number, pages: number) {
  const entries: PagePlanItem[] = Array.from({ length: pages }, (_, i) => ({
    id: `ins-${uuid()}`,
    type: 'inserted' as const,
    sourcePageIndex: i,
    sourceFileIndex: fileIndex + 1, // 0 = original, 1+ = inserted files
  }))
  pagePlan.value.splice(position, 0, ...entries)
}
Enter fullscreen mode Exit fullscreen mode

4. Executing the plan

When the user clicks "Organize PDF," we iterate the page plan and assemble the final document:

async function executePlan() {
  const result = await PDFDocument.create()
  const sourceDocs: PDFDocument[] = [
    originalPdf.value!,
    ...insertedDocs.value,
  ]

  for (const item of pagePlan.value) {
    if (item.type === 'blank') {
      const page = result.addPage(item.pageSize)
      if (item.rotation) {
        page.setRotation(degrees(item.rotation))
      }
    } else {
      const sourceDoc = sourceDocs[item.sourceFileIndex!]
      const [copiedPage] = await result.copyPages(
        sourceDoc,
        [item.sourcePageIndex!]
      )
      result.addPage(copiedPage)
      if (item.rotation) {
        const pages = result.getPages()
        pages[pages.length - 1].setRotation(degrees(item.rotation))
      }
    }
  }

  return await result.save()
}
Enter fullscreen mode Exit fullscreen mode

Key design decisions

1. Plan-first, not action-first

The plan-first approach has several advantages over applying each action immediately to the PDF:

  • Undo is free — just mutate the plan array back
  • Preview the final page order before committing
  • Batch operations — quick-select, reverse, remove blanks are all plan transforms
  • Zero PDF processing until export — better performance, no intermediate saves

2. Blank pages respect PDF dimensions

Blank pages need size and orientation settings. The tool supports:

  • Match the original PDF's dimensions
  • Standard sizes: A4 (595×842 pt), Letter (612×792 pt)
  • Custom portrait or landscape orientation

These are stored in the plan item so different blanks can have different dimensions in the same document.

3. Quick-select for large documents

For 100+ page documents, selecting individual pages is impractical. The toolbar provides:

function selectOdd() {
  // Select pages at odd indices (1, 3, 5...)
  const ids = pagePlan.value
    .filter((_, i) => i % 2 === 0)
    .map(item => item.id)
  selectedIds.value = new Set(ids)
}

function selectRange(from: number, to: number) {
  const ids = pagePlan.value
    .slice(from - 1, to)
    .map(item => item.id)
  selectedIds.value = new Set(ids)
}
Enter fullscreen mode Exit fullscreen mode

4. Export modes

The tool supports two export modes:

  • Export all pages: Execute the full plan
  • Export selected pages only: Execute only the selected subset of the plan

This is simpler than implementing a "delete everything except selected" workflow — just filter the plan during execution.


Summary

Building a visual PDF page organizer involves:

  1. Modeling the page plan as a reactive array of typed items
  2. Implementing all operations as plan transformations (not direct PDF mutations)
  3. Using HTML5 Drag and Drop for visual reordering
  4. Supporting multi-file sources with sourceFileIndex
  5. Executing the plan against pdf-lib only at export time

Try it at en.sotool.top/organize-pdf. Rearrange, insert blanks, pull pages from other files — all in one visual workflow.


Top comments (0)