How to avoid that a large table footer widens the whole table?

Hi,

I have a Julia package which creates tables:

One of the supported formats is Typst. Tables can have footnotes, these are implemented as a single cell at the end, spanning all columns, which contains each footnote separated by a linebreak. My problem is that users may specify long footnotes, but if the table is not very wide itself, the final layout will look bad:

using SummaryTables

Table(
    [Cell("$j$i") for i in 1:3, j in 'A':'E'],
    footnotes = [
        "This is a long footnote. It consists of multiple sentences. Therefore, the table will become very wide."
    ]
)

This creates the following typst code:

#table(
    rows: 3,
    columns: 5,
    column-gutter: 0.25em,
    align: (center, center, center, center, center),
    stroke: none,
    table.hline(y: 0, stroke: 1pt),
    [A1],
    [B1],
    [C1],
    [D1],
    [E1],
    [A2],
    [B2],
    [C2],
    [D2],
    [E2],
    [A3],
    [B3],
    [C3],
    [D3],
    [E3],
    table.hline(y: 3, stroke: 1pt),
    table.cell(align: left, colspan: 5)[#text(size: 0.8em)[
        This is a long footnote. It consists of multiple sentences. Therefore, the table will become very wide.
    ]],
)

And the visual result:

Is there some way I can nudge Typst so that it eagerly linebreaks that one cell with the footnotes? Because this is all programmatically generated, it’s not feasible to force the table to some width (how would I know in advance which width is suitable?).

Maybe a solution could be to move the text outside of the table, but it should always be aligned with it, so an actual table cell seemed the simplest option to ensure that.

Thanks for your help!
Julius

Here is a solution. Slightly hacky, but just a bit. Give your table cell a label or other way to refer to it. It should be ok that multiple footnotes in different tables share the same label, that will work.

Then you can adapt the width:

let adapt-cell-width(thetable) = {
  context {
    // measure the size of the table without the cell
    let size = measure({
      show <mycell>: none
      thetable
    })
    show <mycell>: block.with(width: size.width)
    thetable
  }
}
adapt-cell-width(t)

bild

How to label the cell? The syntax is [#table.cell(...)<mycell>]

Hi. Do you want in-text footnotes?

Also see relevant Why does Typst crash with large amounts of content? - #14 by SillyFreak :

Thank you for your response!

The first link seems to advocate a solution where the footnotes are not part of the table. I’ll have to experiment with that, but I was hoping I could simply issue some command that would make Typst more eager to line break the content in the last cell.

The second link I did not find so relevant, my tables are not huge, this is about layouting and not RAM.

Yes. Using a table to insert a footnote is semantically incorrect. If you need a native solution, you can request it. The only text related things that can be tuned are covered in text.costs.

The point is that you should prefer data loading to generating Typst content. You don’t specify how big a table can be.

I don’t really care about semantics, just the visual output. For all intents and purposes I’m just talking about a cell that spans the whole table, doesn’t matter whether it’s in the footer or elsewhere.

And the data loading thing might be cleaner but is not possible for my use cases where I have to supply snippets of typst that are merged into a larger doc. It also shouldn’t matter at all in terms of performance with the table sizes I’m using.

It would be cool to have an element that acts as a non-greedy container: don’t request any size, but use everything that’s available once the size is decided, but I don’t think Typst has that currently.

That’s probably the best approach. It’s easy to make two things aligned with a grid or stack:

#let table-with-notes(notes: none, ..args) = layout(size => {
  let tbl = table(..args)
  let w = measure(..size, tbl).width
  stack(dir: ttb, spacing: 0.3em, tbl, block(width: w, notes))
})

#table-with-notes(
  rows: 3,
  columns: 5,
  column-gutter: 0.25em,
  align: (center, center, center, center, center),
  stroke: none,
  table.hline(y: 0, stroke: 1pt),
  [A1], [B1], [C1], [D1], [E1],
  [A2], [B2], [C2], [D2], [E2],
  [A3], [B3], [C3], [D3], [E3],
  table.hline(y: 3, stroke: 1pt),
  notes: [
    #set text(size: 0.8em)
    This is a long footnote. It consists of multiple sentences. Therefore, the table will become very wide.
  ],
)

