I am trying to implement reading annotations for t...
# compose
k
I am trying to implement reading annotations for text in CJK scripts, where each character has a reading associated with it. This can be done in HTML using the
ruby
tag. I am looking for an alternative solution for Compose. There are some edgecases that I have problems implementing. I will give a short overview and then my current code inside the thread.
There are three ways to render ruby annotations. The first picture depicts them all. I am mainly interested in the "jukugo" case, where the reading is spread across all the characters. The third case is also interesting for hiding readings that one might know. This seems to be a mixed case of mono and jukugo in practice. Line breaks are also complicated, in that the reading of a character has to stay with said character if the word is split across two lines. The image shows common line breaking scenarios, where only the first one is "valid". The third one looks right, but it leaves an empty spot on the first line, which is usually undesired. Currently, my code works by defining a data class that associates a text with a reading. And I use a
Column
that holds two
Text
components stacked vertically. That way I can do mono and jukogo variants, but it requires me to manually parse a sentence into words.
Copy code
@Composable
fun TextWithReading(
    textContent: List<TextData>,
    showReadings: Boolean = false,
) {
    val (text, inlineContent) = remember(textContent) {
        calculateAnnotatedString(textContent = textContent, showReadings = showReadings)
    }

    Text(text = text, inlineContent = inlineContent)
}

fun calculateAnnotatedString(textContent: List<TextData>, showReadings: Boolean):
        Pair<AnnotatedString, Map<String, InlineTextContent>> {
    val inlineContent = mutableMapOf<String, InlineTextContent>()

    return buildAnnotatedString {
        for (elem in textContent) {
            val text = elem.text
            val reading = elem.reading

            // If there is not reading available, simply add the text and move to the next element.
            if (reading == null) {
                append(text)
                continue
            }

            // Words larger than one character/kanji need a small amount of additional space in their
            // x-dimension.
            val width = (text.length.toDouble() + (text.length - 1) * 0.05).em
            appendInlineContent(text, text)
            inlineContent[text] = InlineTextContent(
                // TODO: find out why height and width need magic numbers.
                placeholder = Placeholder(
                    width = width,
                    height = 1.97.em,
                    placeholderVerticalAlign = PlaceholderVerticalAlign.Bottom,
                ),
                children = {
                    val readingFontSize = LocalTextStyle.current.fontSize / 2
                    val boxHeight = with(LocalDensity.current) { readingFontSize.toDp() }

                    Column(
                        modifier = Modifier.fillMaxHeight(),
                        horizontalAlignment = Alignment.CenterHorizontally,
                        verticalArrangement = Arrangement.Bottom,
                    ) {
                        Box(modifier = Modifier.requiredHeight(boxHeight + 3.dp)) {
                            if (showReadings) {
                                Text(
                                    modifier = Modifier.wrapContentWidth(unbounded = true),
                                    text = reading,
                                    style = TextStyle.Default.copy(fontSize = readingFontSize)
                                )
                            }
                        }
                        Text(text = text)
                    }
                }
            )
        }
    } to inlineContent
}

@Preview
@Composable
internal fun PreviewTextWithReading() {
    val textContent = listOf(
        TextData(text = "このルールを"),
        TextData(text = "守", reading = "まも"),
        TextData(text = "るらない"),
        TextData(text = "人", reading = "ひと"),
        TextData(text = "は"),
        TextData(text = "旅行", reading = "りょこう"),
        TextData(text = "ができなくなることもあります。"),
    )

    MaterialTheme {
        TextWithReading(textContent = textContent, showReadings = true)
    }
}
I would really like to take advantage of Compose's new Text splitting API: https://developer.android.com/develop/ui/compose/text/style-paragraph#cjk-considerations Compose already knows the rules for splitting text into usable chunks. Is there any way to hook into this? I don't see how I can make this work without access to the text layout engine. Given that, I assume I have to use SubcomposeLayout? Is it possible to create the mono and jukogo variants without subcompose?