Add Tajwīd highlighting

Quran Tajweed publishes where every recitation rule applies as character positions, not markup. This guide takes those positions and puts colour on text you are already rendering, without inserting anything into the Qur’anic string and without running any Arabic analysis. The corpus is for Ḥafṣ ʿan ʿĀṣim only.

Platforms
Browser · Node · React
Time
About 12 minutes

By the end, al-Fātiḥa is on screen coloured by rule topic, with a legend, from three files and no matching engine.

  1. Install the three packages

    Names are from each package's package.json. The annotations are the precomputed spans, the rules are the corpus they index into, and core is the small library that resolves one to the other.

    npm i @quran.ws/tajwid @quran.ws/tajwid-rules @quran.ws/tajwid-annotations
    
    Publishing is decided, not yet done

    At the commit this guide was read against, none of the @quran.ws/tajwid-* names resolve on npm. What exists is the repository’s GitHub release with the annotation and rules JSON, and the edition file in the repository tree. The imports below are the ones the packages declare. They were @tajweed/*, then briefly @quran-ws/*, and are now @quran.ws/*, the scope that matches the domain and the spelling a repository and package name takes (Naming, rule 5.2). Nothing was ever published under any of the three, so no install is broken by the change; it was made before the first publish rather than after.

  2. Load the annotations, the corpus and the edition together

    Three files are one unit. The annotations record which corpus version they were computed under and the SHA-256 of the exact text they were measured against; the corpus resolves a rule id to its ruling and topic; the edition is that exact text.

    import corpus from "@quran.ws/tajwid-rules";
    import annotations from "@quran.ws/tajwid-annotations";
    import { unpack, assertEdition } from "@quran.ws/tajwid";
    
    const edition = await (await fetch("/editions/uthmani-hafs.json")).json();
    
    if (annotations.corpusVersion !== corpus.version) {
      throw new Error(`annotations are v${annotations.corpusVersion}, corpus is v${corpus.version}`);
    }
    await assertEdition(edition, annotations.edition.sha256);   // throws EditionMismatchError otherwise
    

    assertEdition recomputes a digest over the edition's content — each reference and its text in muṣḥaf order, with U+0000 between a reference and its text and U+0001 between records — and compares it with annotations.edition.sha256. The two control characters are part of the contract; Pinning has the computation in full. Run against the shipped edition it computes b5d29736bb3ef49d9d331c4e60a59d83fe899b921e9e8dc35911bd4a18ce55f3, which is what the annotation set carries.

  3. Render the edition's text, not another dataset's

    These offsets belong to one text, and it is not Quran Text's

    The spans are character offsets into editions/uthmani-hafs.json. 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 the edition in 319 of 6,236 ayahs. The differences are invisible (a shadda and its vowel in the other order, آ as one code point against two, waqf marks inline against a separate layer) and two of them change the length of the string, so every offset after them is shifted to the end of the ayah. In 1:7 the span [84, 88] is لِّي in the edition and ِّين in the joined Quran Text words. Our own demo shipped this bug. Render edition.ayahs[ref].

    const ref = "1:1";
    const text = edition.ayahs[ref];   // "بِسۡمِ ٱللَّهِ ٱلرَّحۡمَٰنِ ٱلرَّحِيمِ" — 38 code points
    

    If your product's text comes from Quran Text, keep it for everything else (search, word identity, the other six riwayat) and take the displayed string of a tajwīd view from the edition. The reference is the join between them.

  4. Unpack the spans for an ayah

    Spans are packed as [start, end, ruleIndex], where the third element indexes ruleIds. unpack expands them and resolves each rule to its ruling, category and topic:

    const spans = unpack(annotations, corpus, "1:1");
    // [
    //   { start: 8,  end: 10, ruleId: "mutamathilain-idgham-kamil.23", hukumId: "mutamathilain-idgham-kamil", categoryId: "mutamathilain-sagheer", topicId: "letter-relations" },
    //   { start: 17, end: 18, ruleId: "raa-tafkheem.1",                hukumId: "raa-tafkheem",                categoryId: "raa-rules",             topicId: "tafkheem-tarqeeq" },
    //   { start: 22, end: 25, ruleId: "madd-tabee-kalimi.1",           hukumId: "madd-tabee-kalimi",           categoryId: "madd-tabee",            topicId: "madd" },
    //   { start: 30, end: 31, ruleId: "raa-tafkheem.1",                …                                                                            topicId: "tafkheem-tarqeeq" },
    //   { start: 33, end: 36, ruleId: "madd-tabee-kalimi.3",           …                                                                            topicId: "madd" },
    // ]
    

    start and end are half-open code-point offsets. All Arabic and all Qur’anic marks sit in the Basic Multilingual Plane, so for this text they work directly as JavaScript string indices: text.slice(8, 10) is لل, the two lāms the first ruling concerns.

    An ayah absent from annotations.spans has no annotation (one ayah in the muṣḥaf, 20:1) and unpack returns [] for it.

  5. Flatten the overlaps, and colour by topic

    Spans overlap: one letter can demonstrate more than one ruling, and 1:6 has eleven spans over its 36 characters, several stacked. For a single-colour reader flatten first; resolveOverlaps keeps the earliest span, and the longest on a tie:

    import { resolveOverlaps } from "@quran.ws/tajwid";
    
    const flat = resolveOverlaps(unpack(annotations, corpus, "1:6"));
    // 11 spans → 5: [5,8] madd · [11,14] tafkheem-tarqeeq · [14,17] madd · [17,19] tafkheem-tarqeeq · [29,32] madd
    

    Then colour by topic. There are seven in the whole corpus; colouring by rule would give you well over a hundred shades no reader can tell apart:

    const COLOURS = {                     // this site's demo palette — see the note below
      "tafkheem-tarqeeq": "#C2543F",
      "letter-relations": "#3D74B6",
      "noon-tanween":     "#7B5EA7",
      "meem-sakinah":     "#2E8B74",
      "mushaddadatan":    "#B8862B",
      "madd":             "#C77D3A",
      "qalqalah":         "#4A7C2F",
    };
    
    These colours are ours, and yours will be yours

    The palette above is the one this site’s demo uses, chosen only so seven topics can be told apart. It is not the colour scheme of any printed tajwīd muṣḥaf, and it carries no scholarly meaning. Publishers use different schemes, and a reader who learned one will read yours as if it were that one. Ship a legend, say which scheme it follows, and if it follows none, say that. @quran.ws/tajwid exports a default TOPIC_COLORS too; the same sentence applies to it.

  6. Paint by position, without wrapping the text

    The text node stays exactly as the edition has it. The CSS Custom Highlight API paints ranges of an existing text node from JavaScript, so the DOM never gains a wrapper and the string is never cut:

    const el = document.querySelector("#ayah");        // dir="rtl" lang="ar"
    el.textContent = text;                              // the edition's string, untouched
    const node = el.firstChild;
    
    const byTopic = new Map();
    for (const s of flat) {
      const r = new Range();
      r.setStart(node, s.start);                        // BMP text: code-point index === UTF-16 index
      r.setEnd(node, s.end);
      (byTopic.get(s.topicId) ?? byTopic.set(s.topicId, []).get(s.topicId)).push(r);
    }
    for (const [topic, ranges] of byTopic) {
      CSS.highlights.set(`tajweed-${topic}`, new Highlight(...ranges));
    }
    
    ::highlight(tajweed-madd)             { color: #C77D3A; }
    ::highlight(tajweed-tafkheem-tarqeeq) { color: #C2543F; }
    /* … one rule per topic */
    

    Switching the layer off is CSS.highlights.clear(), and the text is what it always was. This is a standard web API, not part of the package; check current browser support before relying on it alone.

    Where you cannot use it (server rendering, older engines, React Native) compute runs from the positions and emit one element per run:

    const marks = new Array(text.length).fill(null);
    for (const { start, end, topicId } of flat) {
      for (let i = start; i < end && i < text.length; i++) marks[i] = topicId;
    }
    
    const COMBINING = /[\p{Mn}\p{Me}\u200D]/u;
    
    const runs = [];
    for (let i = 0; i < text.length; i++) {
      // A span ends on the base letter, so its marks would start the next run
      // with nothing to sit on. Break only before a base character.
      const topic = i > 0 && COMBINING.test(text[i]) ? runs[runs.length - 1].topic : marks[i];
      const last = runs[runs.length - 1];
      if (last && last.topic === topic) last.text += text[i];
      else runs.push({ text: text[i], topic });
    }
    // render each run; leave topic === null runs in the default colour
    

    That is only half of it. Each run becomes its own element, and a browser shapes each element on its own — so the letters either side of every colour change are drawn in isolated or final form and the word visibly comes apart, even though the string is exactly right. Bridge each cut with a zero-width joiner:

    const ZWJ = "\u200D";
    // ا د ذ ر ز و ة and the alef family never join to the letter after them; ء joins
    // to neither side. A joiner after one of those asks for a medial form that does
    // not exist, and the shaper answers with a tatweel stub — broken a second way.
    const NO_JOIN_FORWARD = new Set([..."ءآأؤإاةدذرزوٱ"]);
    const base = (s, from) => [...s].filter((c) => !COMBINING.test(c)).at(from);
    
    for (let i = 0; i + 1 < runs.length; i++) {
      const before = base(runs[i].text, -1) ?? "";
      const after = base(runs[i + 1].text, 0) ?? "";
      const joins = before && after
        && !NO_JOIN_FORWARD.has(before)
        && after !== "ء"
        && /\p{Script=Arabic}/u.test(before) && /\p{Script=Arabic}/u.test(after);
      if (joins) {
        runs[i].text += ZWJ;
        runs[i + 1].text = ZWJ + runs[i + 1].text;
      }
    }
    

    The joiners are invisible and belong to the drawing, not to the text: offsets, counts and anything copied back out come from the original string.

    So the run-based path has two hazards and both are silent. Split a letter from its marks and the browser draws them on a dotted circle; split a word between two joining letters and it comes apart. The Highlight path above has neither: it ranges over one text node and never cuts it, which is the real reason to prefer it.

    Either way the Qur’anic string is an input, never something you edit: no tag goes into it, no marked-up copy is stored, and the colouring can be regenerated from the positions at any time.

  7. Use the React component, if you are in React

    @quran.ws/tajwid-react does the run-based rendering above and adds the things easy to get wrong: the text is emitted character for character; waqf marks inside a span keep the surrounding colour, because they instruct the reciter rather than belong to the letter; and every coloured stretch carries its ruling in aria-label, so colour is never the only thing carrying meaning.

    import corpus from "@quran.ws/tajwid-rules";
    import annotations from "@quran.ws/tajwid-annotations";
    import { unpack } from "@quran.ws/tajwid";
    import { TajweedText, TajweedLegend } from "@quran.ws/tajwid-react";
    
    <TajweedText text={edition.ayahs["1:1"]} corpus={corpus} spans={unpack(annotations, corpus, "1:1")} colors={COLOURS} />
    <TajweedLegend corpus={corpus} colors={COLOURS} />
    

    Pass spans. Left out, the component runs the matching engine on the text in the browser, which is what a playground needs and a product does not.

Try it live

Pick an ayah of al-Fātiḥa, hover a coloured stretch and see the corpus name the ruling — real spans over unchanged text, in the demo’s own colours.

What the colouring does not say

Careful

Silence is not "no rule applies here". A stretch with no span means no rule in this corpus matched; the corpus goes deep in seven topics and says nothing outside them. The natural madd in the disjoined-letter names, qalqalah at a mid-ayah stop, the rulings of the basmalah and the isti'ādhah are all absent, and the repository lists them. Render the legend, and do not present the colouring as complete.

The corpus labels everything in Arabic only; no topic, ruling or rule carries an English label. If your interface is not in Arabic, keep a mapping keyed on the ids; they are stable slugs.

You wantUse instead
Tajwīd for Warsh, Qālūn or any other riwayahNothing here — the corpus and the tools are Ḥafṣ only
Colour a printed page rather than a stringElements: style a mark by its name
The text of the Qur'an as dataQuran Text — Quran Tajweed ships one reference edition, not a text dataset

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 and frozen as a conformance suite. Matching runs on an internal normalised copy and maps every match back to positions in the original, so nothing strips a mark or reorders a code point. The annotation set is produced once over all 6,236 ayahs and published with the digest of the exact text it was measured against.