Asciidoctor 支持自定义转换器。如果你想要生成一个现有的内置转换器或生态系统中可用的转换器都不支持的输出格式,你可以创建并使用你自己的转换器。你也可能决定创建一个自定义转换器来定制一个支持的输出格式的输出,或者采取一种完全不同的方法。自定义转换器提供了这种可能性,提供了一种更正式的替代方案,相对于转换器模板

Tip
除了Asciidoctor的内置转换器{url-org}/asciidoctor/tree/main/lib/asciidoctor/converter,还有许多自定义转换器可供参考,包括{url-org}/asciidoctor-epub3[Asciidoctor EPUB3]、{url-org}/asciidoctor-pdf[Asciidoctor PDF]、{url-org}/asciidoctor-reveal.js[Asciidoctor reveal.js]、{url-org}/asciidoctor-fb2[Asciidoctor FB2]以及{url-org}/asciidoctor-docbook45[Asciidoctor DocBook 4.5]。

在这个页面上,你将学习如何在Ruby中创建一个自定义转换器,注册它,然后使用它。简要概述之后,我们将开始通过扩展和替换一个已注册的转换器。然后,我们将继续从零开始制作一个新的转换器。

概览

Asciidoctor中的转换器是一个专门的扩展点。即使是Asciidoctor内置的转换器也使用这一机制。这意味着,除了能够引入一个新的转换器之外,你还可以替换任何现有的转换器。由于Asciidoctor是用Ruby编程语言编写的,你也可以用Ruby编写自定义转换器。

Tip
您也可以使用AsciidoctorJ在Java中编写转换器,或者使用Asciidoctor.js在JavaScript中编写转换器。在Ruby中编写转换器的优势是您可以使用相同的代码,无论您选择哪个Asciidoctor运行时。如果您计划与社区共享转换器,那么这是最佳策略。

当创建一个自定义转换器时,你既可以从头开始编写,也可以扩展一个内置转换器。你可以将该转换器注册到已知的后端中,以替换先前注册的转换器,或者你可以将其注册到一个新的后端以创建一个新的输出目标。如果你不想将转换器与后端注册,你可以使用`:converter`选项将转换器类或实例传递给API。

生成非SGML输出格式

