Quran Tajweed
Quran Tajweed publishes where every recitation rule applies in the muṣḥaf, as character positions rather than markup. Look an ayah up and you have its positions — no Arabic processing, no regular expressions, nothing wrapped around the text.
The package splits the work in two. A rule corpus describes the patterns, written by scholars in a compact Arabic notation. An engine reports where each pattern applies. The engine has already been run over the whole muṣḥaf, so for most uses you never run it: you read the results.
Read at build time from public/demo/tajweed-index.json, summed from the annotation set at the commit pinned in public/demo/SOURCES.json. A different corpus version publishes different totals, which is the reason this page does not print any of them as literals.
What ships
| File | What it is | Contains Qur'anic text? |
|---|---|---|
packages/annotations/uthmani-hafs.json | The precomputed spans for the whole muṣḥaf, plus the digest of the text they were measured against | No — positions only |
packages/rules/rules.json | The rule corpus: topics → categories → aḥkām → rules, with Arabic labels | No — patterns only |
editions/uthmani-hafs.json | The text edition the spans point into, keyed "1:1" | Yes |
Positions carry no text, so the annotation set can be distributed where a muṣḥaf cannot, and the question which edition is this? stays explicit rather than assumed.
A rule has a status, and one of them ships anyway
Every rule in the corpus carries a status. Two of the three are what you would guess; the third is the one
that needs handling in your own interface, because the spans are published either way.
| status | n | What it means for you |
|---|---|---|
| stable | 162 | Published, and the ruling is not in question. Most rules. |
| disabled | 18 | Authored but producing nothing, because the notation cannot yet express the condition. Each records the limitation. Silence here is a gap, not coverage. |
| disputed | 2 | The spans still ship. A reviewer has questioned the ruling at specific places, and the rule carries the finding and who raised it. Surface it wherever you name the ruling. |
Separately, 12 rules carry needsReview: verified against the text, not signed off by a qualified reviewer. That is a weaker statement than disputed — nobody has objected, nobody has confirmed.
القلقلة الكبرى — إذا جاء أحد حروف القلقلة (ق ط ب ج د) في آخر الآية
Fires on the qāf of the opening of 42:2, where the auditor holds there is no qalqala at all. Its reasoning, recorded as given: the opening is recited ʿayn sīn qāf, so the letter stopped on is the fāʾ of the name قاف, not the qāf — the qāf is the first letter of a three-letter name and is not sākin at the stop, and fāʾ is not a qalqala letter. The correct annotation is none. The auditor checked all 29 openings and found this the only instance: 50:1 and 38:1 correctly stay silent because they continue into more text. Same class as the madd-lazim-harfi.1 finding — the engine reading a letter NAME as if it were a letter. One false span in the muṣḥaf, out of 422 the rule emits; the other 421 are not in question. Unresolved pending a human scholar. Narrowing the pattern would be writing a rule, which is not done here.
Raised by: integration audit, 2026-09-12; all 29 openings checked
المد اللازم الحرفي — إذا جاء في فواتح السور حرف هجاؤه ثلاثة أحرف أوسطها حرف مد (ل م س ص ع ق ك ن)
Rules the ʿayn of the ḥurūf muqaṭṭaʿāt as madd lāzim ḥarfī. The auditor holds that this is wrong. Its reasoning, recorded as given: the eight letters نقص عسلكم are the right set to gather, but ʿayn is the standing exception. Its name عَيْن has a līn letter in the middle, not a madd letter, so it takes madd līn ḥarfī with two accepted wujūh — tawassuṭ or ṭūl — where madd lāzim is obligatorily six ḥarakāt. Ruling it flatly lāzim removes a خلاف that every primer preserves (وعينُ ذو وجهين). Unresolved: the auditor reasoned from the standard treatment and did not cite a page, and neither the auditor nor this repository is a qualified reviewer. Pending a human scholar. No pattern, no Arabic and no ruling has been changed here — only the claim that the question is settled.
Raised by: scholarly audit, 2026-09-12; reasoned from the standard treatment, no page cited
Withdrawing a questioned rule was considered upstream and rejected: between them these rules emit hundreds of spans and only a handful are in question, so suppressing the rule would delete far more correct annotations than doubtful ones. The corpus therefore records the objection instead of acting on it, and leaves the ruling to a qualified reviewer. If your interface names the ruling to a reader, name the doubt with it.
The repository declares npm names (@quran.ws/tajwid-annotations, @quran.ws/tajwid-rules, @quran.ws/tajwid), but at the commit
this page was written against none of them resolve on the public npm registry. What exists is a GitHub release —
v0.4.0 carries tajweed-annotations-uthmani-hafs-v0.4.0.json, tajweed-rules-v0.4.0.json and the rules JSON
Schema. The editions/ file is not a release asset; read it from the repository. Note that the release and the
repository can be at different corpus versions, and the totals differ between them — so read counts from the file
you actually loaded.
The shape of a span
The annotation file is one object. Spans are packed, and the third element is an index into ruleIds, not a
rule id:
type PackedSpan = [start: number, end: number, ruleIndex: number];
interface Annotations {
corpusVersion: string; // "0.4.2"
riwayah: string; // "hafs-an-asim"
edition: { id: string; sha256: string; ayahCount: number };
ruleIds: string[]; // 164 ids; spans index into this
spans: Record<string, PackedSpan[]>; // keyed "surah:ayah"
}
start and end are half-open code-point offsets, so [...text].slice(start, end) is the stretch the ruling
concerns. All Arabic and all Qur'anic annotation marks sit in the Basic Multilingual Plane, so for this text the
offsets also work directly as JavaScript string indices.
Ayahs with no annotations are left out of the object, not stored as an empty array, which keeps "no rule matched" distinguishable from "never computed". One ayah of the muṣḥaf is currently in that position — 20:1, whose ruling, the natural madd in the names of the disjoined letters, has no rule in the corpus.
The spans are character offsets into the tajwīd edition's own text. They are not offsets into Quran Text's
words, and joining those words with a space does not reproduce the edition. Measured across the whole muṣḥaf, the
join matches editions/uthmani-hafs.json in 319 of 6,236 ayahs.
Four divergences do it, and each one is invisible on screen: shadda and
harakah stored in the opposite order, آ written precomposed in one and as alif +
U+0653 in the other, positional forms of tanwīn, and waqf marks and tatweel that the
edition carries inline while Quran Text holds them in a separate marks layer. Two of the four change the
length of the ayah, so every offset after them stays shifted to the end.
Nothing throws. Our own demo shipped this bug and rendered 11 of 59 al-Fatiha spans on the wrong characters; the divergence is smallest on al-Fatiha, so it looked right. Render the edition's own text, and check the digest before you trust an offset.
From the two payloads this site ships, in 1:7 the span [84, 88] names a natural madd:
edition text 1:7[84,88] → "لِّي" the lām, its shadda and kasrah, then the yāʾ
quran-text, joined 1:7[84,88] → "ِّين" a kasrah, a shadda, a yāʾ and a nūn
The two strings render as the same ayah; the precomposed آ (U+0622) in Quran Text is one code point where the edition has two, and every offset after it shifts by one.
Load spans for an ayah
Load the three files together
The annotations, the corpus they were computed under, and the edition they point into are one unit. Loading two of the three is the state most bugs start from.
const annotations = JSON.parse(await readFile("uthmani-hafs.json", "utf8")); const corpus = JSON.parse(await readFile("rules.json", "utf8")); const edition = JSON.parse(await readFile("editions/uthmani-hafs.json", "utf8")); if (annotations.corpusVersion !== corpus.version) { throw new Error(`annotations are v${annotations.corpusVersion}, corpus is v${corpus.version}`); }Check the digest before you trust an offset
The annotation set records a SHA-256 of the exact text it was measured against. Recomputing it is the only check that catches a substituted text.
The digest is taken over the content, not the file: each reference and its text in muṣḥaf order, joined with two control characters —
U+0000between a reference and its text,U+0001between one record and the next — so re-indenting or reordering the JSON does not change it. Neither character can occur in a reference or in Qur'anic text, and both are part of the contract: a reimplementation that uses a different separator computes a different digest.import { createHash } from "node:crypto"; const refs = Object.keys(edition.ayahs).sort((a, b) => { const [sa, aa] = a.split(":").map(Number); const [sb, ab] = b.split(":").map(Number); return sa - sb || aa - ab; }); const canonical = refs .map((ref) => `${ref}\u0000${edition.ayahs[ref]}`) .join("\u0001"); const digest = createHash("sha256").update(canonical, "utf8").digest("hex"); if (digest !== annotations.edition.sha256) { throw new Error("this is not the text these offsets describe"); }Run against the shipped edition this prints
b5d29736bb3ef49d9d331c4e60a59d83fe899b921e9e8dc35911bd4a18ce55f3, which is the valueannotations.edition.sha256carries.@quran-ws/tajwidexposes the same computation aseditionDigestandassertEdition, and the Python reader asedition_digest.Read the spans, and resolve the rule
const text = edition.ayahs["1:1"]; const packed = annotations.spans["1:1"] ?? []; const spans = packed.map(([start, end, ruleIndex]) => ({ start, end, ruleId: annotations.ruleIds[ruleIndex], slice: text.slice(start, end), }));For 1:1 that is five spans over 38 characters:
[8, 10] mutamathilain-idgham-kamil.23 "لل" [17, 18] raa-tafkheem.1 "ر" [22, 25] madd-tabee-kalimi.1 "مَٰ" [30, 31] raa-tafkheem.1 "ر" [33, 36] madd-tabee-kalimi.3 "حِي"A rule id is namespaced under its hukum:
madd-tabee-kalimi.1and.3are two patterns for the same ruling. To get from a rule to something a reader can be shown, walk the chain in the corpus:rule.hukum→hukum.category→category.topic.Turn spans into runs, and render
Spans overlap: one letter can demonstrate more than one ruling, and in al-Fatiha
1:6two of them do. For a single-colour reader, flatten to one rule per character first:const marks: (string | null)[] = new Array(text.length).fill(null); for (const { start, end, ruleId } of spans) { for (let i = start; i < end && i < text.length; i++) marks[i] = ruleId; } const COMBINING = /[\p{Mn}\p{Me}\u200D]/u; const runs: { text: string; rule: string | null }[] = []; for (let i = 0; i < text.length; i++) { // A mark never starts a run: it belongs to the letter before it. const rule = i > 0 && COMBINING.test(text[i]) ? runs[runs.length - 1].rule : marks[i]; const last = runs[runs.length - 1]; if (last && last.rule === rule) last.text += text[i]; else runs.push({ text: text[i], rule }); }Then render each run, colouring by topic rather than by rule, and leaving
rule === nullruns in the default colour.@quran-ws/tajwidalso shipsresolveOverlaps(spans)— earliest wins, longest wins on a tie — if you would rather flatten the spans than the characters.A span ends on the letter, not after its marks[17, 18]in 1:1 is the rāʾ alone; the shadda at 18 sits on that same rāʾ and is outside the span. Group the characters naively and the base letter lands in a coloured element while its marks start the next one — and a combining mark that begins a text run has no letter to sit on, so the browser draws it on a dotted circle and stops joining the word. That is the boxes-and-broken-letters rendering, not a font problem.Break a run only before a base character, as above. In al-Fātiḥa alone 21 of the 59 spans end this way, across six of the seven ayahs, so it shows up on the first screen you build.
Pick an ayah of al-Fatiha, hover a coloured stretch and see the corpus name the ruling — real spans over unchanged text.
Colour by topic, and say whose colours they are
There are seven topics, and this is the whole corpus:
| Topic | Arabic | Rules that fire | Spans |
|---|---|---|---|
| Tafkhīm & tarqīq tafkheem-tarqeeq | التفخيم والترقيق | 30 | 41,986 |
| Letter relations letter-relations | علاقات الحروف | 32 | 6,313 |
| Nūn sākinah & tanwīn noon-tanween | النون والتنوين | 29 | 18,628 |
| Mīm sākinah meem-sakinah | الميم الساكنة | 3 | 7,482 |
| The two shaddahs mushaddadatan | المشددتان | 2 | 7,342 |
| Madd madd | المد | 28 | 61,665 |
| Qalqalah qalqalah | القلقلة | 3 | 3,839 |
Counted at build time from public/demo/tajweed-index.json. Seven topics is the whole corpus — anything outside them is not annotated at all.
Colour is keyed to topic because past roughly a dozen colours a reader stops being able to tell them apart, and there are 58 aḥkām and 182 rules underneath.
Two consequences:
- The corpus labels everything in Arabic only. No topic, hukum or rule carries an English label. If your interface is not in Arabic you maintain that mapping yourself; the ids are stable slugs and are the thing to key it on.
- The colours are yours, and must be labelled as yours. A printed tajwīd muṣḥaf's colour scheme carries scholarly meaning, and different publishers use different schemes. Our demo says on screen that its palette is its own, chosen only to be told apart. Ship a legend, and say which scheme it follows.
What the data does not cover
Silence is not "no rule applies here". When an ayah has no span across a stretch of text, it means no rule in this corpus matched — the corpus goes deep in seven topics and says nothing outside them. A reader shown an unlabelled wash of colour will assume the rules that were never included do not exist.
Named gaps, from the repository's own coverage document:
| Not annotated | Why |
|---|---|
| المد الطبيعي الحرفي — the natural madd in disjoined-letter names | No rule describes it; this is why 20:1 has no annotation at all |
| القلقلة at a mid-ayah stop | Whether a reciter stops there is their choice, and the text does not record it. Ayah endings are annotated (qalqalah-kubra) |
| مد الفرق, مد الصلة الكبرى, السكت, الوقف والابتداء, أحكام الاستعاذة والبسملة | Not in the corpus |
| المتباعدين, and the الكبير forms of المتماثلين والمتجانسين | Not in the corpus |
A further 18 rules exist in the corpus but are disabled: published rather than deleted, each recording which
part of the notation cannot yet express it. The annotation set is built from the 164 stable rules; the number that
actually fire is in the totals above.
What this block is not for
| You want | Use |
|---|---|
| The Qur'anic text itself, in seven riwayat | Quran Text — Quran Tajweed publishes no text except the one reference edition |
| Tajwīd for Warsh, Qālūn or another riwayah | Nothing here. The corpus is for Ḥafṣ ʿan ʿĀṣim, and the tools treat another riwayah as an error rather than a setting, because both the rulings and the spelling differ |
| Colouring a printed page rather than a string | Quran SVG Elements — spans address characters, not printed shapes |
How this data is made
The rules and the engine are a port of the tajwīd system behind tajweed.quranpedia.net, checked against that
implementation over the whole muṣḥaf (normalised text character by character, each rule's set of matching ayahs set
by set) and frozen as a conformance suite in the repository. Matching runs on an internal normalised copy of the
text and maps every match back to positions in the original, so nothing in the pipeline applies Unicode
normalisation, strips diacritics or drops waqf marks. Rules written for this corpus
rather than inherited, qalqalah and lāzim madd, carry needsReview: true until a qualified reviewer signs them off;
the annotation set is produced once over all 6,236 ayahs and published with the
digest of the exact text it was measured against.