DEV Community

xiao lu
xiao lu

Posted on

Building a Unicode Text Transformer with Pure Character Maps

I built Unicode Text Tools, a free site with a bunch of text converters — superscript, subscript, bubble/circled text, upside-down text, small caps, and more. Type something, get it transformed, copy it out.

The whole engine is one dependency-free JS file built entirely from character mapping tables. No AI, no server, no libraries. Here's why that's the right architecture for this class of tool, and how the trickier conversions work.

The core idea: it's all just lookup tables

Every conversion on the site is a function that maps each input character to a Unicode character (or does a small transform). The simplest cases are pure dictionaries:

// Superscript (full a-z, 0-9)
var SUP = {
  a: '', b: '', c: '', d: '', e: '', f: '', g: '', h: 'ʰ', i: '',
  j: 'ʲ', k: '', l: 'ˡ', m: '', n: '', o: '', p: '', q: '', r: 'ʳ',
  s: 'ˢ', t: '', u: '', v: '', w: 'ʷ', x: 'ˣ', y: 'ʸ', z: '',
  '0': '', '1': '¹', '2': '²', '3': '³', '4': '', '5': '',
  '6': '', '7': '', '8': '', '9': '', '+': '', '-': '', '=': '',
  '(': '', ')': ''
};
Enter fullscreen mode Exit fullscreen mode

The transform itself is trivial — walk the string, look up each char, append the mapped value (or the original char if unmapped). The work is in the tables: knowing which Unicode blocks exist, what's 1:1 reversible, and what's incomplete.

The Unicode reality check

Here's the thing nobody tells you about Unicode text transformation: the blocks are inconsistent.

  • Superscript: complete for a-z and 0-9 — fully reversible.
  • Subscript: incomplete — there's no subscript b, c, d, f, g, q, w, y, z. If you map an input with those letters, you have to decide what to do with them.
  • Small caps: x has no small-cap form ( is the closest, but it's a different character and looks wrong). j is a problem too — the Unicode small-cap collides visually with the small-cap i in many fonts.

The site's approach: fallback keeps the original character. Unmappable input passes through unchanged rather than being silently corrupted:

function convert(text, table) {
  return text.split('').map(ch => table[ch] || ch).join('');
}
Enter fullscreen mode Exit fullscreen mode

This is the pragmatic contract — "I'll transform everything I can, and leave the rest alone" — and it's honest about Unicode's limits. A subscript c just stays c, and the user understands.

Uppercase reuse: a tiny DRY trick

The superscript and subscript blocks only define lowercase. But users type "HELLO" in all caps all the time. Instead of duplicating every entry, uppercase letters are folded onto their lowercase mapping at load time:

(function addUpper(tbl) {
  Object.keys(tbl).forEach(function (k) {
    if (k >= 'a' && k <= 'z') tbl[k.toUpperCase()] = tbl[k];
  });
})(SUP);
Enter fullscreen mode Exit fullscreen mode

One line, and the whole uppercase alphabet is covered without writing 26 more table entries.

The one that's not a lookup: upside-down text

Upside-down text is the interesting one, because it's not a pure per-character map. Flipping text 180° means you have to reverse the string order too — the first character visually ends up at the bottom-right.

var FLIP = {
  a: 'ɐ', b: 'q', c: 'ɔ', d: 'p', e: 'ǝ', f: 'ɟ', g: 'ƃ', h: 'ɥ', i: 'ı',
  j: 'ɾ', k: 'ʞ', l: 'l', m: 'ɯ', n: 'u', o: 'o', p: 'd', q: 'b', r: 'ɹ',
  s: 's', t: 'ʇ', u: 'n', v: 'ʌ', w: 'ʍ', x: 'x', y: 'ʎ', z: 'z',
  A: '', B: '', C: 'Ɔ', D: '', E: 'Ǝ', F: '', G: '', H: 'H', I: 'I',
  J: 'ſ', K: 'ʞ', L: '', M: 'W', N: 'N', O: 'O', P: 'Ԁ', Q: 'Ό', R: '',
  S: 'S', T: '', U: '', V: 'Λ', W: 'M', X: 'X', Y: '', Z: 'Z',
  '?': '¿', '!': '¡', '(': ')', ')': '(', '[': ']', ']': '[', '&': '', '_': ''
};
Enter fullscreen mode Exit fullscreen mode

Notice the map is symmetricb flips to q, and q flips back to b. Making the map symmetric means the transform is a true involution: flip the text twice and you get the original back. That's a great property for a tool — users can copy flipped text and un-flip it to verify.

The symmetry is enforced programmatically, so the table can't drift:

// Make the flip map symmetric so upside-down text reverses exactly.
Object.keys(FLIP).forEach(function (k) {
  var v = FLIP[k];
  if (v !== k && !Object.prototype.hasOwnProperty.call(FLIP, v)) FLIP[v] = k;
});
Enter fullscreen mode Exit fullscreen mode

And the transformation itself reverses the string:

function flipText(s) {
  return s.split('').reverse().map(function (ch) {
    return FLIP[ch] || ch;
  }).join('');
}
Enter fullscreen mode Exit fullscreen mode

Bubble text: computed, not tabulated

Bubble/circled text is the one case where a range-based calculation beats a hand-written table. The Unicode circled letters are in contiguous blocks, so they're computed on the fly:

function bubbleChar(ch) {
  var code = ch.charCodeAt(0);
  if (code >= 97 && code <= 122) return String.fromCharCode(0x24D0 + (code - 97)); // ⓐ-ⓩ
  if (code >= 65 && code <= 90) return String.fromCharCode(0x24B6 + (code - 65));  // Ⓐ-Ⓩ
  if (code >= 48 && code <= 57) {
    if (ch === '0') return '';
    return String.fromCharCode(0x2460 + (code - 49)); // ①-⑨
  }
  return ch;
}
Enter fullscreen mode Exit fullscreen mode

One quirk worth noting: circled 0 is ⓪, not Ⓞ — the circled digits block starts at ①, and zero lives in a different Unicode block. If you'd written a generic 0x24EA + (code - 48) you'd have produced a circled letter O instead of a circled digit. These are the details that separate a polished tool from a broken one.

Architecture takeaways

  1. Character maps are the right tool for deterministic text transforms. They're fast, predictable, and debuggable — you can read the entire behavior of the engine by reading one table.
  2. Fallback = keep the original. When Unicode doesn't have a mapping (subscript c, small-cap x), pass the character through rather than corrupting it.
  3. A transform that's reversible is a feature. Symmetric flip maps let users un-flip text to verify — and the enforced symmetry prevents table drift.
  4. Know your Unicode blocks. Circled-zero lives in a different block than circled-1-9. Contiguous ranges are computable, but verify the edge cases.

The live site is at unicodetexttools.com — superscript, subscript, bubble, small caps, upside down, and a dozen more, all in one dependency-free JS file.

Have you built a text-transform tool? What did Unicode surprise you with? Let me know in the comments.

Top comments (0)