How do I make an inline heading that's also sticky when immediately followed by a subheading?

I’ve used #box to create inline/run-in headings (see below), but doing so orphans the heading if it’s at the end of the page and doesn’t have inline content (which in my use case only happens when there’s a subordinate heading).

#show heading.where(level: 2): it => {
  box(it) + h(1em)
}

= H1

== H2
#lorem(600)

== H2 // this one is orphaned at the bottom of the page

=== H3
#lorem(10)

Maybe there’s a different way to do inline headings that doesn’t have this side-effect, but I haven’t been able to figure it out. It seems like being sticky requires being a block, but being inline requires not being a block. (Manually inserting page breaks or the like isn’t a good solution for me because my use case is a template for long union contracts, which undergo a ton of edits in the course of negotiations.) Any assistance would be much appreciated!

I’m pretty sure you can’t, because that’s 2 opposite things. block.sticky is only for block heading while run-in heading is inline, hence without block.sticky. So you have to get creative with some show rules.

One way, though at first kinda makes sense, in practice was very tricky. Yet for this example it kinda works in the end.

#import "@preview/scaffolder:0.2.1": get-page-margins

// #show heading.where(level: 2): it => it.body + h(1em, weak: true)
#show heading.where(level: 2): it => {
  let marked = state("marked", ())
  let next-h3 = query(heading.where(level: 3).after(here()))
  // On the same page one right under the other.
  let h3-same = next-h3.filter(h => (
    h.location().page() == here().page()
      and h.location().position().y - here().position().y < 1cm
  ))
  // On the adjacent pages one right under the other.
  let h3-diff = next-h3.filter(h => (
    h.location().page() - here().page() == 1
      and h.location().position().y - get-page-margins().top < 1cm
      and page.height - here().position().y - get-page-margins().bottom < 1cm
  ))
  if h3-diff.len() != 0 or h3-same.len() != 0 { return it }
  it.body + h(1em, weak: true)
}

= H1
== H2.1
#lorem(600)
== H2.2 // this one is orphaned at the bottom of the page
=== H3
#lorem(10)

The second one would be an “everything” show rule instead of heading one. It will probably be even more hacky and messy, but in theory should be more reliable, because you won’t rely on approximate distance and re-layout shenanigans, and instead use in-syntax-tree adjacency. Here is the (almost) bear-bone idea:

#let run-in-heading(it) = it.body + h(1em, weak: true)

#show: doc => context {
  let space = [ ].func()
  let adjacent-h2-index
  let h2-indeces = ()
  let are-sticky = ()
  for (i, child) in doc.children.enumerate() {
    if child.func() == heading {
      let level = child.depth + child.at("offset", default: heading.offset)
      if level == 2 { h2-indeces.push(i) }
      if level == 3 and adjacent-h2-index != none {
        are-sticky.push(adjacent-h2-index)
      }
      adjacent-h2-index = if level == 2 { i } else { none }
    } else if child.func() not in (space, parbreak) {
      adjacent-h2-index = none
    }
  }
  let children = doc.children
  // Or do the opposite with a heading show rule:
  // for i in are-sticky { children.at(i) = block(sticky: true, children.at(i)) }
  for i in h2-indeces.filter(x => x not in are-sticky) {
    children.at(i) = run-in-heading(children.at(i))
  }
  children.join()
}

= H1
== H2.1
#lorem(600)
== H2.2 // this one is orphaned at the bottom of the page
=== H3
#lorem(10)
2 Likes

This is very helpful—thank you!

I think conceptually the second route makes the most sense to me as a solution: “sticky” and “run-in” are mutually exclusive states, so for each heading we assign one state or the other based on whether it’s followed by a child heading.

I don’t understand all the details in your answer, so I’ll have to figure out how they work to tweak it. One oddity is that when I compile it the formatting for the heading goes away whenever run-in-heading applies. That’s fixable by adding some formatting to the function, something like this:

#let run-in-heading(it) = {
  set text(size: 1.2em, weight: "bold")
  it.body + h(1em, weak: true)
}

The challenge I’ll need to figure out (which was not in my original example because I was trying to keep it simple) is that I want the level 3 headings to generally be run-in as well, and their formatting is a little different. Maybe the solution is two formulae, one for run-in level 2 headings and another for run-in level 3 headings? I’ll post whatever I come up with, which hopefully will be more elegant than that!

1 Like

Right, because the styling is outside of the heading show rule and it’s no longer a heading.

