Handling Old English Characters (þ, ð, æ, ƿ, macrons) in Web Apps: Unicode, Fonts and Input
If you build anything that touches Old English (a glossary, a reading app, a search box for Beowulf quotes), you will meet a small set of characters that break naive assumptions about text: thorn, eth, ash, wynn, yogh, dotted consonants and macron vowels. None of them are exotic by Unicode standards, but each one hits a different part of the stack: storage, comparison, URLs, fonts and keyboards. This post walks through each layer with code you can paste and run.
The code points you actually need
All of these live in the Latin-1 Supplement, Latin Extended-A and Latin Extended-B blocks. I checked every name below with Python's unicodedata.name().
| Char | Code point | Unicode name |
|---|---|---|
| þ / Þ | U+00FE / U+00DE | LATIN SMALL / CAPITAL LETTER THORN |
| ð / Ð | U+00F0 / U+00D0 | LATIN SMALL / CAPITAL LETTER ETH |
| æ / Æ | U+00E6 / U+00C6 | LATIN SMALL / CAPITAL LETTER AE |
| ƿ / Ƿ | U+01BF / U+01F7 | LATIN LETTER WYNN / LATIN CAPITAL LETTER WYNN |
| ȝ | U+021D | LATIN SMALL LETTER YOGH |
| ċ, ġ | U+010B, U+0121 | LATIN SMALL LETTER C / G WITH DOT ABOVE |
| ā ē ī ō ū | U+0101, U+0113, U+012B, U+014D, U+016B | LATIN SMALL LETTER A/E/I/O/U WITH MACRON |
| ȳ | U+0233 | LATIN SMALL LETTER Y WITH MACRON |
| ǣ | U+01E3 | LATIN SMALL LETTER AE WITH MACRON |
Two things stand out. Wynn's lowercase and uppercase forms sit far apart (U+01BF and U+01F7), because the capital was added to Unicode later. And the four "core" letters (þ, ð, æ, ƿ) are base letters with no decomposition. The macron and dot forms, by contrast, are precomposed characters that can also be written as a base letter plus a combining mark.
Normalization: why "hālig" !== "hālig"
A macron vowel can arrive in two shapes: precomposed ā (U+0101), or a followed by COMBINING MACRON (U+0304). Text pasted from PDFs, older editions or some input methods often uses the decomposed form. They look the same on screen but are different strings.
const composed = "h\u0101lig"; // hālig, precomposed ā
const decomposed = "ha\u0304lig"; // a + combining macron
console.log(composed === decomposed); // false
console.log(composed.length, decomposed.length); // 5 6
console.log(composed.normalize("NFC") === decomposed.normalize("NFC")); // true
The same thing in Python:
import unicodedata as ud
a, b = "h\u0101lig", "ha\u0304lig"
print(a == b, len(a), len(b)) # False 5 6
print(ud.normalize("NFC", a) == ud.normalize("NFC", b)) # True
print(ud.name("ǣ"), [hex(ord(c)) for c in ud.normalize("NFD", "ǣ")])
print("ÞÆT".casefold()) # þæt
Note that ǣ decomposes to æ + U+0304, so NFD will not turn it into plain ASCII. The rule of thumb is to normalize to NFC on input, before you save to the database, build search indexes or compute hashes. If you skip this, unique constraints and WHERE word = ? queries will quietly miss matches.
Case-insensitive comparison and search
Case mapping works fine for these letters in modern engines. toUpperCase() knows that ƿ maps to Ƿ. The trouble starts when you want accent-insensitive matching, or when you assume þ will match "th".
console.log("ƿīf".toUpperCase()); // ǷĪF
console.log("ÞÆT".toLowerCase()); // þæt
const strip = s => s.normalize("NFD").replace(/\p{M}/gu, "");
console.log(strip("hālig ġēar")); // halig gear
console.log(strip("þæt ǣr")); // þæt ær (æ is not decomposable)
const coll = new Intl.Collator("en", { sensitivity: "base" });
console.log(coll.compare("hālig", "HALIG")); // 0
console.log(coll.compare("þæt", "that")); // not 0: þ is its own letter
Takeaways:
toLowerCase()and Python'scasefold()handle þ/Þ, ð/Ð, æ/Æ and ƿ/Ƿ correctly.- Stripping
\p{M}after NFD removes macrons and dots, which is what users usually want ("halig" should find "hālig"). It does not touch þ, ð or æ. Intl.Collatorwithsensitivity: "base"ignores case and diacritics, but it treats þ as a separate letter, not "th". If you want "that" to find "þæt", you have to add that folding yourself (see the next section) and index both forms.localeComparesorting order depends on the locale. Icelandic (is) sorts þ and æ at the end of the alphabet, whileenusually interleaves them. Pick a locale on purpose and don't rely on the default.
Slugs and transliteration
URLs and file names still tend to want ASCII. A practical mapping for Old English is þ→th, ð→th (some projects use "dh" to keep it distinct), æ→ae, ƿ→w and ȝ→y (or "g", depending on how you read it). Order matters: decompose and strip marks first, so that ǣ becomes æ before the mapping runs. My first draft did it the other way round and produced lfr-d for "Ǣlfrǣd".
const MAP = { þ: "th", ð: "th", æ: "ae", ƿ: "w", ȝ: "y" };
function slugify(text) {
return text
.toLowerCase()
.normalize("NFD") // ǣ -> æ + U+0304, ġ -> g + U+0307
.replace(/\p{M}/gu, "") // drop macrons and dots
.replace(/[þðæƿȝ]/g, ch => MAP[ch])
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
console.log(slugify("Þā wæs on burgum Bēowulf Scyldinga")); // tha-waes-on-burgum-beowulf-scyldinga
console.log(slugify("Ǣlfrǣd and ƿīf ġēar")); // aelfraed-and-wif-gear
Store the original NFC title next to the slug. Never try to reconstruct one from the other, because the transliteration is lossy.
Fonts and fallback
Most modern system fonts cover þ, ð and æ, since they are in Latin-1. Wynn, yogh and ȳ are where gaps appear: you get tofu boxes, or the browser silently switches to another font for a single glyph, which looks patchy in body text. Fonts that reliably cover the full set include Noto Serif / Noto Sans, DejaVu, Segoe UI on Windows, and Charis SIL / Gentium. For scholarly display, Junicode is the standard medievalist font. It covers the entire Old English repertoire along with manuscript-specific variants, and it is free under the SIL Open Font License.
.ang {
font-family: "Junicode", "Noto Serif", "Charis SIL", "Gentium Plus",
"DejaVu Serif", Georgia, serif;
}
Mark Old English passages with lang="ang" (the ISO 639 code for Old English). It helps screen readers, hyphenation and :lang() selectors. If you self-host Junicode, subset it with unicode-range so you don't ship the whole font to every visitor.
HTML entities
Named entities exist for the Latin-1 letters: þ Þ ð Ð æ Æ. There is no named entity for wynn or yogh, so use numeric references instead: ƿ (ƿ), Ƿ (Ƿ), ȝ (ȝ). For the macron vowels you have ā, ē and similar in HTML5, but honestly the best option is to serve UTF-8 (<meta charset="utf-8">) and write the characters directly.
Input: letting users type them
- Linux (X11/most desktops): with a Compose key enabled,
Compose t hgives þ,Compose d hgives ð,Compose a egives æ, andCompose - agives ā. Wynn and yogh have no default Compose sequence, so add them to~/.XCompose. - macOS:
Option+'types æ on the US layout. For the rest, the "ABC – Extended" input source gives you macrons viaOption+afollowed by the vowel. You can also use the Character Viewer (Ctrl+Cmd+Space). - Windows: the US-International layout gives æ with
Right Alt+z, þ withRight Alt+tand ð withRight Alt+d. Anything else needs a custom layout or the emoji/symbol panel (Win+.).
The most reliable fix is on your side, though: add a small on-screen palette (þ ð æ ƿ ȝ ā ē ī ō ū ȳ ǣ) next to text inputs that inserts at the cursor. Then normalize whatever comes in to NFC.
Testing with real text
Synthetic strings miss real-world mixtures: decomposed macrons next to precomposed ones, ð and þ used in the same word, capital wynn at the start of a sentence. A quick way to get realistic fixtures is to take a modern sentence, translate to Old English, and paste the result into your test suite. Then assert that it round-trips through your database, search index, slug generator and font stack unchanged.
Checklist
- Normalize to NFC at the boundary.
- Use NFD +
\p{M}stripping for accent-insensitive search, plus explicit þ/ð→th folding if needed. - Use
Intl.Collatorwith an explicit locale for sorting. - Transliterate after decomposition when you build slugs.
- Declare a font stack that includes wynn and yogh, and mark content with
lang="ang". - Use numeric references for ƿ and ȝ. Better yet, just use UTF-8.
All rights reserved