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.

Platforms
Web — the files are plain SVG and JSON, no runtime required
Time
About 12 minutes

Where it sits among the page blocks

Three blocks publish muṣḥaf pages, and coverage narrows as addressing deepens:

CoverageThe smallest thing you can address
Quran SVGevery vectorised muṣḥaf, and growing by contributionan ayah
Quran SVG Elementsonly the muṣḥafs that have been splita word, a mark — a letter, later
Quran Enginethose split muṣḥafs, on mobilethe 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.

Splitting is hard, and it is done per muṣḥaf

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.

pathwhat it answers
pages/NNN.svgthe page itself, 001604
index/by-page/NNN.jsonthat page's words — five text forms each, plus printed line and bounding box
index/words.jsonevery word in the corpus with its page, line, printed text and search form; 77,432 rows
index/pages.jsonper page: which surahs, first and last ayah, word / mark / line counts, divisions starting there
index/surahs.jsonthe 114 surah records: names, revelation place, ayah count, page range
index/divisions.jsonevery juz, hizb, half-hizb and rubʿ boundary, with its ayah and page
schema/FORMAT.mdthe format specification, including its own list of known defects
schema/mark-taxonomy.jsonthe closed vocabulary of mark names, with category and family
schema/*.schema.jsonJSON Schema (2020-12) for each data file above
VERSION.jsonschema version, build date, source commits, corpus counts
CHECKSUMS.txtsha256 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 1MeasuredWhat it is
Word groups29g.word — one per printed word, each with a data-word-key
Ayat on the page7distinct data-ayah-key values
Ayah fragments10g.ayah-fragment — one per ayah per printed line, not one per ayah
Printed lines8g.line, numbered top to bottom
Named marks179each diacritic, dot and sign is its own path
Letter paths67the letter ink itself
Page SVG219.6 KiB raw45.5 KiB gzip · 33.5 KiB brotli
Word sidecar6.8 KiBthe 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 wantselector
one wordg.word[data-word-key="1:1:2"]
every word of one ayahg.ayah-fragment[data-ayah-key="1:1"] g.word
one printed lineg.line[data-line="2"] g.word
every instance of one markpath[data-mark="hamzat_al_wasl"]
a whole family of markspath[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

  1. 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-missing is required. CHECKSUMS.txt lists 3,647 files because the build also produces pre-compressed .svg.br and .svg.gz copies 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/ and index/ as static files. Since those pre-compressed copies are not in the tarball, plan on your CDN compressing the pages for you.

  2. 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 into contentDocument.

    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 the viewBox still draws.

  3. Check what the file lets you address

    The root <svg> describes itself in nine attributes. data-decomposition is the one to branch on: word means every word and every named mark is addressable; ayah means 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 read data-decomposition before you conclude the file is wrong.

  4. 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; }
    
  5. Highlight, by selecting a set

    Colour is a fill on 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.

  6. 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_wasl paths and 38 in the dots family. The names come from schema/mark-taxonomy.json; do not invent your own.

  7. Lift one word out as its own image

    Each word's box in the sidecar is in the page's own viewBox units, so a crop is a viewBox swap.

    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 — visibility inherits, 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.

Try it live

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.

querySelector gives you one line of the verse

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:

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.

fieldwherewhat it is1:1:2
rasm_uthmanion the word group, as data-rasm-uthmani, and in the sidecarthe print's own text, fully marked — display thisٱللَّهِ
rasmsidecar onlythe skeleton of the printed text — what shapes are actually drawnٱلله
rasm_imlaisidecar onlymodern spelling, with marksاللَّهِ
searchsidecar, and index/words.jsonthe skeleton of the modern spelling — match against thisالله
qpcsidecar onlythe same text in the King Fahd Complex's own codepointsٱللَّهِ
Searching against the printed text finds nothing, quietly

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:

The page's own edition id does not match the indexes

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.

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

6,236 is not a constant

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.