Quran SVG Elements
Printed muṣḥaf pages as vector artwork, with the ink taken apart: every word is one group carrying a stable
surah:ayah:word key, and every diacritic is its own path carrying the name of what it is. Beside each
page is a JSON sidecar giving each word five text forms, its printed line and its bounding box. This page is about
consuming those files in a browser.
Where it sits among the page blocks
Three blocks publish muṣḥaf pages, and coverage narrows as addressing deepens:
| Coverage | The smallest thing you can address | |
|---|---|---|
| Quran SVG | every vectorised muṣḥaf, and growing by contribution | an ayah |
| Quran SVG Elements | only the muṣḥafs that have been split | a word, a mark — a letter, later |
| Quran Engine | those split muṣḥafs, on mobile | the same, fast |
Elements ships 604 whole displayable pages too, exactly like Quran SVG; the difference is what you can address once a page is on screen. Use it for word-level interaction on the web. Use Quran SVG when whole-ayah regions are enough or you need an edition Elements has not split, and Quran Engine when the target is mobile, where SVG of this density stops performing.
One edition is split today: hafs-kfgqpc — the King Fahd Complex Madani muṣḥaf, V4 1441H print, in the
Ḥafṣ Riwayah. Treat that as the coverage, not as a snapshot of a queue. Not every muṣḥaf will be
split.
What ships
One release artefact: a tarball holding 604 pages and the indexes derived from them.
| path | what it answers |
|---|---|
pages/NNN.svg | the page itself, 001–604 |
index/by-page/NNN.json | that page's words — five text forms each, plus printed line and bounding box |
index/words.json | every word in the corpus with its page, line, printed text and search form; 77,432 rows |
index/pages.json | per page: which surahs, first and last ayah, word / mark / line counts, divisions starting there |
index/surahs.json | the 114 surah records: names, revelation place, ayah count, page range |
index/divisions.json | every juz, hizb, half-hizb and rubʿ boundary, with its ayah and page |
schema/FORMAT.md | the format specification, including its own list of known defects |
schema/mark-taxonomy.json | the closed vocabulary of mark names, with category and family |
schema/*.schema.json | JSON Schema (2020-12) for each data file above |
VERSION.json | schema version, build date, source commits, corpus counts |
CHECKSUMS.txt | sha256 of every file the build produced — verify with shasum -a 256 --ignore-missing -c CHECKSUMS.txt |
Every JSON file carries schema and schema_version at its top level, so a file tells you what it is without you
inspecting its shape. Columnar files carry a fields array — read the column order from it rather than assuming it.
One real page:
| On page 1 | Measured | What it is |
|---|---|---|
| Word groups | 29 | g.word — one per printed word, each with a data-word-key |
| Ayat on the page | 7 | distinct data-ayah-key values |
| Ayah fragments | 10 | g.ayah-fragment — one per ayah per printed line, not one per ayah |
| Printed lines | 8 | g.line, numbered top to bottom |
| Named marks | 179 | each diacritic, dot and sign is its own path |
| Letter paths | 67 | the letter ink itself |
| Page SVG | 219.6 KiB raw | 45.5 KiB gzip · 33.5 KiB brotli |
| Word sidecar | 6.8 KiB | the demo's trimmed copy of index/by-page/001.json |
Counted at build time out of public/demo/elements-page-001.svg, which scripts/sync-demo-data.mjs took from the release bundle published on quran-ws/quran-svg-elements, pinned in public/demo/SOURCES.json at de4a3b8c6b94. Page 1 is one of the two short pages of the opening spread; a normal page carries fifteen lines rather than eight: page 3 holds 127 words and is about three times the file size.
The addressing model
Everything on a page hangs off that key. The page is a DOM tree, so addressing is a CSS selector, and there is no database and no second request in any of it:
| you want | selector |
|---|---|
| one word | g.word[data-word-key="1:1:2"] |
| every word of one ayah | g.ayah-fragment[data-ayah-key="1:1"] g.word |
| one printed line | g.line[data-line="2"] g.word |
| every instance of one mark | path[data-mark="hamzat_al_wasl"] |
| a whole family of marks | path[data-mark-family~="dots"] |
| an ayah's medallion | #mk-1-1 |
data-mark-family is a space-separated token list like class, because a mark can belong to two families at once.
Match it with ~=, never =.
Load a page and make it interactive
Get the bundle
It is published as a single tarball on the repository's release, and there is no per-file host today. Expect to download the whole corpus — about 108 MB — to obtain one page.
gh release download v1.0.0 -R <the elements repo> -p 'quran-svg-hafs-kfgqpc.tar.gz' tar xzf quran-svg-hafs-kfgqpc.tar.gz cd quran-svg-hafs-kfgqpc && shasum -a 256 --ignore-missing -c CHECKSUMS.txt # 1223 files OK--ignore-missingis required.CHECKSUMS.txtlists 3,647 files because the build also produces pre-compressed.svg.brand.svg.gzcopies of every page, 2,424 files the tarball excludes. Without the flag the command warns on all 2,424 and exits non-zero on an intact bundle.Then serve
pages/andindex/as static files. Since those pre-compressed copies are not in the tarball, plan on your CDN compressing the pages for you.Put the page in the document, not in an <img>
The decomposition only exists if the SVG is part of your DOM. An
<img src="001.svg">renders the same pixels and gives you nothing to select — no words, no marks, no events. Fetch the text and inject it, or use<object>and reach intocontentDocument.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");Drive the size from one dimension and let the other follow. An SVG clips to its viewport, not to its
viewBox— if you force a viewport with a different aspect ratio, ink that lies outside theviewBoxstill draws.Check what the file lets you address
The root
<svg>describes itself in nine attributes.data-decompositionis the one to branch on:wordmeans every word and every named mark is addressable;ayahmeans the file goes no deeper than ayah regions.svg.dataset.decomposition; // "word" svg.dataset.riwayah; // "hafs" svg.dataset.qiraah; // "asim" svg.dataset.ayahNumbering; // "kufi" svg.dataset.ayahTotal; // "6236" svg.dataset.page; // "1"On an
ayah-level file,querySelectorAll("g.word")legitimately returns nothing. An empty result there means "not split", not "broken" — so readdata-decompositionbefore you conclude the file is wrong.Turn a tap into a reference
One listener on the root handles the whole page. Walk up from whatever path was hit.
svg.addEventListener("click", (e) => { const word = e.target.closest("g.word"); if (!word) return; // tapped bare paper const fragment = word.closest("g.ayah-fragment"); console.log( word.dataset.wordKey, // "1:1:2" fragment.dataset.ayahKey, // "1:1" word.closest("g.line").dataset.line, // "2" ); });Only the ink is hit-testable, so a tap in the gap between two words hits nothing. On touch, widen the target without changing a pixel:
g.word path { stroke: transparent; stroke-width: 1.5; paint-order: stroke fill; pointer-events: all; }Highlight, by selecting a set
Colour is a
fillon the paths. There is no text layer and no background rectangle to tint.document .querySelectorAll('g.ayah-fragment[data-ayah-key="1:1"] g.word path') .forEach((p) => p.setAttribute("fill", "#15705D"));Read the next section before you write this against a real page.
Style one mark by meaning
Every diacritic and sign is its own path with a name from a closed vocabulary, so a rule that would otherwise need glyph analysis is one selector. This is what makes a tajwīd colouring or a beginner-reader mode possible over printed artwork.
path[data-mark="hamzat_al_wasl"] { fill: #C2543F; } path[data-mark-family~="dots"] { fill: #5A6963; }Page 1 carries 14
hamzat_al_waslpaths and 38 in thedotsfamily. The names come fromschema/mark-taxonomy.json; do not invent your own.Lift one word out as its own image
Each word's
boxin the sidecar is in the page's ownviewBoxunits, so a crop is aviewBoxswap.const page = await (await fetch("/index/by-page/001.json")).json(); const w = page.words.find((x) => x.word_key === "1:1:2"); const [x0, y0, x1, y1] = w.box; svg.setAttribute("viewBox", `${x0} ${y0} ${x1 - x0} ${y1 - y0}`);That frames the word, but it does not isolate it: Arabic words interleave, so a neighbour's ascender or an adjacent mark sits inside the same rectangle. To get this word and nothing else, hide the page and show the one group —
visibilityinherits, and a descendant can override it.svg.style.visibility = "hidden"; svg.querySelector('g.word[data-word-key="1:1:2"]').style.visibility = "visible";The shapes are only ever moved, never redrawn, so the result is the printed ink at its printed proportions.
All seven of those, running against this same page: click a word for its five text forms, colour a mark family, search the page, crop a word, and follow a recitation word by word.
An ayah is not one node
This is the mistake the format produces most often.
An ayah is emitted once per printed line it occupies. An ayah on three lines is three sibling
g.ayah-fragment nodes, and there is no single node that "is" the ayah. Corpus-wide, 6,236 ayat are emitted as
13,489 fragments; 4,455 of them have more than one.
document.querySelector('g.ayah-fragment[data-ayah-key="2:6"]') returns fragment 1 of N. Highlight from it and you
colour the part of the verse on the first line and leave the rest black. It looks correct on every ayah that fits
on one line, which is most of the short ones you will test with.
Use the plural, and use the count the file gives you as a completeness check:
const frags = document.querySelectorAll('g.ayah-fragment[data-ayah-key="2:6"]');
frags.length === Number(frags[0].dataset.ayahFragments); // true if you loaded the right page
No ayah spans two pages, so if you have the right page you always have all of its fragments.
Two consequences worth planning for:
- A band highlight is one rectangle per line, not one per ayah. Union the word boxes per
data-line. - Do not take an ayah's first or last word from a fragment's first or last child. Order by
data-word-keyacross all the fragments.
Text on the page, and searching it
There are no <text> elements anywhere in these files. All ink is outlines, so browser find-in-page, text selection
and copy do not work on the Qurʾānic text. What you get instead is the text carried as data.
Each word ships in five text forms, built around its rasm. The page itself carries only the printed one; the other four are in the per-page
sidecar, keyed by word_key.
| field | where | what it is | 1:1:2 |
|---|---|---|---|
rasm_uthmani | on the word group, as data-rasm-uthmani, and in the sidecar | the print's own text, fully marked — display this | ٱللَّهِ |
rasm | sidecar only | the skeleton of the printed text — what shapes are actually drawn | ٱلله |
rasm_imlai | sidecar only | modern spelling, with marks | اللَّهِ |
search | sidecar, and index/words.json | the skeleton of the modern spelling — match against this | الله |
qpc | sidecar only | the same text in the King Fahd Complex's own codepoints | ٱللَّهِ |
The printed spelling writes several long vowels as combining marks. Strip the marks off it and the vowel disappears
from the string: مِيثَٰقَكُمْ reduces to
ميثقكم, while a user types
ميثاقكم. Your search box returns zero results and looks like an
empty corpus. Match on search, which is derived from the modern spelling.
Nothing is folded in any of the five forms: the different forms of alif and of hamzah stay distinct. If you want a forgiving search, fold on your side and keep the stored value as written:
const fold = (s) => s.replace(/[أإآٱ]/g, "ا").replace(/ى/g, "ي").replace(/ة/g, "ه");
const hits = page.words.filter((w) => fold(w.search).includes(fold(query)));
Two more things that catch tokenisers: some search values contain a space, and the forms legitimately disagree with
each other at certain sites. Neither is corruption — never split a word value on whitespace, and never assert that
two forms of one word are equal.
Geometry
Boxes in the sidecar are exact outline extents in the page's viewBox units, rounded to two decimals, with the page
and line transforms already applied. You can draw one straight onto the rendered page as an overlay.
Inside the SVG itself the page is drawn under a matrix with a negative y scale, so raw path coordinates are not
viewBox coordinates and y increases upward. In a browser you rarely care: getBBox() on a g.word already answers
in that word's frame, and getCTM() composes the rest. The boxes exist for the cases where there is no browser —
server-side cropping, hit-testing before the SVG has loaded, or a text layer over a raster render.
Two rules that keep this from going wrong:
- Never compare raw coordinates across pages. Recto and verso use different page matrices.
- Boxes belong to the page they came from. They are not portable to another edition, or to the same ayah on another muṣḥaf.
Every page declares data-mushaf="hafs-kfqc", while VERSION.json and every index file say hafs-kfgqpc. A join
or an equality check across the two sources fails on a missing letter. Key on data-edition or on the file you
loaded, not on data-mushaf, until this is resolved upstream — it is recorded in our
cross-repo notes.
Size, and what it costs to serve
Vector outlines of a fully marked muṣḥaf page are large. There is no way around it: the file is the artwork.
- Page 1, the smallest, is 219.6 KiB raw and 33.5 KiB brotli.
- A normal fifteen-line page is around 760 KiB raw, near 120 KiB brotli.
- The whole corpus of 604 pages is about 449 MiB raw, near 70 MiB compressed.
So: compress on the wire, cache aggressively, and fetch one page at a time. Do not ship pages inside an app bundle.
And fetch index/by-page/NNN.json for a page you are drawing rather than index/words.json — the corpus index is a
few hundred kilobytes to answer a question about a few dozen words. It exists for searching everything, not for
drawing one page.
If pages feel heavy in a mobile web view, that is where Quran Engine comes in; it is not a tuning problem.
Limits
- No
<text>, no letter segmentation. A word is the smallest addressable unit of text today, and a named mark the smallest addressable unit of ink. Letter split is the goal; word and mark is how far it has got. - One edition is split. See the coverage note at the top.
- Word boundaries are one project's decision. They agree with other word-by-word decompositions almost everywhere, and a handful of words are split differently. If you join to another dataset on word number, spot-check rather than assume.
- A few marks are exceptions to otherwise-uniform rules — one unnamed mark in the corpus, a handful of paths
without a shape signature, two incomplete sajdah groups.
schema/FORMAT.md§10 lists each one by page. A strict consumer should read it; a normal one will not meet them. - Paths inside a word are not in reading order. Word order within a line is reliable; path order within a word is not. Sort by geometry if you need it.
- The licence in the bundle is unresolved. The bundle's
LICENSEis a placeholder reading "no licence has been chosen", while the same bundle's JSON files declareCC-BY-4.0. Ask before redistributing the artwork. The page artwork and the Qurʾānic text are the King Fahd Complex's in any case, and are not this project's to license.
Pages carry data-ayah-total="6236" because this edition follows the Kufan counting. It is a property of the
printed edition, not of the Qurʾān, and another riwayah's muṣḥaf will differ. Read it from the file you loaded —
see ayah-counting systems.
How this data is made
The input is the vectorised muṣḥaf artwork; the pipeline regroups that ink without moving or redrawing it, so the
output is pixel-identical to the print. Ink is assigned to words, each mark is classified against a fixed taxonomy,
and every index in the bundle is read back out of the shipped pages, so pages and indexes cannot drift apart.
VERSION.json records the artwork and pipeline commits, CHECKSUMS.txt covers every file, and two builds of the
same inputs are byte-identical.