<?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: Dani Polani</title>
    <description>The latest articles on DEV Community by Dani Polani (@dani_polani).</description>
    <link>https://dev.to/dani_polani</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%2F4008714%2Fe32edd8b-9c9b-4161-bbb7-59ee4268fc18.png</url>
      <title>DEV Community: Dani Polani</title>
      <link>https://dev.to/dani_polani</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dani_polani"/>
    <language>en</language>
    <item>
      <title>Building a word-alignment tool with no database and making image exports that match the screen</title>
      <dc:creator>Dani Polani</dc:creator>
      <pubDate>Mon, 29 Jun 2026 20:45:20 +0000</pubDate>
      <link>https://dev.to/dani_polani/building-a-word-alignment-tool-with-no-database-and-making-image-exports-that-match-the-screen-1pn</link>
      <guid>https://dev.to/dani_polani/building-a-word-alignment-tool-with-no-database-and-making-image-exports-that-match-the-screen-1pn</guid>
      <description>&lt;p&gt;I make small tools for linguistics and conlanging on the side. A while back I built one called Word Aligner. It draws which word matches which between a sentence and its translation, with curved connectors, and you can stack extra rows for a gloss or an IPA transcription.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsrcr0ruu8ozm3mop3x3n.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsrcr0ruu8ozm3mop3x3n.png" alt="Word Aligner visualization example" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the conlang community people post word-by-word alignments of their languages all the time, and there was no easy way to make them. Folks were lining up arrows in Paint or PowerPoint. I wanted a page where you click two words and a connector appears, so I built one. It caught on in the conlang subreddit, and it turned out language teachers and linguists wanted the same thing.&lt;/p&gt;

&lt;p&gt;This post is about two decisions that shaped the codebase: keeping all state in the URL, and getting exports to look exactly like the preview. The second one sent me further down the font rabbit hole than I expected.&lt;/p&gt;

&lt;p&gt;The stack, briefly: SvelteKit with Svelte 5, TypeScript, Tailwind v4, and Flowbite for UI. It runs on adapter-node. Most pages are static.&lt;/p&gt;

&lt;h2&gt;
  
  
  No database. The diagram lives in the URL
&lt;/h2&gt;

&lt;p&gt;There are no accounts and no backend storage. The entire project, the lines of text, the links between words, the per-line fonts, the colors, all of it, is encoded into the page URL after every edit. Open the link and you get the same diagram back. That is also the share feature: there is nothing else to share.&lt;/p&gt;

&lt;p&gt;I like this for a free tool. No login, no storage cost, no table of other people's sentences to worry about. The privacy story writes itself, because the data never reaches a server I control.&lt;/p&gt;

&lt;p&gt;The encoding has three steps. First I build a compact form of the state that only stores what differs from the defaults, with short keys and sorted fields and rounded floats. Then I deflate it. Then I&lt;br&gt;
base64url it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;deflateSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;strToU8&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fflate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;encodeState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AppStateV2&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;deflateBase64url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;toCompactJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;deflateBase64url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&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="kr"&gt;string&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;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;deflateSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;strToU8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;9&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;toBase64url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// + / = swapped for - _ and stripped&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The compaction matters more than the compression for short diagrams. A two-line alignment with a few links produces a tiny payload because almost nothing deviates from the defaults, so most fields are simply absent. Deflate then earns its keep on the big interlinear examples with custom fonts and many rows.&lt;/p&gt;

&lt;p&gt;Decoding has a guard I added after thinking about what a hostile link could do: inflate, but bail if the decompressed string is over 2 MB, and return null on any parse error rather than throwing.&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;MAX_DECOMPRESSED_BYTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;inflateBase64url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&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="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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;strFromU8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;inflateSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;fromBase64url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&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;out&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;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_DECOMPRESSED_BYTES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The honest trade-off is URL length. A heavy diagram makes a long link. For the cases people actually share it stays well within what browsers and chat apps accept, and the compaction keeps the common case short, so I have not needed a fallback store. If I ever do, it slots in behind the same encode and decode functions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exports that match the preview
&lt;/h2&gt;

