使用 Pandoc API

Pandoc 可以作为 Haskell 库来使用,以便编写自己的转换工具或为 Web 应用程序提供动力。本文档提供了使用 Pandoc API 的入门介绍。

详细的 API 文档,包括各个函数和类型的说明,可以在 https://hackage.haskell.org/package/pandoc 找到。

Pandoc 的结构

Pandoc 的结构由一系列的读取器组成,这些读取器将不同的输入格式转换成抽象语法树(即 Pandoc AST),表示一个结构化的文档;以及一系列的写入器,这些写入器将这个 AST 渲染成各种输出格式。图示如下:

[输入格式] == 读取器 ==> [Pandoc AST] == 写入器 ==> [输出格式]

这种架构允许 Pandoc 使用 M 个读取器和 N 个写入器执行 M×N 种转换。

Pandoc 的抽象语法树(AST)定义在 pandoc-types 包中。你应该从查看 Text.Pandoc.Definition 的 Haddock 文档开始。你会看到,Pandoc 是由一些元数据和一个 Block 列表组成的。Block 有多种类型,包括 Para(段落)、Header(章节标题)和 BlockQuote(块引用)。某些 Block(如 BlockQuote )包含 Block 列表,而其他 Block( 如 Para )包含 Inline 列表,还有一些 Block( 如 CodeBlock )只包含纯文本或为空。 Inline 是段落的基本元素。类型系统中 BlockInline 的区别使得无法表示例如链接( Inline )中的链接文本为块引用( Block )这样的情况。这种表达上的限制实际上大多是一种帮助而非阻碍,因为 Pandoc 支持的许多格式都有类似的限制。

探索 Pandoc 的 AST 的最佳方式是使用 pandoc -t native 命令,它会显示对应于 Markdown 输入的 AST:

% echo -e "1. *foo*\n2. bar" | pandoc -t native
[OrderedList (1,Decimal,Period)
 [[Plain [Emph [Str "foo"]]]
 ,[Plain [Str "bar"]]]]

一个简单的示例

下面是一个使用 pandoc 读取器和写入器执行转换的简单示例:

import Text.Pandoc
import qualified Data.Text as T
import qualified Data.Text.IO as TIO

main :: IO ()
main = do
  result <- runIO $ do
    doc <- readMarkdown def (T.pack "[testing](url)")
    writeRST def doc
  rst <- handleError result
  TIO.putStrLn rst

一些说明:

  1. 第一部分构建了一个转换流水线:输入字符串被传递给 readMarkdown,生成的 Pandoc AST (doc) 然后通过 writeRST 渲染。转换流水线通过 runIO 运行 —— 关于这一点下面会有更多解释。

  2. result 的类型是 Either PandocError Text。我们可以手动进行模式匹配,但在这种情况下,使用 Text.Pandoc.Error 模块中的 handleError 函数更为简单。如果值是 Left,这会以适当的错误代码和消息退出程序;如果值是 Right,则返回 Text

PandocMonad 类

让我们来看一下 readMarkdown 和 writeRST 的类型签名:

readMarkdown :: (PandocMonad m, ToSources a)
             => ReaderOptions
             -> a
             -> m Pandoc
writeRST     :: PandocMonad m
             => WriterOptions
             -> Pandoc
             -> m Text

PandocMonad m => 部分是一个类型类约束。它表明 readMarkdownwriteRST 定义了可以在 PandocMonad 类型类的任何实例中使用的计算。PandocMonad 在模块 Text.Pandoc.Class 中定义。

提供了两种 PandocMonad 的实例:PandocIOPandocPure。两者的区别在于,在 PandocIO 中运行的计算可以执行 I/O 操作(例如,读取文件),而在 PandocPure 中的计算则没有副作用。PandocPure 适用于沙箱环境,当你希望防止用户做任何恶意操作时非常有用。要在 PandocIO 中运行转换,使用 runIO(如上所述)。要在 PandocPure 中运行转换,则使用 runPure

正如你从 Haddocks 文档中所见,Text.Pandoc.Class 导出了许多辅助函数,这些函数可以在 PandocMonad 的任何实例中使用。例如:

-- | 获取当前的详细级别。
getVerbosity :: PandocMonad m => m Verbosity

