From 3ae2367391516c4232882a147628b5227cb7a2ff Mon Sep 17 00:00:00 2001 From: Ji Sungbin Date: Mon, 1 Jul 2024 23:39:14 +0900 Subject: [PATCH] Adds FootnoteModifier and ClickableModifier --- .../sungbin/markdown/runtime/MarkdownNode.kt | 9 +- .../land/sungbin/markdown/MarkdownNodeTest.kt | 8 - markdown-ui/build.gradle.kts | 3 + .../land/sungbin/markdown/ui/list/List.kt | 1 - .../markdown/ui/modifier/ClickableModifier.kt | 39 ++++- .../markdown/ui/modifier/FootnoteModifier.kt | 32 +++- .../sungbin/markdown/ui/modifier/Modifier.kt | 56 +++++-- .../markdown/ui/text/AnnotatedString.kt | 5 +- .../land/sungbin/markdown/ui/text/Text.kt | 17 +- .../ui/modifier/ClickableTransformerTest.kt | 30 ++++ .../ui/modifier/FootnoteTransformerTest.kt | 30 ++++ .../markdown/ui/modifier/ModifierTest.kt | 146 +++++++++++++----- 12 files changed, 299 insertions(+), 77 deletions(-) create mode 100644 markdown-ui/src/test/kotlin/land/sungbin/markdown/ui/modifier/ClickableTransformerTest.kt create mode 100644 markdown-ui/src/test/kotlin/land/sungbin/markdown/ui/modifier/FootnoteTransformerTest.kt diff --git a/markdown-runtime/src/main/kotlin/land/sungbin/markdown/runtime/MarkdownNode.kt b/markdown-runtime/src/main/kotlin/land/sungbin/markdown/runtime/MarkdownNode.kt index e6a6493..4c3cb33 100644 --- a/markdown-runtime/src/main/kotlin/land/sungbin/markdown/runtime/MarkdownNode.kt +++ b/markdown-runtime/src/main/kotlin/land/sungbin/markdown/runtime/MarkdownNode.kt @@ -14,7 +14,7 @@ import org.jetbrains.annotations.TestOnly public class MarkdownNode( private val source: ((MarkdownOptions) -> String)? = null, private val kind: MarkdownKind = MarkdownKind.TEXT, - private val contentKind: MarkdownKind? = null, + private val contentKind: MarkdownKind = MarkdownKind.ANY, private val contentTag: ((index: Int, MarkdownOptions) -> String)? = null, ) { internal val children = MutableVectorWithMutationTracking(MutableVector(capacity = 20)) { @@ -28,7 +28,7 @@ public class MarkdownNode( @TestOnly internal constructor( children: List, kind: MarkdownKind = MarkdownKind.GROUP, - contentKind: MarkdownKind, + contentKind: MarkdownKind = MarkdownKind.ANY, contentTag: (index: Int, MarkdownOptions) -> String, ) : this(kind = kind, contentKind = contentKind, contentTag = contentTag) { for (i in children.indices) { @@ -47,7 +47,6 @@ public class MarkdownNode( "A node that is a group or a footnote cannot have its own 'source'. " + "Only children are allowed to be the source of a 'source'." } - if (group) require(contentKind != null) { "A node that is a group must have a 'contentKind'." } require(contentTag != null) { "A node that is a group or a footnote must have a 'contentTag'." } } false -> { @@ -55,7 +54,7 @@ public class MarkdownNode( "A MarkdownNode has been emitted as a MarkdownKind.TEXT, but the 'source' is null. " + "Use a 'source' containing a markdown string instead of null." } - require(contentKind == null) { "A node that is not a group or a footnote cannot have a 'contentKind'." } + require(contentKind == MarkdownKind.ANY) { "A node that is not a group or a footnote cannot have a 'contentKind'." } require(contentTag == null) { "A node that is not a group or a footnote cannot have a 'contentTag'." } } } @@ -97,7 +96,7 @@ public class MarkdownNode( val source = child.draw(options).lineSequence().iterator() while (source.hasNext()) { val line = source.next() - val prefix = (contentKind!! + child.kind).prefix(tag = tag, forceConcat = !touched) + val prefix = (contentKind + child.kind).prefix(tag = tag, forceConcat = !touched) append(prefix).append(line) if (index != lastChildIndex || source.hasNext()) append(NEW_LINE) if (!touched) touched = true diff --git a/markdown-runtime/src/test/kotlin/land/sungbin/markdown/MarkdownNodeTest.kt b/markdown-runtime/src/test/kotlin/land/sungbin/markdown/MarkdownNodeTest.kt index 796f36a..51e5b70 100644 --- a/markdown-runtime/src/test/kotlin/land/sungbin/markdown/MarkdownNodeTest.kt +++ b/markdown-runtime/src/test/kotlin/land/sungbin/markdown/MarkdownNodeTest.kt @@ -56,11 +56,6 @@ class MarkdownNodeTest { ) } - @Test fun groupNodeShouldHaveContentKind() { - assertFailure { node = MarkdownNode(kind = MarkdownKind.GROUP, contentTag = { _, _ -> "" }) } - .hasMessage("A node that is a group must have a 'contentKind'.") - } - @Test fun layoutNodeShouldHaveContentTag() { assertFailure { node = MarkdownNode(kind = MarkdownKind.GROUP, contentKind = MarkdownKind.TEXT) } .hasMessage("A node that is a group or a footnote must have a 'contentTag'.") @@ -88,7 +83,6 @@ class MarkdownNodeTest { MarkdownNode(source = { worldSource2 }), ), kind = MarkdownKind.FOOTNOTE, - contentKind = MarkdownKind.ANY, contentTag = { _, _ -> "[^T]: " }, ) @@ -156,7 +150,6 @@ class MarkdownNodeTest { .apply { index = actualIndex } }, kind = MarkdownKind.GROUP + MarkdownKind.REPEATATION_PARENT_TAG, - contentKind = MarkdownKind.ANY, contentTag = { index, _ -> "${index + 1}. " }, ) val childrenNodes = MarkdownNode( @@ -175,7 +168,6 @@ class MarkdownNodeTest { childrenNodes, ), kind = MarkdownKind.GROUP, - contentKind = MarkdownKind.ANY, contentTag = { _, _ -> "" }, ) diff --git a/markdown-ui/build.gradle.kts b/markdown-ui/build.gradle.kts index d4c0b3e..7c46020 100644 --- a/markdown-ui/build.gradle.kts +++ b/markdown-ui/build.gradle.kts @@ -12,6 +12,9 @@ plugins { kotlin { explicitApi() + sourceSets.all { + languageSettings.enableLanguageFeature("ExplicitBackingFields") + } } dependencies { diff --git a/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/list/List.kt b/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/list/List.kt index 7a3702c..b07884a 100644 --- a/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/list/List.kt +++ b/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/list/List.kt @@ -25,7 +25,6 @@ public inline fun List( factory = { MarkdownNode( kind = MarkdownKind.GROUP, - contentKind = MarkdownKind.ANY, contentTag = { index, _ -> if (ordered) "${index + 1}. " else "- " }, ) }, diff --git a/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/modifier/ClickableModifier.kt b/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/modifier/ClickableModifier.kt index d90d56e..25d44cd 100644 --- a/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/modifier/ClickableModifier.kt +++ b/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/modifier/ClickableModifier.kt @@ -7,6 +7,41 @@ package land.sungbin.markdown.ui.modifier -public fun Modifier.clickable(range: (String) -> IntRange, link: String): Modifier { - TODO() +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import land.sungbin.markdown.runtime.MarkdownOptions +import land.sungbin.markdown.ui.text.TextTransformer + +private val DefaultClickableRange: (String) -> IntRange = { 0..it.length } + +@Stable +public fun Modifier.clickable(link: String, range: (String) -> IntRange = DefaultClickableRange): Modifier = + this then ClickableTransformer(link, range) + +@Immutable +public class ClickableTransformer( + private val link: String, + private val range: (String) -> IntRange, +) : TextTransformer { + override fun transform(options: MarkdownOptions, value: String): String { + val originalLength = value.length + val linkLength = link.length + val replacementRange = range(value) + + return CharArray(originalLength + 4 + linkLength).also { new -> + if (replacementRange.first > 0) { + value.toCharArray(new, 0, 0, replacementRange.first) + } + new[replacementRange.first] = '[' + value.toCharArray(new, replacementRange.first + 1, replacementRange.first, replacementRange.last + 1) + new[replacementRange.last + 2] = ']' + new[replacementRange.last + 3] = '(' + link.toCharArray(new, replacementRange.last + 4, 0, linkLength) + new[replacementRange.last + 4 + linkLength] = ')' + if (replacementRange.last + 1 < originalLength) { + value.toCharArray(new, replacementRange.last + 4 + linkLength + 1, replacementRange.last + 1, originalLength) + } + } + .concatToString() + } } diff --git a/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/modifier/FootnoteModifier.kt b/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/modifier/FootnoteModifier.kt index 6660751..955c3e8 100644 --- a/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/modifier/FootnoteModifier.kt +++ b/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/modifier/FootnoteModifier.kt @@ -8,11 +8,35 @@ package land.sungbin.markdown.ui.modifier import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import land.sungbin.markdown.runtime.MarkdownComposable +import land.sungbin.markdown.runtime.MarkdownOptions +import land.sungbin.markdown.ui.text.TextTransformer +private val DefaultFootnotePosition: (String) -> Int = { it.length } + +@Stable public fun Modifier.footnote( tag: String, - range: (String) -> IntRange, - message: @Composable () -> Unit, -): Modifier { - TODO() + position: (String) -> Int = DefaultFootnotePosition, + message: @Composable @MarkdownComposable () -> Unit, +): Modifier = + this then FootnoteModifier(tag, position) with FootnoteGroup(tag, message) + +// TODO make public +internal class FootnoteModifier( + internal val tag: String, + private val position: (String) -> Int = DefaultFootnotePosition, +) : TextTransformer { + override fun transform(options: MarkdownOptions, value: String): String = + when (val position = position(value)) { + 0 -> "[^$tag]$value" + value.length - 1 -> "$value[^$tag]" + else -> value.substring(0, position + 1) + "[^$tag]" + value.substring(position + 1) + } } + +internal data class FootnoteGroup( + val tag: String, + val content: @Composable () -> Unit, +) diff --git a/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/modifier/Modifier.kt b/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/modifier/Modifier.kt index 4133c16..339e7d7 100644 --- a/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/modifier/Modifier.kt +++ b/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/modifier/Modifier.kt @@ -8,32 +8,53 @@ package land.sungbin.markdown.ui.modifier import androidx.compose.runtime.Stable +import androidx.compose.ui.util.fastFold import land.sungbin.markdown.runtime.MarkdownOptions import land.sungbin.markdown.ui.text.TextTransformer @Stable -public sealed interface Modifier : Collection, RandomAccess { - public operator fun get(index: Int): TextTransformer +public sealed interface Modifier { public override fun hashCode(): Int public override fun equals(other: Any?): Boolean - public companion object : Modifier, List by emptyList() { + public companion object : Modifier { override fun hashCode(): Int = 0 override fun equals(other: Any?): Boolean = other === Modifier } } -private class MutableModifier : AbstractMutableCollection(), Modifier { - private val transformers = ArrayList() +internal class MutableModifier : Modifier { + val transformers: List + field = ArrayList() - override fun add(element: TextTransformer): Boolean = transformers.add(element) - override fun get(index: Int): TextTransformer = transformers[index] + val footnotes: List + field = ArrayList() - override val size: Int get() = transformers.size - override fun iterator(): MutableIterator = transformers.iterator() + fun add(transformer: TextTransformer) { + transformers.add(transformer) + } + + fun add(footnote: FootnoteGroup) { + footnotes.add(footnote) + } + + override fun hashCode(): Int { + var result = transformers.hashCode() + result = 31 * result + footnotes.hashCode() + return result + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false - override fun hashCode(): Int = transformers.hashCode() - override fun equals(other: Any?): Boolean = transformers == other + other as MutableModifier + + if (transformers != other.transformers) return false + if (footnotes != other.footnotes) return false + + return true + } } @Stable @@ -42,11 +63,12 @@ public infix fun Modifier.then(transformer: TextTransformer): Modifier { return (this as MutableModifier).apply { add(transformer) } } -// TODO not cloning the sink -> Documentation required -@PublishedApi +internal infix fun Modifier.with(footnote: FootnoteGroup): Modifier { + if (this === Modifier) return MutableModifier().apply { add(footnote) } + return (this as MutableModifier).apply { add(footnote) } +} + internal fun Modifier.applyTo(options: MarkdownOptions, value: String): String { - if (isEmpty()) return value - var acc = value - repeat(size) { index -> acc = get(index).transform(options, acc) } - return acc + if (this !is MutableModifier) return value + return transformers.fastFold(value) { acc, transformer -> transformer.transform(options, acc) } } diff --git a/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/text/AnnotatedString.kt b/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/text/AnnotatedString.kt index 0312333..10d1dbd 100644 --- a/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/text/AnnotatedString.kt +++ b/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/text/AnnotatedString.kt @@ -13,7 +13,9 @@ import androidx.compose.ui.util.fastFold import land.sungbin.markdown.runtime.MarkdownOptions @Immutable -public class AnnotatedString(@Suppress("MemberVisibilityCanBePrivate") public val styledTexts: List) : CharSequence { +public class AnnotatedString( + @Suppress("MemberVisibilityCanBePrivate") public val styledTexts: List, +) : CharSequence { private val backing = run { styledTexts .fastFold(StringBuilder()) { acc, (text, style) -> @@ -37,7 +39,6 @@ public class AnnotatedString(@Suppress("MemberVisibilityCanBePrivate") public va } override fun hashCode(): Int = backing.hashCode() - override fun toString(): String = backing @Immutable diff --git a/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/text/Text.kt b/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/text/Text.kt index b63dd85..e90a0d0 100644 --- a/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/text/Text.kt +++ b/markdown-ui/src/main/kotlin/land/sungbin/markdown/ui/text/Text.kt @@ -10,18 +10,31 @@ package land.sungbin.markdown.ui.text import androidx.compose.runtime.Composable import androidx.compose.runtime.ComposeNode import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.ui.util.fastForEach import land.sungbin.markdown.runtime.MarkdownApplier import land.sungbin.markdown.runtime.MarkdownComposable +import land.sungbin.markdown.runtime.MarkdownKind import land.sungbin.markdown.runtime.MarkdownNode import land.sungbin.markdown.ui.EmptyUpdater import land.sungbin.markdown.ui.modifier.Modifier +import land.sungbin.markdown.ui.modifier.MutableModifier import land.sungbin.markdown.ui.modifier.applyTo -@Suppress("NOTHING_TO_INLINE") @[Composable NonRestartableComposable MarkdownComposable] -public inline fun Text(value: CharSequence, modifier: Modifier = Modifier) { +public fun Text(value: CharSequence, modifier: Modifier = Modifier) { ComposeNode( factory = { MarkdownNode(source = { options -> modifier.applyTo(options, value.toString()) }) }, update = EmptyUpdater, + content = { + if (modifier is MutableModifier) { + modifier.footnotes.fastForEach { (tag, content) -> + ComposeNode( + factory = { MarkdownNode(kind = MarkdownKind.FOOTNOTE, contentTag = { _, _ -> "[^$tag]: " }) }, + update = EmptyUpdater, + content = content, + ) + } + } + }, ) } diff --git a/markdown-ui/src/test/kotlin/land/sungbin/markdown/ui/modifier/ClickableTransformerTest.kt b/markdown-ui/src/test/kotlin/land/sungbin/markdown/ui/modifier/ClickableTransformerTest.kt new file mode 100644 index 0000000..57d5504 --- /dev/null +++ b/markdown-ui/src/test/kotlin/land/sungbin/markdown/ui/modifier/ClickableTransformerTest.kt @@ -0,0 +1,30 @@ +/* + * Developed by Ji Sungbin 2024. + * + * Licensed under the MIT. + * Please see full license: https://github.com/jisungbin/compose-markdown/blob/main/LICENSE + */ + +package land.sungbin.markdown.ui.modifier + +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.Test +import land.sungbin.markdown.ui.transform + +class ClickableTransformerTest { + @Test fun clickableRangeStartsWithZero() { + val result = ClickableTransformer(link = "bye", range = { 0..4 }).transform("hello, world!") + assertThat(result).isEqualTo("[hello](bye), world!") + } + + @Test fun clickableRangeInMiddle() { + val result = ClickableTransformer(link = "bye", range = { 5..6 }).transform("hello, world!") + assertThat(result).isEqualTo("hello[, ](bye)world!") + } + + @Test fun clickableRangeEndsWithLastIndex() { + val result = ClickableTransformer(link = "bye", range = { 7..12 }).transform("hello, world!") + assertThat(result).isEqualTo("hello, [world!](bye)") + } +} diff --git a/markdown-ui/src/test/kotlin/land/sungbin/markdown/ui/modifier/FootnoteTransformerTest.kt b/markdown-ui/src/test/kotlin/land/sungbin/markdown/ui/modifier/FootnoteTransformerTest.kt new file mode 100644 index 0000000..071ec71 --- /dev/null +++ b/markdown-ui/src/test/kotlin/land/sungbin/markdown/ui/modifier/FootnoteTransformerTest.kt @@ -0,0 +1,30 @@ +/* + * Developed by Ji Sungbin 2024. + * + * Licensed under the MIT. + * Please see full license: https://github.com/jisungbin/compose-markdown/blob/main/LICENSE + */ + +package land.sungbin.markdown.ui.modifier + +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlin.test.Test +import land.sungbin.markdown.ui.transform + +class FootnoteTransformerTest { + @Test fun footnotePositionStartsWithZero() { + val result = FootnoteModifier(tag = "bye", position = { 0 }).transform("hello, world!") + assertThat(result).isEqualTo("[^bye]hello, world!") + } + + @Test fun footnotePositionInMiddle() { + val result = FootnoteModifier(tag = "bye", position = { 5 }).transform("hello, world!") + assertThat(result).isEqualTo("hello,[^bye] world!") + } + + @Test fun footnotePositionEndsWithLastIndex() { + val result = FootnoteModifier(tag = "bye", position = { 12 }).transform("hello, world!") + assertThat(result).isEqualTo("hello, world![^bye]") + } +} diff --git a/markdown-ui/src/test/kotlin/land/sungbin/markdown/ui/modifier/ModifierTest.kt b/markdown-ui/src/test/kotlin/land/sungbin/markdown/ui/modifier/ModifierTest.kt index 2aafd56..402836e 100644 --- a/markdown-ui/src/test/kotlin/land/sungbin/markdown/ui/modifier/ModifierTest.kt +++ b/markdown-ui/src/test/kotlin/land/sungbin/markdown/ui/modifier/ModifierTest.kt @@ -14,53 +14,127 @@ import kotlin.test.Test import land.sungbin.markdown.ui.text.TextTransformer class ModifierTest { - @Test fun modifierChaining() { + private val testTransformer = TextTransformer { _, value -> value } + private val testTransformer2 = TextTransformer { _, value -> value } + private val testTransformer3 = TextTransformer { _, value -> value } + private val testTransformer4 = TextTransformer { _, sink -> sink } + + private val testFootnote = FootnoteGroup(tag = "1", content = {}) + private val testFootnote2 = FootnoteGroup(tag = "2", content = {}) + private val testFootnote3 = FootnoteGroup(tag = "3", content = {}) + private val testFootnote4 = FootnoteGroup(tag = "4", content = {}) + + @Test fun transformerChaining() { + val modifier = Modifier + .then(testTransformer) + .then(testTransformer2) + .then(testTransformer3) + .then(testTransformer4) + + assertThat(modifier.transformers()).containsExactly( + testTransformer, + testTransformer2, + testTransformer3, + testTransformer4, + ) + } + + @Test fun multiTransformerChaining() { + val modifierChain1 = Modifier + .then(testTransformer) + .then(testTransformer2) + .then(testTransformer3) + .then(testTransformer4) + val modifierChain2 = Modifier + .then(testTransformer4) + .then(testTransformer) + .then(testTransformer3) + .then(testTransformer2) + + assertThat(modifierChain1.transformers()).containsExactly( + testTransformer, + testTransformer2, + testTransformer3, + testTransformer4, + ) + assertThat(modifierChain2.transformers()).containsExactly( + testTransformer4, + testTransformer, + testTransformer3, + testTransformer2, + ) + } + + @Test fun footnoteChaining() { val modifier = Modifier - .then(testModifier1) - .then(testModifier2) - .then(testModifier3) - .then(testModifier4) - - assertThat(modifier.toList()).containsExactly( - testModifier1, - testModifier2, - testModifier3, - testModifier4, + .with(testFootnote) + .with(testFootnote2) + .with(testFootnote3) + .with(testFootnote4) + + assertThat(modifier.footnotes()).containsExactly( + testFootnote, + testFootnote2, + testFootnote3, + testFootnote4, ) } - @Test fun multiModifierChaining() { + @Test fun multiFootnoteChaining() { val modifierChain1 = Modifier - .then(testModifier1) - .then(testModifier2) - .then(testModifier3) - .then(testModifier4) + .with(testFootnote) + .with(testFootnote2) + .with(testFootnote3) + .with(testFootnote4) val modifierChain2 = Modifier - .then(testModifier4) - .then(testModifier1) - .then(testModifier3) - .then(testModifier2) - - assertThat(modifierChain1.toList()).containsExactly( - testModifier1, - testModifier2, - testModifier3, - testModifier4, + .with(testFootnote4) + .with(testFootnote) + .with(testFootnote3) + .with(testFootnote2) + + assertThat(modifierChain1.footnotes()).containsExactly( + testFootnote, + testFootnote2, + testFootnote3, + testFootnote4, + ) + assertThat(modifierChain2.footnotes()).containsExactly( + testFootnote4, + testFootnote, + testFootnote3, + testFootnote2, + ) + } + + @Test fun mixedChaining() { + val modifier = Modifier + .then(testTransformer) + .with(testFootnote) + .then(testTransformer2) + .with(testFootnote2) + .then(testTransformer3) + .with(testFootnote3) + .then(testTransformer4) + .with(testFootnote4) + + assertThat(modifier.transformers()).containsExactly( + testTransformer, + testTransformer2, + testTransformer3, + testTransformer4, ) - assertThat(modifierChain2.toList()).containsExactly( - testModifier4, - testModifier1, - testModifier3, - testModifier2, + assertThat(modifier.footnotes()).containsExactly( + testFootnote, + testFootnote2, + testFootnote3, + testFootnote4, ) } @Test fun emptyModifierIsSame() { assertThat(Modifier).isSameInstanceAs(Modifier) } - - private val testModifier1 = TextTransformer { _, sink -> sink } - private val testModifier2 = TextTransformer { _, sink -> sink } - private val testModifier3 = TextTransformer { _, sink -> sink } - private val testModifier4 = TextTransformer { _, sink -> sink } } + +private fun Modifier.transformers() = (this as MutableModifier).transformers +private fun Modifier.footnotes() = (this as MutableModifier).footnotes