Back in 1990, Microsoft licensed a series of dingbat glyphs designed by legendary typographers Charles Bigelow and Kris Holmes, packaging them directly into Windows 3.1 under the name Wingdings.
For decades, we used them for playful typography and hidden easter eggs: typing J in Microsoft Word turned into a classic smiley face ☺, and typing A produced a victory sign ✌. In gaming communities like Undertale, W. D. Gaster’s mysterious Wingdings monologues became an enduring piece of internet folklore.
Yet, if you try to copy Wingdings text from a desktop document or a traditional web generator and paste it into Discord, Slack, or X (Twitter) today, you run into this frustrating result:
Input in app: Hello World (rendered in Wingdings symbols)
Pasted output: Hello World (back to standard Latin characters)
Why does this happen? Because legacy Wingdings is not a character encoding—it is a glyph substitution hack.
Here is a breakdown of how symbol fonts actually work under the hood, why they break on the modern web clipboard, and how I built a zero-dependency bidirectional Unicode engine to solve the problem with TypeScript and Next.js.
The Underlying Issue: Glyph Substitution vs. True Unicode
In the early 1990s, before Unicode became the universal standard for digital text, operating systems had to map non-alphabetic icons to physical keyboard keys.
Rather than inventing new character encodings, Microsoft mapped dingbat glyphs onto the standard ASCII table:
- Key
A(ASCII code0x41) was assigned a victory hand glyph. - Key
B(ASCII code0x42) was assigned a telephone glyph. - Key
C(ASCII code0x43) was assigned a thumbs-up glyph.
When you opened Microsoft Word, typed ABC, and switched the font family to "Wingdings", the underlying file still held the exact bytes [0x41, 0x42, 0x43]. The software simply swapped the font renderer's glyph table.
When the web moved entirely to UTF-8 and cross-platform communication, this decades-old technique broke in three major ways:
-
Clipboard Isolation: When you copy text styled with
@font-face { font-family: 'Wingdings'; }, your browser's clipboard receives the raw ASCII string. Because platforms like Discord and WhatsApp use their own system font stacks, the recipient's device renders standard Latin characters. - Platform Absence: Mobile operating systems (iOS and Android) do not ship with Microsoft’s proprietary desktop symbol font files.
- Screen Reader Confusion: Accessibility tools try to pronounce the underlying Latin characters rather than acknowledging the visual symbols.
The Architectural Fix: Mapping Directly to Modern Unicode
To make Wingdings symbols work seamlessly across every chat app, operating system, and browser, we cannot rely on CSS font loading. Instead, we must translate each Latin character directly to its authentic Unicode counterpart (found in Unicode blocks like Dingbats, Miscellaneous Symbols, and Emoji blocks).
For instance:
-
'A'in Wingdings 1 maps to Unicode\u270C(✌) -
'B'maps to Unicode\u2706(✆) -
'M'maps to Unicode\u{1F4A3}(💣)
With true Unicode points, when a user clicks "Copy", the symbols remain identical whether they are pasted on an iPhone, an Android phone, or a Linux terminal.
Engineering the Engine in TypeScript
When building Wingdings Translator, the system needed to support four classic font dialects: Wingdings 1, Wingdings 2, Wingdings 3, and Webdings.
Here is how the core translator handles character mapping, lazy reverse-caching, and surrogate pairs.
1. Dictionary Mapping Matrix
export type FontType = 'wingdings' | 'wingdings2' | 'wingdings3' | 'webdings';
export const DICTIONARIES: Record<FontType, Record<string, string>> = {
wingdings: {
'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': '☪',
'a': '♋', 'b': '♌', 'c': '♍', 'd': '♎', 'e': '♏', 'f': '♐', 'g': '♑', 'h': '♒',
'i': '♓', 'j': '♈', 'k': '♉', 'l': '●', 'm': '❍', 'n': '■', 'o': '□', 'p': '◻',
'0': '📁', '1': '📂', '2': '📄', '3': '🗏', '4': '🗐', '5': '🗄', '6': '⌛',
'!': '✏', '"': '✂', '#': '✁', '$': '👓', '%': '🕭', '&': '🕮'
},
wingdings2: {
'A': '🖎', 'B': '✍', 'C': '🖒', 'D': '🖓', 'E': '☚', 'F': '☛',
'a': '🄋', 'b': '➀', 'c': '➁', 'd': '➂', 'e': '➃',
'0': '⓪', '1': '①', '2': '②', '3': '③', '4': '④'
},
wingdings3: {
'A': '↕', 'B': '↕', 'C': '↔', 'D': '↔', 'E': '↗', 'F': '↙',
'a': '←', 'b': '↑', 'c': '→', 'd': '↓', 'e': '↖', 'f': '↗'
},
webdings: {
'A': '🏗', 'B': '🏢', 'C': '🏙', 'D': '🏚', 'E': '🏕', 'F': '🏭',
'a': '✔', 'b': '🚲', 'c': '🔲', 'd': '🛡', 'e': '📦'
}
};
2. Bidirectional Decoding with Lazy Lookup Inversion
Encoding from English to symbols is straightforward. However, decoding (converting obscure dingbat symbols back into readable English text) requires inverting the dictionary: keys become values, and values become keys.
Creating an inverted object on every user keystroke would cause unnecessary memory allocations and garbage collection stutter during fast typing.
To keep the UI fluid at 60 FPS, the reverse dictionary is constructed lazily once per font family and kept in an in-memory cache:
export class TranslatorCore {
private cacheReversed: Partial<Record<FontType, Record<string, string>>> = {};
// English -> Wingdings Unicode Symbols
toSymbols(text: string, fontType: FontType, useEmojis: boolean = true): string {
const dict = DICTIONARIES[fontType];
if (!useEmojis || !dict) return text;
let result = '';
for (let i = 0; i < text.length; i++) {
const char = text[i];
result += dict[char] !== undefined ? dict[char] : char;
}
return result;
}
// Wingdings Unicode Symbols -> English
toEnglish(text: string, fontType: FontType): string {
const dict = DICTIONARIES[fontType];
if (!dict) return text;
// Lazily build the reverse lookup map on demand
if (!this.cacheReversed[fontType]) {
this.cacheReversed[fontType] = Object.entries(dict).reduce(
(acc, [eng, sym]) => {
acc[sym] = eng;
return acc;
},
{} as Record<string, string>
);
}
const revDict = this.cacheReversed[fontType]!;
let result = '';
// Note: Iterate using `for...of` to preserve surrogate pairs in Unicode
for (const char of text) {
result += revDict[char] !== undefined ? revDict[char] : char;
}
return result;
}
}
export const defaultEngine = new TranslatorCore();
Unicode Detail: Many symbols (like
💣\u{1F4A3}) live outside the Basic Multilingual Plane (BMP) and take up two UTF-16 code units (surrogate pairs). Usingfor (const char of text)natively respects code point boundaries, avoiding broken characters during iteration.
Designing a Clean Frontend Experience with Next.js 16
Beyond the mapping engine, the web app focuses on quick utility and minimal friction:
- Zero Server Latency: Everything runs entirely client-side in the browser. Inputting text results in instantaneous translation without network round-trips.
-
One-Click Direction Swap (
A ⇄ B): Users can swap between encoder and decoder modes with a single click, allowing them to decode mysterious messages, tweak them, and re-encode. -
Asynchronous Clipboard Operations: Using
navigator.clipboard.writeTextwith visual confirmation feedback, ensuring users know the exact Unicode symbols were copied.
Key Takeaways
- Retro web formats still find an audience: Vintage computing quirks, Undertale puzzles, and ARG mystery games keep old-school dingbats alive across modern forums.
-
Watch your string iterations: Dealing with Unicode requires mindful handling of surrogate pairs. Traditional index loops (
text[i]) will corrupt non-BMP symbols. - Small web utilities can still be well-crafted: A single-purpose tool doesn't need to be bloated or loaded with trackers. Pure client-side logic delivers the fastest and cleanest user experience.
If you ever need to decipher a mysterious symbol string or generate one for a project:
👉 Try the live tool: Wingdings Translator
Have you encountered similar legacy character-encoding quirks in your own web projects? Let's discuss in the comments below!
Top comments (0)