How can I measure a table cell size to draw a vertical line as long as the cell height?

Hi,

I am trying to use a #table to create timeline looking content in my document. The first column in the table indicates the year and the second column includes content of varying lengths.

I want to be able to draw a line from the bottom of the year text in one cell (e.g., 2025) to the bottom of the cell (i.e., up until the start of the next year cell, 2024) to create the effect of a timeline.

I can’t seem to get the table.cell size because it isn’t working as desired.

Here are my two attempts so far:

#set text(size: 12pt)

// Does not work as desired
#table(
  columns: (1fr, 10fr),
  stroke: none,
  [2025 #rotate(90deg)[#line(length: 100%)]], lorem(100),
  [2024], lorem(100)
)

#pagebreak()

// Does not work as desired
#table(
  columns: (1fr, 10fr),
  stroke: none,
  [2025 #layout(size => [
    #rotate(90deg)[
      #line(length: size.height)
    ]
  ])], lorem(100),
  [2024], lorem(100)
)

I presume I am using layout() or measure() incorrectly. Any advice is much appreciated.

Hi. You can save position of each year and then draw lines between them:

#let get-margin(side) = {
  if page.margin == auto or page.margin.at(repr(side)) == auto {
    calc.min(page.height, page.width) * 2.5 / 21
  } else {
    page.margin.at(repr(side))
  }
}

#let timeline(year) = [#year#metadata(none)<timeline>]

#show table: it => context {
  let selector = here => selector(<timeline>).before(here)
  let init-marks = query(selector(here()))
  let before = here()
  it
  context {
    let marks = query(selector(here()))
    if init-marks.len() == marks.len() { return }
    let selector = std.selector(<timeline>).after(before).before(here())
    let marks = query(selector).map(x => x.location().position())
    let left-margin = get-margin(left)
    let top-margin = get-margin(top)
    let year-width = measure[2000].width
    for (a, b) in marks.windows(2) {
      place(
        top + left,
        dx: a.x - left-margin + year-width / 2,
        dy: a.y - top-margin,
        line(length: b.y - a.y - 0.6em, stroke: green + 1mm, angle: 90deg),
      )
    }
  }
}

#set text(size: 12pt)

#table(
  columns: (1fr, 10fr),
  stroke: none,
  timeline[2025], lorem(100),
  timeline[2024], lorem(50),
  timeline[2024], lorem(20),
)

#table(
  columns: (1fr, 10fr),
  stroke: none,
  timeline[1999], lorem(10),
  timeline[2010], lorem(10),
  timeline[2077], lorem(10),
)

The get-margin is used from How to right annotate a span of list items with a brace and text? - #2 by Andrew.

I think this post is also extremely relevant:

Thanks @Andrew for your solution. It is going to take me some time to work through your solution and make sure I actually understand it.

And thanks @SillyFreak for sharing that thread.

I’ll add this GitHub Issue to the mix so that if anyone ends up here it will give them some resources to turn to: How to measure a cell width and height in a table or a grid ? · typst/typst · Discussion #3943 · GitHub

I admit I’m not a Typst expert (yet?), but all of these solutions seem so complex compared to what I thought was doable with the functionality I knew about. Oh well. Time to learn more, I guess.

So, the problem is that "Real" absolute option for `place` (or alternative function) · Issue #5233 · typst/typst · GitHub is not implemented, so you need a bunch of relative lengths for place to work correctly. And for multiple tables to be usable, you need to check for metadata that was before the table and after it, which requires 2 contexts. Then just extract the locations from metadata for this table and iterate over all pairs to draw all lines.

The table cells don’t have a fixed size by default, which is why you can’t just use layout. The random lines from two random points in a table is not a part of a table API in any document/word processor, so it needs some amount of complexity to implement exactly what you need. The good part is that Typst syntax is readable, and here you can achieve exactly what you want. Automatically.

1 Like

After taking some time to read through your solution I am starting wrap my head around it. One thing I noted is that it breaks if the table spans across multiple pages.

I guess I’m not sure what the desired behaviour should be in that situation…

But I don’t yet understand your code well enough to know if this is expected or if it can be resolved.

Example code:

#table(
  columns: (1fr, 12fr),
  stroke: none,
  timeline[2025], table.cell()[
    + #lorem(40)
    + #lorem(40)

  ],
  timeline[2024], table.cell()[
    + #lorem(40)
    + #lorem(40)
  ],
  timeline(""),[]
)

#table(
  columns: (1fr, 12fr),
  stroke: none,
  timeline[2025], table.cell()[
    + #lorem(40)
    + #lorem(40)
  ],
  timeline[2024], table.cell()[
    + #lorem(40)
    + #lorem(40)
    + #lorem(40)
    + #lorem(40)
    + #lorem(40)
    + #lorem(40)
    + #lorem(40)
  ],
  timeline(""),[]
)

In addition to "Real" absolute option for `place` (or alternative function) · Issue #5233 · typst/typst · GitHub, Add `page` parameter for `place` function · Issue #2248 · typst/typst · GitHub + Allow adding content / configuring breakpoints of broken blocks · Issue #735 · typst/typst · GitHub restrict from having a native way to handle multi-page/cross-page styling/elements. I think in theory it is still should be possible, but the hack level will drastically increase.

A solution using grid strokes instead of trying to measure the grid and add lines could look like this:

#let row(year, body, last: false) = (
  grid.cell(colspan: 2, align: center, year),
  grid.cell(rowspan: 2, inset: (bottom: 4pt), body),
  grid.cell(stroke: if not last { (right: 1pt) }, none),
  none,
)

// Does not work as desired
#grid(
  columns: (0.5fr, 0.5fr, 10fr),
  row-gutter: 6pt,
  ..row([2025], lorem(100)),
  ..row([2024], lorem(100), last: true),
)

Basically I split the 1fr column into two 0.5fr columns, so that I can add a stroke in the middle. To make the stroke as high as the leftover space from the main body, the main body goes into a cell with rowspan: 2.

The last cell gets all the leftover space, so this works because the stroke is added at the bottom. It wouldn’t work if you wanted the year at the bottom and stroke at the top.

5 Likes

Wow, cool solution @SillyFreak. That seems to works real nicely.