///|
// The block-level converter: a walk over the document tree that inks each
// block into the page flow, following asciidoctor-pdf's `convert_*`
// methods and its default theme.

///|
priv struct Converter {
  doc : @core.Node
  flow : Flow
  outline : Array[@pdf.OutlineEntry]
  /// asciidoctor-pdf's `@bottom_gutters`: the block whose last line must
  /// keep clear of an enclosing padding, and by how much
  gutters : Array[(@core.Node, Double)?]
  /// asciidoctor-pdf's `@base_text_align`: how prose aligns by default
  mut text_align : Alignment
  /// the pages reserved for the TOC (`@toc_extent`), once allocated
  mut toc_extent : TocExtent?
  /// the `toc::[]` block the TOC was placed at, and its destination
  mut toc_node : (@core.Node, String)?
  /// `toclevels` as the conversion starts (`toc_num_levels`), before the
  /// body's attribute entries: the TOC's depth and the outline's default
  mut toc_levels : Int
  /// asciidoctor-pdf's `@disable_running_content`: the (1-based) pages
  /// whose header or footer is left out (the TOC's, when the `toc::[]`
  /// macro has the `noheader` or `nofooter` option)
  disable_running_content : Map[String, Array[Int]]
  /// asciidoctor-pdf's `@float_box`: the space beside a floated image
  /// that the next paragraphs wrap into (see `init_float_box`)
  mut float_box : FloatBox?
  /// the first page is a front cover (see `ink_cover_page`)
  mut has_front_cover : Bool
  /// asciidoctor-pdf's `pinned` option of `convert_image` (the title page
  /// logo): the image stays on the page, with no margin or float after it
  mut pinned : Bool
  /// asciidoctor-pdf's `@ppbook`: a book for `media=prepress`, whose
  /// chapters (and TOC and body) start on a recto page
  mut ppbook : Bool
  /// the height of the last content page, which asciidoctor-pdf's
  /// `dest_top` of the outline's title and the `FitH` open action take
  /// (`page_height`)
  mut end_page_h : Double
}

///|
fn Converter::new(doc : @core.Node, flow : Flow) -> Converter {
  {
    doc,
    flow,
    outline: [],
    gutters: [],
    text_align: Justify,
    toc_extent: None,
    toc_node: None,
    toc_levels: resolve_toclevels(doc),
    disable_running_content: Map([("header", []), ("footer", [])]),
    float_box: None,
    has_front_cover: false,
    pinned: false,
    ppbook: false,
    end_page_h: page_height(),
  }
}

///|
/// Lay out `body` in a scratch document (asciidoctor-pdf's `dry_run`) on a
/// new page, with bounds `left`..`right` and the cursor at `start_y` (by
/// default the page's top); returns the scratch flow, whose pages and
/// cursor tell how far the content went. The run stores no index terms
/// and leaves no footnotes registered (`push_scratch`/`pop_scratch`).
fn Converter::dry_run(
  self : Converter,
  left : Double,
  right : Double,
  start_y? : Double,
  bottom? : Double,
  body : (Converter) -> Unit,
) -> Flow {
  let flow = Flow::new(self.flow.catalog, scratch=true)
  flow.start_new_page()
  flow.left = left
  flow.right = right
  match bottom {
    Some(b) => flow.bottom = b
    None => ()
  }
  match start_y {
    Some(y) => flow.y = y
    None => ()
  }
  let scratch = Converter::new(self.doc, flow)
  scratch.gutters.append(self.gutters)
  scratch.text_align = self.text_align
  // the outermost dry run restores the document's attributes, and so its
  // counters (a footnote's number), when it ends (`push_scratch`)
  let saved_attributes = if session().scratch == 0 {
    Some(self.doc.attributes.copy())
  } else {
    None
  }
  session().scratch += 1
  let footnotes = self.doc.catalog().footnotes
  let registered = footnotes.length()
  body(scratch)
  while footnotes.length() > registered {
    ignore(footnotes.pop())
  }
  session().scratch -= 1
  match saved_attributes {
    Some(saved) => self.doc.attributes.replace(saved)
    None => ()
  }
  flow
}

///|
/// asciidoctor-pdf's `arrange_block` for a block that may break across
/// pages: `body` inks the block; a dry run of it from the cursor tells
/// whether it would ink nothing on this page but tared content (its
/// caption, its background: `Flow::tared`) before moving to the next page
/// (`dry_run` with `stop_if_first_page_empty`), in which case the block
/// starts on the next page.
///
/// With `keep_together` (the `unbreakable` option), a first dry run from
/// the top of a page tells whether the block fits on one page; when it
/// does but not in the room left here, it starts on the next page
/// (`ScratchExtent#position_onto`); when it does not, it is arranged as
/// above.
fn Converter::arrange_block(
  self : Converter,
  keep_together? : Bool = false,
  node? : @core.Node,
  body : (Converter) -> Unit,
) -> Unit {
  // `pdf-at-top`: the block starts at the top of a page, or is the first
  // child of one that does (read by block images)
  match node {
    Some(n) => {
      if self.flow.at_page_top() || first_child_at_top(n) {
        ignore(n.set_attr("pdf-at-top", value=Str("")))
      }
      // a dry run from the top of a page sets it too, for good
      let body_for_scratch = fn(c : Converter) {
        if c.flow.scratch && c.flow.at_page_top() {
          ignore(n.set_attr("pdf-at-top", value=Str("")))
        }
        body(c)
      }
      self.arrange_block_at(
        keep_together~,
        moved=() => ignore(n.set_attr("pdf-at-top", value=Str(""))),
        body_for_scratch,
      )
      ignore(n.remove_attr("pdf-at-top"))
    }
    None => self.arrange_block_at(keep_together~, body)
  }
}

