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
}
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
}
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)
}
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
}
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:
- Be honest about limitations. Clearly state that scanned PDFs need OCR.
- Show a preview. Let users see extracted text before downloading.
- Track drop-off. How many users upload but never download? That tells you if the preview is confusing.
- 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 })
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)