Converting PDF to Word in the browser, without a server (and why layout is the hard part)
Most "PDF to Word" sites ask you to upload the PDF first. That means your contract, your invoice or your medical report sits on someone else's disk, and you are trusting a privacy policy you have not read. It does not h
Most "PDF to Word" sites ask you to upload the PDF first. That means your contract, your invoice or your medical report sits on someone else's disk, and you are trusting a privacy policy you have not read.
It does not have to work that way. A PDF is a file format, and the browser can parse it. This post is how the conversion actually works when you do it client-side β including the part nobody tells you: converting a PDF to Word means throwing the layout away on purpose.
A PDF has no paragraphs
This is the thing that makes the problem interesting.
In a Word document, text is structured: a paragraph contains runs, a run contains characters. In a PDF, there are no paragraphs. There are glyphs, each with a position and a font. A line break is not a character β it is a gap between two y-coordinates.
So "convert to Word" is not a format translation. It is an inference problem: you have to decide, from coordinates alone, what the author meant by a paragraph.
Step 1 β get the text items out
pdf.js gives you the content stream as items:
const pdf = await pdfjsLib.getDocument({ data: bytes }).promise;
const page = await pdf.getPage(1);
const content = await page.getTextContent();
for (const item of content.items) {
// item.str -> the characters
// item.transform -> [a, b, c, d, x, y] β x and y are the position
}
item.transform[5] is the baseline y. Two items with the same y are on the same line. That single fact is the whole grouping algorithm.
Step 2 β group by baseline, not by order
The naive version sorts by y and joins with spaces. It fails on two things: superscripts and slightly rotated text.
What works in practice is a tolerance:
const TOL = 2.5; // points β a fraction of the font size
const lines = [];
for (const item of items) {
const y = item.transform[5];
const x = item.transform[4];
const line = lines.find((l) => Math.abs(l.y - y) <= TOL);
if (line) line.parts.push({ x, text: item.str });
else lines.push({ y, parts: [{ x, text: item.str }] });
}
// within a line, left to right
for (const line of lines) line.parts.sort((a, b) => a.x - b.x);
The tolerance matters. Too tight and one line becomes five. Too loose and two lines merge. Around 2β3 points works for ordinary body text β which is why this is a heuristic and not a spec.
Step 3 β write a real .docx
A .docx is a zip of XML. Not an HTML file renamed, not an RTF β a zip with a specific structure:
[Content_Types].xml
_rels/.rels
word/document.xml
Minimum viable document.xml:
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p><w:r><w:t xml:space="preserve">First line of the page</w:t></w:r></w:p>
<w:p><w:r><w:t xml:space="preserve">Second line</w:t></w:r></w:p>
</w:body>
</w:document>
Two details that will bite you:
-
Escape the text.
&,<and>are XML syntax. A document containingR&Dproduces a file Word refuses to open. -
xml:space="preserve"β without it, leading and trailing spaces disappear and your text runs together.
Then zip it with JSZip and hand it to the user as a Blob:
const zip = new JSZip();
zip.file("[Content_Types].xml", contentTypes);
zip.file("_rels/.rels", rels);
zip.file("word/document.xml", documentXml);
const blob = await zip.generateAsync({
type: "blob",
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
});
That MIME type matters. Without it, Word shows a "file is corrupt" dialog even though the bytes are fine.
What you lose, and why you should say so out loud
This is the part I think tool sites get wrong.
The output has the text. It does not have:
- columns (they unwrap into one flow)
- tables (they become lines of text)
- images (dropped)
- fonts, colours, bold runs (everything is Calibri 11pt)
- headers, footers, footnotes
For someone who wants to edit the wording of a report, that is exactly right. For someone who needs a document that looks the same, it is the wrong tool and re-typesetting the original is faster than repairing the conversion.
A conversion tool that pretends otherwise produces a beautiful Word file that is a lie about the original.
Scanned PDFs are a different problem
A scan is a photograph of a page. There is no text layer, so getTextContent() returns nothing β not an error, just emptiness.
The honest behaviour is to detect that and say "this PDF has no text layer" instead of producing an empty document. OCR is a separate pipeline: render each page to a canvas, run it through Tesseract, then feed the recognised text into the same docx writer.
Memory is the real limit
A 200-page PDF with images can hold a lot of decoded data. Two things help:
- process page by page and release the canvas
- never hold the whole document as strings if you can stream per page
There is no server-side limit here, which sounds like a feature until you remember the tab has 300MB to play with on a mid-range phone.
The takeaway
Client-side document conversion is not a hack anymore. pdf.js, JSZip and Canvas are enough for text extraction, and the hard part was never the file format β it was admitting that layout cannot be reconstructed from coordinates without guessing.
If you want to see the version I built, it is one of 368 tools at https://toolcin.pages.dev/tools/pdf-to-word/ β no upload, no account, and the page states the limitations above rather than hiding them.
If you are building something similar, the baseline-grouping step is where I would spend the testing time. Everything after it is plumbing.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.