<?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: Mike Printz</title>
    <description>The latest articles on DEV Community by Mike Printz (@getdropsheet).</description>
    <link>https://dev.to/getdropsheet</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%2F4148265%2F39fd2411-78ca-45fc-b599-54469e35a8fc.png</url>
      <title>DEV Community: Mike Printz</title>
      <link>https://dev.to/getdropsheet</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/getdropsheet"/>
    <language>en</language>
    <item>
      <title>I ran OCR in the browser so receipt images never leave the device</title>
      <dc:creator>Mike Printz</dc:creator>
      <pubDate>Tue, 29 Sep 2026 01:13:13 +0000</pubDate>
      <link>https://dev.to/getdropsheet/i-ran-ocr-in-the-browser-so-receipt-images-never-leave-the-device-22aj</link>
      <guid>https://dev.to/getdropsheet/i-ran-ocr-in-the-browser-so-receipt-images-never-leave-the-device-22aj</guid>
      <description>&lt;p&gt;Every receipt scanner I looked at uploaded the image to a server to read it. That makes sense technically. It made no sense to me otherwise.&lt;/p&gt;

&lt;p&gt;A year of receipts is a fairly complete picture of a person. Which pharmacy you use. Which bar. Whether you hired a lawyer. Where you were on a Tuesday night in March. Most of them carry the last four of your card across the bottom. I did not want mine sitting on someone else's disk, and I did not want to explain to anyone else why they should be comfortable with theirs.&lt;/p&gt;

&lt;p&gt;So I tried to do the whole thing in the browser. No server, no upload, no account. Here is what that actually cost.&lt;/p&gt;

&lt;p&gt;Tesseract.js is the easy part and the slow part&lt;/p&gt;

&lt;p&gt;The OCR itself is tesseract.js, loaded from a CDN. Getting text out of an image is about ten lines.&lt;/p&gt;

&lt;p&gt;The cost is weight and time. You are shipping a WASM build and a trained language model to the browser before anything happens. On a desktop with a warm cache it is fine. On a phone, on mobile data, the first run is noticeably slower than anyone expects from a web page.&lt;/p&gt;

&lt;p&gt;The mitigation is boring but it works: load the libraries once at page load rather than on demand, and let the browser cache do the rest. After the first visit the tool runs offline. That is a nice property to have accidentally.&lt;/p&gt;

&lt;p&gt;A digital PDF receipt reads essentially instantly, because there is real text in the file and no OCR is needed at all. A phone photo of a printed receipt takes a few seconds. A bad photo takes longer and returns worse text.&lt;/p&gt;

&lt;p&gt;Faded thermal paper defeats it completely&lt;/p&gt;

&lt;p&gt;This is the part I would tell anyone building in this space first.&lt;/p&gt;

&lt;p&gt;Thermal receipts do not use ink. They use a heat sensitive dye that keeps reacting after you leave the store. Heat accelerates it, so does sunlight, so does the plastic in a wallet. A gas receipt that spent a summer in a glovebox is often blank by spring. Not faint. Blank.&lt;/p&gt;

&lt;p&gt;No OCR engine recovers that, because there is nothing left to recover. Mine does not either.&lt;/p&gt;

&lt;p&gt;The design question is what you do about it, and I think this is where most tools get it wrong. The tempting behaviour is to return a best guess and let it look like a successful read. That is worse than failing, because the user has no signal that a number in their spreadsheet is fiction.&lt;/p&gt;

&lt;p&gt;So the row stays on the sheet, flagged, with the total left empty. The footer says something like "3 of 4 read, 1 needs a total." You type the missing one yourself.&lt;/p&gt;

&lt;p&gt;It makes the product look less capable in a demo. It makes it trustworthy in use. Given the output ends up in a tax return, that is not a close call.&lt;/p&gt;

&lt;p&gt;Parsing is the part nobody writes about&lt;/p&gt;

&lt;p&gt;OCR gives you a blob of text. Turning that into date, vendor, total, tax is where most of the actual work lives, and it is unglamorous.&lt;/p&gt;

