compose-rust 0.0.4

Declarative UI in Rust, rendered by an AOT-compiled Compose Multiplatform engine.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
package dev.darkpyonix.composerust.foundation

import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.MultiParagraph
import androidx.compose.ui.text.MultiParagraphIntrinsics
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign as ComposeTextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow as ComposeOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import dev.darkpyonix.composerust.design.ResolvedTheme
import kotlin.math.ceil
import dev.darkpyonix.composerust.design.platformUiFamily
import dev.darkpyonix.composerust.protocol.ColorRole
import dev.darkpyonix.composerust.protocol.FontRef
import dev.darkpyonix.composerust.protocol.FontRefRecords
import dev.darkpyonix.composerust.protocol.GenericFamily
import dev.darkpyonix.composerust.protocol.OverflowWrap
import dev.darkpyonix.composerust.protocol.Paint
import dev.darkpyonix.composerust.protocol.PropertyKind
import dev.darkpyonix.composerust.protocol.PropertyValue
import dev.darkpyonix.composerust.protocol.SpanFontRecords
import dev.darkpyonix.composerust.protocol.TYPE_ROLE_NONE
import dev.darkpyonix.composerust.protocol.TextAlign
import dev.darkpyonix.composerust.protocol.TypeRole
import dev.darkpyonix.composerust.protocol.WordBreak
import dev.darkpyonix.composerust.ui.floatProp
import dev.darkpyonix.composerust.ui.intProp
import dev.darkpyonix.composerust.ui.maxLines
import dev.darkpyonix.composerust.ui.node.Asset
import dev.darkpyonix.composerust.ui.node.AssetCache
import dev.darkpyonix.composerust.ui.node.Node
import dev.darkpyonix.composerust.ui.node.systemFontFamily
import dev.darkpyonix.composerust.ui.overflow
import dev.darkpyonix.composerust.ui.paintProp
import dev.darkpyonix.composerust.ui.role
import dev.darkpyonix.composerust.ui.typeRole

/**
 * Everything that decides how a piece of text is set, wherever it came from.
 *
 * A `Text` node being drawn and a measure request being answered both become one of
 * these, and [resolveText] turns it into what Compose lays out. Measuring and drawing go
 * through that one function on purpose: a measured size is only worth having if it is the
 * size the same text turns out to be on screen, and two functions that each meant to do
 * the same thing would sooner or later disagree about a font, a scale or a break.
 */
internal class TextInput(
    val text: String,
    /** The runs, or empty for none. */
    val runs: List<TextRun>,
    /** The fonts runs name for themselves, by run index. */
    val runFonts: Map<Int, List<FontRef>>,
    /**
     * True for text with no rung of the ladder at all, which takes its size, weight,
     * line height, letter spacing and font from its own values and nothing from the
     * design system.
     */
    val noRole: Boolean,
    /** The rung, where the text named one. */
    val role: TypeRole?,
    /** The rung where the text named none and is not [noRole]. */
    val defaultRole: TypeRole = TypeRole.Body,
    val fontSize: Float? = null,
    val fontWeight: Int? = null,
    val italic: Boolean = false,
    val letterSpacing: Float? = null,
    val lineHeight: Float? = null,
    val maxLines: Int = Int.MAX_VALUE,
    val softWrap: Boolean = true,
    val tabSize: Int = DEFAULT_TAB_SIZE,
    val wordBreak: WordBreak? = null,
    val overflowWrap: OverflowWrap? = null,
    /** Sizes are CSS pixels, which the system's font scale does not enlarge. */
    val absoluteSize: Boolean = false,
    /** The fonts to try, for [noRole] text. Ignored where there is a role. */
    val fonts: List<FontRef> = emptyList(),
    /** Drawing only: neither changes a size. */
    val color: Paint? = null,
    val textAlign: TextAlign? = null,
    val overflow: ComposeOverflow = ComposeOverflow.Clip,
) {
    companion object {
        const val DEFAULT_TAB_SIZE = 8
        /** What text with no role is set at when it says nothing, as CSS's `medium`. */
        const val NO_ROLE_FONT_SIZE = 14f
        const val NO_ROLE_FONT_WEIGHT = 400
    }
}

