<?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: hw lu</title>
    <description>The latest articles on DEV Community by hw lu (@luhw).</description>
    <link>https://dev.to/luhw</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%2F3975130%2F110b44cd-9517-47ac-ae20-3f813fe3fb86.png</url>
      <title>DEV Community: hw lu</title>
      <link>https://dev.to/luhw</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/luhw"/>
    <language>en</language>
    <item>
      <title>How Unicode Bubble Text Generators Work (and Where They Break)</title>
      <dc:creator>hw lu</dc:creator>
      <pubDate>Fri, 02 Oct 2026 13:51:32 +0000</pubDate>
      <link>https://dev.to/luhw/how-unicode-bubble-text-generators-work-and-where-they-break-3eb7</link>
      <guid>https://dev.to/luhw/how-unicode-bubble-text-generators-work-and-where-they-break-3eb7</guid>
      <description>&lt;p&gt;Bubble text looks like a font effect, but a copy-and-paste generator does not render a font at all. It replaces ordinary Latin letters with existing Unicode characters such as Ⓑ, ⓑ, 🅑, 🄱, and ⒝.&lt;/p&gt;

&lt;p&gt;That distinction changes almost every implementation decision. The result must remain real text, unsupported characters need a safe fallback, and each operating system is free to draw the same code point differently.&lt;/p&gt;

&lt;p&gt;I recently built a small browser-only generator around those constraints. Here is how the conversion works, where Unicode makes the job easy, and where the character set becomes uneven.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bubble text is character substitution
&lt;/h2&gt;

&lt;p&gt;A CSS font family can change how text looks on one page, but copying that text normally gives the original letters. A Unicode bubble generator changes the characters themselves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Bubble 123
Ⓑⓤⓑⓑⓛⓔ ①②③
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the output is text, it can be selected, copied, searched, and pasted into compatible fields. There is no canvas, SVG, image upload, or font file involved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Map code points instead of storing giant lookup objects
&lt;/h2&gt;

&lt;p&gt;Several useful Unicode ranges are contiguous. Circled capital A begins at U+24B6, while the ASCII capital A begins at U+0041. The difference between those values can be used as an offset for the whole A–Z range.&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;CIRCLED_UPPER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x24b6&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mh"&gt;0x41&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CIRCLED_LOWER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x24d0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mh"&gt;0x61&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isUpper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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;cp&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x41&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;cp&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mh"&gt;0x5a&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toCircled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;original&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="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="nf"&gt;isUpper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cp&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="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCodePoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cp&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;CIRCLED_UPPER&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;cp&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x61&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;cp&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mh"&gt;0x7a&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="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCodePoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cp&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;CIRCLED_LOWER&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;original&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is smaller and easier to audit than maintaining a large object containing every letter. It also makes the boundaries explicit: only characters inside the supported ranges are transformed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Iterate by code point, not UTF-16 code unit
&lt;/h2&gt;

&lt;p&gt;JavaScript strings use UTF-16 internally. Indexing with a classic numeric loop can split characters outside the Basic Multilingual Plane into surrogate halves.&lt;/p&gt;

&lt;p&gt;A for...of loop iterates by Unicode code point, which makes it a safer default:&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;function&lt;/span&gt; &lt;span class="nf"&gt;convertWith&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;map&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CharMap&lt;/span&gt;&lt;span class="p"&gt;)&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;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&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;character&lt;/span&gt; &lt;span class="k"&gt;of&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;codePoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;character&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;codePointAt&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;output&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;codePoint&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;character&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;codePoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;character&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;output&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even when the generator only transforms A–Z, users will paste emoji, punctuation, accented letters, and characters from other scripts. Iterating safely prevents those inputs from being damaged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unicode style ranges are not symmetrical
&lt;/h2&gt;

&lt;p&gt;The tricky part is that each visual family supports a different set of characters.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Circled letters include uppercase and lowercase Latin letters.&lt;/li&gt;
&lt;li&gt;Negative circled letters are uppercase, so lowercase input must be folded to uppercase if that style is selected.&lt;/li&gt;
&lt;li&gt;Squared letters are also uppercase-only.&lt;/li&gt;
&lt;li&gt;Parenthesized letters use lowercase forms.&lt;/li&gt;
&lt;li&gt;Circled and dark circled digits have different ranges.&lt;/li&gt;
&lt;li&gt;Parenthesized digits cover 1–9, while zero has no matching character in that set.&lt;/li&gt;
&lt;li&gt;Squared digits are not available as a matching sequence, so the original digit should pass through.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That means a style cannot be represented only by a single offset. Each style needs a small mapping policy for case folding, letters, digits, and fallback behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preserve characters you cannot convert
&lt;/h2&gt;

