Rendering Precision: Building a Digital Quran Mushaf

Jibran Kalia 15 min read
Written Updated

بِسْمِ ٱللَّٰهِ ٱلرَّحْمَٰنِ ٱلرَّحِيمِ

In this guide, I'll walk you through building a digital mushaf renderer that preserves the exact page layout using data from qul.tarteel.ai.

Mushaf Editions

The main editions you'll encounter:

  1. Madinah Mushaf V1 (1405H): 604 pages, 15 lines — the classic King Fahd Complex print most of us grew up with
  2. Madinah Mushaf V2 (1421H): 604 pages, 15 lines — updated King Fahd print
  3. IndoPak (Qudratullah): 610 pages, 15 lines — the nastaleeq script common in South Asian madaris
  4. QPC Nastaleeq: 610 pages, 15 lines — another nastaleeq variant

Key differences between editions:

  • Page counts (604 vs 610)
  • Script style (naskh vs nastaleeq)
  • Line breaks and text justification

The Data Architecture

Understanding qul.tarteel.ai Data Structure

qul.tarteel.ai provides downloadable JSON and SQLite files with comprehensive Quran data and mushaf-specific layouts. Here's the core data you'll need:

1. Structural Data

Chapters (Surahs) - 114 total

{
  "id": 1,
  "name_simple": "Al-Fatihah",
  "name_arabic": "الفاتحة",
  "verses_count": 7,
  "revelation_place": "makkah",
  "revelation_order": 5
}

Verses (Ayahs) - 6,236 total

{
  "id": 1,
  "verse_key": "1:1",
  "surah_number": 1,
  "ayah_number": 1,
  "text_qpc_hafs": "بِسۡمِ ٱللَّهِ ٱلرَّحۡمَٰنِ ٱلرَّحِيمِ",
  "words_count": 4
}

Words - 83,668 total

This is where it gets interesting. Each word has multiple text representations for different mushaf editions:

{
  "id": 1,
  "location": "1:1:1",  // surah:ayah:word
  "verse_key": "1:1",
  "surah": 1,
  "ayah": 1,
  "word": 1,
  "qpc_v1": "ﭑ",
  "qpc_v2": "ﱁ",
  "indopak_nastaleeq_15": "بِسْمِ",
  "qpc_nastaleeq": "بِسْمِ"
}

Why multiple text versions? The diacritical marks (tashkeel) and typography vary between editions to ensure each word fits perfectly on its designated line.

2. Page Layout Data

Mushaf Metadata

{
  "id": 1,
  "mushaf_name": "QCF V1 (1405H print)",
  "code": "qpc_v1",
  "pages_count": 604,
  "lines_per_page": 15,
  "font_name": "v1"
}

Page Lines Mapping

This is the heart of mushaf rendering - mapping each line on each page:

{
  "mushaf_id": 1,
  "page_number": 1,
  "line_number": 1,
  "line_type": "surah_name",  // or "basmallah" or "ayah"
  "is_centered": true,
  "surah_number": 1,
  "first_word_id": null,
  "last_word_id": null
}
{
  "mushaf_id": 1,
  "page_number": 1,
  "line_number": 3,
  "line_type": "ayah",
  "is_centered": false,
  "first_word_id": 1,    // Points to word "بِسْمِ"
  "last_word_id": 4      // Points to word "ٱلرَّحِيمِ"
}

For the Madinah Mushaf with 604 pages and ~15 lines per page, you'll have 9,046 page line records mapping the entire Quran. Note that pages 1-2 (Al-Fatihah and start of Al-Baqarah) have only 8 lines due to their decorated surah headers.

3. Structural Divisions

The Quran's traditional divisions for tilawah and hifz:

Juz (30 parts)

{
  "id": 1,
  "first_verse_key": "1:1",
  "last_verse_key": "2:141",
  "verses_count": 148
}
  • Hizb — 60 total, 2 per juz
  • Rub' — 240 quarter markers (the ۞ symbols)
  • Manzil — 7 divisions for completing the Quran weekly
  • Ruku — 558 thematic sections (used in Hanafi taraweeh)
  • Sajdah — 15 ayaat as-sajdah

All available in the qul.tarteel.ai data exports.

Data Model Design

Here's a platform-agnostic data model for mushaf rendering:

Core Tables

1. mushafs

- id (integer, primary key)
- mushaf_name (string)
- code (enum: qpc_v1, qpc_v2, indopak_nastaleeq_15, qpc_nastaleeq)
- pages_count (integer)
- lines_per_page (integer)
- font_name (string)

