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

I tried adding a counter:

#let m-cell(table-id) = table.cell(
  inset: 0pt,
  stroke: none,
  layout(s => [#metadata(s)#label(table-id)]),
)

#let m-cells(table-id, n) = (m-cell(table-id),) * n

#let min-height-table(columns: 1, ..args) = {
  let table-id = counter("table-id")
  table-id.step()
  context {
    let table-id = "table" + str(table-id.get().first())
    let columns-sizes = columns
    let sizes = query(label(table-id))
    if sizes.len() != 0 {
      columns-sizes = sizes.map(md => md.value.width.pt() * 1fr)
    }
    table(
      columns: columns-sizes,
      ..args,
      ..m-cells(table-id, columns),
    )
  }
}

// #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))

But not only does it not look correct for my tests, it also throws the warning on 5 tables, hence only 4 are shown.

I then checked your example, and as long as all tables have unique labels, no warning is shown.

#for i in range(100) {
  let (a, b, c, d) = range(4).map(j => "table" + str(j) + str(i))
  wide-table(a, 3)[#lorem(2)][#lorem(5)][#lorem(2)]
  wide-table(b, 3)[#lorem(80)][#lorem(60)][#lorem(2)]
  wide-table(c, 3)[#lorem(2)][#lorem(3)][#lorem(4)]
  wide-table(d, 3, block(width: 12cm), [], [whoop whoop])
}

I don’t yet fully understand what this does, but it clearly takes at least 2 explicit cycles to get the required table. Typst itself can have several implicit cycles to lay out everything, IIUC. So total it can hit 5 attempts very quickly, at least if you throw in table id counter.


Additionally, it takes a different number of tables to trigger the warning.

It looks like this specific test(s) causes the warning:

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

While this does not:

#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(10), lorem(10))
#min-height-table(columns: 2, lorem(50), lorem(10))
#min-height-table(columns: 2, lorem(50), lorem(10))
#min-height-table(columns: 2, lorem(10), lorem(70))
#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))

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

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

I think sijo’s solution is helped by rounding the md.value.width.pt() value, it might converge properly then.

columns-sizes = sizes.map(md => calc.round(md.value.width.pt()) * 1fr)
1 Like

Yes this can be done. It’s usually done like this:

show table: t => {
  let f = t.fields()
  let children = f.remove("children")
  measuretable(..f, ..children)
}

But there’s one more thing needed - this would recurse because measuretable creates a new table. So we need some way to terminate the recursion.

We need some way to recognize the already processed table. I reached for checking for an inset-zero cell here, but usually a label is used instead. However, we can’t directly label the measuretable (it’s embedded in context). We could patch the measuretable function so that it optionally labels its output, though.

#show table: t => {
  let f = t.fields()
  let children = f.remove("children")
  let is-processed = children.any(elt => elt.at("inset", default: none) == 0pt)
  if is-processed { t } else {
    measuretable(..f, ..children)
  }
}

Note that I don’t :blush: even know how to turn the measuretable function into what you wanted, something that adapts column sizes (and that without convergence errors).

Indeed.

#let m-cell(table-id) = table.cell(
  inset: 0pt,
  stroke: none,
  layout(s => [#metadata(s)#label(table-id)]),
)

#let m-cells(table-id, n) = (m-cell(table-id),) * n

#let min-height-table(columns: 1, ..args) = {
  let table-id = counter("table-id")
  table-id.step()
  context {
    let table-id = "table" + str(table-id.get().first())
    let columns-sizes = columns
    let sizes = query(label(table-id))
    if sizes.len() != 0 {
      columns-sizes = sizes.map(md => calc.round(md.value.width.pt()) * 1fr)
    }
    table(
      columns: columns-sizes,
      ..args,
      ..m-cells(table-id, columns),
    )
  }
}

#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(10), lorem(60))

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

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

1 Like

Thanks for all your help, I’ve converged now on this #show table solution that seems to work pretty well. The vertical height might not always be minimal but that’s not important, at least no input I’ve tested so far looks unreasonable.

// an empty table cell that stores its own layout space as metadata
#let measurement-cell(table-id, x) = table.cell(
  inset: 0pt,
  stroke: none,
  x: x, // set x explicitly to be robust against dangling cells in the table definition
  layout(s => [#metadata(s)#label(table-id)]),
)

#let measurement-cells(table-id, n) = range(n).map(i => measurement-cell(table-id, i))

#let full-width-table(tbl) = {
  let f = tbl.fields()
  let columns = f.remove("columns")
  let children = f.remove("children")

  context {
    // use a counter to generate the unique ID needed to store the metadata
    let table-id = counter("table-id")
    table-id.step()
    let table-id = "table" + str(table-id.get().first())
    
    // retrieve the resolved cell sizes from the first layout
    // iteration and give each column a proportional fr size,
    // this will expand all columns such that the table now has
    // full page width
    let computed-sizes = query(label(table-id))
    let columns-sizes = columns
    if computed-sizes.len() != 0 {
      columns-sizes = computed-sizes.map(md => calc.round(md.value.width.pt()) * 1fr)
    }
    table(
      columns: columns-sizes,
      ..f,
      ..children,
      // insert the invisible measurement cells in the last row
      ..measurement-cells(table-id, columns.len()),
    )
  }
}

#show table: it => {
  // this is a bit hacky, determine if the table was already modified
  // by a zero inset measurement cell that was appended, the assumption
  // being that normal cells wouldn't have zero inset
  let is-processed = it.children.last().at("inset", default: none) == 0pt
  if is-processed {
    it
  } else {
    full-width-table(it)
  }
}
3 Likes

OK, so does it just save the default/current column widths, so that next time you can use the same measurements that will save the ratio/proportions, but additionally will fill the whole page width because of how fractions work?

Actually, despite using small fractions, it will still fill the whole width, hm.

#table(columns: (0.01fr,) * 5, ..range(5).map(str))

Yes the first time it uses auto sizing which can take less than the full available width. The second time it uses the result of the auto sizes as weights for fraction lengths.

When you have fraction lengths you always fill the available space, and the “absolute” value of the fraction length is irrelevant: 0.01fr will also take the full space. The values are only used as relative weights when there are multiple fraction lengths. So box(width: 1fr) + box(width: 2fr) does exactly the same as box(width: 0.1fr) + box(width: 0.2fr) (if there are no other fraction lengths that compete with those two).