<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: coder3662</title>
    <description>The latest articles on DEV Community by coder3662 (@coder3662).</description>
    <link>https://dev.to/coder3662</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4241139%2Fcbc96fed-2b4c-41d3-acbb-32ba5df5d960.png</url>
      <title>DEV Community: coder3662</title>
      <link>https://dev.to/coder3662</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/coder3662"/>
    <language>en</language>
    <item>
      <title>Converting PDF to Word in the browser, without a server (and why layout is the hard part)</title>
      <dc:creator>coder3662</dc:creator>
      <pubDate>Thu, 12 Nov 2026 16:11:27 +0000</pubDate>
      <link>https://dev.to/coder3662/converting-pdf-to-word-in-the-browser-without-a-server-and-why-layout-is-the-hard-part-2nc</link>
      <guid>https://dev.to/coder3662/converting-pdf-to-word-in-the-browser-without-a-server-and-why-layout-is-the-hard-part-2nc</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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: &lt;strong&gt;converting a PDF to Word means throwing the layout away on purpose.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A PDF has no paragraphs
&lt;/h2&gt;

&lt;p&gt;This is the thing that makes the problem interesting.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — get the text items out
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pdf.js&lt;/code&gt; gives you the content stream as items:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;pdf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pdfjsLib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;promise&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTextContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// item.str   -&amp;gt; the characters&lt;/span&gt;
  &lt;span class="c1"&gt;// item.transform -&amp;gt; [a, b, c, d, x, y] — x and y are the position&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;item.transform[5]&lt;/code&gt; is the baseline y. Two items with the same y are on the same line. That single fact is the whole grouping algorithm.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — group by baseline, not by order
&lt;/h2&gt;

&lt;p&gt;The naive version sorts by y and joins with spaces. It fails on two things: superscripts and slightly rotated text.&lt;/p&gt;

&lt;p&gt;What works in practice is a tolerance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;TOL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// points — a fraction of the font size&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="no"&gt;TOL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;str&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;str&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// within a line, left to right&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — write a real .docx
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;.docx&lt;/code&gt; is a zip of XML. Not an HTML file renamed, not an RTF — a zip with a specific structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Content_Types].xml
_rels/.rels
word/document.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Minimum viable &lt;code&gt;document.xml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?xml version="1.0" encoding="UTF-8" standalone="yes"?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;w:document&lt;/span&gt; &lt;span class="na"&gt;xmlns:w&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"http://schemas.openxmlformats.org/wordprocessingml/2006/main"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;w:body&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;w:p&amp;gt;&amp;lt;w:r&amp;gt;&amp;lt;w:t&lt;/span&gt; &lt;span class="na"&gt;xml:space&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"preserve"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;First line of the page&lt;span class="nt"&gt;&amp;lt;/w:t&amp;gt;&amp;lt;/w:r&amp;gt;&amp;lt;/w:p&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;w:p&amp;gt;&amp;lt;w:r&amp;gt;&amp;lt;w:t&lt;/span&gt; &lt;span class="na"&gt;xml:space&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"preserve"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Second line&lt;span class="nt"&gt;&amp;lt;/w:t&amp;gt;&amp;lt;/w:r&amp;gt;&amp;lt;/w:p&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/w:body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/w:document&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that will bite you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Escape the text.&lt;/strong&gt; &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt; and &lt;code&gt;&amp;gt;&lt;/code&gt; are XML syntax. A document containing &lt;code&gt;R&amp;amp;D&lt;/code&gt; produces a file Word refuses to open.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;xml:space="preserve"&lt;/code&gt;&lt;/strong&gt; — without it, leading and trailing spaces disappear and your text runs together.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then zip it with JSZip and hand it to the user as a Blob:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;zip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JSZip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"[Content_Types].xml"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;contentTypes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"_rels/.rels"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"word/document.xml"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;documentXml&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"blob"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"application/vnd.openxmlformats-officedocument.wordprocessingml.document"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That MIME type matters. Without it, Word shows a "file is corrupt" dialog even though the bytes are fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you lose, and why you should say so out loud
&lt;/h2&gt;

&lt;p&gt;This is the part I think tool sites get wrong.&lt;/p&gt;

&lt;p&gt;The output has the text. It does not have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;columns (they unwrap into one flow)&lt;/li&gt;
&lt;li&gt;tables (they become lines of text)&lt;/li&gt;
&lt;li&gt;images (dropped)&lt;/li&gt;
&lt;li&gt;fonts, colours, bold runs (everything is Calibri 11pt)&lt;/li&gt;
&lt;li&gt;headers, footers, footnotes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For someone who wants to edit the wording of a report, that is exactly right. For someone who needs a document that &lt;strong&gt;looks&lt;/strong&gt; the same, it is the wrong tool and re-typesetting the original is faster than repairing the conversion.&lt;/p&gt;

&lt;p&gt;A conversion tool that pretends otherwise produces a beautiful Word file that is a lie about the original.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scanned PDFs are a different problem
&lt;/h2&gt;

&lt;p&gt;A scan is a photograph of a page. There is no text layer, so &lt;code&gt;getTextContent()&lt;/code&gt; returns nothing — not an error, just emptiness.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory is the real limit
&lt;/h2&gt;

&lt;p&gt;A 200-page PDF with images can hold a lot of decoded data. Two things help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;process page by page and release the canvas&lt;/li&gt;
&lt;li&gt;never hold the whole document as strings if you can stream per page&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Client-side document conversion is not a hack anymore. &lt;code&gt;pdf.js&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;If you want to see the version I built, it is one of 368 tools at &lt;strong&gt;&lt;a href="https://toolcin.pages.dev/tools/pdf-to-word/" rel="noopener noreferrer"&gt;https://toolcin.pages.dev/tools/pdf-to-word/&lt;/a&gt;&lt;/strong&gt; — no upload, no account, and the page states the limitations above rather than hiding them.&lt;/p&gt;

&lt;p&gt;If you are building something similar, the baseline-grouping step is where I would spend the testing time. Everything after it is plumbing.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>pdf</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