///|
/// Whether `node` is its parent's first block and the parent is arranged
/// at the top of a page (asciidoctor-pdf's `node.first_child? &&
/// node.parent.attr?('pdf-at-top')`).
fn first_child_at_top(node : @core.Node) -> Bool {
  match node.parent() {
    Some(parent) =>
      parent.blocks.get(0) is Some(first) &&
      physical_equal(first, node) &&
      parent.has_attr("pdf-at-top")
    None => false
  }
}

///|
fn Converter::arrange_block_at(
  self : Converter,
  keep_together? : Bool = false,
  moved? : () -> Unit = () => (),
  body : (Converter) -> Unit,
) -> Unit {
  let flow = self.flow
  if !flow.at_page_top() && keep_together {
    let scratch = self.dry_run(flow.left, flow.right, bottom=flow.bottom, body)
    if scratch.model.pages.length() == 1 {
      if scratch.y - scratch.page_top > flow.cursor() {
        flow.advance_page()
      }
      body(self)
      return
    }
  }
  if !flow.at_page_top() {
    let scratch = self.dry_run(
      flow.left,
      flow.right,
      start_y=flow.y,
      bottom=flow.bottom,
      body,
    )
    if scratch.first_break_empty == Some(true) {
      flow.advance_page()
      // asciidoctor-pdf's dry run goes on from the top of the next page,
      // where the block is at the top (see `arrange_block`)
      moved()
    }
  }
  body(self)
}

///|
/// Run `body` with prose aligned to `align` by default, when given
/// (asciidoctor-pdf swaps `@base_text_align`).
fn Converter::with_text_align(
  self : Converter,
  align : Alignment?,
  body : () -> Unit,
) -> Unit {
  match align {
    Some(a) => {
      let saved = self.text_align
      self.text_align = a
      body()
      self.text_align = saved
    }
    None => body()
  }
}

///|
/// What the last `convert_document` could not render, one line each:
/// `UNSUPPORTED   at :`. Content the spike does not
/// implement is left out of the PDF (or approximated), and this is where it
/// says so; the CLI prints these lines on stderr. Problems Ruby
/// asciidoctor-pdf reports too (content it cuts off, such as a table cell
/// taller than a page) are `WARNING  at :`.
let diagnostics : Array[String] = []

///|
/// Report a diagnostic (see `diagnostics`): one of the conversion in
/// progress, or, outside a conversion (while the document is loaded), one
/// its conversions report first.
fn report(line : String) -> Unit {
  let s = session()
  if s.converting {
    diagnostics.push(line)
  } else {
    s.pending_diagnostics.push(line)
  }
}

///|
/// The unsupported content and the warnings the last `convert_document`
/// met (see `diagnostics`), in document order.
pub fn conversion_warnings() -> Array[String] {
  diagnostics.copy()
}

///|
/// Where one block of the last `convert_document` went: its nesting depth,
/// what it is, its source line, and the flow position (page from 1, cursor
/// in points from the page top) where its conversion started and ended.
priv struct TraceEntry {
  depth : Int
  what : String
  line : Int
  start_page : Int
  start_y : Double
  mut end_page : Int
  mut end_y : Double
}

///|
let trace : Array[TraceEntry] = []

///|
let trace_depth : Ref[Int] = { val: 0, }

///|
/// Where the blocks of the last `convert_document` went, one line each in
/// the order they started: `depth what line start_page start_y end_page
/// end_y`, tab-separated (pages from 1, y in points from the page top).
/// Comparison tools map a position in the PDF to the innermost block
/// containing it.
pub fn conversion_trace() -> Array[String] {
  trace.map(e => {
    "\{e.depth}\t\{e.what}\t\{e.line}\t\{e.start_page}\t\{e.start_y}\t\{e.end_page}\t\{e.end_y}"
  })
}

///|
/// Run `body`, the conversion of `node`, recording where it went (see
/// `conversion_trace`); dry runs leave no trace.
fn Converter::traced(
  self : Converter,
  node : @core.Node,
  what : String,
  body : () -> Unit,
) -> Unit {
  if self.flow.scratch {
    body()
    return
  }
  let entry : TraceEntry = {
    depth: trace_depth.val,
    what,
    line: node.lineno().unwrap_or(0),
    start_page: self.flow.page + 1,
    start_y: self.flow.y,
    end_page: 0,
    end_y: 0.0,
  }
  trace.push(entry)
  trace_depth.val += 1
  body()
  trace_depth.val -= 1
  entry.end_page = self.flow.page + 1
  entry.end_y = self.flow.y
}

///|
/// `file:line` of a node, or of its nearest ancestor that has one (inline
/// nodes carry no location of their own). Locations exist only when the
/// document was loaded with `sourcemap`.
fn source_location(node : @core.Node) -> String {
  let mut current : @core.Node? = Some(node)
  while current is Some(n) {
    match n.lineno() {
      Some(line) => return "\{n.file().unwrap_or("")}:\{line}"
      None => current = n.parent()
    }
  }
  ""
}

///|
/// Record content the spike leaves out or approximates.
fn unsupported(kind : String, what : String, node : @core.Node) -> Unit {
  report("UNSUPPORTED \{kind} \{what} at \{source_location(node)}")
}

