How to combine heading and enum counters?

I want to use Typst to write simple legal documents like non-profit organization statutes. I would like to write something like this

/* ... #show/#set magic ... */

#title()

// unnumbered
= Preamble

#lorem(5)

// numbered
= Heading of section I

+ #lorem(5)
+ #lorem(5)
  + #lorem(5) <a>
  + #lorem(5)
+ #lorem(5)
+ ... as defined in @a, and @b

// numbered
= Heading of section II

+ #lorem(5) <b>
+ #lorem(5)

in order to get a result like this (mocked in libreoffice)

I no problem with modifying the heading to show the “Section …” numbering, I can probably figure out how to make the Preamble section unnumbered, but I have been unable to work out how to make the cross-references work such that they combine the section number and the enum item with proper nesting. I got close using @preview/itemize:0.2.0 (which solves referencing enum items which is not possible in vanilla typst), but I was unable to do the last step of combining with the section counter. Any tips on how to do that?

Hi. Since in the public API you cannot get the position of the reference enum item when customizing the numbering, you have 3 options:

  • Ask author to add this feature in some way
  • Copy the source code and modify the logic
  • Copy the minimal code and hope it works
Copy the whole thing (around 170 LoC)
#import "@preview/itemize:0.2.0": config, default-enum

#set heading(numbering: "I")
#set enum(numbering: "1.a.")
#show: default-enum
#show <unnumbered>: set heading(numbering: none)

#let enum-label-ID = "__cdl_enum-label-ID__"
#let curr-parent-level = state("__enum-parent-number__", ())
#let enum-numbering = state("__enum-numbering__", (
  numbering: "1.1.1.1.1.",
  full: false,
))
#let curr-base-parent-level = state("__curr-base-parent-level__", ())

// https://github.com/tianyi-smile/itemize/blob/main/src/util/numbering.typ
#let numbering-kind-from-char(c) = {
  let numberings = (
    "1",
    "a",
    "A",
    "i",
    "I",
    "α",
    "Α",
    "*",
    "א",
    "一",
    "壹",
    "あ",
    "い",
    "ア",
    "イ",
    "ㄱ",
    "가",
    "\u{0661}",
    "\u{06F1}",
    "\u{0967}",
    "\u{09E7}",
    "\u{0995}",
    "①",
    "⓵",
  )
  if c in numberings { c }
}

#let numbering-pattern-from-str(pattern) = {
  let pieces = ()
  let handled = 0
  let pattern-to-codepoints = pattern.codepoints()
  for (i, c) in pattern-to-codepoints.enumerate() {
    let kind = numbering-kind-from-char(c)
    if kind == none { continue }
    let prefix = pattern-to-codepoints.slice(handled, i).join()
    pieces.push((prefix, kind))
    handled = 1 + i
  }

  let suffix = pattern-to-codepoints.slice(handled).join()
  if pieces.len() == 0 {
    panic("invalid numbering pattern")
  }
  (pieces: pieces, suffix: suffix, trimmed: false)
}

#let apply-numbering-kth(numbering, k, number) = {
  let fmt = ""
  let self = numbering-pattern-from-str(numbering)
  if self.pieces.len() > 0 {
    let (prefix, _) = self.pieces.first()
    fmt += prefix
    let (_, kind) = if k < self.pieces.len() {
      self.pieces.at(k)
    } else {
      self.pieces.last()
    }
    fmt += std.numbering(kind, number)
  }
  fmt += self.suffix
  return fmt
}

