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

I am writing a template for a document that will have lots of programmatically created tables inserted. Per our style guide, all tables have to span the full page width. The only way to do this that I currently know is to have at least one column with an fr column width. However, if you don’t know what content is going to be in a table you have to fear edge cases.

If you have a lot of content, the auto setting in general does a good job:

#table(
    columns: (auto, auto, auto),
)[#lorem(80)][#lorem(60)][#lorem(2)]

But if there’s little content, then auto sized columns will not have the table fill the page width:

#table(
    columns: (auto, auto, auto),
)[#lorem(2)][#lorem(3)][#lorem(4)]

image

So you can make the columns all 1fr which works for this case:

image

But if you have a lot of content and it’s not distributed equally, then this gives a worse layout where some columns waste space:

#table(
    columns: (1fr, 1fr, 1fr),
)[#lorem(80)][#lorem(60)][#lorem(2)]

So you might think maybe make the column with less content auto and the rest 1fr, which can work:

#table(
    columns: (1fr, 1fr, auto),
)[#lorem(80)][#lorem(60)][#lorem(2)]

But as I said, my content is auto-generated so I don’t generally know which column would be good to set to auto, and if I pick the wrong one then I get completely broken layouts:

#table(
    columns: (auto, 1fr, 1fr),
)[#lorem(80)][#lorem(60)][#lorem(2)]

My current thinking is that I could maybe measure the tables in a context block, check if they are smaller than the page width if auto wide, and only in that case use 1fr everywhere. But I’m not sure if there’s a better way?

1 Like

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

Maybe this is a way to do it. Insert one row that is guaranteed to take all width.

This works for the examples you have. It has certain limitations.

  • If this doesn’t work for you, insert the widerow in some other custom way that does
  • If that doesn’t work, go back one step to the table generation and take this requirement into account already in the generation of the tables.

#let widerow(n, ..args, body: []) = table.cell(colspan: n, inset: 0pt, stroke: none, ..args, block(width: 100%, body))

/// Always insert a wide row into each table
/// Warning: this assumes the table has evenly filled rows (ie the last row of cells in the table must also be full to the column count)
#let widetable(..args) = {
  let columns = args.at("columns", default: 1)
  let columns = if type(columns) == array { columns.len() } else { columns }
  std.table(
    ..args,
    widerow(columns),
  )
}

#widetable(
    columns: 3,
)[#lorem(2)][#lorem(5)][#lorem(2)]

#widetable(
    columns: 3,
)[#lorem(80)][#lorem(60)][#lorem(2)]

#widetable(
    columns: 3,
)[#lorem(2)][#lorem(3)][#lorem(4)]

#widetable(
  columns: 3,
  block(width: 12cm),
  [],
  [whoop whoop],
)

Thank you, that’s an interesting idea. Unfortunately this results in the last column taking all the remaining space if the table is smaller than the page, which can look quite unbalanced:

This gave me another idea, although I’m not sure, yet, if it can be done in Typst. I think I cannot get the sizes of the laid out columns of a table directly, but maybe I could measure blocks inserted into each cell of a row appended to a temporary copy of the table. Then if the table as a whole is smaller than the page width, I could replace the column widths with fractions that reflect the measured values.

I wrote about “a way to measure table column widths” here Build Table, Measure, then Build it Again but it really doesn’t work if you have text columns that wrap - I realize that now. It works for tables with fixed column widths, like your new example with the hierarchical groups.

I really think you need to go back to the table generation stage. You say these are autogenerated. At that point you know more what kind of size transformation you can do for a particular table. The hierarchical table seems good for equal 1fr split among all columns for example.

I can check the length of some strings included in the table, yes, but that doesn’t map directly to typographic width, especially not if there are some nonstandard objects in there that should be rendered by typst, like #sub(...). So trying to determine in table generation what column widths are appropriate will be very brittle.

In principle I just want this logic

  • leave all columns auto
  • is the table approximately full width?
    • yes: leave it as it is
    • no: determine rest space, measure columns, add rest/n to each column width

The missing piece is getting column measurements but so far I haven’t seen anything that could measure a whole table and return those values, just workarounds where columns are measured on their own.