&lt;p&gt;A generator should never silently delete or guess unsupported input. If a user types café, a squared-uppercase style can convert the supported letters and preserve the rest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;café → 🄲🄰🄵é
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same rule keeps spaces, punctuation, emoji, and non-Latin scripts intact. Partial conversion is predictable; destructive conversion is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Show every result at once
&lt;/h2&gt;

&lt;p&gt;I originally considered a style dropdown, but the output is easier to choose when all variants are visible together. For each keystroke, the interface runs the same input through six mapping functions and renders six rows:&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;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;styles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;style&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convert&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The data set is tiny, so there is no reason to add a server request or generation queue. The whole interaction can remain local to the browser.&lt;/p&gt;

&lt;p&gt;Each row gets its own Copy button. navigator.clipboard.writeText() is enough for modern secure contexts, but the call should still be wrapped in try/catch because clipboard permission or browser policy can reject it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compatibility is part of the product
&lt;/h2&gt;

&lt;p&gt;Unicode defines characters, not an identical visual appearance everywhere. A circled letter may have different spacing, weight, or proportions on macOS, Windows, Android, and individual apps. Some username fields also reject unusual Unicode even when normal messages accept it.&lt;/p&gt;

&lt;p&gt;A responsible UI should say this clearly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The result is Unicode text, not a downloadable font.&lt;/li&gt;
&lt;li&gt;Appearance can vary by device and app.&lt;/li&gt;
&lt;li&gt;Some fields may reject the characters.&lt;/li&gt;
&lt;li&gt;Important information should remain in ordinary text.&lt;/li&gt;
&lt;li&gt;Users should test the result in its final destination.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is more useful than promising that decorative text works everywhere.&lt;/p&gt;

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

&lt;p&gt;I used these rules in &lt;a href="https://gummytype.com/bubble-text-generator" rel="noopener noreferrer"&gt;GummyType's Bubble Text Generator&lt;/a&gt;. It produces six copy-and-paste styles, preserves unsupported characters, and keeps conversion inside the browser.&lt;/p&gt;

&lt;p&gt;The implementation is small, but it is a useful example of a broader rule: when a tool transforms text, the edge cases are part of the feature. Unicode ranges, case behavior, digits, fallback policy, clipboard errors, and platform rendering all deserve explicit decisions.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Bubble text generators substitute Unicode characters; they do not apply fonts.&lt;/li&gt;
&lt;li&gt;Contiguous Unicode ranges make offset-based mapping practical.&lt;/li&gt;
&lt;li&gt;Each style still needs its own case and digit policy.&lt;/li&gt;
&lt;li&gt;Iterate by code point and preserve unsupported input.&lt;/li&gt;
&lt;li&gt;Generate locally and show variants together when the data set is small.&lt;/li&gt;
&lt;li&gt;Treat compatibility limitations as product information, not footnotes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Small text tools become much more reliable when their boundaries are designed as carefully as their happy path.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>tutorial</category>
      <category>showdev</category>
    </item>
    <item>
      <title>How I Built a Browser-Only Bubble Text Generator with SVG and Canvas</title>
      <dc:creator>hw lu</dc:creator>
      <pubDate>Mon, 17 Aug 2026 01:30:13 +0000</pubDate>
      <link>https://dev.to/luhw/how-i-built-a-browser-only-bubble-text-generator-with-svg-and-canvas-i65</link>
      <guid>https://dev.to/luhw/how-i-built-a-browser-only-bubble-text-generator-with-svg-and-canvas-i65</guid>
      <description>&lt;p&gt;Visual text generators look simple from the outside: type a phrase, choose a style, and download an image.&lt;/p&gt;

&lt;p&gt;The implementation gets more interesting when the whole pipeline has to stay in the browser. The preview must match the exported PNG, fonts must render consistently across devices, transparent margins should be cropped, and changing a slider should not make the layout jump.&lt;/p&gt;