#let ref-enum(
  it,
  full: auto,
  numbering: auto,
  supplement: auto,
  no-label-warning: false,
) = {
  let el = it.element
  if el != none {
    if (
      el.func() == text
        or el.func() == enum.item
        or el.func() == metadata and el.value == enum-label-ID
    ) {
      let enum-count = curr-parent-level.at(it.target)
      if enum-count.len() > 0 {
        let supplement-body = if it.supplement == auto {
          if supplement not in (auto, [], none) {
            [#supplement#h(0em, weak: true)~]
          }
        } else {
          [#it.supplement#h(0em, weak: true)~]
        }
        let number-body = if numbering != auto {
          let _full = full
          let _numbering = numbering
          let (
            numbering,
            full,
            auto-base-level,
            curr-enum-level,
          ) = enum-numbering.at(it.target)
          if auto-base-level {
            if (_full == auto and full) or (_full == true) {
              let base-num-count = curr-base-parent-level.at(it.target)
              [#std.numbering(_numbering, ..base-num-count)]
            } else {
              [#apply-numbering-kth(
                _numbering,
                curr-enum-level,
                enum-count.last(),
              )]
            }
          } else {
            if (_full == auto and full) or (_full == true) {
              [#std.numbering(_numbering, ..enum-count)]
            } else {
              [#apply-numbering-kth(
                _numbering,
                enum-count.len() - 1,
                enum-count.last(),
              )]
            }
          }
        } else {
          let _full = full
          let (
            numbering,
            full,
            auto-base-level,
            curr-enum-level,
          ) = enum-numbering.at(it.target)
          if auto-base-level {
            if (_full == auto and full) or (_full == true) {
              let base-num-count = curr-base-parent-level.at(it.target)
              [#std.numbering(numbering, ..base-num-count)]
            } else {
              [#apply-numbering-kth(
                numbering,
                curr-enum-level,
                enum-count.last(),
              )]
            }
          } else {
            if (_full == auto and full) or (_full == true) {
              let last-heading = counter(heading).at(el.location()).first()
              [#std.numbering(
                heading.numbering.first() + "." + numbering,
                last-heading,
                ..enum-count,
              )]
            } else {
              [#apply-numbering-kth(
                numbering,
                enum-count.len() - 1,
                enum-count.last(),
              )]
            }
          }
        }
        link(el.location(), [#supplement-body#number-body])
      } else {
        it
      }
    } else {
      it
    }
  } else {
    if no-label-warning [#text(weight: "bold", fill: red)[[???]]] else { it }
  }
}

#show ref: ref-enum.with(full: true)

#set document(title: [Title])
/* ... #show/#set magic ... */

#title()

// unnumbered
= Preamble <unnumbered>

#lorem(5)

// numbered
= Heading of section I

+ #lorem(5)
+ #lorem(5)
  + #lorem(5) <a>
  + #lorem(5)
+ #lorem(5)
+ ... as defined in @a, and @b

// numbered
= Heading of section II

+ #lorem(5) <b>
+ #lorem(5)

Or a minimal solution (17 LoC):

#import "@preview/itemize:0.2.0": config, default-enum

#set heading(numbering: "I")
#set enum(numbering: "1.a.")
#show: default-enum
#show ref: it => {
  let curr-parent-level = state("__enum-parent-number__", ())
  let default = (numbering: "1.1.1.1.1.", full: false)
  let enum-numbering = state("__enum-numbering__", default)
  let enum-label-ID = "__cdl_enum-label-ID__"
  if it.element == none { return it }
  let el = it.element
  let is-metadata = el.func() == metadata and el.value == enum-label-ID
  if el.func() not in (text, enum.item) and not is-metadata { return it }
  let enum-count = curr-parent-level.at(it.target)
  if enum-count.len() == 0 { return it }
  let (numbering, ..) = enum-numbering.at(it.target)
  let last-section = counter(heading).at(el.location()).first()
  let numbering = heading.numbering.first() + "." + numbering
  let num = std.numbering(numbering, last-section, ..enum-count)
  link(el.location(), num)
}
#show <unnumbered>: set heading(numbering: none)

#set document(title: [Title])
/* ... #show/#set magic ... */

#title()

// unnumbered
= Preamble <unnumbered>

#lorem(5)

// numbered
= Heading of section I

+ #lorem(5)
+ #lorem(5)
  + #lorem(5) <a>
  + #lorem(5)
+ #lorem(5)
+ ... as defined in @a, and @b

// numbered
= Heading of section II

+ #lorem(5) <b>
+ #lorem(5)