Extended Converter Use Cases

This page presents common use cases that can be accomplished by extending and customizing the converter. In Create an Extended Converter, we were just biting around the edges of what you can do with an extended converter. This page gets into more realistic use cases. Each section introduces a different use case and presents the code for an extended converter you can use as a starting point.

The extended converter can access predefined or custom theme keys via the theme accessor. The segments in a key are always separated by an underscore character (e.g., theme.title_page_font_color). Consulting the value of theme keys allows the extra behavior provided by the extended converter to be styled using the theme.

Custom thematic break

One of the simplest ways to extend the converter is to make a thematic break. For this case, we’ll override the convert handler method for a thematic break, which is convert_thematic_break. The thematic break only consists of line graphics, no text. That means we can make use of graphics fill and stroke methods provided by Asciidoctor PDF or Prawn.

Extended converter with custom thematic break
class PDFConverterCustomThematicBreak < (Asciidoctor::Converter.for 'pdf')
  register_for 'pdf'

  def convert_thematic_break node
    theme_margin :thematic_break, :top
    stroke_horizontal_rule 'FF0000', line_width: 0.5, line_style: :solid
    move_down 1
    stroke_horizontal_rule 'FF0000', line_width: 1, line_style: :solid
    move_down 1
    stroke_horizontal_rule 'FF0000', line_width: 0.5, line_style: :solid
    theme_margin :thematic_break, ((block_next = next_enclosed_block node) ? :bottom : :top), block_next || true
  end
end

The return value of the convert handler method for a block node is ignored, which is why there’s no clear return value in this override. If this were a convert handler method for an inline node, a return value would be required, which becomes the text to render.

Custom title page

Every title page is as unique as the work itself. That’s why Asciidoctor PDF gives you the ability to customize the title page by overriding the ink_title_page method in an extended converter. The ink_title_page method is called after the title page has been created and the background applied, so it can focus on writing content. In this method, you can choose to honor the title-page settings from the theme, or go your own way. The one rule is that the ink_title_page method must not start a new page. If it tries to start a new page, that request will be ignored and a warning will be generated.

Let’s create a custom title page that shows the document title and subtitle between two lines in the top half and a logo in the bottom half.

Extended converter with custom title page
class PDFConverterCustomTitlePage < (Asciidoctor::Converter.for 'pdf')
  register_for 'pdf'

  def ink_title_page doc
    move_cursor_to page_height * 0.75
    theme_font :title_page do
      stroke_horizontal_rule '2967B2', line_width: 1.5, line_style: :double
      move_down 10
      doctitle = doc.doctitle partition: true
      theme_font :title_page_title do
        ink_prose doctitle.main, align: :center, color: theme.base_font_color, line_height: 1, margin: 0
      end
      if (subtitle = doctitle.subtitle)
        theme_font :title_page_subtitle do
          move_down 10
          ink_prose subtitle, align: :center, margin: 0
          move_down 10
        end
      end
      stroke_horizontal_rule '2967B2', line_width: 1.5, line_style: :double
      move_cursor_to page_height * 0.5
      convert ::Asciidoctor::Block.new doc, :image,
        content_model: :empty,
        attributes: { 'target' => 'sample-logo.jpg', 'pdfwidth' => '1.5in', 'align' => 'center' },
        pinned: true
    end
  end
end

The methods move_cursor_to and move_cursor advance the cursor on the page where the next content will be written. The method theme_font applies the font from the specified category in the theme (with hyphens in the category name replaced by underscores). The method stroke_horizontal_rule draws a horizontal line using the specified color and line width. The method ink_prose is provided by Asciidoctor PDF to make writing text to the page easier. Finally, the method convert will convert and render the Asciidoctor node that is passed to it, in this case a block image.

Custom part title

A common need is to add extra styling to the title page for a part in a multi-part book. Since this is a specialized section element, there’s a dedicated method named ink_part_title that you can override. The converter already allocates a dedicated page for the part title (so there’s no need to worry about doing that). The extended converter can override the method that inks the part title to add extra decoration or content to that page.

