<?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: İlhan Neğiş</title>
    <description>The latest articles on DEV Community by İlhan Neğiş (@ilhannegis).</description>
    <link>https://dev.to/ilhannegis</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F15255%2F73355b2a-e996-46e7-8766-abc8900dc352.jpeg</url>
      <title>DEV Community: İlhan Neğiş</title>
      <link>https://dev.to/ilhannegis</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ilhannegis"/>
    <language>en</language>
    <item>
      <title>Built a Free Analytics Platform, Here's Why</title>
      <dc:creator>İlhan Neğiş</dc:creator>
      <pubDate>Wed, 18 Mar 2026 06:12:07 +0000</pubDate>
      <link>https://dev.to/ilhannegis/built-a-free-analytics-platform-heres-why-mmk</link>
      <guid>https://dev.to/ilhannegis/built-a-free-analytics-platform-heres-why-mmk</guid>
      <description>&lt;h2&gt;
  
  
  The Problem That Bugged Me
&lt;/h2&gt;

&lt;p&gt;I run my own sites, and every time I set up analytics, the same cycle repeated. Install Google Analytics, add a cookie banner, feel weird about handing over my visitors' browsing data to a company that already knows way too much about everyone. Try a privacy-friendly alternative, hit a paywall the moment traffic picks up. Rinse, repeat.&lt;/p&gt;

&lt;p&gt;I figured there had to be a better way. Turns out, there wasn't, so I made one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Nanolytica Does
&lt;/h2&gt;

&lt;p&gt;Nanolytica is privacy-first analytics that runs without cookies, without consent banners, and without storing any personal data. IPs get hashed, Do Not Track is honored, and the whole thing is GDPR and CCPA compliant by design, not bolted on as an afterthought.&lt;/p&gt;

&lt;p&gt;You sign up, paste one script tag on your site, and that's it. Real-time dashboard, engagement tracking, scroll depth, referrer analysis, bot detection, it's all there from the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It's Free
&lt;/h2&gt;

&lt;p&gt;Because I think basic analytics shouldn't cost money. No tiers, no plans, no "upgrade to unlock" nonsense. You get unlimited pageviews, up to 50 sites, CSV exports, real-time data, everything. No credit card required, ever.&lt;/p&gt;

&lt;p&gt;I built this because I needed it. And if I needed it, I figured a lot of other people did too.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's Also Open Source
&lt;/h2&gt;

&lt;p&gt;The entire codebase is on GitHub under the MIT license. If you want to self-host it, clone the repo, build the single binary, and run it on your own server. Full control, no strings attached.&lt;/p&gt;

&lt;p&gt;But most people just use the cloud version at &lt;a href="https://nanolytica.org" rel="noopener noreferrer"&gt;nanolytica.org&lt;/a&gt;. Sign up takes 30 seconds and there's nothing to maintain on your end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who I Built This For
&lt;/h2&gt;

&lt;p&gt;Indie developers, bloggers, small business owners, anyone running a side project, basically anyone who wants clean, honest analytics without the baggage. If you've been looking for a reason to move on from Google Analytics, I'd love for you to give Nanolytica a try.&lt;/p&gt;

&lt;p&gt;It's free. It's private. And it's yours.&lt;/p&gt;

&lt;p&gt;Head over to &lt;a href="https://nanolytica.org" rel="noopener noreferrer"&gt;nanolytica.org&lt;/a&gt; and see what you think.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>cloud</category>
      <category>clickhouse</category>
      <category>gdpr</category>
    </item>
    <item>
      <title>talkDOM: A Tiny Message-Passing Runtime for the DOM</title>
      <dc:creator>İlhan Neğiş</dc:creator>
      <pubDate>Sat, 07 Mar 2026 13:33:05 +0000</pubDate>
      <link>https://dev.to/ilhannegis/talkdom-a-tiny-message-passing-runtime-for-the-dom-4jgc</link>
      <guid>https://dev.to/ilhannegis/talkdom-a-tiny-message-passing-runtime-for-the-dom-4jgc</guid>
      <description>&lt;p&gt;Modern frontend development often assumes that building interactive web interfaces requires a heavy JavaScript framework. Over the years, frameworks have grown increasingly complex, introducing components, state management systems, build pipelines, and large runtime bundles.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://talkdom.org" rel="noopener noreferrer"&gt;talkdom.org&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/eringen/talkdom" rel="noopener noreferrer"&gt;github&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But the web already has a powerful platform: &lt;strong&gt;HTML + the DOM + HTTP&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The idea behind &lt;strong&gt;talkDOM&lt;/strong&gt; is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What if DOM elements could send messages to each other?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Instead of wiring JavaScript handlers everywhere, &lt;strong&gt;HTML becomes a small message-passing language&lt;/strong&gt;. Elements act as &lt;strong&gt;senders&lt;/strong&gt; and &lt;strong&gt;receivers&lt;/strong&gt;, and interactions are expressed declaratively.&lt;/p&gt;

&lt;p&gt;The result is a tiny runtime (only a few kilobytes) that enables fetching data, updating DOM fragments, composing pipelines, polling endpoints, and controlling navigation — all from HTML.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Core Idea
&lt;/h2&gt;

&lt;p&gt;In talkDOM, elements communicate using &lt;strong&gt;Smalltalk-style messages&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A sender contains a &lt;code&gt;sender&lt;/code&gt; attribute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;sender=&lt;/span&gt;&lt;span class="s"&gt;"posts get:/posts | list apply:inner"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  Load Posts
&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And somewhere in the page there is a receiver:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;receiver=&lt;/span&gt;&lt;span class="s"&gt;"list"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the button is clicked:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A message is sent to &lt;code&gt;posts&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A GET request is performed&lt;/li&gt;
&lt;li&gt;The response is piped to &lt;code&gt;list&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The list's content is replaced&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;posts get:/posts | list apply:inner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;can be read almost like a sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Posts, get &lt;code&gt;/posts&lt;/code&gt;, then list apply the result as inner content."&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Message Pipelines
&lt;/h2&gt;

&lt;p&gt;One of the key features of talkDOM is &lt;strong&gt;message pipelines&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Multiple operations can be composed using &lt;code&gt;|&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;posts get:/posts | transform apply:json | list apply:inner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each step can return a value (or a promise), which is passed to the next step in the pipeline.&lt;/p&gt;

&lt;p&gt;This design is inspired by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Unix pipes&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Smalltalk keyword messages&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;message passing systems&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pipelines allow complex UI behaviors to be expressed with surprisingly little code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Receivers
&lt;/h2&gt;

&lt;p&gt;Receivers are simply DOM elements with a &lt;code&gt;receiver&lt;/code&gt; attribute.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;receiver=&lt;/span&gt;&lt;span class="s"&gt;"posts"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Receivers can also belong to multiple groups:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;receiver=&lt;/span&gt;&lt;span class="s"&gt;"toast error"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Messages sent to &lt;code&gt;toast&lt;/code&gt; will target all matching elements.&lt;/p&gt;

&lt;p&gt;Receivers can optionally restrict what operations they accept:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;receiver=&lt;/span&gt;&lt;span class="s"&gt;"posts"&lt;/span&gt; &lt;span class="na"&gt;accepts=&lt;/span&gt;&lt;span class="s"&gt;"inner append text"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This adds a small layer of safety to DOM updates.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fetching and Updating Content
&lt;/h2&gt;

&lt;p&gt;talkDOM provides built-in methods for HTTP requests and DOM updates.&lt;/p&gt;

&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;sender=&lt;/span&gt;&lt;span class="s"&gt;"posts get:/posts apply:inner"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  Load
&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;receiver=&lt;/span&gt;&lt;span class="s"&gt;"posts"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Supported request methods:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;get:&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;post:&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;put:&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;delete:&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And update operations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;inner&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;append&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;text&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;outer&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Server-Triggered Actions
&lt;/h2&gt;

&lt;p&gt;Servers can trigger client-side actions using a response header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;X-TalkDOM-Trigger
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example response header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;X-TalkDOM-Trigger: toast apply:"Saved!" text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This allows the server to instruct the client to update UI elements without embedding JavaScript.&lt;/p&gt;




