///|
// Tables, after asciidoctor-pdf's `convert_table` over prawn-table: column
// widths from the column specs, cells placed on a grid that row and column
// spans occupy (Table#make_cells), row heights from the cells' natural
// heights (a spanning cell shares its height evenly among its rows), rows
// that move to the next page when they do not fit, with the header rows
// repeated there, and every cell stroking its own edges.
//
// A text cell is a Prawn text box (Cell::Text, as asciidoctor-pdf patches
// it); an AsciiDoc cell (`a|`) lays out its nested document in the cell
// (ext/prawn-table/cell/asciidoc.rb): measured with a dry run in the
// cell's width, then drawn; content taller than a page is cut off with a
// warning, as Ruby does.

///|
priv enum VAlign {
  VTop
  VCenter
  VBottom
}

///|
priv enum CellContent {
  /// placed lines, their line metrics and base style
  TextContent(Array[Line], LineMetrics, Style)
  /// the nested document of an AsciiDoc cell, and the alignment its
  /// paragraphs take (when all its blocks are paragraphs)
  AsciiDocContent(@core.Node, Alignment?)
}

///|
priv struct TableCell {
  node : @core.Node
  row : Int
  column : Int
  rowspan : Int
  colspan : Int
  /// left edge and width of the whole span
  x : Double
  width : Double
  content : CellContent
  /// top, right, bottom, left (text cells include the line metrics'
  /// padding above and below)
  padding : (Double, Double, Double, Double)
  valign : VAlign
  /// the height of the content alone (prawn-table's
  /// `natural_content_height`)
  natural : Double
  background : Color?
}

///|
fn cell_align(cell : @core.Node) -> Alignment {
  match cell.attr("halign") {
    Some("center") => Center
    Some("right") => Right
    _ => Left
  }
}

///|
fn cell_valign(cell : @core.Node) -> VAlign {
  match cell.attr("valign") {
    Some("middle") => VCenter
    Some("bottom") => VBottom
    _ => VTop
  }
}

///|
/// Prawn's text box height for placed lines: the first line hangs by its
/// ascender, each next one drops by its height and the leading; the last
/// line's descender ends it.
fn text_box_height(
  lines : Array[Line],
  face : Face,
  base : Style,
  metrics : LineMetrics,
) -> Double {
  let mut baseline = 0.0
  let mut depth = 0.0
  for index, line in lines {
    let (a, d, h) = if line.height == 0.0 {
      (
        face.ascender_pt(base.size),
        face.descender_pt(base.size),
        face.height_pt(base.size),
      )
    } else {
      (line.ascender, line.descender, line.height)
    }
    baseline = if index == 0 { a } else { baseline + h + metrics.leading }
    depth = d
  }
  baseline + depth
}

///|
/// Collapse runs of spaces, tabs and newlines to one space (Ruby's
/// `tr_s " \t\n", ' '`).
fn squeeze_whitespace(text : String) -> String {
  let sb = StringBuilder()
  let mut in_space = false
  for c in text {
    if c == ' ' || c == '\t' || c == '\n' {
      if !in_space {
        sb.write_char(' ')
      }
      in_space = true
    } else {
      sb.write_char(c)
      in_space = false
    }
  }
  sb.to_string()
}

///|
/// The formatted text of a body cell, as `convert_table` hands it to
/// prawn-table: separate paragraphs become lines apart (hard breaks, the
/// whitespace in each squeezed), else one normalized paragraph.
fn body_cell_fragments(text : String, base : Style) -> Array[Fragment] {
  let text = text.trim().to_owned()
  if text.contains("\n\n") {
    let paragraphs = []
    let mut rest = text
    while rest.find("\n\n") is Some(i) {
      paragraphs.push(squeeze_whitespace(rest[:i].to_owned()))
      let mut j = i
      while j < rest.length() && rest[j] == '\n' {
        j += 1
      }
      rest = rest[j:].to_owned()
    }
    paragraphs.push(squeeze_whitespace(rest))
    parse_formatted(paragraphs.join("\n\n"), base, normalize=false)
  } else {
    parse_formatted(text, base)
  }
}

///|
/// A text cell: its lines, line metrics and natural height after the
/// cell's style (`convert_table`'s `case cell.style`).
fn Converter::text_cell_content(
  self : Converter,
  cell : @core.Node,
  head : Bool,
  width : Double,
  foot? : Bool = false,
) -> (CellContent, Double) {
  let flow = self.flow
  let (base, fragments, line_height) = cell_fragments(cell, head, foot~)
  let face = flow.font(base)
  let metrics = line_metrics(line_height, face, base.size)
  // prawn-table lays text out one point wider than the content box
  // (`FPTolerance`)
  let (_, pad_right, _, pad_left) = table_cell_padding_of(head)
  let inner = width - pad_left - pad_right + 1.0
  let lines = typeset_lines(fragments, flow.catalog, inner, cell_align(cell))
  // Cell::Text#natural_content_height: the box's height and its line gap,
  // the last line's height less its ascender and descender (Box#line_gap:
  // a line all in the code font has that font's gap)
  let last_gap = match lines.last() {
    Some(line) if line.height != 0.0 =>
      line.height - (line.ascender + line.descender)
    _ => face.line_gap_pt(base.size)
  }
  let natural = text_box_height(lines, face, base, metrics) + last_gap
  (TextContent(lines, metrics, base), natural)
}