&lt;p&gt;Some of the problems:&lt;/p&gt;

&lt;p&gt;Totals are not labelled consistently. TOTAL, AMOUNT DUE, BALANCE, or nothing at all. Sometimes the largest number on the receipt is the total. Sometimes it is the cash tendered.&lt;/p&gt;

&lt;p&gt;Dates come in every format a point of sale system has ever emitted, and OCR will happily turn a 0 into an O or an 8 into a B.&lt;/p&gt;

&lt;p&gt;The vendor name is usually the first line, except when the first line is a slogan, a store number, or an address.&lt;/p&gt;

&lt;p&gt;Tax lines look like totals.&lt;/p&gt;

&lt;p&gt;There is no clever solution here. It is heuristics, tested against a pile of real receipts, adjusted when something breaks. Anyone claiming otherwise is either using a hosted service or has not tried enough receipts.&lt;/p&gt;

&lt;p&gt;Generating xlsx on the client&lt;/p&gt;

&lt;p&gt;Originally I used SheetJS. It writes a valid workbook, it is well documented, and the community build is free.&lt;/p&gt;

&lt;p&gt;What the community build will not do is cell styling. No bold headers, no fills, no borders. You can set column widths, number formats and autofilter, and that is where it stops.&lt;/p&gt;

&lt;p&gt;For a while that was fine. Then I looked at the output the way a user would and it was a grid of unformatted text with 18.4 in a column that should read $18.40. Technically correct, and it looked like a data dump rather than something you hand to an accountant.&lt;/p&gt;

&lt;p&gt;I swapped to ExcelJS. It is roughly the same payload, it supports real styling, and it took an afternoon. Now the export has a header row, banded rows, currency formatting, a frozen header, an autofilter, and a second sheet with totals by category.&lt;/p&gt;

&lt;p&gt;One detail worth passing on: write dates as real Date objects with a numFmt, not as strings. Text dates sort alphabetically, which means 01/05 sorts before 12/31/2025. If your output is someone's year of expenses, that quietly corrupts their ordering.&lt;/p&gt;

&lt;p&gt;And parse YYYY-MM-DD into a local date rather than letting the default constructor treat it as UTC, or every date renders a day early for anyone west of Greenwich.&lt;/p&gt;

&lt;p&gt;Shipping as a single HTML file&lt;/p&gt;

&lt;p&gt;The whole thing is one HTML file. Style inlined, script inlined, libraries from a CDN. You download it, double click it, and it runs.&lt;/p&gt;

&lt;p&gt;What that buys:&lt;/p&gt;

&lt;p&gt;The privacy claim is checkable. Anyone can open the file in a text editor and read what it does. Anyone can run it with the network tab open and watch nothing leave. That is a stronger statement than a privacy policy, because it does not require trusting me.&lt;/p&gt;

&lt;p&gt;It works offline after first load.&lt;/p&gt;

&lt;p&gt;There is no backend to secure, no database to breach, no account system to get wrong, and no subscription to cancel.&lt;/p&gt;

&lt;p&gt;What it costs:&lt;/p&gt;

&lt;p&gt;No sync, no multi-device, no server-side processing for anything heavy. Every constraint of the browser is your constraint.&lt;/p&gt;

&lt;p&gt;The file must stay small enough to be readable by a human who wants to check it, which rules out a lot of convenient dependencies.&lt;/p&gt;

&lt;p&gt;The part I would do the same way again&lt;/p&gt;

&lt;p&gt;Everything runs locally, which means you can verify it rather than believe it. Open devtools, watch the network tab, drop a receipt in. Nothing goes out.&lt;/p&gt;

&lt;p&gt;That is not a policy. It is just how it is built, and it is the only version of a privacy claim I would be comfortable making.&lt;/p&gt;

&lt;p&gt;I build DropSheet, which is the tool described above. It converts receipt photos and PDFs into a spreadsheet, entirely in the browser.&lt;/p&gt;

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