-- | 设置详细级别。
setVerbosity :: PandocMonad m => Verbosity -> m ()

-- | 获取累积的日志消息(按时间顺序)。
getLog :: PandocMonad m => m [LogMessage]
getLog = reverse <$> getsCommonState stLog

-- | 使用 'logOutput' 记录一条消息。注意 'logOutput' 只有在详细级别超过消息级别时才会被调用,
-- 但是无论消息的详细级别如何,该消息都会被添加到可通过 'getLog' 获取的日志消息列表中。
report :: PandocMonad m => LogMessage -> m ()

-- | 从本地文件系统或网络获取图像或其他项目。返回原始内容和可能的 MIME 类型。
fetchItem :: PandocMonad m
          => Text
          -> m (B.ByteString, Maybe MimeType)

-- | 设置 'fetchItem' 所搜索的资源路径。
setResourcePath :: PandocMonad m => [FilePath] -> m ()

如果我们希望在上一节定义的转换过程中有更多的详细信息消息,我们可以这样做:

  result <- runIO $ do
    setVerbosity INFO
    doc <- readMarkdown def (T.pack "[testing](url)")
    writeRST def doc

注意

PandocIOMonadIO 的一个实例,因此您可以使用 liftIO 在 Pandoc 的转换链中执行任意的 I/O 操作。

readMarkdown 在其第二个参数上是多态的,该参数可以是任何 ToSources 类型类的实例类型。您可以在如上面的例子中那样使用 Text。但如果您输入来自多个文件并且希望准确地跟踪源位置,也可以使用 [(FilePath, Text)]

选项

每个读取器或写入器的第一个参数是用来控制读取器或写入器行为的选项:对于读取器是 ReaderOptions,对于写入器是 WriterOptions。这些选项定义在 Text.Pandoc.Options 中。研究这些选项是个好主意,以便了解可以调整的内容。

def(来自 Data.Default)表示每种选项类型的默认值。(您也可以使用 defaultWriterOptionsdefaultReaderOptions。)通常,您会想要使用默认值,并且仅在需要时对其进行修改,例如:

writeRST $ def { writerReferenceLinks = True }

以下是一些特别重要的选项:

  1. writerTemplate: 默认情况下,这是 Nothing,这意味着将产生一个文档片段。如果您需要一个完整的文档,您需要指定 Just template,其中 template 是来自 Text.Pandoc.TemplatesTemplate Text,包含模板的内容(而不是路径)。

  2. readerExtensionswriterExtensions: 这些指定了在解析和渲染时要使用的扩展。扩展定义在 Text.Pandoc.Extensions 中。

构建器

有时,以编程方式构建一个Pandoc文档是非常有用的。为了简化这一过程,我们提供了模块 Text.Pandoc.Builderpandoc-types 包中。

因为连接列表的操作较慢,所以我们使用了特殊的类型 InlinesBlocks,它们分别包裹了一个 SequenceInlineBlock 元素。这些是 Monoid 类型类的实例,可以很容易地进行连接:

import Text.Pandoc.Builder

mydoc :: Pandoc
mydoc = doc $ header 1 (text (T.pack "Hello!"))
           <> para (emph (text (T.pack "hello world")) <> text (T.pack "."))

main :: IO ()
main = print mydoc

如果你使用了 OverloadedStrings 语言扩展,你可以进一步简化这段代码:

mydoc = doc $ header 1 "Hello!"
           <> para (emph "hello world" <> ".")

这里有一个更实际的例子。假设你的老板说:给我写一封Word文档的信,列出芝加哥所有接受Voyager卡的加油站。你找到了一些格式如下的JSON数据(fuel.json):

[ {
  "state" : "IL",
  "city" : "Chicago",
  "fuel_type_code" : "CNG",
  "zip" : "60607",
  "station_name" : "Clean Energy - Yellow Cab",
  "cards_accepted" : "A D M V Voyager Wright_Exp CleanEnergy",
  "street_address" : "540 W Grenshaw"
}, ...]

然后使用 aesonpandoc 解析 JSON 数据并创建 Word 文档:

