Home About Contact
Text Processing , Kotlin

マークアップテキストのパース、ブロッククオート領域をハンドルする

マークダウン書式でいえば行頭を "> " からはじめた行は BlockQuote として扱われる。 このような領域を含んだテキストをパースして(たとえば)HTMLに変換したい場合どうすればいいか、ということを考えた。

blockquote.svg

このようなテキストをパースする場合、一度で変換をすませようとすると難しい。 また、BlockQuote 領域の中にさらに BlockQuote 領域が存在するような場合もありえる。 そのようなことまで想定すれば、一度のパースで変換を完了させるという発想は筋が悪いだろう。

いったん BlockQuote されていない領域(Just) と BlockQuote された領域(BlockQuote) を分離して取り出すことができれば、 あとは(たとえばそのテキストをHTMLに変換したければ)その領域ごとに取り出したテキストに対して別途 text-to-html するパース処理をすればよい。 その辺は過去のエントリーで検討しているので、ここでは省く。

また、 BlockQuote 内にさらに BlockQuote がネストしているような場合でも ネストを想定しない一重の BlockQuote をパースして領域分離できれば、 あとは再帰的に処理すれば問題ないであろう。 したがってここでは、あくまで 一重の BlockQuote が存在するマークアップテキストをパースする方法を考える。

ネストした BlockQuote に対処するコードをこちらに書きました。

以前に パーサーコンビネータを使って文字列をパースする 方法を調べた。 これは文字列を先頭の文字から順に調べていってなんとかする方法だったわけだが、 ならば、「文字列」の代わりに「複数の行」、「文字」の代わりに「ひとつの行」と見なして同じようにパーサーコンビネータすれば パースできるんじゃないの? とは思って実装してみたところ、問題なく処理できた。

ただ、この問題に解くために わざわざパーサーコンビネータするほどのことではなく、もっと簡単に解ける方法があったので、そちらをここに書き残します。

環境

$ kotlin -version
Kotlin version 1.8.10-release-430 (JRE 17.0.11+9-Ubuntu-122.04.1)

対象のテキスト

aaa
bbb
> ccc
> ddd
eee
fff

最終的にこれを次のテキストに変換します。

aaa
bbb
<blockquote>
ccc
ddd
</blockquote>
eee
fff

テキストを行ごとに分割する

まず toLines 関数を定義 (String)->List<String> にします。

val toLines: (String)->List<String> = { text->
    val regex = "\r?\n".toRegex()
    text.split(regex)
}

行ごとに Just か BlockQuote か区別したいので、Line クラスを定義します。

sealed class Line(open val value: String){
    data class Just(override val value: String): Line(value)
    data class BlockQuote(override val value: String): Line(value)
}

さらに (List<String>)->List<Line> する関数 toLineList を定義。

val toLineList: (List<String>)->List<Line> ={ lines->
    val isBlockQuote: (String)->Boolean = { line->
        val m0 = "^>$".toRegex().find(line)
        val m1 = "^> ".toRegex().find(line)
        (m0!=null || m1!=null)
    }

    val stripBlockQuoteMarkup: (String)-> String = { line->
        if( isBlockQuote(line) ){
            val m0 = "^>$".toRegex().find(line)
            if( m0!=null ){
                ""
            } else {
                val m1 = "^> (.*)".toRegex().find(line)
                if( m1!=null ){ m1.groupValues[1] } else { line }
            }
        } else {
            line
        }
    }


    lines.map { line->
        if( isBlockQuote(line) ){
            Line.BlockQuote( stripBlockQuoteMarkup(line) )
        } else {
            Line.Just(line)
        }
    }
}

ひとつの行 (String) を Line.Just か Line.BlockQuote のインスタンスにする必要があるので isBlockQuote という補助関数を使って判別しています。 また、BlockQuote であることを示す先頭のマーク > を取り除くために stripBlockQuoteMarkup 補助関数も定義しました。

それでは、これらを使ってテキストをパースしてみます。

//
// main.kts
//

// ここに先ほど説明した関数定義を書く。

val markupText = """
aaa
bbb
> ccc
> ddd
eee
fff
"""

val lines: List<String> = toLines(markupText)
println( lines )

val lineList: List<Line> = toLineList( lines )
println( lineList )

実行します。

$ kotlin main.kts
[, aaa, bbb, > ccc, > ddd, eee, fff]
[Just(value=), Just(value=aaa), Just(value=bbb), BlockQuote(value=ccc), BlockQuote(value=ddd), Just(value=eee), Just(value=fff)]