///|
/// Record a problem Ruby asciidoctor-pdf also reports (as a warning or
/// error on its log), e.g. content cut off: `WARNING  at
/// :`.
fn warning(message : String, node : @core.Node) -> Unit {
  report("WARNING \{message} at \{source_location(node)}")
}

///|
/// The name of the destination at the top of the document
/// (asciidoctor-pdf's `pdf-anchor` of the document): its id, else
/// `__anchor-top`.
fn top_anchor(doc : @core.Node) -> String {
  match doc.id {
    Some(id) => pdf_anchor_name(id)
    None => "__anchor-top"
  }
}

///|
/// The PDF destination name for an id (asciidoctor-pdf's
/// `derive_anchor_from_id`): the id itself when it is ASCII, else `0x` and
/// the hex of its UTF-8 bytes.
fn pdf_anchor_name(id : String) -> String {
  if id.iter().all(c => c.to_int() < 0x80) {
    return id
  }
  let sb = StringBuilder()
  sb.write_string("0x")
  for byte in @utf8.encode(id) {
    let v = byte.to_int()
    sb.write_char("0123456789abcdef"[v >> 4].unsafe_to_char())
    sb.write_char("0123456789abcdef"[v & 15].unsafe_to_char())
  }
  sb.to_string()
}

///|
/// The document title as asciidoctor-pdf resolves it for the outline and
/// the information dictionary (`resolve_doctitle`): the header's title, or
/// `untitled-label` for a document without a header.
fn resolve_doctitle(doc : @core.Node) -> String? {
  if doc.has_header() {
    doc.doctitle()
  } else {
    doc.attr("untitled-label")
  }
}

///|
/// A date attribute as Asciidoctor writes it (`2026-09-29 15:12:28 +0800`,
/// or `... UTC`) in PDF date form, `D:20260929151228+08'00'`, as Prawn
/// writes the Ruby `Time` it parses to. None when it does not parse, or
/// when the result is not a valid PDF date (month 13, 30 February, hour
/// 24): the date is then left out, where `render_pdf` would refuse the
/// whole document.
fn pdf_date_of_attribute(value : String) -> String? {
  let digits = StringBuilder()
  let mut count = 0
  let mut i = 0
  // the fourteen digits of the date and time, skipping their separators
  while i < value.length() && count < 14 {
    let c = value[i]
    if c >= '0' && c <= '9' {
      digits.write_char(c.to_int().unsafe_to_char())
      count += 1
    } else if c != '-' && c != ':' && c != ' ' {
      return None
    }
    i += 1
  }
  if count != 14 {
    return None
  }
  let zone = value[i:].trim().to_owned()
  let offset = if zone == "" || zone == "UTC" || zone == "Z" {
    "+00'00'"
  } else if zone.length() == 5 &&
    (zone[0] == '+' || zone[0] == '-') &&
    zone[1:].iter().all(c => c >= '0' && c <= '9') {
    "\{zone[0:3]}'\{zone[3:5]}'"
  } else {
    return None
  }
  let date = "D:\{digits.to_string()}\{offset}"
  if @pdf.is_pdf_date(date) {
    Some(date)
  } else {
    None
  }
}

///|
/// The document information entries asciidoctor-pdf writes
/// (`build_pdf_info`).
fn pdf_info(doc : @core.Node) -> @pdf.DocumentInfo {
  let author = if doc.attribute_locked("author") &&
    !doc.attribute_locked("authors") {
    doc.attr("author")
  } else {
    match doc.attr("authors") {
      Some(authors) => Some(authors)
      None => doc.attr("author")
    }
  }
  let creator = match doc.attr("publisher") {
    Some(publisher) => sanitize(publisher)
    None => author.map(sanitize).unwrap_or("")
  }
  // the modification date is the input's, the creation date the
  // conversion's
  let (mod_date, creation_date) = if doc.has_attr("reproducible") {
    (None, None)
  } else {
    (
      doc.attr("docdatetime").bind(pdf_date_of_attribute),
      doc.attr("localdatetime").bind(pdf_date_of_attribute),
    )
  }
  @pdf.DocumentInfo::new(
    title?=resolve_doctitle(doc).map(sanitize),
    author?=author.map(sanitize),
    subject?=doc.attr("subject").map(sanitize),
    keywords?=doc.attr("keywords").map(sanitize),
    creator~,
    producer=doc
      .attr("producer")
      .map(sanitize)
      .unwrap_or("Asciidoctor PDF \{version} (MoonBit), based on pagelayout"),
    creation_date?,
    mod_date?,
  )
}

///|
/// asciidoctor-pdf's outline depth and how many levels start expanded:
/// `outlinelevels` (default `toclevels` as the conversion starts, see
/// `Converter::toc_levels`), where `N:M` expands only the first M levels.
fn outline_levels(doc : @core.Node, toclevels : Int) -> (Int, Int) {
  match doc.attr("outlinelevels") {
    Some(spec) =>
      match spec.find(":") {
        Some(i) => {
          let levels = spec[:i].to_owned()
          let num = if levels == "" {
            toclevels
          } else {
            @string.parse_int(levels) catch {
              _ => toclevels
            }
          }
          let expand = @string.parse_int(spec[i + 1:].to_owned()) catch {
            _ => 0
          }
          (num, expand)
        }
        None => {
          let num = @string.parse_int(spec) catch { _ => toclevels }
          (num, num)
        }
      }
    None => (toclevels, toclevels)
  }
}

