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.

Blocks used
Platforms
Anything that renders SVG
Time
About 12 minutes

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 wantUse
A whole printed page, and a tap that resolves to an ayahthis package
To address a single word or a single mark on that pageQuran SVG Elements — only for the muṣḥafs that have been split
The same, drawn fast on a phoneQuran Engine
The words of the ayah you just tapped, as textQuran Text
Surah headers, ayah markers and page borders on your own typeset pageQuran Assets
There is no text inside these files

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.

  1. Fetch the page and put it in the DOM

    The files are static and raw.githubusercontent.com serves them with open CORS, so a plain fetch works from a browser with nothing in between. The pages declare a viewBox and no width/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 through getSVGDocument() to reach them.

  2. Fit the artwork to its ink

    The declared viewBox is 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.

  3. 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…
      });
    });
    
  4. 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");
    };
    
`fill: none` silently stops the taps

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.

Try it live

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"/>
AttributeMeaning
surahSurah number, 1–114
ayahThe ayah number within that surah
numberSSSAAA — the two above, zero-padded and concatenated
idverse-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.

PathWhat is in it
svg/001.svgThe page: glyph outlines plus the ayah hit-layer
svg-br/001.svg.brThe same file, Brotli-compressed, to serve with Content-Encoding: br
json/001.jsonOne entry per ayah on that page: surahNumber, ayahNumber, x, y, polygon
json/surah.jsonAll 114 surahs: starting page, juz, names, ayah count
json/markers.jsonEvery 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:

FolderRiwayahPagesAyat
hafs/kfqcḤafṣ6046,236
warsh/kfqcWarsh6046,214
qalon/kfqcQālūn6046,214
douri/kfqcal-Dūrī6046,218
shubah/kfqcShuʿbah6046,236
Page 42 is page 42 of one muṣḥaf only

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.

`markers.json`’s `ayah` is not the ayah number

It is the running index of the ayah within that muṣḥaf1..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:

PagesShapeExample
3–604 and the surah variantsSVG path data"M 0.0 0.75 L 337.75 0.75 … Z M 254.12 41.0 … Z"
1–2, the opening spreada bare points list"181.08,18.31 57.54,18.31 57.54,48.94 181.08,48.94"
A parser written against one shape returns NaN on the other

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);
Clip a wrapper, not the content group

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:

PagesviewBox
3–6040 0 345 550 — except Warsh and Qālūn, whose user space starts at x = −6: -6 0 345 550
1–2, the opening spreadthe same 345 × 550 box, but with its own origin, which differs per muṣḥaf: Ḥafṣ is -53.3109 -198.4777 345 550
Surah variantsthe 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

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.