<?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: datalog</title>
    <description>The latest articles on DEV Community by datalog (@datalog).</description>
    <link>https://dev.to/datalog</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%2F4205559%2F95686e39-99ed-43ce-a7ca-6cf867e04574.png</url>
      <title>DEV Community: datalog</title>
      <link>https://dev.to/datalog</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/datalog"/>
    <language>en</language>
    <item>
      <title>How I Shrunk a QR Kanji Table by 36% Using Dynamic Programming</title>
      <dc:creator>datalog</dc:creator>
      <pubDate>Sun, 25 Oct 2026 00:01:20 +0000</pubDate>
      <link>https://dev.to/datalog/how-i-shrunk-a-qr-kanji-table-by-36-using-dynamic-programming-1fmc</link>
      <guid>https://dev.to/datalog/how-i-shrunk-a-qr-kanji-table-by-36-using-dynamic-programming-1fmc</guid>
      <description>&lt;p&gt;&lt;em&gt;QR codes have a dedicated Kanji mode that encodes supported characters in 13 bits each. A character like 龍 takes 24 bits in UTF-8, so choosing Kanji mode can save useful space.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For my JavaScript implementation, I needed to ship a lookup table mapping Unicode values to QR Kanji indices. The challenge was to preserve every mapping while keeping the embedded data as small as possible.&lt;/p&gt;

&lt;p&gt;The table is only 16,384 bytes as binary data. Encode those bytes as Base64, though, and it becomes a 21,848-character string. I reduced that to &lt;strong&gt;13,984 characters&lt;/strong&gt; by describing the table with a small command language and using dynamic programming to choose the commands.&lt;/p&gt;

&lt;p&gt;That is a &lt;strong&gt;35.99% reduction in Base64 size&lt;/strong&gt;, or &lt;strong&gt;28.84% after gzip&lt;/strong&gt;, measured on the table data alone. Here is how it works—and what those numbers leave out.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the table is for
&lt;/h2&gt;

&lt;p&gt;The table maps 8,192 possible QR Kanji indices to 16-bit Unicode values. There are 6,879 occupied positions; the rest contain &lt;code&gt;0xFFFF&lt;/code&gt;, a marker for an unused slot.&lt;/p&gt;

&lt;p&gt;The table includes kanji, hiragana, katakana, and symbols. Its order matters: moving a character would change its QR Kanji index.&lt;/p&gt;

&lt;p&gt;For QR generation, the useful lookup goes in the other direction: given a Unicode value, find the corresponding 13-bit index. The project's decoder builds that reverse lookup at initialization.&lt;/p&gt;

&lt;p&gt;The task here is to store this particular reference table compactly while preserving every value and every empty position.&lt;/p&gt;

&lt;h2&gt;
  
  
  Base64 is a representation, not compression
&lt;/h2&gt;

&lt;p&gt;Each source position takes two bytes, including empty positions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;8,192 positions × 2 bytes = 16,384 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turning those bytes into source text adds overhead:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Representation&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Raw 16-bit values&lt;/td&gt;
&lt;td&gt;16,384&amp;nbsp;bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Joined hexadecimal string&lt;/td&gt;
&lt;td&gt;32,768&amp;nbsp;chars&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Base64 of the raw bytes&lt;/td&gt;
&lt;td&gt;21,848&amp;nbsp;chars&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;String sizes exclude quotes. Both textual representations use ASCII characters, so their character counts also equal their UTF-8 byte counts.&lt;/p&gt;

&lt;p&gt;Base64 is convenient for embedding bytes in JavaScript, but it does not make the data smaller. To reduce the string, I first needed a smaller byte stream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store instructions instead of individual values
&lt;/h2&gt;

&lt;p&gt;The table is irregular, but it has useful local structure: empty runs, sequences with a constant step, and small differences between neighboring nonempty values.&lt;/p&gt;