2. chapters

- id (integer, 1-114)
- name_simple (string)
- name_arabic (string)
- name_display (string)
- revelation_place (enum: makkah, madinah)
- revelation_order (integer)
- verses_count (integer)

3. verses

- id (integer)
- verse_key (string, unique, e.g., "1:1")
- surah_number (integer, foreign key to chapters)
- ayah_number (integer)
- text_qpc_hafs (text)
- words_count (integer)

4. words

- id (integer, primary key)
- location (string, unique, e.g., "1:1:1")
- verse_key (string, foreign key to verses)
- surah (integer)
- ayah (integer)
- word (integer)
- qpc_v1 (string)
- qpc_v2 (string)
- indopak_nastaleeq_15 (string)
- qpc_nastaleeq (string)

5. mushaf_page_lines (The critical mapping table)

- id (integer)
- mushaf_id (foreign key to mushafs)
- page_number (integer)
- line_number (integer)
- line_type (enum: surah_name, basmallah, ayah)
- is_centered (boolean)
- first_word_id (foreign key to words, nullable)
- last_word_id (foreign key to words, nullable)
- surah_number (integer, nullable, foreign key to chapters)

UNIQUE INDEX on (mushaf_id, page_number, line_number)

Virtual Page Object

Instead of storing pages in the database, generate them on-demand:

class MushafPage {
  constructor(mushaf, pageNumber) {
    this.mushaf = mushaf;
    this.pageNumber = pageNumber;
  }

  getLines() {
    // Query: SELECT * FROM mushaf_page_lines
    //        WHERE mushaf_id = ? AND page_number = ?
    //        ORDER BY line_number
    return database.query(/* ... */);
  }

  getWords() {
    const lines = this.getLines();
    const wordIds = lines
      .filter(line => line.line_type === 'ayah')
      .flatMap(line => [line.first_word_id, line.last_word_id]);

    const minId = Math.min(...wordIds);
    const maxId = Math.max(...wordIds);

    // Query: SELECT * FROM words WHERE id BETWEEN ? AND ? ORDER BY id
    return database.query(/* ... */);
  }

  getVerseBoundaries() {
    const words = this.getWords();
    return {
      firstVerseKey: words[0].verse_key,
      lastVerseKey: words[words.length - 1].verse_key
    };
  }
}

The Rendering Strategy

Step 1: Fetch Page Data

async function renderPage(mushafId, pageNumber) {
  const mushaf = await getMushaf(mushafId);
  const lines = await getPageLines(mushafId, pageNumber);

  return {
    mushaf,
    pageNumber,
    lines: await Promise.all(lines.map(line => processLine(line, mushaf)))
  };
}

Step 2: Process Each Line

async function processLine(line, mushaf) {
  switch (line.line_type) {
    case 'surah_name':
      return {
        type: 'surah_name',
        centered: line.is_centered,
        surahNumber: line.surah_number,
        chapter: await getChapter(line.surah_number)
      };

    case 'basmallah':
      return {
        type: 'basmallah',
        centered: line.is_centered
      };

    case 'ayah':
      const words = await getWordsByRange(
        line.first_word_id,
        line.last_word_id
      );
      return {
        type: 'ayah',
        centered: line.is_centered,
        words: words.map(word => ({
          id: word.id,
          text: getWordTextForMushaf(word, mushaf.code),
          location: word.location
        }))
      };
  }
}

function getWordTextForMushaf(word, mushafCode) {
  switch (mushafCode) {
    case 'qpc_v1': return word.qpc_v1;
    case 'qpc_v2': return word.qpc_v2;
    case 'indopak_nastaleeq_15': return word.indopak_nastaleeq_15;
    case 'qpc_nastaleeq': return word.qpc_nastaleeq;
    default: return word.qpc_v1;
  }
}

Step 3: HTML Structure

<div class="mushaf mushaf-qpc-v1" dir="rtl">
  <div class="page-wrapper">
    <div class="page page-1-qpc-v1">

      <!-- Line 1: Surah Name (centered) -->
      <div class="line line--center line--surah-name">
        <span class="surah-name-icon"><!-- Icon font character --></span>
      </div>

      <!-- Line 2: Basmallah (centered) -->
      <div class="line line--center line--bismillah">
        <span class="bismillah-icon">﷽</span>
      </div>

      <!-- Line 3-15: Verses -->
      <div class="line">
        <div class="ayah">
          <span class="char" id="word-1">بِسْمِ</span>
          <span class="char" id="word-2">ٱللَّهِ</span>
          <span class="char" id="word-3">ٱلرَّحْمَٰنِ</span>
          <span class="char" id="word-4">ٱلرَّحِيمِ</span>
        </div>
      </div>

    </div>
  </div>
