5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pandoc と Lua フィルターで HTML をサクッと Markdown 変換!余分なタグも一括除去

5
Last updated at Posted at 2025-09-17

やりたいこと

CursorにMarkdownの仕様書を食わせたいけど、手元にある仕様書がHTMLで書かれてる!!

こんなことで悩んでいるあなた!!

この記事を読めば、そのHTMLをMarkdownに楽々変換できちゃうかも?!

というわけで、ドキュメント変換ツール「Pandoc」と、その機能を拡張する「Luaフィルター」を組み合わせることで、特定のルールに沿ったHTML → Markdown変換を効率的に行えます。

注意点

一般のHTML全ての文章に通用する方法ではないため、あくまで参考程度になります。

やり方

まずは、作業に必要なツールをインストールしましょう。

1. HTMLで書かれた文章を用意する

変換したいHTMLファイル(例: input.html)を用意します。

2. Pandoc をインストールする

Pandocは、様々な形式のドキュメントを相互に変換できる強力なツールです。

Windowsの場合

winget install --source winget --exact --id JohnMacFarlane.Pandoc

Macの場合(Homebrew使用)

brew install pandoc

3. Luaフィルターファイルを用意する

次に、HTMLからMarkdownに変換する際のルールを記述したLuaファイル(例: filter.lua)を作成します。以下のコードをコピーして保存してください。

function Div(el)
  -- 内容を抽出
  return el.content
end

function Link(el)
  -- [](link) のように、表示文字列が空のリンクは生成しない
  if #el.content == 0 then
    return {}
  end
  -- htmlそのままの記法が返ってこないように工夫
  return pandoc.Link(el.content, el.target)
end