///|
/// The base style, formatted text and line height of a text cell, after
/// the cell's style (`convert_table`'s `case cell.style`).
fn cell_fragments(
  cell : @core.Node,
  head : Bool,
  foot? : Bool = false,
) -> (Style, Array[Fragment], Double) {
  let (table_style, table_lh) = category_font("table")
  let text_of = (style : Style) => {
    let text = cell.text().unwrap_or("").trim().to_owned()
    match style.text_transform {
      Some(transform) => transform_text(text, transform)
      None => text
    }
  }
  if head {
    // `theme_font :table_head`; the text transform is the head's own
    let (base, lh) = theme_font(table_style, table_lh, "table_head")
    let base = {
      ..base,
      text_transform: t_str("table_head_text_transform").bind(t => {
        if t == "none" {
          None
        } else {
          Some(t)
        }
      }),
    }
    let text = text_of(base)
    let line_height = t_num("table_head_line_height")
      .or_some(t_num("table_cell_line_height"))
      .unwrap_or(lh)
    let base = { ..base, text_transform: None, }
    (base, parse_formatted(text, base), line_height)
  } else {
    let cell_lh = t_num("table_cell_line_height").unwrap_or(table_lh)
    match cell.style {
      Some("literal") => {
        // the raw text, without inline formatting, in the code font, sized
        // relative to the table's font
        let (code, code_lh) = theme_font(table_style, table_lh, "code")
        let base = {
          ..code,
          size: code.size * (table_style.size / base_font_size()),
          doc_bold: false,
          doc_italic: false,
        }
        let text = guard_indentation(cell.cell_data().text)
        (base, [{ text, style: base, anchor: None, }], code_lh)
      }
      style => {
        let (base, line_height) = match style {
          Some("strong") =>
            ({ ..table_style, doc_bold: true, doc_italic: false, }, cell_lh)
          Some("emphasis") =>
            ({ ..table_style, doc_bold: false, doc_italic: true, }, cell_lh)
          // `theme_font_cascade [:table_head, :table_header_cell]`
          Some("header") => {
            let (s, lh) = theme_font(table_style, table_lh, "table_head")
            let (s, lh) = theme_font(s, lh, "table_header_cell")
            (s, lh)
          }
          // the codespan font and colour (`theme_font :codespan` sets the
          // colour the cell takes)
          Some("monospaced") => {
            let (s, _) = theme_font(table_style, table_lh, "codespan")
            ({ ..s, doc_bold: false, doc_italic: false, }, table_lh)
          }
          _ => (table_style, cell_lh)
        }
        let text = cell.text().unwrap_or("")
        let text = match base.text_transform {
          Some(transform) => transform_text(text, transform)
          None => text
        }
        let mut base = { ..base, text_transform: None, }
        // the foot row's font (`table_foot_font_*`)
        if foot {
          match t_color("table_foot_font_color") {
            Some(color) => base = { ..base, color, }
            None => ()
          }
          match t_num("table_foot_font_size") {
            Some(size) => base = { ..base, size, }
            None => ()
          }
          match t_str("table_foot_font_family") {
            Some(family) => base = { ..base, family, }
            None => ()
          }
          match t_str("table_foot_font_style") {
            Some(fs) => {
              let (doc_bold, doc_italic) = font_style_of(fs)
              base = { ..base, doc_bold, doc_italic, }
            }
            None => ()
          }
        }
        (base, body_cell_fragments(text, base), line_height)
      }
    }
  }
}

///|
/// The padding of body cells (`table_cell_padding`) and of header cells
/// (`table_head_cell_padding`, else the body's): top, right, bottom, left.
fn table_cell_padding_of(head : Bool) -> (Double, Double, Double, Double) {
  if head && t_set("table_head_cell_padding") {
    t_padding("table_head_cell_padding")
  } else {
    t_padding("table_cell_padding")
  }
}

///|
/// The background colours of a table's parts: the table's
/// (`table_background_color`) stands in for any the theme leaves out.
priv struct TableColors {
  head : Color?
  foot : Color?
  body : Color?
  stripe : Color?
}

