先に謝っておきます。「完璧」は言い過ぎました。正確には「自分の用途では完璧になった」です。
ただ、そこに至るまでに気付いたことがいくつかあって、それは他の人にも当てはまるんじゃないかと思ったので書きます。技術的な話が半分、それ以外が半分です。
1. Markdown が「書くもの」から「受け取るもの」に変わった
ここ 1〜2 年で、自分の Markdown との付き合い方がはっきり変わりました。
AI に何か頼むと、返ってくるのは Markdown です。
- コーディングエージェントが出す設計メモや手順書
- チャットに投げた質問への回答
- 会議の文字起こしから作らせた議事録
- 論文や仕様書を読ませて作らせた要約
気が付くと、自分が書く Markdown より、AI から受け取る Markdown の方が量が多くなっていました。
そして受け取ってから、毎回同じところで引っかかっていました。
2. 受け取った Markdown で困る 3 つのこと
2-1. Mermaid が図にならない
AI は図を出したがります。当然で、テキストしか返せない相手にとって Mermaid は「絵を描く」ほぼ唯一の実用的な手段だからです。処理の流れを説明させると、かなりの頻度でこれが返ってきます。
```mermaid
flowchart TD
A[下書き] --> B{承認?}
B -->|はい| C[公開]
B -->|いいえ| A
```
ところが手元のエディタがこれをただのコードブロックとして表示すると、せっかく構造化された情報が、読みにくいテキストに退化します。
「GitHub に貼れば見える」は正しいのですが、社内の資料や顧客向けの下書きを毎回 GitHub に貼るわけにもいきません。
2-2. 日本語の改行が消える
これが個人的に一番きつかった問題です。
AI の出力は、読みやすさのために段落の途中で改行を入れてきます。ところが CommonMark では単独の改行は「単語の区切り」=半角スペースとして扱われます。
つまり、こう書かれたものが、
本日の会議では次の 3 点を決定した。
まず、リリース日を 8 月 28 日とする。
次に、テスト期間を 2 週間確保する。
こう表示されます。
本日の会議では次の 3 点を決定した。 まず、リリース日を 8 月 28 日とする。 次に、テスト期間を 2 週間確保する。
1 行につながるうえに、日本語の文中に半角スペースが入ります。
英文なら「改行を単語の区切りとして扱う」のは完全に正しい仕様です。Hello と world が Helloworld になったら困ります。でも日本語には単語間スペースがないので、これは単なるゴミになります。
そして厄介なのは、人間が書く Markdown より AI の出力の方が改行が多いことです。人間は 1 段落を 1 行で書きがちですが、AI は律儀に改行を入れてきます。だから AI 時代になって、この問題に当たる頻度が上がりました。
2-3. そのまま人に渡せない
受け取った内容を上司や顧客に渡す段になると、Word か PDF を求められます。Markdown のまま送って喜ばれる相手は、残念ながら多くありません。
ここは変換ツールを噛ませればいい話ではあるのですが、毎回コマンドを叩くのは面倒だし、Mermaid の図がどう扱われるかが問題になります。
3. 結局、自分で作りました
3 つとも「既存のエディタの設定をいじれば解決」ではなかったので、Mac 用に作りました。Markdocx という名前で公開しています。
リアルタイムでのMarkdown編集/表示に加え、mermaidグラフの描画、さらに.docxへの書き出しに対応しています。Claude Codeを活用した自前実装ですね。
以下、作るうえで面白かった部分を書きます。
4. 実装で引っかかったところ
4-1. Mermaid はオフラインで描く
Mermaid の描画は、公式の JS をそのまま使うのが確実です。ただし CDN から読むのはやめました。
- サンドボックスアプリで外部通信を前提にしたくない
- 社内資料の内容を外部サーバに送る形になりかねない
- オフラインで開けないエディタは使い物にならない
mermaid.min.js(3.4MB)をアプリに同梱し、WKWebView に読み込ませています。アプリ全体が 6.1MB なので、半分以上が Mermaid という構成です。それでも外部依存を無くす価値の方が大きいと判断しました。
結果、mermaidは、Markdocxではこのように描画されます。
4-2. NSImage の SVG 描画は Mermaid を殺す
Word に図を埋め込むには、Mermaid が吐いた SVG を PNG にする必要があります。
macOS の NSImage は SVG を読めるので、最初はこう書きました。
// これは動かない
guard let image = NSImage(data: svgData) else { return nil }
一応「動き」ます。ただし出てくる画像がおかしい。
-
図形の色が全部同じになる —
<style>内のクラス指定が無視されるため、既定色で塗りつぶされる -
マインドマップの文字が全部消える — Mermaid はラベルに
<foreignObject>(SVG 内に HTML を置く仕組み)を使うが、これに対応していない - タイムラインのタイトルが出ない、文字が二重に重なる
NSImage の SVG 対応は、SF Symbols のような単純な図形を想定したもので、CSS と <foreignObject> を使い倒す Mermaid の出力には力不足でした。
面倒なのは、プレビュー(WebKit)では正常に見えていることです。SVG そのものは正しく、ラスタライザだけが問題でした。
解決策は「プレビューと同じ WebKit に描かせる」ことです。
func png(fromSVG svg: String, scale: CGFloat = 2) async -> Data? {
guard let size = Self.viewBoxSize(in: svg) else { return nil }
// 画面外に置いた WKWebView に SVG をインラインで埋め込んで読ませる
webView.frame = NSRect(origin: .zero, size: size)
await load(html: html(embedding: svg, size: size))
// いったん PDF にする。ビューの高さを超える図でも切れない
let config = WKPDFConfiguration()
config.rect = NSRect(origin: .zero, size: size)
guard let pdf = try? await webView.pdf(configuration: config) else { return nil }
// PDF はベクタなので、好きな倍率でラスタライズできる
return Self.pngData(fromPDF: pdf, size: size, scale: scale)
}
途中で PDF を挟んでいるのは、takeSnapshot だとビューに収まる範囲しか撮れないからです。縦に長いマインドマップが切れます。PDF ならページ全体が入り、しかもベクタのまま取れるので、あとから 2 倍でも 3 倍でも起こせます。
SVG を <img> で読み込む方法もありますが、<foreignObject> の扱いがブラウザによって違うので、DOM に直接インラインで置く方が確実でした。
4-3. 日本語の改行問題をロケールで切り替える
2-2 の問題です。実装自体は 1 行で、SoftBreak を <br /> にするかどうかです。
/// CommonMark では空白扱い(英文の単語が繋がらないようにするため)だが、
/// 設定が有効なら見たままの改行として扱う。
mutating func visitSoftBreak(_ softBreak: SoftBreak) -> String {
softBreakAsLineBreak ? "<br />" : "\n"
}
難しいのは既定値をどうするかでした。
- 常にオンにすると、GitHub や他のツールと表示が食い違う。英語圏のユーザーには余計なお世話
- 常にオフにすると、日本語ユーザーは全員が設定画面を探すことになる
そこで 初回起動時にアプリの表示言語を見て決めることにしました。
var softBreakAsLineBreak: Bool {
get {
if let stored = defaults.object(forKey: Key.softBreakAsLineBreak) as? Bool {
return stored
}
let initial = Self.isJapaneseEnvironment
defaults.set(initial, forKey: Key.softBreakAsLineBreak) // 決めた値をその場で保存
return initial
}
set { defaults.set(newValue, forKey: Key.softBreakAsLineBreak); postChange() }
}
/// システムの優先言語とアプリのローカライズを突き合わせた結果で判断する
static var isJapaneseEnvironment: Bool {
Bundle.main.preferredLocalizations.first?.hasPrefix("ja") ?? false
}
ポイントは **「毎回ロケールを見る」のではなく「初回だけ見て、その値を保存する」**ところです。
毎回判定する実装にすると、あとから表示言語を切り替えたときにすでに書いた文書の見え方が勝手に変わります。設定は最初の一度だけ決めて、以降は保存値だけを見る。ユーザーが設定画面で変更したときも当然そちらが優先されます。
Locale.current ではなく Bundle.main.preferredLocalizations を使っているのは、「システムの言語」ではなく **「実際にアプリが何語で表示されているか」**を見たかったからです。画面の言語と改行の挙動が必ず一致します。
4-4. プレビューと書き出しで図の色を揃える
図に色を付けられるようにしたのですが、ここで一度失敗しました。
最初はプレビューを prefers-color-scheme に追随させ、ダークモードでは Mermaid のダークテーマを使っていました。ところが Word や PDF は白い紙面なので、書き出しのときだけライトテーマに切り替えていました。
結果、同じ書類なのに、画面と Word で図の色が違うという状態になっていました。しかも PDF は画面の描画をそのまま使うので PDF だけダーク、という三つ巴です。
configuration を分けるのをやめて、外観に追随させないことにしました。
window.mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
theme: 'base', // 組み込みテーマではなく base + 変数指定
themeVariables: themeVars // Swift 側から渡す
});
theme: 'base' と themeVariables を使うと色を任意に指定できます。配色は Swift 側で 6 種類定義し、書類ごとに選べるようにしました。
このとき地味に効いたのが、系列色を図の種類をまたいで共通にすることです。Mermaid は図の種類ごとに別の変数を見ます。タイムラインは cScale0..、円グラフは pie1..、Git グラフは git0.. です。ここに同じ 6 色を割り当てておかないと、同じ書類の中で図ごとに色調がバラバラになります。
for (index, color) in scale.enumerated() {
v["cScale\(index)"] = color // タイムライン / マインドマップ
v["pie\(index + 1)"] = color // 円グラフ
v["git\(index)"] = color // Git グラフ
}
外観に追随しなくなった代わりに、ダークモードでも図が読めるよう 図を白い下地に載せるようにしました。結果として、画面で見えているものがそのまま Word にも PDF にも入ります。
4-5. Word / EPUB 書き出しは自前で実装した
これは長くなるので別記事にするかもしれません(需要があれば...)。pandoc を同梱しようとしてライセンス・サイズ・コード署名の 3 つで詰んだ結果、.docx と .epub の生成を自前で書いた話です。
要点だけ書くと、.docx も .epub も中身は ZIP + XML なので、Markdown から生成できる範囲に絞れば自前で書けます。ZIP ライタ 160 行を含めて 936 行で、179MB の pandoc バイナリを置き換えました。
5. 「完璧」ではない部分
タイトルの回収をしておきます。
- 脚注と数式に未対応。使っていないので後回しにしています
- Mermaid のバージョンは同梱したもので固定。新しい記法は追随が要ります
- 書き出し専用。docx を読み込むことはできません
- Mac のみ
「AI から受け取った Markdown を、読んで、整えて、人に渡す」という一本道に絞ったから成立している、というだけの話です。
まとめ
- AI とのやり取りが増えて、Markdown は「書くもの」から「受け取るもの」に変わった
- 受け取る側に回ると、Mermaid のレンダリング・日本語の改行・他形式への書き出しが効いてくる
-
NSImageの SVG 描画は Mermaid の出力を正しく扱えない。WebKit に描かせて PDF 経由でラスタライズするのが確実 - CommonMark のソフトブレークは日本語と相性が悪い。初回起動時のロケールで既定値を決め、以降は保存値を使うのが落としどころ
- Mermaid の配色は
theme: 'base'+themeVariables。系列色を図の種類をまたいで揃えると統一感が出る
同じことで困っている人の役に立てば幸いです。
なお、この記事自体も Markdocx で書きました。