&lt;p&gt;I recently built a small browser-based text-art generator to explore that problem. This post explains the architecture and the details that took the most work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I wanted the tool to do
&lt;/h2&gt;

&lt;p&gt;The product requirements were intentionally narrow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Accept a short phrase, including line breaks and mixed case&lt;/li&gt;
&lt;li&gt;Offer three visibly different font shapes&lt;/li&gt;
&lt;li&gt;Apply eight finishes such as glossy, sugar, holographic, chrome, neon, and sticker&lt;/li&gt;
&lt;li&gt;Let the user change the font size and color&lt;/li&gt;
&lt;li&gt;Preview changes immediately&lt;/li&gt;
&lt;li&gt;Export a watermark-free PNG&lt;/li&gt;
&lt;li&gt;Keep the phrase and rendering work inside the browser&lt;/li&gt;
&lt;li&gt;Avoid accounts, uploads, and a server-side image API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last requirement shaped almost every technical decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rendering pipeline
&lt;/h2&gt;

&lt;p&gt;The editor is a Client Component in a Next.js App Router project. React owns the controls, but the artwork itself is created by a pure TypeScript function.&lt;/p&gt;

&lt;p&gt;The pipeline looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;React controls
  -&amp;gt; SVG string
  -&amp;gt; SVG Blob
  -&amp;gt; HTML Image
  -&amp;gt; Canvas raster
  -&amp;gt; alpha-bound scan
  -&amp;gt; cropped PNG Blob
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping SVG generation in a pure function made it easier to test every combination without a browser. The browser-specific work is limited to font loading, text measurement, preview rasterization, and downloading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why SVG was a good fit
&lt;/h2&gt;

&lt;p&gt;SVG gives text effects a useful middle ground between plain CSS and a full graphics engine.&lt;/p&gt;

&lt;p&gt;Each finish is built from three text layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An outer stroke and shadow&lt;/li&gt;
&lt;li&gt;The main gradient-filled text&lt;/li&gt;
&lt;li&gt;A thin highlight shifted slightly upward and left&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A simplified version looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;g&lt;/span&gt; &lt;span class="na"&gt;text-anchor=&lt;/span&gt;&lt;span class="s"&gt;"middle"&lt;/span&gt; &lt;span class="na"&gt;dominant-baseline=&lt;/span&gt;&lt;span class="s"&gt;"middle"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;text&lt;/span&gt;
    &lt;span class="na"&gt;fill=&lt;/span&gt;&lt;span class="s"&gt;"url(#gummyFill)"&lt;/span&gt;
    &lt;span class="na"&gt;stroke=&lt;/span&gt;&lt;span class="s"&gt;"#8a174e"&lt;/span&gt;
    &lt;span class="na"&gt;paint-order=&lt;/span&gt;&lt;span class="s"&gt;"stroke fill"&lt;/span&gt;
    &lt;span class="na"&gt;filter=&lt;/span&gt;&lt;span class="s"&gt;"url(#softShadow)"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    HELLO
  &lt;span class="nt"&gt;&amp;lt;/text&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;text&lt;/span&gt;
    &lt;span class="na"&gt;fill=&lt;/span&gt;&lt;span class="s"&gt;"url(#gummyFill)"&lt;/span&gt;
    &lt;span class="na"&gt;stroke=&lt;/span&gt;&lt;span class="s"&gt;"#ff4fa3"&lt;/span&gt;
    &lt;span class="na"&gt;paint-order=&lt;/span&gt;&lt;span class="s"&gt;"stroke fill"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    HELLO
  &lt;span class="nt"&gt;&amp;lt;/text&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;text&lt;/span&gt;
    &lt;span class="na"&gt;fill=&lt;/span&gt;&lt;span class="s"&gt;"none"&lt;/span&gt;
    &lt;span class="na"&gt;stroke=&lt;/span&gt;&lt;span class="s"&gt;"#ffffff"&lt;/span&gt;
    &lt;span class="na"&gt;transform=&lt;/span&gt;&lt;span class="s"&gt;"translate(-2 -4)"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    HELLO
  &lt;span class="nt"&gt;&amp;lt;/text&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/g&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The styles share the same renderer. Only a finish profile changes the gradient definitions, texture filter, stroke policy, and outer filter.&lt;/p&gt;