/** What Compose is handed to lay the text out, drawn or measured. */
internal class ResolvedText(
    val text: AnnotatedString,
    val style: TextStyle,
    val softWrap: Boolean,
    val maxLines: Int,
    val overflow: ComposeOverflow,
    /** Where each tab sits and how far it advances. Empty where there are no tabs. */
    val placeholders: List<AnnotatedString.Range<Placeholder>>,
    /** The same placeholders, in the form `BasicText` takes them. */
    val inlineContent: Map<String, InlineTextContent>,
    /**
     * The text broken anywhere, for working out the narrowest it can be when a word too
     * long for its line may be broken inside. Null where that is the text itself.
     */
    val minContentText: AnnotatedString?,
    val minContentPlaceholders: List<AnnotatedString.Range<Placeholder>>,
    /** What was wrong with the runs, which were left out, or null where nothing was. */
    val runsProblem: String?,
    /** What was wrong with the fonts, one sentence each, for the caller to report. */
    val problems: List<String>,
)

/**
 * Turns a [TextInput] into what Compose lays out.
 *
 * [link] makes the annotation a run that is a link carries, for text that is drawn and
 * can be pressed. Measuring passes null: a link changes nothing about a size.
 */
internal fun resolveText(
    input: TextInput,
    theme: ResolvedTheme,
    assets: AssetCache,
    density: Density,
    fontFamilyResolver: FontFamily.Resolver,
    link: ((TextRun) -> LinkAnnotation.Clickable)? = null,
    /**
     * Leaves out what only paints: the colour. Nothing about a size depends on it, and
     * resolving a colour through the design system is the dearest thing done here.
     */
    forMeasure: Boolean = false,
): ResolvedText {
    val problems = mutableListOf<String>()
    // Sizes are sp. Text in CSS pixels is not given smaller sp to undo the font scale:
    // it is set in a density whose font scale is one (see [textDensity]), which is the only
    // way that stays right where the font scale is not linear.
    fun sized(value: Float): TextUnit = value.sp

    val style = if (input.noRole) {
        TextStyle(
            fontSize = sized(input.fontSize ?: TextInput.NO_ROLE_FONT_SIZE),
            fontWeight = FontWeight(input.fontWeight ?: TextInput.NO_ROLE_FONT_WEIGHT),
            lineHeight = input.lineHeight?.let(::sized) ?: TextUnit.Unspecified,
            letterSpacing = input.letterSpacing?.let(::sized) ?: TextUnit.Unspecified,
            fontFamily = resolveFontFamily(input.fonts, assets, problems),
        )
    } else {
        val role = input.role ?: input.defaultRole
        val token = theme.type(role)
        TextStyle(
            fontSize = sized(input.fontSize ?: token.size),
            fontWeight = input.fontWeight?.let { FontWeight(it) } ?: FontWeight(token.weight),
            lineHeight = sized(input.lineHeight ?: token.lineHeight),
            letterSpacing = sized(input.letterSpacing ?: token.letterSpacing),
            // A font named for text that has a role is ignored: the theme decides what a
            // rung is written in, and one node cannot say otherwise.
            fontFamily = theme.family(role),
        )
    }.merge(
        TextStyle(
            color = when {
                forMeasure -> androidx.compose.ui.graphics.Color.Unspecified
                else -> input.color?.let(theme::color) ?: theme.color(ColorRole.OnSurface)
            },
            fontStyle = if (input.italic) FontStyle.Italic else null,
            textAlign = when (input.textAlign) {
                TextAlign.Start -> ComposeTextAlign.Start
                TextAlign.Center -> ComposeTextAlign.Center
                TextAlign.End -> ComposeTextAlign.End
                TextAlign.Justify -> ComposeTextAlign.Justify
                null -> ComposeTextAlign.Unspecified
            },
        ),
    )

    val badRuns = if (input.runs.isEmpty()) null else runsProblem(input.runs, input.text.encodeToByteArray().size)
    val runs = if (badRuns == null) input.runs else emptyList()
    val mode = when (input.wordBreak) {
        WordBreak.KeepAll -> BreakMode.KeepAll
        WordBreak.BreakAll -> BreakMode.BreakAll
        WordBreak.Normal, null -> BreakMode.Normal
    }
    val text = annotate(input, runs, mode, theme, assets, density, ::sized, link, problems)
    val (placeholders, inline) = tabStops(text, style, input.tabSize, density, fontFamilyResolver)

    // Breaking anywhere only matters to the narrowest width: laid out at any width, a word
    // too long for its line is broken inside it already. So the text broken anywhere is
    // made only for that question, and only where the answer differs.
    val anywhere = input.overflowWrap == OverflowWrap.Anywhere && mode != BreakMode.BreakAll
    val minContent = if (anywhere) {
        annotate(input, runs, BreakMode.BreakAll, theme, assets, density, ::sized, null, mutableListOf())
    } else {
        null
    }
    val minContentPlaceholders = minContent
        ?.let { tabStops(it, style, input.tabSize, density, fontFamilyResolver).first }
        ?: emptyList()

    return ResolvedText(
        text = text,
        style = style,
        softWrap = input.softWrap,
        maxLines = input.maxLines,
        overflow = input.overflow,
        placeholders = placeholders,
        inlineContent = inline,
        minContentText = minContent,
        minContentPlaceholders = minContentPlaceholders,
        runsProblem = badRuns,
        problems = problems,
    )
}