Let’s customize the part title page by making the background orange, making the font white, aligning the title to the right, adding a line below it, and switching off the running content.

Extended converter with custom part title
class PDFConverterCustomPartTitle < (Asciidoctor::Converter.for 'pdf')
  register_for 'pdf'

  def ink_part_title node, title, opts = {}
    fill_absolute_bounds 'E64C3D'
    move_down cursor * 0.25
    indent bounds.width * 0.5 do
      ink_prose title, line_height: 1.3, color: 'FFFFFF', inline_format: true, align: :right, size: 42, margin: 0
    end
    indent bounds.width * 0.33 do
      move_down 12
      stroke_horizontal_rule 'FFFFFF', line_width: 3
    end
    page.imported
  end
end

The method ink_prose is provided by Asciidoctor PDF to make writing text to the page easier. If you wanted, you could just use the low-level text method provided by Prawn.

It’s also possible to override the start_new_part method if all you want to do is called page.imported to turn off the running content.

Now let’s look at how to center the part title both vertically and horizontally. For this, we first need to compute the height of the title using the height_of_typeset_text helper, taking into account the vertical padding as well. Then, we use that height to position the cursor so that the title falls in the vertical center of the page. Next, we set the text alignment to center (which alternately could be done using the theme). Finally, we delegate to the super method to handle rendering the title in the new position.

Extended converter with centered part title
class PDFConverterCenteredPartTitle < (Asciidoctor::Converter.for 'pdf')
  register_for 'pdf'

  def ink_part_title node, title, opts = {}
    vertical_padding = (padding = expand_padding_value @theme.heading_h1_padding)[0] + padding[2]
    title_height = height_of_typeset_text title, inline_format: true, text_transform: @text_transform
    space_above = (effective_page_height - (title_height + vertical_padding)) * 0.5
    move_down space_above
    opts = opts.merge align: :center
    page.imported
    super
  end
end

To find all the methods available to an extended converter, consult the API docs.

Custom chapter title

A similar need is to add extra styling to the title of a chapter, or to place it on a page by itself. The extended converter can override the method that inks the chapter title to add extra decoration or content to that page, then insert a page break afterwards.

Extended converter with custom chapter title
class PDFConverterCustomChapterTitle < (Asciidoctor::Converter.for 'pdf')
  register_for 'pdf'

  def ink_chapter_title node, title, opts = {}
    move_down cursor * 0.25
    ink_heading title, (opts.merge align: :center, text_transform: :uppercase)
    stroke_horizontal_rule 'DDDDDD', line_width: 2
    move_down theme.block_margin_bottom
    theme_font :base do
      layout_prose 'Custom text here, maybe a chapter preamble.'
    end
    start_new_page
  end
end
It’s also possible to override the start_new_chapter method if all you want to do is called page.imported to turn off the running content.

Chapter image

As another way to customize the chapter title, you may want to add an image above the chapter title if specified. Once again, the extended converter can override the method that inks the chapter title and use it as an opportunity to insert an image.

Extended converter with chapter image
class PDFConverterChapterImage < (Asciidoctor::Converter.for 'pdf')
  register_for 'pdf'

  def ink_chapter_title sect, title, opts
    if (image_path = sect.attr 'image')
      image_attrs = { 'target' => image_path, 'pdfwidth' => '1in' }
      image_block = ::Asciidoctor::Block.new sect.document, :image, content_model: :empty, attributes: image_attrs
      convert_image image_block, relative_to_imagesdir: true, pinned: true
    end
    super
  end
end

The path to the image is controlled using the image block attribute on the chapter.

[image=gears.png]
== Chapter Title

Per chapter TOC