同じ種類の Line が連続している部分を fold する

ここまでできたら、あとは、連続して出現する Line.Just または Line.BlockQuotefold で折り畳みます。

折り畳んだときにそれら( Line ) を入れておく入れ物があった方が分かりやすいので Node クラスを定義します。

sealed class Node(open val lines: List<Line>){
    data class Just(override val lines: List<Line>): Node(lines)
    data class BlockQuote(override val lines: List<Line>): Node(lines)
}

Node.Just は Line.Just だけを許可、 Node.BlockQuote は Line.BlockQuote だけを許可するように実装する方が安全なのですが、 つまり、次のように:

sealed class Node {
    data class Just(override val lines: List<Line.Just>): Node()
    data class BlockQuote(override val lines: List<Line.BlockQuote>): Node()
}

実装がややこしくなりそうなので、ここではこの実装は使いません。

それでは、この Node クラスを使って (List<Line>)->List<Node> する関数 toNodeList を定義します。

val toNodeList: (List<Line>)->List<Node> = { lineList->
    val removeLastNode: (List<Node>)->List<Node> = { nodeList->
        if( nodeList.isEmpty() ){ listOf() } else { 0.until(nodeList.size-1).map{ nodeList[it] } }
    }

    val initValue = listOf<Node>()

    lineList.fold(initValue){ acc, line->
        if( acc.isEmpty() ){
            val newNode = when( line ){
                is Line.Just  -> Node.Just(listOf(line))
                is Line.BlockQuote -> Node.BlockQuote(listOf(line))
            }
            acc + listOf(newNode)
        } else {
            val lastNode: Node = acc.last()
            when( lastNode ){
                is Node.Just-> {
                    when(line){
                        is Line.Just -> {
                            // Just and Just
                            val newLastOne = Node.Just(lastNode.lines + listOf(line))
                            removeLastNode(acc) + newLastOne
                        }
                        is Line.BlockQuote -> {
                            // Just and BlockQuote
                            val newLastOne = Node.BlockQuote(listOf(line))
                            acc + listOf(newLastOne)
                        }
                    }
                }
    
                is Node.BlockQuote-> {
                    when(line){
                        is Line.Just -> {
                            // BlockQuote and Just
                            val newLastOne = Node.Just(listOf(line))
                            acc + listOf(newLastOne)
                        }
                        is Line.BlockQuote -> {
                            // BlockQuote and BlockQuote
                            val newLastOne = Node.BlockQuote(lastNode.lines + listOf(line))
                            removeLastNode(acc) + newLastOne
                        }
                    }
                }
            }
        }
    }
}

nodeList: List<Line> の各要素を前から順番に畳み込み( fold )ながら acc: List<Node> に結果を蓄積していきます。 不正確な表現ですが、おおざっぱにいえば、 直前の行(Line)と現在の行(Line) を見てその種類(Just か BlockQutoe か)によって、マージするか新しい Node として追加するかしているだけです。

それではこの toNodeList を使って nodeList を生成して、中身を標準出力します。

val nodeList: List<Node> = toNodeList( lineList )

nodeList.forEach { node->
    val name = when(node){
        is Node.Just -> "Just"
        is Node.BlockQuote -> "BlockQuote"
    }

    val br = System.getProperty("line.separator")
    val text = node.lines.map { it.value }.joinToString(br)

    println("--- <$name> ---")
    println(text)
    println("--- </$name> ---")
}

実行。

$ kotlin main.kts
--- <Just> ---

aaa
bbb
--- </Just> ---
--- <BlockQuote> ---
ccc
ddd
--- </BlockQuote> ---
--- <Just> ---
eee
fff

--- </Just> ---

意図通りブロックごとにわけてテキストを取り出すことができました。

目標とするテキスト(HTMLっぽいやつ)を出力する関数 toHtml を書いて仕上げとします。

val toHtml: (List<Node>)->String = { nodeList->
    val br = System.getProperty("line.separator")
    
    nodeList.map { node->
        val elementName = when(node){
            is Node.Just -> "div"
            is Node.BlockQuote -> "blockquote"
        }
    
        val text = node.lines.map { it.value } .joinToString(br)
    
        if( elementName!=null ){
            val begin = listOf("<",  elementName, ">").joinToString("")
            val end   = listOf("</", elementName, ">").joinToString("")
            listOf(begin, br, text, br, end).joinToString("")
        } else {
            text
        }
    }.joinToString(br)
}