/**
 * The density a text is set in: the one it is drawn or measured under, with the font scale
 * taken out for text in CSS pixels, which the system's text size does not enlarge.
 */
internal fun textDensity(input: TextInput, density: Density): Density =
    if (input.absoluteSize && density.fontScale != 1f) Density(density.density, 1f) else density

/**
 * The first candidate that resolves, as a CSS font list is read.
 *
 * A registered font asset that is not there is reported and passed over. An installed
 * family is asked for by name, and a name the machine does not have is passed over without
 * a report, because a list that names fonts it may not find is what a font list is for.
 * Nothing resolving is a sans serif.
 */
internal fun resolveFontFamily(
    refs: List<FontRef>,
    assets: AssetCache,
    problems: MutableList<String>,
): FontFamily {
    for (ref in refs) {
        when (ref) {
            is FontRef.Asset -> {
                val font = assets.asset(ref.assetId) as? Asset.Font
                if (font != null) return font.family
                problems += "font asset ${ref.assetId} is not a registered font; the next font in the list is used"
            }
            is FontRef.System -> systemFontFamily(ref.name)?.let { return it }
            is FontRef.Generic -> return genericFontFamily(ref.family)
        }
    }
    return FontFamily.SansSerif
}

/** Which installed family each generic name is, which is the platform's answer. */
internal fun genericFontFamily(family: GenericFamily): FontFamily = when (family) {
    GenericFamily.SystemUi -> platformUiFamily
    GenericFamily.SansSerif -> FontFamily.SansSerif
    GenericFamily.Serif -> FontFamily.Serif
    GenericFamily.Monospace -> FontFamily.Monospace
}

/** Where lines may break, beyond where the language itself allows. */
internal enum class BreakMode { Normal, KeepAll, BreakAll }

/** Keeps two letters on one line: CSS `keep-all` between letters of a CJK word. */
private const val WORD_JOINER = '⁠'

/** Lets a line break between two letters: CSS `break-all`. */
private const val ZERO_WIDTH_SPACE = '​'

/**
 * The text with invisible characters put where a break is forbidden or allowed, and where
 * each of the original characters ended up. Both characters are default ignorable, so
 * they take no width and are never drawn: they only change where a line can end.
 *
 * The platform's own line breaking is what decides everything else. It breaks Korean
 * between syllables, which is CSS `normal`, so `keep-all` is the one that has to be asked
 * for, and it differs from platform to platform in ways that this does not.
 */
internal class BrokenText(val text: String, private val map: IntArray?) {
    /** Where character [index] of the original string is in [text]. */
    fun position(index: Int): Int = map?.get(index) ?: index
}

internal fun breakText(text: String, mode: BreakMode): BrokenText {
    if (mode == BreakMode.Normal || text.length < 2) return BrokenText(text, null)
    val out = StringBuilder(text.length + text.length / 2)
    val map = IntArray(text.length + 1)
    for (index in text.indices) {
        map[index] = out.length
        val current = text[index]
        out.append(current)
        val next = text.getOrNull(index + 1) ?: continue
        val insert = when (mode) {
            BreakMode.KeepAll -> letter(current) && letter(next) && (cjk(current) || cjk(next))
            BreakMode.BreakAll -> letter(current) && letter(next) && !joinedJamo(current, next)
            BreakMode.Normal -> false
        }
        if (insert) out.append(if (mode == BreakMode.KeepAll) WORD_JOINER else ZERO_WIDTH_SPACE)
    }
    map[text.length] = out.length
    return BrokenText(out.toString(), map)
}

/** A letter or digit, whole: half of a surrogate pair is never split from the other. */
private fun letter(char: Char): Boolean = !char.isSurrogate() && char.isLetterOrDigit()

/** Hangul, the CJK ideographs, and the two kana. */
private fun cjk(char: Char): Boolean {
    val code = char.code
    return code in 0xAC00..0xD7A3 || code in 0x1100..0x11FF || code in 0x3130..0x318F ||
        code in 0x4E00..0x9FFF || code in 0x3400..0x4DBF || code in 0x3040..0x30FF
}

