Skip to content

Instantly share code, notes, and snippets.

@kennypete
Last active April 1, 2026 07:14
Show Gist options
  • Select an option

  • Save kennypete/1b835dc408d39a0bd6d206594216b1fc to your computer and use it in GitHub Desktop.

Select an option

Save kennypete/1b835dc408d39a0bd6d206594216b1fc to your computer and use it in GitHub Desktop.
Asciidoctor PDF: Chapter-level Custom Running Headers

Asciidoctor PDF: Chapter-level Custom Running Headers

The challenge

Asciidoctor PDF lets you display {chapter-title} in running headers, but that always mirrors the == literal heading text. There is no built-in mechanism to show alternative text in the header from what appears as the chapter heading in the body.

This Gist shows a minimal, self-contained solution using a Ruby extension that patches SectionInfoByPage#[]= (the internal class that Asciidoctor PDF uses to record which chapter is current for each page).

Files, command line, and PDF output

  • book.adoc – the AsciiDoc document

  • theme.yml – the PDF YAML theme

  • running-header.rb – the Ruby extension

  • a minimal command line

  • book.pdf – the PDF result, and

  • versions and operating systems details.

book.adoc

:doctype: book
:pdf-theme: ./theme.yml

= Book Title

[running-header="Ch 1 - changed 2026-04-01"]
== One

Chapter One’s text.  Note the header is picked up from the _running-header_
attribute, so this page, and the following two pages, have “Ch 1 - changed
2026-04-01” as the header.

<<<

More of Chapter One’s text....

<<<

Final page of Chapter One’s text....

[running-header="Ch 2 - changed 2026-03-31"]
== Two

Chapter Two’s text.  Note the header is picked up from the _running-header_
attribute, so this page and the next page have “Ch 2 - changed 2026-03-31” as
the header.

<<<

Final page of Chapter Two’s text....

== Three

Chapter Three’s text.  Note there is no _running-header_ attribute, so the
Chapter heading is reflected in the header verbatim.

//vim:ft=asciidoc:tw=78:noet:norl:
Note
The running-header attribute is set on the block attribute line immediately before each == heading. Chapters without a running-header attribute fall through to the default behaviour and display the heading text as-is.

theme.yml

extends: default

header:
  height: 48pt
  line-height: 1
  recto:
    center:
      content: '{chapter-title}'
  verso:
    center:
      content: '{chapter-title}'

Only the standard attribute, {chapter-title}, is used in the theme extension. The extension patches the value that gets stored under that attribute for each page.

running-header.rb

module SectionInfoOverride
  def []= pgnum, val
    if ::Asciidoctor::Section === val && (override = val.attr('running-header', nil, false))
      @table[pgnum] = { title: override, numeral: val.numeral }
    else
      super
    end
  end
end

Asciidoctor::PDF::SectionInfoByPage.prepend SectionInfoOverride

Command line

asciidoctor-pdf -r ./running-header.rb book.adoc
Warning
The -r flag must be on the command line. It cannot be specified inside the .adoc file, because the running-header.rb extension must be loaded before processing begins.

How it works

Inside ink_running_content (in converter.rb, which should be in the gem environment gemdir location – for me using WSL and Debian 13.4 that was /var/lib/gems/3.1.0/gems/asciidoctor-pdf-2.3.9/lib/asciidoctor/pdf/), Asciidoctor PDF builds a local variable chapters_by_page, which is an instance of SectionInfoByPage. It iterates every page, finds which chapter is current, and calls:

chapters_by_page[pgnum] = last_chap

Later, for each page during the stamp pass, it reads:

doc.set_attr 'chapter-title', ((chap_info = chapters_by_page[pgnum])[:title] || '')

So, {chapter-title} in the theme resolves to whatever :title was stored in chapters_by_page for that page number.

SectionInfoByPage#[]= (in section_info_by_page.rb – in the same gems path as converter.rb) is where the section node gets converted to a { title:, numeral: } hash:

def []= pgnum, val
  if ::Asciidoctor::Section === val
    @table[pgnum] = { title: val.send(*@title_method), numeral: val.numeral }
  else
    @table[pgnum] = { title: val }
  end
end

The mechanism

Because SectionInfoByPage#[]= receives the raw section node, it has access to all of the node’s attributes, including any running-header attribute set in book.adoc. The prepended module (see [running-headerrb], above) intercepts the storage step at the class level: if a running-header attribute is present, the override title is written to @table instead of the heading text; otherwise super fires and the literal chapter title is stored unchanged. Since the extension is loaded via -r before processing begins, the patch is in place for every chapters_by_page[pgnum] = last_chap call.

The result is that {chapter-title} resolves per page to the override string on chapters that have one, and to the normal heading text on chapters that do not.

PDF output

  • See this Gist’s attachments, below.

Operating systems, Asciidoctor, and Ruby versions

  • Linux (WSL, Debian 13.4)

    • Asciidoctor 2.0.20

    • Asciidoctor PDF 2.3.9

    • Ruby 3.1.2p20 (2022-04-12 revision 4491bb740a) [x86_64-linux-gnu]

  • Windows 11

    • Asciidoctor 2.0.20

    • Asciidoctor PDF 2.3.10

    • Ruby 3.2.2 (2023-03-30 revision e51014f9c0) [x64-mingw-ucrt]

Book Title

One

Chapter One’s text. Note the header is picked up from the running-header attribute, so this page, and the following two pages, have “Ch 1 - changed 2026-04-01” as the header.

More of Chapter One’s text…​.

Final page of Chapter One’s text…​.

Two

Chapter Two’s text. Note the header is picked up from the running-header attribute, so this page and the next page have “Ch 2 - changed 2026-03-31” as the header.

Final page of Chapter Two’s text…​.

Three

Chapter Three’s text. Note there is no running-header attribute, so the Chapter heading is reflected in the header verbatim.

Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
module SectionInfoOverride
def []= pgnum, val
if ::Asciidoctor::Section === val && (override = val.attr('running-header', nil, false))
@table[pgnum] = { title: override, numeral: val.numeral }
else
super
end
end
end
Asciidoctor::PDF::SectionInfoByPage.prepend SectionInfoOverride
extends: default
header:
height: 48pt
line-height: 1
recto:
center:
content: '{chapter-title}'
verso:
center:
content: '{chapter-title}'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment