自定义转换器
Asciidoctor 支持自定义转换器。如果您想生成内置转换器或生态系统中任何可用转换器不支持的输出格式,您可以创建并使用自己的转换器。您还可以决定创建自定义转换器来定制受支持输出格式的输出,或采取完全不同的方法。自定义转换器为您提供了这种能力,提供了比转换器模板更正式的替代方案。
| 除了 Asciidoctor 的内置转换器之外,还有许多自定义转换器可以作为参考,包括 Asciidoctor EPUB3、Asciidoctor PDF、Asciidoctor reveal.js、Asciidoctor FB2 和 Asciidoctor DocBook 4.5。 |
在本页中,您将学习如何在 Ruby 中创建自定义转换器、注册它,然后使用它。简要概述之后,我们将从扩展和替换已注册的转换器开始。然后我们将着手从头开始创建一个新的转换器。
概述
Asciidoctor 中的转换器是一个专门的扩展点。即使是 Asciidoctor 的内置转换器也使用此功能。这意味着,除了能够引入新转换器外,您还可以替换任何现有转换器。由于 Asciidoctor 是用 Ruby 编程语言编写的,因此您也可以用 Ruby 编写自定义转换器。
| 您还可以使用 AsciidoctorJ 用 Java 或使用 Asciidoctor.js 用 JavaScript 编写转换器。用 Ruby 编写转换器的优点是,无论您选择哪个 Asciidoctor 运行时,都可以使用相同的代码。如果您打算与社区共享转换器,这是最佳策略。 |
创建自定义转换器时,您可以从头开始编写一个,也可以扩展一个内置转换器。然后,您可以将该转换器注册到一个已知后端来替换先前注册的转换器,或者将其注册到一个新后端来创建一个新的输出目标。如果您不想将转换器注册到后端,可以使用 :converter 选项通过 API 传递转换器类或实例。
除非将转换器实例传递给处理器,否则每次处理 AsciiDoc 文档时都会实例化转换器。Asciidoctor 中的转换器不是为了在一次转换到下一次转换之间重用而设计的,因此是无状态的。
实现自定义转换器包括以下步骤
-
编写一个包含
Asciidoctor::Converter模块的 Ruby 类,或扩展一个这样做的类。 -
实现一个回调方法,将解析文档中的节点(即块元素或行内元素)转换为目标输出格式。
-
可选地将转换器注册到一个或多个后端名称。
-
必需(即加载)包含转换器类的 Ruby 文件。
-
通过在文档上设置后端来激活转换器(如果转换器已注册到后端),否则通过 API 使用
:converter选项传递转换器类或实例
为了初步了解,让我们从扩展和替换已注册的转换器开始。
扩展和替换已注册的转换器
开始开发转换器的最佳方法是扩展已注册的转换器并尝试更改其行为。
要创建自定义转换器,您需要在 Ruby 源文件中定义一个 Ruby 类,然后在运行 Asciidoctor 时将其传递给 Asciidoctor。要开始,请创建一个名为 my-html5-converter.rb 的文件并打开它。此文件中的 Ruby 代码将在 Asciidoctor 的上下文中运行,因此您无需添加 require 语句即可使用 Asciidoctor 中的 Ruby API。
要扩展已注册的转换器,您首先需要获取对其的引用。这就是 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 CLI 选项。
$ asciidoctor -r ./my-html5-converter.rb doc.adoc
当 Asciidoctor 启动时,它将指示 Ruby 求值 Ruby 源文件。执行此操作时,Ruby 将定义 MyHtml5Converter 类。在定义类的过程中,它将调用 register_for 方法,该方法将类注册到 html5 后端(替换内置转换器)。这意味着 Asciidoctor 现在正在使用您的自定义转换器。
既然您已经配置了 Asciidoctor 来使用您的自定义转换器,那么是时候让它做一些不同的事情了。假设您想将内置转换器为段落生成的 HTML 简化为单个 <p> 元素。自定义转换器正是您实现此目标所需的工具。
在这种情况下,我们将覆盖 convert_paragraph 方法。扩展内置转换器(或任何扩展 Asciidoctor::Converter::Base)时,解析文档模型中节点(即块元素或行内元素)的转换方法名称是节点的上下文(例如 paragraph),前面加上 convert_。这就是我们为段落得出的方法名称 convert_paragraph。您可以在可转换上下文中找到所有此类方法的列表。
转换方法将节点作为第一个参数接受。对于块,节点是 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
end
| 1 | 基本转换器自动包含 Logging 模块,该模块使您的转换器能够访问 Asciidoctor 的日志记录器。 |
到目前为止,我们所做的只是打印转换段落的意图,然后委托回 super 方法(即原始实现)。如果您像以前一样运行 Asciidoctor
$ asciidoctor -r ./my-html5-converter.rb doc.adoc
现在应该会在终端窗口中看到以下消息
asciidoctor: WARNING: Converting a paragraph...
显示如何委托给 super 方法很重要,因为它表明在某些情况下您仍然可以使用内置逻辑(甚至可以修饰它生成的 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 以满足您自己的需求方面取得了长足的进步!
既然您已成功扩展并替换了已注册的转换器,让我们看看如何从头开始创建一个转换器。
创建并注册一个新转换器
与其修改内置转换器的行为,不如为新的或现有的后端创建一个全新的转换器。让我们创建一个映射到 dita 后端的新转换器,该转换器将(部分)AsciiDoc 转换为 DITA。
您将首先创建一个 Ruby 源文件,这次将其命名为 dita-converter.rb。我们将首先混合使用 Asciidoctor::Converter 模块,这将使类成为转换器类。(您很快就会发现这是一种繁琐的方法,扩展基本转换器是一种更简单的方法。)
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
在继续之前,我们需要退后一步,谈谈转换器如何转换节点。
如何进行转换
转换开始时,Asciidoctor 将文档节点传递给一个名为 convert 的方法。该方法应启动并执行转换过程。因此,是转换器控制文档树的遍历,而不是处理器。
一种方法是为每个可转换上下文使用一个巨大的 switch 语句,并在 convert 方法内直接处理所有转换逻辑。但是,这种方法很快就会变得难以管理。
更典型的方法是遍历节点的子项,并将每个子项传递给一个以 convert_ 开头的方法,该方法匹配节点上下文(字符串形式)(例如 convert_section)。convert 方法使用换行符连接这些调用的返回值,并返回结果,该结果成为转换过程的结果。这恰好是 Asciidoctor::Converter::Base 类提供的方法。
这些命名转换方法中发生了什么?它们也应该遍历子项并调用相应命名的转换方法吗?它们当然可以,但有一个更简单的方法。
块上的 content 方法会自动按文档顺序访问每个子节点,将子节点传递给 convert 方法进行转换(从而再次调用转换器),并返回使用换行符连接的结果。对于仅包含行内元素的块,此方法还可以有效地调用所有行内节点的 convert 方法(即使行内元素在技术上是就地替换而不是排列在树中)。
因此,在转换块元素时,转换器应调用节点上的 content 方法(例如 node.content)。此方法调用会从该节点继续文档遍历,并返回其子树的转换结果。如果您不调用此方法(并且不以其他方式处理子项),则会跳过子节点。
在可以使用 content 方法的这两个例外情况下,列表和表格。列表和表格不提供(功能正常的)content 方法。这是因为列表和表格的子项不是真正的块,而是复杂的结构。因此,转换器必须自行处理这些块的直接子项的遍历。请参阅内置转换器以了解如何处理它们。
转换为 DITA
有了我们学到的知识,让我们继续我们的 AsciiDoc 到 DITA 转换器。这是我们旨在转换的 AsciiDoc 示例。
= Document Title
== Section Title
This is the *main* content.
现在,让我们使用前面提到的 switch 语句方法实现必需的 convert 方法。一旦实现此方法,转换器就可以开始接收要转换的节点。我们将首先只处理主要的结构节点,然后将剩余节点的原始输出传递(稍后完成)。
class DitaConverter
include Asciidoctor::Converter
register_for 'dita'
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 会从该节点继续文档结构的遍历。 |
如您所见,必须编写 switch 语句来处理每种类型的节点比我们扩展内置转换器时编写的离散方法更笨拙。如前所述,我们可以使用方法分派。这是它的样子
class DitaConverter
include Asciidoctor::Converter
register_for 'dita'
def initialize *args
super
outfilesuffix '.dita'
end
def convert node, transform = node.node_name, opts = nil
opts ? (send 'convert_' + transform, node, opts) : (send 'convert_' + transform, node)
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
# ...
end
但请稍等。convert 方法中的分派正是 Asciidoctor::Converter::Base 在用作转换器基类时提供的功能。此外,任何时候遇到没有相应转换方法的节点,它都会记录警告。
让我们更改我们转换器的定义以扩展此类。主要区别在于,现在我们只需为每个可转换上下文实现一个转换方法,前面加上 convert_。
class DitaConverter < Asciidoctor::Converter::Base
register_for 'dita'
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
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 CLI 选项,并使用 b CLI 选项将后端设置为 dita。
$ asciidoctor -r ./dita-converter.rb -b dita doc.adoc
以下是您将看到的输出示例,它会自动写入 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>
如果传递给 Asciidoctor 的 convert API 的 :to_file 选项的值响应 write 方法(例如,IO 对象),Asciidoctor 将确保输出具有尾随换行符。否则,转换器应自行决定是否向输出添加尾随换行符。 |
如果您不将转换器注册到后端,可以使用 :converter 选项将转换器类(或实例)传递给 Asciidoctor API,如下面的代码片段所示
require 'asciidoctor'
require_relative 'dita-converter.rb'
Asciidoctor.convert_file 'doc.adoc', safe: :safe, backend: 'dita', converter: DitaConverter
要编写功能齐全的转换器,您需要为所有可转换上下文提供一个转换方法(或为转换器未处理的上下文提供回退)。
仅转换为文本
您可能希望从 AsciiDoc 文档中提取文本而不包含任何标记。由于“纯文本”没有单一的定义,这是一个使用自定义转换器的绝佳机会。
由于转换为文本的重点仅仅是提取内容,我们可以利用许多共享逻辑。因此,我们将只混合 Asciidoctor::Converter,而不是扩展 Asciidoctor::Converter::Base。所有块将简单地委托给它们的 content 方法(列表和表格除外),行内元素委托给它们的 text 方法。
在 text-converter.rb 文件中定义一个名为 TextConverter 的转换器,将其注册到 text 后端,并实现 convert 方法,如下所示
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'
[node.title, node.content].join(?\n).strip
when 'section'
?\n + [node.title, node.content].join(?\n).rstrip
when 'paragraph'
?\n + normalize_space(node.content)
when 'ulist', 'olist', 'colist'
?\n + node.items.map do |item|
normalize_space(item.text) + (item.blocks? ? ?\n + item.content : '')
end.join(?\n)
when 'dlist'
?\n + node.items.map do |terms, dd|
terms.map(&:text).join(', ') +
(dd&.text? ? ?\n + normalize_space(dd.text) : '') +
(dd&.blocks? ? ?\n + dd.content : '')
end.join(?\n)
when 'table'
?\n + node.rows.to_h.map do |_, rows|
rows.map do |cells|
cells.map do |cell|
cell.style == :asciidoc ? cell.content.lstrip : cell.content.join(%(\n\n))
end
end
end.flatten.join(%(\n\n))
else
transform.start_with?('inline_') ? node.text : [?\n, node.content].compact.join
end
end
def normalize_space text
text.tr ?\n, ' '
end
end
| 此转换器仅用于说明目的,必须进一步开发才能完全正常工作。虽然尝试消除了多余的换行符,但逻辑并不完美。 |
您现在可以使用此转换器将示例 AsciiDoc 文档转换为文本。要做到这一点,将转换器传递给 -r CLI 选项,并使用 b CLI 选项将后端设置为 text。
$ asciidoctor -r ./text-converter.rb -b text doc.adoc
以下是您将看到的输出示例,它会自动写入 doc.txt 文件。
Document Title
Section Title
This is the main content.
如果您需要保留某些文本表示法,可以在转换文档时根据需要将其添加回来。