Make words clickable

A tap on a printed word should hand you something you can look up — a translation, a root, an audio segment, a note of your own. On a split page every word is already a node with a key on it, so the hit-test is a DOM walk and the join is string equality. It only works on the muṣḥafs that have been split; for a tap that resolves to an ayah on any vectorised muṣḥaf, see Display a complete Muṣḥaf page.

Platforms
Web via Elements · mobile via Quran Engine
Time
About 12 minutes

The key that survives across blocks

On a split page the word key is surah:ayah:word, with the word 1-based within its ayah. It is the same key in the Elements page (data-word-key), in the Engine (wordKey), in Quran Text (m.word(surah, ayah, index)), and in third-party recitation timings that report a word number, so nothing below builds a mapping table.

By the end, page 1 of the split Ḥafṣ muṣḥaf has every word clickable: the click logs its key, shows its text in five forms, and joins to Quran Text's word record.

  1. Get a split page into the document

    The split muṣḥaf ships as one release tarball — 604 pages and their indexes, about 108 MB — and the pages must be inline to be addressable. An img element renders the same pixels and gives you nothing to click.

    const host = document.querySelector("#page");
    host.innerHTML = await (await fetch("/pages/001.svg")).text();
    
    const svg = host.querySelector("svg");
    svg.removeAttribute("width");
    svg.removeAttribute("height");
    svg.setAttribute("style", "height:70vh;width:auto;display:block");
    

    The root describes what it lets you address. Branch on it before you assume:

    svg.dataset.decomposition;   // "word"  — every word and mark is a node
    svg.dataset.riwayah;         // "hafs"
    svg.dataset.ayahNumbering;   // "kufi"
    svg.dataset.ayahTotal;       // "6236"  — this edition's, not a constant
    svg.dataset.page;            // "1"
    

    On a file whose data-decomposition is ayah, querySelectorAll("g.word") legitimately returns nothing. That is "not split", not "broken".

  2. One listener, and walk up to the word

    Every word is g.word[data-word-key], inside a g.ayah-fragment[data-ayah-key], inside a g.line[data-line]. One listener on the root handles the whole page:

    svg.addEventListener("click", (e) => {
      const word = e.target.closest("g.word");
      if (!word) return;                                    // tapped bare paper
    
      const key = word.dataset.wordKey;                     // "1:1:2"
      const ayah = word.closest("g.ayah-fragment").dataset.ayahKey;   // "1:1"
      const line = word.closest("g.line").dataset.line;               // "2"
      const printed = word.dataset.rasmUthmani;             // "ٱللَّهِ"
    
      onWord(key, { ayah, line, printed });
    });
    

    Note dataset.rasmUthmani, camel-cased — the attribute on the node is data-rasm-uthmani.

  3. Make the target big enough for a thumb

    Only the ink is hit-testable, so a tap in the gap between two words hits nothing, and a tap on a thin stroke can miss. Widen the target without changing a pixel:

    g.word path {
      stroke: transparent;
      stroke-width: 1.5;
      paint-order: stroke fill;
      pointer-events: all;
    }
    
  4. Show the hit

    Colour is a fill on the paths inside the group. There is no text layer and no background box.

    function selectWord(svg, key) {
      svg.querySelectorAll("g.word path[data-kind]").forEach((p) => p.setAttribute("fill", "#231f20"));
      svg
        .querySelectorAll(`g.word[data-word-key="${key}"] path[data-kind]`)
        .forEach((p) => p.setAttribute("fill", "#15705D"));
    }
    

    Page 1 carries 67 data-kind="body" paths and 179 data-kind="mark" paths across its 29 words; the selector above colours both kinds for the one word.

  5. Read the word's text in five forms

    The page carries only the printed rasm on the node. The per-page sidecar carries the rest, keyed by the same word_key, along with the word's printed line and bounding box:

    const page = await (await fetch("/index/by-page/001.json")).json();
    const w = page.words.find((x) => x.word_key === "1:1:2");
    
    w.rasm_uthmani;   // "ٱللَّهِ"  — the print's own text, fully marked: display this
    w.rasm_imlai;     // "اللَّهِ"  — modern spelling
    w.rasm;           // "ٱلله"     — the skeleton actually drawn
    w.search;         // "الله"     — match user input against this, and only this
    w.line;           // 2
    w.box;            // [185.33, 219.36, 200.18, 239.21] in the page's viewBox units
    
  6. Join to Quran Text by key, never by text

    The three parts of the key are the three arguments of m.word(). That gives you the word's record in the text dataset — and the number that means the same word in all seven riwayat:

    import { Mushaf } from "quran-text";
    const m = await Mushaf.hafs();
    
    const [s, a, i] = "1:4:1".split(":").map(Number);
    const word = m.word(s, a, i);
    
    word.text;     // "مَٰلِكِ"
    word.number;   // 11   — the same word in every riwayah
    word.ayah.key; // "1:4"
    word.page.number;   // 1
    
    The same word, two datasets, two different strings

    Word 1:1:2 is ٱللَّهِ in both. In the Elements sidecar its qpc form is encoded 0671 0644 0644 0651 064E 0647 0650; in Quran Text the same word is 0671 0644 0644 064E 0651 0647 0650 — the shadda and the fatḥah in the other order. They render identically and compare unequal. On page 1, 13 of the 29 words differ this way, and rasm_uthmani differs from Quran Text in 26 of 29, because it writes sukūn as U+0652 where Quran Text prints U+06E1. Join on the key — the word key is surah:ayah:word (Engineering, rule 2.2). A join on the string succeeds on a few words and drops the rest.

    Across the whole muṣḥaf the two projects agree on word boundaries almost everywhere, and a handful of words are split differently. On page 1 all 29 keys line up with Quran Text's word positions; if you join further in, spot-check rather than assume.

  7. Join to audio timings

    Word-level timings from a recitation API give a word number 1-based within the ayah — the third part of the key. The join is string concatenation, with no alignment step; this is the code the demo on this site runs:

    // timings: one entry per ayah, words as [wordNumber, startMs, endMs]
    audio.addEventListener("timeupdate", () => {
      const ms = audio.currentTime * 1000;
      const hit = track.words.find(([, start, end]) => ms >= start && ms <= end);
      selectWord(svg, hit ? `1:${ayah}:${hit[0]}` : null);
    });
    