&lt;p&gt;That is important because it prevents style logic from being scattered across the codebase. Adding a new finish means defining one complete rendering profile rather than updating several conditionals.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making fonts consistent across devices
&lt;/h2&gt;

&lt;p&gt;Relying on system font stacks caused an early problem. A rounded font and a chunky font can look different on one desktop and collapse to similar fallback fonts on another device.&lt;/p&gt;

&lt;p&gt;I fixed that by bundling the font files with the application.&lt;/p&gt;

&lt;p&gt;When a user selects a shape, the browser:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetches the matching TTF file&lt;/li&gt;
&lt;li&gt;Waits for the browser font to load&lt;/li&gt;
&lt;li&gt;Converts the file to a data URL&lt;/li&gt;
&lt;li&gt;Embeds it into the generated SVG with @font-face&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That makes the SVG self-contained before it is rasterized. The preview and the downloaded PNG therefore use the same font data instead of depending on whatever fonts happen to be installed on the device.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring text before sizing the canvas
&lt;/h2&gt;

&lt;p&gt;Character count is not a reliable width measurement. A string full of W characters can be much wider than a string of i characters with the same length.&lt;/p&gt;

&lt;p&gt;The renderer uses CanvasRenderingContext2D.measureText with the selected family, weight, style, size, and letter spacing. The widest line determines the SVG canvas width.&lt;/p&gt;

&lt;p&gt;The canvas is then sized around the measured artwork:&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;widestLine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;line&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;measureLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fontSize&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;padding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fontSize&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.22&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;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;widestLine&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;padding&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;lines&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="nx"&gt;fontSize&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.94&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;padding&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="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This avoids two opposite problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Long phrases getting clipped&lt;/li&gt;
&lt;li&gt;Short phrases being exported inside a huge fixed square&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also means a larger font size produces a genuinely larger PNG instead of merely scaling the same fixed canvas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the preview stable
&lt;/h2&gt;

&lt;p&gt;A tightly sized export is useful, but a tightly sized live preview can be distracting. The preview frame would change width on every keystroke and every font-size adjustment.&lt;/p&gt;

&lt;p&gt;I use two SVG renders from the same source options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The preview SVG uses a frame sized for the maximum font size&lt;/li&gt;
&lt;li&gt;The download SVG is sized tightly around the selected font size&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The text effect is identical, but the preview has a stable visual frame. The exported file remains compact.&lt;/p&gt;

&lt;p&gt;The preview raster is capped at a smaller resolution for responsiveness, while export can use a larger maximum side. Neither path upscales the artwork beyond its natural dimensions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auto-cropping the PNG
&lt;/h2&gt;

&lt;p&gt;After loading the SVG into an Image element, the browser draws it onto a temporary canvas. For transparent exports, the code reads the alpha channel and finds the first and last nontransparent pixel on each axis.&lt;/p&gt;

&lt;p&gt;A safe margin is added to those bounds so shadows and glow effects are not cut off. The selected rectangle is then copied to a second canvas and encoded as PNG.&lt;/p&gt;

&lt;p&gt;This step is skipped when the user chooses a solid or gradient background, because the background intentionally fills the entire canvas.&lt;/p&gt;

&lt;p&gt;The same rasterization function is used by both preview and download. Sharing that path prevents the common bug where the editor looks correct but the saved file has different spacing or clipping.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small details that mattered
&lt;/h2&gt;

&lt;p&gt;A few less obvious issues were worth handling explicitly.&lt;/p&gt;

&lt;h3&gt;
  
  
  SVG whitespace
&lt;/h3&gt;

&lt;p&gt;Regular spaces can be collapsed or stripped when SVG text is rasterized. Replacing user-entered spaces with non-breaking spaces keeps single and repeated spaces visible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stale asynchronous previews
&lt;/h3&gt;

&lt;p&gt;Rendering is asynchronous. If a user types quickly, an older render can finish after a newer one. Each effect cleanup marks its render inactive so stale results cannot replace the latest preview.&lt;/p&gt;

&lt;h3&gt;
  
  
  Object URL cleanup
&lt;/h3&gt;

&lt;p&gt;Every generated preview creates an object URL. The previous URL is revoked when a new preview replaces it, and the current URL is revoked when the component unmounts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Input limits
&lt;/h3&gt;

