Skip to content

Custom Formats & Serializers

Arranger features a modular Service Provider Interface (SPI) designed to serialize rich text to and from arbitrary data representations. Whether your application communicates with a Slack API using mrkdwn, persists structured rich text in SQLite/PostgreSQL as JSON, or synchronizes collaborative edits using a Delta format, Arranger's format abstraction makes custom format integration straightforward and type-safe.

flowchart TD
    Exporter["RichTextExporter&lt;T&gt;<br><i>export(richString): T</i>"]
    Importer["RichTextImporter&lt;T&gt;<br><i>import(input): RichString</i>"]
    Format["RichTextFormat&lt;T&gt;<br><i>(Optional combination)</i>"]

    Exporter -.-> Format
    Importer -.-> Format

    Exporter --> CustomExporter["SlackMrkdwnExporter / JsonExporter"]
    Importer --> CustomImporter["SlackMrkdwnImporter / JsonImporter"]

Independent Exporter & Importer Interfaces

In many applications, only one direction is required: - Export only: Sending formatted chat messages to a REST API or Slack webhook. - Import only: Loading structured templates or legacy content into the editor.

Arranger embraces the Interface Segregation Principle: RichTextExporter<T> and RichTextImporter<T> are completely independent interfaces:

public interface RichTextExporter<T> {
    public fun export(richString: RichString): T
}

public interface RichTextImporter<T> {
    public fun import(input: T): RichString
}

If you need bidirectional conversion, you can optionally implement RichTextFormat<T>, which simply combines both:

public interface RichTextFormat<T> : RichTextExporter<T>, RichTextImporter<T>

Extension Helpers

The core library provides generic extension functions that bridge your implementations directly to RichString:

// Export via any exporter
public fun <T> RichString.export(exporter: RichTextExporter<T>): T = exporter.export(this)

// Import via any importer
public fun <T> RichString.Companion.import(input: T, importer: RichTextImporter<T>): RichString = importer.import(input)

Implementing a Custom Format: Slack mrkdwn

Slack uses a unique syntax known as mrkdwn, which differs from standard CommonMark (for example, *bold* instead of **bold**, and _italic_ instead of *italic*).

Here is a complete implementation of a bi-directional SlackMrkdwnFormat:

Step 1: Implement the Exporter

We can inspect consecutive styled chunks using richString.runs:

public object SlackMrkdwnExporter : RichTextExporter<String> {
    override fun export(richString: RichString): String {
        if (richString.text.isEmpty()) return ""

        val builder = StringBuilder()
        val text = richString.text

        // Extract styled ranges
        val boldRanges = richString.runs(BoldKey).map { it.range }.toSet()
        val italicRanges = richString.runs(ItalicKey).map { it.range }.toSet()
        val strikeRanges = richString.runs(StrikethroughKey).map { it.range }.toSet()

        // Slicing and wrapping with Slack delimiters (*bold*, _italic_, ~strike~)
        var i = 0
        while (i < text.length) {
            val isBold = boldRanges.any { i in it }
            val isItalic = italicRanges.any { i in it }
            val isStrike = strikeRanges.any { i in it }

            if (isBold && (i == 0 || !boldRanges.any { (i - 1) in it })) builder.append('*')
            if (isItalic && (i == 0 || !italicRanges.any { (i - 1) in it })) builder.append('_')
            if (isStrike && (i == 0 || !strikeRanges.any { (i - 1) in it })) builder.append('~')

            builder.append(text[i])

            if (isStrike && (i == text.lastIndex || !strikeRanges.any { (i + 1) in it })) builder.append('~')
            if (isItalic && (i == text.lastIndex || !italicRanges.any { (i + 1) in it })) builder.append('_')
            if (isBold && (i == text.lastIndex || !boldRanges.any { (i + 1) in it })) builder.append('*')

            i++
        }

        return builder.toString()
    }
}

Step 2: Implement the Importer

We parse the input string and construct a RichString with RichSpans:

