kotlinandroid-jetpack-composeandroid-compose-textfieldandroid-jetpack-compose-material3letter-spacing

Jetpack Compose lacks letter spacing for Persian/Arabic text


Jetpack Compose Text Composable features a letterSpacing parameter, which according to the documentation is described as:

the amount of space to add between each letter

However, in English scripts like many Latin/Roman scripts letters are not joined together and are separate.

english text with 5sp letter spacing

@Preview(showBackground = true)
@Composable
fun SomeText() {
    Text(
        text = "Hello there!",
        letterSpacing = 5.sp
    )
}

In Persian/Arabic scripts sometimes letters are joined together and letters take different shapes according to the place they are in the word, so using the letterSpacing parameter should do the same however somehow Jetpack Compose cannot make letters far from each other when letterSpacing is increased instead it increases the distance between the words which is an unwanted behavior and even a bug in my opinion. In this example, I even choose words that mostly have separated letters but still, Jetpack Compose cannot detect them as letters.

This is a three-word sentence meaning, Greetings from Iran.

persian text with 5.sp letter spacing

@Preview(showBackground = true)
@Composable
fun SomeText() {
    Text(
        text = "درود از ایران",
        letterSpacing = 5.sp
    )
}

The same text with more letter spacing, however, it's converted to word spacing.

persian text with 50 sp letter spacing

@Preview(showBackground = true)
@Composable
fun SomeText() {
    Text(
        text = "درود از ایران",
        letterSpacing = 50.sp
    )
}

You may ask what's the correct/desired behavior. I can show you it using Photoshop.

Zero letter spacing in Photoshop persian text with zero letter spacing in photoshop

200-letter spacing in Photoshop persian text with 200 letter spacing in photoshop

So the question is how to overcome this problem and add true letter spacing for Persian/Arabic texts.

Related article by w3


Solution

  • I tried reporting this as a bug to Google's issue tracker but it was ignored and responded to me that it's intended behavior by referencing W3 standards and they don't fix it since this behavior is the same for Text views, however, in the same link to the W3 standards there are two appropriate examples required by them but Android's team choose the simpler example to implement.

    w3 standards

    w3 note

    I understand this is an edge case and even W3 standards indicated that but it can be implemented. So I implemented it by adding U+0640 ـ ARABIC TATWEEL between certain characters by demand of the letterSpacing parameter, the result and code are not perfect but it works.

    @Composable
    fun LetterSpacedPersianText(
        text: String,
        modifier: Modifier = Modifier,
        color: Color = Color.Unspecified,
        fontSize: TextUnit = 14.sp,
        fontStyle: FontStyle? = null,
        fontWeight: FontWeight? = null,
        fontFamily: FontFamily? = null,
        letterSpacing: TextUnit = TextUnit.Unspecified,
        textDecoration: TextDecoration? = null,
        textAlign: TextAlign? = null,
        lineHeight: TextUnit = TextUnit.Unspecified,
        overflow: TextOverflow = TextOverflow.Clip,
        softWrap: Boolean = true,
        maxLines: Int = Int.MAX_VALUE,
        onTextLayout: (TextLayoutResult) -> Unit = {},
        style: TextStyle = LocalTextStyle.current
    ) {
        val isPersian = remember { Regex("[ء-ی]") }
        val keshides = letterSpacing.value.toInt()
        val keshidesText = buildString { repeat(keshides) { append('ـ') } }
        val spacesText = buildAnnotatedString {
            repeat(keshides) {
                withStyle(style = SpanStyle(fontSize = fontSize.div(50 * letterSpacing.value))) {
                    append(' ')
                }
            }
        }
        if (isPersian.containsMatchIn(text)) {
            if (text.length != 1) {
                val totalCursive = "ئبپتثجچحخسشصضطظعغفقکگلمنهی"
                val finalCursive = "أؤدذرزژو"
                val newText = buildAnnotatedString {
                    var i = 0
                    while (i < text.length) {
                        val current = text[i]
                        append(current)
                        val next = text.getOrNull(i + 1)
                        if (next != null) {
                            if (totalCursive.contains(current) && totalCursive.contains(next) ||
                                totalCursive.contains(current) && finalCursive.contains(next)
                            ) {
                                append(keshidesText)
                            }
                            if (current !in totalCursive && current !in finalCursive && next !in totalCursive && next !in finalCursive) {
                                append(spacesText)
                            }
                        }
                        i++
                    }
                }
                Text(
                    text = newText,
                    modifier = modifier,
                    color = color,
                    fontSize = fontSize,
                    fontStyle = fontStyle,
                    fontWeight = fontWeight,
                    fontFamily = fontFamily,
                    letterSpacing = letterSpacing,
                    textDecoration = textDecoration,
                    textAlign = textAlign,
                    lineHeight = lineHeight,
                    overflow = overflow,
                    softWrap = softWrap,
                    maxLines = maxLines,
                    onTextLayout = onTextLayout,
                    style = style
                )
            } else {
                Text(
                    text = text,
                    modifier = modifier,
                    color = color,
                    fontSize = fontSize,
                    fontStyle = fontStyle,
                    fontWeight = fontWeight,
                    fontFamily = fontFamily,
                    letterSpacing = letterSpacing,
                    textDecoration = textDecoration,
                    textAlign = textAlign,
                    lineHeight = lineHeight,
                    overflow = overflow,
                    softWrap = softWrap,
                    maxLines = maxLines,
                    onTextLayout = onTextLayout,
                    style = style
                )
            }
        } else {
            Text(
                text = text,
                modifier = modifier,
                color = color,
                fontSize = fontSize,
                fontStyle = fontStyle,
                fontWeight = fontWeight,
                fontFamily = fontFamily,
                letterSpacing = letterSpacing,
                textDecoration = textDecoration,
                textAlign = textAlign,
                lineHeight = lineHeight,
                overflow = overflow,
                softWrap = softWrap,
                maxLines = maxLines,
                onTextLayout = onTextLayout,
                style = style
            )
        }
    }
    

    Example:

    persian text with 10 letter spacing

    @Preview(showBackground = true)
    @Composable
    fun SomeText() {
        LetterSpacedPersianText(
            text = "درود از ایران",
            letterSpacing = 10.sp
        )
    }
    

    persian text with 1 letter spacing

    @Preview(showBackground = true)
    @Composable
    fun SomeText() {
        LetterSpacedPersianText(
            text = "درود از ایران",
            letterSpacing = 1.sp
        )
    }
    

    more example

    @Preview(showBackground = true)
    @Composable
    fun SomeText() {
        LetterSpacedPersianText(
            text = "بسیار عالی",
            letterSpacing = 1.sp
        )
    }
    

    moar example

    @Preview(showBackground = true)
    @Composable
    fun SomeText() {
        LetterSpacedPersianText(
            text = "بسیار عالی",
            letterSpacing = 10.sp
        )
    }