Quran SVG
Complete printed muṣḥaf pages as SVG, each one carrying a transparent hit-region for every verse on it. Drop a page into a container, listen for a click, and you have a reference — no OCR, no layout engine, no coordinate work of your own.
What you get, and what you do not
Quran SVG is the archive: every muṣḥaf that has been vectorised, complete, with its page geometry, and the finest thing you can address being an ayah. It grows by contribution rather than on a schedule.
| You want | Use |
|---|---|
| A whole printed page, and a tap that resolves to an ayah | this package |
| To address a single word or a single mark on that page | Quran SVG Elements — only for the muṣḥafs that have been split |
| The same, drawn fast on a phone | Quran Engine |
| The words of the ayah you just tapped, as text | Quran Text |
| Surah headers, ayah markers and page borders on your own typeset page | Quran Assets |
A page is glyph outlines, not selectable Unicode. Nothing in the file will tell you what an ayah says. The polygons hand you a reference; you look the words up in a text dataset from there.
Mount a page and turn a tap into a reference
This is the code the demo on this site runs, trimmed.
Fetch the page and put it in the DOM
The files are static and
raw.githubusercontent.comserves them with open CORS, so a plainfetchworks from a browser with nothing in between. The pages declare aviewBoxand nowidth/height, so they scale to whatever box you give them.const RAW = "https://raw.githubusercontent.com/quran-ws/quran-svg/main/mushafs"; const pad = (n: number) => String(n).padStart(3, "0"); async function mountPage(host: HTMLElement, mushaf: string, page: number) { const res = await fetch(`${RAW}/${mushaf}/svg/${pad(page)}.svg`); if (!res.ok) throw new Error(String(res.status)); host.innerHTML = await res.text(); const root = host.querySelector("svg")!; root.setAttribute("style", "width:100%;height:auto;display:block"); return root; } const root = await mountPage(el, "hafs/kfqc", 42);Inline is the mode that gives you the most: you can style and hit-test the polygons from your own document. An
<object>works too, but you have to go throughgetSVGDocument()to reach them.Fit the artwork to its ink
The declared
viewBoxis not always tight around the drawing — see the box is not the artwork below. Measuring the rendered content once and writing the result back makes every page fill its container:requestAnimationFrame(() => { try { const b = (root as SVGGraphicsElement).getBBox(); if (b.width > 0 && b.height > 0) { const p = Math.max(b.width, b.height) * 0.04; // a little breathing room root.setAttribute("viewBox", `${b.x - p} ${b.y - p} ${b.width + p * 2} ${b.height + p * 2}`); } } catch { /* keep the declared viewBox */ } });getBBox()needs the element to be laid out, so it runs a frame later.Listen on the ayah layer
Every verse on the page is one
<path class="ayahPolygon">carrying its own identity as attributes. Read them off the element you were clicked on:root.querySelectorAll<SVGPathElement>(".ayahPolygon").forEach((p) => { p.style.cursor = "pointer"; p.addEventListener("click", () => { const surah = Number(p.getAttribute("surah")); const ayah = Number(p.getAttribute("ayah")); open(surah, ayah); // your tafsīr, translation, audio, bookmark… }); });Highlight by changing the opacity, never the fill
The polygons ship as
fill-opacity="0". Raising that is all a highlight needs:const on = (p: SVGPathElement, hit: boolean) => { p.setAttribute("fill", hit ? "#15705D" : "#D6AD64"); p.setAttribute("fill-opacity", hit ? "0.35" : "0"); };
Under SVG’s default pointer-events: visiblePainted, a shape whose fill is none is not a
hit target. Setting .ayahPolygon { fill: none } to hide the layer renders identically to the
shipped fill-opacity: 0, and every tap falls through to the page behind. Hide the layer with
opacity, keep the fill.
The stacking order differs between muṣḥafs. In Ḥafṣ the polygons are the last elements in the file
and sit above the glyphs. In Warsh, Qālūn, al-Dūrī and Shuʿbah they come first, under the glyphs, so
a tap on ink hits the letter. Set pointer-events: none on the #content and #ayah_markers groups
after mounting and both orders behave the same.
Click any ayah on any of the 604 pages, in five riwayat, against the files fetched live from the repository.
How a verse is identified in the artwork
One polygon per ayah, and four attributes on it:
<path class="ayahPolygon" fill-opacity="0" id="verse-13" number="002006" surah="2" ayah="6"
d="M 0.0 0.75 L 337.75 0.75 L 337.75 41.0 L 0.0 41.0 Z M 254.12 41.0 … Z"/>
| Attribute | Meaning |
|---|---|
surah | Surah number, 1–114 |
ayah | The ayah number within that surah |
number | SSSAAA — the two above, zero-padded and concatenated |
id | verse-N — numbered globally across all five muṣḥafs, not per muṣḥaf. Page 1 starts at verse-1 in Ḥafṣ but verse-18709 in Warsh. Do not parse it; use surah and ayah. |
surah + ayah is the pair to keep. Store the muṣḥaf key beside it: a
muṣḥaf is one publisher’s typesetting, so a page number is only
meaningful once you say which edition it belongs to.
What ships
Folders are mushafs/<qiraa>/<publisher>/, and at b91d39e the publisher is kfqc throughout
— the King Fahd Glorious Qur’an Printing Complex, the Madinah muṣḥaf.
| Path | What is in it |
|---|---|
svg/001.svg … | The page: glyph outlines plus the ayah hit-layer |
svg-br/001.svg.br … | The same file, Brotli-compressed, to serve with Content-Encoding: br |
json/001.json … | One entry per ayah on that page: surahNumber, ayahNumber, x, y, polygon |
json/surah.json | All 114 surahs: starting page, juz, names, ayah count |
json/markers.json | Every ayah medallion in the muṣḥaf, as { page, ayah, x, y } |
Each muṣḥaf holds 604 numbered pages, plus 118 surah-specific variants for pages that carry more than
one surah — 106-surah4.svg is the part of page 106 belonging to surah 4. That is 722 files in
each svg/ directory, and 724 in each json/ (the same pages, plus the two indexes).
Five muṣḥafs are vectorised at this commit. The ayah counts are each muṣḥaf’s own medallions,
summed from its surah.json:
| Folder | Riwayah | Pages | Ayat |
|---|---|---|---|
hafs/kfqc | Ḥafṣ | 604 | 6,236 |
warsh/kfqc | Warsh | 604 | 6,214 |
qalon/kfqc | Qālūn | 604 | 6,214 |
douri/kfqc | al-Dūrī | 604 | 6,218 |
shubah/kfqc | Shuʿbah | 604 | 6,236 |
All five have 604 pages, so page numbers look interchangeable. They are not: a riwayah divides its verses differently and its pages are set separately, so the same page number holds different ayat in each. Persist the muṣḥaf key with every page number you store, and re-resolve the page when the reader switches reading — do not carry the number across. Why the counts differ at all is in ayah-counting systems.
Find the page a reference sits on
Two ways, and the small one is usually the right one.
Bounded binary search over the per-page JSON. surah.json gives you the surah’s first page
and the next surah’s, and the per-page files are tiny — the Ḥafṣ median is 1,679 bytes.
Run over all 6,236 Ḥafṣ references, this resolves every one of them in at most six requests, with
no index to build and nothing to host. It is what the demo on this site runs:
interface PolyEntry { surahNumber: number; ayahNumber: number; x: number; y: number; polygon: string }
interface SurahEntry { number: number; pageNumber: number; ayahCount: number; nameEnglish: string }
// surah.json is an array of 114 entries, in surah order
const surahs: SurahEntry[] = await fetch(`${RAW}/hafs/kfqc/json/surah.json`).then((r) => r.json());
async function pageOf(surah: number, ayah: number) {
let lo = surahs[surah - 1].pageNumber;
let hi = surah < 114 ? surahs[surah].pageNumber : 604;
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2);
const entries: PolyEntry[] = await fetch(`${RAW}/hafs/kfqc/json/${pad(mid)}.json`).then((r) => r.json());
if (entries.some((e) => e.surahNumber === surah && e.ayahNumber === ayah)) return mid;
const below = entries.some((e) => e.surahNumber < surah || (e.surahNumber === surah && e.ayahNumber < ayah));
if (below) lo = mid + 1;
else hi = mid - 1;
}
return null;
}
Or load markers.json once if you need every answer offline. It is one 598 KB file per muṣḥaf,
in reading order, one entry per ayah.
It is the running index of the ayah within that muṣḥaf — 1..N, where N is the
muṣḥaf’s own total (Ḥafṣ 6,236, Warsh 6,214). The first entry for page 3 of Ḥafṣ is
{"page":3,"ayah":13,…}, and that is 2:6, not 2:13. There is no surah field, so you cannot look a
reference up directly; you convert it first, using the cumulative ayahCount from the same
muṣḥaf’s surah.json.
It is not the same number as the polygon’s id="verse-N", which is numbered globally
across all five muṣḥafs. The two agree in Ḥafṣ and diverge everywhere else.
// 2:255 → running index 262 → page 42, in Ḥafṣ
const runningIndex = (surah: number, ayah: number) =>
surahs.slice(0, surah - 1).reduce((n, s) => n + s.ayahCount, 0) + ayah;
const page = markers[runningIndex(2, 255) - 1].page;
The polygon field has two shapes
A polygon is the region of the page belonging to one ayah. In the JSON it is written two different ways depending on the page:
| Pages | Shape | Example |
|---|---|---|
| 3–604 and the surah variants | SVG path data | "M 0.0 0.75 L 337.75 0.75 … Z M 254.12 41.0 … Z" |
| 1–2, the opening spread | a bare points list | "181.08,18.31 57.54,18.31 57.54,48.94 181.08,48.94" |
Split page 3’s path data on whitespace and commas and you get M, L and Z where you
expected numbers — Number("M") is NaN, Math.min of anything containing it is NaN, and
the crop you compute is an empty box rather than an error. Pulling every
number out and pairing them handles both shapes, because path commands are letters and every
coordinate is written x then y:
function boxOf(polygon: string) {
const nums = (polygon.match(/-?\d+(?:\.\d+)?/g) ?? []).map(Number);
const xs: number[] = [];
const ys: number[] = [];
for (let i = 0; i + 1 < nums.length; i += 2) { xs.push(nums[i]); ys.push(nums[i + 1]); }
const x = Math.min(...xs);
const y = Math.min(...ys);
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
}
The SVG does not have this problem: the d attribute on .ayahPolygon is path data on every page,
including 1 and 2. If you already have the page in the DOM, read the geometry from the element and skip
the JSON entirely.
A polygon is usually several rectangles
An ayah generally starts mid-line and ends mid-line, so its region is a stack of line-strips rather than one box. In Ḥafṣ, 4,221 of the 6,236 polygons have more than one subpath.
That is why a bounding box is the wrong crop: the box around an ayah covers ink belonging to the verses either side of it. Clip to the polygon itself and you get the verse and nothing else.
const NS = "http://www.w3.org/2000/svg";
// 1. the crop window is the polygon's bounding box
const box = boxOf(entry.polygon);
root.setAttribute("viewBox", `${box.x} ${box.y} ${box.w} ${box.h}`);
// 2. the clip is the polygon itself — path data or a points list, depending on the page
const shape = /[A-Za-z]/.test(entry.polygon)
? (() => {
const el = document.createElementNS(NS, "path");
el.setAttribute("d", entry.polygon);
return el;
})()
: (() => {
const el = document.createElementNS(NS, "polygon");
el.setAttribute("points", entry.polygon);
return el;
})();
const clip = document.createElementNS(NS, "clipPath");
clip.setAttribute("id", "ayahclip");
clip.setAttribute("clipPathUnits", "userSpaceOnUse");
clip.appendChild(shape);
const defs = document.createElementNS(NS, "defs");
defs.appendChild(clip);
// 3. clip a fresh wrapper that has no transform of its own
const wrap = document.createElementNS(NS, "g");
wrap.setAttribute("clip-path", "url(#ayahclip)");
while (root.firstChild) wrap.appendChild(root.firstChild);
root.append(defs, wrap);
The glyph content sits inside a transform="matrix(1.3333 0 0 -1.3333 …)" — a flipped, scaled
space. The .ayahPolygon paths are siblings of that group, in the root user space, which is the
space the JSON coordinates are in. Hang the clip-path on a new wrapper <g> with no transform of
its own and move the page content inside it; putting the clip on the transformed group runs the clip
shape through the matrix too, and the crop lands somewhere else on the page.
The box is not the artwork
Coordinates are the muṣḥaf’s own page units, and glyphs and polygons share one space, so nothing needs rescaling to line up. What varies is the box:
| Pages | viewBox |
|---|---|
| 3–604 | 0 0 345 550 — except Warsh and Qālūn, whose user space starts at x = −6: -6 0 345 550 |
| 1–2, the opening spread | the same 345 × 550 box, but with its own origin, which differs per muṣḥaf: Ḥafṣ is -53.3109 -198.4777 345 550 |
| Surah variants | the page’s x-origin and width, cropped in height: 106-surah4.svg is 0 0 345 188.58 |
On pages 3 onwards the drawing fills the box — page 3 of Ḥafṣ measures 337.75 × 547.97 inside
345 × 550. On the opening spread it does not: page 1 of Ḥafṣ measures 221.68 × 275.46, about a third of
the declared area, because the box belongs to the two facing pages together. Render it as declared and
most of what you show is empty. That is what the getBBox() fit in step 2 is for.
Read the viewBox from the file rather than assuming any of these values.
Weight
Page 1 of Ḥafṣ is 196,304 bytes as .svg and 32,707 as .svg.br; the per-page JSON runs from 132 to
6,039 bytes. Serve the Brotli copies where you can, and ship only the pages you need — these are
static files, so an offline app just bundles them.
Licensing
The repository’s own contribution — the ayah-polygon layer, the per-page JSON,
surah.json and markers.json — is CC BY 4.0, with attribution waived when the material is
used inside a product, and tools/ is MIT. The page artwork belongs to the King Fahd Complex, which
grants free worldwide digital, web, software and media use; the one restriction is printing physical
muṣḥafs for commercial sale. The full terms and source attribution are in the repository’s
NOTICE.md, and the policy behind the
waiver is Waqf and open licensing.
Limits
- An ayah is the finest thing you can address. No words, no letters, no marks. If you need those, you need Elements, which covers only the muṣḥafs that have been split.
- No text. Glyph outlines, so no selection, no copy, no search inside a page.
- SVG this size is heavy on phones. A page is a few hundred KB of path data; scrolling a muṣḥaf of them in a mobile webview is where SVG stops being the right answer, and Quran Engine exists for that case.
- Five muṣḥafs today. The archive grows when a muṣḥaf is contributed and vectorised; the set is not on a schedule.
- Corrections are welcome upstream. Polygons are derived, not hand-placed, and a wrong one is a bug worth an issue.
How this data is made
Pages start as the publisher’s own vector edition, so the letter shapes are the printed ones rather than a trace of a scan. The ayah layer is derived from each page’s own ayah medallions, so the counts follow the muṣḥaf rather than any external numbering, and the polygons are then audited against the ink on the page. Every polygon in a muṣḥaf accounts for exactly one ayah of it: the 604 Ḥafṣ pages carry 6,236 polygons, and the same holds for the other four against their own totals.
Read from quran-ws/quran-svg at commit 9fc0c87, the commit pinned in public/demo/SOURCES.json.