public object SlackMrkdwnImporter : RichTextImporter<String> {
    private val BOLD_REGEX = Regex("""\*(.*?)\*""")

    override fun import(input: String): RichString {
        return RichString(input).edit {
            BOLD_REGEX.findAll(input).forEach { matchResult ->
                val range = matchResult.range
                setSpanAttribute(BoldKey, Unit, range)
            }
        }
    }
}

Step 3: Combine into a Unified Format

public object SlackMrkdwnFormat : RichTextFormat<String>,
    RichTextExporter<String> by SlackMrkdwnExporter,
    RichTextImporter<String> by SlackMrkdwnImporter

// Idiomatic extension functions
public fun RichString.toSlackMrkdwn(): String = export(SlackMrkdwnFormat)
public fun RichString.Companion.fromSlackMrkdwn(text: String): RichString = import(text, SlackMrkdwnFormat)

Implementing JSON Serialization (Database & Network Sync)

For cloud synchronization (e.g. Firebase, Couchbase, Ktor REST APIs), serializing RichString into structured JSON is the most reliable strategy.

Using kotlinx.serialization:

@Serializable
public data class SerializedSpan(
    val start: Int,
    val end: Int,
    val isBold: Boolean = false,
    val isItalic: Boolean = false,
    val headingLevel: String? = null,
    val linkUrl: String? = null,
)

@Serializable
public data class SerializedDocument(
    val text: String,
    val spans: List<SerializedSpan>,
)

public object JsonRichTextFormat : RichTextFormat<String> {
    private val json = Json { prettyPrint = false; ignoreUnknownKeys = true }

    override fun export(richString: RichString): String {
        val serializedSpans = richString.spans.map { span ->
            SerializedSpan(
                start = span.range.first,
                end = span.range.last,
                isBold = span.attributes.containsKey(BoldKey),
                isItalic = span.attributes.containsKey(ItalicKey),
                headingLevel = span.attributes[HeadingKey]?.name,
                linkUrl = span.attributes[LinkKey],
            )
        }
        val doc = SerializedDocument(text = richString.text, spans = serializedSpans)
        return json.encodeToString(SerializedDocument.serializer(), doc)
    }

    override fun import(input: String): RichString {
        val doc = json.decodeFromString(SerializedDocument.serializer(), input)
        val spans = doc.spans.map { s ->
            var attrs = AttributeContainer.empty()
            if (s.isBold) attrs += BoldKey to Unit
            if (s.isItalic) attrs += ItalicKey to Unit
            s.headingLevel?.let { name ->
                runCatching { HeadingLevel.valueOf(name) }.getOrNull()?.let { level ->
                    attrs += HeadingKey to level
                }
            }
            s.linkUrl?.let { url -> attrs += LinkKey to url }
            RichSpan(range = s.start..s.end, attributes = attrs)
        }
        return RichString(text = doc.text, spans = spans)
    }
}

Traversal & Parsing Strategies

When designing custom formats, choose the appropriate traversal API provided by :richtext:

1. Attribute Runs (richString.runs(key))

Returns a Sequence<RichRun<T>> combining consecutive characters sharing the exact same attribute value:

richString.runs(BoldKey).forEach { run ->
    println("Bold text '${run.text}' spanning ${run.range}")
}

Best for: Exporting isolated styles, building run-length encoded formats, or highlighting specific tokens.

2. Segmenting by Boundary Coordinates

When multiple overlapping styles must be exported in strictly nested trees (like XML, HTML, or Markdown delimiters):

val boundaries = buildList {
    add(0)
    add(richString.text.length)
    richString.spans.forEach {
        add(it.range.first)
        add(it.range.last + 1)
    }
}.distinct().sorted()

Best for: Hierarchy-sensitive markup, preventing crossed tags (<b><i></b></i>).

3. Construction DSL (RichStringScope)

When building RichString instances inside importers:

RichString("Hello world").edit {
    bold(0..4)
    textColor(RgbaColor(0xFF00FF00), 6..10)
}

Best for: Programmatic construction with automatic paragraph snapping and span merging.