DEV Community

Cover image for Converting PDF to Word in the browser, without a server (and why layout is the hard part)
coder3662
coder3662

Posted on

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

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

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

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

Two details that will bite you:

  1. Escape the text. &, < and > are XML syntax. A document containing R&D produces a file Word refuses to open.
  2. 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"
});
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)