///|
fn table_colors() -> TableColors {
  let table = table_color("table_background_color", None)
  {
    head: table_color("table_head_background_color", table),
    foot: table_color("table_foot_background_color", table),
    body: table_color("table_body_background_color", table),
    stripe: table_color("table_body_stripe_background_color", table),
  }
}

///|
/// A table colour key (`resolve_theme_color key, fallback`): transparent
/// is none.
fn table_color(key : String, fallback : Color?) -> Color? {
  match tv(key) {
    Null => fallback
    Transparent => None
    v => value_color(v)
  }
}

///|
/// A cell of prawn-table's width calculation: a master cell, or a dummy
/// standing for a place its span covers (Cell::SpanDummy), in the order
/// `make_cells` creates them.
priv struct WidthCell {
  row : Int
  column : Int
  colspan : Int
  /// the master cell this dummy stands for (its index), or -1
  master : Int
  /// width_ignoring_span, min_width_ignoring_span
  width : Double
  min : Double
}

///|
/// `Cells#aggregate_cell_values(:column, :avg_spanned_min_width, :max)`
/// over `cells` (the table's or one column's): each column's largest
/// minimum width, a span's shared evenly among its columns, summed.
fn aggregate_min_widths(
  all : Array[WidthCell],
  cells : Array[WidthCell],
) -> Double {
  let avg = fn(c : WidthCell) {
    if c.master >= 0 {
      let m = all[c.master]
      m.min / m.colspan.to_double()
    } else {
      c.min / c.colspan.to_double()
    }
  }
  let values : Map[Int, Double] = Map([])
  let put_max = fn(i : Int, v : Double) {
    match values.get(i) {
      Some(old) => values[i] = @cmp.maximum(old, v)
      None => values[i] = v
    }
  }
  for c in cells {
    if c.colspan == 1 && c.master < 0 {
      put_max(c.column, c.min)
    }
  }
  let rows_with_dummy : Map[Int, Bool] = Map([])
  for c in cells {
    if c.master >= 0 {
      rows_with_dummy[c.row] = true
    }
  }
  let mut needs_fixing = true
  for c in cells {
    let index = c.column
    if c.colspan > 1 {
      // fill_values_if_needed: some but not all of the span's columns have
      // a value, in a row with a dummy
      let mut missing = 0
      for i in 0.. 0 && missing < c.colspan && rows_with_dummy.contains(c.row) {
        let mut sum = avg(c) * c.colspan.to_double()
        for i in 0.. sum -= v
            None => ()
          }
        }
        let share = sum / missing.to_double()
        for i in 0.. 1.0e-9
      if needs_fixing {
        put_max(index, avg(c))
        let mut all_exist = true
        for i in 0..= 0 {
      put_max(index, avg(c))
    }
  }
  let mut total = 0.0
  for _, v in values {
    total += v
  }
  total
}

///|
/// The column widths of an `autowidth` table, as prawn-table sizes a table
/// given no column widths: each cell's natural width (its text on one
/// line, or the widest of its hard-broken lines, at most the bounds'
/// width; an AsciiDoc cell takes the bounds' width), the widest per column
/// (`ColumnWidthCalculator#natural_widths`), then shrunk towards the
/// columns' minimum widths when wider than `width` (the table's width, by
/// default the natural width capped by the bounds), or grown when
/// narrower.
fn Converter::autowidth_columns(
  self : Converter,
  node : @core.Node,
  bounds_width : Double,
  table_width : Double?,
) -> Array[Double] {
  let flow = self.flow
  let rows = node.rows()
  let all_rows : Array[(Array[@core.Node], Bool)] = []
  for row in rows.head {
    all_rows.push((row, true))
  }
  for row in rows.body {
    all_rows.push((row, false))
  }
  for row in rows.foot {
    all_rows.push((row, false))
  }
  let side_padding = (head : Bool) => {
    let (_, right, _, left) = table_cell_padding_of(head)
    left + right
  }
  let occupied : Map[(Int, Int), Bool] = Map([])
  let cells : Array[WidthCell] = []
  let mut num_cols = node.columns().length()
  for r, entry in all_rows {
    let (row, head) = entry
    let mut column = 0
    for cell in row {
      while occupied.contains((r, column)) {
        column += 1
      }
      let colspan = @cmp.maximum(cell.colspan().unwrap_or(1), 1)
      let rowspan = if head {
        1
      } else {
        @cmp.maximum(cell.rowspan().unwrap_or(1), 1)
      }
      let pad = side_padding(head)
      let (natural, min_content) = if !head &&
        cell.style == Some("asciidoc") &&
        cell.inner_document() is Some(_) {
        (bounds_width - pad, 0.0)
      } else {
        let (base, fragments, _) = cell_fragments(cell, head)
        let lines = typeset_lines(fragments, flow.catalog, 1.0e9, Left)
        let mut widest = 0.0
        for line in lines {
          widest = @cmp.maximum(widest, line.fit_width)
        }
        let natural = @cmp.minimum(widest, bounds_width)
        (natural, @cmp.minimum(natural, flow.width_of("M", base)))
      }
      let master = cells.length()
      cells.push({
        row: r,
        column,
        colspan,
        master: -1,
        width: natural + pad,
        min: pad + min_content,
      })
      for i in 0.. 0 || j > 0 {
            cells.push({
              row: r + i,
              column: column + j,
              colspan: 1,
              master,
              width: 0.0,
              min: 0.0,
            })
          }
        }
      }
      column += colspan
      num_cols = @cmp.maximum(num_cols, column)
    }
  }
  // ColumnWidthCalculator#natural_widths
  let rows_with_dummy : Map[Int, Bool] = Map([])
  for c in cells {
    if c.master >= 0 {
      rows_with_dummy[c.row] = true
    }
  }
  let natural = Array::make(num_cols, 0.0)
  for c in cells {
    if !rows_with_dummy.contains(c.row) && c.master < 0 {
      natural[c.column] = @cmp.maximum(natural[c.column], c.width)
    }
  }
  for c in cells {
    if !rows_with_dummy.contains(c.row) || c.master >= 0 {
      continue
    }
    if c.colspan == 1 {
      natural[c.column] = @cmp.maximum(natural[c.column], c.width)
    } else {
      let mut current = 0.0
      for i in c.column..<@cmp.minimum(c.column + c.colspan, num_cols) {
        current += natural[i]
      }
      if c.width - current > 1.0e-9 {
        for i in c.column..<@cmp.minimum(c.column + c.colspan, num_cols) {
          natural[i] = c.width / c.colspan.to_double()
        }
      }
    }
  }
  let natural_width = natural.fold(init=0.0, (a, b) => a + b)
  let width = table_width.unwrap_or(@cmp.minimum(natural_width, bounds_width))
  if width - natural_width < -1.0e-9 {
    // shrink towards each column's minimum width
    let min_width = aggregate_min_widths(cells, cells)
    let f = (width - min_width) / (natural_width - min_width)
    Array::makei(num_cols, c => {
      let min = aggregate_min_widths(
        cells,
        cells.filter(cell => cell.column == c),
      )
      f * (natural[c] - min) + min
    })
  } else if width - natural_width > 1.0e-9 {
    // grow towards each column's maximum width (the bounds' width)
    let f = (width - natural_width) /
      (bounds_width * num_cols.to_double() - natural_width)
    natural.map(nat => f * (bounds_width - nat) + nat)
  } else {
    natural
  }
}