image

3 Likes

If you want a non-expanding cell feature, then you should open an issue.

Using the same hack as in How to distribute column widths equally for specific rows in a table? - #4 by Andrew, it’s not that hard to make it work:

#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 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-left-right-x(counter) = {
  let cell-inset = if table.cell.inset == auto { 5pt } else { cell.inset }
  let adjusted-position(pos) = (pos.x - page.margin, pos.y - page.margin)
  let n = counter.get().first()
  let (left, right) = (left, right).map(alignment => {
    adjusted-position(locate(make-label(counter, alignment, n: n)).position())
  })
  (left.first(), right.first())
}

#let non-expanding-cell(body, ..args) = {
  mark-cell(cell-counter, ..args, context {
    let (x1, x2) = get-pinned-left-right-x(cell-counter)
    block(width: x2 - x1, body)
  })
}

Basically the same thing, but instead of Y axis you work with X axis.

#table(
  rows: 3,
  columns: 5,
  column-gutter: 0.25em,
  align: center,
  stroke: none,
  table.hline(),
  [A1], [B1], [C1], [D1], [E1],
  [A2], [B2], [C2], [D2], [E2],
  [A3], [B3], [C3], [D3], [E3],
  table.hline(),
  non-expanding-cell(align: left, colspan: 5, text(0.8em, lorem(15)))
)

image

Full example
#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 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-left-right-x(counter) = {
  let cell-inset = if table.cell.inset == auto { 5pt } else { cell.inset }
  let adjusted-position(pos) = (pos.x - page.margin, pos.y - page.margin)
  let n = counter.get().first()
  let (left, right) = (left, right).map(alignment => {
    adjusted-position(locate(make-label(counter, alignment, n: n)).position())
  })
  (left.first(), right.first())
}

#let non-expanding-cell(body, ..args) = {
  mark-cell(cell-counter, ..args, context {
    let (x1, x2) = get-pinned-left-right-x(cell-counter)
    block(width: x2 - x1, body)
  })
}

#table(
  rows: 3,
  columns: 5,
  column-gutter: 0.25em,
  align: center,
  stroke: none,
  table.hline(),
  [A1], [B1], [C1], [D1], [E1],
  [A2], [B2], [C2], [D2], [E2],
  [A3], [B3], [C3], [D3], [E3],
  table.hline(),
  non-expanding-cell(align: left, colspan: 5, text(0.8em, lorem(15)))
)
3 Likes

Would you have an example of what counts as hard? :smile:

That’s very useful, so hopefully this ends up in a package somewhere eventually

How did you realize this would work - it’s not super intuitive to me that it would work?

Labels are placed with place in first layout iteration. What happens with the block width in the first iteration, does it get the right width already then? or in a second iteration?

1 Like

No, but “hard” would be creating a hack from scratch. Since I already have it, I just need to change the axis and a few small details to make it work for this use case.

I actually didn’t think about it. I’m pretty sure I can just use place instead, and it will not affect width either way. But then there will be an issue of being outside of the layout.

The thing is that the pinned corners go first, and the context body goes after. So the way I see it, the context resolution starts doing its thing before anything inside the context is inserted, because the block depends on the context info. Basically, context part is dropped when pinned corners are being located. And when you have that info, you add context data that doesn’t modify the width, therefore when/if the next iteration happens, it will retrieve the same X coordinates as before and the layout will converge. The only thing that will change is the cell height.

1 Like

The non-expanding cell hack seems cool, but it’s not too suitable for my purposes as I’d have to copy that code into every table I generate (I want those snippets to work standalone, without having users paste additional preambles into their documents).

I’ll probably go with the simple measure workaround, that’s also pretty simple to understand and I didn’t know Typst offered such a simple way to hook into the layout resolution process.

Thank you for all your suggestions :)

Wouldn’t any workaround have to be copied into every table? If the API is just Julia code, then there is no difference what workaround code is used in the backend. You will have to use a custom function either way.

The place in the source code.

Yes sure, but I’d rather keep the output as short and humanly readable as I can, in case a person does want to tweak it later. So if over half of a table is setup for the footnote hack, that’s not great even if the render is fine. But it’s good to have the option, I’ll see what I converge on