DEV Community

sunshey
sunshey

Posted on

How to Convert PDF to Word with Vue 3 and pdfjs-dist

Extracting text from a PDF and saving it as a .docx file sounds simple. It is not. PDFs were designed for fixed layouts, not for text extraction. Characters sit at arbitrary coordinates, fonts may be embedded or subsetted, and reading order is not guaranteed.

This post walks through a minimal but production-ready PDF to Word converter built with Vue 3 and pdfjs-dist.

The core challenge

PDF text is stored as a flat list of glyphs with positions. Unlike a Word document, there is no paragraph structure, no heading hierarchy, no table semantics. The converter has to reconstruct all of that from raw coordinates.

import * as pdfjs from 'pdfjs-dist'
import { Document, Packer, Paragraph, TextRun } from 'docx'

pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js'

async function extractText(file) {
  const arrayBuffer = await file.arrayBuffer()
  const pdf = await pdfjs.getDocument({ data: arrayBuffer }).promise

  const paragraphs = []

  for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
    const page = await pdf.getPage(pageNum)
    const textContent = await page.getTextContent()
    const items = textContent.items

    // Group by Y coordinate (lines)
    const lines = groupByY(items)

    for (const line of lines) {
      // Sort by X coordinate (reading order)
      line.sort((a, b) => a.transform[4] - b.transform[4])
      const text = line.map(item => item.str).join(' ')
      paragraphs.push(text)
    }
  }

  return paragraphs
}
Enter fullscreen mode Exit fullscreen mode

Grouping text into lines

PDF.js returns text items in content stream order, not visual order. We need to group items that share the same Y coordinate into lines:

function groupByY(items) {
  const lines = []
  let currentLine = []
  let lastY = -1

  for (const item of items) {
    const y = Math.round(item.transform[5])
    if (y !== lastY && currentLine.length > 0) {
      lines.push(currentLine)
      currentLine = []
      lastY = y
    }
    currentLine.push(item)
  }
  if (currentLine.length > 0) {
    lines.push(currentLine)
  }

  return lines
}
Enter fullscreen mode Exit fullscreen mode

The tolerance is 1 pixel (we round the Y coordinate). This works for most documents but fails for multi-column layouts where text from different columns may share the same Y coordinate.

Generating the Word document

Once we have the text, we use the docx library to create a .docx file:

import { Document, Packer, Paragraph } from 'docx'

async function generateDocx(paragraphs) {
  const doc = new Document({
    sections: [{
      children: paragraphs.map(text =>
        new Paragraph(text)
      ),
    }],
  })

  return await Packer.toBlob(doc)
}
Enter fullscreen mode Exit fullscreen mode

This is the simplest possible approach. Each paragraph is a single line of text. No formatting, no styling, no tables.

Handling multi-column layouts

Multi-column PDFs (newspapers, magazines, academic papers) are a known pain point. The text content stream reads top-to-bottom, left-to-right within each column. But our grouping logic reads top-to-bottom, left-to-right across all columns.

A simple heuristic: if two consecutive lines share the same X range (within 50 pixels), they are likely in the same column. Otherwise, they are in different columns.

function detectMultiColumn(lines) {
  const xRanges = lines.map(line => ({
    minX: Math.min(...line.map(i => i.transform[4])),
    maxX: Math.max(...line.map(i => i.transform[4])),
  }))

  let columnChanges = 0
  for (let i = 1; i < xRanges.length; i++) {
    if (xRanges[i].minX > xRanges[i - 1].maxX + 50) {
      columnChanges++
    }
  }

  return columnChanges > lines.length * 0.3
}
Enter fullscreen mode Exit fullscreen mode

If multi-column is detected, we warn the user that formatting may not be perfect.

What doesn't work

  • Scanned PDFs — no text layer exists, extraction returns empty
  • Complex tables — table structure is lost, cells merge
  • Images and charts — not text, cannot be extracted
  • Custom fonts — may render as gibberish or blank squares

For these cases, redirect users to a desktop editor with OCR.

UX tips from a live tool

At en.sotool.top/pdf-to-word, we learned:

  1. Be honest about limitations. Clearly state that scanned PDFs need OCR.
  2. Show a preview. Let users see extracted text before downloading.
  3. Track drop-off. How many users upload but never download? That tells you if the preview is confusing.
  4. Suggest alternatives. When conversion fails, link to PDFelement for OCR.

Tracking the funnel

onFileUpload(file)
onToolAction('convert-to-word')
onCompleted({ page_count: pdf.numPages, text_length: totalChars })
onDownload({ file_size_kb: blob.size / 1024 })
Enter fullscreen mode Exit fullscreen mode

Going further

For text-based PDFs with simple layouts, pdfjs-dist plus docx is enough. For scanned PDFs, complex layouts, or high-fidelity conversion, you need a desktop editor with OCR.

Want to see the full source? The site is built in public at github.com/sunshey/pdf-tool.


If you need desktop-grade PDF conversion — OCR, complex layouts, tables, charts — check out Wondershare PDFelement.

Top comments (0)