&lt;h2&gt;
  
  
  Polling
&lt;/h2&gt;

&lt;p&gt;Receivers can automatically poll an endpoint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;receiver=&lt;/span&gt;&lt;span class="s"&gt;"feed get:/posts poll:5s"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will fetch &lt;code&gt;/posts&lt;/code&gt; every 5 seconds and update the receiver.&lt;/p&gt;

&lt;p&gt;Polling stops automatically if the element is removed from the DOM.&lt;/p&gt;




&lt;h2&gt;
  
  
  Navigation and History
&lt;/h2&gt;

&lt;p&gt;talkDOM also supports history-aware navigation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;sender=&lt;/span&gt;&lt;span class="s"&gt;"page get:/posts apply:inner"&lt;/span&gt; &lt;span class="na"&gt;push-url=&lt;/span&gt;&lt;span class="s"&gt;"/posts"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  Posts
&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When clicked:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The request is executed&lt;/li&gt;
&lt;li&gt;The content is updated&lt;/li&gt;
&lt;li&gt;The URL is pushed to browser history&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Back/forward navigation replays the same message chain.&lt;/p&gt;




&lt;h2&gt;
  
  
  Persistence
&lt;/h2&gt;

&lt;p&gt;Receivers can optionally persist their content in &lt;code&gt;localStorage&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;receiver=&lt;/span&gt;&lt;span class="s"&gt;"cart"&lt;/span&gt; &lt;span class="na"&gt;persist&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After page reload, the content is restored automatically.&lt;/p&gt;




&lt;h2&gt;
  
  
  Events
&lt;/h2&gt;

&lt;p&gt;talkDOM emits lifecycle events after operations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;talkdom:done&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;talkdom:error&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example:&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;talkdom:done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;operation finished&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detail&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;This makes it easy to hook into the runtime when needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Build This?
&lt;/h2&gt;

&lt;p&gt;Projects like &lt;strong&gt;HTMX&lt;/strong&gt;, &lt;strong&gt;Unpoly&lt;/strong&gt;, and &lt;strong&gt;Turbo&lt;/strong&gt; have shown that many interactive interfaces can be built without large frontend frameworks.&lt;/p&gt;

&lt;p&gt;talkDOM explores a slightly different direction.&lt;/p&gt;

&lt;p&gt;Instead of attribute-based APIs like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hx-get="/posts"
hx-target="#list"
hx-swap="inner"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;talkDOM uses &lt;strong&gt;a message syntax&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;posts get:/posts apply:inner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes interactions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;composable&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;pipeline-friendly&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;closer to a programming language&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;HTML becomes a small DSL for UI behavior.&lt;/p&gt;




&lt;h2&gt;
  
  
  Design Goals
&lt;/h2&gt;

&lt;p&gt;talkDOM was built with a few goals in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Tiny runtime&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No build step&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Progressive enhancement&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Composable interactions&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Minimal JavaScript&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The entire runtime is just a few hundred lines of JavaScript.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Is (and Isn’t)
&lt;/h2&gt;

&lt;p&gt;talkDOM is not trying to replace full UI frameworks.&lt;/p&gt;

&lt;p&gt;It is best suited for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;server-rendered applications&lt;/li&gt;
&lt;li&gt;dashboards&lt;/li&gt;
&lt;li&gt;admin panels&lt;/li&gt;
&lt;li&gt;progressively enhanced pages&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where the server remains the primary source of truth.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;The web already provides a powerful programming model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;documents&lt;/li&gt;
&lt;li&gt;events&lt;/li&gt;
&lt;li&gt;HTTP&lt;/li&gt;
&lt;li&gt;the DOM&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sometimes the best tool is not another abstraction layer, but &lt;strong&gt;a small runtime that lets the platform speak for itself&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;talkDOM is an experiment in that direction: turning HTML into a tiny message-passing language for building interactive interfaces.&lt;/p&gt;

&lt;p&gt;The project is available on GitHub:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/eringen/talkdom" rel="noopener noreferrer"&gt;https://github.com/eringen/talkdom&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;and you can see in action @ &lt;a href="https://eringen.com" rel="noopener noreferrer"&gt;eringen.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dom</category>
      <category>dsl</category>
      <category>smalltalk</category>
      <category>htmx</category>
    </item>
    <item>
      <title>SSH Gaming: Turning a Login into a Level</title>
      <dc:creator>İlhan Neğiş</dc:creator>
      <pubDate>Thu, 26 Feb 2026 16:29:09 +0000</pubDate>
      <link>https://dev.to/ilhannegis/ssh-gaming-turning-a-login-into-a-level-2p34</link>
      <guid>https://dev.to/ilhannegis/ssh-gaming-turning-a-login-into-a-level-2p34</guid>
      <description>&lt;p&gt;I recently decided to experiment with OpenSSH's &lt;code&gt;ForceCommand&lt;/code&gt; directive to see how far I could push a "headless" user experience. The result? &lt;strong&gt;XZAP&lt;/strong&gt;, a terminal-based arcade game that you play by literally just SSHing into a server.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnoca1mrljn3f12q6wnrr.jpg" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fnoca1mrljn3f12q6wnrr.jpg" alt="Claude Code" width="497" height="456"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The Experiment
&lt;/h2&gt;

&lt;p&gt;The goal was simple: test how to securely host a public interactive process without ever granting a user a shell. I wanted to see if I could create a seamless transition where the SSH handshake acts as the "Play" button.&lt;/p&gt;

&lt;p&gt;By using the &lt;code&gt;Match User&lt;/code&gt; and &lt;code&gt;ForceCommand&lt;/code&gt; directives in &lt;code&gt;sshd_config&lt;/code&gt;, I locked the &lt;code&gt;gozap&lt;/code&gt; user directly into the game binary. When the user connects, the game starts; when they quit or die, the session drops. No bash, no filesystem access, just pure terminal logic.&lt;/p&gt;
&lt;h2&gt;
  
  
  How it Works
&lt;/h2&gt;

&lt;p&gt;The game is written in Go, utilizing raw terminal mode to capture keypresses (&lt;code&gt;W/A/S/D&lt;/code&gt;) and ANSI escape codes for rendering. It's lightweight, fast, and lives entirely in the buffer of your local terminal emulator.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Stack
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Language:&lt;/strong&gt; Go (Golang)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment:&lt;/strong&gt; Linux (Ubuntu/Debian)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gatekeeper:&lt;/strong&gt; OpenSSH (with a hardened, shell-less configuration)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Why?
&lt;/h2&gt;

&lt;p&gt;Honestly? Mostly for fun. There's something nostalgic about "BBS-style" access where the connection itself is the interface. It's a great way to test SSH security hardening while providing a 30-second distraction for anyone with a terminal.&lt;/p&gt;

&lt;p&gt;If you have a terminal handy, give it a try:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh gozap@eringen.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;source:&lt;/strong&gt; &lt;a href="https://github.com/eringen/gozap" rel="noopener noreferrer"&gt;https://github.com/eringen/gozap&lt;/a&gt;^&lt;/p&gt;

&lt;p&gt;&lt;em&gt;No password required. Just gobble the berries and avoid the aliens.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw97dapqavqsp3yfja209.jpg" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fw97dapqavqsp3yfja209.jpg" alt="Claude Code" width="500" height="783"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>game</category>
      <category>go</category>
      <category>ssh</category>
      <category>terminal</category>
    </item>
    <item>
      <title>Building a Pure Go WebP Encoder with Claude Code</title>
      <dc:creator>İlhan Neğiş</dc:creator>
      <pubDate>Tue, 24 Feb 2026 09:31:34 +0000</pubDate>
      <link>https://dev.to/ilhannegis/building-a-pure-go-webp-encoder-with-claude-code-5fl3</link>
      <guid>https://dev.to/ilhannegis/building-a-pure-go-webp-encoder-with-claude-code-5fl3</guid>
      <description>&lt;p&gt;I built &lt;a href="https://github.com/eringen/gowebper" rel="noopener noreferrer"&gt;gowebper&lt;/a&gt;, a pure Go WebP (VP8L) encoder. Zero dependencies, no CGo, no &lt;code&gt;golang.org/x/image&lt;/code&gt;. Just Go and a spec. Claude Code wrote most of the implementation while I directed the architecture and debugged the gnarliest format bugs alongside it. Here's how it went, what broke, and what I learned.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr573d30bwj18dl3mxy3z.jpg" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fr573d30bwj18dl3mxy3z.jpg" alt="Claude Code" width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why?
