Annotation layers
The obvious way to colour a tajwīd rule is to wrap the letters in a tag. We never do that, and neither should you. Every annotation in these packages is published as positions into something else — a character range into a named text, a region on a named page, a name on a path — and the thing it points into stays byte-for-byte what the publisher released.
A tajwīd rule applies to a span of letters, often shorter than a word and sometimes crossing a word boundary.
Printed tajwīd muṣḥafs show this by colouring the letters. The temptation in software is to reproduce the print
directly — emit <span class="madd"> around the letters — and store that as the text.
That breaks three things at once. The string is no longer the publisher's text, so its digest no longer verifies. Two annotations that overlap cannot both be expressed as nesting. And scholar-authored rulings become entangled with publisher-owned text under one licence and one file.
The alternative is the pattern every block here follows: the text is one artefact, the annotation is another, and the annotation addresses the text by position.
The same pattern, four times
| Layer | Points into | Address | Shape |
|---|---|---|---|
| Tajwīd spans — Quran Tajweed | one named text edition, by digest | character offsets, half-open | [start, end, ruleIndex] per ayah |
| Waqf and sajdah marks — Quran Text | the edition's own words array | word position | marks: [[position, markType], …] |
| Ayah regions — Quran SVG | one page of one muṣḥaf | page coordinates | a transparent path.ayahPolygon with surah and ayah |
| Mark names — Quran SVG Elements | one page of one muṣḥaf | the path itself | data-mark="sukun" on the path that draws it |
Notice what each one leaves untouched. The tajwīd file contains no Qur’anic text — positions only — so
it can be distributed where a muṣḥaf cannot, and "which edition is this?" stays an explicit field rather than an
assumption. Quran Text's words[i] is the word and only the word; the waqf sign that trails it lives in marks
and is rendered back in on request:
m.wordAt(33).text; // "رَيۡبَ"
m.wordAt(33).marks; // [{ kind: "waqf", side: "after", sign: "ۛ" }]
m.wordAt(33).render(true); // "رَيۡبَۛ"
Quran SVG's polygon layer is a sibling of the artwork in the same file, not a change to it. That is what lets the project license the polygons and JSON as its own CC BY 4.0 contribution while the page artwork keeps the publisher's terms.
What the pattern asks of you
Positions are cheap to publish and cheap to consume, and they have one cost: a position is only meaningful against the exact thing it was computed from.
A tajwīd span is a code-point range into the tajwīd edition's text. Quran Text's words joined with a space look
the same and are not the same bytes — a shadda and its vowel are stored in the opposite order, one writes آ as
one code point where the other writes two, and the edition carries waqf marks inline that Quran Text keeps in
marks. Apply the spans to the wrong text and the colouring lands on the wrong letters, with nothing thrown. Our
own demo shipped this: 11 of 59 al-Fātiḥa spans on the wrong characters. Encoding
shows the bytes; the fix is to render the edition the spans were measured against.
- Load the spans, the corpus they index and the edition text as one unit, and check
edition.sha256over the text you loaded: offsets name their text (Quranic text, rule 4.4) and fail loudly (rule 8.4). - The colouring is a render-time layer and is never written back into the string: annotations, not edits (Quranic text, rule 6.2) and no markup in source (rule 3.5).
- An ayah with no annotation is absent from the file, not an empty array, so "no rule matched" and "never computed" stay apart: fail loudly (Quranic text, rule 8.4).
- A colour scheme is an edition's mark, not the text, so name the scheme on screen: edition marks are not the text (Quranic text, rule 3.4).
Resolving positions into runs
Spans can overlap: one letter can demonstrate two rulings. For a single-colour reader, flatten to one rule per character, then group. This is the code the demo on this site runs:
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 span ends on the base letter; its marks belong to that same letter.
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 });
}
// Each run is its own element, and a browser shapes each element on its own, so
// every cut inside a word breaks the join and the word comes apart on screen.
// A joiner on each side restores it — but only where a join existed: ا د ذ ر ز و ة
// and the alef family never join forwards, and ء joins to neither side.
const NO_JOIN_FORWARD = new Set([..."ءآأؤإاةدذرزوٱ"]);
const base = (s: string, at: -1 | 0) => [...s].filter((c) => !COMBINING.test(c)).at(at) ?? "";
for (let i = 0; i + 1 < runs.length; i++) {
const before = base(runs[i]!.text, -1);
const after = base(runs[i + 1]!.text, 0);
if (!before || !after) continue;
if (!/\p{Script=Arabic}/u.test(before) || !/\p{Script=Arabic}/u.test(after)) continue;
if (NO_JOIN_FORWARD.has(before) || after === "ء") continue;
runs[i]!.text += "\u200D";
runs[i + 1]!.text = "\u200D" + runs[i + 1]!.text;
}
Both of those guards exist because the failure is silent and looks like corrupt data. A combining mark that starts a run has no letter to sit on, so the browser draws it on a dotted circle; and a cut between two joining letters leaves each side in isolated or final form, so the word visibly comes apart. The text is correct in both cases — only the drawing is wrong. The joiners belong to the drawing too: offsets, counts and anything copied back out come from the original string.
Then render each run with a colour keyed to its rule's topic — seven of them — and leave rule === null
runs in the default colour. The wrapping happens at render time, in your view layer, and is never written back
as the text.
Where the pattern stops
- Silence is not "no rule". The tajwīd corpus goes deep in seven topics and says nothing outside them. A stretch with no span means no rule in this corpus matched. Do not present the absence of colour as a ruling.
- A layer is per riwayah. The tajwīd annotations are for Ḥafṣ ʿan ʿĀṣim only; the rulings and the spelling both differ in another reading, and the tools treat another riwayah as an error, not a setting.
- A layer is per muṣḥaf. Ayah polygons and mark names belong to one page of one printed edition. See Page geometry.
- Character layers and page layers do not convert directly. To colour a printed page from tajwīd spans you go span → word key → the word's marks on the page, not offset → outline.
Real spans over unchanged text: hover a coloured stretch of al-Fātiḥa and the corpus names the ruling.
How this data is made
The tajwīd rules were written by scholars in a compact Arabic notation; an engine was run once over the whole muṣḥaf to produce the published spans, which carry the digest of the text they were measured against, and every match is mapped back to positions in the original text. Quran Text's mark layer is peeled from the publisher's own text by the build, which lists every sign it moved. The ayah polygons in Quran SVG are derived from each page's own ayah medallions and audited against the ink.