///|
/// Report document-level features the spike does not implement.
fn report_document_features(doc : @core.Node, catalog : FontCatalog) -> Unit {
  if doc.attr("icons") == Some("font") && !catalog.has_icons() {
    unsupported("attribute", "icons=font (no icon fonts)", doc)
  }
  if doc.has_attr("source-highlighter") {
    unsupported("attribute", "source-highlighter", doc)
  }
}

///|
/// A name given to several destinations (an index term and a subterm of
/// the same name, a repeated id) goes to the last one: Prawn's name tree
/// inserts a new entry before an equal one, so a reader finds the latest
/// (pdf-core's `NameTree::Node#insertion_point`). Pagelayout keeps the
/// first, so the earlier ones are dropped.
fn keep_last_anchors(model : @pagelayout.PageModel) -> Unit {
  let seen : Map[String, Bool] = Map([])
  for p = model.pages.length() - 1; p >= 0; p = p - 1 {
    let items = model.pages[p].items
    let kept : Array[@pagelayout.PageItem] = []
    for i = items.length() - 1; i >= 0; i = i - 1 {
      match items[i] {
        Anchor(anchor) if seen.contains(anchor.name) => ()
        Anchor(anchor) => {
          seen[anchor.name] = true
          kept.push(items[i])
        }
        item => kept.push(item)
      }
    }
    if kept.length() != items.length() {
      kept.rev_in_place()
      items.clear()
      items.append(kept)
    }
  }
}

///|
/// The document outline (asciidoctor-pdf's `add_outline`): the document
/// title (`outline-title`, which defaults to the resolved title), going to
/// the top of the first page, then the sections, with the TOC among them
/// when there is one (`insert_toc_section`).
fn Converter::document_outline(self : Converter) -> Array[@pdf.OutlineEntry] {
  let doc = self.doc
  let outline : Array[@pdf.OutlineEntry] = []
  // `add_outline`: only when the `outline` attribute is set; the title
  // entry is `outline-title`'s (the document title when empty)
  if !doc.has_attr("outline") {
    return outline
  }
  let outline_title = match doc.attr("outline-title") {
    Some("") => resolve_doctitle(doc)
    title => title
  }
  // the title goes to the first page after the front cover, if any
  let initial_page = if self.has_front_cover { 1 } else { 0 }
  match outline_title {
    Some(title) if self.flow.model.pages.length() > initial_page =>
      outline.push({
        title: sanitize(title),
        level: 0,
        // `dest_top initial_pagenum`: at the height of the page the
        // conversion ended on (the last content page)
        dest: Position(
          page=initial_page,
          x=0.0,
          y=self.flow.model.pages[initial_page].height_pt - self.end_page_h,
        ),
        open: false,
      })
    _ => ()
  }
  let sections = self.outline
  let toc_title = doc.attr("toc-title").unwrap_or("")
  let toc_entry = match self.toc_extent {
    Some(extent) if toc_title != "" && !doc.sections().is_empty() =>
      match self.toc_node {
        // the macro: after the section it is in (at that section's level),
        // going to its destination
        Some((node, anchor)) => {
          let entry : @pdf.OutlineEntry = {
            title: sanitize(toc_title),
            level: 0,
            dest: Named(anchor),
            open: false,
          }
          match node.parent() {
            Some(parent) if parent.context == Section => {
              let parent_anchor = pdf_anchor_name(
                parent.attr("pdf-anchor").unwrap_or(""),
              )
              let mut at = -1
              let mut level = 0
              for i, e in sections {
                if at < 0 && e.dest == Named(parent_anchor) {
                  at = i + 1
                  level = e.level
                } else if at == i && e.level > level {
                  at = i + 1
                }
              }
              if at < 0 {
                None
              } else {
                Some((at, { ..entry, level, }))
              }
            }
            _ => Some((0, entry))
          }
        }
        // at the top: first, going to the top of the TOC's first page
        None =>
          Some(
            (
              0,
              {
                title: sanitize(toc_title),
                level: 0,
                dest: Position(page=extent.from_page, x=0.0, y=0.0),
                open: false,
              },
            ),
          )
      }
    _ => None
  }
  for i, entry in sections {
    match toc_entry {
      Some((at, toc)) if at == i => outline.push(toc)
      _ => ()
    }
    outline.push(entry)
  }
  match toc_entry {
    Some((at, toc)) if at == sections.length() => outline.push(toc)
    _ => ()
  }
  outline
}

///|
/// Strip markup and decode references: the plain text of formatted text.
fn sanitize(text : String) -> String {
  let sb = StringBuilder()
  let mut in_tag = false
  let mut after_tag = false
  for c in text {
    if c == '<' {
      in_tag = true
    } else if c == '>' && in_tag {
      in_tag = false
      after_tag = true
      continue
    } else if !in_tag && !(after_tag && c == '\u{0}') {
      sb.write_char(c)
    }
    after_tag = false
  }
  // `compact`: stripped, runs of spaces squeezed
  let decoded = decode_entities(sb.to_string())
  let out = StringBuilder()
  let mut space = false
  for c in decoded.trim(chars=" \t\n") {
    if c == ' ' {
      if !space {
        out.write_char(c)
      }
      space = true
    } else {
      out.write_char(c)
      space = false
    }
  }
  out.to_string()
}

///|
fn Converter::traverse(self : Converter, node : @core.Node) -> Unit {
  if node.has_blocks() {
    for block in node.blocks {
      self.convert_block(block)
    }
  } else if node.content_model != Compound {
    // a simple block (e.g. `NOTE: text`) carries its text itself
    let text = node.content()
    if !text.is_empty() {
      self.ink_prose(
        text,
        align=role_align(node.roles(), self.text_align),
        margin_bottom=0.0,
        bottom_gutter=self.gutter(node),
      )
    }
  }
}