&lt;/h2&gt;

&lt;p&gt;Go's standard library can decode WebP but can't encode it. The only option is &lt;code&gt;golang.org/x/image/webp&lt;/code&gt;, which is decode only too. If you want to &lt;em&gt;create&lt;/em&gt; WebP files in Go, you're stuck reaching for CGo bindings to libwebp. I wanted something that could run anywhere Go runs: cross compiled, statically linked, no shared libraries.&lt;/p&gt;

&lt;p&gt;VP8L (WebP lossless) seemed tractable. The spec is public, the format is well documented, and the bitstream is relatively straightforward compared to VP8 (the lossy variant). A Huffman coder, an LZ77 compressor, four image transforms, and a bit writer. How hard could it be?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;The final codebase is about 3,000 lines of Go across six internal packages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;encode.go                        # main encoder, RIFF wrapper, transform orchestration
quantize.go                      # near lossless pre quantization
internal/bitwriter/              # LSB first bit packing (VP8L is LSB first, unlike most formats)
internal/colormodel/             # image.Image to packed ARGB uint32 with fast path type switches
internal/huffman/                # length limited canonical Huffman codes + VP8L tree serialization
internal/lz77/                   # hash chain LZ77 with VP8L's 2D spatial distance table
internal/transform/              # SubtractGreen, Predictor (14 modes), CrossColor (least squares)
internal/vp8ldec/                # reference decoder for round trip testing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude Code generated the initial implementation of each package from my descriptions of the VP8L spec. I'd describe a component, something like "implement canonical Huffman coding with a 15 bit length limit using a min heap", and it would produce working code on the first try. The boilerplate heavy parts (the 120 entry spatial distance table, the 14 predictor modes, the RLE code length encoding) were where it saved the most time.&lt;/p&gt;

&lt;p&gt;The encoder supports 10 compression levels (0 through 9), progressively enabling more transforms and larger LZ77 windows. Level 0 is raw Huffman coded pixels. Level 9 uses all four transforms with a million pixel search window and color cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Things Got Interesting: The Bug Hunt
&lt;/h2&gt;

&lt;p&gt;The initial implementation passed all internal round trip tests immediately. Encode an image, decode it with our own decoder, compare pixels. Perfect. I was feeling good.&lt;/p&gt;

&lt;p&gt;Then I ran &lt;code&gt;dwebp&lt;/code&gt; on the output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Decoding of output.webp failed.
Status: 3(BITSTREAM_ERROR)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every single file. Every level, every image. Our internal decoder was happy, but libwebp rejected everything. This started a multi day debugging session that taught me more about VP8L than I ever wanted to know.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug 1: The Missing Bit
&lt;/h3&gt;

&lt;p&gt;The first bug was a single missing bit.&lt;/p&gt;

&lt;p&gt;VP8L's bitstream has a flag called &lt;code&gt;use_meta&lt;/code&gt;, a 1 bit field that signals whether the image uses meta Huffman coding (multiple Huffman tree groups for different image regions). We don't use meta Huffman, so the correct value is 0. But we weren't writing it at all.&lt;/p&gt;

&lt;p&gt;This bit sits between the color cache flag and the Huffman tree data. Without it, dwebp would read our first Huffman tree bit as the meta Huffman flag, interpret it as "yes, use meta Huffman", and then try to parse a meta Huffman map from what was actually tree data. Instant corruption.&lt;/p&gt;

&lt;p&gt;The subtle part: this flag only exists at the top level image (what libwebp calls &lt;code&gt;is_level0&lt;/code&gt;). Transform sub images (predictor tile data, palette data) don't have it. So you can't just "always write it". You need to know your context.&lt;/p&gt;

&lt;p&gt;I found it by fetching the libwebp source code and reading &lt;code&gt;ReadHuffmanCodes()&lt;/code&gt;. One line: &lt;code&gt;VP8LReadBits(br, 1)&lt;/code&gt;, right there, between color cache and tree reading, guarded by &lt;code&gt;if (is_level0)&lt;/code&gt;. Claude Code had faithfully implemented the VP8L spec but missed this detail because the spec buries it in prose that's easy to skim past.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: one line of code. &lt;code&gt;bw.WriteBits(0, 1)&lt;/code&gt;. In two places.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;After this fix, small images (1x1, 2x2, up to about 200x200) decoded perfectly with dwebp. But test.png (387x429, a real photo) still failed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug 2: The Undercomplete Huffman Tree
&lt;/h3&gt;

&lt;p&gt;VP8L limits Huffman code lengths to 15 bits. When the naive Huffman algorithm produces codes longer than 15 bits (which happens with skewed frequency distributions), you need to "limit" the tree: shorten the deepest codes and redistribute the code space.&lt;/p&gt;

&lt;p&gt;This redistribution must maintain &lt;strong&gt;Kraft equality&lt;/strong&gt;: the sum of 2^(length) across all codes must equal exactly 1. If it's less than 1, the code is "undercomplete" (there are unused bit patterns) and libwebp rejects it.&lt;/p&gt;

&lt;p&gt;Our &lt;code&gt;limitLengths&lt;/code&gt; function had a greedy loop that would lengthen codes (increase depth) to reduce the Kraft sum after clamping. But each lengthening step reduces the sum by a power of 2, and the loop processed all eligible codes in a single pass. For a tree with 225 active symbols, this could overshoot. The Kraft sum would end up at 32,767 instead of 32,768 (undercomplete by 1).&lt;/p&gt;

&lt;p&gt;Binary search revealed the exact threshold: rows=280 of test.png worked, rows=281 failed. At row 281, the green channel tree had enough symbols that &lt;code&gt;limitLengths&lt;/code&gt; would overshoot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Replace the entry based greedy algorithm with a bit count based approach. Process depths from shallowest to deepest (largest reduction first), using integer division to guarantee exact Kraft equality. It's essentially the binary decomposition of the excess, like making change with powers of 2 coins.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug 3: The Spatial Distance Table
&lt;/h3&gt;

&lt;p&gt;With the Kraft fix, Level 0 (no LZ77) worked perfectly at all image sizes. But Levels 1 through 6 (with LZ77 backward references) still failed.&lt;/p&gt;

&lt;p&gt;VP8L uses a 2D spatial distance table for encoding backward reference distances. Instead of storing "120 pixels back", you store a plane code that the decoder maps to a (dx, dy) offset. The first 120 plane codes have predefined spatial offsets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;plane code 1 = (dx=0, dy=1)   pixel directly above
plane code 2 = (dx=1, dy=0)   pixel to the left
plane code 3 = (dx=1, dy=1)   pixel above right
plane code 4 = (dx=-1, dy=1)  pixel above left
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our encoder assumed that pixel distances 1 through 4 mapped directly to plane codes 1 through 4. They don't. Plane code 1 is the pixel &lt;em&gt;above&lt;/em&gt; (distance = image width), not the pixel to the left (distance = 1). Plane code 2 is the pixel to the left.&lt;/p&gt;

&lt;p&gt;This meant every single backward reference with a small distance was pointing to the wrong pixel. Our internal decoder had the same bug in reverse, so round trip tests passed. Both sides were wrong in the same way. But dwebp uses the correct mapping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Include all 120 spatial table entries in the reverse lookup (we were only using entries 5 through 120), and remove the special case for small distances. Also fix the internal decoder to match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Working with Claude Code
&lt;/h2&gt;