</div>

Font Handling: The Secret Sauce

The Challenge

Traditional Quran fonts are large (often 1-2 MB per font file). Loading all fonts at once would destroy performance. Additionally, each mushaf page might need specific typographic adjustments.

The Solution: Per-Page Fonts

For editions like QPC V1, use page-specific font files (604 separate font files):

fonts/
  quran/
    common/
      qpc-hafs-v22.woff2          # General fallback
      surah-names-v4.woff2         # Surah headers
      bismillah.woff2              # Basmallah icon
      quran-common.woff2           # Decorative icons
    v1/
      p1.woff2                     # Page 1 specific
      p2.woff2                     # Page 2 specific
      ...
      p604.woff2                   # Page 604 specific

Font Loading Strategy

Critical: Current Page (Preload)

<link rel="preload"
      href="https://static.quranportal.io/fonts/quran/v1/p1.woff2"
      as="font"
      type="font/woff2"
      crossorigin="anonymous">

<style>
  @font-face {
    font-family: 'p1-v1';
    src: url('https://static.quranportal.io/fonts/quran/v1/p1.woff2') format('woff2');
    font-display: block;  /* Block rendering until loaded */
  }

  .page-1-qpc_v1 {
    font-family: 'p1-v1', 'qpc-hafs-v22', sans-serif;
  }
</style>

Optimization: Next/Previous Pages (Prefetch)

<link rel="prefetch"
      href="https://static.quranportal.io/fonts/quran/v1/p2.woff2"
      as="font"
      type="font/woff2"
      crossorigin="anonymous">

<style>
  @font-face {
    font-family: 'p2-v1';
    src: url('https://static.quranportal.io/fonts/quran/v1/p2.woff2') format('woff2');
    font-display: swap;  /* Show fallback first, swap when loaded */
  }
</style>

This approach:

  • ✅ Loads only 3 fonts per page (current + next + previous)
  • ✅ Instant rendering when navigating forward/backward
  • ✅ Minimal initial page load

CSS Architecture

Base Styles

.mushaf {
  color: #111827;
  user-select: none;  /* Prevent accidental text selection */
  -webkit-user-select: none;
}

.dark .mushaf {
  color: #f9fafb;
}

.page-wrapper {
  text-align: center;
  display: flex;
  justify-content: center;
  margin: 0 auto;
  direction: rtl;  /* Right-to-left */
}

.page {
  text-align: justify;
  text-align-last: justify;  /* Justify the last line too */
  unicode-bidi: embed;       /* Proper RTL embedding */
  width: 100%;
  max-width: 100%;
  padding: 0.5rem;
  font-size: 1.5rem;         /* Mobile: 24px */
}

@media (min-width: 768px) {
  .page {
    padding: 2rem;
    font-size: 3rem;         /* Desktop: 48px */
  }
}

Line Styles

.line {
  text-align: right;
  direction: rtl;
  width: 100%;
  margin-bottom: 0.375rem;
}

@media (min-width: 768px) {
  .line {
    margin-bottom: 1rem;
  }
}

.line--center {
  text-align: center !important;
  text-align-last: center !important;
}

.line--bismillah {
  display: flex;
  justify-content: center;
  align-items: center;
}

Word Rendering: The Whitespace Problem

Here's a critical CSS trick. When rendering justified text with inline elements, browsers add whitespace between elements that breaks justification:

.ayah {
  display: block;
  width: 100%;
  text-align: justify;
  text-align-last: justify;
  font-size: 0;  /* ← CRITICAL: Removes whitespace between chars */
}

.ayah .char {
  display: inline-block;
  font-size: 1.5rem;  /* ← Restore font size on actual words */
}

@media (min-width: 768px) {
  .ayah .char {
    font-size: 3rem;
  }
}

Setting font-size: 0 on the container eliminates whitespace, then individual characters restore their proper size.

Interactive Words (Optional)

For hifz apps with mistake tracking or highlighting individual words during sima':

.char--clickable {
  cursor: pointer;
  padding: 2px 0.5px;
  transition: all 200ms;
  border-radius: 0;

  /* Remove default button styles if using <button> */
  background: none;
  border: none;
  font: inherit;
  color: inherit;
}

.char--clickable:hover {
  background-color: #e5e7eb;
  border-radius: 0.25rem;
}