&lt;p&gt;People export these diagrams into slides, papers, and worksheets, so an export that looks even slightly different from the screen is a bug report waiting to happen. The preview is SVG. So are the exports, built from the same layout and the same link geometry that the preview uses. That part was easy.&lt;/p&gt;

&lt;p&gt;PNG and PDF are where it got fiddly. PNG is the SVG drawn onto a canvas and read back as a blob:&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;dataUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`data:image/svg+xml;charset=utf-8,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;img&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;Image&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// await onload, then drawImage onto a scaled canvas, then canvas.toBlob(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PDF I do as a raster page. The SVG goes to a canvas, the canvas to a PNG, and the PNG into a single-page jsPDF sized to the image. I tried the vector route first and gave up on it. The SVG-to-PDF libraries fought with Vite over CommonJS interop, and a raster page at 2x is good enough for what these diagrams are. I would rather ship the boring version that always works than babysit a build issue for a feature nobody asked to be vector.&lt;/p&gt;

&lt;h2&gt;
  
  
  The font problem
&lt;/h2&gt;

&lt;p&gt;Here is the part that ate a weekend.&lt;/p&gt;

&lt;p&gt;When you rasterize an SVG by loading it through an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;, any &lt;code&gt;@font-face&lt;/code&gt; with a data URL races the image decode. For Google Fonts as small woff2 files it usually wins, so I leave those embedded as woff2 data URLs and they render fine. For a font the user uploaded, often a custom conlang script or something the web does not ship, it usually loses. The first and only frame gets drawn in a fallback family, and the exported PNG looks wrong while the screen looked right.&lt;/p&gt;

&lt;p&gt;The fix is to take the font out of the raster path entirely. Before rasterizing, I find every &lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt; element that uses an uploaded font and replace it with vector outlines, so the export no longer depends on the font loading at all.&lt;/p&gt;

&lt;p&gt;Getting the outlines right meant two libraries doing two jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;harfbuzzjs&lt;/strong&gt; does the shaping. It is the same engine family browsers use, so ligatures, contextual alternates, and right-to-left runs come out the way the preview shows them. I pass the usual feature set and let it tell me which glyph sits where.
&lt;/li&gt;
&lt;/ul&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;EXPORT_HB_FEATURES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;kern,liga,rlig,clig,calt,ccmp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;opentype.js&lt;/strong&gt; turns each shaped glyph id into a path with &lt;code&gt;glyph.getPath&lt;/code&gt;. I tried using HarfBuzz's own &lt;code&gt;glyphToPath&lt;/code&gt; and the text came out upside down, because HarfBuzz works in Y-up typographic coordinates and SVG is Y-down. Rather than flip everything, I let opentype.js produce the paths. It is pinned at 1.3.4, which is the version whose parsing and path output I trust here.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If HarfBuzz fails to load for some reason, there is a fallback that outlines straight from opentype.js without proper shaping. It is worse for complex scripts, but it beats a broken export.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffzbj00bflz1nh3hu2osh.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffzbj00bflz1nh3hu2osh.png" alt="Custom font export working example" width="800" height="399"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The result is that an uploaded font survives into the SVG, the PNG, and the PDF as exact shapes, and the file matches what you saw in the browser. That sounds small written down. It was the difference between the tool being usable for conlang scripts and not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Odds and ends
&lt;/h2&gt;

&lt;p&gt;The same SVG path also feeds a server-rendered preview image for social cards, using resvg. And there is a small HTTP API plus an MCP server, so an agent can generate a diagram from a phrase and get a link back. I hand-rolled the MCP side as plain JSON-RPC instead of pulling in the SDK, because it is one stateless tool and the SDK's transport assumed a Node request and response shape that SvelteKit does not hand me. That is probably its own post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it is
&lt;/h2&gt;

&lt;p&gt;The tool is at &lt;a href="https://aligner.tinygods.dev" rel="noopener noreferrer"&gt;aligner.tinygods.dev&lt;/a&gt; if you want to poke at it. It is free and there is nothing to sign up for. Happy to answer questions about any of the above in the comments, the font part especially, since I could not find a clean writeup when I was stuck on it.&lt;/p&gt;

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