&lt;p&gt;The pattern that worked best: I'd describe the architecture and constraints, Claude Code would generate the implementation, and then I'd test against external tools (dwebp, cwebp, Pillow). When something failed, I'd direct the investigation. "Binary search for the failing image size." "Compare our bitstream against cwebp's output byte by byte." "Fetch the libwebp source and find where it reads the meta Huffman flag." Claude Code would execute.&lt;/p&gt;

&lt;p&gt;Claude Code was excellent at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Generating boilerplate heavy code.&lt;/strong&gt; The 120 entry distance table, 14 predictor modes, RLE code length encoding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implementing well specified algorithms.&lt;/strong&gt; Huffman tree construction, canonical code assignment, LZ77 hash chains.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writing targeted debugging tools.&lt;/strong&gt; Bit level comparators, Kraft sum validators, binary search test harnesses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Iterating quickly.&lt;/strong&gt; Modifying code, running tests, analyzing output in tight loops.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The challenges were all in &lt;strong&gt;format compatibility&lt;/strong&gt;: the gaps between the spec as written and the spec as implemented by libwebp. These required reading the actual libwebp C source code and understanding the decoder's expectations, not just the encoder's intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;p&gt;For test.png (387x429 RGBA):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;Quality&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0 (lossless)&lt;/td&gt;
&lt;td&gt;277 KB&lt;/td&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;0 (lossless)&lt;/td&gt;
&lt;td&gt;104 KB&lt;/td&gt;
&lt;td&gt;Competitive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;0 (lossless)&lt;/td&gt;
&lt;td&gt;121 KB&lt;/td&gt;
&lt;td&gt;Diminishing returns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;52 KB&lt;/td&gt;
&lt;td&gt;Near lossless sweet spot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;41 KB&lt;/td&gt;
&lt;td&gt;Visible loss, small file&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All output validates with dwebp 1.6.0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Internal round trip tests are necessary but not sufficient.&lt;/strong&gt; If both your encoder and decoder share a bug, your tests will pass and your output will be wrong. Always test against an external reference decoder.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Binary search is the most underrated debugging technique.&lt;/strong&gt; "Works at 280 rows, fails at 281" is infinitely more useful than "fails on large images". Same for isolating which feature flag causes the failure.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A single missing bit can corrupt everything downstream.&lt;/strong&gt; Bit packed formats have no error recovery. One missing bit shifts every subsequent field by one position, and the decoder interprets garbage until it gives up.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;LLMs are great at implementing specs, less great at catching spec ambiguities.&lt;/strong&gt; The VP8L spec says there's a meta Huffman flag but doesn't emphasize that it only appears at level 0. Claude Code implemented what the spec says; the bug was in what the spec implies.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pure Go implementations are worth the effort.&lt;/strong&gt; No CGo means easy cross compilation, no shared library headaches, simpler deployment, and full control over the code. The performance is reasonable: encoding a 387x429 image at level 6 takes about 50ms on an M series Mac.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The code is at &lt;a href="https://github.com/eringen/gowebper" rel="noopener noreferrer"&gt;github.com/eringen/gowebper&lt;/a&gt;. &lt;code&gt;go get github.com/eringen/gowebper@v0.1.1&lt;/code&gt; and you're encoding WebP files in pure Go.&lt;/p&gt;

</description>
      <category>go</category>
      <category>webp</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>Barcode Scanning on iOS: The Missing Web API and a WebAssembly Solution</title>
      <dc:creator>İlhan Neğiş</dc:creator>
      <pubDate>Tue, 24 Feb 2026 09:28:29 +0000</pubDate>
      <link>https://dev.to/ilhannegis/barcode-scanning-on-ios-the-missing-web-api-and-a-webassembly-solution-2in2</link>
      <guid>https://dev.to/ilhannegis/barcode-scanning-on-ios-the-missing-web-api-and-a-webassembly-solution-2in2</guid>
      <description>&lt;p&gt;If you've ever tried to build a web based barcode scanner targeting iOS, you've likely hit a wall: Safari doesn't support the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Barcode_Detection_API#browser_compatibility" rel="noopener noreferrer"&gt;Barcode Detection API&lt;/a&gt;^.&lt;/p&gt;

&lt;p&gt;tldr; Live demo: &lt;a href="https://eringen.com/workbench/wasm-barcode/" rel="noopener noreferrer"&gt;https://eringen.com/workbench/wasm-barcode/&lt;/a&gt;^ (NPM version)&lt;/p&gt;

&lt;p&gt;source version: &lt;a href="https://eringen.com/workbench/wasm-barcode/index2.html" rel="noopener noreferrer"&gt;https://eringen.com/workbench/wasm-barcode/index2.html&lt;/a&gt;^ (With rotation passes visible)&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;The Barcode Detection API is part of the Shape Detection API spec and provides a clean, native way to detect barcodes from images or camera feeds in the browser. Chrome on Android has supported it for a while, but Safari and by extension every browser on iOS, since they all use WebKit under the hood simply doesn't implement it.&lt;/p&gt;

&lt;p&gt;This means any web app relying on &lt;code&gt;BarcodeDetector&lt;/code&gt; will silently fail on iPhones and iPads. For projects that need crossplatform barcode scanning without a native app, this is a dealbreaker.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Usual Workarounds
&lt;/h2&gt;

&lt;p&gt;Most JavaScript barcode libraries tackle this with pure JS decoding. They work, but the performance cost is noticeable especially on older iOS devices where you need smooth, realtime scanning from a camera feed. Dropped frames and slow detection make for a poor user experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  A WebAssembly Approach
&lt;/h2&gt;

&lt;p&gt;Instead of decoding barcodes in JavaScript, we can compile a proven C library &lt;a href="https://zbar.sourceforge.net/" rel="noopener noreferrer"&gt;ZBar&lt;/a&gt;^ to WebAssembly using Emscripten. ZBar has been around for years and handles a wide range of barcode formats reliably.&lt;/p&gt;

&lt;p&gt;The workflow is straightforward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Capture camera frames using &lt;code&gt;getUserMedia&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Crop to a region of interest and convert to grayscale in JavaScript&lt;/li&gt;
&lt;li&gt;Pass the pixel data to ZBar running in WebAssembly&lt;/li&gt;
&lt;li&gt;Get back the decoded barcode type, data, and bounding polygon&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because the heavy lifting happens in compiled WASM rather than interpreted JavaScript, the performance is near native. On iOS devices, this translates to fast, responsive scanning that feels like a native app.&lt;/p&gt;

&lt;h2&gt;
  
  
  From Prototype to Production
&lt;/h2&gt;

&lt;p&gt;A big shout out to Berkley Wolf and his blog post, I was able to cobble together a prototype quickly, pay him a visit: &lt;a href="https://barkeywolf.consulting/posts/barcode-scanner-webassembly/" rel="noopener noreferrer"&gt;Using the ZBar barcode scanning suite in the browser with WebAssembly&lt;/a&gt;^&lt;/p&gt;

&lt;p&gt;The initial prototype worked, but it was a single &lt;code&gt;index.js&lt;/code&gt; file with global state, &lt;code&gt;parseInt&lt;/code&gt; for rounding, implicit globals, and a static PNG overlay. Useful for proving the concept, not so much for shipping in a real app or embedding in a React/Vue project.&lt;/p&gt;

&lt;p&gt;Here's what the modernization looked like.&lt;/p&gt;

&lt;h3&gt;
  
  
  TypeScript + Vite
&lt;/h3&gt;

&lt;p&gt;The codebase moved from a plain &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag to TypeScript with &lt;code&gt;strict: true&lt;/code&gt; and &lt;code&gt;noImplicitAny&lt;/code&gt;, built with Vite. The Emscripten generated &lt;code&gt;a.out.js&lt;/code&gt; stays as a classic script (it needs to land on &lt;code&gt;window.Module&lt;/code&gt;), but everything else is typed. A &lt;code&gt;src/types/emscripten.d.ts&lt;/code&gt; file declares the subset of the Emscripten API we use: &lt;code&gt;cwrap&lt;/code&gt;, &lt;code&gt;HEAP8&lt;/code&gt;, &lt;code&gt;HEAP32&lt;/code&gt;, &lt;code&gt;UTF8ToString&lt;/code&gt;, and the custom &lt;code&gt;processResult&lt;/code&gt; callback.&lt;/p&gt;