I’ve made a few additions to How can I robustly make (unknown) tables span the full page width? - #2 by Andrew. The brute force method now works pretty good, but in some cases you can do better. The more I think about it, the more I feel this is basically a superset of oasis-align – Typst Universe, since it also operates on equal-height columns.

So, from here, either the MVP can be used, or can be improved with binary search, or some sort of more fine iteration should be used, not just (min-column-width, 1fr). But this is tricky, since you need to juggle column widths so that they get to 100% page width in all cases. This means that 1 fr might not be good, but a combination of different fractions (or ratios) seems like the way to go, though still small fractions will probably not fill the whole page width.

For future reference this is the binary search for typst.

It speeds up Andrew’s test document significantly, even if it’s small. Since it might be useful in some other settings, there’s a test function that travels with this code… And yes, this is partition point, the simplified version of bisection that we need.

/// 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 test-partition(arr, func) = {
  let partition(arr, i) = (arr.slice(0, i), arr.slice(i, none))
  let pp = partition-point(arr, func)
  [pp=#pp: ]
  let (low, high) = partition(arr, pp)
  repr((low, high))
  assert(low.all(elt => func(elt)))
  assert(high.all(elt => not func(elt)))
}

// #test-partition(range(10), elt => elt <= 4)
// #test-partition(range(10, step: 2), elt => elt <= 5)

#let estimate-min-width(doc) = {
  let nominal-length = measure(doc).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(doc, width: max-width).width
    not (actual-width < max-width)
  })
  let min-step = widths.at(pp, default: ceil) * 1pt
  let min-width = measure(doc, width: min-step).width
  return min-width
}
1 Like

Hm I’m not sure if this goes into the direction I need. As I mentioned before, I’m generally fine with tables as they come out by default with auto column sizes, as long as they end up approximately filling the page. This will be the case whenever they have lots of content.

In other situations, tables will have less content and be laid out with less width than the page offers. In those cases I just want to ensure that each column’s final size, given with some fr multiplier, retains the relative weight from the initial layout because if I give each column the same 1fr weight, this might lead to unnecessary line breaks if there’s one longer column and a couple short ones. This is basically like expanding the original columns which should in many basic cases of the tables I’m working with just lead to increased whitespace in each cell. I just can’t seem to find a way to get information from Typst about its original layout for the all-auto column case. Maybe that has to be a feature request on Github then.

Would it be possible to layout each cell of the table as if it had infinite space, then keep track of any column that contains a cell taking up more than 1/n the page width (for n columns). Then for those columns, set the width to 1fr, and for all others set it to auto. Then of course perform the actual layout with each cell having the space defined by the column.
I’m not sure that makes total sense, but the general idea is to find out which columns can be left as auto, and which columns require something wider.

This is turning into a hacking bonanza. I guess we’ll just keep trying until something works.

Combining the previous wide row from this thread and with Andrew’s non expanding cell: How to avoid that a large table footer widens the whole table? - #8 by Andrew

Then we could potentially measure all the table column widths:

code
#set page(margin: 1cm)

#let make-label(counter, alignment, n: auto) = {
  assert(alignment in (left, right))
  let counter-name = repr(counter).split("\"").at(1)
  let alignment-str = repr(alignment)
  let n = if n == auto { str(counter.get().first()) } else { str(n) }
  label(counter-name + "." + n + "." + alignment-str)
}

#let cell-counter = counter("cell-counter")
#let table-counter = counter("table-counter")

#let pin-corner(counter, alignment) = context {
  place(alignment)[#metadata(none)#make-label(counter, alignment)]
}

#let pin-left-right(counter) = {
  pin-corner(counter, left)
  pin-corner(counter, right)
}

#let mark-cell(counter, body, ..args) = table.cell(
  ..args,
  counter.step() + pin-left-right(counter) + body,
)

#let get-pinned-delta-x(counter) = {
  let cell-inset = if table.cell.inset == auto { 5pt } else { cell.inset }
  let n = counter.get().first()
  let (left, right) = (left, right).map(alignment => {
    locate(make-label(counter, alignment, n: n)).position()
  })
  right.x - left.x
}

#let non-expanding-cell(body, ..args, instate: none) = {
  mark-cell(cell-counter, ..args, context {
    let dx = get-pinned-delta-x(cell-counter)
    if instate != none {
      instate.update(dx)
    }
  })
}

