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

LayerPoints intoAddressShape
Tajwīd spans — Quran Tajweedone named text edition, by digestcharacter offsets, half-open[start, end, ruleIndex] per ayah
Waqf and sajdah marks — Quran Textthe edition's own words arrayword positionmarks: [[position, markType], …]
Ayah regions — Quran SVGone page of one muṣḥafpage coordinatesa transparent path.ayahPolygon with surah and ayah
Mark names — Quran SVG Elementsone page of one muṣḥafthe path itselfdata-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.

Offsets from one text applied to another

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.

The rules
  • Load the spans, the corpus they index and the edition text as one unit, and check edition.sha256 over 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

Try it live

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.