&lt;p&gt;The editor accepts short display text rather than paragraphs. A small character limit keeps the interface focused and prevents extreme canvas dimensions.&lt;/p&gt;

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

&lt;p&gt;The finished tool is &lt;a href="https://gummytype.com/" rel="noopener noreferrer"&gt;GummyType&lt;/a&gt;. It creates bubble, Y2K, and chrome-style word graphics, and everything described above runs locally in the browser.&lt;/p&gt;

&lt;p&gt;If you are more interested in shaping the letterforms than the rendering pipeline, I also wrote a &lt;a href="https://gummytype.com/how-to-make-bubble-letters" rel="noopener noreferrer"&gt;step-by-step guide to making bubble letters&lt;/a&gt; by hand and with free browser tools.&lt;/p&gt;

&lt;p&gt;The project reminded me that a small visual tool can still contain several interesting browser-engineering problems: font portability, SVG composition, accurate measurement, asynchronous rendering, raster export, and memory cleanup.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would add next
&lt;/h2&gt;

&lt;p&gt;The current version deliberately keeps the workflow simple. Possible future improvements include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;More font shapes without making the control panel overwhelming&lt;/li&gt;
&lt;li&gt;SVG export for users who need editable artwork&lt;/li&gt;
&lt;li&gt;Reusable presets for common visual styles&lt;/li&gt;
&lt;li&gt;Better keyboard controls and accessibility feedback&lt;/li&gt;
&lt;li&gt;More export backgrounds and layout templates&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For now, the most useful constraint has been keeping the tool focused: short text in, finished image out, with no server rendering step in between.&lt;/p&gt;

&lt;p&gt;If you have built a browser-based image or SVG tool, I would be interested to hear which rendering edge cases caused the most trouble for you.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>nextjs</category>
      <category>showdev</category>
    </item>
    <item>
      <title>How to Create Charts from CSV Files Without Excel</title>
      <dc:creator>hw lu</dc:creator>
      <pubDate>Tue, 09 Jun 2026 05:48:32 +0000</pubDate>
      <link>https://dev.to/luhw/how-to-create-charts-from-csv-files-without-excel-4p3j</link>
      <guid>https://dev.to/luhw/how-to-create-charts-from-csv-files-without-excel-4p3j</guid>
      <description>&lt;p&gt;CSV files are everywhere: analytics exports, sales reports, product usage logs, survey results, finance data, and database dumps. But turning a CSV into a quick chart can still feel heavier than it should.&lt;/p&gt;

&lt;p&gt;Most people open Excel or Google Sheets, clean the data, select a chart type, adjust the axes, export an image, and then paste it into a report or slide deck. That works, but it is not always the fastest path when you just need a simple line chart, bar chart, or scatter plot.&lt;/p&gt;

&lt;p&gt;This post walks through a lightweight workflow for creating charts from CSV files without starting a full spreadsheet project.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a spreadsheet is more than you need
&lt;/h2&gt;

&lt;p&gt;Spreadsheets are great when you need formulas, pivot tables, collaboration, or manual editing. But for quick visualization, they can add friction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You have to import the file correctly.&lt;/li&gt;
&lt;li&gt;Numeric columns may be treated as text.&lt;/li&gt;
&lt;li&gt;Dates may be parsed differently depending on locale.&lt;/li&gt;
&lt;li&gt;Exporting a clean chart image takes extra steps.&lt;/li&gt;
&lt;li&gt;The file may contain data you do not want to upload to a shared workspace.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the task is just "show me how this CSV changes over time," a smaller workflow is often enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  A simple CSV-to-chart workflow
&lt;/h2&gt;