&lt;p&gt;This caught real bugs during the port. For example, the original code did &lt;code&gt;parseInt(cameraWidth * 0.702)&lt;/code&gt; where &lt;code&gt;Math.floor&lt;/code&gt; was the correct operation, &lt;code&gt;parseInt&lt;/code&gt; expects a string. TypeScript flagged these immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Animated CSS Overlay
&lt;/h3&gt;

&lt;p&gt;The original project used a static &lt;code&gt;barcodelayout.png&lt;/code&gt; image positioned over the video to show the scan region. The new version replaces it with a programmatic overlay: four dark mask divs surrounding a transparent scan region, "L" shaped corner brackets, and a sweeping laser line, all pure CSS with a &lt;code&gt;@keyframes&lt;/code&gt; animation. The overlay injects its styles via a single deduplicated &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; tag and positions everything using CSS custom properties derived from the scan region dimensions.&lt;/p&gt;

&lt;p&gt;This eliminates an external asset, scales to any container size, and gives users clear visual feedback that something is actively scanning.&lt;/p&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;BarcodeScanner&lt;/code&gt; Class
&lt;/h3&gt;

&lt;p&gt;The core of the rewrite is a single exportable class:&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;scanner&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;BarcodeScanner&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="p"&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;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scanner-mount&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="na"&gt;onDetect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;scanner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// later...&lt;/span&gt;
&lt;span class="nx"&gt;scanner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The constructor stores configuration only, no side effects. This matters for React strict mode, which double invokes effects in development. &lt;code&gt;start()&lt;/code&gt; handles the full initialization chain: DOM setup, WASM loading, camera acquisition, result handler wiring, and the scan interval. &lt;code&gt;stop()&lt;/code&gt; tears everything down cleanly including stopping camera tracks, something the original code never did.&lt;/p&gt;

&lt;p&gt;The class is designed to be wrapped. A React component is just a &lt;code&gt;useEffect&lt;/code&gt; that calls &lt;code&gt;start()&lt;/code&gt; and returns &lt;code&gt;stop()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;scanner&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;BarcodeScanner&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;onDetect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onScan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;scanner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;scanner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&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="nx"&gt;onScan&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Rotation Based Skew Correction
&lt;/h3&gt;

&lt;p&gt;In the real world, users don't hold barcodes perfectly straight. The original scanner only looked at 0 degrees, meaning a slight tilt could cause a miss.&lt;/p&gt;

&lt;p&gt;The new scan loop tries three angles per tick: 0 degrees, +30 degrees, and -30 degrees. The key insight is that this can use early exit. If ZBar finds a barcode at 0 degrees, we skip the rotated passes entirely. Most scans only need one pass. The rotation itself is just a Canvas 2D &lt;code&gt;translate-rotate-translate&lt;/code&gt; transform before drawing the video frame to the offscreen canvas:&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;// Rotate around the center of the offscreen canvas&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;translate&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="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="p"&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;rotate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;angle&lt;/span&gt;&lt;span class="p"&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;translate&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="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="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="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Draw the video crop (now rotated)&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;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;srcX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;srcY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;srcW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;srcH&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rotation cost is minimal since we're only rotating a small cropped region (roughly 700 by 240 pixels), not the full camera frame.&lt;/p&gt;

&lt;h3&gt;
  
  
  A WASM Memory Pitfall
&lt;/h3&gt;

&lt;p&gt;One bug that took some digging: the original code allocated a new WASM heap buffer every scan tick but never freed it. A memory leak, but it worked because ZBar's &lt;code&gt;zbar_image_free_data&lt;/code&gt; callback actually freed the buffer when the image was destroyed at the end of &lt;code&gt;scan_image&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The modernized version initially tried to be clever by allocating one buffer and reusing it. This was use after free: ZBar freed the buffer at the end of the first &lt;code&gt;scan_image&lt;/code&gt; call, and every subsequent write to that pointer corrupted the WASM heap. It would run for about 70 seconds (around 475 successful scans) before crashing with &lt;code&gt;RuntimeError: memory access out of bounds&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix was simple: allocate a fresh buffer per &lt;code&gt;scan_image&lt;/code&gt; call, matching the original behavior. ZBar handles cleanup. The lesson: when bridging JS and WASM, understand who owns the memory. In &lt;code&gt;scan.c&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This registers zbar_image_free_data as the cleanup handler.&lt;/span&gt;
&lt;span class="c1"&gt;// When zbar_image_destroy runs, it calls free() on our buffer.&lt;/span&gt;
&lt;span class="n"&gt;zbar_image_set_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;zbar_image_free_data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Video Resolution Scaling
&lt;/h3&gt;

&lt;p&gt;Another subtle fix: the original code assumed the camera delivered exactly 2x the container size. It requested 1000x1000 for a 500x500 container and hardcoded &lt;code&gt;barcodeOffset * 2&lt;/code&gt; as the &lt;code&gt;drawImage&lt;/code&gt; source coordinates. If the camera returned a different resolution (common across devices), the crop region was wrong.&lt;/p&gt;

&lt;p&gt;The new code reads &lt;code&gt;video.videoWidth&lt;/code&gt; and &lt;code&gt;video.videoHeight&lt;/code&gt; each tick and computes a proper scale factor:&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;scaleX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;videoW&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cameraWidth&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;scaleY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;videoH&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cameraHeight&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;srcX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;barcodeOffsetX&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scaleX&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works regardless of the actual camera resolution.&lt;/p&gt;

&lt;h3&gt;
  
  
  Debug Preview Panel
&lt;/h3&gt;

&lt;p&gt;For development and troubleshooting, the scanner accepts an optional &lt;code&gt;previewCanvas&lt;/code&gt; element. When provided, it renders all three rotation passes stacked vertically in real time. The angle that detected a barcode gets a green border. This makes it trivial to see exactly what ZBar is processing at each angle, useful both for development and for demos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;In practice, the WASM scanner detects barcodes within a couple of frames on modern iPhones. The scan loop runs at roughly 150ms intervals, and ZBar's processing time per frame is negligible. Combined with the animated overlay, audio feedback on detection, and rotation based skew correction, the experience is smooth enough that users won't notice they're using a web app.&lt;/p&gt;

&lt;p&gt;The rotation passes add real value. In testing, barcodes tilted at around 25-30 degrees that the original scanner missed entirely are now caught on the second or third pass of the same tick.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Supported
&lt;/h2&gt;

&lt;p&gt;ZBar handles most common 1D formats (EAN-13, EAN-8, UPC-A, UPC-E, Code 128, Code 39, Code 93, ISBN, Interleaved 2 of 5, DataBar) and QR codes. It does not support Data Matrix, PDF417, or Aztec. For those, you'd need a different WASM library like ZXing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Until Apple adds Barcode Detection API support to Safari, WebAssembly is the best path to performant barcode scanning on iOS. By leveraging real world tested C libraries through WASM, we get reliability and speed without waiting for browser vendors to catch up.&lt;/p&gt;

&lt;p&gt;The TypeScript rewrite makes the scanner embeddable in any modern frontend stack. The &lt;code&gt;BarcodeScanner&lt;/code&gt; class handles the full lifecycle, camera permissions, WASM initialization, scan loop, cleanup, so your framework wrapper only needs to call &lt;code&gt;start()&lt;/code&gt; and &lt;code&gt;stop()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The full source is available at &lt;a href="https://github.com/eringen/web-wasm-barcode-reader" rel="noopener noreferrer"&gt;https://github.com/eringen/web-wasm-barcode-reader&lt;/a&gt;^.&lt;/p&gt;

&lt;p&gt;NPM package: &lt;a href="https://www.npmjs.com/package/web-wasm-barcode-reader" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/web-wasm-barcode-reader&lt;/a&gt;^&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i web-wasm-barcode-reader
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Usage as npm Package
&lt;/h2&gt;

