DEV Community

Idris
Idris

Posted on

Solving Complex Arabic Script & Tajweed Rendering in Jetpack Compose: A Technical Deep Dive

Building a digital Quran application demands extraordinary precision. Unlike standard typography, Quranic text is governed by intricate orthographic rules: cursive letter joining (ligatures), dynamic diacritic positioning (Tashkeel), color-coded recitation rules (Tajweed), and pause indicators (Waqf).

During the development of our native Android application built with Jetpack Compose and Kotlin, we encountered severe rendering glitches when enabling Tajweed color styling: connected Arabic letters severed into isolated fragments, letter shapes distorted, orphan marks rendered ugly dotted circles (), and essential stoppage signs vanished.

In this technical blog post, we explore the root causes of these rendering failures in Android's native text shaper and detail the engineering architecture used to solve them.


1. The Anatomy of the Problem

A. Broken Cursive Ligatures & Severed Letters

Arabic is an inherently cursive, Right-to-Left (RTL) script. Letter shapes mutate dynamically based on their position in a word (Initial, Medial, Final, or Isolated) using OpenType GSUB (Glyph Substitution) tables.

In the web version of the app, Tajweed rules are marked up with inline HTML tags:

بِ<rule class="madda_normal">ٱلْمَلَٰٓئِكَةِ</rule>
Enter fullscreen mode Exit fullscreen mode

When we initially parsed these HTML strings into Jetpack Compose AnnotatedString using sub-word SpanStyle(color = ...) boundaries:

// ❌ Problematic Approach: Applying SpanStyle inside a single word
buildAnnotatedString {
    append("بِ")
    withStyle(SpanStyle(color = Color(0xFF537FFF))) {
        append("ٱلْمَلَٰٓئِكَةِ")
    }
}
Enter fullscreen mode Exit fullscreen mode

Android’s native StaticLayout text shaper treated every SpanStyle color boundary as a hard layout break. As a result:

  • The OpenType cursive joining engine failed across span boundaries.
  • Fluid words fragmented into disjointed, unreadable letter snippets.
  • Diacritics (Fatha, Kasra, Sukoon) detached from their base consonants.
Web Browser Rendering (Correct) Initial Native Compose Rendering (Distorted)
Smooth, connected cursive ligatures with colored sub-letter spans. Severed, isolated letter fragments with floating diacritics.

B. The Dotted Circle ( / U+25CC) Bug

In certain Ayahs (e.g. Surah Al-Hijr 15:2–3: كَفَرُواْ, يَأْكُلُواْ), dotted gray circles appeared over silent Alefs.

ذَرْهُمْ يَأْكُلُواْ ◌ وَيَتَمَتَّعُواْ ◌
Enter fullscreen mode Exit fullscreen mode

Why did this happen?

In Unicode, U+25CC (DOTTED CIRCLE) is a fallback placeholder base character. When a Unicode string contains an orphan combining mark—such as U+06DF (Small High Rounded Zero ۟) or U+06E2 (Small High Meem)—without an recognized preceding base consonant, the OS font fallback engine inserts U+25CC () underneath the mark to show where it belongs.


C. Missing Quranic Stoppage Signs (Waqf)

To prevent rendering artifacts, an earlier regex sanitization rule had been applied across the codebase:

// ❌ Aggressive Regex: Accidentally deleted authentic Waqf signs
text.replace("[\u06d6-\u06dc\u06df-\u06e8\u06ea-\u06ec\u25cc\u06dd]".toRegex(), "")
Enter fullscreen mode Exit fullscreen mode

This regex range (U+06D6 to U+06DC) explicitly stripped all 7 fundamental Quranic stoppage signs:

  • \u06D6 (ۖ - Slay / Mandatory Pause)
  • \u06D7 (ۗ - Qlay / Preferred Pause)
  • \u06D8 (ۘ - Meem / Compulsory Stop)
  • \u06D9 (ۙ - Lam-Alef / Do Not Stop)
  • \u06DA (ۚ - Jeem / Permissible Stop)
  • \u06DB (ۛ - Three Dots / Conformity Stop)
  • \u06DC (ۜ - Seen / Pause)

2. Analyzing the Engine Differences: WebKit vs. Native Compose

Why did Tajweed HTML render flawlessly in web browsers but fail in Jetpack Compose?