///|
fn Converter::convert_block(self : Converter, node : @core.Node) -> Unit {
  // the attribute entries in the body take effect as the block before
  // which they stand is converted (AbstractBlock#convert); after parsing,
  // the document's attributes are those of its header
  node.document().playback_attributes(node.attributes)
  let what = match node.style {
    Some(style) => "\{node.context.name()}/\{style}"
    None => node.context.name()
  }
  self.traced(node, what, () => self.convert_block_body(node))
}

///|
fn Converter::convert_block_body(self : Converter, node : @core.Node) -> Unit {
  match node.context {
    Section => self.convert_section(node)
    Preamble => self.convert_preamble(node)
    Paragraph => self.convert_paragraph(node)
    Listing | Literal => self.convert_code(node)
    Stem => self.convert_code(node, plain=true)
    Pass => self.convert_pass(node)
    Video => self.convert_media(node, "video")
    Audio => self.convert_media(node, "audio")
    Ulist => self.convert_ulist(node)
    Olist => self.convert_olist(node)
    Colist => self.convert_colist(node)
    Dlist => self.convert_dlist(node)
    Table => self.convert_table(node)
    Admonition => self.convert_admonition(node)
    Image => self.convert_image(node)
    Example => self.convert_example(node)
    Sidebar => self.convert_sidebar(node)
    Quote | Verse => self.convert_quote(node)
    Open => self.convert_open(node)
    ThematicBreak => self.convert_thematic_break(node)
    PageBreak => self.convert_page_break(node)
    Toc => self.convert_toc(node, "macro")
    FloatingTitle => self.convert_floating_title(node)
    other => unsupported("block", other.name(), node)
  }
}

///|
/// asciidoctor-pdf's `convert_page_break`: the next page, in the layout
/// the `page-layout` attribute (else the last layout role) names; at the
/// top of a page, nothing unless `always`, or the empty page turned to
/// that layout. In a column box, a page break (unless the `column` role)
/// leaves from the last column.
fn Converter::convert_page_break(self : Converter, node : @core.Node) -> Unit {
  let flow = self.flow
  let valid = (l : String) => l == "portrait" || l == "landscape"
  let layout = match node.attr("page-layout") {
    Some(l) if l != "" => if valid(l) { Some(l) } else { None }
    _ => node.roles().rev_iter().find_first(valid)
  }
  let current = match flow.model.pages.get(flow.page) {
    Some(p) => if p.width_pt > p.height_pt { "landscape" } else { "portrait" }
    None => "portrait"
  }
  if flow.at_page_top() && !node.has_option("always") {
    match layout {
      Some(l) if l != current &&
        flow.model.pages[flow.page].items.iter().all(i => i is Anchor(_)) => {
        // delete_current_page, then a page in that layout
        let anchors = flow.model.pages[flow.page].items
        ignore(flow.model.pages.pop())
        flow.page -= 1
        flow.start_new_page(layout=l)
        flow.model.pages[flow.page].items.append(anchors)
      }
      _ => ()
    }
    return
  }
  if !node.has_role("column") {
    flow.to_last_column()
  }
  match layout {
    // `advance_page layout:` starts a new page when on the last one
    Some(l) if flow.page + 1 >= flow.model.pages.length() &&
      flow.columns is None => flow.start_new_page(layout=l)
    _ => flow.advance_page()
  }
}

///|
/// A named destination for the block at the cursor (asciidoctor-pdf's
/// `add_dest_for_block`).
fn Flow::dest_x(self : Flow) -> Double {
  // `add_dest_for_block`: `bounds.absolute_left.truncate 4`, 0 when at the
  // margin
  let left = (self.left * 10000.0).floor() / 10000.0
  if left <= self.margin_left {
    0.0
  } else {
    left
  }
}

///|
fn Converter::add_dest(self : Converter, node : @core.Node) -> Unit {
  match node.id {
    Some(id) =>
      // `bounds.absolute_left.truncate 4`, 0 when at the margin; the y is
      // the cursor even at the top of a page (only sections jump to the
      // page's top edge)
      self.flow.add_anchor(id, self.flow.dest_x(), self.flow.y)
    None => ()
  }
}

///|
/// The next block after `block` in reading order, crossing out of the
/// containers that do not end a vertical rhythm unit
/// (asciidoctor-pdf's `next_enclosed_block`). Decides whether a block gets
/// its bottom margin.
fn next_enclosed_block(
  block : @core.Node,
  descend? : Bool = false,
) -> @core.Node? {
  if block.context == Document {
    return None
  }
  guard block.parent() is Some(parent) else { return None }
  let is_list_item = block.context == ListItem
  if is_list_item && descend && block.has_blocks() {
    return Some(block.blocks[0])
  }
  let siblings : Array[@core.Node] = if parent.context == Dlist {
    let flat = []
    for item in parent.dlist_items {
      flat.append(item.terms)
      match item.desc {
        Some(desc) => flat.push(desc)
        None => ()
      }
    }
    flat
  } else if is_list_item {
    parent.items()
  } else {
    parent.blocks
  }
  let mut index = -1
  for i, sibling in siblings {
    if physical_equal(sibling, block) {
      index = i
      break
    }
  }
  if index >= 0 && index < siblings.length() - 1 {
    return Some(siblings[index + 1])
  }
  match parent.context {
    ListItem | Section => next_enclosed_block(parent)
    Open if parent.style != Some("abstract") => next_enclosed_block(parent)
    _ =>
      if is_list_item {
        match parent.parent() {
          Some(grandparent) if grandparent.context == ListItem =>
            next_enclosed_block(grandparent)
          _ => None
        }
      } else {
        None
      }
  }
}