&lt;p&gt;The library requires the Emscripten WASM glue script (a.out.js) to be loaded before the scanner is started. The WASM binary (a.out.wasm) is fetched automatically by the glue script at runtime.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Copy the WASM files into your public/static directory
After installing, copy the WASM assets to a location your web server can serve:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cp node_modules/web-wasm-barcode-reader/public/a.out.js  public/
cp node_modules/web-wasm-barcode-reader/public/a.out.wasm public/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Serve index.html as show.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang="en"&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset="UTF-8"&amp;gt;
  &amp;lt;meta name="viewport" content="width=device-width, initial-scale=1.0"&amp;gt;
  &amp;lt;title&amp;gt;Barcode Scanner&amp;lt;/title&amp;gt;
  &amp;lt;style&amp;gt;
    * { margin: 0; padding: 0; box-sizing: border-box; }

    body {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
      background: #0f172a;
      color: #e2e8f0;
      min-height: 100vh;
      display: flex;
      flex-direction: column;
      align-items: center;
    }

    h1 {
      margin: 1.5rem 0 1rem;
      font-size: 1.5rem;
      font-weight: 600;
    }

    #scanner-container {
      width: 100%;
      max-width: 480px;
      aspect-ratio: 1;
      background: #1e293b;
      border-radius: 12px;
      overflow: hidden;
      position: relative;
    }

    #controls {
      display: flex;
      gap: 0.75rem;
      margin: 1rem 0;
    }

    button {
      padding: 0.6rem 1.4rem;
      border: none;
      border-radius: 8px;
      font-size: 0.95rem;
      font-weight: 500;
      cursor: pointer;
      transition: background 0.2s;
    }

    #start-btn {
      background: #22c55e;
      color: #fff;
    }
    #start-btn:hover { background: #16a34a; }
    #start-btn:disabled { background: #4b5563; cursor: not-allowed; }

    #stop-btn {
      background: #ef4444;
      color: #fff;
    }
    #stop-btn:hover { background: #dc2626; }
    #stop-btn:disabled { background: #4b5563; cursor: not-allowed; }

    #torch-btn {
      background: #eab308;
      color: #1e293b;
    }
    #torch-btn:hover { background: #ca8a04; }
    #torch-btn:disabled { background: #4b5563; color: #9ca3af; cursor: not-allowed; }

    #results {
      width: 100%;
      max-width: 480px;
      margin-bottom: 2rem;
    }

    #results h2 {
      font-size: 1.1rem;
      margin-bottom: 0.5rem;
      color: #94a3b8;
    }

    #result-list {
      list-style: none;
      display: flex;
      flex-direction: column;
      gap: 0.5rem;
    }

    .result-item {
      background: #1e293b;
      border: 1px solid #334155;
      border-radius: 8px;
      padding: 0.75rem 1rem;
      display: flex;
      justify-content: space-between;
      align-items: center;
      animation: fadeIn 0.3s ease;
    }

    .result-item .data {
      font-family: "SF Mono", "Fira Code", monospace;
      font-size: 0.95rem;
      word-break: break-all;
    }

    .result-item .type {
      font-size: 0.75rem;
      background: #334155;
      padding: 0.2rem 0.5rem;
      border-radius: 4px;
      color: #94a3b8;
      white-space: nowrap;
      margin-left: 0.75rem;
    }

    .result-item .copy-btn {
      background: none;
      border: 1px solid #475569;
      color: #94a3b8;
      padding: 0.3rem 0.6rem;
      font-size: 0.75rem;
      border-radius: 4px;
      margin-left: 0.5rem;
      flex-shrink: 0;
    }
    .result-item .copy-btn:hover { background: #334155; color: #e2e8f0; }

    #status {
      font-size: 0.85rem;
      color: #64748b;
      margin-bottom: 0.5rem;
      min-height: 1.2em;
    }

    @keyframes fadeIn {
      from { opacity: 0; transform: translateY(-4px); }
      to { opacity: 1; transform: translateY(0); }
    }
  &amp;lt;/style&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;h1&amp;gt;Barcode Scanner&amp;lt;/h1&amp;gt;
  &amp;lt;p id="status"&amp;gt;Press Start to begin scanning&amp;lt;/p&amp;gt;

  &amp;lt;div id="scanner-container"&amp;gt;&amp;lt;/div&amp;gt;

  &amp;lt;div id="controls"&amp;gt;
    &amp;lt;button id="start-btn"&amp;gt;Start&amp;lt;/button&amp;gt;
    &amp;lt;button id="stop-btn" disabled&amp;gt;Stop&amp;lt;/button&amp;gt;
    &amp;lt;button id="torch-btn" disabled&amp;gt;Torch&amp;lt;/button&amp;gt;
  &amp;lt;/div&amp;gt;

  &amp;lt;div id="results"&amp;gt;
    &amp;lt;h2&amp;gt;Scanned Results&amp;lt;/h2&amp;gt;
    &amp;lt;ul id="result-list"&amp;gt;&amp;lt;/ul&amp;gt;
  &amp;lt;/div&amp;gt;

  &amp;lt;!-- Emscripten glue must load as a classic script before the module --&amp;gt;
  &amp;lt;script&amp;gt;
    var Module = {
      locateFile: function(path) {
        return '/public/' + path;
      }
    };
  &amp;lt;/script&amp;gt;
  &amp;lt;script src="/public/a.out.js"&amp;gt;&amp;lt;/script&amp;gt;
  &amp;lt;script type="module"&amp;gt;
    import { BarcodeScanner } from './public/web-wasm-barcode-reader.js';

    const container = document.getElementById('scanner-container');
    const startBtn = document.getElementById('start-btn');
    const stopBtn = document.getElementById('stop-btn');
    const torchBtn = document.getElementById('torch-btn');
    const resultList = document.getElementById('result-list');
    const status = document.getElementById('status');

    let scanner = null;
    const seenCodes = new Set();

    function addResult(result) {
      // Deduplicate consecutive identical scans
      const key = result.symbol + ':' + result.data;
      if (seenCodes.has(key)) return;
      seenCodes.add(key);

      const li = document.createElement('li');
      li.className = 'result-item';

      const dataSpan = document.createElement('span');
      dataSpan.className = 'data';
      dataSpan.textContent = result.data;

      const typeSpan = document.createElement('span');
      typeSpan.className = 'type';
      typeSpan.textContent = result.symbol;

      const copyBtn = document.createElement('button');
      copyBtn.className = 'copy-btn';
      copyBtn.textContent = 'Copy';
      copyBtn.addEventListener('click', () =&amp;gt; {
        navigator.clipboard.writeText(result.data).then(() =&amp;gt; {
          copyBtn.textContent = 'Copied!';
          setTimeout(() =&amp;gt; { copyBtn.textContent = 'Copy'; }, 1500);
        });
      });

      li.appendChild(dataSpan);
      li.appendChild(typeSpan);
      li.appendChild(copyBtn);
      resultList.prepend(li);
    }

    startBtn.addEventListener('click', async () =&amp;gt; {
      startBtn.disabled = true;
      status.textContent = 'Starting camera...';

      scanner = new BarcodeScanner({
        container,
        onDetect: (result) =&amp;gt; {
          addResult(result);
          status.textContent = `Detected: ${result.symbol} - ${result.data}`;
        },
        onError: (err) =&amp;gt; {
          status.textContent = 'Error: ' + err.message;
          console.error(err);
        },
        beepOnDetect: true,
        facingMode: 'environment',
      });

      try {
        await scanner.start();
        status.textContent = 'Scanning... point camera at a barcode';
        stopBtn.disabled = false;
        torchBtn.disabled = false;
      } catch (err) {
        status.textContent = 'Failed to start: ' + err.message;
        startBtn.disabled = false;
        scanner = null;
      }
    });

    stopBtn.addEventListener('click', () =&amp;gt; {
      if (scanner) {
        scanner.stop();
        scanner = null;
      }
      startBtn.disabled = false;
      stopBtn.disabled = true;
      torchBtn.disabled = true;
      status.textContent = 'Scanner stopped';
    });

    torchBtn.addEventListener('click', async () =&amp;gt; {
      if (!scanner) return;
      try {
        const on = await scanner.toggleTorch();
        torchBtn.textContent = on ? 'Torch Off' : 'Torch';
      } catch (err) {
        status.textContent = 'Torch not supported on this device';
      }
    });
  &amp;lt;/script&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>zbar</category>
      <category>barcode</category>
      <category>ios</category>
      <category>webassembly</category>
    </item>
    <item>
      <title>Browser Based Passport MRZ Reader with Tesseract.js</title>
      <dc:creator>İlhan Neğiş</dc:creator>
      <pubDate>Tue, 24 Feb 2026 09:27:47 +0000</pubDate>
      <link>https://dev.to/ilhannegis/browser-based-passport-mrz-reader-with-tesseractjs-655</link>
      <guid>https://dev.to/ilhannegis/browser-based-passport-mrz-reader-with-tesseractjs-655</guid>
      <description>&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;p&gt;This project was born out of a real world need: VAT refund processing for tourists shopping in Dubai. When visitors make purchases in the UAE, they're eligible for VAT returns but claiming that refund requires passport verification at the point of sale. Rather than manually typing passport details (slow, error-prone, and frustrating for both staff and customers), we needed a fast, accurate way to capture document data directly.&lt;/p&gt;

