How can I robustly make (unknown) tables span the full page width?

Why not columns: 3,? Especially if it’s auto-generated. Also (1fr,) * 3.

“if if”? Also, what is “if auto wide”?

You basically want a generic table layouter for minimum height written in Typst? Are all cell 1x1?

Sounds like oasis-align – Typst Universe or gridlock – Typst Universe.

I can only think of having some smart predictor based on one or couple measurements, or a stupid loop where you just iterate over all possible values for table.columns and then choose the one that has the least height.


With Truth tables: How can I fill a table with auto generated data (inputs) and data from an array (output)? - #2 by Andrew

Code
#let generate-binary-columns(n, subs: (auto, 1fr)) = {
  let ith-row = i => ("0" * (n - 1) + str(i, base: 2)).slice(-n)
  let rows = range(calc.pow(2, n)).map(ith-row).map(row => row.clusters())
  let map-subs = value => range(2).map(str).zip(subs).to-dict().at(value)
  rows.map(row => row.map(map-subs))
}

#let min-height-table(columns: 1, ..cells) = {
  let column-configs = generate-binary-columns(columns)
  let table = table.with(..cells)
  layout(size => {
    let height-config = ()
    if measure(table(columns: columns), ..size).width < size.width {
      return table(columns: (1fr,) * columns)
    }
    for column-config in column-configs {
      height-config.push((
        measure(table(columns: column-config), ..size).height,
        table(columns: column-config),
      ))
    }
    // array.zip(..height-config.sorted(key: row => row.first()))
    height-config.sorted(key: row => row.first()).first().last()
  })
}

// #set par(justify: true)
#place(rect(width: 100%, height: 100%, stroke: 0.5pt + gray))

#min-height-table(columns: 1, lorem(10))

#min-height-table(columns: 1, lorem(20))

#min-height-table(columns: 2, lorem(5), lorem(10))
#min-height-table(columns: 2, lorem(10), lorem(5))
#min-height-table(columns: 2, lorem(10), lorem(10))
#min-height-table(columns: 2, lorem(50), lorem(10))
#min-height-table(columns: 2, lorem(10), lorem(70))

This doesn’t look that great, so just 2 options isn’t enough, even if all column combinations are tried. So as I thought, you probably need to either find some formula/algorithm, or just step over some widths and see which one fits better. Which will be even more iterations.


Actually, this works better if the narrow tables are also processed, and width is taken into account.

Code
#let generate-binary-columns(n, subs: (auto, 1fr)) = {
  let ith-row = i => ("0" * (n - 1) + str(i, base: 2)).slice(-n)
  let rows = range(calc.pow(2, n)).map(ith-row).map(row => row.clusters())
  let map-subs = value => range(2).map(str).zip(subs).to-dict().at(value)
  rows.map(row => row.map(map-subs))
}

#let min-height-table(columns: 1, ..cells) = {
  let column-configs = generate-binary-columns(columns)
  let table = table.with(..cells)
  layout(size => {
    let height-width-config = ()
    for column-config in column-configs {
      height-width-config.push((
        measure(table(columns: column-config), ..size).height,
        measure(table(columns: column-config), ..size).width,
        table(columns: column-config),
      ))
    }
    height-width-config
      .sorted(key: row => row.at(1))
      .rev()
      .sorted(key: row => row.first())
      .first()
      .last()
  })
}

#place(rect(width: 100%, height: 100%, stroke: 0.5pt + gray))

#min-height-table(columns: 1, lorem(10))

#min-height-table(columns: 1, lorem(20))

#min-height-table(columns: 2, lorem(5), lorem(10))
#min-height-table(columns: 2, lorem(10), lorem(5))
#min-height-table(columns: 2, lorem(10), lorem(10))
#min-height-table(columns: 2, lorem(50), lorem(10))
#min-height-table(columns: 2, lorem(10), lorem(70))

#min-height-table(columns: 3, lorem(10), lorem(50), lorem(10))

One thing to note, is that you need to define a minimum width for each column, so that you don’t get artifacts.


Okay, here is a less worse version, that does check for min column width, but depends on string representation of each cell, because you can’t easily check for min width.

Code
#import "@preview/t4t:0.4.3": get

#let get-min-cell-width(cell, table: table, layout-size) = {
  let cell = lorem(10)
  let a = get.text(cell).split(" ").sorted(key: x => x.clusters().len()).last()
  measure(table(a), ..layout-size).width
}