In addition to (or instead of) a TOC for the whole book, you may want to insert a TOC per chapter immediately following the chapter title. Inserting a TOC into the PDF is a two-step process. First, you need to allocate the space for the chapter TOC using the allocate_toc method. Then, you need to come back and ink the TOC after the chapter has been rendered using the ink_toc method.

Extended converter with TOC per chapter
class PDFConverterChapterTOC < (Asciidoctor::Converter.for 'pdf')
  register_for 'pdf'

  def convert_section sect, opts = {}
    result = super
    if (toc_extent = sect.attr 'pdf-toc-extent')
      levels = (sect.document.attr 'chapter-toclevels', 1).to_i + 1
      page_numbering_offset = @index.start_page_number - 1
      float do
        ink_toc sect, levels, toc_extent.from.page, toc_extent.from.cursor, page_numbering_offset
      end
    end
    result
  end

  def ink_chapter_title sect, title, opts
    super
    if ((doc = sect.document).attr? 'chapter-toc') && (levels = (doc.attr 'chapter-toclevels', 1).to_i + 1) > 1
      theme_font :base do
        sect.set_attr 'pdf-toc-extent', (allocate_toc sect, levels, cursor, false)
      end
    end
  end
end

The chapter TOC can is activated by setting the chapter-toc attribute and the depth of the TOC is controlled using the chapter-toclevels attribute. For example:

= Book Title
:chapter-toc:
:chapter-toclevels: 2

License page

Let’s say you want to insert a license page into your documents, but you don’t want to have to put a block macro for it in the document source. You can use an extended converter to add new pages to the body of the document.

Let’s consider the case of reading the license text from a file and inserting it into the first page of the body.

Extended converter with license page
class PDFConverterLicensePage < (Asciidoctor::Converter.for 'pdf')
  register_for 'pdf'

  def traverse node
    return super unless node.context == :document
    start_new_page unless at_page_top?
    theme_font :heading, level: 2 do
      ink_heading 'License', level: 2
    end
    license_text = File.read 'LICENSE'
    theme_font :code do
      ink_prose license_text, normalize: false, align: :left, color: theme.base_font_color
    end
    start_new_page
    super
  end
end

The method start_new_page will create a new page in the document. The ink_prose method provides a normalize option. When this option is false, it will preserve the newlines in the content, which is what we want in the case of license text. You may want to take this a bit further and allow the location of the license file to be configurable.

Paragraph numbering

To help with content auditing or correlation, you may want to add a number in front of each paragraph. You can do this first by assigning a number to each paragraph in the document in the init_pdf method. Then, you can add this number in the left margin at the start of each paragraph by overriding the convert_paragraph method.