///|
/// The height the nested document of an AsciiDoc cell takes in the cell's
/// content width (Cell::AsciiDoc#dry_run): laid out from the top of a page
/// after moving down by the vertical padding; when it does not fit on
/// that page, the page's height.
fn Converter::asciidoc_cell_height(
  self : Converter,
  inner : @core.Node,
  align : Alignment?,
  left : Double,
  right : Double,
  padding_y : Double,
) -> Double {
  let scratch = self.dry_run(left, right, fn(scratch) {
    scratch.flow.move_down(padding_y)
    scratch.with_text_align(align, () => scratch.traverse(inner))
  })
  let flow = self.flow
  if scratch.model.pages.length() > 1 {
    flow.bottom - flow.page_top - padding_y
  } else {
    scratch.y - flow.page_top - padding_y
  }
}

///|
/// Rule widths of a table, after its `frame` and `grid` attributes.
priv struct Rules {
  /// the frame: top, right, bottom, left widths and colours
  frame : Array[Double]
  frame_colors : Array[Color?]
  grid_rows : Double
  grid_cols : Double
  grid_row_color : Color?
  grid_col_color : Color?
  /// under the last header row (`table_head_border_bottom_*`)
  head : Double
  head_color : Color?
}

///|
/// `expand_rect_values`: a value, or an array of 1 to 4, for the four sides.
fn expand_sides(v : @theme.Value) -> Array[@theme.Value] {
  match v {
    Array([]) => [Null, Null, Null, Null]
    Array([a]) => [a, a, a, a]
    Array([a, b]) => [a, b, a, b]
    Array([a, b, c]) => [a, b, c, b]
    Array(items) => items[:4].to_owned()
    other => [other, other, other, other]
  }
}

///|
/// `expand_grid_values`: a value, or an array of rows and columns.
fn expand_grid(v : @theme.Value) -> (@theme.Value, @theme.Value) {
  match v {
    Array([a]) => (a, a)
    Array([a, b, ..]) => (a, b)
    Array([]) => (Null, Null)
    other => (other, other)
  }
}