実行します。

$ kotlin main.kts
<div>

aaa
bbb
</div>
<blockquote>
ccc
ddd
</blockquote>
<div>
eee
fff

</div>

できました。

まとめ

完成したコードを掲載します。

main.kts

val toLines: (String)->List<String> = { text->
    val regex = "\r?\n".toRegex()
    text.split(regex)
}

sealed class Line(open val value: String){
    data class Just(override val value: String): Line(value)
    data class BlockQuote(override val value: String): Line(value)
}

val toLineList: (List<String>)->List<Line> ={ lines->
    val isBlockQuote: (String)->Boolean = { line->
        val m0 = "^>$".toRegex().find(line)
        val m1 = "^> ".toRegex().find(line)
        (m0!=null || m1!=null)
    }

    val stripBlockQuoteMarkup: (String)-> String = { line->
        if( isBlockQuote(line) ){
            val m0 = "^>$".toRegex().find(line)
            if( m0!=null ){
                ""
            } else {
                val m1 = "^> (.*)".toRegex().find(line)
                if( m1!=null ){ m1.groupValues[1] } else { line }
            }
        } else {
            line
        }
    }

    lines.map { line->
        if( isBlockQuote(line) ){
            Line.BlockQuote( stripBlockQuoteMarkup(line) )
        } else {
            Line.Just(line)
        }
    }
}

sealed class Node(open val lines: List<Line>){
    data class Just(override val lines: List<Line>): Node(lines)
    data class BlockQuote(override val lines: List<Line>): Node(lines)
}

val toNodeList: (List<Line>)->List<Node> = { lineList->
    val removeLastNode: (List<Node>)->List<Node> = { nodeList->
        if( nodeList.isEmpty() ){ listOf() } else { 0.until(nodeList.size-1).map{ nodeList[it] } }
    }

    val initValue = listOf<Node>()

    lineList.fold(initValue){ acc, line->
        if( acc.isEmpty() ){
            val newNode = when( line ){
                is Line.Just  -> Node.Just(listOf(line))
                is Line.BlockQuote -> Node.BlockQuote(listOf(line))
            }
            acc + listOf(newNode)
        } else {
            val lastNode: Node = acc.last()
            when( lastNode ){
                is Node.Just-> {
                    when(line){
                        is Line.Just -> {
                            // Just and Just
                            val newLastNode = Node.Just(lastNode.lines + listOf(line))
                            removeLastNode(acc) + newLastNode
                        }
                        is Line.BlockQuote -> {
                            // Just and BlockQuote
                            val newLastNode = Node.BlockQuote(listOf(line))
                            acc + listOf(newLastNode)
                        }
                    }
                }
    
                is Node.BlockQuote-> {
                    when(line){
                        is Line.Just -> {
                            // BlockQuote and Just
                            val newLastNode = Node.Just(listOf(line))
                            acc + listOf(newLastNode)
                        }
                        is Line.BlockQuote -> {
                            // BlockQuote and BlockQuote
                            val newLastNode = Node.BlockQuote(lastNode.lines + listOf(line))
                            removeLastNode(acc) + newLastNode
                        }
                    }
                }
            }
        }
    }
}

val toHtml: (List<Node>)->String = { nodeList->
    val br = System.getProperty("line.separator")
    
    nodeList.map { node->
        val elementName = when(node){
            is Node.Just -> "div"
            is Node.BlockQuote -> "blockquote"
        }
    
        val text = node.lines.map { it.value } .joinToString(br)
    
        if( elementName!=null ){
            val begin = listOf("<",  elementName, ">").joinToString("")
            val end   = listOf("</", elementName, ">").joinToString("")
            listOf(begin, br, text, br, end).joinToString("")
        } else {
            text
        }
    }.joinToString(br)
}

val markupText = """
aaa
bbb
> ccc
> ddd
eee
fff
"""

val lines: List<String> = toLines(markupText)
println( lines )

val lineList: List<Line> = toLineList( lines )
println( lineList )

val nodeList: List<Node> = toNodeList( lineList )
println( nodeList )

nodeList.forEach { node->
    val name = when(node){
        is Node.Just -> "Just"
        is Node.BlockQuote -> "BlockQuote"
    }

    val br = System.getProperty("line.separator")
    val text = node.lines.map { it.value }.joinToString(br)

    println("--- <$name> ---")
    println(text)
    println("--- </$name> ---")
}

println( toHtml(nodeList) )

以上です。