How to repeat column contents across pages when the row spans multiple pages?

#grid header and footer each support a repeat option. Is there a similar way to repeat a cell if a grid spans multiple pages?

I’m trying to do something like a column-based header, where the header text is in the first column (as opposed to the first row). I’d like column 1’s content to be repeated an subsequent pages if column 2’s content spans a page; e.g.:

#show heading: it => { rotate(-90deg, reflow:true, upper(it.body)) }
#grid(
   columns: (3%, auto),
   rows: (auto, auto),
   gutter: 10pt,

   [ = Some heading ],
   [ Some really long text that reflows over two pages. ],

   [ = Another heading ],
   [ More text. ]
)

Am I using the wrong structure? I don’t see a way of ensuring that a row has something in the first column on each page (except by manually breaking the content). Headers and footers, AFAICT, are always rows, so their repeat functions aren’t able to do this.

This question is very similar to How can I make text in table cells spanning two pages appear twice? ; that question is unanswered, and involves row spans, although any answer might work for my situation as well.

Is there a particular reason you are inserting headings inside a grid? While typst’s accessibility guidelines technically don’t say this isn’t ok, this feels very hacky espetially if you intend the same header to re-appear multiple times in the document (by e.g. wrapping it in grid.header), see Accessibility Guide - Typst Documentation

Maintaining semantics

To add correct semantic information for AT and repurposing to a file, Typst needs to know what semantic role each part of the file plays. For example, this means that a heading in a compiled PDF should not just be text that is large and bold. Instead, the file should contain the explicit information (known as a tag) that a particular text makes up a heading. A screen reader will then announce it as a heading and allow the user to navigate between headings.

If your intention is to re-display the previous heading on subsequent pages, have a look at hydra

I tried a workaround for this using layout introspection.

The basic idea is to let the grid paginate normally, mark the beginning and end of each logical row with invisible metadata, then use those physical page positions on the next layout iteration to repeat the first-column content on continuation pages.

Something like this:

#let page-top = 12mm

#set page(
  width: 12cm,
  height: 9cm,
  margin: (x: 12mm, y: page-top),

  foreground: context {
    let page-num = here().page()
    let starts = query(<repeat-col-start>)
    let ends = query(<repeat-col-end>)

    // Grid rows are sequential, so the only row that can
    // continue onto this page is the most recent one that
    // started on an earlier page.
    let lo = 0
    let hi = starts.len()

    while lo < hi {
      let mid = int((lo + hi) / 2)

      if starts.at(mid).location().page() < page-num {
        lo = mid + 1
      } else {
        hi = mid
      }
    }

    let idx = lo - 1

    if idx >= 0 and idx < ends.len() {
      let start = starts.at(idx)
      let end = ends.at(idx)

      if start.value.id == end.value.id {
        let start-pos = start.location().position()
        let end-pos = end.location().position()

        if (
          page-num > start-pos.page
          and page-num <= end-pos.page
        ) {
          place(
            top + left,
            dx: start-pos.x,
            dy: page-top,
            start.value.content,
          )
        }
      }
    }
  },
)

#set text(size: 8pt)

#let heading-cell(id, body) = [
  #metadata(
    (
      id: id,
      content: rotate(
        -90deg,
        reflow: true,
        text(weight: "bold", upper(body)),
      ),
    )
  )<repeat-col-start>

  #rotate(
    -90deg,
    reflow: true,
    text(weight: "bold", upper(body)),
  )
]

#let body-cell(id, body) = [
  #body
  #metadata((id: id))<repeat-col-end>
]

#grid(
  columns: (9mm, 1fr),
  rows: auto,
  gutter: 8pt,

  heading-cell(1, [Head 1]),
  body-cell(1, lorem(460)),

  heading-cell(2, [Head 2]),
  body-cell(2, lorem(85)),
)

The first-column cell itself still only exists once logically. The repeated copies on later pages are just overlays placed in the page foreground.

So if HEAD 1 starts on page 1 and its corresponding second-column cell ends on page 3, the result is roughly:

page 1: HEAD 1 + text
page 2: repeated HEAD 1 + continued text
page 3: repeated HEAD 1 + remaining text

I also did a few scaling tests because I was curious whether this kind of introspection would become expensive in a long document.

After optimizing the lookup so that each page only finds the one row that could be continuing, rather than scanning every row, the overhead was quite small in my synthetic tests:

25 rows   / 42 pages:
normal grid:     ~1.19 s
with workaround: ~1.26 s

200 rows / 334 pages:
normal grid:     ~3.27 s
with workaround: ~3.28 s

400 rows / 667 pages:
normal grid:     ~5.38 s
with workaround: ~5.72 s

So this seems reasonably scalable, at least for this simple sequential-grid case.

One important caveat: this is only a visual workaround. Typst still does not actually repeat the first cell as part of the fragmented grid row. The copy is placed afterwards based on introspected page positions. But for PDF output it seems to work quite well.

1 Like

Hrm. The out-of-context rendering of replies surprised me, so I deleted my response to @aarnent as my answer was basically “because I want to”.

@Sovichea, as I use typst only rarely (I author documents requiring more than ASCII markup only occasionally) I wasn’t even aware of the ability to embed Turing-complete code – that’s awesome, and thank you! I’ll definitely give it a shot.

I didn’t get to read your full reply, but perhaps let me clarify my final point in case it wasn’t clear: It sounds like you want to have your previous headings re-appear on subsequence pages, and there are ways of doing this that are better for both accessibility and ease of writing. If this is indeed what you want I can’t think of a good reason for doing things the way you propose, so rather than give you the solution to a potentially bad problem I would rather make sure we both understand what you are actually trying to solve.

2 Likes