#let run-in-h2 = state("marked-h2", ())
#show heading.where(level: 2): it => {
  if it.body not in run-in-h2.get() { return it }
  it.body + h(1em, weak: true)
}

#show: doc => context {
  let space = [ ].func()
  let adjacent-h2-index
  let h2-indeces = ()
  let are-sticky = ()
  for (i, child) in doc.children.enumerate() {
    if child.func() == heading {
      let level = child.depth + child.at("offset", default: heading.offset)
      if level == 2 { h2-indeces.push(i) }
      if level == 3 and adjacent-h2-index != none {
        are-sticky.push(adjacent-h2-index)
      }
      adjacent-h2-index = if level == 2 { i } else { none }
    } else if child.func() not in (space, parbreak) {
      adjacent-h2-index = none
    }
  }
  let children = doc.children
  for i in h2-indeces.filter(x => x not in are-sticky) {
    run-in-h2.update(x => x + (children.at(i).body,))
  }
  children.join()
}

= H1
== H2.1
#lorem(600)
== H2.2 // this one is orphaned at the bottom of the page
=== H3
#lorem(10)

Are both levels run-in? I don’t understand how both levels supposed to interact.

1 Like

That solves the heading formatting problem very nicely!

As for the level 3 headings, the intended result looks like the result of this (but solving the orphan problem):

#import "@preview/numbly:0.1.0": numbly
#set heading(numbering: numbly(
  "Article {1:1}",
  "{1:1}.{2:1}",
  "{3:A}.",
))
#show heading.where(level: 2): it => {
  box(it) + h(1em)
}
#show heading.where(level: 3): it => {
  box(it) + h(1em)
}

= Grievance and Arbitration

== General Considerations
#lorem(50)

#lorem(50)

== Procedure

=== Informal Resolution
#lorem(30)

=== 
#lorem(30)

=== Appeal to Arbitration

+ #lorem(12)
+ #lorem(14)
+ #lorem(7)

So, generally, both level 2 and level 3 headings are inline (sometimes with a heading body, sometimes without), but sometimes not inline (achieved here by just leaving a blank line in the content markup) when there’s an enum or something else that doesn’t look right run into the heading.

In some ways, this kind of thing would be more easily formatted using enums instead of level 2 and 3 headings. The problem is that union contracts are full of cross-references that you want to automatically update using labels, which you can’t do with an enum item but you can do with a heading. (Also, you want to be able to list the level 2 headings in the table of contents.)

#import "@preview/numbly:0.1.0": numbly
#set heading(numbering: numbly(
  "Article {1:1}",
  "{1:1}.{2:1}",
  "{3:A}.",
))
// #show heading.where(level: 2): it => box(it) + h(1em, weak: true)
// #show heading.where(level: 3): it => box(it) + h(1em, weak: true)

#let run-in-h = state("marked-h", ())
#let run-in-heading = it => {
  if it.body not in run-in-h.get() { return it }
  box(it) + h(1em, weak: true)
}
#show heading.where(level: 2): run-in-heading
#show heading.where(level: 3): run-in-heading

#show: doc => context {
  let space = [ ].func()
  let adjacent-h2-index
  let adjacent-h3-index
  let h-indeces = ()
  let are-sticky = ()
  for (i, child) in doc.children.enumerate() {
    if child.func() == heading {
      let level = child.depth + child.at("offset", default: heading.offset)
      if level in (2, 3) { h-indeces.push(i) }
      if level == 3 and adjacent-h2-index != none {
        are-sticky.push(adjacent-h2-index)
      }
      adjacent-h2-index = if level == 2 { i }
      adjacent-h3-index = if level == 3 { i }
    } else if child.func() not in (space, parbreak) {
      if adjacent-h3-index != none and child.func() in (enum,) {
        are-sticky.push(adjacent-h3-index)
      }
      adjacent-h2-index = none
      adjacent-h3-index = none
    }
  }
  let children = doc.children
  for i in h-indeces.filter(x => x not in are-sticky) {
    run-in-h.update(x => x + (children.at(i).body,))
  }
  children.join()
}

// = H1
// == H2.1
// #lorem(600)
// == H2.2 // this one is orphaned at the bottom of the page
// === H3
// #lorem(10)

= Grievance and Arbitration

== General Considerations
#lorem(50)

#lorem(50)

== Procedure

=== Informal Resolution
#lorem(30)

===
#lorem(30)

=== Appeal to Arbitration
+ #lorem(12)
+ #lorem(14)
+ #lorem(7)

1 Like