#let measuretable(..args) = {
  table-counter.step()
  context {
    let columns = args.at("columns", default: 1)
    let columns = if type(columns) == array { columns.len() } else { columns }
    let states = range(columns).map(i => state("_measure_col_" + str(table-counter.get().first()) + "_" +  str(i)))
    let measurecell(st) = non-expanding-cell(inset: 0pt, stroke: none, instate: st)[]
    let measurerow = states.map(measurecell)
    for st in states { st.update(0pt) }
    context {
      [Column widths: ]
      states.map(st => [#st.final()]).join([, ])
    }
    std.table(
      ..args,
      ..measurerow
    )
  }
}

#measuretable(columns: 1,
 [Abc])
 
#measuretable(columns: 3, [A], [AB], [ABC])

#measuretable(columns: 1, lorem(10))

#measuretable(columns: 1, lorem(20))

#measuretable(columns: 2, lorem(5), lorem(10))
#measuretable(columns: 2, lorem(10), lorem(5))
#measuretable(columns: 2, lorem(10), lorem(10))
#measuretable(columns: 2, lorem(50), lorem(10))
#measuretable(columns: 2, lorem(10), lorem(70))

#measuretable(columns: 3, lorem(10), lorem(50), lorem(10))


#measuretable(
    columns: 3,
)[#lorem(2)][#lorem(5)][#lorem(2)]

#measuretable(
    columns: 3,
)[#lorem(80)][#lorem(60)][#lorem(2)]

#measuretable(
    columns: 3,
)[#lorem(2)][#lorem(3)][#lorem(4)]

#measuretable(
  columns: 3,
  block(width: 12cm), [], lorem(2),
)

Looks like it’s about right to me.

I’m wondering if these hacks are even passable by size, since from previous context, if still applied, the inserted code should be minimal.

Code size is not a problem in this case, the tables are generated by one piece of code, and a template is supposed to modify these to adhere to the full-page requirement. The two don’t necessarily know about each other.

Huh that looks promising, how would I modify that code so the measure function takes the output of a #table call and not the same args as #table? Not yet super fluent with Typst’s object model.

Is there a reason you want to do that? The measuretable, as that function is called, needs to be placed into the document to do its job. So it’s not a function that can measure a table but it’s a table that can measure itself, if that makes sense. The measurecells need to be part of the actual document :slight_smile:

What about this:

#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 wide-table(table-id, n-cols, ..args) = context {
  let columns = n-cols
  let sizes = query(label(table-id))
  if sizes.len() != 0 {
    columns = sizes.map(md => md.value.width.pt() * 1fr)
  }
  table(
    columns: columns,
    ..args,
    ..m-cells(table-id, n-cols),
  )
}

#wide-table("table1", 3)[#lorem(2)][#lorem(5)][#lorem(2)]
#wide-table("table2", 3)[#lorem(80)][#lorem(60)][#lorem(2)]
#wide-table("table3", 3)[#lorem(2)][#lorem(3)][#lorem(4)]
#wide-table("table4", 3, block(width: 12cm), [], [whoop whoop])

(I haven’t studied all the previous solutions in detail so maybe this has already been proposed…)

1 Like

The output looks exactly like I imagined, nice! So you seem to use some metadata system as a cache for the measured values, right? I had tried something with an inner scope setting variables in an enclosing scope but that seemed not to be allowed in typst, so your idea seems to be a workaround for that. I don’t like coming up with IDs for tables, though, can this be avoided?

Yes exactly I’m adding an invisible row where each cell contains metadata with the cell size that is given in the first iteration. In the next iteration the final column sizes are computed based on the metadata.

To remove the table IDs I can think of two solutions (not sure if they’d work):

  • Use selectors like .before(here()).last()
  • Put the metadata in a state

But I think these might be less robust / need more complex handling of context / lead to more convergence issues, so I’d personally use an explicit ID if I can. Do you foresee any issue with a simple counter for the table ID?

It’s just that I get the finished #table call from the generator. I assumed I would use a #show rule to replace each of those embedded #table instances in a document with a #measuretable instance, but for that I’d still need to interact with the original table object, no?

As long as the id can be automatically determined, like a counter, or a gensym or so, that’s fine. But I don’t want to have to specify anything in the final document.