マークダウン等のマークアップ言語で書いた文章を読み上げ機能でより良く推敲する

最近は特に書籍に関連する仕事をさせて頂く事が多くなってきた。その中で記述した文書のチェックをする時、文を黙読するだけではなく音声読み上げ機能を使って正しい文章になっているかを確認している。モニターやスマホを使えない場所でも作業ができる為、とても効率的なチェック方法だ。また、おかしな表記になっている箇所は、おかしく読み上げてくれる為、問題のある箇所を発見しやすい。この方法をやっていない人はぜひ試してみて欲しい。詳しくは「読み上げ機能で誤字や脱字を減らす/文章を書く事を工夫する」で解説している。実際に作業をしていると、まだまだ改善の余地を見付ける事ができる。

読み上げ機能は何を使っても良い。外部サービスを使っても良いし、 voicevoxDocker コンテナをローカルに起動しそれをを使っても良い(これらの方法は別の機会に書く事にする)。 macOS を使用していて手っ取り早くこれを実現したいなら、 say コマンドを使用すると良いだろう。

マークダウンのテキストを say コマンドの標準入力にそのまま投入しても、まあそこそこ読み上げてくれる。しかし読み上げる必要のない文字まで読み上げてしまう。例えばURLやコードブロック等が該当する。例えば https://127.0.0.1:8080 というURLがあった場合、読み上げ機能は「エイチティティピーエスヤクニジュウナナピリオド…」のように読み上げる。プログラムのコードの場合も同様に頑張って読み上げてくれるのだが、プログラムは読み上げを聞いて理解できるものではない。そのため雑音を大量に聞いている気分になり、 頭痛が痛く なってくる。

そこでURLは「リンク」、コードブロックは「コードブロック」等の様に、実際のアルファベットを読み上げるのではなく、それが何なのかという事を読み上げるようにしたい。今回はこれを取り組む事にする。

PandocのLuaフィルターの仕組み

Pandoc 自体は Haskell で実装されている。ただ Pandoc 自体に Lua 処理系が組み込まれている為、出力処理を Lua を使用して拡張する事ができる。 Pandoc ではこの機能を Luaフィルター と呼ぶ。 LuaフィルターPandoc の解析器が解析したASTを受け取り、そのデータから必要な処理を行う。そしてそのデータは変換器へと渡される。この 必要な処理 を行う時、 AST の加工ができるため、精度の良い文章の操作が簡単に実現できる。

+--------------+
| 入力データ   |
+------+-------+
       |
       |
+------|---------------------------+
|      |                   pandoc  |
|      v                           |
|  +---+----------------------+    |
|  |                          |    |
|  |  解析器                  |    |
|  |                          |    |
|  +---+----------------------+    |
|      | AST                       |
|      v                           |
|  +---+----------------------+    |
|  |                          |    |
|  |  Luaフィルター           |    |
|  |                          |    |
|  +---+----------------------+    |
|      | AST                       |
|      v                           |
|  +---+----------------------+    |
|  |                          |    |
|  |  変換器                  |    |
|  |                          |    |
|  +---+----------------------+    |
|      |                           |
+------|---------------------------+
       |
       v
+------+-------+
| 出力データ   |
+--------------+
PandocとLuaフィルターによるデータの流れ

Luaフィルターを実装する

今回は、読み上げ時に邪魔になるような部分を、理解しやすい内容で置き換える事が目的だ。次の処理を行う。

  1. メタ情報は削除する。
  2. コードブロックの部分は文字列 "[コード]" で置き換える。
  3. 実行例の部分は文字列 "[実行例]" で置き換える。
  4. URLの部分は文字列 "[リンク]" で置き換える。

Luaフィルター の実装例では、 MetaCodeBlockDivLink に対し、要素の置き換えを行っている。

function Meta(elm)
   return;
end

function CodeBlock(elm)
   return pandoc.Str("[コード]");
end

function Div(elm)
   return pandoc.Str("[実行例]");
end

function Link(elm)
   return pandoc.Str("[リンク]");
end

Luaフィルター で実装する関数は、 AST の型名を関数名として関数を実装する。解析を実行しその型に到達すると、その関数が呼び出される。その時引数として、その要素を渡される。そこで加工などの必要な処理を行い、置き換えたいオブジェクトを返す事で、要素を置き換える事ができる。

Luaフィルターを実装する際に型名を調べる方法

Luaフィルター 用の関数を実装するには、当然、処理を行う対象の型名が必要になる。この型名は Pandoc のコードや公式ドキュメントを参照する事もできるだろうが、もっと簡単に調べる方法がある。 Pandoc には native という出力形式がある。この出力形式は、 ASTHaskell のコードとして出力する。そのため要素の型名は native 出力形式ではそのまま出力されている。これを使って Luaフィルター で実装する必要のある関数を特定する事ができる。

pandoc index.org --to native
出力形式にnativeを指定してPandocを実行する例

Luaフィルターを使ってPandocを実行し結果を読み上げる

Luaフィルター を使用してPandocを実行するには、 --lua-filter オプションの引数にLuaファイルのパスを渡す。

pandoc index.org --to plain --lua-filter filter.lua
index.orgをLuaフィルターを通してplain形式のテキストに変換する例

Luaフィルター が正しく実行されると、コードブロックやリンクの部分が置き換わる事が確認できるだろう。この出力を say コマンドで読み上げれば良い。読み上げ時にはテキスト以外の記述はない方が良いため、出力形式は plain を指定する。また say コマンドの -r 500 というのは読み上げの速度の指定だ。

pandoc index.org --to plain --lua-filter filter.lua | say -r 500
pandocの出力結果をsayで読み上げる

