<?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: Sunny Radadiya</title>
    <description>The latest articles on DEV Community by Sunny Radadiya (@sunnyradadiya).</description>
    <link>https://dev.to/sunnyradadiya</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4220475%2Fa626bc94-e118-46b9-a388-f7b807f9b1e6.jpg</url>
      <title>DEV Community: Sunny Radadiya</title>
      <link>https://dev.to/sunnyradadiya</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sunnyradadiya"/>
    <language>en</language>
    <item>
      <title>How we built a QR code designer that can't break scanning</title>
      <dc:creator>Sunny Radadiya</dc:creator>
      <pubDate>Sun, 01 Nov 2026 11:50:00 +0000</pubDate>
      <link>https://dev.to/sunnyradadiya/how-we-built-a-qr-code-designer-that-cant-break-scanning-2ggi</link>
      <guid>https://dev.to/sunnyradadiya/how-we-built-a-qr-code-designer-that-cant-break-scanning-2ggi</guid>
      <description>&lt;p&gt;Last week we added QR code design to &lt;a href="https://linkinseconds.com/qr-code-generator?utm_source=devto&amp;amp;utm_medium=article" rel="noopener noreferrer"&gt;Link in Seconds&lt;/a&gt;: colours, dot styles, rounded corners, a logo in the middle and a "Scan me" frame. The tricky part was not drawing pretty QR codes. It was making sure &lt;strong&gt;no combination of options could produce a code a phone can't read&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here is how we did it, including the two styles we threw away because our own tests failed them.&lt;/p&gt;

&lt;h2&gt;
  
  
  One SVG string for everything
&lt;/h2&gt;

&lt;p&gt;We generate the code once, as an SVG string, in a pure function:&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;qrSvg&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;design&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;QrDesign&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sizePx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one string is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the live preview (&lt;code&gt;&amp;lt;img src="data:image/svg+xml,..."&amp;gt;&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;SVG download&lt;/strong&gt; (sharp at any print size)&lt;/li&gt;
&lt;li&gt;the source of the &lt;strong&gt;PNG download&lt;/strong&gt; (drawn onto a &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; in the browser)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No DOM, no canvas, no network inside the function, so it runs the same in the browser and in tests.&lt;/p&gt;

&lt;p&gt;We use the &lt;code&gt;qrcode&lt;/code&gt; package only for the hard part, the module matrix:&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;qr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;QRCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;errorCorrectionLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;logo&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;H&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;M&lt;/span&gt;&lt;span class="dl"&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;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;qr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&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;y&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;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;x&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;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="o"&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;qr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* draw a dot */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then we draw every dark module ourselves as a &lt;code&gt;&amp;lt;rect&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;circle&amp;gt;&lt;/code&gt;, which is what makes the styles possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: the three "eyes" are drawn separately
&lt;/h2&gt;

&lt;p&gt;The big squares in three corners (finder patterns) are how a scanner locates and orients the code. We skip those modules in the dot loop and draw each eye as three solid shapes: an outer ring, a gap and a centre. Styling (square or rounded, any colour) only changes the corner radius and fill. The shape always stays solid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: a logo forces error correction H
&lt;/h2&gt;

&lt;p&gt;QR codes can lose part of their data and still decode. Level H tolerates the most damage (about 30%). When a logo is added we:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;switch to level H automatically,&lt;/li&gt;
&lt;li&gt;cap the logo box at about 22% of the code width, snapped to whole modules,&lt;/li&gt;
&lt;li&gt;clear the modules under it plus a one-module margin, so the logo sits on a clean background.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The user never picks an error correction level. The tool picks the safe one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: warn about contrast
&lt;/h2&gt;

&lt;p&gt;A designer will happily pick pale grey on white, or white on black. Both look fine on screen and fail in real light. We compute the WCAG contrast ratio and require darker dots than background:&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;contrastOk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;luminance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;luminance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;contrastRatio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it fails, the UI shows a warning under the colour pickers. We don't block the download, we just tell you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that keeps us honest
&lt;/h2&gt;

&lt;p&gt;Every style gets rendered to pixels and decoded with a real QR reader (&lt;a href="https://github.com/cozmo/jsQR" rel="noopener noreferrer"&gt;jsQR&lt;/a&gt;). If the decoded text isn't the input, the test fails:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loadImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;      &lt;span class="c1"&gt;// @napi-rs/canvas&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;"&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;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&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="kd"&gt;const&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="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getImageData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsQR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8ClampedArray&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="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;designs&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;%s scans&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;design&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;qrSvg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;design&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;600&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="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;URL&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;We run it across short URLs, long URLs with query strings, and a Wi-Fi string, for every style combination: dots, rounded, logo, frame and all of them at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the test caught
&lt;/h2&gt;

&lt;p&gt;This is the part I'm glad we automated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Circle-shaped corner eyes.&lt;/strong&gt; They look lovely. They failed to decode. A ring of &lt;code&gt;stroke&lt;/code&gt; around a dot doesn't give the scanner the crisp 1:1:3:1:1 ratio it looks for. We dropped the option instead of shipping it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dots that were slightly too small.&lt;/strong&gt; Our first "dots" style used &lt;code&gt;r="0.45"&lt;/code&gt; (90% of the module). It decoded for some colour pairs and failed for others. At &lt;code&gt;r="0.5"&lt;/code&gt;, where neighbouring dots touch, every test passed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rounded squares with a gap.&lt;/strong&gt; We first drew rounded modules at 0.9 width with a gap between them. Combined with a logo, decoding failed on short URLs (smaller codes, fewer modules to spare). Making each rounded module fill its full cell fixed it.&lt;/p&gt;

&lt;p&gt;None of these were visible problems. They all looked fine. Without a decode test we would have shipped codes that fail on someone's restaurant menu.&lt;/p&gt;

&lt;h2&gt;
  
  
  Safety: the input is user-controlled
&lt;/h2&gt;

&lt;p&gt;Because we build an SVG string from user input, we treat everything as untrusted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Colours&lt;/strong&gt; must match &lt;code&gt;/^#[0-9a-f]{6}$/i&lt;/code&gt; or fall back to the default, so &lt;code&gt;red" onload="..."&lt;/code&gt; never reaches the markup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frame text&lt;/strong&gt; is HTML-escaped and capped at 24 characters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The logo&lt;/strong&gt; must be a &lt;code&gt;data:image/(png|jpeg|webp|gif|svg+xml);base64,&lt;/code&gt; URL. A remote &lt;code&gt;https://&lt;/code&gt; URL is dropped.&lt;/li&gt;
&lt;li&gt;The logo is read with &lt;code&gt;FileReader&lt;/code&gt; in the browser and &lt;strong&gt;never uploaded&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these has a test too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Free, runs in the browser, PNG and SVG out, and every style it offers has been decoded by a QR reader in CI: &lt;a href="https://linkinseconds.com/qr-code-generator?utm_source=devto&amp;amp;utm_medium=article" rel="noopener noreferrer"&gt;linkinseconds.com/qr-code-generator&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you're building anything that generates QR codes, my one takeaway: &lt;strong&gt;add a decode test.&lt;/strong&gt; It takes 20 lines and it will catch things your eyes won't.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built with Next.js, TypeScript, the &lt;code&gt;qrcode&lt;/code&gt; package, jsQR and @napi-rs/canvas for tests. Questions welcome in the comments.&lt;/em&gt;&lt;/p&gt;

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