Extended converter with paragraph numbering
class PDFConverterNumberedParagraphs < (Asciidoctor::Converter.for 'pdf')
  register_for 'pdf'

  def init_pdf doc
    doc
      .find_by(context: :paragraph) {|candidate| [:document, :section].include? candidate.parent.context }
      .each_with_index {|paragraph, idx| paragraph.set_attr 'number', idx + 1 }
    super
  end

  def convert_paragraph node
    if (paragraph_number = node.attr 'number')
      float do
        label = %(#{paragraph_number}.#{::Prawn::Text::NBSP})
        label_width = rendered_width_of_string label
        bounding_box [-label_width, cursor], width: label_width do
          ink_prose label, color: 'CCCCCC', align: :right, margin: 0, single_line: true
        end
      end
    end
    super
  end
end

Change bars

If you have a preprocessor that adds change metadata to the content, you can use an extended converter to draw change bars to add a visual indicator in the rendered output.

Extended converter with change bars
class PDFConverterChangeBars < (Asciidoctor::Converter.for 'pdf')
  register_for 'pdf'

  def convert_paragraph node
    start_cursor = cursor
    super
    if node.role? 'changed'
      float do
        bounding_box [bounds.left - 4, start_cursor], width: 2, height: (start_cursor - cursor) do
          fill_bounds 'FF0000'
        end
      end
    end
  end
end

This converter will look for paragraphs like this one:

[.changed]
This line has been changed.

Avoid break after heading

This functionality is already provided by the converter if you set the breakable option on section title or discrete heading or you set the heading-min-height-after theme key to auto. The code is presented here both to explain how it works and show how to do it programmatically (perhaps to tune it for specific headings).

If an in-flow heading is followed by content that doesn’t fit on the current page, and the breakable option is not set on the heading, the converter will orphan the heading on the current page. You can fix this behavior by overriding the arrange_heading method in an extended converter.

This extended converter takes this opportunity to use dry_run to make an attempt to write content in the remaining space on the page after the heading. If no content is written, it advances to the next page before inking the heading (and its corresponding anchor).

Extended converter that avoids a page break after a heading
class PDFConverterAvoidBreakAfterSectionTitle < (Asciidoctor::Converter.for 'pdf')
  register_for 'pdf'

  def arrange_heading node, title, opts
    return if y >= page_height / 3 (1)
    orphaned = nil
    dry_run single_page: true do (2)
      start_page = page
      theme_font :heading, level: opts[:level] do
        if opts[:part]
          ink_part_title node, title, opts (3)
        elsif opts[:chapterlike]
          ink_chapter_title node, title, opts (3)
        else
          ink_general_heading node, title, opts (3)
        end
      end
      if page == start_page
        page.tare_content_stream
        orphaned = stop_if_first_page_empty do (4)
          if node.context == :section
            traverse node
          else # discrete heading
            convert (siblings = node.parent.blocks)[(siblings.index node).next]
          end
        end
      end
    end
    advance_page if orphaned (5)
    nil
  end
end
1 An optional optimization to skip this logic if the cursor is above the bottom third of the page.
2 Initiate a dry run up to the end of the current page.
3 Render the heading as normal.
4 Proceed with converting content until the end of the page is reached. Returns true if content is written, false otherwise.
5 Start new page before rendering heading if orphaned.

Additional TOC entries

By default, the table of contents (TOC) only includes section references. If you want to include additional entries in the TOC, or to filter the sections that are included, you can extend the converter and override the get_entries_for_toc method. This method is invoked for each parent entry in the TOC, starting from the document.

Extended converter that adds additional entries to the TOC
class PDFConverterAdditionalTOCEntries < (Asciidoctor::Converter.for 'pdf')
  register_for 'pdf'

  def get_entries_for_toc node
    return super if node.context == :document
    node.blocks.select do |candidate|
      candidate.context == :section ||
        (candidate.id && (candidate.title? || candidate.reftext?))
    end
  end
end

The depth of the TOC is automatically controlled by the toclevels attributes. Once this limit is reached, the converter will not call get_entries_for_toc for that parent (as none of its children will be included in the TOC).

Narrow TOC

Let’s say you want to make the content on the TOC page(s) really narrow. You can do so by overriding the ink_toc method and squeezing the margins by applying extra indentation.

Extended converter with narrow TOC
class PDFConverterNarrowTOC < (Asciidoctor::Converter.for 'pdf')
  register_for 'pdf'

  def ink_toc *_args
    indent 100, 100 do
      super
    end
  end
end

Indent block image

If you want all (or some) block images to be indented by an amount specified in the theme, you can override the convert handler method for block images, convert_image, and call super within an indented context.

Extended converter that indents block images
class PDFConverterImageIndent < (Asciidoctor::Converter.for 'pdf')
  register_for 'pdf'

  def convert_image node
    if (image_indent = theme.image_indent)
      indent(*Array(image_indent)) { super }
    else
      super
    end
  end
end

The indent DSL method adds padding to either side of the content area, delegates to the specified code block, then shaves it back off.

This converter works when a custom theme defines the image-indent key, as follows:

extends: default
image:
  indent: [0.5in, 0]

Look for images in multiple dirs

By default, an AsciiDoc converter only supports resolving images from a single location, the value of the imagesdir attribute. You can use an extended converter to have Asciidoctor PDF look in multiple locations until it finds the image.

Extended converter that resolve images from multiple locations
class PDFConverterMultipleImagesdirs < (Asciidoctor::Converter.for 'pdf')
  register_for 'pdf'

  def resolve_image_path node, image_path, image_format, relative_to = true
    if relative_to == true
      unless File.file? image_path
        docdir = (doc = node.document).attr 'docdir'
        %w(imagesdir imagesdir2).each do |attr_name|
          imagesdir = (doc.attr attr_name) || ''
          abs_imagesdir = File.absolute_path imagesdir, docdir
          next unless File.file? (File.absolute_path image_path, abs_imagesdir)
          relative_to = abs_imagesdir
          break
        end
      end
    end
    super
  end
end

If you need the converter to support more than two locations, update the list of attribute names in the extended converter.

Language label on code block

The built-in HTML converter inserts a source language label in the upper right corner of the code block, which appears on hover. You can use an extended converter to imprint a fixed label in the PDF output.

To add this label, you’ll need to override the arrange_block method of the converter. This method arranges content blocks that have a border and/or background or support unbreakable, such as code blocks. The override needs to filter the arguments for a node that has the source style and language attribute. If the method detects that combination, it must decorate the callback passed via the &block argument to inject the extra logic. Otherwise, the method should delegate directly to super.

When a code block is detected, the decorator should first call the block argument using instance_exec. Then, it should look to see if the extent is set and that this is not a dry run. The extent provides information about where the background and border of the code block started. The extended converter should move to that page and cursor, reapply the code block padding, and ink the label using the code font settings.

Extended converter that imprints a source langauge label on code blocks
class PDFConverterSourceLanguageLabel < (Asciidoctor::Converter.for 'pdf')
  register_for 'pdf'

  def arrange_block node, &block
    return super unless node.style == 'source' && (lang = node.attr 'language')
    super node do |extent|
      return_val = instance_exec extent, &block
      if extent && !scratch?
        float do
          go_to_page extent.from.page
          bounds.current_column = extent.from.column if ColumnBox === bounds
          move_cursor_to extent.from.cursor
          pad_box theme.code_padding, node do
            theme_font :code do
              ink_prose lang,
                align: :right,
                text_transform: :uppercase,
                margin: 0,
                color: theme.quote_cite_font_color
            end
          end
        end
      end
      return_val
    end
  end
end

The way this extended converter is written, the label is inked on top of the inked code block. You’re free to customize where the label is placed. The float method allows you to move the cursor around in absolute space without impacting the flow of the content. The extent gives you the information about the location of the code block.

Wrap code blocks around an image float

Asciidoctor PDF provides basic support for image floats. It will wrap paragraph text on the opposing side of the float. However, if it encounters a non-paragraph, the converter will clear the float and continue positioning content below the image.

As a companion to this basics support, the converter provides a framework for broadening support for float wrapping. We can take advantage of this framework in an extended converter. By extending the converter and overriding the supports_float_wrapping? as well as the convert handler for the block you want to enlist (e.g., convert_code), you can arrange additional content into the empty space adjacent to the floated image. In the following example, code (listing and literal) blocks are included in the float wrapping.

Extended converter that additionally wraps code blocks around an image float
class PDFConverterCodeFloatWrapping < (Asciidoctor::Converter.for 'pdf')
  register_for 'pdf'

  def supports_float_wrapping? node
    %i(paragraph listing literal).include? node.context
  end

  def convert_code node
    return super unless (float_box = @float_box ||= nil)
    indent(float_box[:left] - bounds.left, bounds.width - float_box[:right]) { super }
    @float_box = nil unless page_number == float_box[:page] && cursor > float_box[:bottom]
  end
end

You can configure the gap next to and below the image using the image-float-gap key in the theme.

extends: default
image:
  float-gap: [12, 6]

Theme table using roles

The converter only supports custom roles on paragraphs and phrases. You can use an extended converter to add this capability to tables.

Extended converter that supports a custom role on a table
class PDFConverterTableRole < (Asciidoctor::Converter.for 'pdf')
  register_for 'pdf'

  def convert_table node
    if node.role?
      key_prefix = %(role_<table>_#{node.roles[0]}_)
      unless (role_entries = theme.each_pair.select {|name, val| name.to_s.start_with? key_prefix }).empty?
        save_theme do
          role_entries.each do |name, val|
            theme[%(table_#{name.to_s.delete_prefix key_prefix})] = val
          end
          super
        end
        return
      end
    end
    super
  end
end

This extended converter allows you to specify any theme key on the custom role that’s supported for tables. The role must be defined under a special role name <table> (to avoid clashing with other role names). Here’s an example of a custom table role named thick that increases the width of the table border and grid lines.

extends: default
role:
  <table>:
    thick:
      border-width: 2
      grid-width: 2

You apply this role to a table by prepending .thick to the first positional attribute in the block attribute line above the table.

As written, the extended converter only supports the first role on the table. It could be enhanced to support an arbitrary number of roles, with each successive role cascading (like CSS).

You can use the technique shown in this extended converter to add role-based theming to any other block type recognized by the theme (e.g., code, sidebar, etc).

Theme admonition per type

Similarly to the custom table role, we can use an extended converter to add support for theme keys per admonition type.

Extended converter that supports theme keys per admonition type
class PDFConverterAdmonitionThemePerType < (Asciidoctor::Converter.for 'pdf')
  register_for 'pdf'

  def convert_admonition node
    type = node.attr 'name'
    key_prefix = %(admonition_#{type}_)
    entries = theme.each_pair.select {|name, val| name.to_s.start_with? key_prefix }
    return super if entries.empty?
    save_theme do
      entries.each do |name, val|
        theme[%(admonition_#{name.to_s.delete_prefix key_prefix})] = val
      end
      super
    end
  end
end

This converter temporarily promotes keys under the admonition-<type> theme category to the admonition theme category, overriding any existing keys. The placeholder <type> represents the admonition type; caution, important, note, tip, or warning.

Here’s an example that shows how you’d use the theme to apply a border to the important admonition type when using this extended converter:

admonition:
  important:
    border-color: #BF0000
    border-width: 1
    column-rule-width: 0
    padding: 12

Multiple columns

Starting with Asciidoctor PDF 2.1, this converter provides built-in support for multiple columns. This feature is available when the doctype is article or manpage, but not book. The columns get applied to the body of the document, which excludes the document title and TOC, if present.

The Asciidoctor PDF converter also provides the framework for making multi-column layouts in an extended converter. This framework is accessible via the helper method column_box. To make a multi-column layout, you put statements that ink content inside a code block and pass it to the column_box method as follows:

column_box [bounds.left, cursor], columns: 2, width: bounds.width, reflow_margins: true do
  ink_prose 'left column'
  bounds.move_past_bottom
  ink_prose 'right column'
end

If you want a multi-column layout for a specific chapter or section, you can override the traverse method, look for the section you want to arrange, and wrap the call to super in a column_box enclosure.

Access page number from inline macro

Although not an extended converter, this use case uses information from the converter in much the same way. In this case, we’re interested in retrieving the page number and inserting it into the content.

Let’s create an inline macro named pagenum that inserts the current page number into the document when the macro is converted.

Asciidoctor::Extensions.register do
  inline_macro :pagenum do
    format :short
    process do |parent|
      create_inline parent, :quoted, parent.document.converter.page_number.to_s
    end
  end
end

Here’s how this macro would be used.

= Document Title
:doctype: book

You're looking at page number pagenum:[].

Resources

To find even more examples of how to override the behavior of the converter, refer to the extended converter in the InfoQ Mini-Book template.