///|
/// A border colour (transparent draws nothing).
fn border_color_of(v : @theme.Value) -> Color? {
  match v {
    Transparent | Null => None
    v => value_color(v)
  }
}

///|
/// The rules of a table, after the theme's `table_border_*`,
/// `table_grid_*` and `table_head_border_bottom_*` and the table's `frame`
/// and `grid` attributes.
fn table_rules(node : @core.Node) -> Rules {
  let border_colors = expand_sides(tv("table_border_color"))
  let border_widths = expand_sides(tv("table_border_width")).map(v => {
    value_num(v).unwrap_or(0.0)
  })
  let (grid_row_color, grid_col_color) = match tv("table_grid_color") {
    Null => (border_colors[0], border_colors[3])
    v => expand_grid(v)
  }
  let (grid_row_width, grid_col_width) = match tv("table_grid_width") {
    Null =>
      (
        @theme.Value::Float(border_widths[0]),
        @theme.Value::Float(border_widths[3]),
      )
    v => expand_grid(v)
  }
  let mut grid_rows = value_num(grid_row_width).unwrap_or(0.0)
  let mut grid_cols = value_num(grid_col_width).unwrap_or(0.0)
  let head = t_num("table_head_border_bottom_width").unwrap_or(grid_rows * 2.5)
  let head_color = match tv("table_head_border_bottom_color") {
    Null => border_color_of(grid_row_color)
    v => border_color_of(v)
  }
  match node.attr("grid", default="all", fallback_name="table-grid") {
    Some("all") => ()
    Some("cols") => grid_rows = 0.0
    Some("rows") => grid_cols = 0.0
    _ => {
      grid_rows = 0.0
      grid_cols = 0.0
    }
  }
  let frame = border_widths.copy()
  match node.attr("frame", default="all", fallback_name="table-frame") {
    Some("all") => ()
    Some("topbot") | Some("ends") => {
      frame[1] = 0.0
      frame[3] = 0.0
    }
    Some("sides") => {
      frame[0] = 0.0
      frame[2] = 0.0
    }
    _ => {
      frame[0] = 0.0
      frame[1] = 0.0
      frame[2] = 0.0
      frame[3] = 0.0
    }
  }
  {
    frame,
    frame_colors: border_colors.map(border_color_of),
    grid_rows,
    grid_cols,
    grid_row_color: border_color_of(grid_row_color),
    grid_col_color: border_color_of(grid_col_color),
    head,
    head_color,
  }
}

///|
/// A laid-out table: its cells in row-major order, the row heights, and
/// how many header rows, rows and columns it has.
priv struct TableLayout {
  cells : Array[TableCell]
  /// the cells (indices into `cells`, in order) that start in each row: a
  /// tall table is drawn page by page and row by row, and a scan of every
  /// cell for each would be quadratic
  by_row : Array[Array[Int]]
  row_heights : Array[Double]
  head_rows : Int
  num_rows : Int
  num_cols : Int
  rules : Rules
  /// prawn-table's `row_colors` for the body rows (None: the page's white)
  row_colors : Array[Color?]
}

