<?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: Madhi Omar</title>
    <description>The latest articles on DEV Community by Madhi Omar (@omarmdhi).</description>
    <link>https://dev.to/omarmdhi</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%2F2382819%2F50acb9d5-9c03-41c7-a9ba-5498bb734274.jpeg</url>
      <title>DEV Community: Madhi Omar</title>
      <link>https://dev.to/omarmdhi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/omarmdhi"/>
    <language>en</language>
    <item>
      <title>6 Free PDF Editors You Can Use in a Browser in 2026 (and What Happens to Your File in Each)</title>
      <dc:creator>Madhi Omar</dc:creator>
      <pubDate>Tue, 13 Oct 2026 19:18:58 +0000</pubDate>
      <link>https://dev.to/omarmdhi/6-free-pdf-editors-you-can-use-in-a-browser-in-2026-and-what-happens-to-your-file-in-each-3g2b</link>
      <guid>https://dev.to/omarmdhi/6-free-pdf-editors-you-can-use-in-a-browser-in-2026-and-what-happens-to-your-file-in-each-3g2b</guid>
      <description>&lt;p&gt;&lt;em&gt;A fact-checked comparison of Smallpdf, iLovePDF, PDF24, Sejda, Adobe Acrobat online and PDFkind: free limits, what "free" actually unlocks, and whether your document is uploaded.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Disclosure first: I build PDFkind, one of the six tools below. I have tried to keep this list to verifiable facts (limits and policies quoted from each vendor's own pages, linked at the end) rather than opinions, and I have included the places where PDFkind is weaker than the others.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "where is my file processed" matters more than the feature list
&lt;/h2&gt;

&lt;p&gt;Every tool in this list can merge, split, rotate and compress a PDF. The real differences between them are three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What the free plan actually lets you do.&lt;/strong&gt; Most "free PDF editors" are free for page operations and charge for the thing most people search for: editing the existing text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether the file leaves your computer.&lt;/strong&gt; Four of the six upload your document to a server. That is fine for a flyer. It is a problem for a contract, a medical report, a payslip or anything covered by an NDA, even with a one or two hour deletion policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Daily or hourly quotas.&lt;/strong&gt; Free plans are rate limited, and the limits are rarely printed on the pricing page.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is what each tool does on those three points, as of October 2026.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Smallpdf
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; The best-known online PDF suite, run from Switzerland, with 20+ tools and a polished interface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free plan, in facts:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Smallpdf's own Free vs Pro comparison lists OCR and "direct PDF editing" (changing existing text) as Pro features. Free users get page tools, conversion, annotation and signing.&lt;/li&gt;
&lt;li&gt;The pricing page describes the free tier as "Limited document downloads" with a "Daily download limit" per tool. The number is not published on the page.&lt;/li&gt;
&lt;li&gt;AI tools are capped at 50 MB on Free.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Where your file goes:&lt;/strong&gt; Uploaded. The privacy policy states that files are handled on Hetzner servers in the European Union and, for account holders, deleted "within one hour unless you save them to your file storage." Without an account, the policy says files are retained for a period after they were last opened, and that reopening extends it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Good for:&lt;/strong&gt; quick conversions and signing when the document is not sensitive.&lt;br&gt;
&lt;strong&gt;Not good for:&lt;/strong&gt; editing text or OCR without paying, or anything confidential.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. iLovePDF
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; A Barcelona-based suite with the most generous per-tool file limits of the server-based tools, plus mobile and desktop apps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free plan, in facts (from the pricing page):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Most tools accept files up to 100 MB. Compress accepts 200 MB, Redact and Compare 400 MB. Office, OCR and PDF conversion tools are limited to 15 MB.&lt;/li&gt;
&lt;li&gt;Batch limits per tool: Merge 25 files, Image to PDF 20, Organize 5, Sign 3, Compress 2, Split 1.&lt;/li&gt;
&lt;li&gt;OCR PDF is included on Free (15 MB). The OCR variants of PDF to Word, PowerPoint and Excel are Premium only, as are AI Summarizer, Translate, Smart Split and Smart Redact.&lt;/li&gt;
&lt;li&gt;Premium is 5 USD/month billed annually (60 USD/year) or 9 USD/month monthly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Where your file goes:&lt;/strong&gt; Uploaded. The privacy policy states files are deleted "within TWO (2) HOURS of being processed" and that processing happens, as a general rule, within the European Economic Area.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Good for:&lt;/strong&gt; large batch jobs (merging a lot of files, compressing big scans).&lt;br&gt;
&lt;strong&gt;Not good for:&lt;/strong&gt; text editing on Free is limited, and the OCR tools you most likely want (scan to Word) are paid.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. PDF24 Tools
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; The quiet workhorse of this list. Run by Geek Software GmbH in Germany since 2006, ad-funded, with a large set of tools including OCR, and a free desktop app (PDF24 Creator) that works offline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free plan, in facts (from the FAQ):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Free of charge and without restriction," including for companies. No daily quota, no file-size limit stated, no Pro tier.&lt;/li&gt;
&lt;li&gt;OCR is available online for free.&lt;/li&gt;
&lt;li&gt;Companies processing third-party personal data online are asked to sign a data processing agreement. The desktop Creator does not need one because files never leave the machine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Where your file goes:&lt;/strong&gt; Uploaded for the online tools, to servers in EU data centres, and deleted "within one hour." The desktop app keeps everything local.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Good for:&lt;/strong&gt; anyone who wants everything free with no quotas and does not mind ads.&lt;br&gt;
&lt;strong&gt;Not good for:&lt;/strong&gt; the online text editor is basic compared with the others, and the interface shows its age. Sensitive files should go through the desktop app, not the website.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Sejda
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; A well-regarded editor with one of the better "edit existing text" tools among server-based services, plus a desktop version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free plan, in facts:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The pricing page says Free has "Page and hourly limits," "Limited file uploads" and "Tool specific limits," without figures.&lt;/li&gt;
&lt;li&gt;The desktop page prints the numbers for the free desktop tier: "3 tasks per day," "Docs up to 50MB and 200 pages," "OCR files up to 10 pages," "Images up to 5MB," "Combine up to 30 files and 50 pages." The web version is usually described in reviews as 3 tasks per hour with the same size and page caps.&lt;/li&gt;
&lt;li&gt;Paid plans go up to 500 MB per file and OCR up to 100 pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Where your file goes:&lt;/strong&gt; Uploaded for the web version (Sejda's own help pages describe automatic deletion after a few hours). Local for the desktop app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Good for:&lt;/strong&gt; occasional text edits on small documents when you only need a couple of tasks.&lt;br&gt;
&lt;strong&gt;Not good for:&lt;/strong&gt; anything over 200 pages or 50 MB, scans needing OCR beyond 10 pages, or repeated use in one sitting.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Adobe Acrobat online
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; Adobe's free web tools, tied to an Adobe account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free plan, in facts (from Adobe's own editor page):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Free: comments, sticky notes, text boxes, highlights, freehand drawing, Fill &amp;amp; Sign and digital signatures.&lt;/li&gt;
&lt;li&gt;The page states plainly that the free version "does not let you edit existing body text within a PDF." Changing text or fonts requires Acrobat Pro, with a 7-day trial.&lt;/li&gt;
&lt;li&gt;Sign-in is required to download or share edited files. Unsaved files are deleted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Where your file goes:&lt;/strong&gt; Uploaded to Adobe's cloud.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Good for:&lt;/strong&gt; annotating and signing when your team already lives in Adobe.&lt;br&gt;
&lt;strong&gt;Not good for:&lt;/strong&gt; editing text for free. It is the most searched use case and it is not in the free tier.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. PDFkind
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; A free editor that runs entirely in the browser tab. The PDF is parsed, rendered, edited and rebuilt on your own device with open-source libraries. Nothing is uploaded, there is no account, and it is funded by ads rather than a Pro tier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free plan, in facts:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;There is one plan. No daily task limit, no file-size cap other than what your browser's memory can hold.&lt;/li&gt;
&lt;li&gt;Editing existing text is included: the original embedded font is kept, and multi-line paragraphs can be re-flowed as one block.&lt;/li&gt;
&lt;li&gt;OCR runs in the browser too (Tesseract compiled to WebAssembly), so a scanned PDF becomes searchable and editable without the scan leaving your machine. It is slower than a server for very long documents.&lt;/li&gt;
&lt;li&gt;Also included: whiteout and true redaction (the text objects and image pixels are removed, not covered), add text, images, shapes and signatures, fill forms, merge, split, rotate, reorder and delete pages, compress, and a keyboard-driven interface in 8 languages including Arabic with right-to-left text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Where your file goes:&lt;/strong&gt; Nowhere. The file is read by the browser and never sent to a server. You can check this yourself: open the browser's network panel while editing and you will see no upload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it is weaker:&lt;/strong&gt; OCR on a 300-page scan takes minutes rather than seconds because your laptop is doing the work. Very large files are limited by device memory. There is no cloud storage, no mobile app, and no collaboration features.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Good for:&lt;/strong&gt; editing or redacting anything you would not want on someone else's server, and for people who are tired of hitting a daily quota.&lt;br&gt;
&lt;strong&gt;Not good for:&lt;/strong&gt; heavy batch processing of huge files on a low-end device.&lt;/p&gt;




&lt;h2&gt;
  
  
  At a glance
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Edit existing text on the free plan&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Smallpdf: no (Pro)&lt;/li&gt;
&lt;li&gt;iLovePDF: limited&lt;/li&gt;
&lt;li&gt;PDF24: yes (basic)&lt;/li&gt;
&lt;li&gt;Sejda: yes, within quota&lt;/li&gt;
&lt;li&gt;Adobe online: no (Pro)&lt;/li&gt;
&lt;li&gt;PDFkind: yes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;OCR on the free plan&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Smallpdf: no (Pro)&lt;/li&gt;
&lt;li&gt;iLovePDF: yes, 15 MB, OCR-to-Office is paid&lt;/li&gt;
&lt;li&gt;PDF24: yes&lt;/li&gt;
&lt;li&gt;Sejda: yes, up to 10 pages&lt;/li&gt;
&lt;li&gt;Adobe online: no&lt;/li&gt;
&lt;li&gt;PDFkind: yes, unlimited, runs on your device&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Free usage quota&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Smallpdf: daily download limit (number not published)&lt;/li&gt;
&lt;li&gt;iLovePDF: per-tool file and batch limits, no stated task cap&lt;/li&gt;
&lt;li&gt;PDF24: none&lt;/li&gt;
&lt;li&gt;Sejda: 3 tasks per day (desktop) or per hour (web), 50 MB, 200 pages&lt;/li&gt;
&lt;li&gt;Adobe online: no stated limit, account required&lt;/li&gt;
&lt;li&gt;PDFkind: none&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Is your file uploaded?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Smallpdf: yes, EU servers, deleted within 1 hour (account holders)&lt;/li&gt;
&lt;li&gt;iLovePDF: yes, EEA, deleted within 2 hours&lt;/li&gt;
&lt;li&gt;PDF24: yes online (EU, deleted within 1 hour); no with the desktop app&lt;/li&gt;
&lt;li&gt;Sejda: yes online; no with the desktop app&lt;/li&gt;
&lt;li&gt;Adobe online: yes, Adobe cloud&lt;/li&gt;
&lt;li&gt;PDFkind: no&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  How to choose
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You need to change the words in a PDF, for free, today:&lt;/strong&gt; PDFkind, Sejda (if the file is small and you have fewer than three tasks) or PDF24.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You have a scan and need it searchable:&lt;/strong&gt; PDFkind or PDF24 for unlimited pages; iLovePDF if the file is under 15 MB and you want it fast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You are merging 25 files or compressing a 200 MB scan:&lt;/strong&gt; iLovePDF.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The document is confidential:&lt;/strong&gt; anything that does not upload. That means PDFkind in the browser, or the desktop versions of PDF24 and Sejda.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You already pay for Adobe:&lt;/strong&gt; Acrobat Pro does all of this and more; the free online tools are just a preview.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A one-hour or two-hour deletion policy is a reasonable promise from a reputable company, but it is still a promise you have to trust. For most documents that is fine. For the ones that are not, the simplest guarantee is a tool that never asks for the upload in the first place.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Smallpdf, Free vs Pro plan comparison: &lt;a href="https://smallpdf.com/blog/smallpdf-free-vs-pro-plan-comparison" rel="noopener noreferrer"&gt;https://smallpdf.com/blog/smallpdf-free-vs-pro-plan-comparison&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Smallpdf pricing: &lt;a href="https://smallpdf.com/pricing" rel="noopener noreferrer"&gt;https://smallpdf.com/pricing&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Smallpdf privacy policy (retention, Hetzner EU servers): &lt;a href="https://smallpdf.com/privacy" rel="noopener noreferrer"&gt;https://smallpdf.com/privacy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;iLovePDF pricing and free limits: &lt;a href="https://www.ilovepdf.com/pricing" rel="noopener noreferrer"&gt;https://www.ilovepdf.com/pricing&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;iLovePDF privacy (deletion within two hours, EEA processing): &lt;a href="https://www.ilovepdf.com/help/privacy" rel="noopener noreferrer"&gt;https://www.ilovepdf.com/help/privacy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PDF24 Tools FAQ (free without restriction, EU servers, deletion within one hour): &lt;a href="https://tools.pdf24.org/en/faq" rel="noopener noreferrer"&gt;https://tools.pdf24.org/en/faq&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PDF24 OCR tool: &lt;a href="https://tools.pdf24.org/en/ocr-pdf" rel="noopener noreferrer"&gt;https://tools.pdf24.org/en/ocr-pdf&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Sejda pricing: &lt;a href="https://www.sejda.com/pricing" rel="noopener noreferrer"&gt;https://www.sejda.com/pricing&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Sejda desktop free limits: &lt;a href="https://www.sejda.com/desktop" rel="noopener noreferrer"&gt;https://www.sejda.com/desktop&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Adobe Acrobat online free editor (free features, text editing requires Pro): &lt;a href="https://www.adobe.com/acrobat/online/pdf-editor.html" rel="noopener noreferrer"&gt;https://www.adobe.com/acrobat/online/pdf-editor.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PDFkind: &lt;a href="https://pdfkind.com" rel="noopener noreferrer"&gt;https://pdfkind.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Figures were checked in October 2026. Vendors change limits without notice, so verify on the linked pages before relying on a number.&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>privacy</category>
      <category>pdf</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Someone in the comments found a real gap: covered pixels still live in the embedded scan. Fixing it now. If you work with PDFs or on-device ML, I’d love more eyes like that on it.</title>
      <dc:creator>Madhi Omar</dc:creator>
      <pubDate>Tue, 13 Oct 2026 14:12:38 +0000</pubDate>
      <link>https://dev.to/omarmdhi/someone-in-the-comments-found-a-real-gap-covered-pixels-still-live-in-the-embedded-scan-fixing-it-4l98</link>
      <guid>https://dev.to/omarmdhi/someone-in-the-comments-found-a-real-gap-covered-pixels-still-live-in-the-embedded-scan-fixing-it-4l98</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/omarmdhi/running-ocr-entirely-in-the-browser-and-making-scanned-pdfs-editable-5bp2" class="crayons-story__hidden-navigation-link"&gt;Running OCR entirely in the browser (and making scanned PDFs editable)&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/omarmdhi" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2382819%2F50acb9d5-9c03-41c7-a9ba-5498bb734274.jpeg" alt="omarmdhi profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/omarmdhi" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Madhi Omar
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Madhi Omar
                
                
              
              &lt;div id="story-author-preview-content-4842671" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/omarmdhi" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2382819%2F50acb9d5-9c03-41c7-a9ba-5498bb734274.jpeg" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Madhi Omar&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/omarmdhi/running-ocr-entirely-in-the-browser-and-making-scanned-pdfs-editable-5bp2" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Oct 13&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/omarmdhi/running-ocr-entirely-in-the-browser-and-making-scanned-pdfs-editable-5bp2" id="article-link-4842671"&gt;
          Running OCR entirely in the browser (and making scanned PDFs editable)
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag crayons-tag--filled  " href="/t/showdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;showdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/webdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;webdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/javascript"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;javascript&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/machinelearning"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;machinelearning&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/omarmdhi/running-ocr-entirely-in-the-browser-and-making-scanned-pdfs-editable-5bp2" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;2&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/omarmdhi/running-ocr-entirely-in-the-browser-and-making-scanned-pdfs-editable-5bp2#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              4&lt;span class="hidden s:inline"&gt;&amp;nbsp;comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            9 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
      <category>javascript</category>
      <category>machinelearning</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Running OCR entirely in the browser (and making scanned PDFs editable)</title>
      <dc:creator>Madhi Omar</dc:creator>
      <pubDate>Tue, 13 Oct 2026 13:56:38 +0000</pubDate>
      <link>https://dev.to/omarmdhi/running-ocr-entirely-in-the-browser-and-making-scanned-pdfs-editable-5bp2</link>
      <guid>https://dev.to/omarmdhi/running-ocr-entirely-in-the-browser-and-making-scanned-pdfs-editable-5bp2</guid>
      <description>&lt;p&gt;A few months ago I needed to change one line in a scanned contract. Every "free" PDF site I tried wanted me to upload the file to their server first. One let me edit for ten minutes and then asked me to pay to download the result.&lt;/p&gt;

&lt;p&gt;That's a strange deal for a contract, a passport scan or a payslip. So I started building &lt;a href="https://pdfkind.com" rel="noopener noreferrer"&gt;PDFkind&lt;/a&gt;, a PDF editor where everything happens in the browser. The hardest part by far was OCR: reading the text out of a scan, then turning it into real text you can edit.&lt;/p&gt;

&lt;p&gt;This post is about how that works, what broke along the way, and the numbers I got. Everything runs client-side; the server only hands out static files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint: no server, no exceptions
&lt;/h2&gt;

&lt;p&gt;The rule I set myself was simple: &lt;strong&gt;the document never leaves the device&lt;/strong&gt;. No "we delete it after an hour", no API call to a cloud OCR service. That rules out the easy options and leaves two questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which OCR engine can run in a browser tab with acceptable quality?&lt;/li&gt;
&lt;li&gt;How big a download can I ask people to accept?&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  First try: Tesseract.js
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/naptha/tesseract.js" rel="noopener noreferrer"&gt;Tesseract.js&lt;/a&gt; is the obvious choice. It's mature, easy to use, and works well on clean scans. On my test set it was fine for flat, well-lit documents.&lt;/p&gt;

&lt;p&gt;Then I tried phone photos: slightly tilted pages, shadows, uneven lighting. That's what people actually have when they "scan" something. Tesseract mostly fell apart:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Document type&lt;/th&gt;
&lt;th&gt;Tesseract.js&lt;/th&gt;
&lt;th&gt;PaddleOCR (what I ended up with)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Poor-quality scans&lt;/td&gt;
&lt;td&gt;3.5 to 6.4% of characters wrong&lt;/td&gt;
&lt;td&gt;about 1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone photos&lt;/td&gt;
&lt;td&gt;unusable&lt;/td&gt;
&lt;td&gt;10 to 14%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;(Character error rate on my own small test set of French and English documents. Not a benchmark, but the gap was big enough to decide.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The difference comes from how the text is found. Tesseract relies on classical layout analysis, which struggles once the page isn't flat and evenly lit. PaddleOCR uses a neural network to find text lines first, and that detector copes much better with real-world photos.&lt;/p&gt;

&lt;h2&gt;
  
  
  PaddleOCR in the browser with ONNX Runtime Web
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/PaddlePaddle/PaddleOCR" rel="noopener noreferrer"&gt;PaddleOCR&lt;/a&gt; is normally a Python library, but its models can be exported to ONNX and run with &lt;a href="https://onnxruntime.ai/docs/tutorials/web/" rel="noopener noreferrer"&gt;onnxruntime-web&lt;/a&gt; in WebAssembly. I use the PP-OCRv5 mobile models:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;detection&lt;/strong&gt; model that finds text lines&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;recognition&lt;/strong&gt; model for Latin-script languages (English, French, Spanish, German and so on)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Together with the WASM runtime that's about &lt;strong&gt;20 MB&lt;/strong&gt;. It's only downloaded the first time someone uses OCR, and it's cached for 30 days:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.ts&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/ocr/:file*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cache-Control&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;public, max-age=2592000, stale-while-revalidate=86400&lt;/span&gt;&lt;span class="dl"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One setting worth knowing about: I run ONNX Runtime with &lt;code&gt;numThreads = 1&lt;/code&gt;. Multi-threaded WASM needs &lt;code&gt;SharedArrayBuffer&lt;/code&gt;, which in turn needs the page to be cross-origin isolated (COOP/COEP headers). Those headers break a lot of third-party embeds. One thread is slower, but a page of OCR still takes a few seconds, which is fine.&lt;/p&gt;

&lt;p&gt;OCR happens in two steps: find the lines, then read each line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: finding the text lines
&lt;/h2&gt;

&lt;p&gt;The detector (a DBNet model) takes the page image and returns a &lt;strong&gt;probability map&lt;/strong&gt;: for every pixel, how likely it is to be text.&lt;/p&gt;

&lt;p&gt;Preparing the input has a couple of traps. The image size must be a multiple of 32, and PaddleOCR models expect &lt;strong&gt;BGR&lt;/strong&gt; channel order, not RGB, normalized with the ImageNet mean and standard deviation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MEAN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.485&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.456&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.406&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;STD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.229&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.224&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.225&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="c1"&gt;// resize so the longest side is at most 1600 px, rounded to multiples of 32&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&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;min&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="mi"&gt;1600&lt;/span&gt; &lt;span class="o"&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;W&lt;/span&gt; &lt;span class="o"&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&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;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;H&lt;/span&gt; &lt;span class="o"&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&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;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;px&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getPixels&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;W&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;H&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// RGBA from a resized canvas&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;plane&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;W&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;H&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&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;Float32Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;plane&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;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;plane&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&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="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;             &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;px&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;MEAN&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;STD&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// B&lt;/span&gt;
  &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;plane&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;px&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;MEAN&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="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;STD&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="c1"&gt;// G&lt;/span&gt;
  &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;plane&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;px&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;     &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;MEAN&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;STD&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// R&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inputNames&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;ort&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Tensor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;float32&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;H&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;W&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 probability map isn't a list of boxes yet. Turning it into lines is the post-processing that the Python library normally does for you, so I had to rewrite it in TypeScript:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Threshold&lt;/strong&gt; the map (pixels above 0.3 count as text).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flood fill&lt;/strong&gt; to group connected text pixels into regions.&lt;/li&gt;
&lt;li&gt;Drop regions that are too small or whose average score is below 0.6.&lt;/li&gt;
&lt;li&gt;Compute each region's &lt;strong&gt;convex hull&lt;/strong&gt;, then the &lt;strong&gt;minimum-area rotated rectangle&lt;/strong&gt; around it. This is what makes tilted lines work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expand ("unclip")&lt;/strong&gt; the rectangle, because the model predicts a slightly shrunk core of each line.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The expansion distance comes from the DBNet paper: area × ratio ÷ perimeter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;UNCLIP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// UNCLIP = 1.6&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The bug that cost me a day:&lt;/strong&gt; my first version found the four corners of each box by sorting them by y coordinate. That works on a flat scan. On a tilted photo, the top-right corner of a line can be &lt;em&gt;lower&lt;/em&gt; than its bottom-left corner, so lines got read upside down or mirrored. Recognition on photos was garbage and I couldn't figure out why.&lt;/p&gt;

&lt;p&gt;The fix was to stop guessing corners from coordinates and derive them from the rectangle's angle instead. I keep the angle within ±45° (swapping width and height when needed), so the width always runs along the text line. Then the corners come out in a known order: top-left, top-right, bottom-right, bottom-left.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: reading each line
&lt;/h2&gt;

&lt;p&gt;The recognizer expects one straight, horizontal strip of text, 48 pixels high. So each detected line, which may be tilted, has to be cut out and straightened.&lt;/p&gt;

&lt;p&gt;Canvas can do this with a single affine transform. Map three corners of the detected box to the corners of the output strip, invert the matrix, and draw the page through it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;crop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HTMLCanvasElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quad&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;][],&lt;/span&gt; &lt;span class="nx"&gt;boxW&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;boxH&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;48&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="nx"&gt;w&lt;/span&gt; &lt;span class="o"&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&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;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;boxW&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;boxH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;outH&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;outH&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;quad&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// top-left, top-right, bottom-left&lt;/span&gt;
  &lt;span class="c1"&gt;// matrix mapping strip coordinates to page coordinates...&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;p0&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p1&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="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;p0&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="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p3&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;p0&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p3&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="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;p0&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="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...inverted, so drawing the page lands the line flat in the strip&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;det&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;cc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ia&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;det&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ib&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;det&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;cc&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;det&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;det&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTransform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ia&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ia&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;p0&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ic&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;p0&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="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ib&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;p0&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;p0&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="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getImageData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&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 recognizer outputs, for each horizontal position in the strip, a probability for every character in its dictionary. Turning that into text is &lt;strong&gt;CTC greedy decoding&lt;/strong&gt;: take the most likely character at each step, merge repeats, drop the "blank" symbol.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// dictionary: blank first, space last (PaddleOCR convention)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;conf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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;let&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;t&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="kc"&gt;Infinity&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;let&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;classes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;k&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;classes&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;k&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="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;k&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="nx"&gt;conf&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;n&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="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;best&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 average probability of the kept characters gives a confidence score. I skip lines below 0.4: they're usually logos, stamps or noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Arabic needed a different approach
&lt;/h2&gt;

&lt;p&gt;I'm from Morocco, and a lot of documents here mix French and Arabic, sometimes in the same line. PaddleOCR's Arabic recognizer was disappointing in my tests: it dropped digits and letters often enough to be useless for things like dates and ID numbers.&lt;/p&gt;

&lt;p&gt;What worked was a hybrid:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;keep &lt;strong&gt;PaddleOCR's detector&lt;/strong&gt; to find the lines, since it's robust to photos&lt;/li&gt;
&lt;li&gt;read Arabic lines with &lt;strong&gt;Tesseract&lt;/strong&gt; in single-line mode, on a cleaned-up crop of each line&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cleanup makes a big difference with Tesseract. For each line strip I estimate the background with a max filter (the paper is the brightest thing around), blur it, divide the image by it to flatten shadows and uneven light, then stretch the contrast between two percentiles. Tesseract then sees near-black text on near-white paper.&lt;/p&gt;

&lt;p&gt;Two more details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Line joining.&lt;/strong&gt; The detector often splits a line at wide gaps, for example after a colon. For Arabic I join pieces that sit on the same row and are close together (gap under 2.5× the line height), then order them right to left. The gap limit matters: without it, a French heading on the left and an Arabic heading on the right got merged into one nonsense line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Tesseract setting that silently drops spaces.&lt;/strong&gt; I had &lt;code&gt;preserve_interword_spaces: '1'&lt;/code&gt; enabled. On Arabic it removed the spaces between words entirely ("شهادةعمل" instead of "شهادة عمل"). Turning it off fixed it. If your Arabic OCR output has words glued together, check this first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On my Arabic test documents the hybrid gets about 2 to 4% of characters wrong on scans and around 4% on phone photos. Not perfect, but very usable.&lt;/p&gt;

&lt;h2&gt;
  
  
  From "searchable" to "editable"
&lt;/h2&gt;

&lt;p&gt;Recognizing text is half the job. The classic output of OCR is a &lt;strong&gt;searchable PDF&lt;/strong&gt;: the recognized text is added as an invisible layer behind the image, so you can select and copy it while the page looks unchanged.&lt;/p&gt;

&lt;p&gt;That's useful, but it doesn't answer the question people actually ask: &lt;em&gt;"how do I change the text in my scan?"&lt;/em&gt; So the editor has an option to go further. Each recognized line gets:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;a &lt;strong&gt;cover&lt;/strong&gt; in the color of the paper, hiding the original pixels&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;real text box&lt;/strong&gt; on top, with the same words, roughly the same font, size, weight, slant and ink color&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Getting that to look right is mostly careful measuring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Paper and ink color.&lt;/strong&gt; The paper color is the most common color along the edges of the line strip (bucketed to 5 bits per channel so noise doesn't matter). The ink color is the average of the darkest pixels inside the letters. Edge pixels blend with the paper, so averaging all of them would make the text look washed out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Font size.&lt;/strong&gt; I measure the height of the ink from the top of the letters to the baseline, then ask the browser how tall the same text is in a candidate font:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`100px &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;family&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;measureText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;inkHeightAboveBaseline&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actualBoundingBoxAscent&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using &lt;code&gt;actualBoundingBoxAscent&lt;/code&gt; for the &lt;em&gt;exact&lt;/em&gt; text matters. A line with no capitals or tall letters ("one more") is shorter than "Total", and a fixed cap-height ratio gets the size wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which font?&lt;/strong&gt; I can't identify arbitrary typefaces, but I can tell serif from sans serif. For each candidate (Helvetica, Times), I draw the recognized text into an offscreen canvas, stretched to the same length as the original, and measure how well it overlaps the original's letter shapes (intersection over union). The best overlap wins. Since a page is usually set in one typeface, a family that clearly wins most lines also gets a bonus on close calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bold.&lt;/strong&gt; My first attempt compared stroke thickness, and it failed on blurry scans, where everything looks bold. What works is comparing the &lt;strong&gt;total amount of ink&lt;/strong&gt; in a line with how much ink the regular-weight font would use for the same text. Blur spreads ink out but doesn't change the total. And because a faded scan affects every line alike, I compare each line with the page's median rather than an absolute threshold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Italic.&lt;/strong&gt; I shear the letter shapes by different amounts and keep the shear at which the vertical strokes line up best (the sum of squared column counts peaks). Upright text peaks at 0; italic peaks at around 0.1 to 0.25.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Width.&lt;/strong&gt; Finally, a little letter spacing makes the new text exactly as long as the original, so it doesn't overlap whatever comes after it.&lt;/p&gt;

&lt;p&gt;The result isn't pixel-perfect. Unusual fonts come out as Helvetica or Times, and handwriting is left alone. But for the common case (fix a date, a name, an amount on a scanned letter) it's close enough that the change is hard to spot.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone trying this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Test on phone photos early.&lt;/strong&gt; Clean scans hide almost every problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ONNX Runtime Web is production-ready&lt;/strong&gt;, but you have to rewrite the pre- and post-processing that Python libraries normally hide. Budget time for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache the models aggressively&lt;/strong&gt; and load them only when the feature is used. Nobody should download 20 MB just to merge two PDFs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure with real documents&lt;/strong&gt; and track a simple error rate. Several of my "obvious" improvements made things worse.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Everything above is live in &lt;a href="https://pdfkind.com" rel="noopener noreferrer"&gt;PDFkind&lt;/a&gt;. It's free, with no signup. Open a scanned PDF in the editor, tick &lt;strong&gt;"Make scanned text editable"&lt;/strong&gt;, and you can check in DevTools that your file never goes over the network.&lt;/p&gt;

&lt;p&gt;I'd love feedback, especially from anyone who has worked on OCR or PDF internals. Where does it break for you?&lt;/p&gt;

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