&lt;p&gt;tldr link: &lt;a href="https://github.com/eringen/web-mrz-reader" rel="noopener noreferrer"&gt;https://github.com/eringen/web-mrz-reader&lt;/a&gt;^&lt;br&gt;
demo: &lt;a href="https://eringen.com/workbench/web-mrz-reader/" rel="noopener noreferrer"&gt;https://eringen.com/workbench/web-mrz-reader/&lt;/a&gt;^&lt;/p&gt;

&lt;p&gt;What started as a passport-only reader quickly evolved. Customers presented national ID cards, residence permits, and various travel documents all with machine readable zones, but in different formats. That's when we expanded the reader to support all three ICAO document types.&lt;/p&gt;
&lt;h2&gt;
  
  
  What
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://en.wikipedia.org/wiki/Machine-readable_passport" rel="noopener noreferrer"&gt;Machine Readable Zone&lt;/a&gt;^ is a standardized format defined by ICAO (International Civil Aviation Organization) in &lt;a href="https://www.icao.int/publications/pages/publication.aspx?docnum=9303" rel="noopener noreferrer"&gt;Doc 9303&lt;/a&gt;^. It's the block of text printed in the OCR-B font at the bottom of passports, ID cards, and travel documents. Every international border checkpoint in the world can read it.&lt;/p&gt;

&lt;p&gt;ICAO defines three MRZ formats, each designed for a different document size:&lt;/p&gt;
&lt;h3&gt;
  
  
  TD3 Passports
&lt;/h3&gt;

&lt;p&gt;The most familiar format. Two lines, 44 characters each, totaling 88 characters.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;P&amp;lt;UTOERIKSSON&amp;lt;&amp;lt;ANNA&amp;lt;MARIA&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;
L898902C36UTO7408122F1204159ZE184226B&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Line 1 carries the document type (&lt;code&gt;P&lt;/code&gt; for passport), issuing country, and the holder's name. Line 2 packs in the passport number, nationality, date of birth, gender, expiration date, personal number, and five check digits.&lt;/p&gt;

&lt;h3&gt;
  
  
  TD1 ID Cards
&lt;/h3&gt;

&lt;p&gt;The most compact format, used on credit-card-sized ID documents. Three lines, 30 characters each, totaling 90 characters.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I&amp;lt;UTOD231458907&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;
7408122F1204159UTO&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;6
ERIKSSON&amp;lt;&amp;lt;ANNA&amp;lt;MARIA&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TD1 distributes data across three shorter lines. Line 1 holds the document type, country, document number, and optional data. Line 2 carries dates, gender, nationality, and more optional data. Line 3 is dedicated entirely to the holder's name. This separation makes TD1 the only format where the name lives on its own line.&lt;/p&gt;

&lt;h3&gt;
  
  
  TD2 Travel Documents
&lt;/h3&gt;

&lt;p&gt;A middle format for larger-than-ID-card but smaller-than-passport documents. Two lines, 36 characters each, totaling 72 characters.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I&amp;lt;UTOERIKSSON&amp;lt;&amp;lt;ANNA&amp;lt;MARIA&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;
D231458907UTO7408122F1204159&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;06
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TD2 is structured like TD3 but narrower the name shares line 1 with the document type and country, while line 2 mirrors the TD3 layout in a compressed form.&lt;/p&gt;

&lt;h3&gt;
  
  
  Comparing the Three Formats
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;TD1 (ID Card)&lt;/th&gt;
&lt;th&gt;TD2 (Travel Doc)&lt;/th&gt;
&lt;th&gt;TD3 (Passport)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Lines&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Chars/line&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;44&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;td&gt;72&lt;/td&gt;
&lt;td&gt;88&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Name location&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Line 3&lt;/td&gt;
&lt;td&gt;Line 1&lt;/td&gt;
&lt;td&gt;Line 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Check digits&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Doc type prefix&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;I&amp;lt;&lt;/code&gt;, &lt;code&gt;A&amp;lt;&lt;/code&gt;, &lt;code&gt;C&amp;lt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;I&amp;lt;&lt;/code&gt;, &lt;code&gt;A&amp;lt;&lt;/code&gt;, &lt;code&gt;C&amp;lt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;P&amp;lt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three formats share the same check digit algorithm but apply it to different field positions. TD3 has an extra check digit for the personal number field, which TD1 and TD2 don't have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Challenge
&lt;/h2&gt;

&lt;p&gt;Traditional MRZ readers require specialized hardware infrared scanners or dedicated OCR devices costing thousands. We wanted something different: a solution that works with any smartphone or laptop camera, runs entirely in the browser, handles all three document formats, and keeps sensitive data private by never sending it to a server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Approach
&lt;/h2&gt;

&lt;p&gt;The entire solution is built with &lt;strong&gt;vanilla Javascript&lt;/strong&gt; no frameworks, no build process, no bundler required. Just plain HTML and Javascript files served directly. This keeps the project simple, auditable, and eliminates tooling complexity.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Tesseract.js with Custom Training
&lt;/h3&gt;