///|
/// Place the cells on the grid (prawn-table's `make_cells`: a cell takes
/// the first column its row has free after the spans from above) and size
/// them.
fn Converter::layout_table(
  self : Converter,
  node : @core.Node,
  columns : Array[Double],
  left : Double,
) -> TableLayout {
  let rows = node.rows()
  let all_rows : Array[(Array[@core.Node], Bool, Bool)] = []
  for row in rows.head {
    all_rows.push((row, true, false))
  }
  for row in rows.body {
    all_rows.push((row, false, false))
  }
  for row in rows.foot {
    all_rows.push((row, false, true))
  }
  let num_cols = columns.length()
  let colors = table_colors()
  let occupied : Map[(Int, Int), Bool] = Map([])
  let cells : Array[TableCell] = []
  for r, entry in all_rows {
    let (row, head, foot) = entry
    let mut column = 0
    for cell in row {
      while occupied.contains((r, column)) {
        column += 1
      }
      let colspan = @cmp.maximum(cell.colspan().unwrap_or(1), 1)
      // header rows take no row span
      let rowspan = if head {
        1
      } else {
        @cmp.maximum(cell.rowspan().unwrap_or(1), 1)
      }
      for i in 0.. None
          other =>
            if inner.blocks.iter().all(b => b.context == Paragraph) {
              Some(other)
            } else {
              None
            }
        }
        let natural = self.asciidoc_cell_height(
          inner,
          align,
          x + pad_left,
          x + width - pad_right,
          pad_top + pad_bottom,
        )
        (
          AsciiDocContent(inner, align),
          (pad_top, pad_right, pad_bottom, pad_left),
          natural,
        )
      } else {
        let (content, natural) = self.text_cell_content(
          cell,
          head,
          width,
          foot~,
        )
        let (top, bottom) = match content {
          TextContent(_, metrics, _) =>
            (metrics.padding_top, metrics.padding_bottom)
          _ => (0.0, 0.0)
        }
        (
          content,
          (pad_top + top, pad_right, pad_bottom + bottom, pad_left),
          natural,
        )
      }
      cells.push({
        node: cell,
        row: r,
        column,
        rowspan,
        colspan,
        x,
        width,
        content,
        padding,
        valign: cell_valign(cell),
        natural,
        background: if foot {
          colors.foot
        } else if head {
          colors.head
        } else if cell.style == Some("header") {
          table_color("table_header_cell_background_color", colors.head)
        } else {
          None
        },
      })
      column += colspan
    }
  }
  let mut num_rows = all_rows.length()
  for cell in cells {
    num_rows = @cmp.maximum(num_rows, cell.row + cell.rowspan)
  }
  // prawn-table's `row_heights`: a cell's height shared evenly among the
  // rows it spans
  let row_heights = Array::make(num_rows, 0.0)
  for cell in cells {
    let height = cell.natural + cell.padding.0 + cell.padding.2
    let share = height / cell.rowspan.to_double()
    for i in 0.. row_heights[cell.row + i] {
        row_heights[cell.row + i] = share
      }
    }
  }
  let by_row : Array[Array[Int]] = Array::makei(num_rows, _ => [])
  for i, cell in cells {
    by_row[cell.row].push(i)
  }
  {
    cells,
    by_row,
    row_heights,
    head_rows: rows.head.length(),
    num_rows,
    num_cols,
    rules: table_rules(node),
    row_colors: match node.attr("stripes", fallback_name="table-stripes") {
      Some("all") => [colors.stripe]
      Some("even") => [colors.body, colors.stripe]
      Some("odd") => [colors.stripe, colors.body]
      _ => [colors.body]
    },
  }
}

///|
/// The height of a cell's whole span.
fn TableLayout::span_height(self : TableLayout, cell : TableCell) -> Double {
  let mut h = 0.0
  for i in cell.row..<(cell.row + cell.rowspan) {
    h += self.row_heights[i]
  }
  h
}

///|
/// The room a row needs to start on a page: its tallest cell with its span
/// (prawn-table's `height_with_span`).
fn TableLayout::row_height_with_span(self : TableLayout, row : Int) -> Double {
  let mut h = self.row_heights[row]
  for i in self.by_row[row] {
    h = @cmp.maximum(h, self.span_height(self.cells[i]))
  }
  h
}

///|
/// Stroke a cell's edges: the frame on the table's outside, the grid
/// inside, the heavier rule between header and body. Left and right edges
/// run on by half the top and bottom widths, so corners are square
/// (asciidoctor-pdf's `draw_borders`).
fn Converter::draw_cell_borders(
  self : Converter,
  layout : TableLayout,
  cell : TableCell,
  top : Double,
  height : Double,
) -> Unit {
  let flow = self.flow
  let rules = layout.rules
  let last_row = cell.row + cell.rowspan - 1
  let (top_width, top_color) = if cell.row == 0 {
    (rules.frame[0], rules.frame_colors[0])
  } else if layout.head_rows > 0 && cell.row == layout.head_rows {
    (rules.head, rules.head_color)
  } else {
    (rules.grid_rows, rules.grid_row_color)
  }
  let (bottom_width, bottom_color) = if last_row == layout.num_rows - 1 {
    (rules.frame[2], rules.frame_colors[2])
  } else if layout.head_rows > 0 && last_row == layout.head_rows - 1 {
    (rules.head, rules.head_color)
  } else {
    (rules.grid_rows, rules.grid_row_color)
  }
  let (left_width, left_color) = if cell.column == 0 {
    (rules.frame[3], rules.frame_colors[3])
  } else {
    (rules.grid_cols, rules.grid_col_color)
  }
  let (right_width, right_color) = if cell.column + cell.colspan >=
    layout.num_cols {
    (rules.frame[1], rules.frame_colors[1])
  } else {
    (rules.grid_cols, rules.grid_col_color)
  }
  let right = cell.x + cell.width
  let bottom = top + height
  match top_color {
    Some(color) if top_width > 0.0 =>
      flow.push(hrule(cell.x, right, top, top_width, color))
    _ => ()
  }
  match bottom_color {
    Some(color) if bottom_width > 0.0 =>
      flow.push(hrule(cell.x, right, bottom, bottom_width, color))
    _ => ()
  }
  let (y1, y2) = (top - top_width / 2.0, bottom + bottom_width / 2.0)
  match left_color {
    Some(color) if left_width > 0.0 =>
      flow.push(vrule(cell.x, y1, y2, left_width, color))
    _ => ()
  }
  match right_color {
    Some(color) if right_width > 0.0 =>
      flow.push(vrule(right, y1, y2, right_width, color))
    _ => ()
  }
}