///|
/// `theme_margin :block, :bottom, next_enclosed_block(node)`.
fn Converter::block_margin_bottom(self : Converter, node : @core.Node) -> Unit {
  if next_enclosed_block(node) is Some(_) {
    self.flow.margin(block_margin_bottom())
  }
}

///|
/// Ink formatted text as prose (asciidoctor-pdf's `ink_prose`).
fn Converter::ink_prose(
  self : Converter,
  text : String,
  style? : Style = base_style(),
  align? : Alignment,
  margin_bottom? : Double = prose_margin_bottom(),
  line_height? : Double = base_line_height(),
  normalize? : Bool = true,
  bottom_gutter? : Double = 0.0,
  normalize_line_height? : Bool = false,
  first_line_style? : Style,
  text_indent? : Double = 0.0,
) -> Unit {
  let face = self.flow.font(style)
  let metrics = line_metrics(line_height, face, style.size)
  self.flow.typeset(
    parse_formatted(text, style, normalize~),
    metrics,
    style,
    align=align.unwrap_or(self.text_align),
    bottom_gutter~,
    normalize_line_height~,
    first_line_style?,
    text_indent~,
  )
  self.flow.margin(margin_bottom)
}

///|
/// Enter a padded box whose bottom padding is `bottom` (asciidoctor-pdf's
/// `pad_box`): the block that will ink its last line gets a gutter.
fn Converter::push_gutter(
  self : Converter,
  node : @core.Node,
  bottom : Double,
) -> Unit {
  if node.content_model != Compound {
    self.gutters.push(Some((node, bottom)))
  } else if node.blocks.last() is Some(last) && last.context == Paragraph {
    self.gutters.push(Some((last, bottom)))
  } else {
    self.gutters.push(None)
  }
}

///|
fn Converter::pop_gutter(self : Converter) -> Unit {
  ignore(self.gutters.pop())
}

///|
/// The bottom gutter of `node`, when it is the last block of the
/// innermost padded box.
fn Converter::gutter(self : Converter, node : @core.Node) -> Double {
  match self.gutters.last() {
    Some(Some((last, bottom))) if physical_equal(last, node) => bottom
    _ => 0.0
  }
}

///|
/// A discrete heading (asciidoctor-pdf's `convert_floating_title`): kept
/// with the next block unless it is the last of its parent.
fn Converter::convert_floating_title(
  self : Converter,
  node : @core.Node,
) -> Unit {
  let title = node.title().unwrap_or("")
  let hlevel = node.level + 1
  let last_child = match node.parent() {
    Some(parent) =>
      parent.blocks.last().map(b => physical_equal(b, node)).unwrap_or(false)
    None => true
  }
  if !self.flow.at_page_top() && !last_child {
    self.arrange_heading(title, hlevel, true)
  }
  self.add_dest(node)
  self.ink_heading(
    title,
    hlevel,
    align?=text_align_from_roles(node.roles()),
    outdent=node.parent() is Some(p) && p.context == Section,
  )
}

///|
/// asciidoctor-pdf's `convert_section`: parts and chapters (a book's
/// level-1 sections) start a new page; other headings keep with the
/// content after them (`arrange_heading`). The section records the page it
/// starts on and its destination (`pdf-page-start`, `pdf-anchor`), which
/// the TOC reads; chapters end with their footnotes. The index section
/// lists the index, and is left out while there are no index terms.
fn Converter::convert_section(self : Converter, section : @core.Node) -> Unit {
  let flow = self.flow
  let is_index = section.sectname == Some("index")
  if is_index && session().index.is_empty() {
    return
  }
  let title = numbered_title(section, formal=true)
  // a title separator (`separator`, `title-separator`) splits off a subtitle,
  // set on its own line
  let separator = match section.attr("separator") {
    Some(s) => s
    None => section.document().attr("title-separator").unwrap_or("")
  }
  let heading = match title.rev_find(separator + " ") {
    Some(i) if separator != "" => {
      let main = title[:i].to_owned()
      let subtitle = title[i + separator.length() + 1:].to_owned()
      "\{main}\n\{subtitle}"
    }
    _ => title
  }
  let hlevel = section.level + 1
  let is_book = self.doc.doctype() == "book"
  let part = section.sectname == Some("part")
  let chapterlike = !part &&
    (section.sectname == Some("chapter") || (is_book && section.level == 1))
  let hidden = section.has_option("notitle")
  // parts and chapters start a new page (`heading_part_break_before`,
  // `heading_chapter_break_before`, `heading_part_break_after`)
  let first_of_part = match section.parent() {
    Some(parent) if parent.sectname == Some("part") =>
      parent
      .sections()
      .get(0)
      .map(s => physical_equal(s, section))
      .unwrap_or(false)
    _ => false
  }
  let started_new = if part {
    t_str("heading_part_break_before") == Some("always")
  } else if chapterlike {
    let part_break_after = t_str("heading_part_break_after")
    (
      t_str("heading_chapter_break_before") == Some("always") &&
      !(part_break_after == Some("avoid") && first_of_part)
    ) ||
    (part_break_after == Some("always") && first_of_part)
  } else {
    false
  }
  if started_new {
    // `start_new_chapter`: on a new page, a recto one in a prepress book
    // unless `nonfacing`
    if !flow.at_page_top() {
      flow.advance_page()
    }
    if self.ppbook && flow.on_verso() && !section.has_option("nonfacing") {
      flow.advance_page()
    }
  }
  if !hidden && !started_new && !flow.at_page_top() {
    self.arrange_heading(heading, hlevel, section.has_blocks())
  }
  let start_page = flow.page + 1
  section.set_attr("pdf-page-start", value=Int(start_page)) |> ignore
  // a section without an id is named after where it starts
  let anchor = match section.id {
    Some(id) => pdf_anchor_name(id)
    None => "__anchor-\{start_page}-\{(flow.page_h() - flow.y).ceil().to_int()}"
  }
  section.set_attr("pdf-anchor", value=Str(anchor)) |> ignore
  flow.add_anchor(
    anchor,
    flow.dest_x(),
    if flow.at_page_top() {
      0.0
    } else {
      flow.y
    },
  )
  let (num_levels, expand_levels) = outline_levels(self.doc, self.toc_levels)
  // `add_outline_level` leaves out an empty, untitled last section
  let untitled_last = hidden &&
    !section.has_blocks() &&
    self.doc.blocks.last() is Some(last) &&
    physical_equal(last, section)
  if section.level <= num_levels && sanitize(title) != "" && !untitled_last {
    // the depth in the section tree, not the section level: a document
    // without a header may start at `===`, and its first sections are
    // still top-level entries
    let mut depth = 0
    let mut parent = section.parent()
    while parent is Some(p) && p.context == Section {
      depth += 1
      parent = p.parent()
    }
    self.outline.push({
      title: sanitize(title),
      level: depth,
      dest: Named(anchor),
      // `closed: expand_levels < 1`, counted down one per level
      open: depth < expand_levels,
    })
  }
  if !hidden {
    self.ink_heading(heading, hlevel, outdent=true)
  }
  if is_index {
    self.outdent_section(() => self.convert_index_section())
  } else {
    self.traverse(section)
  }
  if chapterlike {
    self.outdent_section(() => self.ink_footnotes(section))
  }
}

