🔖

Pandoc謹製シンタックスハイライト機構Skylighting

2022/11/30に公開

「コード片のシンタックスを解釈してキーワードなどをハイライトするツール」というと、おそらく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.xmllanguageタグの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 "&#009;">
]>
...(中略)...

<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 "&#032;">とすれば)、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)

独自のシンタックスハイライトの定義を追加するときにはparseSyntaxDefinitionaddSyntaxDefinitionを使います。

{-# 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を自分で拡張してあげる必要があります。前項で説明したparseSyntaxDefinitionaddSyntaxDefinitionを使うだけなので、それ自体は難しくはないのですが、問題は上記のフィルターの文脈が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