Pandoc謹製シンタックスハイライト機構Skylighting
「コード片のシンタックスを解釈してキーワードなどをハイライトするツール」というと、おそらくPygmentsがもっとも有名でしょう。PygmentsはPython製で、同じくPython製のドキュメントシステムであるSphinxの内部でも使われています。pigmentize
というコマンドとしても利用可能。RubyにもPygments gemというラッパーがあり、こちらはRe:VIEWの内部で使われてるようです。LaTeXにもPygmentsを利用したmintedというパッケージがあります。
一方、ドキュメントシステムの世界にはPandocという怪物めいた代物がありますが、こちらではPygmentsを利用していません。その代わり、Skylightingという別のツールが独自に開発されており、これを内部で使ってシンタックスハイライトを実現しています。
PygmentsとSkylighting、どっちが優秀かといった比較にはあまり意味がないと思うのですが、ドキュメントシステムとしてPandocを利用する場合にはSkylightingしか選択肢はありません。もっとも、それだけPandocと密結合しているということでもあるので、通常は何も考えずにPandocの機能の一部として使えるようになっています。しかし、Pandocの標準からはずれたことをしようと思うとちょっと厄介です。ドキュメントも必要最小限しかないので、ソースを読んだりして手探りで抜け道を探すことになります。毎回、思い出すのが大変なので、2022年12月時点でのSkylightingの使いこなしについて、以下のような話を書き出しておきます。
- コマンドとしての使い方
- デフォルトで対応してない言語などのシンタックスハイライトにどう対応するか
- Pandocとの関係
- Haskellのライブラリとして使うとき
- Pandocのフィルターで使うとき
コマンドとしての使い方
Skylightingは単独のコマンドとして使えます。使い勝手は pigmentize
を簡素化した感じ。「ハイライトしたいコードなどの種類」、「出力の形式」、「色合い」をオプションで指定して、対象のコードを含むファイルもしくは標準入力をコマンドに渡すだけです。たとえば、標準入力に書いたRubyのコードをシンタックスハイライトしてHTMLとして出力したいと思ったら、こんな感じにskylighting
コマンドを実行します(標準入力からの入力終了は[Ctrl-D]
で伝えられます)。
$ skylighting -s ruby -f html -r
def a
p 1
end
[Ctrl-D]
<div class="sourceCode"><pre class="sourceCode"><code class="sourceCode"><span id="1"><a href="#1" aria-hidden="true" tabindex="-1"></a><span class="cf">def</span> a</span>
<span id="2"><a href="#2" aria-hidden="true" tabindex="-1"></a> <span class="fu">p</span> <span class="dv">1</span></span>
<span id="3"><a href="#3" aria-hidden="true" tabindex="-1"></a><span class="cf">end</span></span></code></pre></div>
-r
オプションは、コード片だけを出力するために指定します。これを指定しないと、HTMLヘッダやらCSSやらを含んだ「スタンドアローン」なHTMLファイルを出してくれます。
参考までに、pygmentize
コマンドだとこんな感じ。
$ pygmentize -l ruby -f html
def a
p 1
end
[Ctrl-D]
<div class="highlight"><pre><span></span><span class="k">def</span> <span class="nf">a</span>
<span class="nb">p</span> <span class="mi">1</span>
<span class="k">end</span>
</pre></div>
pygmentize
コマンドの場合は、とくにオプションをつけなくてもスタンドアローンなHTMLファイルではなくコード片のみが出力されます。スタンドアローンなHTMLファイルを得たい場合には、skylighting
とは逆に、そのためのオプションとして-O full
を指定する必要があります。
デフォルトで対応してない言語などのシンタックスハイライトにどう対応するか
Pygmentsでは、自分に必要なシンタックスハイライトの定義がない場合、Pythonのコードを書いて拡張できます。Pygmentsではこの方法で書いた定義のことをレキサーと呼んでいます。独自のレキサーの書き方については以下のマニュアルを参照。
一方、Skylightingでは、KDEのテキストエディタKATEに内臓されたシンタックスハイライト用エンジン(下記URL参照)が利用している定義ファイルを流用しています。
KATEにおけるシンタックスハイライトの定義ファイルはXML形式で、以下で一覧できます。形式こそXMLですが、Pygmentsのレキサーと同じような考え方で書かれていることがうかがえます。つまり、キーワードとなりうる文字列と、それをキーワードとして扱うべき文脈を規定する、という感じです。
Skylightingで独自のシンタックスハイライトをさせたい場合には、上記にぴったりのXMLがあればそれ、なければ近い内容のXMLをもってきて手を入れて使えばよさそうだ、と想像できます。
実際、Skylightingをコマンドとして使う場合には、シンタックスハイライトを定義したXMLを--definition
オプションで指定するだけです。たとえば、現状SkylightingのデフォルトはTSVフォーマットのハイライトには対応していませんが、KATEの定義ファイルにはtsv.xml
があるので、これをもってきて次のようにすれば、TSV形式の文字列をシンタックスハイライトできてしまいます。
$ skylighting --definition tsv.xml --syntax tsv -f ansi -r -C 16
Loaded syntax definition for TSV
1[\t] 2[\t] 3[\t]
a[\t] b[\t] c[\t]
[Ctrl-D]
上記では出力結果を省いていますが、出力形式のオプションとして-f ansi
(ANSI escape code)を指定しているので、端末上でカラー出力されます。この機能はpygmentize
にはないはず。
ちなみに --syntax tsv
は必須です。ここはtsv.xml
のlanguage
タグのextensions
属性を見ているようです。
なお、この例は「なんとなくうまくいく」ような書き方をしていますが、このXMLはKATEというエディタのハイライト用であることに注意してください。つまり、あくまでも「KATEでそれっぽく表示されればいいや」という定義になっています。KATE用のシンタックスハイライトのルールについては、以下にXMLの構文的なものが説明されているので、実用上は自分の必要に応じて適宜直していく必要があります。
Pandocとの関係
前述のとおり、SkylightingはPandocにおけるシンタックスハイライト処理に使われています。したがって、たとえばpandoc
コマンドでmarkdownを変換すると、markdownに含まれるコードブロックがSkylightingでパースされ、ハイライトのためのメタ情報が付加されます。
$ pandoc -f markdown
```ruby
def a
p 1
end
```
[Ctrl-D]
<div class="sourceCode" id="cb1"><pre
class="sourceCode ruby"><code class="sourceCode ruby"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a><span class="cf">def</span> a</span>
<span id="cb1-2"><a href="#cb1-2" aria-hidden="true" tabindex="-1"></a> <span class="fu">p</span> <span class="dv">1</span></span>
<span id="cb1-3"><a href="#cb1-3" aria-hidden="true" tabindex="-1"></a><span class="cf">end</span></span></code></pre></div>
上記の出力結果を、先ほどskylighting
コマンドでシンタックスハイライトした結果と比べると、言語名などのメタ情報が増えているほかは、ほぼほぼ同じものであることがわかると思います。
デフォルトで対応してない言語なども、skylighting
コマンドのときと同様に、KATEのXML定義を流用してシンタックスハイライトできます。ただしpandoc
コマンドとskylighting
コマンドではオプションの名前が異なります。pandoc
コマンドでKATE用のXML定義を指定するには--syntax-definition
オプションを使います。たとえば先にskylighting
コマンドの実行例と同じTSV形式をシンタックスハイライトしたい場合なら、こんなふうにpandoc
コマンドを実行することになります([\t]
はTabキーの入力、[Ctrl-D]はCtrl-Dの入力です)。
$ pandoc -f markdown -t html --highlight tango -o temp.html -s --syntax-definition tsv.xml
```tsv
1[\t] 2[\t] 3[\t]
a[\t] b[\t] c[\t]
```
[Ctrl-D]
しかし、実際にやってみるとわかりますが、これはうまくいきません。なぜなら、Pandocでは入力のタブをスペースとして扱ってしまうから。つまりSkylighting単体でうまくいってもPandocでうまくいくとは限らないのです。
どうすればPandocでこれをシンタックスハイライトできるでしょうか。おそらくtsv.xml
をいじるしかありません。たとえば、このときKATEのソースから拝借してきたtsv.xml
はこんなふうになっています。
<!DOCTYPE language SYSTEM "language.dtd" [
<!ENTITY sep "	">
]>
...(中略)...
<language name="TSV" ... extensions="*.tsv;*.tab" ...
<highlighting>
<contexts>
<context name="Column0" lineEndContext="#stay" attribute="Column 0">
<DetectChar char="&sep;" context="#pop!Column1" attribute="Column 0 Separator"/>
</context>
...
TSVファイルにおけるセル(各行の列)の区切りを<DetectChar char="&sep;"...
という部分で指定するようになっていることが読み取れます。この場合はTSVファイルですから「Tab区切り」を検出したいので、冒頭のENTITY宣言において&sep;
をTab文字と定義されています。これをスペースにすれば(つまり<!ENTITY sep " ">
とすれば)、Pandocがスペースとして読み取ってしまったTab文字を検出できるでしょう。
実際、ここを変えると、pandoc
コマンドでもtsv.xml
でシンタックスを検出できます。しかし、ここをスペースに変えたものは、もはや「TSV形式のシンタックスハイライトの定義」とはいえないでしょう。Pandocには、この手の「余計なお世話」がかなりあるので、ときどき足をすくわれます。
Haskellのライブラリとして使うとき
Skylightingは、Haskellのプログラムの中でライブラリとして使うこともできます。もっとも原始的な使い方は、あらかじめ読み込まれているdefaultSyntaxMap
から必要なシンタックスハイライトの定義を取り出し、それをtokenize :: TokenizerConfig -> Syntax -> Text -> Either String [SourceLine]
関数に指定してTextをまるっとハイライト処理してもらう、という感じです。具体的には次のようなコードを書くことになります。
{-# LANGUAGE OverloadedStrings #-}
module Main where
import qualified Data.Text.IO as T (readFile)
import System.Environment (getArgs)
import Data.Maybe (fromJust)
import Data.Either (rights)
import Skylighting
import Text.Blaze.Html.Renderer.Text (renderHtml)
main :: IO ()
main = do
fn:_ <- getArgs
txt <- T.readFile fn
let opts = TokenizerConfig { syntaxMap = defaultSyntaxMap
, traceOutput = False }
syntax = fromJust $ "ruby" `lookupSyntax` defaultSyntaxMap
mapM_ printHtml $ rights [tokenize opts syntax txt]
printHtml = print . renderHtml . (formatHtmlBlock defaultFormatOpts)
独自のシンタックスハイライトの定義を追加するときにはparseSyntaxDefinition
とaddSyntaxDefinition
を使います。
{-# LANGUAGE OverloadedStrings #-}
module Main where
import qualified Data.Text.IO as T (readFile)
import System.Environment (getArgs)
import Data.Maybe (fromJust)
import Data.Either (rights)
import Skylighting
import Text.Blaze.Html.Renderer.Text (renderHtml)
main :: IO ()
main = do
fn:_ <- getArgs
txt <- T.readFile fn
-- print txt
customSyntax <- parseSyntaxDefinition "tsv.xml"
let syntax = case customSyntax of
Left e -> error e
Right s -> s
opts = TokenizerConfig { syntaxMap = addSyntaxDefinition syntax defaultSyntaxMap
, traceOutput = True }
mapM_ printHtml $ rights [tokenize opts syntax txt]
printHtml = print . renderHtml . (formatHtmlBlock defaultFormatOpts)
Pandocのフィルターで使うとき
Pandocには、toJSONFilterという仕組みがあり、これを使ってReaderとWriterの間で追加の変換をかけることができます。シンタックスハイライトとの絡みでは、特殊なエスケープ処理や、コードブロックを囲む環境やクラスをカスタマイズしたいという要求から、このtoJSONFilterの中で個別にSkylightingを呼び出したいときがあります。これにはPandoc.Highlightingで用意されているhighlight
関数を使うとよいでしょう。この関数がPandocの入出力(ReaderとWriter)とうまく連携してくれます。
以下は、PandocのLaTeX出力でコードブロックに使われる環境をデフォルトのものから変える例です。PandocのLaTeX出力ではコードブロックの出力でframed
パッケージが使われるのだけど(正確にはSkylighting.Format.LaTeXのstyleToLaTeXをそのままPandocで使っている)、いまどきframed
はないと思うので、これを独自のものに変えたい場合にはこのようなフィルターを書く必要があります(Pandoc内部のデータ構造がStringだったちょっと古い頃の情報だけどこちらの記事も参照)。
{-# LANGUAGE OverloadedStrings #-}
import Text.Pandoc.JSON
import Text.Pandoc.Highlighting (highlight)
import Skylighting
import Skylighting.Format.LaTeX
import qualified Data.Text as T
import Data.Char (isSpace)
main :: IO ()
main = toJSONFilter block
block (CodeBlock attr contents) =
RawBlock (Format "tex") $ doCodeHighlight contents
where
doCodeHighlight s =
case highlight defaultSyntaxMap formatLaTeXBlock attr s of
Left e -> s
Right cnt -> cnt
formatLaTeXBlock :: FormatOptions -> [SourceLine] -> T.Text
formatLaTeXBlock opts ls = T.unlines
[ "\\begin{Verbatim}"
, formatLaTeX False ls
, "\\end{Verbatim}"
]
formatLaTeX :: Bool -> [SourceLine] -> T.Text
formatLaTeX inline = T.intercalate (T.singleton '\n')
. map (sourceLineToLaTeX inline)
sourceLineToLaTeX :: Bool -> SourceLine -> T.Text
sourceLineToLaTeX inline = mconcat . map (tokenToLaTeX inline)
tokenToLaTeX :: Bool -> Token -> T.Text
tokenToLaTeX inline (NormalTok, txt)
| T.all isSpace txt = txt
tokenToLaTeX inline (toktype, txt) = T.cons '\\'
(T.pack (show toktype) <> "{" <> txt <> "}")
block bs = bs
Haskellに慣れていないとちょっとびっくりするコードだと思いますが、要するに「formatLaTeXBlock
を自分で定義したものにしてhighlight
に渡す」という処理が必要になるということです。
実行するときは、このコードを保存したファイルを--filter
に指定します。
$ pandoc -f markdown -t latex --filter temp.hs
```ruby
def a
p 1
end
```
[Ctrl-D]
\begin{Verbatim}
\ControlFlowTok{def}\NormalTok{ a}
\FunctionTok{p} \DecValTok{1}
\ControlFlowTok{end}
\end{Verbatim}
注意が必要なのは、このようにフィルターを使っている場合、独自シンタックスハイライト用のXMLをPandoc標準の--syntax-definition
で読み込んでも無効になることです。当然といえば当然なのですが、上記のコードでhighlight
関数に渡しているdefaultSyntaxMap
を自分で拡張してあげる必要があります。前項で説明したparseSyntaxDefinition
とaddSyntaxDefinition
を使うだけなので、それ自体は難しくはないのですが、問題は上記のフィルターの文脈がIOではないことです。そのため、上記の路線でやるならunsafePerformIO
が必要になります。
(略)
import qualified System.IO as IO
import qualified System.IO.Unsafe as UNSAFE
(略)
doCodeHighlight s =
case highlight syntaxmap formatLaTeXBlock attr s of
Left e -> s
Right cnt -> cnt
syntaxmap = addSyntaxDefinition
(UNSAFE.unsafePerformIO customSyntax)
defaultSyntaxMap
customSyntax :: IO Syntax
customSyntax = do
res <- parseSyntaxDefinition "tsv.xml"
case res of
Left e -> error e
Right s -> return s
(略)
もしくは、toJSONFilter
にわたす関数をIOに持ち上げるか。
{-# LANGUAGE OverloadedStrings #-}
import Text.Pandoc.JSON
import Text.Pandoc.Highlighting (highlight)
import Skylighting
import Skylighting.Format.LaTeX
import qualified Data.Text as T
import Data.Char (isSpace)
main :: IO ()
main = toJSONFilter block
block (CodeBlock attr contents) = do
res <- parseSyntaxDefinition "tsv.xml"
let customSyntax = case res of
Left e -> error e
Right s -> s
syntaxmap = addSyntaxDefinition customSyntax defaultSyntaxMap
doCodeHighlight s =
case highlight syntaxmap formatLaTeXBlock attr s of
Left e -> s
Right cnt -> cnt
return $ RawBlock (Format "tex") $ doCodeHighlight contents
where
formatLaTeXBlock :: FormatOptions -> [SourceLine] -> T.Text
formatLaTeXBlock opts ls = T.unlines
[ "\\begin{Verbatim}"
, formatLaTeX False ls
, "\\end{Verbatim}"
]
formatLaTeX :: Bool -> [SourceLine] -> T.Text
formatLaTeX inline = T.intercalate (T.singleton '\n')
. map (sourceLineToLaTeX inline)
sourceLineToLaTeX :: Bool -> SourceLine -> T.Text
sourceLineToLaTeX inline = mconcat . map (tokenToLaTeX inline)
tokenToLaTeX :: Bool -> Token -> T.Text
tokenToLaTeX inline (NormalTok, txt)
| T.all isSpace txt = txt
tokenToLaTeX inline (toktype, txt) = T.cons '\\'
(T.pack (show toktype) <> "{" <> txt <> "}")
block bs = return bs
いずれにしても、pandoc
コマンドの実行時にはもちろん--syntax-definition
は不要で、上記のフィルターを--filter
で指定するだけです。
Discussion