/** Conjoining jamo that make one syllable together, which a break would tear apart. */
private fun joinedJamo(first: Char, second: Char): Boolean =
    first.code in 0x1100..0x11FF && second.code in 0x1100..0x11FF

/** The text and its runs, with the breaks [mode] asks for, as one annotated string. */
private fun annotate(
    input: TextInput,
    runs: List<TextRun>,
    mode: BreakMode,
    theme: ResolvedTheme,
    assets: AssetCache,
    density: Density,
    sized: (Float) -> TextUnit,
    link: ((TextRun) -> LinkAnnotation.Clickable)?,
    problems: MutableList<String>,
): AnnotatedString {
    val broken = breakText(input.text, mode)
    val hasTabs = broken.text.indexOf('\t') >= 0
    if (runs.isEmpty() && !hasTabs) return AnnotatedString(broken.text)
    val utf8 = if (runs.isEmpty()) null else input.text.encodeToByteArray()
    return buildAnnotatedString {
        // Tabs become placeholders, each standing in place of its own character, so the
        // string keeps its length and every run still covers what it covered.
        var tab = 0
        for (char in broken.text) {
            if (char == '\t') {
                appendInlineContent(tabId(tab++), "\t")
            } else {
                append(char)
            }
        }
        if (utf8 == null) return@buildAnnotatedString
        runs.forEachIndexed { index, run ->
            val from = broken.position(utf8.decodeToString(0, run.start).length)
            val to = broken.position(utf8.decodeToString(0, run.start + run.length).length)
            // A run of text with no role may name its own font. Where the text has a
            // role, or the run does, the font is the theme's.
            val family = input.runFonts[index]
                ?.takeIf { input.noRole && run.role == null }
                ?.let { resolveFontFamily(it, assets, problems) }
            val style = SpanStyle(
                color = run.color?.let(theme::color) ?: androidx.compose.ui.graphics.Color.Unspecified,
                // Behind this run's letters only, line by line where it wraps. The line's
                // own background is the node's, and this is the part of it that changed.
                background = runBackground(run, theme),
                fontWeight = if (run.bold) FontWeight.Bold else null,
                fontStyle = if (run.italic) FontStyle.Italic else null,
                fontSize = run.role?.let { sized(theme.type(it).size) } ?: TextUnit.Unspecified,
                fontFamily = family,
                textDecoration = when {
                    run.underline && run.strikethrough ->
                        TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough))
                    run.underline -> TextDecoration.Underline
                    run.strikethrough -> TextDecoration.LineThrough
                    else -> null
                },
            )
            addStyle(style, from, to)
            if (link != null && run.handlerId != 0L) addLink(link(run), from, to)
        }
    }
}

private fun tabId(index: Int): String = "compose-rust-tab-$index"

/**
 * One placeholder per tab, as wide as it takes to reach the next tab stop.
 *
 * The stops are [tabSize] spaces of the text's own font apart, as CSS `tab-size` puts
 * them, and a tab advances to the next one after whatever comes before it on its line. A
 * tab that would advance less than half a space goes on to the stop after, as CSS has it.
 * Only the side that measures the letters can work this out, which is why a Host cannot
 * expand tabs into spaces in a proportional font.
 *
 * "Its line" is the line the text starts it on, counted from the last newline. Text that
 * wraps before a tab puts the tab's stops where the unwrapped line would have them, which
 * is right for `pre` and as close as a layout that has not happened yet can get otherwise.
 */
