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.

Time
About 12 minutes

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.

Spans: 147,255Ayahs carrying at least one: 6,236Rules that fire: 127Corpus: v0.4.2

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

FileWhat it isContains Qur'anic text?
packages/annotations/uthmani-hafs.jsonThe precomputed spans for the whole muṣḥaf, plus the digest of the text they were measured againstNo — positions only
packages/rules/rules.jsonThe rule corpus: topics → categories → aḥkām → rules, with Arabic labelsNo — patterns only
editions/uthmani-hafs.jsonThe 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.

statusnWhat it means for you
stable162Published, and the ruling is not in question. Most rules.
disabled18Authored but producing nothing, because the notation cannot yet express the condition. Each records the limitation. Silence here is a gap, not coverage.
disputed2The 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.

qalqalah-kubra.1 — questioned

القلقلة الكبرىإذا جاء أحد حروف القلقلة (ق ط ب ج د) في آخر الآية

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

madd-lazim-harfi.1 — questioned

المد اللازم الحرفيإذا جاء في فواتح السور حرف هجاؤه ثلاثة أحرف أوسطها حرف مد (ل م س ص ع ق ك ن)

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.

Getting the files today

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.

These offsets belong to one text, and it is not quran-text's

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

  1. 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}`);
    }
    
  2. 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+0000 between a reference and its text, U+0001 between 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 value annotations.edition.sha256 carries. @quran-ws/tajwid exposes the same computation as editionDigest and assertEdition, and the Python reader as edition_digest.

  3. 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.1 and .3 are 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.hukumhukum.categorycategory.topic.

  4. Turn spans into runs, and render

    Spans overlap: one letter can demonstrate more than one ruling, and in al-Fatiha 1:6 two 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 === null runs in the default colour. @quran-ws/tajwid also ships resolveOverlaps(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.

Try it live

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:

TopicArabicRules that fireSpans
Tafkhīm & tarqīq
tafkheem-tarqeeq
التفخيم والترقيق3041,986
Letter relations
letter-relations
علاقات الحروف326,313
Nūn sākinah & tanwīn
noon-tanween
النون والتنوين2918,628
Mīm sākinah
meem-sakinah
الميم الساكنة37,482
The two shaddahs
mushaddadatan
المشددتان27,342
Madd
madd
المد2861,665
Qalqalah
qalqalah
القلقلة33,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:

What the data does not cover

Careful

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 annotatedWhy
المد الطبيعي الحرفي — the natural madd in disjoined-letter namesNo rule describes it; this is why 20:1 has no annotation at all
القلقلة at a mid-ayah stopWhether 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 wantUse
The Qur'anic text itself, in seven riwayatQuran Text — Quran Tajweed publishes no text except the one reference edition
Tajwīd for Warsh, Qālūn or another riwayahNothing 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 stringQuran 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.