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.
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.
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
imgelement 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-decompositionisayah,querySelectorAll("g.word")legitimately returns nothing. That is "not split", not "broken".One listener, and walk up to the word
Every word is
g.word[data-word-key], inside ag.ayah-fragment[data-ayah-key], inside ag.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 isdata-rasm-uthmani.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; }Show the hit
Colour is a
fillon 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 179data-kind="mark"paths across its 29 words; the selector above colours both kinds for the one word.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 unitsJoin 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; // 1The same word, two datasets, two different stringsWord
1:1:2is ٱللَّهِ in both. In the Elements sidecar itsqpcform is encoded0671 0644 0644 0651 064E 0647 0650; in Quran Text the same word is0671 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, andrasm_uthmanidiffers from Quran Text in 26 of 29, because it writes sukūn asU+0652where Quran Text printsU+06E1. Join on the key — the word key issurah: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.
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); });
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.
Related guides
| You want | Use instead |
|---|---|
| Tap a verse, on a muṣḥaf that has not been split | Display a complete Muṣḥaf page |
| Colour a word by its recitation rule | Add Tajwīd highlighting — spans address characters, not printed shapes |
| Search the text | match 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.