正しく実行されると読み上げを開始する。その際、雑音になるようなアルファベットの読み上げが、意味のある言葉に置き換わっている事を確認できるだろう。

あるディレクトリからマークダウン形式のファイルを読み上げるワンライナーは次のようになる。

find  ./TARGET_DIR -name '*.md' | sort -u | xargs cat | pandoc --from gfm --to plain --lua-filter filter.lua | say -r 400

ただし、これはファイルを一括してPandocが処理するため、ファイル数が多かったり、サイズが大きい場合は、処理に時間がかかってしまうという欠点もある。それを回避したい場合は、ループで処理すると良い。

Emacsからそれを使用する

読み上げ機能で誤字や脱字を減らす/文章を書く事を工夫する」ではリージョン選択している部分の文字列を say コマンドに渡す事で、読み上げを実現していた1。こういった機能を即座に実現できる所がEmacsの魅力だ。今回のこの Luaフィルター の成果をこのコマンドにも組み込む事にする。

Pandoc は標準入力からも入力データを受け付ける事ができる。そのため say コマンドにデータを流す前に Pandoc に流せば良い。これについても幾つかの実装方法を考える事ができる。 Bash 経由で実行しパイプで繋ぐ方法が順当ではあるが、部分的に実行したい要件がある事を考えると、 Pandoc は独立して実行し、それを独立して say に読み込ませる事が良さそうだ。 say にデータを渡すタイミングをハンドリングするために、 Pandoc の実行関数には、タスク完了時に実行されるフックを設定し、フックの実行は終了イベントに対するセンチネルを登録する。この方針で実装する。

まずは say.el を実装する。これは https://github.com/TakesxiSximada/emacs.d/blob/main/init.el#L252-L262 で実装済みのものだ。成長していきそうだから、ファイルとして独立させても良いかもしれない。ここでは、試しに say.el として実装する。

;;;###autoload
(defun say-on-region ()
  (interactive)
  (let ((proc (or (if-let* ((buf (get-buffer "*SAY*")))
                      (if-let* ((current-proc (get-buffer-process buf)))
                          (when (eq 'run (process-status current-proc))
                            current-proc)))
                  (start-process "*SAY*" (get-buffer-create "*SAY*")
                                 "say" "--rate" "250"))))
    (process-send-string proc (string-replace "\n" "" (buffer-substring-no-properties (region-beginning) (region-end))))
    (process-send-string proc "\n")))

(defun say-enter ()
  (interactive)
  (process-send-string (get-process "*SAY*") "\n"))

(provide 'say)

次に pandoc-with-filter.el を実装する。これは Pandoc の処理を管理する。 pandoc という名前のモジュールは既にMELPAに登録されていた為、このモジュールは pandoc-with-filter という名前にする。まあMELPAに登録するつもりはあまりない。余談だけれど、Emacsのパッケージングの仕組みはNPMやPyPIやGemのような強い中央集権的なものは、あまり適していないのではないかと思う。それはパッケージのインポート時にasで名前を変えられない事や、そもそもとても小さなパッケージが多い事などがそう思う理由だ。私はELPAとOrgとMELPAからパッケージを引いているけれど、この部分についても考える時間を取りたい。話が逸れたが pandoc-with-filter では変換の実行後に呼び出されるフックを提供している。このフックに say-on-region のような関数を設定する事で、読み上げが実現できる。

(defvar pandoc-with-filter-exit-hook nil)

(defcustom pandoc-with-filter-lua-file (file-name-concat
                                        (file-name-directory
                                         (or load-file-name buffer-file-name))
                                        "filter.lua")
  "")


;; (add-hook 'pandoc-with-filter-exit-hook 'say-on-region)

(defun pandoc-with-filter-sentinel (process signal)
  (when (string-equal signal "finished\n")
    (run-hooks 'pandoc-with-filter-exit-hook)))

;;;###autoload
(defun pandoc-with-filter-on-region ()
  (interactive)
  (let ((proc (start-process
               "*PANDOC*"
               (let ((buf (get-buffer-create "*PANDOC*")))
                 (with-current-buffer buf (erase-buffer))
                 buf)
               "pandoc"
               "--lua-filter" pandoc-with-filter-lua-file
               "--from" "gfm"
               "--to" "plain")))
    (set-process-sentinel proc #'pandoc-with-filter-sentinel)
    (process-send-region proc (region-beginning) (region-end))
    (process-send-eof proc)
    (process-send-eof proc)  ;; EOFは2回送信しないと認識しないプログラムがある
    ))

(provide 'pandoc-with-filter)

最後に filter.lua だ。これは先程掲載したものと同じものだ。

function Meta(elm)
   return;
end

function CodeBlock(elm)
   return pandoc.Str("[コード]");
end

function Div(elm)
   return pandoc.Str("[実行例]");
end

function Link(elm)
   return pandoc.Str("[リンク]");
end

まだ整理がしきれいていないけれど、この方針で実施した実装によって、Emacsから 良い感じ の読み上げを実行できるようになった。

まとめ

今回は作成した文章を読み上げ機能で推敲する方法について取り組んだ。 Pandoc での形式の変換時に、読み上げるには不適切なURLやコードブロックを読み上げに適した表現に置き換えるための Luaフィルター を実装した。そして出力を読み上げ用コマンドに渡す事方法を確認した。

以前に「読み上げ機能で誤字や脱字を減らす/文章を書く事を工夫する」で行った取り組みではEmacs拡張として、テキスト読み上げを実装していたが、今回の内容はそこに反映できるものになった。時間を見付けて、内容を更新したい。