How to apply titlecase to headings in Document Outline?

The whole point is to remove unnecessary manual labor and automate as much as possible, making a document as perfect as possible. This would defeat the purpose (and kind of use of Typst). What are the alternatives?

Why exactly? The one and only concern is that it might interfere with AT, but I don’t see a single mention of none in Accessibility – Typst Documentation. I guess wrapping it in pdf.artifact should seal the deal? But since it’s none, will it actually do anything?

My initial concern was something like Bookmarks are displaying at the correct depth only in the Typst app but not in other PDF readers. · Issue #5615 · typst/typst · GitHub, where something would break. However, so far I don’t see a single problem with this. Well, actually there is one, but it’s only because of titlecase and it’s fixable by excluding the letter numberings:

#let titlecase-only-name = it => {
  let limit = 4
  show regex(".{" + str(limit) + ",}"): it => {
    if it.text.match(regex("^[IVXLCDM]\\.[a-z]\\)$")) != none { return it }
    string-to-titlecase(it.text)
  }
  it
}

// #show heading: titlecase
#show heading: titlecase-only-name
#set heading(numbering: "I.a)")

The solution was not what entirely what I expected. Recreating headings without Replacing show rules · Issue #7165 · typst/typst · GitHub would probably introduce a lot of issues because of the duplication. But this is more sane, because the document is basically untouched, however the invisible duplicates are still created, but only for the outline. Moreover, the show rule is not destructive, so you can stack it with other show rules however you like.

However, the biggest flaw is the discrimination point. We want to have numbering, and better to automate it. But numbering is none to be able to break out of the inception. Thankfully, heading has a ton of fields and for the purpose of text-only outline, you can use almost all of them instead of numbering. And the problem here is that with automating numbering comes automation of a counter, so you either fix/reimplement numbering + spacing, or fix/unstep the counter. Apart from just feeling wrong, the reimplementation of numbering + space is probably affecting the AT, though…maybe not, since it’s probably all text anyway (in the outline). Anyway, I chose to combat auto counter step with a simple unstep, and here is the result:

#set heading(bookmarked: false)
#show heading.where(outlined: true): it => {
  if it.hanging-indent == 1pt * float.inf { return it }
  let (body, ..settings) = it.fields()
  let _ = settings.remove("label", default: none)
  it
  show heading: none
  counter(heading).update(unstep)
  heading(
    ..settings,
    outlined: false,
    bookmarked: true,
    hanging-indent: 1pt * float.inf,
    modify-heading-name(get-text(body)),
  )
}

Full example:

#import "@preview/titleize:0.1.1": string-to-titlecase, titlecase


// Opposite of counter.step. Use as `counter.update(unstep)`.
#let unstep(..n) = {
  let (..rest, last) = n.pos()
  (..rest, calc.max(0, last - 1))
}

#let modify-heading-name = string-to-titlecase // Anything else.
#let get-text = it => it.text // Use t4t package or something better.

#set heading(bookmarked: false)
#show heading.where(outlined: true): it => {
  if it.hanging-indent == 1pt * float.inf { return it }
  let (body, ..settings) = it.fields()
  let _ = settings.remove("label", default: none)
  it
  show heading: none
  counter(heading).update(unstep)
  heading(
    ..settings,
    outlined: false,
    bookmarked: true,
    hanging-indent: 1pt * float.inf,
    modify-heading-name(get-text(body)),
  )
}

#let titlecase-only-name = it => {
  let limit = 4
  show regex(".{" + str(limit) + ",}"): it => {
    if it.text.match(regex("^[IVXLCDM]\\.[a-z]\\)$")) != none { return it }
    string-to-titlecase(it.text)
  }
  it
}

#show heading: titlecase-only-name
#set heading(numbering: "I.a)")

#outline()

= simple heading

== simple heading2

= simple heading3

Your example showed “Contents” in the outline, which is not the default behavior, hence the show heading.where(outlined: true).

The in-document outline can be additionally fixed with something like this:

#show outline.entry: it => {
  if it.element.func() != heading { return it }
  show: titlecase-only-name
  it
}

The only side effect that is present is any kind of query(heading) is affected by this and the duplicates can break something. But I think heading.where(outlined: true) or similar should fix that.

Overall, I like the semi-hacky idea, because it’s actually robust (with invisible side effects), but it’s still undeniably hacky and advanced, Typst must have something user-friendly and native-like.

1 Like