#let generate-binary-columns(n, subs: (auto, 1fr)) = {
  let ith-row = i => ("0" * (n - 1) + str(i, base: 2)).slice(-n)
  let rows = range(calc.pow(2, n)).map(ith-row).map(row => row.clusters())
  rows
}

#let min-height-table(columns: 1, ..cells) = {
  layout(size => {
    let height-width-config = ()
    let min-widths = ()
    for cell in cells.pos() {
      min-widths.push(get-min-cell-width(cell, size))
    }
    let min-column-widths = array
      .zip(..min-widths.chunks(columns))
      .map(column => calc.max(..column))
    let map-subs = ((i, value)) => range(2)
      .map(str)
      .zip((min-column-widths.at(i), 1fr))
      .to-dict()
      .at(value)
    let column-configs = generate-binary-columns(columns).map(row => row
      .enumerate()
      .map(map-subs))
    if measure(table(columns: columns, ..cells), ..size).width < size.width {
      column-configs = range(columns).map(i => (
        "0" * (columns - 1) + str(calc.pow(2, i), base: 2)
      )
        .slice(-columns)
        .clusters()
        .map(x => ("0": auto, "1": 1fr).at(x)))
    }
    let table = table.with(..cells)
    for column-config in column-configs {
      height-width-config.push((
        measure(table(columns: column-config), ..size).height,
        measure(table(columns: column-config), ..size).width,
        table(columns: column-config),
      ))
    }
    height-width-config
      .sorted(key: row => row.at(1))
      .rev()
      .sorted(key: row => row.first())
      .first()
      .last()
  })
}

#place(rect(width: 100%, height: 100%, stroke: 0.5pt + gray))

#min-height-table(columns: 1, lorem(10))

#min-height-table(columns: 1, lorem(20))

#min-height-table(columns: 2, lorem(5), lorem(10))
#min-height-table(columns: 2, lorem(10), lorem(5))
#min-height-table(columns: 2, lorem(10), lorem(10))
#min-height-table(columns: 2, lorem(50), lorem(10))
#min-height-table(columns: 2, lorem(10), lorem(70))

#min-height-table(columns: 3, lorem(10), lorem(50), lorem(10))


@bluss came up with a proper hackery hack of estimating min width, which has an overhead of many measurement iterations, but precision can be changed, and number of iterations reduced in average case to O(log(n)) with bisection. But it will also worsen the best case and improve worse case. Either way, it doesn’t depend on t4t package and string representation limitations. However, if there are a lot of table cells, then it can significantly increase compilation time, I reckon.

Code
#let estimate-min-width(body) = {
  let min-width
  let nominal-length = measure(body).width
  for w in range(calc.ceil(nominal-length.pt())) {
    let max-width = w * 1pt
    let actual-width = measure(body, width: max-width).width
    min-width = actual-width
    if actual-width < max-width { break }
  }
  return min-width
}

#let get-min-cell-width(cell, table: table, layout-size) = {
  estimate-min-width(table(cell))
}

#let generate-binary-columns(n, subs: (auto, 1fr)) = {
  let ith-row = i => ("0" * (n - 1) + str(i, base: 2)).slice(-n)
  let rows = range(calc.pow(2, n)).map(ith-row).map(row => row.clusters())
  rows
}

#let min-height-table(columns: 1, ..cells) = {
  layout(size => {
    let height-width-config = ()
    let min-widths = ()
    for cell in cells.pos() {
      min-widths.push(get-min-cell-width(cell, size))
    }
    let min-column-widths = array
      .zip(..min-widths.chunks(columns))
      .map(column => calc.max(..column))
    let map-subs = ((i, value)) => range(2)
      .map(str)
      .zip((min-column-widths.at(i), 1fr))
      .to-dict()
      .at(value)
    let column-configs = generate-binary-columns(columns).map(row => row
      .enumerate()
      .map(map-subs))
    if measure(table(columns: columns, ..cells), ..size).width < size.width {
      column-configs = range(columns).map(i => (
        "0" * (columns - 1) + str(calc.pow(2, i), base: 2)
      )
        .slice(-columns)
        .clusters()
        .map(x => ("0": auto, "1": 1fr).at(x)))
    }
    let table = table.with(..cells)
    for column-config in column-configs {
      height-width-config.push((
        measure(table(columns: column-config), ..size).height,
        measure(table(columns: column-config), ..size).width,
        table(columns: column-config),
      ))
    }
    height-width-config
      .sorted(key: row => row.at(1))
      .rev()
      .sorted(key: row => row.first())
      .first()
      .last()
  })
}