Try it live

Click a word for its five text forms, colour a mark family, or press play and watch the printed words light up in time with a recitation — joined by key.

On mobile: the same key, from Quran Engine

Where SVG of this density stops performing, Quran Engine loads the same split page as a compact binary and hit-tests inside the engine. The result carries the same key:

const vx = (clientX - rect.left - view.ox) / view.scale;
const vy = (clientY - rect.top - view.oy) / view.scale;

const h = page.hitTestViewEx(vx, vy, { maxDistance: 4 });
// → { word: 0, line: 1, distance: 0, exact: false, wordKey: "1:1:1", ayahKey: "1:1", … }

if (h && h.word >= 0) {
  page.clearHighlights();
  page.highlight(QVP.T.word(h.word), { mode: "band", band: "rgba(21,112,93,0.28)", ink: "#15705D" });
}

A tap between two words still resolves: the engine partitions each line into gap-aware boxes with no dead zones. Convert viewport pixels through the layout first — hitTest fed raw mouse coordinates returns a word, just the wrong one. The Engine reference has the load and layout steps around this call.

You wantUse instead
Tap a verse, on a muṣḥaf that has not been splitDisplay a complete Muṣḥaf page
Colour a word by its recitation ruleAdd Tajwīd highlighting — spans address characters, not printed shapes
Search the textmatch on search, never on the printed form — see the Elements reference

How this data is made

The input is the vectorised muṣḥaf artwork; the pipeline regroups that ink into words and named marks without moving or redrawing it, so the split page is pixel-identical to the print. Every index in the bundle is read back out of the shipped pages, and the bundle's checker verifies that every data-word-key in an index resolves to a word group in the page it names.