graph TD
    A["Raw Tajweed HTML:<br>&lt;rule class='ikhafa'&gt;نْ&lt;/rule&gt;تَ"] --> B{"Rendering Engine"}

    B -->|"Web Browser (Chromium / WebKit)"| C["Cross-DOM-Node Font Shaper"]
    C --> D["Shapes entire word FIRST,<br>then paints CSS colors on glyphs"]
    D --> E["✅ 100% Intact Cursive Ligatures"]

    B -->|"Native Android (Jetpack Compose)"| F["StaticLayout / Canvas"]
    F --> G["Splits text at SpanStyle boundaries"]
    G --> H["❌ Severed Ligatures & Disjointed Letters"]
Enter fullscreen mode Exit fullscreen mode
  1. Chromium / WebKit (Web): Features a Cross-Node OpenType Font Shaper. It processes the complete word as a unified glyph run before applying CSS colors (color: #26BFFD;) to individual character nodes.
  2. Android Native StaticLayout (Compose): Processes individual text spans independently. Any mid-word SpanStyle color boundary breaks HarfBuzz script shaping for Arabic.

3. The Engineering Solution

To resolve these challenges without compromising performance or typography quality, we architected a comprehensive fix.

Step 1: WebKit-Powered Sub-Character Tajweed Component (TajweedHtmlView)

For exact sub-character Tajweed rendering matching the web version, we built a custom Jetpack Compose component wrapped in AndroidView that leverages WebKit's native cross-node font shaping engine.

@Composable
fun TajweedHtmlView(
    tajweedHtml: String,
    fontSizeSp: Float = 26f,
    fontFileName: String = "scheherazade_regular.ttf",
    textColorHex: String = "#2B3F3C",
    modifier: Modifier = Modifier
) {
    // 1. Strip orphan zero placeholders while keeping all Waqf marks
    val cleanHtml = remember(tajweedHtml) {
        tajweedHtml.replace("[\u06df\u06e0\u06e2\u06ea-\u06ec\u25cc]".toRegex(), "")
    }

    // 2. Build self-contained HTML page with asset font binding
    val pageData = remember(cleanHtml, fontSizeSp, fontFileName, textColorHex) {
        """
        <!DOCTYPE html>
        <html dir="rtl">
        <head>
        <meta charset="utf-8">
        <style>
        @font-face {
          font-family: 'CustomArabicFont';
          src: url('file:///android_asset/fonts/$fontFileName');
        }
        body {
          margin: 0;
          padding: 2px 0;
          background-color: transparent;
          color: $textColorHex;
          font-family: 'CustomArabicFont', serif;
          font-size: ${fontSizeSp.toInt()}px;
          line-height: 1.8;
          text-align: right;
          direction: rtl;
        }
        tajweed.ikhafa, rule.ikhafa { color: #26BFFD; }
        tajweed.ghunnah, rule.ghunnah { color: #FF7E1E; }
        tajweed.qalaqah, rule.qalaqah { color: #DD0008; }
        tajweed.madda_normal, rule.madda_normal { color: #537FFF; }
        .end { color: #CBA135; font-weight: normal; margin: 0 4px; }
        </style>
        </head>
        <body>$cleanHtml</body>
        </html>
        """.trimIndent()
    }

    // 3. Render via transparent, scroll-disabled WebView
    AndroidView(
        modifier = modifier,
        factory = { ctx ->
            WebView(ctx).apply {
                setBackgroundColor(AndroidColor.TRANSPARENT)
                isVerticalScrollBarEnabled = false
                isHorizontalScrollBarEnabled = false
                settings.apply {
                    allowFileAccess = true
                    allowContentAccess = true
                }
                webViewClient = WebViewClient()
            }
        },
        update = { webView ->
            webView.loadDataWithBaseURL("file:///android_asset/", pageData, "text/html", "utf-8", null)
        }
    )
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Dynamic Font Asset Mapping

To ensure that font selections in the app settings (e.g. Scheherazade New, Amiri Quran, KFGQPC Hafs, Uthman Taha Naskh, Noto Naskh) apply dynamically to TajweedHtmlView, we mapped settings font names directly to asset .ttf files:

fun getArabicFontFileName(name: String): String {
    return when (name.trim().lowercase()) {
        "kfgqpc-hafs", "kfgqpc hafs" -> "kfgqpc_hafs.ttf"
        "uthman-taha-naskh", "uthman taha naskh" -> "uthman_taha_naskh.ttf"
        "amiri-quran", "amiri quran" -> "amiri_regular.ttf"
        "noto-naskh-arabic", "noto naskh arabic" -> "noto_regular.ttf"
        "scheherazade-new", "scheherazade new" -> "scheherazade_regular.ttf"
        else -> "scheherazade_regular.ttf"
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Targeted Unicode Cleaning & Single End-Marker Formatting

To eliminate duplicate Ayah markers and empty gold medallions (۝), we created a unified HTML builder function:

fun formatArabicDigits(number: Int): String {
    val arabicDigits = charArrayOf('٠', '١', '٢', '٣', '٤', '٥', '٦', '٧', '٨', '٩')
    return number.toString().map { arabicDigits[it - '0'] }.joinToString("")
}

fun formatCleanEndMarker(endWord: WordEntity?, verseNumber: Int): String {
    val digits = if (endWord != null) {
        val raw = endWord.textUthmani ?: endWord.textQpcHafs ?: ""
        val cleaned = raw.replace("﴿", "").replace("﴾", "").replace("{", "").replace("}", "").replace("\u06dd", "").trim()
        if (cleaned.isNotBlank()) {
            cleaned.toIntOrNull()?.let { formatArabicDigits(it) } ?: cleaned
        } else {
            formatArabicDigits(verseNumber)
        }
    } else {
        formatArabicDigits(verseNumber)
    }
    return " <span class='end'>$digits</span>"
}

fun buildCleanVerseTajweedHtml(fullVerseHtml: String?, words: List<WordEntity>, verseNumber: Int): String {
    val endWord = words.firstOrNull { it.charTypeName == "end" }
    val cleanEndMarker = formatCleanEndMarker(endWord, verseNumber)

    // Strip pre-existing u06DD (empty medallion), orphan zeroes, and old end spans
    val baseHtml = if (!fullVerseHtml.isNullOrBlank()) {
        fullVerseHtml
            .replace("\u06dd", "")
            .replace("[\u06df\u06e0\u06e2\u06ea-\u06ec\u25cc]".toRegex(), "")
            .replace("<(span|tajweed|rule)\\s+class=['\"]?end['\"]?>.*?</(span|tajweed|rule)>".toRegex(), "")
            .replace("﴿.*?﴾".toRegex(), "")
    } else {
        words.filter { it.charTypeName != "end" }.joinToString(" ") { word ->
            word.textUthmaniTajweed ?: (word.textUthmani ?: "")
        }
            .replace("\u06dd", "")
            .replace("[\u06df\u06e0\u06e2\u06ea-\u06ec\u25cc]".toRegex(), "")
    }

    // Attach exactly ONE clean end marker
    return baseHtml.trim() + cleanEndMarker
}
Enter fullscreen mode Exit fullscreen mode

4. Results & Summary

With this architecture in place:

  1. 100% Intact Arabic Cursive Ligatures: Sub-character Tajweed coloring now works with zero letter fragmentation or distortion.
  2. Zero Dotted Circles: Stripping orphan placeholders (\u06DF, \u25CC) eliminated all fallback engine dotted circles.
  3. Full Waqf Preservation: All 7 Quranic stoppage signs (ۖ, ۗ, ۘ, ۙ, ۚ, ۛ, ۜ) remain intact in their authentic positions.
  4. Clean Single End Markers: Every Ayah displays exactly one gold verse number (e.g. ١, ٢, ١١٢) without duplicate brackets or empty medallions.
  5. Dynamic Font Customization: Changing the Arabic font in settings instantly updates both Tajweed and non-Tajweed modes across the entire app.

Key Takeaway for Android Developers

When working with complex scripts (Arabic, Persian, Urdu, Devanagari) in Android:

  • Never apply mid-word SpanStyle boundaries in native Compose Text if your design relies on OpenType ligatures.
  • For sub-character typography styling, leverage WebKit cross-node font shaping via lightweight HTML components (AndroidView + WebView).
  • Be vigilant with regex text replacement: never strip Unicode ranges indiscriminately without auditing orthographic diacritics and script marks.

Top comments (0)