&lt;p&gt;The format describes those patterns with five command types:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;00&lt;/code&gt; — &lt;strong&gt;&lt;code&gt;A(value)&lt;/code&gt;&lt;/strong&gt;: Emit one explicit 16-bit value.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;01&lt;/code&gt; — &lt;strong&gt;&lt;code&gt;E(count)&lt;/code&gt;&lt;/strong&gt;: Advance over empty positions, preserving the previous nonempty value.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;02&lt;/code&gt; — &lt;strong&gt;&lt;code&gt;R(start, step, count)&lt;/code&gt;&lt;/strong&gt;: Emit at least three values from an explicit start.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;03&lt;/code&gt; — &lt;strong&gt;&lt;code&gt;P(step, count)&lt;/code&gt;&lt;/strong&gt;: Emit at least two values, beginning at &lt;code&gt;previous + step&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;04&lt;/code&gt; and above — &lt;strong&gt;&lt;code&gt;D(delta)&lt;/code&gt;&lt;/strong&gt;: Emit the previous nonempty value plus a signed difference.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tokens are shown in hexadecimal. Full values occupy two bytes, most significant byte first. Tokens, counts, and encoded steps use unsigned varints, with seven data bits per byte.&lt;/p&gt;

&lt;p&gt;ZigZag encoding maps small signed numbers to small unsigned numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Signed:   0  -1  +1  -2  +2
Unsigned: 0   1   2   3   4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A delta is stored directly in its token as &lt;code&gt;zigzag(delta) + 4&lt;/code&gt;. There is no separate opcode byte for each delta: &lt;code&gt;06&lt;/code&gt;, for example, means &lt;code&gt;D(+1)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Arithmetic wraps modulo 65,536. Deltas and steps use the range −32,768 through 32,767; &lt;code&gt;0xFFFF&lt;/code&gt; remains reserved for empty entries. Series never cross empty positions.&lt;/p&gt;

&lt;h2&gt;
  
  
  A sequence in five bytes
&lt;/h2&gt;

&lt;p&gt;One part of the table contains these 20 consecutive Unicode values, from &lt;code&gt;0x3041&lt;/code&gt; through &lt;code&gt;0x3054&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;ぁあぃいぅうぇえぉおかがきぎくぐけげこご
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Storing them directly takes 40 bytes. The Delta encoder stores the first value in full, followed by nineteen &lt;code&gt;+1&lt;/code&gt; deltas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A(3041), D(+1), D(+1), ...
3 bytes + 19 bytes = 22 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Full encoder describes the same fragment with a five-byte series command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;R(3041, +1, 20)

02 30 41 02 14
│  └─┬─┘ │  └── count: 20
│    │   └───── ZigZag(+1): 2
│    └───────── start: 0x3041
└────────────── explicit-series token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As Base64, the original fragment takes 56 characters. The series takes eight: &lt;code&gt;AjBBAhQ=&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is an especially regular fragment, so it illustrates the format rather than predicting the compression ratio for the whole table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing commands with dynamic programming
&lt;/h2&gt;

&lt;p&gt;Having a compact series command does not tell us where to use it. At a given position, the encoder may have several valid choices: one full value, one delta, or series of different lengths.&lt;/p&gt;

&lt;p&gt;A series has setup costs. An explicit series costs five bytes when its step and count each fit in one byte. If its values can already be written as three one-byte deltas, using a series would make the stream larger.&lt;/p&gt;

&lt;p&gt;The encoder compares &lt;strong&gt;the command's cost plus the best cost of everything after it&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Let &lt;code&gt;cost[i]&lt;/code&gt; be the minimum number of bytes needed to encode the suffix starting at position &lt;code&gt;i&lt;/code&gt;. The terminal cost is zero:&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="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;]&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="c1"&gt;// Conceptual recurrence; candidate generation is omitted.&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="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;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;--&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;candidatesAt&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="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&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;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;next&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;total&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;cost&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="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nx"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The implementation works backward through the table, evaluates full values, deltas, and valid series lengths, and handles empty runs. It records the selected command at each position. A forward pass then writes the chosen stream.&lt;/p&gt;