需要牢记的一个重要点是,转换器(通常指像Asciidoctor这样的AsciiDoc处理器)倾向于生成SGML输出(例如,XML和HTML)。这意味着在生成其他输出格式时,您需要解码XML字符引用,并采用保留处理器预期行为的技术。一种这样的技术是在内联元素(如格式化文本)的边界周围使用临时XML标签,这样处理器在执行内联替换时仍然可以识别这些边界。内置的[man页面转换器](https://github.com/asciidoctor/asciidoctor/blob/HEAD/lib/asciidoctor/converter/manpage.rb)提供了这些技术的一个很好的例子。

除非将转换器实例传递给处理器,否则每次处理AsciiDoc文档时都会实例化转换器。在Asciidoctor中的转换器并非设计为从一次转换复用到下一次,因此它是无状态的。

实现一个自定义转换器包括以下几个步骤:

  1. 编写一个Ruby类,它包括{url-api-gems}/asciidoctor/{release-version}/Asciidoctor/Converter[Asciidoctor::Converter]模块或扩展一个已经包含该模块的类。

  2. 实现一个回调方法,将解析文档中的节点(即块级元素或内联元素)转换为目标输出格式。

  3. 可以选择性地将转换器注册到一个或多个后端名称。

  4. 要求(即加载)包含转换器类的 Ruby 文件。

  5. 如果转换器注册了一个后端,则通过设置文档上的后端来激活转换器;如果没有,则通过使用`:converter`选项将转换器类或实例传递给API来激活转换器。

为了入门,让我们从扩展和替换一个已注册的转换器开始。

扩展并替换已注册的转换器

开始开发转换器的最佳方式是扩展一个已注册的转换器,并尝试改变它的行为。

要创建一个自定义转换器,你需要在一个Ruby源文件中定义一个Ruby类,然后在运行Asciidoctor时传入这个文件。要开始,请创建一个命名为[.path]_my-html5-converter.rb_的文件并打开它。这个文件中的Ruby代码将在Asciidoctor的上下文中运行,所以你不需要添加require语句来使用Asciidoctor的Ruby APIs。

要扩展一个已注册的转换器,首先你需要获取它的引用。这就是{url-api-gems}/asciidoctor/{release-version}/Asciidoctor/Converter/Factory#for-instance_method[Asciidoctor::Converter.for]方法的目的。这个方法将解析当前为某个后端注册的转换器的类。如果我们正在寻找`html5`后端的转换器(即HTML 5转换器),我们传入字符串`html5`。

Asciidoctor::Converter.for 'html5'
# => Asciidoctor::Converter::Html5Converter

接下来,我们想要扩展这个类。要在Ruby中扩展一个类,你需要声明这个类,然后使用 < 运算符来指明从哪个类进行扩展。

class MyHtml5Converter < (Asciidoctor::Converter.for 'html5')
end

恭喜!您已经创建了您的第一个自定义转换器。但请等一下,它还没有注册,这意味着它不会被使用。让我们解决这个问题。

要注册转换器类,你需要声明你希望它映射到的后端。为了自定义 Asciidoctor 生成的 HTML,你可以使用 register_for 方法声明后端为 html5。这样做的结果是,这会将自定义转换器注册到内置转换器之上,有效地替换它。

class MyHtml5Converter < (Asciidoctor::Converter.for 'html5')
  register_for 'html5'
end

虽然我们没有改变任何行为,但这个转换器几乎可以使用。最后一步是告诉 Asciidoctor 在启动时加载这个文件。您可以通过以下方式将文件路径传递给 -r 命令行选项来做到这一点。

$ asciidoctor -r ./my-html5-converter.rb doc.adoc

当Asciidoctor启动时,它会告诉Ruby去执行Ruby源文件。当它这么做时,Ruby会定义`MyHtml5Converter`类。在定义类的时候,它会调用`register_for`方法,这会将该类注册到`html5`后端(替换内置转换器)。这意味着Asciidoctor现在正在使用你的自定义转换器。

既然你已经配置了Asciidoctor以使用你的自定义转换器,现在是时候让它做一些不同的事情了。假设你想要简化内置转换器为段落生成的HTML,将其减少到单个的`<p>`元素。自定义转换器正是你完成这个目标所需要的工具。

在这种情况下,我们将重写 convert_paragraph 方法。当扩展一个内置转换器(或任何扩展了 {url-api-gems}/asciidoctor/{release-version}/Asciidoctor/Converter/Base[Asciidoctor::Converter::Base] 的转换器)时,解析文档模型中的节点(即,块或内联元素)的转换方法名称是节点的上下文(例如,paragraph)前缀加上 convert_。这就是我们为段落得出方法名称 convert_paragraph 的原因。你可以在 contexts-ref.html 中找到所有此类方法的列表。

转换器方法接受节点作为第一个参数。对于块,节点是{url-api-gems}/asciidoctor/{release-version}/Asciidoctor/Block[Asciidoctor::Block]的一个实例。

让我们向自定义转换器添加 convert_paragraph 方法以提供自定义实现。

class MyHtml5Converter < (Asciidoctor::Converter.for 'html5')
  register_for 'html5'

def convert_paragraph node
  logger.warn 'Converting a paragraph...' (1)
  super
end
  1. 基础转换器自动包含了Logging模块,这使得你的转换器可以访问Asciidoctor的日志记录器。

到目前为止,我们所做的只是打印出一个转换段落的意图,然后将工作委托回超类方法(即原始实现)。如果您像之前一样运行Asciidoctor:

$ asciidoctor -r ./my-html5-converter.rb doc.adoc

你现在应该在你的终端窗口中看到以下消息:

asciidoctor: 警告:正在转换一个段落...

展示如何委托给超类方法是很重要的,因为它表明在某些情况下你依然可以使用内建逻辑(或者甚至装饰它产生的HTML)。但是,让我们用我们自己的逻辑来取代它。

class MyHtml5Converter < (Asciidoctor::Converter.for 'html5')
  register_for 'html5'

def
convert_paragraph node
    %(<p>#{node.content}</p>)
end
end

如果你像之前一样运行Asciidoctor,你应该会看到段落被转换成了一个简单的`<p>`元素。

<p>Content of paragraph.</p>

但我们缺少了一些东西,比如ID、角色和头衔。让我们填补这些空白。

class MyHtml5Converter < (Asciidoctor::Converter.for 'html5')
  register_for 'html5'

def convert_paragraph node
    attributes = []
    attributes << %( id="#{node.id}") if node.id
    attributes << %( class="#{node.role}") if node.role
    title = node.title? ? %(<span class="title">#{node.title}</span> ) : ''
    %(<p#{attributes.join}>#{title}#{node.content}</p>)
  end
end

假设这段文字具有ID、角色和标题,那么这个转换器将产生以下输出:

<p id="intro" class="summary"><span class="title">What is a wolpertinger?</span> A wolpertinger is a ravenous beast.</p>

你不仅创建了你的第一个自定义转换器,而且你已经在定制Asciidoctor生成的HTML以满足你自己的需求方面取得了很大进展!

现在您已经成功地扩展并替换了一个已注册的转换器,让我们来看看如何从零开始创建一个转换器。

创建并注册一个新的转换器

您不必修改内置转换器的行为,您可以为新的或现有的后端从头创建一个转换器。让我们创建一个新的转换器,将(部分)AsciiDoc转换为DITA。以下是我们打算转换的AsciiDoc样本。

= 文件标题

== 章节标题

这是*主要*内容。

您将再次开始创建一个Ruby源文件,这次将其命名为 dita-converter.rb。 我们将开始混入 {url-api-gems}/asciidoctor/{release-version}/Asciidoctor/Converter[Asciidoctor::Converter] 模块,这将类转换为转换器类。 不过,您很快会发现,这样做是单调乏味的,而扩展基础转换器是一种更简易的方法。

让我们设置我们的转换器,并将其映射到名为`dita`的后端。

class DitaConverter
  include Asciidoctor::Converter
  register_for 'dita'
end

默认情况下,转换器会假设它生成一个带有 .html 扩展名的文件。由于我们打算创建一个 DITA 文件,我们需要在构造函数中调用 outfilesuffix 来将其更改为 .dita

class DitaConverter
  include Asciidoctor::Converter
  register_for 'dita'

def initialize *args
  super
  outfilesuffix '.dita'
end
end

现在让我们实现所需的`convert`方法,以便转换器可以开始接收需要转换的节点。我们将只处理起始的主要结构节点,对于剩余的节点则通过原始输出(稍后完成)。

class DitaConverter
  include Asciidoctor::Converter
  register_for 'dita'

ruby
def initialize *args
  super
  outfilesuffix '.dita'
end


def convert node, transform = node.node_name, opts = nil (1)
    case transform (2)
    when 'document'
      <<~EOS.chomp
      <!DOCTYPE topic PUBLIC "-//OASIS//DTD DITA Topic//EN" "topic.dtd">
      <topic>
      <title>#{node.doctitle}</title>
      <body>
      #{node.content} (3)
      </body>
      </topic>
      EOS
    when 'section'
      <<~EOS.chomp
      <section id="#{node.id}">
      <title>#{node.title}</title>
      #{node.content} (3)
      </section>
      EOS
    when 'paragraph'
      %(<p>#{node.content}</p>)
    else
      (transform.start_with? 'inline_') ? node.text : node.content
    end
  end
end
  1. node_name` 方法返回节点的上下文作为一个字符串。

  2. "`transform` 参数只在特殊情况下设置,例如用于嵌入式文档。"

  3. 在块级元素上调用 node.content 会继续从那个节点遍历文档结构。

Important
#content` 方法控制着一个块是否被遍历,而不是处理器。因此,在转换一个块元素时,转换器应该调用节点上的 #content 方法(例如,node.content)。这个方法调用继续从该节点出发的文档遍历,并返回转换后的子树。当这个方法被调用时,Asciidoctor 会按文档顺序访问每个子节点,并将其传递给转换器的 convert 方法进行转换。然后,返回的值被连接起来。如果你不调用这个方法,子节点将被跳过。

您可以看到,必须编写一个 switch 语句来处理每种类型的节点,这比当我们扩展内置转换器时所编写的分散方法更笨拙。如果我们更改转换器类的定义,继承 {url-api-gems}/asciidoctor/{release-version}/Asciidoctor/Converter/Base[Asciidoctor::Converter::Base],Asciidoctor 会为我们处理这种分派。一个明显的区别是,我们现在要么必须为每个可转换的上下文提供一个处理程序,要么实现一个 method_missing 方法作为全能处理。这是它的样子:

dita-converter.rb
class DitaConverter < Asciidoctor::Converter::Base
  register_for 'dita'

ruby
def initialize *args
  super
  outfilesuffix '.dita'
end

 def convert_document node
    <<~EOS.chomp
    <!DOCTYPE topic PUBLIC "-//OASIS//DTD DITA Topic//EN" "topic.dtd">
    <topic>
    <title>#{node.doctitle}</title>
    <body>
    #{node.content}
    </body>
    </topic>
    EOS
  end

ruby
def convert_section node
  <<~EOS.chomp
  <section id="#{node.id}">
  <title>#{node.title}</title>
  #{node.content}
  </section>
  EOS
end

def convert_paragraph node
    %(<p>#{node.content}</p>)
end

def convert_inline_quoted node
    node.type == :strong ? %(<b>#{node.text}</b>) : node.text
  end
end

您现在可以使用这个转换器将示例AsciiDoc文档转换为DITA。要这样做,请将转换器传递给`-r`命令行选项,并使用`b`命令行选项将后端设置为`dita`。

$ asciidoctor -r ./dita-converter.rb -b dita doc.adoc

这是您将获得的输出示例,它会自动写入到[.path]_doc.dita_文件中。

doc.dita
<!DOCTYPE topic PUBLIC "-//OASIS//DTD DITA Topic//EN" "topic.dtd">
<topic>
<title>Document Title</title>
<body>
<section id="_section_title">
<title>Section Title</title>
<p>This is the <b>main</b> content.</p>
</section>
</body>
</topic>
Note
如果传递给Asciidoctor的convert API的`:to_file`选项的值响应`write`方法(例如,一个IO对象),Asciidoctor将确保输出具有一个结尾的换行符。否则,是否在输出末尾追加换行符则取决于转换器的决定。

如果你没有使用后端注册转换器,你可以通过Asciidoctor API的`:converter`选项传递转换器类(或实例),如以下代码片段所示:

require 'asciidoctor'
require_relative 'dita-converter.rb'

Asciidoctor.convert_file 'doc.adoc', safe: :safe, backend: 'dita', converter: DitaConverter

要编写一个功能完备的转换器,你需要为所有可转换的上下文提供一个转换方法(或为转换器无法处理的上下文提供一个后备方案)。

仅转换为文本

你可能想要从AsciiDoc文档中提取文本,而不包含任何标记。由于“纯文本”没有统一的定义,这是使用自定义转换器的绝佳机会。

如下定义一个`TextConverter`,并将其注册到`text`后端:

text-converter.rb
class TextConverter
  include Asciidoctor::Converter
  register_for 'text'
  def initialize *args
    super
    outfilesuffix '.txt'
  end
  def convert node, transform = node.node_name, opts = nil
    case transform
    when 'document', 'section'
      [node.title, node.content].join %(\n\n)
    when 'paragraph'
      (node.content.tr ?\n, ' ') << ?\n
    else
      (transform.start_with? 'inline_') ? node.text : node.content
    end
  end
end

您现在可以使用这个转换器将样本AsciiDoc文档转换为文本。要这样做,将转换器传递给`-r`命令行选项,并使用`b`命令行选项将后端设置为`text`。

$ asciidoctor -r ./text-converter.rb -b text doc.adoc

这是你将获得的输出示例,它会自动写入到[.path]_doc.txt_文件中。

doc.txt
文档标题

章节标题

这是主要内容。

如果您需要保留一些文本注释,在文档转换时可以根据需要添加回来。