&lt;p&gt;The most reliable workflow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start with a clean CSV file.&lt;/li&gt;
&lt;li&gt;Pick the column for the X axis.&lt;/li&gt;
&lt;li&gt;Pick one or more numeric columns for the Y axis.&lt;/li&gt;
&lt;li&gt;Choose a chart type.&lt;/li&gt;
&lt;li&gt;Export the chart as PNG or SVG.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is enough for many common cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Monthly revenue by region&lt;/li&gt;
&lt;li&gt;Product usage over time&lt;/li&gt;
&lt;li&gt;Survey responses by category&lt;/li&gt;
&lt;li&gt;Error counts per release&lt;/li&gt;
&lt;li&gt;CSV exports from internal tools&lt;/li&gt;
&lt;li&gt;Simple comparisons across numeric columns&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, a file like this can become a line chart quickly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;month,visits,signups,paid_users
2026-01,1200,84,12
2026-02,1480,101,18
2026-03,1760,133,21
2026-04,1690,126,24
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;month&lt;/code&gt; as the X axis, then plot &lt;code&gt;visits&lt;/code&gt;, &lt;code&gt;signups&lt;/code&gt;, or &lt;code&gt;paid_users&lt;/code&gt; as Y values.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common issues to check before charting
&lt;/h2&gt;

&lt;p&gt;If a CSV does not chart correctly, the problem is usually in the data shape rather than the chart tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Numbers are stored as text
&lt;/h3&gt;

&lt;p&gt;Values like &lt;code&gt;$1,200&lt;/code&gt;, &lt;code&gt;1,200 users&lt;/code&gt;, or &lt;code&gt;~1200&lt;/code&gt; may not be treated as numbers. Remove symbols and units before charting.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. There are extra header rows
&lt;/h3&gt;

&lt;p&gt;CSV exports from reporting tools often include a title row, notes, or metadata before the actual header. The first row should usually be the column names.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Dates are inconsistent
&lt;/h3&gt;

&lt;p&gt;Try to keep dates in a consistent format such as &lt;code&gt;YYYY-MM-DD&lt;/code&gt; or &lt;code&gt;YYYY-MM&lt;/code&gt;. This makes the X axis easier to read and sort.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The data is too aggregated or not aggregated enough
&lt;/h3&gt;

&lt;p&gt;If you need "sales by region per month," make sure each row has a clear month and region, or aggregate the data first in a spreadsheet or script.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool options
&lt;/h2&gt;

&lt;p&gt;There are several good ways to make charts from CSV data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Google Sheets or Excel if you need editing, formulas, or pivot tables.&lt;/li&gt;
&lt;li&gt;Python with pandas and matplotlib if you want a reproducible code workflow.&lt;/li&gt;
&lt;li&gt;Observable or notebooks if you want interactive analysis.&lt;/li&gt;
&lt;li&gt;Datawrapper if you need polished charts for publishing.&lt;/li&gt;
&lt;li&gt;A lightweight browser tool if you just need a quick chart export.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built one of those lightweight browser tools called CSV Graph:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://csv.qingyanglabs.com/" rel="noopener noreferrer"&gt;https://csv.qingyanglabs.com/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It lets you paste or upload CSV data, choose columns, create line, bar, or scatter charts, and export the result as PNG or SVG. It is intentionally simple: no sign-up, no dashboard setup, and no AI-generated numbers. The goal is to make the chart from the data you actually provide.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to use a lightweight chart tool
&lt;/h2&gt;

&lt;p&gt;A simple CSV chart tool is not the right fit for every job.&lt;/p&gt;

&lt;p&gt;Use a spreadsheet, BI tool, or notebook when you need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pivot tables&lt;/li&gt;
&lt;li&gt;Joins across multiple files&lt;/li&gt;
&lt;li&gt;Complex formulas&lt;/li&gt;
&lt;li&gt;Scheduled reports&lt;/li&gt;
&lt;li&gt;Permissions and team dashboards&lt;/li&gt;
&lt;li&gt;Very large datasets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But if you only need to turn a CSV export into a clean chart for a report, issue, README, slide, or quick analysis, a small browser-based workflow can save time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thought
&lt;/h2&gt;

&lt;p&gt;CSV is simple, but visualizing it should be simple too. Before opening a full spreadsheet or asking an AI tool to generate a chart from a prompt, try the deterministic path first: inspect the CSV, pick the columns, make the chart, export the result.&lt;/p&gt;

&lt;p&gt;That keeps the numbers honest and the workflow fast.&lt;/p&gt;




&lt;p&gt;You can also find CSV Graph on Product Hunt:&lt;br&gt;
&lt;a href="https://www.producthunt.com/products/csv-graph?launch=csv-graph" rel="noopener noreferrer"&gt;https://www.producthunt.com/products/csv-graph?launch=csv-graph&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>productivity</category>
      <category>data</category>
    </item>
  </channel>
</rss>