&lt;p&gt;There is an important reason one cost per position is enough: &lt;strong&gt;the previous nonempty value is determined by the source table&lt;/strong&gt;, regardless of which commands encoded the prefix. A full value and a delta that emit the same value leave the same reference for the next command. The encoder precomputes that reference at each position.&lt;/p&gt;

&lt;p&gt;This gives a shortest-path interpretation: positions are nodes, commands are edges, and encoded byte lengths are weights. The optimizer minimizes the binary stream size for this format. It does &lt;strong&gt;not&lt;/strong&gt; optimize the eventual gzip size.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results across the complete table
&lt;/h2&gt;

&lt;p&gt;The Delta encoder is a simplified baseline: it uses one initial full value, individual deltas, and empty-run commands. The Full encoder also selects full values and series, using dynamic programming to minimize the complete binary stream.&lt;/p&gt;

&lt;p&gt;Here are the results for all 8,192 positions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Binary stream&lt;/th&gt;
&lt;th&gt;Base64&lt;/th&gt;
&lt;th&gt;Gzip of Base64&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Original&lt;/td&gt;
&lt;td&gt;16,384 bytes&lt;/td&gt;
&lt;td&gt;21,848&amp;nbsp;chars&lt;/td&gt;
&lt;td&gt;14,033 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delta encoder&lt;/td&gt;
&lt;td&gt;10,784 bytes&lt;/td&gt;
&lt;td&gt;14,380&amp;nbsp;chars&lt;/td&gt;
&lt;td&gt;10,094 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full encoder&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10,488 bytes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;13,984&amp;nbsp;chars&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9,986 bytes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full encoder savings vs. Original&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;35.99%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;35.99%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;28.84%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Full encoder reduces Base64 size by &lt;strong&gt;35.99%&lt;/strong&gt; compared with the Original representation, or &lt;strong&gt;28.84% after gzip&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Gzip sizes were measured on standalone Base64 strings at level 9, excluding decoder code and JavaScript wrappers. Results for a complete bundle may differ.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens at runtime
&lt;/h2&gt;

&lt;p&gt;Only the encoded data and decoder need to ship to the client; the optimizer can run beforehand.&lt;/p&gt;

&lt;p&gt;The scripts have no external dependencies. To use the pre-encoded table:&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;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"kanji-lookup-table-base64.min.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"lookup-table-decoder.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lookup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decodeKanjiTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;KANJI_LOOKUP_TABLE_BASE64&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="nx"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mh"&gt;0x9F8D&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// 4340: 龍&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The decoder returns an &lt;code&gt;Int16Array(65536)&lt;/code&gt; indexed by Unicode value, containing a QR Kanji index or &lt;code&gt;-1&lt;/code&gt; for an unsupported character. This reusable lookup occupies &lt;strong&gt;128 KiB&lt;/strong&gt; in memory; the compression savings apply to the embedded data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;p&gt;The useful step was finding a small vocabulary for the data: full values, deltas, empty runs, and arithmetic series. Dynamic programming then removed the guesswork from choosing between those representations.&lt;/p&gt;

&lt;p&gt;General-purpose compression still helped after that transformation. Whether the same approach is worthwhile elsewhere depends on the data, the decoder, and the complete application bundle.&lt;/p&gt;

&lt;p&gt;The source, format details, and demo are available at &lt;strong&gt;&lt;a href="https://github.com/datalog/kanji-base64" rel="noopener noreferrer"&gt;datalog/kanji-base64&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Have you used a small custom format to shrink static data in a JavaScript application? I'd be interested to hear which patterns made the difference.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>performance</category>
      <category>webdev</category>
      <category>kanji</category>
    </item>
  </channel>
</rss>