///|
/// Draw a cell's content in its content box, whose top is `top`.
fn Converter::draw_cell_content(
  self : Converter,
  cell : TableCell,
  top : Double,
  spanned_content_height : Double,
) -> Unit {
  let flow = self.flow
  let (_, pad_right, _, pad_left) = cell.padding
  match cell.content {
    TextContent(lines, metrics, base) => {
      let face = flow.font(base)
      // the text box is one point taller than the content (FPTolerance);
      // asciidoctor-pdf centres the text on its own height
      let box_height = spanned_content_height + 1.0
      let text_height = text_box_height(lines, face, base, metrics)
      let offset = match cell.valign {
        VTop => 0.0
        VCenter => (box_height - text_height) * 0.5
        VBottom => box_height - text_height
      }
      let mut baseline = top + offset
      for index, line in lines {
        let (a, h) = if line.height == 0.0 {
          (face.ascender_pt(base.size), face.height_pt(base.size))
        } else {
          (line.ascender, line.height)
        }
        baseline = if index == 0 {
          baseline + a
        } else {
          baseline + h + metrics.leading
        }
        flow.ink_line(line, cell.x + pad_left, baseline)
      }
    }
    AsciiDocContent(inner, align) => {
      let excess = spanned_content_height - cell.natural
      let offset = if excess > 0.0 {
        match cell.valign {
          VTop => 0.0
          VCenter => excess / 2.0
          VBottom => excess
        }
      } else {
        0.0
      }
      let (page, y, left, right, bottom) = (
        flow.page,
        flow.y,
        flow.left,
        flow.right,
        flow.bottom,
      )
      let pages = flow.model.pages.length()
      flow.left = cell.x + pad_left
      flow.right = cell.x + cell.width - pad_right
      // the content may take a page's height from the cell's top (less the
      // padding), even past the bottom margin (`draw_content`)
      flow.bottom = top +
        (bottom - flow.page_top) -
        cell.padding.0 -
        cell.padding.2
      flow.y = top + offset
      self.with_text_align(align, () => self.traverse(inner))
      flow.bottom = bottom
      let extra = flow.model.pages.length() - pages
      if extra > 0 {
        // Ruby deletes the pages the cell ran onto, and says so unless it
        // only started an empty one
        let empty = flow.model.pages[flow.model.pages.length() - 1].items
          .iter()
          .all(i => i is Anchor(_))
        if !(extra == 1 && empty) {
          warning(
            "the table cell on page \{page + 1} has been truncated; Asciidoctor PDF does not support table cell content that exceeds the height of a single page",
            cell.node,
          )
        }
        while flow.model.pages.length() > pages {
          ignore(flow.model.pages.pop())
        }
      }
      flow.go_to_page(page)
      flow.y = y
      flow.left = left
      flow.right = right
    }
  }
}

///|
/// Draw `rows` with the top of the first at the cursor, as prawn-table
/// draws the cells of one page: every background, then each cell's borders
/// and content.
fn Converter::draw_table_rows(
  self : Converter,
  layout : TableLayout,
  rows : Array[Int],
  start_row : Int,
) -> Unit {
  let flow = self.flow
  let tops : Map[Int, Double] = Map([])
  let mut y = flow.y
  for r in rows {
    tops[r] = y
    y += layout.row_heights[r]
  }
  // in the order of `cells`
  let indices = []
  for r in rows {
    indices.append(layout.by_row[r])
  }
  indices.sort()
  let on_page = indices.map(i => layout.cells[i])
  for cell in on_page {
    // body rows take the row colours, restarting on every page
    // (`set_background_color`)
    let background = match cell.background {
      Some(color) => Some(color)
      None if cell.row >= layout.head_rows => {
        let index = cell.row - @cmp.maximum(start_row, layout.head_rows)
        let n = layout.row_colors.length()
        layout.row_colors[(index % n + n) % n]
      }
      None => None
    }
    match background {
      Some(color) =>
        flow.push(
          rect_item(
            cell.x,
            tops.get(cell.row).unwrap(),
            cell.width,
            layout.span_height(cell),
            fill=color,
          ),
        )
      None => ()
    }
  }
  for cell in on_page {
    let top = tops.get(cell.row).unwrap()
    let height = layout.span_height(cell)
    self.draw_cell_borders(layout, cell, top, height)
    let (pad_top, _, pad_bottom, _) = cell.padding
    self.draw_cell_content(cell, top + pad_top, height - pad_top - pad_bottom)
  }
}