#place(rect(width: 100%, height: 100%, stroke: 0.5pt + gray))

#min-height-table(columns: 1, lorem(10))

#min-height-table(columns: 1, lorem(20))

#min-height-table(columns: 2, lorem(5), lorem(10))
#min-height-table(columns: 2, lorem(10), lorem(5))
#min-height-table(columns: 2, lorem(10), lorem(10))
#min-height-table(columns: 2, lorem(50), lorem(10))
#min-height-table(columns: 2, lorem(10), lorem(70))

#min-height-table(columns: 3, lorem(10), lorem(50), lorem(10))

Using binary search from How can I robustly make (unknown) tables span the full page width? - #8 by bluss, which is about 37% faster for this test.

Code
/// Get index of partition point
/// - func (function): Return true if less or equal, false if greater than partition point
/// based on rust libcore partition point
#let partition-point(range, func) = {
  let size = range.len()
  if size == 0 { panic("Empty range in partition-point") }
  let base = 0
  while size > 1 {
    let half = calc.div-euclid(size, 2)
    let mid = base + half
    let lte = func(range.at(mid))
    base = if lte { mid } else { base }
    size -= half
  }
  let lte = func(range.at(base))
  base + int(lte)
}

#let estimate-min-width(body) = {
  let nominal-length = measure(body).width
  let ceil = calc.ceil(nominal-length.pt())
  let widths = range(ceil, step: 1)
  let pp = partition-point(widths, w => {
    let max-width = w * 1pt
    let actual-width = measure(body, width: max-width).width
    not (actual-width < max-width)
  })
  let best-guess-max-width = widths.at(pp, default: ceil) * 1pt
  measure(body, width: best-guess-max-width).width
}

#let get-min-cell-width(cell, table: table, layout-size) = {
  estimate-min-width(table(cell))
}

#let generate-binary-columns(n, subs: (auto, 1fr)) = {
  let ith-row = i => ("0" * (n - 1) + str(i, base: 2)).slice(-n)
  let rows = range(calc.pow(2, n)).map(ith-row).map(row => row.clusters())
  rows
}

#let min-height-table(columns: 1, ..cells) = {
  layout(size => {
    let height-width-config = ()
    let min-widths = ()
    for cell in cells.pos() {
      min-widths.push(get-min-cell-width(cell, size))
    }
    let min-column-widths = array
      .zip(..min-widths.chunks(columns))
      .map(column => calc.max(..column))
    let map-subs = ((i, value)) => range(2)
      .map(str)
      .zip((min-column-widths.at(i), 1fr))
      .to-dict()
      .at(value)
    let column-configs = generate-binary-columns(columns).map(row => row
      .enumerate()
      .map(map-subs))
    if measure(table(columns: columns, ..cells), ..size).width < size.width {
      column-configs = range(columns).map(i => (
        "0" * (columns - 1) + str(calc.pow(2, i), base: 2)
      )
        .slice(-columns)
        .clusters()
        .map(x => ("0": auto, "1": 1fr).at(x)))
    }
    let table = table.with(..cells)
    for column-config in column-configs {
      height-width-config.push((
        measure(table(columns: column-config), ..size).height,
        measure(table(columns: column-config), ..size).width,
        table(columns: column-config),
      ))
    }
    height-width-config
      .sorted(key: row => row.at(1))
      .rev()
      .sorted(key: row => row.first())
      .first()
      .last()
  })
}

#place(rect(width: 100%, height: 100%, stroke: 0.5pt + gray))

#min-height-table(columns: 1, lorem(10))

#min-height-table(columns: 1, lorem(20))

#min-height-table(columns: 2, lorem(5), lorem(10))
#min-height-table(columns: 2, lorem(10), lorem(5))
#min-height-table(columns: 2, lorem(10), lorem(10))
#min-height-table(columns: 2, lorem(50), lorem(10))
#min-height-table(columns: 2, lorem(10), lorem(70))

#min-height-table(columns: 3, lorem(10), lorem(50), lorem(10))

#min-height-table(columns: 4, lorem(10), lorem(50), lorem(10), lorem(50))