local function drop_first(cells)
  -- 1列目を落とす
  local out = {}
  for i = 2, #cells do
    out[#out + 1] = cells[i]
  end
  return out
end

function Table(tbl)
  -- 列情報を更新(更新しないと列を落としてもその分だけ自動補完される)
  if #tbl.colspecs > 1 then
    table.remove(tbl.colspecs, 1)
  end
  for _, body in ipairs(tbl.bodies) do
    for _, row in ipairs(body.body) do
      row.cells = drop_first(row.cells)
    end
    -- 最後の行に空行を追加(最下層に線を引く)
    local col_count = #body.body[1].cells
    local empty_row = pandoc.Row({})
    for i = 1, col_count do
      table.insert(empty_row.cells, pandoc.Cell({pandoc.Plain({pandoc.Str(" ")})}))
    end
    table.insert(body.body, empty_row)
  end
  return tbl
end

4. PandocとLuaを用いてHTMLをMarkdownに変換する

pandoc input.html -f html -t gfm -L filter.lua -o output.md

Pandocとは / Luaフィルター

PandocJohn MacFarLane氏によって開発されたフリーソフトウェアです。
様々なマークアップ形式(MarkdownやHTMLなど)を可逆変換あるいは非可逆変換することができます。
Pandocは入力言語を抽象構文木(AST)に構造化し、そのASTから出力言語を生成します。

PandocはLua言語で記述されたフィルターを用いることで、中間ASTの構造を書き換えることができます。
このLuaフィルターを用いて、HTMLからMarkdownに変換した際に発生する冗長な表現(無意味なコロン、空の表など)を除去することができます。

Luaフィルターの記述

Luaフィルターについては記述例を参考にすることで、Lua言語を書いたことのない方でも構文を理解できるようにしてあります。
もちろん、HTMLやMarkdownの構文についても触れるため、これらについてあまり覚えていない方も理解できるようにしてあります。

1. divタグの冗長表現の除去

入力

<div id="id1">
  <div id="id2">
    <div class="class1">
      <div class="class2">
        <p>content</p>
      </div>
    </div>
  </div>
</div>

<div> タグはdivisionの意で、1区分を作ります。
この区分自体に意味はありませんが、id='id' 等を付けることでCSSやJavaScriptから参照できるようになります。
Markdownにおいては、内容以外の部分は不要です。

出力(フィルターなし)

<div id="id1">

<div id="id2">

<div class="class1">

<div class="class2">

content

</div>

</div>

</div>

</div>

Markdownにおいて、<div> は不要なので、Luaフィルターでこの部分を除去します。

Luaフィルター

function Div(el)
  return el.content
end

出力(フィルターあり)

content

入力 el にはASTのDivノードが格納されます。
確認のため、以下のようなLuaフィルターをかけてみましょう。

function Div(el)
  print("el.t = " .. pandoc.utils.stringify(el.t))
  print("el.content = " .. pandoc.utils.stringify(el.content))
end

print("hoo") は標準出力(出力ファイルではない)に "hoo" を出力します。
.. は文字列の連結を表します。
Luaフィルターでは el の内容をそのまま print() 等で出力できないので、関数 pandoc.utils.stringify() を使います。

el.t = Div
el.content = content
el.t = Div
el.content = content
el.t = Div
el.content = content
el.t = Div
el.content = content

同じ文字列が4回繰り返しているのが確認できますが、これは <div> ダグが4個存在し、中身は全て "content" になっていることを表します。
ただし、これらのタグの内容は同一のオブジェクトを指していて、el.content のみを出力するように設計しても、出力ファイルには重複して出力されることはありません。

2. リンク付き見出しの冗長表現の除去

入力

<h2 id="_section1">
  <a class="anchor" href="#_section1"></a>
  <a class="link" href="#_section1">section1</a>
</h2>

<h2> タグはheader2の意で、2番目に大きな見出しのことです。
<a> タグはanchorの意で、アンカー(クリックすると指定したリンクへ飛ぶテキスト)のことです。
<a class href=link>text</a> の形で、link が指定するリンク、text がアンカーの表示文字列になります。
Markdownにおいては、idclass を直接扱うことはありません。そのままHTML記法として残しておくと、一部のMarkdownのビューアでは装飾された表示がされます。
しかし、今回の目的はCursorに食わせることなので、装飾の情報は余計なノイズを含む恐れがあるため除去する方針です。
また、text が空の <a> も不要です。

出力(フィルターなし)

## <a href="#_section1" class="anchor"></a> <a href="#_section1" class="link">section1</a>

この出力ファイルをVSCode等でプレビューしてみると正しく変換できていることがわかりますが、コード的には冗長な表現です(つまり、これを修正するのは実質無意味ですが、より簡潔な表現に直すことが目的なので、ここでは以下のような変換を行います)。
冗長でない表現に直すLuaフィルターは以下の通りです。

Luaフィルター

function Link(el)
  if #el.content == 0 then
    return {}
  end
  return pandoc.Link(el.content, el.target)
end

出力(フィルターあり)

##  [section1](#_section1)

配列 hoge の要素数を表すには、配列名の頭に # をつけて #hoge という表現をします。
el.content は文字列なので、#el.contentel.content の文字数を表します。
Luaフィルターでは、関数名と対応するASTのノードを引数として取り込みます。
ASTのノードがどうなっているかについては以下のコマンドで json 形式で確認できますが、ラベル名のついていないデータが大量に現れるため、確認は困難です。

pandoc input.html -f html -t json | jq . > ast.json

今回はASTを直接操作することについては詳しく述べません。興味のある方はぜひ調べてみてください。

return 文を書かなかったり、あるいは return {} と書くと、そのノードは出力ファイルに出現しなくなります。
Link(el) に関しては、return el と書いてしまうと、PandocはLinkノードを「再利用する」ノードとして認識するため、idclass が付随するとうまくMarkdownに落とし込めず、そのままHTMLの記法で出力されます。
return pandoc.Link(el.content, el.target) と書いた場合は、PandocはLinkノードを「再構築する」ノードとして認識するため、id 等の属性は捨てられ、Markdownの記法に直ります(というよりも、実はMarkdownのレンダリング(プレビュー)はHTMLへの変換なので、この処理は属性を捨てているだけで、無意味なのは当然と言えます...)。
つまり、この処理は「テキストのないアンカーは除去し、それ以外のアンカーはMarkdownの記法に直るよう細工する」というものです。
以下にMarkdownプレビューを示しておきます。

出力(フィルターなし)

section1

出力(フィルターあり)

section1

3. 表の冗長表現の除去

入力

<table class="class1">
  <tbody>
    <tr>
      <td class="class2"><p class="class3"></p></td>
      <td class="class2"><p class="class3">hoo</p></td>
      <td class="class2"><p class="class3">bar</p></td>
    </tr>
    <tr>
      <td class="class2"><p class="class3"></p></td>
      <td class="class2"><p class="class3">hoge</p></td>
      <td class="class2"><p class="class3">hoge</p></td>
    </tr>
  </tbody>
</table>

<table> タグは表を表します。
<tbody> タグは表の本体部分を表します(他にはヘッダー部分の <thead> タグ、フッター部分の <tfoot> タグがあります)。
<tr> タグはtable rowの意で、表の1行部分を表し、複数の <tr> タグを用いて複数行の表を表現できます。
<td> タグはtable data cellの意で、1データ部分を表します。
<p> タグはparagraphの意で、段落を表します。
Markdownにおいては、class 等の属性が不要です。
この入力例だと、各行の先頭に空のセルが入り込んでいるため、これを除去します。

出力(フィルターなし)

|     |      |      |
|-----|------|------|
|     | hoo  | bar  |
|     | hoge | hoge |

各行の先頭のセルの除去に加えて、最下の行に全て空のデータを含む1行を追加し、プレビューの際には最下にも罫線を引きましょう。

Luaフィルター

local function drop_first(cells)
  local out = {}
  for i = 2, #cells do
    out[#out + 1] = cells[i]
  end
  return out
end

function Table(tbl)
  if #tbl.colspecs > 1 then
    table.remove(tbl.colspecs, 1)
  end
  for _, body in ipairs(tbl.bodies) do
    for _, row in ipairs(body.body) do
      row.cells = drop_first(row.cells)
    end
    local col_count = #body.body[1].cells
    local empty_row = pandoc.Row({})
    for i = 1, col_count do
      table.insert(empty_row.cells, pandoc.Cell({pandoc.Plain({pandoc.Str(" ")})}))
    end
    table.insert(body.body, empty_row)
  end
  return tbl
end

出力(フィルターあり)

|      |      |
|------|------|
| hoo  | bar  |
| hoge | hoge |
|      |      |

tbl.colspecs は表の列数情報を格納した配列で、関数 table.remove(tbl.colspecs, 1) は1列目のデータを消去します。
これによって、表は2列で出力されるようになりますが、入力の表をそのまま変換しようとすると行の末端(一番右)のデータが除去されてしまいます。
そこで、Tableノード自体も行の最初(一番左)のデータを除去する必要があります。
関数 drop_first() は自作関数で、行の最初のデータを除去します。
その後、関数 table.insert() により、空のデータを含む行を追加します。
以下にMarkdownプレビューを示しておきます。

出力(フィルターなし)

hoo bar
hoge hoge

出力(フィルターあり)

hoo bar
hoge hoge

Pandoc コマンドオプション

実行するには以下のように書きます。

pandoc 入力ファイル -f html -t gfm -L フィルター -o 出力ファイル
  • -f : fromの意で、-f html は入力形式がHTMLであることを示します
  • -t : toの意で、-t gfm は出力形式がGFM(GitHub-Flavored Markdown)であることを示します
  • -L : Lua-Filterの意で、-L filter.luafilter.lua というLuaフィルターを用いることを示します
  • -o : outの意で、-o output.md は出力ファイル名が output.md であることを示します

また、-t markdown-t markdown_mmd-t markdown_phpextra-t markdown_strict というオプションもありますが、こちらは出力結果が若干異なります。
Markdownにも複数種類があるのですが、これらはその種類に応じた出力結果になります。
今回はVSCode(Visual Studio Code)やGitHub等のプレビューに沿いたいので、-t gfm を用います。

謝辞

  1. カワキ:変換手順書の大元を作成してもらいました。

  2. ReyoKatsu(Twitter/X):文章の校正を行ってもらいました。

このお二方に感謝の意を称します。

参考文献

[1] Pandoc 公式ドキュメント : Pandoc a universal document converter

更新履歴

  1. 2025/09/17:本記事を全体公開
  2. 2025/09/17:AI タグを追加
5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?