Writing · · 7 min read
Nastaliq: A Script That Runs Downhill
Why Urdu's calligraphic script slopes, why it resisted computers for decades, and what a shaping engine does between a string of code points and a line of Nastaliq.
#urdu#unicode#language#systems
In Nastaliq, every word is a small slope: it starts high on the right and lands on the baseline at the left.
That one geometric fact explains most of what is beautiful about the script and most of what made it hard to put on a screen. This post starts from the letters as Unicode stores them, adds the joining rules that every Arabic-script language shares, and then adds the one thing Nastaliq adds on top: a vertical position for every join. Along the way it builds a toy shaper in Python and ends with what a real engine, HarfBuzz, does step by step.
The simplest version: a string of code points
The word nastaʿlīq itself, نستعلیق, is seven letters. Unicode stores it as seven code points, in logical order (the order you type and speak), with no information about shape or position.
import unicodedata
word = "نستعلیق" # nasta'līq
for ch in word:
print(f"U+{ord(ch):04X} {unicodedata.name(ch)}")U+0646 ARABIC LETTER NOON
U+0633 ARABIC LETTER SEEN
U+062A ARABIC LETTER TEH
U+0639 ARABIC LETTER AIN
U+0644 ARABIC LETTER LAM
U+06CC ARABIC LETTER FARSI YEH
U+0642 ARABIC LETTER QAFTwo observations. First, the names say "ARABIC LETTER" even for Urdu: Urdu, Persian, Arabic, Pashto and others share one block, and Urdu-specific letters such as ٹ (TTEH) and ے (YEH BARREE) are additions to it. Second, nothing here says "slope," "connect," or even "draw right to left." A code point is an abstract character. The picture comes later.
Bottleneck: if you draw each code point with one fixed picture, laid side by side, you get disconnected letters that no reader of Urdu would accept as writing.
Joining: four forms per letter
Arabic-script letters connect to their neighbours, and most letters change shape depending on which sides are connected. Each letter has up to four contextual forms.
| Form | Joins to previous? | Joins to next? | Example: ب (beh) |
|---|---|---|---|
| isolated | no | no | ب |
| initial | no | yes | بـ |
| medial | yes | yes | ـبـ |
| final | yes | no | ـب |
Not every letter can join forward. Unicode's ArabicShaping.txt classifies letters by joining type. Dual-joining letters (D) connect on both sides. Right-joining letters (R), such as ا alef, د dal, ر reh and و waw, connect only to the letter before them, and always break the word after themselves. A word therefore splits into ligature groups: runs of connected letters separated by these breaks.
The rule for each letter is a function of its two neighbours:
import unicodedata
# Joining types from Unicode's ArabicShaping.txt, for the letters used here.
# D = dual-joining, R = right-joining (never connects to the following letter).
JOINING = {
"ن": "D", "س": "D", "ت": "D", "ع": "D", "ل": "D", "ی": "D", "ق": "D",
"ب": "D", "ک": "D", "ہ": "D", "م": "D",
"ا": "R", "د": "R", "ر": "R", "و": "R",
}
def forms(word: str) -> list[str]:
"""Choose ISOLATED/INITIAL/MEDIAL/FINAL for each letter, in logical order."""
out = []
for i, ch in enumerate(word):
prev = word[i - 1] if i > 0 else None
nxt = word[i + 1] if i + 1 < len(word) else None
# Joins backwards if the previous letter can join forwards.
join_prev = prev is not None and JOINING.get(prev) == "D"
# Joins forwards if this letter is dual-joining and a joining letter follows.
join_next = JOINING.get(ch) == "D" and nxt is not None and nxt in JOINING
out.append({(False, False): "ISOLATED", (False, True): "INITIAL",
(True, True): "MEDIAL", (True, False): "FINAL"}[(join_prev, join_next)])
return out
def presentation(ch: str, form: str) -> str:
"""Look up the legacy presentation-form code point, if Unicode has one."""
base = unicodedata.name(ch).replace("ARABIC LETTER ", "")
try:
return unicodedata.lookup(f"ARABIC LETTER {base} {form} FORM")
except KeyError:
return ch
for word in ["نستعلیق", "اردو"]:
for ch, f in zip(word, forms(word)):
p = presentation(ch, f)
print(f"{ch} U+{ord(ch):04X} {f:<8} -> U+{ord(p):04X} {unicodedata.name(p)}")
print()ن U+0646 INITIAL -> U+FEE7 ARABIC LETTER NOON INITIAL FORM
س U+0633 MEDIAL -> U+FEB4 ARABIC LETTER SEEN MEDIAL FORM
ت U+062A MEDIAL -> U+FE98 ARABIC LETTER TEH MEDIAL FORM
ع U+0639 MEDIAL -> U+FECC ARABIC LETTER AIN MEDIAL FORM
ل U+0644 MEDIAL -> U+FEE0 ARABIC LETTER LAM MEDIAL FORM
ی U+06CC MEDIAL -> U+FBFF ARABIC LETTER FARSI YEH MEDIAL FORM
ق U+0642 FINAL -> U+FED6 ARABIC LETTER QAF FINAL FORM
ا U+0627 ISOLATED -> U+FE8D ARABIC LETTER ALEF ISOLATED FORM
ر U+0631 ISOLATED -> U+FEAD ARABIC LETTER REH ISOLATED FORM
د U+062F ISOLATED -> U+FEA9 ARABIC LETTER DAL ISOLATED FORM
و U+0648 ISOLATED -> U+FEED ARABIC LETTER WAW ISOLATED FORMنستعلیق is one ligature group of seven letters. اردو, the name of the language itself, is four isolated letters: every one of them is right-joining, so nothing connects.
The FExx and FBxx code points are Arabic Presentation Forms, kept in Unicode for compatibility with older systems that stored glyph shapes as characters. Modern text should not contain them. They fold back to the base letters under compatibility normalisation:
glyphy = "".join(presentation(c, f) for c, f in zip("نستعلیق", forms("نستعلیق")))
print(glyphy == "نستعلیق") # False
print(unicodedata.normalize("NFKC", glyphy) == "نستعلیق") # TrueBottleneck: four forms per letter is enough for Naskh, the upright style used for most Arabic printing. It is nowhere near enough for Nastaliq.
Naskh vs Nastaliq: the baseline moves
Naskh writes along a horizontal baseline. Connected letters sit on the same line, and a word is a horizontal chain.
Nastaliq, which took shape in the Persian world in roughly the fourteenth and fifteenth centuries and became the standard hand for Persian and later Urdu, does not. Within a ligature group, each join sits a little higher than the one after it. Read right to left, the group starts high and descends; only its last letter rests on the baseline. A long word is a staircase going down to the left.
Naskh: ـــــــــــــــــــ every join on one line
Nastaliq: ــ first join (rightmost), highest
ــ
ــ
ــ last letter (leftmost), on the baselineReading direction in that sketch is right to left: the group enters at the top right and steps down towards the left.
| Property | Naskh | Nastaliq |
|---|---|---|
| baseline | flat | one per ligature group, sloping |
| shape of a letter | depends on its 2 neighbours | depends on many letters ahead |
| line height | fairly uniform | grows with the longest group |
| horizontal density | wide | compact; groups overlap |
| typical use | Arabic books, Quran print | Urdu books, newspapers, signage |
The consequence is the important part. The height at which a letter is drawn depends on how many letters come after it in its group. The shape of a join can depend on the letter two or three positions later. A letter's appearance is no longer a function of its immediate neighbours; it is a function of its whole ligature group.
Why computers struggled
Three problems compound.
- Contextual shaping beyond neighbours. Some letters have dozens of variants in a good Nastaliq font, chosen by what follows. A four-form table cannot express that.
- Kerning across ligatures. Because groups slope, the tail of one group can sit under the start of the next. Good Nastaliq spacing tucks groups into each other, which means horizontal spacing depends on the vertical shapes of both groups. Fixed advance widths leave visible gaps; naive overlap causes collisions.
- Vertical stacking and dots. A long group climbs high above the baseline. Dots (nuqte) and vowel marks on letters packed into a steep slope collide with each other and with neighbouring groups, and must be moved individually.
The first widely used digital solution sidestepped all three. Noori Nastaliq, developed in the early 1980s from the calligraphy of Ahmed Mirza Jamil, stored ligature groups as pre-drawn glyphs: an entire connected group such as نستعلیق is one picture, made by a calligrapher, and the software maps the text to the nearest stored picture. Figures quoted for the number of such ligatures run into the tens of thousands.
The trade-offs of that approach:
| Approach | Strength | Weakness |
|---|---|---|
| pre-composed ligatures | calligraphic quality; spacing tuned by hand | finite: a rare or new word has no stored picture; huge font; tied to specific software |
| contextual OpenType rules | any word renders; standard text pipeline | rules are hard to write; quality depends on the font and the engine |
Modern fonts such as Noto Nastaliq Urdu, Gulzar and SIL's Awami Nastaliq take the second route. They store letter parts and encode the Nastaliq behaviour as rules: substitution tables for contextual variants, and positioning tables that attach each letter's exit point to the next letter's entry point, which is what produces the slope.
What a shaping engine does
A shaping engine turns code points into positioned glyphs. HarfBuzz is the one used by most browsers, Android, and many desktop systems. The Python binding uharfbuzz exposes it directly:
# pip install uharfbuzz ; point FONT at any Nastaliq .ttf, e.g. Noto Nastaliq Urdu
import uharfbuzz as hb
FONT = "NotoNastaliqUrdu-Regular.ttf"
blob = hb.Blob.from_file_path(FONT)
font = hb.Font(hb.Face(blob))
buf = hb.Buffer()
buf.add_str("نستعلیق")
buf.guess_segment_properties() # script=Arab, direction=RTL, language from locale
hb.shape(font, buf, {})
for info, pos in zip(buf.glyph_infos, buf.glyph_positions):
print(font.glyph_to_string(info.codepoint), info.cluster,
pos.x_advance, pos.x_offset, pos.y_offset)Note that after shaping, info.codepoint holds a glyph id, not a character. The number of glyphs need not equal the number of characters; cluster maps each glyph back to the characters it came from.
The steps inside the call, in order:
- Segment properties. Script (Arabic), direction (right to left), and language (Urdu vs Persian vs Arabic can select different glyph variants through the font's language system).
- Normalisation and mapping. Characters are mapped to the font's default glyphs, composing or decomposing where the font prefers.
- Joining analysis. The same D/R logic as the toy shaper above, using the full Unicode joining table, tags every character as
isol,init,medi, orfina. - Substitution (GSUB). The font's features run in a defined order: the four joining features, then required ligatures (
rlig), contextual alternates (calt), and optional ligatures (liga). In a Nastaliq font, contextual rules here pick the variant of each letter that suits what follows. - Positioning (GPOS). Cursive attachment (
curs) connects each glyph's exit anchor to the next glyph's entry anchor. Since anchors sit at different heights, chaining them is what builds the downhill slope. Mark attachment (mark,mkmk) places dots and vowel signs. Kerning (kern) adjusts spacing between groups. - Output. A list of glyph ids with advances and offsets, in visual order, ready for the rasteriser.
Bidirectional reordering of mixed Urdu and English text, and line breaking, happen outside the shaper, in the layout library that calls it.
The model in one line
Only the first arrow is about language. Everything specific to Nastaliq lives in the font tables consumed by the second.
What to try next
- Run the joining code on ہمارا and on پاکستان and predict the ligature groups before reading the output.
- Count code points and glyphs for the same word in a Naskh font and in a Nastaliq font with
uharfbuzz; the glyph counts differ, the code points do not. - Search a scraped Urdu corpus for code points in U+FB50–U+FDFF and U+FE70–U+FEFF. Any hits are presentation forms that will break search and tokenisation until normalised with NFKC.
- Compare
y_offsetvalues across a long ligature group. They should change steadily along the group; that change is the slope. - Render the same paragraph with HarfBuzz and with a renderer that has no Nastaliq support, and note where words fall back to flat, Naskh-like shapes.