マークダウン等のマークアップ言語で書いた文章を読み上げ機能でより良く推敲する
最近は特に書籍に関連する仕事をさせて頂く事が多くなってきた。その中で記述した文書のチェックをする時、文を黙読するだけではなく音声読み上げ機能を使って正しい文章になっているかを確認している。モニターやスマホを使えない場所でも作業ができる為、とても効率的なチェック方法だ。また、おかしな表記になっている箇所は、おかしく読み上げてくれる為、問題のある箇所を発見しやすい。この方法をやっていない人はぜひ試してみて欲しい。詳しくは「読み上げ機能で誤字や脱字を減らす/文章を書く事を工夫する」で解説している。実際に作業をしていると、まだまだ改善の余地を見付ける事ができる。
読み上げ機能は何を使っても良い。外部サービスを使っても良いし、 voicevox
の Docker
コンテナをローカルに起動しそれをを使っても良い(これらの方法は別の機会に書く事にする)。 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
の加工ができるため、精度の良い文章の操作が簡単に実現できる。
Luaフィルターを実装する
今回は、読み上げ時に邪魔になるような部分を、理解しやすい内容で置き換える事が目的だ。次の処理を行う。
- メタ情報は削除する。
- コードブロックの部分は文字列
"[コード]"
で置き換える。 - 実行例の部分は文字列
"[実行例]"
で置き換える。 - URLの部分は文字列
"[リンク]"
で置き換える。
Luaフィルター
の実装例では、 Meta
、 CodeBlock
、 Div
、 Link
に対し、要素の置き換えを行っている。
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
という出力形式がある。この出力形式は、 AST
を Haskell
のコードとして出力する。そのため要素の型名は native
出力形式ではそのまま出力されている。これを使って Luaフィルター
で実装する必要のある関数を特定する事ができる。
Luaフィルターを使ってPandocを実行し結果を読み上げる
Luaフィルター
を使用してPandocを実行するには、 --lua-filter
オプションの引数にLuaファイルのパスを渡す。
Luaフィルター
が正しく実行されると、コードブロックやリンクの部分が置き換わる事が確認できるだろう。この出力を say
コマンドで読み上げれば良い。読み上げ時にはテキスト以外の記述はない方が良いため、出力形式は plain
を指定する。また say
コマンドの -r 500
というのは読み上げの速度の指定だ。
正しく実行されると読み上げを開始する。その際、雑音になるようなアルファベットの読み上げが、意味のある言葉に置き換わっている事を確認できるだろう。
あるディレクトリからマークダウン形式のファイルを読み上げるワンライナーは次のようになる。
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拡張として、テキスト読み上げを実装していたが、今回の内容はそこに反映できるものになった。時間を見付けて、内容を更新したい。