&lt;p&gt;The backbone is &lt;a href="https://github.com/naptha/tesseract.js" rel="noopener noreferrer"&gt;Tesseract.js&lt;/a&gt;^, a Javascript port of the Tesseract OCR engine compiled to WebAssembly. The default English model struggles with MRZ because MRZ uses the OCR-B font and only 37 possible characters (A-Z, 0-9, &lt;code&gt;&amp;lt;&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;We trained a custom model specifically for MRZ recognition. This dramatically improved accuracy, especially for commonly confused characters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;0&lt;/code&gt; (zero) vs &lt;code&gt;O&lt;/code&gt; (letter O)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;1&lt;/code&gt; (one) vs &lt;code&gt;I&lt;/code&gt; (letter I)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;&lt;/code&gt; (filler) vs &lt;code&gt;K&lt;/code&gt; or &lt;code&gt;X&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Multi-Format Detection
&lt;/h3&gt;

&lt;p&gt;The first challenge with supporting multiple formats is detection. A passport MRZ always starts with &lt;code&gt;P&amp;lt;&lt;/code&gt;, but ID cards and travel documents use &lt;code&gt;I&amp;lt;&lt;/code&gt;, &lt;code&gt;A&amp;lt;&lt;/code&gt;, or &lt;code&gt;C&amp;lt;&lt;/code&gt;. Our detection regex handles all of them:&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;function&lt;/span&gt; &lt;span class="nf"&gt;isMRZ&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mrzPattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;PIAC&lt;/span&gt;&lt;span class="se"&gt;][&lt;/span&gt;&lt;span class="sr"&gt;A-Z&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;][&lt;/span&gt;&lt;span class="sr"&gt;A-Z&lt;/span&gt;&lt;span class="se"&gt;]{3}[&lt;/span&gt;&lt;span class="sr"&gt;A-Z0-9&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/&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;mrzPattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern reads as: a document type character (&lt;code&gt;P&lt;/code&gt;, &lt;code&gt;I&lt;/code&gt;, &lt;code&gt;A&lt;/code&gt;, or &lt;code&gt;C&lt;/code&gt;), followed by a second type character or filler (&lt;code&gt;&amp;lt;&lt;/code&gt;), then a three-letter country code, then the remaining MRZ characters.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Format Routing by Length
&lt;/h3&gt;

&lt;p&gt;Once we detect and extract the MRZ string, we strip whitespace and determine the format purely by character count. This is reliable because the three lengths (90, 88, 72) don't overlap:&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;function&lt;/span&gt; &lt;span class="nf"&gt;parseMrz&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mrz&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;mrz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parseTD1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mrz&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;else&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;mrz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parseTD2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mrz&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;else&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;mrz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;88&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* TD3 parsing */&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;h3&gt;
  
  
  4. Format-Specific Parsing
&lt;/h3&gt;

&lt;p&gt;Each format has its own parser because the field positions differ significantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TD1&lt;/strong&gt; splits the 90-character string into three lines of 30:&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="nx"&gt;line1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mrz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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;30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// doc type, country, doc number, optional data&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mrz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// DOB, gender, expiry, nationality, optional data&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mrz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// full name (SURNAME&amp;lt;&amp;lt;GIVEN&amp;lt;NAMES)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;TD2&lt;/strong&gt; splits into two lines of 36:&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="nx"&gt;line1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mrz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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;36&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// doc type, country, name&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mrz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// doc number, nationality, DOB, gender, expiry&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;TD3&lt;/strong&gt; splits into two lines of 44 the widest format with the most breathing room for long names.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Check Digit Validation
&lt;/h3&gt;

&lt;p&gt;Every MRZ format includes check digits to catch OCR errors and tampering. The algorithm is the same across all formats a weighted sum using the repeating pattern 7, 3, 1:&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;function&lt;/span&gt; &lt;span class="nf"&gt;calculateCheckDigit&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;weights&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;7&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="mi"&gt;1&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;sum&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;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;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;length&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="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;ch&lt;/span&gt; &lt;span class="o"&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;i&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;value&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;ch&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;9&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&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;else&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;ch&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;A&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;A&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&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="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;value&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;sum&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;weights&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;3&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="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;10&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;What differs across formats is &lt;em&gt;which fields&lt;/em&gt; are validated and &lt;em&gt;where&lt;/em&gt; the check digits sit:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;TD1&lt;/th&gt;
&lt;th&gt;TD2&lt;/th&gt;
&lt;th&gt;TD3&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Document number&lt;/td&gt;
&lt;td&gt;line1[14]&lt;/td&gt;
&lt;td&gt;line2[9]&lt;/td&gt;
&lt;td&gt;line2[9]&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Date of birth&lt;/td&gt;
&lt;td&gt;line2[6]&lt;/td&gt;
&lt;td&gt;line2[19]&lt;/td&gt;
&lt;td&gt;line2[19]&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expiration date&lt;/td&gt;
&lt;td&gt;line2[14]&lt;/td&gt;
&lt;td&gt;line2[27]&lt;/td&gt;
&lt;td&gt;line2[27]&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Personal number&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;line2[42]&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Composite&lt;/td&gt;
&lt;td&gt;line2[29]&lt;/td&gt;
&lt;td&gt;line2[35]&lt;/td&gt;
&lt;td&gt;line2[43]&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The composite check digit is the most interesting it's calculated over a concatenation of multiple fields, acting as an overall integrity check. For TD1, this spans data from both line 1 and line 2. For TD2 and TD3, it covers selected ranges within line 2.&lt;/p&gt;

&lt;p&gt;If any check digit fails, the reader displays a warning specifying which fields failed validation, signaling a likely OCR misread.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Visual Feedback
&lt;/h3&gt;

&lt;p&gt;To help users position documents correctly, we draw bounding boxes around recognized text regions on the canvas overlay:&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;function&lt;/span&gt; &lt;span class="nf"&gt;drawBoundingBoxes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;words&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;strokeStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;red&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lineWidth&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="nx"&gt;words&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;bbox&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strokeRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;bbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;bbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y0&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;h2&gt;
  
  
  Privacy by Design
&lt;/h2&gt;

&lt;p&gt;A critical design decision was keeping everything client-side. The document image and extracted data never leave the user's browser. The entire OCR process runs locally via WebAssembly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No server uploads&lt;/li&gt;
&lt;li&gt;No API calls with personal data&lt;/li&gt;
&lt;li&gt;No data retention&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This makes the solution suitable for privacy-sensitive environments where transmitting document data to external servers isn't acceptable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technical Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="/public/mrz-arch.webp" class="article-body-image-wrapper"&gt;&lt;img src="/public/mrz-arch.webp" alt="hedere hodoror"&gt;&lt;/a&gt;{aspect-ratio:500/500|500|500}&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;The reader achieves high accuracy on well-lit, properly positioned documents. Recognition takes 1-3 seconds depending on device capabilities, with SIMD-enabled browsers seeing the fastest results.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TD3 passport output:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Nationality"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UTO"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Surname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ERIKSSON"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Given Names"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ANNA MARIA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Passport Number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"L898902C3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Issuing Country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UTO"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Date of Birth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"740812"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Gender"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Female"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Expiration Date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"120415"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Personal Number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ZE184226B"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Validation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"isValid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;TD1 ID card output:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Document Type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"I"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Nationality"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UTO"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Surname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ERIKSSON"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Given Names"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ANNA MARIA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Document Number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"D23145890"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Issuing Country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UTO"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Date of Birth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"740812"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Gender"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Female"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Expiration Date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"120415"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Optional Data 1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Optional Data 2"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Validation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"isValid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;TD2 travel document output:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Document Type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"I"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Nationality"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UTO"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Surname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ERIKSSON"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Given Names"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ANNA MARIA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Document Number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"D23145890"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Issuing Country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UTO"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Date of Birth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"740812"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Gender"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Female"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Expiration Date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"120415"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Optional Data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Validation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"isValid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Custom training matters&lt;/strong&gt; - Generic OCR models struggle with MRZ's specific character set and font. A dedicated model trained on OCR-B with only 37 character classes dramatically outperforms general-purpose text recognition.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;WebAssembly is production-ready&lt;/strong&gt; - Running Tesseract in the browser via WASM provides near-native performance with zero server infrastructure.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Camera quality varies wildly&lt;/strong&gt; - Desktop webcams, phone cameras, and tablets all produce different results. Good lighting and a steady hand are more important than resolution.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The MRZ spec is well-designed&lt;/strong&gt; - Check digits, fixed positions, and a limited character set make parsing reliable once OCR accuracy is high enough. The composite check digit is particularly clever - it catches errors that individual field checks might miss.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Format detection by length just works&lt;/strong&gt; - Since TD1 (90), TD2 (72), and TD3 (88) have distinct character counts, a simple length check after stripping whitespace is a reliable and foolproof way to route parsing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Field layout differences matter&lt;/strong&gt; - TD1 puts the name on its own line while TD2 and TD3 pack it into line 1 alongside other data. TD1 spreads data across three lines while the others use two. These structural differences require dedicated parsers rather than a one-size-fits-all approach.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Direct try:&lt;/strong&gt; &lt;a href="https://eringen.com/workbench/web-mrz-reader/" rel="noopener noreferrer"&gt;https://eringen.com/workbench/web-mrz-reader/&lt;/a&gt;^&lt;/p&gt;

&lt;p&gt;The project is open source and runs in any modern browser. Clone the repository, serve the files over HTTPS (or localhost), and point your camera at a passport or ID card. All processing happens on your device your data stays with you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/eringen/web-mrz-reader" rel="noopener noreferrer"&gt;https://github.com/eringen/web-mrz-reader&lt;/a&gt;^&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This project demonstrates how modern web technologies can deliver functionality that once required specialized hardware, all while respecting user privacy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>code</category>
      <category>ocr</category>
      <category>webassembly</category>
      <category>tesseract</category>
    </item>
  </channel>
</rss>
