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
shrinkWrapListView, 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:
- Build the full Arabic string as a single
dart:uiParagraphwith the default text color. - Lay out and draw it to the canvas — HarfBuzz shapes the full string in one pass. Shaping is correct.
- For each tajweed segment, call
paragraph.getBoxesForRange()to get the precise glyph bounding boxes for that character range. - Build an identical
Paragraphwith the segment's tajweed color. - 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+TextSpanwith 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:uiParagraphBuildergives 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
- Tarteel Engineering: From Page to Screen — how a production Quran app solved the same problem at scale
- DigitalKhatt — dynamic Arabic font justification and Skia canvas rendering
- Flutter dart:ui API —
Paragraph,ParagraphBuilder,getBoxesForRange - OpenType GPOS Specification — why diacritic positioning requires full string context
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.