.dark .char--clickable:hover {
  background-color: #374151;
}

.char--clickable:active {
  background-color: #d1d5db;
  transform: scale(0.98);
}

Performance Optimizations

1. Database Indexing

-- Critical indexes
CREATE UNIQUE INDEX idx_words_location ON words(location);
CREATE INDEX idx_words_verse_key ON words(verse_key);
CREATE INDEX idx_mushaf_page_lines_lookup
  ON mushaf_page_lines(mushaf_id, page_number, line_number);

-- Composite indexes for range queries
CREATE INDEX idx_words_id_range ON words(id);
CREATE INDEX idx_verses_surah_ayah ON verses(surah_number, ayah_number);

2. Efficient Word Queries

Instead of querying each line's words individually:

// ❌ BAD: N+1 queries
for (const line of lines) {
  if (line.line_type === 'ayah') {
    line.words = await getWords(line.first_word_id, line.last_word_id);
  }
}

// ✅ GOOD: Single query for all words on page
const wordIds = lines
  .filter(line => line.line_type === 'ayah')
  .flatMap(line => [line.first_word_id, line.last_word_id]);

const minId = Math.min(...wordIds);
const maxId = Math.max(...wordIds);

const allWords = await database.query(
  'SELECT * FROM words WHERE id BETWEEN ? AND ? ORDER BY id',
  [minId, maxId]
);

// Map words back to lines
for (const line of lines) {
  if (line.line_type === 'ayah') {
    line.words = allWords.filter(w =>
      w.id >= line.first_word_id && w.id <= line.last_word_id
    );
  }
}

3. Caching Strategy

Cache entire page renders since Quran data rarely changes:

const cacheKey = `mushaf:${mushafId}:page:${pageNumber}:v1`;

async function getCachedPage(mushafId, pageNumber) {
  const cached = await cache.get(cacheKey);
  if (cached) return cached;

  const page = await renderPage(mushafId, pageNumber);
  await cache.set(cacheKey, page, { ttl: 86400 }); // 24 hours
  return page;
}

Common Challenges

RTL Text Rendering

Right-to-left text behaves differently across browsers:

.page {
  direction: rtl;
  unicode-bidi: embed;
  text-align: justify;
  text-align-last: justify;
}

The unicode-bidi: embed ensures proper text ordering without interfering with nested elements.

Mobile Touch Targets

Arabic text at large font sizes can still create words smaller than the recommended 44px touch targets:

.char--clickable {
  min-height: 1.2em;
  min-width: 0.5em;
  padding: 2px 0.5px;
  touch-action: manipulation;  /* Prevents zoom on double-tap */
  -webkit-tap-highlight-color: transparent;
}

Finding a Page by Verse

Given a verse reference (e.g., 2:255), find which page it appears on:

async function findPageForVerse(verseKey, mushafId) {
  // 1. Get first word of the verse
  const firstWord = await database.queryOne(
    'SELECT id FROM words WHERE verse_key = ? ORDER BY id LIMIT 1',
    [verseKey]
  );

  // 2. Find page line containing this word
  const pageLine = await database.queryOne(
    `SELECT page_number FROM mushaf_page_lines
     WHERE mushaf_id = ?
     AND first_word_id <= ?
     AND last_word_id >= ?`,
    [mushafId, firstWord.id, firstWord.id]
  );

  return pageLine.page_number;
}

Advanced Features

Word-Level Highlighting

For tracking mistakes during sima' or highlighting during audio playback:

function highlightWord(wordId, highlightType) {
  const element = document.getElementById(`word-${wordId}`);
  element.classList.add(`highlight-${highlightType}`);
}
.highlight-mistake {
  background-color: #fee2e2;  /* Red-100 */
  color: #7f1d1d;             /* Red-900 */
  border-radius: 0.25rem;
}

.highlight-correct {
  background-color: #dcfce7;  /* Green-100 */
  color: #14532d;             /* Green-900 */
}

Audio Synchronization

Sync qari recitation with word highlighting:

const recitationData = {
  '1:1': [
    { wordId: 1, timestamp: 0.0 },
    { wordId: 2, timestamp: 0.5 },
    { wordId: 3, timestamp: 1.2 },
    { wordId: 4, timestamp: 2.0 }
  ]
};

audio.addEventListener('timeupdate', () => {
  const currentTime = audio.currentTime;
  const currentWord = findWordByTimestamp(currentTime);
  highlightWord(currentWord.wordId, 'active');
});

Resources

Email [email protected] if you have questions.