{-# LANGUAGE OverloadedStrings #-}
import Text.Pandoc.Builder
import Text.Pandoc
import Data.Monoid ((<>), mempty, mconcat)
import Data.Aeson
import Control.Applicative
import Control.Monad (mzero)
import qualified Data.ByteString.Lazy as BL
import qualified Data.Text as T
import Data.List (intersperse)

data Station = Station{
    address        :: T.Text
  , name           :: T.Text
  , cardsAccepted  :: [T.Text]
  } deriving Show

instance FromJSON Station where
    parseJSON (Object v) = Station <$>
       v .: "street_address" <*>
       v .: "station_name" <*>
       (T.words <$> (v .:? "cards_accepted" .!= ""))
    parseJSON _          = mzero

createLetter :: [Station] -> Pandoc
createLetter stations = doc $
    para "Dear Boss:" <>
    para "Here are the CNG stations that accept Voyager cards:" <>
    simpleTable [plain "Station", plain "Address", plain "Cards accepted"]
           (map stationToRow stations) <>
    para "Your loyal servant," <>
    plain (image "JohnHancock.png" "" mempty)
  where
    stationToRow station =
      [ plain (text $ name station)
      , plain (text $ address station)
      , plain (mconcat $ intersperse linebreak
                       $ map text $ cardsAccepted station)
      ]

main :: IO ()
main = do
  json <- BL.readFile "fuel.json"
  let letter = case decode json of
                    Just stations -> createLetter [s | s <- stations,
                                        "Voyager" `elem` cardsAccepted s]
                    Nothing       -> error "Could not decode JSON"
  docx <- runIO (writeDocx def letter) >>= handleError
  BL.writeFile "letter.docx" docx
  putStrLn "Created letter.docx"

就这样!你已经写好了这封信,而且没有使用 Word,也没有查看数据。

数据文件

Pandoc 有一些数据文件,这些文件可以在仓库的 data/ 子目录中找到。这些文件会随着 Pandoc 的安装一同安装(或者如果 Pandoc 是使用 embed_data_files 标志编译的话,这些文件会被嵌入二进制文件中)。您可以使用 Text.Pandoc.Class 中的 readDataFile 函数来检索数据文件。readDataFile 首先会在“用户数据目录”中查找文件(可以通过 setUserDataDirgetUserDataDir 设置),如果没有在那里找到,它会返回系统安装的默认文件。要强制使用默认文件,可以设置 setUserDataDir Nothing

元数据文件

Pandoc 可以为文档添加元数据,具体描述请参阅用户指南。类似于数据文件,元数据 YAML 文件可以使用 Text.Pandoc.Class 中的 readMetadataFile 函数来检索。readMetadataFile 首先会在工作目录中查找文件,如果没有找到,它会在用户数据目录的 metadata 子目录中查找(可以通过 setUserDataDirgetUserDataDir 设置)。

模板

Pandoc 有自己的模板系统,具体描述请参阅用户指南。要检索系统的默认模板,请使用来自 Text.Pandoc.TemplatesgetDefaultTemplate 函数。请注意,此函数首先会在用户数据目录的 templates 子目录中查找,这样用户可以覆盖系统默认模板。如果你想禁用这种行为,可以使用 setUserDataDir Nothing

要渲染一个模板,请使用 renderTemplate',它接受两个参数:一个模板(Text 类型)和一个上下文(任何 ToJSON 类型的实例)。如果你想从 Pandoc 文档的元数据部分创建一个上下文,可以使用来自 Text.Pandoc.Writers.SharedmetaToJSON'。如果你想同时合并变量的值,请改用 metaToJSON,并确保在 WriterOptions 中设置了 writerVariables

处理错误和警告

runIOrunPure 返回一个 Either PandocError a。在运行 PandocMonad 计算时引发的所有错误都会被捕获并作为一个 Left 值返回,这样就可以由调用程序处理。要查看 PandocError 的构造函数,请参阅 Text.Pandoc.Error 的文档。

要在 PandocMonad 计算内部引发 PandocError,可以使用 throwError

除了会导致转换流程停止执行的错误之外,还可以生成信息性消息。使用来自 Text.Pandoc.Classreport 函数来发出一个 LogMessage。对于 LogMessage 的构造函数列表,请参阅 Text.Pandoc.Logging。请注意,每种类型的日志消息都与一个详细级别相关联。详细级别(setVerbosity / getVerbosity)决定了报告是否会被打印到标准错误输出(在 PandocIO 中运行时),但不论详细级别的高低,所有报告的消息都会被内部存储,并且可以使用 getLog 获取。

遍历抽象语法树

遍历 Pandoc 的抽象语法树(AST)通常是很有用的,无论是为了提取信息(例如,这份文档中链接的所有 URL 是什么?所有的代码示例都能编译吗?)还是为了转换文档(例如,增加每个章节标题的级别,删除强调标记,或将特别标记的代码块替换为图片)。为了使这个过程更加简便和高效,pandoc-types 包含了一个模块 Text.Pandoc.Walk

以下是基本文档:

class Walkable a b where
  -- | @walk f x@ 遍历结构 @x@(从下至上),并将每一个出现的 @a@ 替换为应用 @f@ 后的结果。
  walk  :: (a -> a) -> b -> b
  walk f = runIdentity . walkM (return . f)
  -- | 'walkM' 的单子版本。
  walkM :: (Monad m, Functor m) => (a -> m a) -> b -> m b
  -- | @query f x@ 遍历结构 @x@(从下至上),并将 @f@ 应用于每一个 @a@,将结果追加在一起。
  query :: Monoid c => (a -> c) -> b -> c

Walkable 的实例为大多数 Pandoc 类型的组合定义。例如,Walkable Inline Block 实例允许您采用一个函数 Inline -> Inline 并将其应用于 Block 中的每一个内联元素。而 Walkable [Inline] Pandoc 实例允许您采用一个函数 [Inline] -> [Inline] 并将其应用于 Pandoc 中的每一个最大内联元素列表。

这里有一个简单的例子,展示了一个提升标题级别的函数:

promoteHeaderLevels :: Pandoc -> Pandoc
promoteHeaderLevels = walk promote
  where promote :: Block -> Block
        promote (Header lev attr ils) = Header (lev + 1) attr ils
        promote x = x

walkMwalk 的单子版本;当您需要您的转换执行 I/O 操作、使用 PandocMonad 操作或更新内部状态时,它可以被使用。下面是一个使用 State 单子为每个代码块添加唯一标识符的例子:

addCodeIdentifiers :: Pandoc -> Pandoc
addCodeIdentifiers doc = evalState (walkM addCodeId doc) 1
  where addCodeId :: Block -> State Int Block
        addCodeId (CodeBlock (_,classes,kvs) code) = do
          curId <- get
          put (curId + 1)
          return $ CodeBlock (show curId,classes,kvs) code
        addCodeId x = return x

query 用于从抽象语法树中收集信息。它的参数是一个查询函数,该函数会产生某个单体类型的结果(例如,一个列表)。这些结果会被合并在一起。下面是一个返回文档中链接的所有 URL 列表的例子:

listURLs :: Pandoc -> [Text]
listURLs = query urls
  where urls (Link _ _ (src, _)) = [src]
        urls _                   = []

创建一个前端

所有的命令行程序 pandoc 的功能都已经在模块 Text.Pandoc.App 中通过 convertWithOpts 函数进行了抽象。因此,为 Pandoc 创建一个图形用户界面前端仅仅是要填充 Opts 结构并调用这个函数。

在 Web 应用程序中使用 Pandoc 的注意事项

  1. Pandoc 的解析器在某些输入上可能会表现出病态行为。因此,最好在使用 Pandoc 时包裹一个超时函数(例如来自 baseSystem.Timeout.timeout),以防止拒绝服务(DoS)攻击。

  2. 如果 Pandoc 从不可信的用户输入生成 HTML,最好通过一个净化器(例如 xss-sanitize)过滤生成的 HTML,以避免安全问题。

  3. 使用 runPure 而不是 runIO 可以确保 Pandoc 的函数不执行任何 I/O 操作(例如写文件)。如果需要提供某些资源,runPure 可用的状态中提供了一个“虚拟环境”(参见 PureState 及其在 Text.Pandoc.Class 中的相关函数)。此外,也可以编写一个自定义的 PandocMonad 实例,例如,可以在虚拟环境中将 Wiki 资源作为文件提供,同时将 Pandoc 与系统的其余部分隔离。

在本文档中