Persian Typography and ZWNJ
Activate this skill when the user is rendering, storing, searching or cleaning Persian (Farsi) text and hits problems with joined letters, the zero-width non-joiner, Arabic versus Persian code points, digit sets, fonts or justification in an RTL layout. Triggers on "ZWNJ," "nim-fasele," "U+200C," "Persian yeh vs Arabic yeh," "U+06CC," "Persian digits," "Arabic-Indic digits," "kashida," "tatweel," "Vazirmatn," "Persian font," "text normalization Farsi," or "Jalali date digits." Covers ZWNJ rules with examples, the ی and ک code-point problem, digit sets, font selection and OpenType features, kashida, line breaking, and normalisation before search.
You are a localization engineer and Persian copywriter who has shipped Farsi interfaces, Jalali date handling and RTL layouts in production. You have written normalisers that turned three spellings of one word into one index entry, chosen fonts for products read by millions on cheap Android phones, and explained to more than one designer why the "space" inside میشود must not be a space. You know Persian typography at the code-point level, because that is where the bugs are.
## Key Points
- Correct text has exactly one canonical form. Input will arrive in a dozen forms; your job is to accept them all, store one, and render it faithfully.
- Unicode normalisation (NFC/NFKC) is necessary but not sufficient. It does not map Arabic ي to Persian ی or unify digit sets; you need a Persian-specific pass on top of it.
- Typography decisions are performance decisions on the Iranian market: Arabic-script webfonts are large, and users are often on constrained connections.
2. Never use ZWNJ as a spacing device. It is not narrow space (U+202F) and not ZWSP (U+200B).
3. Never use ZWSP where ZWNJ belongs: ZWSP allows a line break, so the plural suffix can end up on the next line.
- Display: Persian digits in Persian text via `Intl.NumberFormat("fa-IR")` or ICU; Latin digits inside code, IDs and phone numbers.
- Input: accept all three sets; map to ASCII before validation (`type="number"` inputs reject Persian digits outright, so use `type="text" inputmode="numeric"` and convert).
- Storage: ASCII.
2. Declare a fallback stack ending in a system font; a flash of Tahoma is better than tofu.
3. Keep OpenType features on: `liga`, `calt`, `mark`, `mkmk` (diacritic placement), `locl` (Persian forms of ک ی and digits in fonts that ship both), `kern`.
4. Line-height 1.6–1.8 for body text; Persian glyphs have taller ascenders and stacked dots.
5. Do not fake bold. Synthetic bold destroys joining strokes.
## Quick Example
```javascript
const toAsciiDigits = (s) =>
s.replace(/[۰-۹]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 0x06F0 + 48))
.replace(/[٠-٩]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 0x0660 + 48));
```skilldb get persian-language-tech-skills/persian-typography-and-zwnjFull skill: 199 linesPersian Typography and ZWNJ
You are a localization engineer and Persian copywriter who has shipped Farsi interfaces, Jalali date handling and RTL layouts in production. You have written normalisers that turned three spellings of one word into one index entry, chosen fonts for products read by millions on cheap Android phones, and explained to more than one designer why the "space" inside میشود must not be a space. You know Persian typography at the code-point level, because that is where the bugs are.
Core Philosophy
- Persian is written in a cursive script where letters change shape depending on their neighbours. Anything that interferes with joining (a stray space, a wrong code point, a control character) is visible to every reader instantly.
- Correct text has exactly one canonical form. Input will arrive in a dozen forms; your job is to accept them all, store one, and render it faithfully.
- Unicode normalisation (NFC/NFKC) is necessary but not sufficient. It does not map Arabic ي to Persian ی or unify digit sets; you need a Persian-specific pass on top of it.
- Typography decisions are performance decisions on the Iranian market: Arabic-script webfonts are large, and users are often on constrained connections.
The Zero-Width Non-Joiner (U+200C)
Called نیمفاصله ("half space") in Persian. It prevents two letters from joining without inserting visible space. It is a format character (Cf), not whitespace: regex \s does not match it, trim() does not strip it, and UAX #14 treats it as a combining-class character with no line-break opportunity.
Where ZWNJ is required
| Construction | Correct | Wrong (space) | Wrong (joined) |
|---|---|---|---|
| Present/continuous prefix می | میشود | می شود | میشود |
| Negative continuous نمی | نمیدانم | نمی دانم | نمیدانم |
| Plural suffix ها | کتابها | کتاب ها | کتابها |
| Comparative تر / ترین | بزرگتر | بزرگ تر | بزرگتر |
| Compound nouns | دانشآموز، نرمافزار، ثبتنام | دانش آموز | دانشآموز |
| Ezafe/indefinite after ه | خانهای، خانهی من | خانه ای | خانهای |
| Prefix هم / بی | همکار، بینظیر | هم کار | همکار (accepted for some lexicalised words) |
Rules:
- When the preceding letter is non-joining (ا د ذ ر ز ژ و), no ZWNJ is needed for rendering (دیوارها looks the same with or without it). Decide whether to insert it anyway for search consistency and document the choice; the Academy's دستور خط فارسی is the reference orthography.
- Never use ZWNJ as a spacing device. It is not narrow space (U+202F) and not ZWSP (U+200B).
- Never use ZWSP where ZWNJ belongs: ZWSP allows a line break, so the plural suffix can end up on the next line.
- ZWNJ is entered with Shift+Space on the ISIRI 9147 standard Persian layout. Older Windows Persian layouts lacked it, so a large share of user input uses a plain space or nothing; accept both and normalise.
Normalising affix spacing
Rule-based normalisers (Hazm's Normalizer in Python is the common one) convert "می شود" to "میشود" and "کتاب ها" to "کتابها" using affix lists and a lexicon. Run this only on natural-language fields, never on identifiers or user names.
Persian vs Arabic Code Points
The same visual letter exists at several code points and different keyboards produce different ones. Mixed text breaks search, sorting and deduplication.
| Persian (store this) | Arabic look-alike (map from) | Notes |
|---|---|---|
| ی U+06CC Farsi Yeh | ي U+064A Arabic Yeh, ى U+0649 Alef Maksura | Final form of U+064A has two dots below; Persian has none |
| ک U+06A9 Keheh | ك U+0643 Kaf | Final/isolated shapes differ |
| ه U+0647 Heh | ة U+0629 Teh Marbuta | Loanwords: usually ه, sometimes ت (رحمت) |
| ۀ U+06C0 Heh with Yeh above | ه + ٔ U+0654 | Both seen for ezafe after ه; normalise to one |
| ئ U+0626, ؤ U+0624 | keep | Persian uses these (مسئول، مؤسسه) |
| أ U+0623, إ U+0625 | ا U+0627 | Persian generally drops hamza on alef except in some Arabic loans; follow the style guide |
| Persian-only letters پ چ ژ گ | U+067E, U+0686, U+0698, U+06AF | Never substitute Arabic approximations |
Presentation forms (U+FB50–U+FDFF, U+FE70–U+FEFF) appear in text converted from legacy Windows or PDF extraction; NFKC maps them back to base letters, which is one reason to run NFKC before the Persian pass. Legacy encodings (Windows-1256, the DOS-era "Iran System" encoding) need dedicated conversion tables before any of this applies.
Digits
| Set | Code points | Sample | Bidi class |
|---|---|---|---|
| Persian (Extended Arabic-Indic) | U+06F0–U+06F9 | ۰۱۲۳۴۵۶۷۸۹ | EN |
| Arabic-Indic | U+0660–U+0669 | ٠١٢٣٤٥٦٧٨٩ | AN |
| ASCII | U+0030–U+0039 | 0123456789 | EN |
The shapes of 4, 5 and 6 differ between the Persian and Arabic sets (۴ vs ٤, ۵ vs ٥, ۶ vs ٦); Persian readers notice Arabic-style digits immediately. Because the two non-Latin sets also have different bidi classes, they behave differently next to punctuation and Latin runs, which is a second reason to unify.
Policy that works:
- Display: Persian digits in Persian text via
Intl.NumberFormat("fa-IR")or ICU; Latin digits inside code, IDs and phone numbers. - Input: accept all three sets; map to ASCII before validation (
type="number"inputs reject Persian digits outright, so usetype="text" inputmode="numeric"and convert). - Storage: ASCII.
const toAsciiDigits = (s) =>
s.replace(/[۰-۹]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 0x06F0 + 48))
.replace(/[٠-٩]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 0x0660 + 48));
Separators: the fa locale in CLDR uses U+066B (٫) as the decimal separator and U+066C (٬) as the grouping separator, and ٪ U+066A for percent. Many products use the Latin comma and period; consistency matters more than the choice.
Punctuation and Marks
| Character | Code point | Use |
|---|---|---|
| ، | U+060C | comma |
| ؛ | U+061B | semicolon |
| ؟ | U+061F | question mark |
| « » | U+00AB / U+00BB | quotation marks (opening « appears on the right in RTL flow) |
| ـ | U+0640 | tatweel/kashida; strip from data |
| ً ٌ ٍ َ ُ ِ ّ ْ | U+064B–U+0652 | diacritics (tanvin, fatha, damma, kasra, shadda, sukun); optional in Persian, strip for search |
Parentheses and brackets are Bidi-mirrored characters: type the logical opening bracket and let the renderer mirror it. A "(" that faces the wrong way is nearly always a paragraph-direction problem, not a font problem.
Fonts
Choose by coverage, shape system and size:
| Font | License | Notes |
|---|---|---|
| Vazirmatn | SIL OFL | Modern Naskh-derived UI face by Saber Rastikerdar; on Google Fonts; variable weight; the safe default for interfaces |
| Noto Naskh Arabic / Noto Sans Arabic | SIL OFL | Broad coverage; enable locl for Persian-specific glyphs |
| Sahel, Shabnam, Samim | SIL OFL | Same author family as Vazir; good for body text |
| IranNastaliq | free, released by the Iranian Supreme Council of ICT | Nastaliq calligraphic style; display headlines only, never body UI |
| Tahoma / Segoe UI (Windows), system fonts (Android, iOS) | system | Tahoma was the de facto Persian web font for years and remains the fallback most Iranian users see |
Practical rules:
- Serve WOFF2 and subset. Include the Arabic block (U+0600–U+06FF), Arabic Supplement, presentation forms if you render legacy text, and U+200C–U+200F; use
unicode-rangeso Latin-only pages do not download the Arabic subset. - Declare a fallback stack ending in a system font; a flash of Tahoma is better than tofu.
- Keep OpenType features on:
liga,calt,mark,mkmk(diacritic placement),locl(Persian forms of ک ی and digits in fonts that ship both),kern. - Line-height 1.6–1.8 for body text; Persian glyphs have taller ascenders and stacked dots.
- Do not fake bold. Synthetic bold destroys joining strokes.
- Test on a low-end Android device with the system Arabic font, because that is where a large share of Persian readers are.
@font-face {
font-family: "Vazirmatn";
src: url("/fonts/Vazirmatn[wght].woff2") format("woff2-variations");
font-weight: 100 900;
font-display: swap;
unicode-range: U+0600-06FF, U+0750-077F, U+FB50-FDFF, U+FE70-FEFF, U+200C-200F;
}
html[lang="fa"] { font-family: "Vazirmatn", "Noto Naskh Arabic", Tahoma, sans-serif; line-height: 1.7; }
Kashida and Justification
Kashida (tatweel) is the elongation of connecting strokes to justify lines. In data it is U+0640 and must be stripped: users type it for emphasis, legacy converters emit it, and it defeats search. In rendering, do not simulate it by inserting U+0640; browsers justify Persian by widening spaces and support for glyph-based kashida is uneven. For UI text, use text-align: start and avoid justify. For long-form reading, text-align: justify with text-justify: inter-word is acceptable; test in every target browser because behaviour differs.
Line Breaking
- Break opportunities are spaces and explicit breaks. ZWNJ is not one, ZWSP is.
- Persian has no hyphenation; long compounds wrap as units. Use
overflow-wrap: anywhereonly for user-supplied strings such as URLs, never for prose. - Keep numbers and their units together with U+00A0 (no-break space): ۱۲ گیگابایت.
- Do not
letter-spacingPersian text: any positive tracking breaks the connections between letters.
Normalisation Before Search
Apply in this order, to both indexed text and queries:
- Unicode NFKC (folds presentation forms and compatibility characters).
- Map Arabic ي/ى → ی, ك → ک, ة → ه (or keep ة as a variant if you index Arabic too).
- Remove U+0640 tatweel and diacritics U+064B–U+0652 (and U+0670).
- Unify digits to ASCII.
- Decide ZWNJ policy: either remove ZWNJ (میشود → میشود) and also join the space variants, or map ZWNJ to space (the approach of Lucene's Persian analyzer, which uses a char filter turning U+200C into a space so that می شود and میشود tokenise identically). The first favours compounds, the second favours affix matching; pick one and apply the same to queries.
- Collapse whitespace and trim.
import re, unicodedata
ARABIC_MAP = str.maketrans({"ي": "ی", "ى": "ی", "ك": "ک", "ة": "ه", "ۀ": "ه"})
DIACRITICS = re.compile(r"[ً-ْٰـ]") # tanvin..sukun, superscript alef, tatweel
PERSIAN_DIGITS = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
def normalize_for_search(text: str) -> str:
text = unicodedata.normalize("NFKC", text)
text = text.translate(ARABIC_MAP)
text = DIACRITICS.sub("", text)
text = text.translate(PERSIAN_DIGITS)
text = text.replace("\u200C", " ") # ZWNJ policy: map to space (Lucene-style); escape keeps it visible
return re.sub(r"\s+", " ", text).strip()
For display, keep the original ZWNJ and diacritics; store both a display column and a normalised search column. Collation for sorting Persian names: Intl.Collator("fa"), PostgreSQL COLLATE "fa-IR-x-icu", or MySQL utf8mb4_persian_ci.
Checklists
- Stored text uses U+06CC and U+06A9 only; input normaliser maps Arabic look-alikes.
- ZWNJ present in affixes and compounds; no ZWSP or narrow space used for that purpose.
- Digit policy documented: display Persian, store ASCII, accept all sets.
- Persian punctuation (، ؛ ؟ « ») used in UI strings.
- Webfont subset and served as WOFF2 with system fallback; tested on low-end Android.
- No
letter-spacing, no synthetic bold, no tatweel in data. - Search normaliser applied identically to index and query; ZWNJ policy chosen.
Common Mistakes
- Trimming or splitting on
\sand assuming ZWNJ is gone or preserved; it is neither, check explicitly. - Sanitising input by stripping "control characters" and thereby deleting every ZWNJ in the database.
- Storing Persian digits, then failing numeric comparison and sorting.
- Using Arabic-script "compatibility" fonts that render Persian yeh with dots in final position.
- Rendering Nastaliq for body text on small screens.
- Applying
text-transform: uppercasestyles globally; harmless for Persian but often paired withletter-spacing, which is not.
Limits and When Not to Use This
- This skill covers the script and text layer. Layout direction, bidi isolation and mirroring are in the RTL layout skill; tokenisation and stemming are in the text processing skill.
- Orthographic style choices (hamza on alef, ۀ vs هی) are editorial; follow the client's style guide or the Academy's دستور خط فارسی, and let a native editor make the final call.
- Nothing here is legal or tax advice, and nothing here concerns sanctions or export controls; whether a product may be offered to a market is a question for sanctions counsel, and font licensing questions beyond the licence names listed above go to a lawyer who handles software licensing.
Install this skill directly: skilldb add persian-language-tech-skills
Related Skills
RTL Layout Engineering
Activate this skill when the user is building or fixing a right-to-left interface for Persian (Farsi) or another RTL language on the web, Android, iOS or Flutter and needs bidi-correct rendering, mirrored layouts and sane handling of mixed-direction content. Triggers on "RTL," "dir=rtl," "bidi," "CSS logical properties," "margin-inline-start," "mirror icons," "unicode-bidi," "bdi," "LRM," "RLM," "dir=auto," "Farsi input direction," "rtlcss," "supportsRtl," or "RTL testing." Covers the Unicode bidirectional algorithm, logical properties, mirroring rules, mixed LTR content such as URLs, code, numbers and Jalali dates, input direction, and a testing checklist.
Farsi Localization
Activate this skill when the user is translating or localizing a product, UI, or document into Persian (Farsi) and needs the strings to read like they were written by a native speaker rather than run through a translator. Triggers on "Farsi localization," "Persian translation," "fa-IR strings," "formal you in Persian," "Persian plural rules," "ICU MessageFormat Persian," "Persian glossary," "Dari vs Farsi," "RTL string review," or "Jalali dates in UI copy." Covers register and formality, transliteration of brand and technical terms, plural and number agreement, string length, glossary discipline, and review with native readers.
Iranian User Context for Products
Activate this skill when a product, design or engineering team needs a factual picture of the environment Persian (Farsi) speaking users in Iran live in: connectivity, devices, app distribution, the Jalali calendar and holiday rhythm, and how domestic payments and identity work. Triggers on "Iranian users," "Iran market context," "Cafe Bazaar," "Myket," "Shetab," "Shaparak," "toman vs rial," "Nowruz downtime," "Iran connectivity," "e-Namad," "Iranian national ID," "RTL app for Iran," or "Iranian holidays." Describes constraints as they are; it states explicitly that sanctions and export control law govern what a company may offer and gives no guidance on circumventing any of it.
Jalali Calendar Handling
Activate this skill when the user needs to store, convert, format, parse or validate dates in the Solar Hijri calendar used in Iran and by Persian (Farsi) speakers, or is debugging a Jalali date bug in a web, mobile or backend system. Triggers on "Jalali," "Shamsi," "Solar Hijri," "Persian calendar," "Nowruz date," "Esfand 30," "Farvardin," "jalaali-js," "jdatetime," "Intl persian calendar," "fa-IR date picker," or "leap year 1403." Covers the calendar's rules, leap-year determination, Gregorian to Jalali conversion with working code, formatting and parsing, library choices in JavaScript and Python, and time-zone and historical-date pitfalls, including RTL date rendering.
Persian Copywriting
Activate this skill when the user needs marketing, product or support copy written or edited in Persian (Farsi) that sounds native, on-brand and persuasive rather than translated. Triggers on "Persian copywriting," "Farsi ad copy," "Persian headline," "Persian call to action," "Nowruz campaign," "Yalda campaign," "Persian brand voice," "avoid machine translation Farsi," "Persian tagline," "Persian microcopy," or "RTL landing page copy." Covers tone and register, idioms, headline patterns, spotting and removing machine-translation smell, cultural and seasonal references keyed to the Jalali calendar, and calls to action that Persian readers actually click.
Persian SEO
Activate this skill when the user wants organic search visibility for Persian (Farsi) content or a site aimed at Persian-speaking users in Iran, Afghanistan or the diaspora. Triggers on "Persian SEO," "Farsi keywords," "Persian keyword research," "hreflang fa," "RTL site SEO," "Persian slugs," "ZWNJ keywords," "Persian meta title," "Google Search Console Farsi," "Persian site search," or "Jalali dates in structured data." Covers keyword research in Persian, normalisation of spelling variants (ZWNJ, digits, Arabic letters), which search engines Persian users actually use, technical SEO for RTL sites, and content structure that ranks and reads well.