///|
fn Converter::convert_preamble(self : Converter, node : @core.Node) -> Unit {
  // the first paragraph of a preamble leads when the document has sections
  if node.blocks.length() > 0 &&
    node.blocks[0].context == Paragraph &&
    self.doc.has_sections() &&
    node.blocks[0].role() is None {
    node.blocks[0].set_role("lead")
  }
  self.traverse(node)
  self.block_margin_bottom(node)
  self.convert_toc(node, "preamble")
}

///|
/// The prose style of a block's roles (`theme_font_cascade` of their
/// `role_` categories), with their text decoration.
fn role_style(roles : Array[String], base : Style) -> Style {
  let mut style = base
  for role in roles {
    let (s, _) = theme_font(style, base_line_height(), "role_" + role)
    style = s
    match t_str("role_\{role}_text_decoration") {
      Some("underline") => style = { ..style, underline: true, }
      Some("line-through") => style = { ..style, strike: true, }
      _ => ()
    }
  }
  style
}

///|
/// The line height of a block's roles (`role__line_height`), else
/// `default`.
fn role_line_height(roles : Array[String], default : Double) -> Double {
  let mut line_height = default
  for role in roles {
    match t_num("role_\{role}_line_height") {
      Some(lh) => line_height = lh
      None => ()
    }
  }
  line_height
}

///|
/// The alignment of a block from its `text-*` roles, else `default`.
fn role_align(roles : Array[String], default : Alignment) -> Alignment {
  text_align_from_roles(roles).unwrap_or(default)
}

///|
/// asciidoctor-pdf's `convert_paragraph`: aligned by its roles (a
/// `text-*` role, else a role's `role__text_align`), in its roles'
/// fonts; then the prose margin (`prose_margin_inner` between paragraphs,
/// when the theme sets it).
fn paragraph_text_indent(node : @core.Node, align : Alignment) -> Double {
  // asciidoctor-pdf's `convert_paragraph`: the first line's indent,
  // prose_text_indent, else prose_text_indent_inner after a paragraph
  // (justified and left-aligned text only)
  if align is (Justify | Left) {
    let indent = t_num_or("prose_text_indent", 0.0)
    let inner = t_num_or("prose_text_indent_inner", 0.0)
    if indent > 0.0 {
      indent
    } else if inner > 0.0 &&
      previous_sibling(node) is Some(prev) &&
      prev.context == Paragraph {
      inner
    } else {
      0.0
    }
  } else {
    0.0
  }
}

///|
fn Converter::convert_paragraph(self : Converter, node : @core.Node) -> Unit {
  self.add_dest(node)
  let roles = node.roles()
  let align = text_align_from_roles(roles, query_theme=true).unwrap_or(
    self.text_align,
  )
  let text_indent = paragraph_text_indent(node, align)
  // a float box takes the paragraph, unless a float group ended before it
  match self.float_box {
    Some(float_box) =>
      if previous_sibling(node) is Some(prev) &&
        prev.context == Open &&
        prev.has_role("float-group") {
        self.flow.y = float_box.bottom
        self.float_box = None
      } else {
        self.ink_paragraph_in_float_box(
          node,
          float_box,
          role_style(roles, base_style()),
          align,
          role_line_height(roles, base_line_height()),
          text_indent,
        )
        return
      }
    None => ()
  }
  if node.has_title() {
    self.ink_caption(node.title().unwrap_or(""))
  }
  self.ink_prose(
    node.content(),
    style=role_style(roles, base_style()),
    align~,
    margin_bottom=0.0,
    line_height=role_line_height(roles, base_line_height()),
    bottom_gutter=self.gutter(node),
    text_indent~,
  )
  match next_enclosed_block(node) {
    Some(next) =>
      match t_num("prose_margin_inner") {
        Some(inner) if next.context == Paragraph => self.flow.margin(inner)
        _ => self.flow.margin(prose_margin_bottom())
      }
    None => ()
  }
}

