logo
/codeiva
Mobile Development

How We Fixed Arabic Diacritic Rendering in a Flutter Quran App (Without WebView)

Building a Quran app in Flutter? If your Arabic text looks broken — overlapping diacritics, misformed letters, or tasydid stacking incorrectly — this is the article for you. We spent weeks debugging this issue across RichText, WebView, and native Platform Views before finding a clean solution using Flutter's own Skia canvas. Here's everything we learned.

March 6, 2026
Admin
How We Fixed Arabic Diacritic Rendering in a Flutter Quran App (Without WebView)

If you have ever tried to render fully-voweled Arabic text in Flutter — especially with per-character color styling for tajweed — you have almost certainly hit a wall. Letters look wrong. Diacritics overlap. Tasydid and fathah collide into a mess instead of stacking cleanly.

We ran into this while building a Quran application that required tajweed coloring: highlighting individual segments of each verse in different colors based on their recitation rule. What seemed like a simple styling task turned into a weeks-long debugging journey through Flutter internals, browser rendering pipelines, and native Android text engines.

This article documents the full journey — what we tried, why each approach failed, and the solution that finally worked.


What Is Tajweed Coloring and Why Is It Hard?

Tajweed coloring means assigning different colors to specific character ranges within an Arabic verse. For example, a nasal sound (ghunnah) might be colored green, while a prolonged vowel (madd) is colored blue.

This requires two things to work at the same time:

  • Correct Arabic shaping — ligatures, contextual letter forms, and diacritic stacking must all render accurately.
  • Per-segment coloring — specific character ranges must be colored independently.

These two requirements are fundamentally at odds with how most text rendering systems work.


Why Flutter's RichText Fails for Complex Arabic Text

Flutter's standard approach to colored text is RichText with multiple TextSpan children, each with a different color. This works perfectly for Latin text. For complex Arabic, it breaks.

The Root Cause: Text Run Splitting

Flutter's text layout engine — sitting between the widget layer and the underlying HarfBuzz shaping engine — splits the text string into separate runs at every TextSpan boundary before shaping begins.

HarfBuzz needs the full string as context to correctly:

  • Select the right contextual glyph form for each letter (initial, medial, final, isolated)
  • Apply GPOS (Glyph Positioning) table rules for diacritic stacking

When the string is cut into fragments at span boundaries, each fragment is shaped in isolation. Letters lose their neighbors. Diacritics lose their positioning context. The result is overlapping marks, incorrect letter forms, and broken ligatures.

This Is Not a Font Bug

We initially suspected the font (LPMQ Asep Misbah) was the culprit. We switched to Uthmanic Hafs, then to several other Arabic fonts. All of them exhibited the same errors. The problem is not in the font — it is in the engine layer above the shaper.


Approaches We Tried (And Why They Failed)

1. flutter_widget_from_html

Our first instinct was to try a package that renders HTML. Surely HTML coloring would work correctly?

It did not. flutter_widget_from_html uses Flutter's own text engine under the hood for rendering. Same split, same broken shaping.

2. WebView

The WebView approach actually produced visually correct Arabic. Chromium's Blink engine shapes the full string before applying color — CSS color on a <span> does not split the text run for shaping purposes. The output was indistinguishable from a printed mushaf.

However, WebView came with severe costs:

  • GPU surface crashes. Inside a shrinkWrap ListView, WebView instances received unbounded height constraints. Chromium attempted to allocate GPU surfaces over 30,000 pixels tall, causing SIGTRAP crashes.
  • Memory. Each WebView instance is a Chromium renderer process: 20–50 MB of RAM per verse. With hundreds of verses per chapter, this was completely unsustainable.
  • Scroll race conditions. Rapid scrolling created races between visibility callbacks and JavaScript height-reporting channels, causing additional crashes.

WebView proved the rendering was solvable — but not via WebView itself.

3. Android Platform View with SpannableString

Native TextView with SpannableString was the next candidate. The OS HarfBuzz pipeline is theoretically equivalent to the browser's.

Testing a plain verse with no color: rendering was perfect.