///|
/// Whether `height` fits below the cursor (prawn-table's `fits_on_page?`).
fn Flow::fits(self : Flow, height : Double, y? : Double = self.y) -> Bool {
  height < self.bottom - y + 1.0e-9
}

///|
fn Converter::convert_table(self : Converter, node : @core.Node) -> Unit {
  let unbreakable = node.has_option("unbreakable")
  if !self.flow.at_page_top() &&
    (
      unbreakable ||
      (node.has_option("breakable") && (node.has_title() || node.id is Some(_)))
    ) {
    // asciidoctor-pdf puts the table in an open block holding its id and
    // its caption (as a tared caption, full width), and arranges that; the
    // margin below is the table's (`next_enclosed_block` of a table
    // container)
    self.arrange_block(keep_together=unbreakable, fn(c) {
      c.add_dest(node)
      if node.has_title() {
        c.flow.tared(() => {
          c.ink_caption(node.captioned_title(), category="table")
        })
      }
      c.convert_table_body(node, contained=true)
    })
    return
  }
  self.convert_table_body(node)
}

///|
/// A table as prawn-table draws it; `contained` when inside the open block
/// asciidoctor-pdf wraps a titled breakable or an unbreakable table in:
/// the block has the destination and the caption.
fn Converter::convert_table_body(
  self : Converter,
  node : @core.Node,
  contained? : Bool = false,
) -> Unit {
  let flow = self.flow
  if !contained {
    self.add_dest(node)
  }
  let pct = node.attributes
    .get("tablepcwidth")
    .map(v => v.to_f())
    .unwrap_or(100.0)
  let columns_spec = node.columns()
  let columns : Array[Double] = []
  let table_width = if node.has_option("autowidth") {
    // prawn-table sizes the columns to their content, within the width
    // given by a `width` attribute or the `stretch` role
    let requested = if node.has_attr("width") {
      Some(flow.width() * pct / 100.0)
    } else if node.has_role("stretch") {
      Some(flow.width())
    } else {
      None
    }
    columns.append(self.autowidth_columns(node, flow.width(), requested))
    columns.fold(init=0.0, (a, b) => a + b)
  } else {
    flow.width() * pct / 100.0
  }
  if !node.has_option("autowidth") {
    for col in columns_spec {
      let colpc = col.attributes
        .get("colpcwidth")
        .map(v => v.to_f())
        .unwrap_or(0.0)
      columns.push(colpc * table_width / 100.0)
    }
  }
  // the table's `align` attribute, else its last alignment role, else the
  // theme's (left)
  let alignment = match node.attr("align") {
    Some("center") => Center
    Some("right") => Right
    Some("left") => Left
    _ => {
      let mut a = t_align("table_align", Left)
      for role in node.roles() {
        match role {
          "left" => a = Left
          "center" => a = Center
          "right" => a = Right
          _ => ()
        }
      }
      a
    }
  }
  let left = match alignment {
    Center => flow.left + (flow.width() - table_width) * 0.5
    Right => flow.left + flow.width() - table_width
    _ => flow.left
  }
  let layout = self.layout_table(node, columns, left)
  let below = caption_below("table")
  // `ink_table_caption`: `table_caption_max_width` (default `fit-content`:
  // no wider than the table, and aligned with it; `none`: the full width)
  let caption = fn(bottom : Bool) {
    self.ink_caption(
      node.captioned_title(),
      bottom~,
      category="table",
      block_align=alignment,
      block_width=table_width,
      max_width=value_str(tv("table_caption_max_width")).unwrap_or(
        "fit-content",
      ),
    )
  }
  if !contained && node.has_title() && !below {
    caption(false)
  }
  if layout.num_rows == 0 {
    self.block_margin_bottom(node)
    return
  }
  // initial_row_on_initial_page (as asciidoctor-pdf patches it): unless at
  // the top, the header rows and the first body row, with their spans, must
  // fit, else the table starts on the next page

  if !flow.at_page_top() {
    let mut needed = 0.0
    for r in 0..<@cmp.minimum(layout.head_rows + 1, layout.num_rows) {
      needed += layout.row_height_with_span(r)
    }
    if !flow.fits(needed) {
      flow.advance_page()
    }
  }
  // the rows of each page, drawn once the page is full
  let mut page_top = flow.y
  let mut rows_here : Array[Int] = []
  let mut used = 0.0
  let mut start_row = 0
  for r in 0.. 0 && !flow.fits(layout.row_height_with_span(r), y=page_top + used) {
      // a page holding only the repeated header draws nothing
      if rows_here.length() > layout.head_rows {
        flow.y = page_top
        self.draw_table_rows(layout, rows_here, start_row)
      }
      flow.advance_page()
      start_row = r
      page_top = flow.y
      rows_here = []
      used = 0.0
      if r >= layout.head_rows {
        for h in 0..