private fun tabStops(
    text: AnnotatedString,
    style: TextStyle,
    tabSize: Int,
    density: Density,
    fontFamilyResolver: FontFamily.Resolver,
): Pair<List<AnnotatedString.Range<Placeholder>>, Map<String, InlineTextContent>> {
    if (text.text.indexOf('\t') < 0) return emptyList<AnnotatedString.Range<Placeholder>>() to emptyMap()
    // Positions inside a laid out line rather than intrinsic widths: an intrinsic width is
    // rounded up to a whole pixel and leaves out trailing spaces, and either would move every
    // stop after it.
    val space = startOf(AnnotatedString("  "), 1, emptyList(), style, density, fontFamilyResolver)
    val stop = space * (if (tabSize > 0) tabSize else TextInput.DEFAULT_TAB_SIZE)
    val placeholders = mutableListOf<AnnotatedString.Range<Placeholder>>()
    val inline = mutableMapOf<String, InlineTextContent>()
    var tab = 0
    for (index in text.text.indices) {
        if (text.text[index] != '\t') continue
        val lineStart = text.text.lastIndexOf('\n', index - 1) + 1
        val before = placeholders
            .filter { it.start >= lineStart }
            .map { AnnotatedString.Range(it.item, it.start - lineStart, it.end - lineStart) }
        val prefix = startOf(
            text.subSequence(lineStart, index + 1),
            index - lineStart,
            before,
            style,
            density,
            fontFamilyResolver,
        )
        var advance = if (stop > 0f) stop - (prefix % stop) else space
        if (advance < space / 2f) advance += stop
        val placeholder = Placeholder(
            width = with(density) { advance.toSp() },
            height = 0.sp,
            placeholderVerticalAlign = PlaceholderVerticalAlign.AboveBaseline,
        )
        placeholders += AnnotatedString.Range(placeholder, index, index + 1)
        inline[tabId(tab++)] = InlineTextContent(placeholder) {}
    }
    return placeholders to inline
}

/** Where character [offset] of [text] starts, laid out on one line. */
private fun startOf(
    text: AnnotatedString,
    offset: Int,
    placeholders: List<AnnotatedString.Range<Placeholder>>,
    style: TextStyle,
    density: Density,
    fontFamilyResolver: FontFamily.Resolver,
): Float {
    if (offset <= 0) return 0f
    val intrinsics = MultiParagraphIntrinsics(text, style, placeholders, density, fontFamilyResolver)
    val paragraph = MultiParagraph(
        intrinsics,
        Constraints.fitPrioritizingWidth(0, ceil(intrinsics.maxIntrinsicWidth).toInt() + 1, 0, Constraints.Infinity),
    )
    return paragraph.getHorizontalPosition(offset, usePrimaryDirection = true)
}

/** What a `Text` node says about how it is set, read off its properties. */
internal fun Node.textInput(): TextInput {
    val blob = (property(PropertyKind.Spans) as? PropertyValue.Bytes)?.value
    val decoded = blob?.let(::decodeRuns)
    return TextInput(
        text = text(PropertyKind.Text),
        runs = decoded ?: emptyList(),
        runFonts = bytes(PropertyKind.SpanFonts)?.let(SpanFontRecords::decodeBlob) ?: emptyMap(),
        noRole = (property(PropertyKind.TypeRole) as? PropertyValue.Integer)?.value == TYPE_ROLE_NONE.toLong(),
        role = typeRole(),
        fontSize = floatProp(PropertyKind.FontSize),
        fontWeight = intProp(PropertyKind.FontWeight)?.toInt(),
        letterSpacing = floatProp(PropertyKind.LetterSpacing),
        lineHeight = floatProp(PropertyKind.LineHeight),
        maxLines = maxLines(),
        softWrap = (property(PropertyKind.SoftWrap) as? PropertyValue.Bool)?.value != false,
        tabSize = intProp(PropertyKind.TabSize)?.toInt() ?: TextInput.DEFAULT_TAB_SIZE,
        wordBreak = role(PropertyKind.WordBreak, WordBreak.entries.toTypedArray()),
        overflowWrap = role(PropertyKind.OverflowWrap, OverflowWrap.entries.toTypedArray()),
        absoluteSize = when (val value = property(PropertyKind.AbsoluteSize)) {
            is PropertyValue.Bool -> value.value
            is PropertyValue.Integer -> value.value != 0L
            else -> false
        },
        fonts = bytes(PropertyKind.Font)?.let(FontRefRecords::decodeBlob) ?: emptyList(),
        color = paintProp(PropertyKind.Color),
        textAlign = role(PropertyKind.TextAlign, TextAlign.entries.toTypedArray()),
        overflow = overflow(),
    )
}

/** What is malformed about a `Text` node's run list, or null where nothing is. */
internal fun Node.runsDecodeProblem(): String? {
    val spans = (property(PropertyKind.Spans) as? PropertyValue.Bytes)?.value
    if (spans != null && decodeRuns(spans) == null) {
        return "the run list is not a whole number of records"
    }
    bytes(PropertyKind.SpanFonts)?.let {
        if (SpanFontRecords.decodeBlob(it) == null) return "the run font table does not decode"
    }
    return null
}

/** What is malformed about a `Text` node's font list, or null where nothing is. */
internal fun Node.fontListProblem(): String? {
    bytes(PropertyKind.Font)?.let {
        if (FontRefRecords.decodeBlob(it) == null) return "the font list does not decode"
    }
    return null
}