Adding ForegroundColorSpan instances for tajweed: Android's text layout engine split the string at span boundaries before shaping — identical to Flutter's TextSpan problem. The nun was misformed. Diacritics were wrong.

The Pattern

Every approach that applies color by splitting the string fails for complex Arabic. Flutter TextSpan, Android SpannableString, iOS NSAttributedString — all suffer from the same structural limitation. The string must remain intact through the shaping stage.


The Solution: Skia Canvas with clipRect Overdraw

The Core Idea

Instead of splitting the string to apply color, we shape it once and then paint color at the pixel level.

The algorithm:

  1. Build the full Arabic string as a single dart:ui Paragraph with the default text color.
  2. Lay out and draw it to the canvas — HarfBuzz shapes the full string in one pass. Shaping is correct.
  3. For each tajweed segment, call paragraph.getBoxesForRange() to get the precise glyph bounding boxes for that character range.
  4. Build an identical Paragraph with the segment's tajweed color.
  5. For each bounding box: canvas.save(), canvas.clipRect(box), draw the colored paragraph, canvas.restore().

The string is never split. The clipRect technique reuses the fully-shaped paragraph and simply masks which pixels are visible for each color pass.

Why This Matches WebView Quality

Both Chromium and this canvas solution ultimately call Skia. The difference is that neither splits the string before shaping. WebView colors at the CSS pixel level; this solution colors at the Skia clipRect level. The shaping result is identical.

The Code

dart

void paint(Canvas canvas, Size size) {
// Step 1 & 2: Shape the full string once — correct GPOS, correct ligatures
final base = buildParagraph(size.width, defaultColor);
canvas.drawParagraph(base, Offset.zero);

for (final seg in segments) {
// Step 3: Get accurate glyph boxes for this segment
final boxes = base.getBoxesForRange(
seg.start, seg.end,
boxHeightStyle: ui.BoxHeightStyle.max,
boxWidthStyle: ui.BoxWidthStyle.tight,
);

// Step 4: Build same paragraph with segment color
final colored = buildParagraph(size.width, seg.color);

// Step 5: Overdraw within each box
for (final box in boxes) {
canvas.save();
canvas.clipRect(box.toRect());
canvas.drawParagraph(colored, Offset.zero);
canvas.restore();
}
}
}

Tap Detection Comes Free

getBoxesForRange() returns actual glyph geometry. Storing these rects and comparing them against GestureDetector.onTapDown positions gives accurate tap hit-testing that respects RTL layout — no extra geometry math needed.

Height Reporting

CustomPainter does not have intrinsic size. After drawing, read paragraph.height and report it via a callback. The parent StatefulWidget updates its SizedBox height on the next frame via addPostFrameCallback, avoiding layout thrashing.


Performance Comparison

Approach

Memory per Verse

Crash Risk

Shaping

RichText + TextSpan

~1 MB

None

❌ Broken

WebView

20–50 MB

High (GPU surface)

✅ Correct

Platform View

~2 MB

Low

❌ Broken

Skia Canvas

~1 MB

None

✅ Correct

The canvas approach rebuilds one Paragraph per enabled tajweed segment per frame when state changes. For a typical verse with 3–6 segments, this is 4–7 Paragraph allocations per paint — well within Flutter's 16ms frame budget.


Key Takeaways

For any Flutter developer working with complex Arabic text:

  • RichText + TextSpan with multiple colors will break Arabic shaping. This is not a bug you can work around at the widget level.
  • The fix is not to use a different font, a different package, or a native view. The fix is to keep the string intact through shaping.
  • dart:ui ParagraphBuilder gives you direct access to the shaping layer. Use it.
  • Paragraph.getBoxesForRange() is the missing link — it converts character positions into pixel geometry that you can use for both rendering and interaction.

The mental model that unlocks the solution: a browser does not color text by splitting it into runs. It shapes the full string, then selectively paints pixels. Replicate that behavior in Flutter using clipRect.


Further Reading


Have you run into Arabic rendering issues in Flutter? The approach described here should work for any complex RTL script that relies on OpenType GPOS for correct glyph positioning — not just Quran applications.

Want to Work With Us?

Let's create something amazing together!

Get In Touch