マークダウン文書からテキストを抽出し、それっぽく整形してみる

# 目次
[1. はじめに](#1-はじめに)
[2. テキスト文書作成の流れ](#2-テキスト文書作成の流れ)
[3. テキスト文書作成の方針(加工1：基本仕様)](#3-テキスト文書作成の方針加工1基本仕様)
[4. テキスト文書作成の方針(加工2：特殊仕様)](#4-テキスト文書作成の方針加工2特殊仕様)
[5. コード](#5-コード)
[6. 動作確認](#6-動作確認)
[7. 改造のポイント](#7-改造のポイント)
[8. 参考にさせていただいた情報](#8-参考にさせていただいた情報)


# 1. はじめに

マークダウンで書かれた文書を他システムに連携するため、マークダウン文書からテキストを抽出し、書式に従って整形を行うプログラムを作成しました。  
完璧を求めると物足りない部分もありますが、**それなりに**見られるテキスト文書を出力することができたので、内容をまとめてみました。

参考までに各製品・ライブラリは以下のバージョンを使っています。
* Java11
* flexmark-java-0.62.2
* jsoup-1.14.3
* commons-lang3-3.12.0
* commons-text-1.9

# 2. テキスト文書作成の流れ

マークダウン文書を元にテキスト文書を作成するために以下の手順を踏みました。

`マークダウン文書(テキスト)`→(flexmark)→`HTML文書(テキスト)`→(jsoup)→`HTML文書(オブジェクト)`→**加工1**→(jsoup)→`テキスト文書`→**加工2**

# 3. テキスト文書作成の方針(加工1：基本仕様)

マークダウン文書をテキスト文書に変換する際に、今回は以下を考慮しました。

* マークダウンの書式をテキスト文書でも**ある程度**再現すること
* 改行を再現すること
* インデントを再現すること

また、マークダウン文書内の画像参照定義については、テキストで画像を扱えないこともあり、テキスト文書変換時に取り除く方針にしました。
もし何らかの形でテキスト文書内に画像の情報を残したい場合は、マークダウン文書をHTMLに変換した後に出力されるimgタグに対し(別のタグに置き換える等の)加工処理を行う必要があります。

:::note info
加工対象のマークダウン書式
* 見出し (マークダウン書式→`# ○○○` | HTML変換後→`<h1>○○○</h1>`)
    `[○○○]`の形式でテキスト出力します。
    ※&lt;h1&gt;～&lt;h6&gt;を処理対象としますが、全て同じ形式での出力になります。

* 箇条書きリスト (マークダウン書式→`* ○○○` or `- ○○○` | `<ul><li>○○○</li></ul>`)
    `・○○○`の形式でテキスト出力します。
* 番号付きリスト (マークダウン書式→`1. ○○○` | `<ol><li>○○○</li></ol>`)
    `1. ○○○`の形式でテキスト出力します。
* 引用 (マークダウン書式→`> ○○○` | `<blockquote>○○○</blockquote>`)
    `> ○○○`の形式でテキスト出力します。
* リンク (マークダウン書式→`[○○○](URL)` | `<a href="URL">○○○</a>`)
    `○○○( URL )`の形式でテキスト出力します。
* リンク (マークダウン書式→`URL` | `<a href="URL">URL</a>`)
    `URL`の形式でテキスト出力します。
    ※こちらについては、正確にはマークダウンの書式ではありませんが・・・。
:::

# 4. テキスト文書作成の方針(加工2：特殊仕様)

連携先の他システムの特性に対応するため、追加で以下を対応しました。
こちらはテキスト文書の利用環境によって対応が変わる部分です。

* スペース(0x20)とタブ(\t)をノーブレークスペース(0xA0)に置き換える
連携先のシステムでスペースやタブが詰められて表示されてしまうため。

* 実体参照を実際の文字に戻す
マークダウン文書内に例えば`<`や`>`を記述した場合、HTML変換の過程で実体参照`&lt;`、`&gt;`に置き換えられてしまい、連携先のシステムで置き換え後の文字列がそのまま表示されてしまうため。

# 5. コード

実装コードは次のようになります。
なお、ライブラリの設定にはMavenを用いました。

```xml:pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.sample</groupId>
    <artifactId>sample-md2txt</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
        <flexmarkVer>0.62.2</flexmarkVer>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.jsoup</groupId>
            <artifactId>jsoup</artifactId>
            <version>1.14.3</version>
        </dependency>
        <dependency>
            <groupId>com.vladsch.flexmark</groupId>
            <artifactId>flexmark</artifactId>
            <version>${flexmarkVer}</version>
        </dependency>
        <dependency>
            <groupId>com.vladsch.flexmark</groupId>
            <artifactId>flexmark-ext-emoji</artifactId>
            <version>${flexmarkVer}</version>
            <exclusions>
                <exclusion>
                    <groupId>com.vladsch.flexmark</groupId>
                    <artifactId>flexmark-jira-converter</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>com.vladsch.flexmark</groupId>
            <artifactId>flexmark-ext-gfm-strikethrough</artifactId>
            <version>${flexmarkVer}</version>
        </dependency>
        <dependency>
            <groupId>com.vladsch.flexmark</groupId>
            <artifactId>flexmark-ext-gfm-strikethrough</artifactId>
            <version>${flexmarkVer}</version>
        </dependency>
        <dependency>
            <groupId>com.vladsch.flexmark</groupId>
            <artifactId>flexmark-ext-tables</artifactId>
            <version>${flexmarkVer}</version>
        </dependency>
        <dependency>
            <groupId>com.vladsch.flexmark</groupId>
            <artifactId>flexmark-ext-autolink</artifactId>
            <version>${flexmarkVer}</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.12.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-text</artifactId>
            <version>1.9</version>
        </dependency>
    </dependencies>
</project>
```

```java:src/main/java/org/sample/Md2TxtUtil.java
package org.sample;

import com.vladsch.flexmark.ext.autolink.AutolinkExtension;
import com.vladsch.flexmark.ext.emoji.EmojiExtension;
import com.vladsch.flexmark.ext.emoji.EmojiImageType;
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension;
import com.vladsch.flexmark.ext.tables.TablesExtension;
import com.vladsch.flexmark.html.HtmlRenderer;
import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.util.data.DataHolder;
import com.vladsch.flexmark.util.data.MutableDataSet;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.apache.commons.text.StringEscapeUtils;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.safety.Safelist;
import org.jsoup.select.Elements;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.stream.Collector;

/**
 * マークダウンからテキストを抽出し、加工するユーティリティクラス
 * @author Koji Hataya
 */
public class Md2TxtUtil {
    private static final DataHolder MD_OPTIONS = getMarkdownOptions();
    private static final Parser MD_PARSER = Parser.builder(MD_OPTIONS).build();
    private static final HtmlRenderer HTML_RENDERER = HtmlRenderer.builder(MD_OPTIONS).build();

    /**
     * HTMLからテキストを抽出し、加工して返します
     * @param markdownString Markdown
     * @param reverseEscapeRef 実体参照に変換された文字を元に戻す場合はtrue
     * @param replaceSpTab 空白("\u0020")とタブ("\t")を特殊空白("\u00A0(&nbsp;)")に置き換える場合はtrue
     * @return 加工後テキスト
     */
    public static String markdownToText(String markdownString, boolean reverseEscapeRef, boolean replaceSpTab) {
        if (StringUtils.isBlank(markdownString)) {
            return "";
        }

        // MarkdownをHTMLに変換する
        com.vladsch.flexmark.util.ast.Document parsed = MD_PARSER.parse(markdownString);
        String html = HTML_RENDERER.render(parsed);

        // HTMLを加工するためのドキュメントを生成する
        Document document = Jsoup.parse(html);
        document.outputSettings(new Document.OutputSettings().prettyPrint(false));	// 改行と空白を保持する

        // 親のul,ol数をカウントするファンクション
        Function<Element, Integer> countParentUol = me -> (int) me.parents().stream().filter(el -> el.is("ul,ol")).count();

        // 親のblockquote数をカウントするファンクション
        Function<Element, Integer> countParentBq = me -> (int) me.parents().stream().filter(el -> el.is("blockquote")).count();

        // pタグ処理
        document.select(":not(li,blockquote)>p").append("\n");	// li,blockquoteの直下のp以外
        document.select("li>p:not(:first-child)").before("\n");	// li直下のp(先頭以外)

        // h1-h6タグ処理
        document.select("h1,h2,h3,h4,h5,h6")
                .forEach(h -> {
                    int parentUolCnt = countParentUol.apply(h);
                    h.prepend(String.format("\n%s[", StringUtils.repeat(" ", parentUolCnt * 4))).append("]");
                });

        // ul,olタグ処理 (入れ子を考慮し、ul,olタグを同時に処理する)
        document.select("ul,ol")
                .stream().collect(reverse())	// 上から処理すると、入れ子の場合に子のol,ulが崩れてしまうので、順番を入れ替える
                .forEach(uol -> {
                    // 親のul,ol数を取得(インデント計算)
                    int parentUolCnt = countParentUol.apply(uol);
                    if (uol.nodeName().equals("ul")) {
                        uol.select(">li")
                                .forEach(li -> {
                                    String lihtml = li.html();
                                    boolean firstP = lihtml.matches("^(\\r\\n|\\n|\\r)?<p>[\\s\\S]*");
                                    org.jsoup.nodes.Element lip = firstP ? li.child(0) : li;
                                    lip.prepend(
                                            String.format("%s・", StringUtils.repeat(" ", parentUolCnt * 4)));
                                    // 以降のpについてインデントを考慮
                                    li.select(firstP ? ">p:nth-child(n+2)" : ">p").prepend(
                                            StringUtils.repeat(" ", (parentUolCnt + 1) * 4));
                                    li.select(">br,>p>br")
                                            .after(StringUtils.repeat(" ", (parentUolCnt + 1) * 4));
                                });
                    } else {
                        // ol毎に連番を振る
                        String strSt = uol.attr("start");	// 初期値(この属性が付くパターンがある)
                        int st = StringUtils.isBlank(strSt) ? 1 : NumberUtils.toInt(strSt, 1);
                        final AtomicInteger num = new AtomicInteger(st);
                        uol.select(">li")
                                .forEach(li -> {
                                    String lihtml = li.html();
                                    boolean firstP = lihtml.matches("^(\\r\\n|\\n|\\r)?<p>[\\s\\S]*");
                                    org.jsoup.nodes.Element lip = firstP ? li.child(0) : li;
                                    lip.prepend(
                                            String.format(
                                                    "%s%d. ",
                                                    StringUtils.repeat(" ", parentUolCnt * 4),
                                                    num.getAndIncrement()));
                                    // 以降のpについてインデントを考慮
                                    li.select(firstP ? ">p:nth-child(n+2)" : ">p").prepend(
                                            StringUtils.repeat(" ", (parentUolCnt + 1) * 4));
                                    li.select(">br,>p>br")
                                            .after(StringUtils.repeat(" ", (parentUolCnt + 1) * 4));
                                });
                    }
                });

        // blockquoteタグ処理(引用)
        document.select("blockquote")
                .forEach(bq -> {
                    Elements p = bq.select(">p");
                    int parentUolCnt = countParentUol.apply(bq);
                    int parentUolBq = countParentBq.apply(bq);
                    if (p.isEmpty()) {
                        if (bq.hasText()) {
                            bq.prepend(String.format("%s%s",
                                    StringUtils.repeat(" ", parentUolCnt * 4),
                                    StringUtils.repeat("> ", parentUolBq + 1)));
                            bq.select(">br").after(String.format("%s%s",
                                    StringUtils.repeat(" ", parentUolCnt * 4),
                                    StringUtils.repeat("> ", parentUolBq + 1)));
                        }
                    } else {
                        p.prepend(String.format("%s%s",
                                StringUtils.repeat(" ", parentUolCnt * 4),
                                StringUtils.repeat("> ", parentUolBq + 1)));
                        p.select(">br").after(String.format("%s%s",
                                StringUtils.repeat(" ", parentUolCnt * 4),
                                StringUtils.repeat("> ", parentUolBq + 1)));
                    }
                });

        // aタグ処理
        document.select("a")
                .forEach(a -> {
                    // URLをaタグのテキスト部に転記
                    String aHref = a.attr("href");
                    String aText = a.text();
                    if (!aHref.startsWith("mailto:") && !StringUtils.equals(aHref, aText)) {
                        a.text(aText + "( " + aHref + " )");
                    }
                });

        // brタグ処理
        document.select("br").append("\n");

        // テキスト抽出
        String s = document.html();
        String result = Jsoup.clean(s, "", Safelist.none(), new Document.OutputSettings().prettyPrint(false)).trim();

        // 空白("\u0020")とタブ("\t")を特殊空白("\u00A0(&nbsp;)")に置換
        if (replaceSpTab) {
            result = result.replaceAll("\u0020", "\u00A0")
                    .replaceAll("\t", "\u00A0\u00A0\u00A0\u00A0");    // タブは空白4つ
        }

        return reverseEscapeRef ? StringEscapeUtils.unescapeHtml4(result) : result;
    }

    /**
     * Markdownのオプションを返します
     * @return Markdownのオプション
     */
    private static DataHolder getMarkdownOptions() {
        return new MutableDataSet()
                .set(HtmlRenderer.ESCAPE_HTML, true)
                .set(HtmlRenderer.SUPPRESSED_LINKS, "(?i)javascript:.*")
                .set(HtmlRenderer.SOFT_BREAK, "<br>")
                .set(HtmlRenderer.AUTOLINK_WWW_PREFIX, "https://")
                .set(EmojiExtension.USE_IMAGE_TYPE, EmojiImageType.UNICODE_FALLBACK_TO_IMAGE)
                // GFM(GitHub Flavored Markdown)テーブルの仕様に倣う
                .set(TablesExtension.COLUMN_SPANS, false)
                .set(TablesExtension.APPEND_MISSING_COLUMNS, true)
                .set(TablesExtension.DISCARD_EXTRA_COLUMNS, true)
                .set(TablesExtension.HEADER_SEPARATOR_COLUMN_MATCH, true)
                .set(Parser.EXTENSIONS, Arrays.asList(TablesExtension.create(), EmojiExtension.create(),
                        StrikethroughExtension.create(), AutolinkExtension.create()));
    }

    /**
     * リストの値の並びを逆にするコレクターを返します
     * @param <T> リストに格納する値の型
     * @return コレクター
     */
    private static <T> Collector<T, List<T>, List<T>> reverse() {
        return Collector.of(
                ArrayList::new,
                (l, e) -> l.add(0, e),
                (l, subl) -> {
                    l.addAll(0, subl);
                    return l;
                },
                Collector.Characteristics.CONCURRENT
        );
    }
}
```

# 6. 動作確認

さて、Md2TxtUtilクラスの動作確認を行ってみましょう。

### 動作確認コード

```java:src/main/java/org/sample/Main.java
package org.sample;

public class Main {
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();
        sb.append("# マークダウン文書からテキストを抽出し、それっぽく整形してみる\n");
        sb.append("## はじめに\n");
        sb.append("マークダウン文書からテキストを抽出し、書式に従って整形を行うプログラムを作成しました。\n");
        sb.append("テキストの整形については、以下を考慮しています。\n");
        sb.append("- マークダウンの書式をテキスト文書でも**ある程度**再現すること\n");
        sb.append("- 改行を再現すること\n");
        sb.append("- インデントを再現すること\n");
        sb.append("\n");
        sb.append("> 他に考慮していることはないのでしょうか？\n");
        sb.append("\n");
        sb.append("ありますが、細かな内容については割愛しています。\n");
        sb.append("\n");
        sb.append("## 変換処理\n");
        sb.append("以下手順で文書の加工を行っています。\n");
        sb.append("1. マークダウン文書(テキスト)をHTML文書(テキスト)に変換\n");
        sb.append("2. HTML文書(テキスト)をHTMLドキュメント(オブジェクト)に変換\n");
        sb.append("3. HTMLドキュメント(オブジェクト)に対し内容を加工\n");
        sb.append("4. HTMLドキュメント(オブジェクト)をテキスト文書に変換\n");
        sb.append("5. テキスト文書に対し内容を加工\n");
        sb.append("\n");
        sb.append("```\n");
        sb.append("public String markdownToText(String markdownString, boolean reverseEscapeRef, boolean replaceSpTab) {\n");
        sb.append("    if (StringUtils.isBlank(markdownString)) {\n");
        sb.append("        return \"\";\n");
        sb.append("    }\n");
        sb.append("\n");
        sb.append("    // MarkdownをHTMLに変換する\n");
        sb.append("    com.vladsch.flexmark.util.ast.Document parsed = MD_PARSER.parse(markdownString);\n");
        sb.append("    String html = HTML_RENDERER_STRICT.render(parsed);\n");
        sb.append("    (略)\n");
        sb.append("```\n");
        sb.append("\n");
        sb.append("## 参考\n");
        sb.append("[vsch / flexmark-java](https://github.com/vsch/flexmark-java)\n");

        System.out.println(Md2TxtUtil.markdownToText(sb.toString(), true, true));
    }
}
```

### 動作確認コード内で使用したマークダウン文書

````md:サンプル マークダウン文書
# マークダウン文書からテキストを抽出し、それっぽく整形してみる
## はじめに
マークダウン文書からテキストを抽出し、書式に従って整形を行うプログラムを作成しました。
テキストの整形については、以下を考慮しています。
- マークダウンの書式をテキスト文書でも**ある程度**再現すること
- 改行を再現すること
- インデントを再現すること

> 他に考慮していることはないのでしょうか？

ありますが、細かな内容については割愛しています。

## 変換処理
以下手順で文書の加工を行っています。
1. マークダウン文書(テキスト)をHTML文書(テキスト)に変換
2. HTML文書(テキスト)をHTMLドキュメント(オブジェクト)に変換
3. HTMLドキュメント(オブジェクト)に対し内容を加工
4. HTMLドキュメント(オブジェクト)をテキスト文書に変換
5. テキスト文書に対し内容を加工

```
public String markdownToText(String markdownString, boolean reverseEscapeRef, boolean replaceSpTab) {
    if (StringUtils.isBlank(markdownString)) {
        return \"\";
    }

    // MarkdownをHTMLに変換する
    com.vladsch.flexmark.util.ast.Document parsed = MD_PARSER.parse(markdownString);
    String html = HTML_RENDERER_STRICT.render(parsed);
    (略)
```

## 参考
[vsch / flexmark-java](https://github.com/vsch/flexmark-java)
````

### 動作確認コード実行の出力結果

```text:出力結果
[マークダウン文書からテキストを抽出し、それっぽく整形してみる]

[はじめに]
マークダウン文書からテキストを抽出し、書式に従って整形を行うプログラムを作成しました。
テキストの整形については、以下を考慮しています。


・マークダウンの書式をテキスト文書でもある程度再現すること
・改行を再現すること
・インデントを再現すること


> 他に考慮していることはないのでしょうか？

ありますが、細かな内容については割愛しています。


[変換処理]
以下手順で文書の加工を行っています。


1. マークダウン文書(テキスト)をHTML文書(テキスト)に変換
2. HTML文書(テキスト)をHTMLドキュメント(オブジェクト)に変換
3. HTMLドキュメント(オブジェクト)に対し内容を加工
4. HTMLドキュメント(オブジェクト)をテキスト文書に変換
5. テキスト文書に対し内容を加工

public String markdownToText(String markdownString, boolean reverseEscapeRef, boolean replaceSpTab) {
    if (StringUtils.isBlank(markdownString)) {
        return "";
    }

    // MarkdownをHTMLに変換する
    com.vladsch.flexmark.util.ast.Document parsed = MD_PARSER.parse(markdownString);
    String html = HTML_RENDERER_STRICT.render(parsed);
    (略)


[参考]
vsch / flexmark-java( https://github.com/vsch/flexmark-java )
```

# 7. 改造のポイント

いかがでしょうか。そこそこ違和感のない出力になったのではないでしょうか。
しかしながら、改善の余地はまだあります。
たとえばこの処理では、tableタグに対して加工処理を行っていません。tableの加工はルールが複雑になりそうですが、チャレンジしてみるとよいかもしれません。
文字の装飾についても、より良い表現方法があれば書き換えてみてください。

また、利用環境に依っては、独自の加工処理が必要になることも考えられます。
ちなみに、私が実際の運用で使っているコードには、以下処理を加えています。

* 半角井桁(#)を全角井桁(＃)に置き換える
連携先のシステムで半角井桁に続く文字列がハッシュタグとして扱われてしまうため。
(注意) URL内の井桁は置き換え対象外とする。

* バックスラッシュ(円記号:"0x5C")を別の円記号("0xA5")に置き換える
連携先のシステムでバックスラッシュが特殊文字の一部(改行文字やファイルパス等)として扱われてしまうため。

# 8. 参考にさせていただいた情報
- [GitHub - vsch/flexmark-java](https://github.com/vsch/flexmark-java)
- [GitHub - vsch/flexmark-java-sample](https://github.com/vsch/flexmark-java-sample)
- [flexmark-java で Markdown を HTML に変換する - Qiita](https://qiita.com/niwasawa/items/5595c2fdbfb943c4cb96)