How can I write a function for custom heading numbering?

I am dabbling with Typst… one seemingly simple thing I wanted to do was to define a custom alternative to:

#set heading( numbering: "1." )

I wanted a different intermediate and final separator - and I wanted to fix the width of the numbered part of the headings - so the fist letter of the textual headings align horizontally throughout the document.

I supposed that I wanted to implement a function, to put in place of “1.” - and that it would need to iterate over the counters for the heading - and decorate those with the separators I choose using conditionals. I assumed I’d want to use a for loop to iterate over the hierarchical heading numbers. I found I could extract the current hierarchical heading numbers using:

#let labels= context(counter(heading).get()) 
#labels // Outputs an array - e.g. "(1,2,7)"

However, when I tried to iterate over the heading numbers in labels:

#for label in labels { /* Deal with each level separately */ }

I get the typst error: “cannot loop over content” I also note that while labels renders as an array of integers, typst does not let me call len on it to get its length… a hurdle to iterating over the heading numbers by index.

I assume there is something I’m overlooking? Is the sort of customisation I envisage supported by Typst?

I’ve read the numbering documentation - but this has left me with more questions than answers. Is one restricted to a unary function to provide custom hierarchical heading numbers?

The first thing to realize that comes to mind is Why is the value I receive from context always content? - #2 by laurmaedje

Also, you will find many example of numbering functions on this forum such as How to Create Independent Numbering Levels Inside a Custom Function or How to produce a heading numbering 1.0.0 as first level? .

If you give us an example of exactly what you are trying to achieve as a result, perhaps we can help.

For the code you posted (which is not using the correct formatting by the way. See here) you can see why you get a complaint about content and why len() didn’t work:

#let labels= context(counter(heading).get()) 
#labels\
#type(labels)\
#repr(labels)

The reason for this is as vmartel08 pointed out in their first link: everything returned by context is of type content.

If you instead check the type inside the context scope:

#context {
  let labels = counter(heading).get()
  [#labels]
  linebreak()
  [#type(labels)]
  linebreak()
  [#repr(labels)]
}

2 Likes

For different numbering separators you would modify the set rule, similar to: How can I change the heading numbering for headings of different levels differently?

About the heading numbering width, I think the easiest would be to rebuild the headings in a custom show rule, and maybe use a grid with a fixed size column for the number. The following answer shows the general approach on how to modify headings: How can I show the heading number after the heading itself? - #2 by janekfleper

Thanks for the reply vmartel08, I think I’ve mostly overcome the headings counter issue like this:

#let hierarchical-dot-fixed-heading-numbers = (label-width: 50pt) => {
  return (..labels) => {
    box(width: label-width, labels.pos().map(str).join("."))
  }
}
#set heading(
  numbering: hierarchical-dot-fixed-heading-numbers(label_width: 40pt)
)

I’m able to access the raw array when I accept it as an argument to my numbering formatting function. It’s not absolutely perfect… as I’d prefer the width of the box to be determined dynamically - for example as 12pt wider than the widest box content (numeric labelling) in the document. I’m not sure how that could be tackled.

Thanks to gezepi’s answer - I’ve now grasped that I need to use values like counter(heading).get() - c.f. the function calls themselves - inside the scope of a *context"… and I can now see how to do that in a variety of ways in which I want to dynamically create layout depending upon counters. I’ll play with it some more. :slight_smile:

I’m not sure I follow flokl’s answer. If the grid had a fixed size column for the numeric part of headings… then I’d still need to pick that width by hand. If I want the width to be dynamically set, from document content, then I’d need a way to calculate the length of the longest numeric-part of any heading. Can this be done using context() - or am I barking up the wrong tree?

Whether you create a box or use a grid, you’ll need a fixed width. You can query for the final heading number and measure its width like this:

#set heading(numbering: "1")

#context {
  let max-heading = counter(heading).final().first()
  let max-heading-width = measure([#max-heading]).width

  [The last heading is number #max-heading which has a width of #max-heading-width]
}
#pagebreak()

#for i in range(100) {heading([Heading #str(i)])}

image

2 Likes

I misinterpreted your level of knowledge and wanted to give you general pointers on where to start :smiley:

That’s an interesting snippet - it gets at least part-way there.

It looks as if it works if there are “100” labels - because “100” is extremely likely to be the widest (depending upon font, of course). Of course, if there were 111 items, it would likely fail as “100” would be wider than “100”. I can’t see a short-cut that would reliably eliminate needing to determine the maximum width for any of the labels. More experimentation required, I think.

In my thesis template, I handled that problem by putting the maximum width of actually observed (page) numbers into a state: typst-diploma-thesis/src/outline.typ at 34122acf831070255c59d42b93e9aff0e62d6252 · TGM-HIT/typst-diploma-thesis · GitHub

Another, probably less perfect approach, would be to loop through all page numbers and measuring them:

let max-width = calc.max(..range(1, 112).map(x => measure[#x].width))
1 Like

I feel like I’m missing something but it looks like it doesn’t matter. If you measure all numbers 0 to 1000, kerning isn’t taken into account.

Code that created the table
#set page("a6")
#set page(height: auto)

#let max-num = 1001

#context {
  let widths = (:)
  set text()
  for i in range(max-num) {
    let i-str = str(i)
    let to-measure = [#(i)]
    let w = measure(to-measure).width
    widths.insert(i-str, w)
  }
  
  // widths

  let grouped = (:)
  for kvp in widths.pairs() {
    let num = kvp.at(0)
    let width = str(repr(kvp.at(1)))
    
    if width not in grouped.keys() {
      grouped.insert(width, ())
    }

    grouped.at(width).push(num)
  }

  // grouped

  table(
    columns: 2,
    table.header([Measured Width], [Numbers with that width]),
    ..for kvp in grouped.pairs() {
      (kvp.at(0), [#kvp.at(1).first() to #kvp.at(1).last()])
    }.flatten()
  )
}

After Libertinus Serif and Fira code, I immediately found one that has some differences. I guess it’s indeed because of kerning.

#set page(width: auto, height: auto, margin: 1pt)
#set text(font: "Liberation Sans")

#let max-num = 1000

#context {
  let widths = (:)
  for i in range(max-num + 1) {
    widths.insert(str(i), repr(measure[#i].width))
  }

  let grouped = (:)
  for (num, width) in widths.pairs() {
    grouped.insert(width, grouped.at(width, default: ()) + (num,))
  }

  table(
    columns: 2,
    table.header([Measured Width], [Numbers with that width]),
    ..grouped
      .pairs()
      .map(((group, nums)) => (group, [#nums.first() to #nums.last()]))
      .flatten(),
  )
}

#set page(width: auto, height: auto, margin: 1pt)
#set text(font: "Liberation Sans")

#for i in range(10, 19) {
  block(stroke: 0.1pt, above: 0.2em)[#i]
}
#place(
  top + left,
  dx: 12.24pt,
  line(angle: 90deg, length: 8em, stroke: 0.2pt + red),
)