///|

///|
/// A block title (asciidoctor-pdf's `ink_caption`, top end): italic,
/// slightly smaller, with the inside margin below.
fn Converter::ink_caption(
  self : Converter,
  text : String,
  bottom? : Bool = false,
  category? : String,
  block_align? : Alignment,
  block_width? : Double,
  max_width? : String,
) -> Unit {
  // the category's caption keys (`table_caption_*`) before the generic ones
  let key = match category {
    Some(c) => c + "_caption"
    None => "caption"
  }
  let setting = (name : String) => {
    match tv("\{key}_\{name}") {
      Null => tv("caption_\{name}")
      v => v
    }
  }
  let outside = value_num(setting("margin_outside")).unwrap_or(0.0)
  let inside = value_num(setting("margin_inside")).unwrap_or(0.0)
  let align = match value_str(setting("align")) {
    Some("inherit") => block_align.unwrap_or(self.text_align)
    Some(a) => alignment_of(a)
    None => self.text_align
  }
  let text_align = match value_str(setting("text_align")) {
    Some("inherit") => align
    Some(a) => alignment_of(a)
    None => align
  }
  let (style, line_height) = theme_font(
    base_style(),
    base_line_height(),
    "caption",
  )
  let (style, line_height) = if key != "caption" {
    theme_font(style, line_height, key)
  } else {
    (style, line_height)
  }
  if value_color(setting("background_color")) is Some(_) {
    unsupported("theme", "caption background", self.doc)
  }
  let (top_margin, bottom_margin) = if bottom {
    (inside, outside)
  } else {
    (outside, inside)
  }
  let (indent_left, indent_right) = caption_indents(
    self.flow.width(),
    align,
    block_align,
    block_width,
    max_width,
  )
  self.flow.margin(top_margin)
  self.flow.indent(indent_left, indent_right, () => {
    self.ink_prose(
      text,
      style~,
      align=text_align,
      margin_bottom=bottom_margin,
      line_height~,
      normalize=false,
      normalize_line_height=true,
    )
  })
}

///|
/// How far `ink_caption` indents a caption from each side for its
/// `max_width`: `fit-content` (the block's width), `fit-content(N%)` (N% of
/// it, placed by the caption's alignment), `N%` of the container or a
/// length (placed by the caption's alignment); the caption then sits where
/// the block does (`block_align`).
fn caption_indents(
  container_width : Double,
  align : Alignment,
  block_align : Alignment?,
  block_width : Double?,
  max_width : String?,
) -> (Double, Double) {
  guard max_width is Some(max_width) && max_width != "none" else {
    return (0.0, 0.0)
  }
  let mut left = 0.0
  let mut right = 0.0
  let mut block_align = block_align
  let share = (amount : Double, by : Alignment?) => {
    match by {
      Some(Right) => left += amount
      Some(Center) => {
        left += amount * 0.5
        right += amount * 0.5
      }
      _ => right += amount
    }
  }
  let max = if max_width.has_prefix("fit-content") {
    let block_width = block_width.unwrap_or(container_width)
    if !(max_width.has_suffix("t") || max_width.has_suffix("()")) {
      let percent = @theme.ruby_string_to_f(
        max_width[12:max_width.length() - 1].to_owned(),
      )
      let delta = block_width - block_width * percent / 100.0
      if delta > 0.0 {
        share(delta, Some(align))
      }
    }
    block_width
  } else if max_width.has_suffix("%") {
    block_align = Some(align)
    @cmp.minimum(
      @theme.ruby_string_to_f(max_width) / 100.0 * container_width,
      container_width,
    )
  } else {
    block_align = Some(align)
    @cmp.minimum(@theme.ruby_string_to_f(max_width), container_width)
  }
  let remainder = container_width - max
  if remainder > 0.0 {
    share(remainder, block_align)
  }
  (left, right)
}

///|
/// asciidoctor-pdf's `guard_indentation`: tabs expand to 4-column stops,
/// then the first space of each line becomes a no-break space so that the
/// line keeps its leading spaces.
fn guard_indentation(text : String) -> String {
  let sb = StringBuilder()
  let mut column = 0
  let mut at_line_start = true
  for c in text {
    if c == '\t' {
      let spaces = 4 - column % 4
      for k in 0.. @core.Node {
  let title = doc.attr("manname-title").unwrap_or("Name")
  let title = match doc.sections().get(0).bind(s => s.title()) {
    Some(first) if first.to_upper() == first => title.to_upper()
    _ => title
  }
  let section = @core.Node::new_section(Some(doc), level=1)
  section.sectname = Some("section")
  section.set_id(doc.attr("manname-id"))
  section.set_title(Some(title))
  let text = "\{doc.attr("manname").unwrap_or("")} - \{doc.attr("manpurpose").unwrap_or("")}"
  section.append(
    @core.Node::new_block(
      section,
      Paragraph,
      subs=SubsSpec("normal"),
      source_text=text,
    ),
  )
  section
}