How to pagebreak before an heading, only if a certain condition is achieved?

Hi !
New user of Typst, so far it’s sooo good. I really wish it was a thing when I was going through uni ahah.


I’m trying to add a pagebreak before every heading(level: 1), but only if they are below half the page.

Adding a pagebreak is easy with :

show heading.where(level: 1, outlined: true): it => {
	pagebreak()
	it
}

But when trying to add a little logic, I keep getting redundancy errors.

show heading.where(level: 1, outlined: true): it => {
	if it.location().position().y > page.height * .5 {
		pagebreak()
	}
	it
}

And I think I understand why (feel free to explain either way :)) ), but what I don’t understand is :

  • Why adding the line v(0pt) above the pagebreak fixes the redundancy error ? (afterward, the outline is garbage, containing the wrong page number)
  • How can I fix this issue without a hacky trick, if it’s even possible.

About the solution I chose
I choose another question as the solution (this one from @Xodarap), since they are both extremely related and @Xodarap received answers are interesting and a good way to learn more how Typst work.

2 Likes

Hello and welcome!

If your error is

warning: layout did not converge within 5 attempts
 = hint: check if any states or queries are updating themselves

This is normal, do not panic.

Let’s start with a simple question: if your heading was to be before the pagebreak, do you think you would get the same error? The answer is no.

What you wrote diverges in an infinite loop:

  • You add a pagebreak if the heading is halfway through the page by comparing to the heading element’s y position
  • You add the heading itself.
  • Backtracking, the heading’s position has … changed!
  • Typst tries to resolve this layout, but cannot do it within 5 attempts, hence the error, and you probably don’t have the result you hoped for.

Now, how do you actually write this condition? You have to use an element that does not move with your pagebreak. The simplest way is to add a metadata element. By querying that element’s position instead, you make sure you don’t have any “loops” :slight_smile:

#show heading.where(level: 1, outlined: true): it => {
  let h1 = query(metadata.where(value: "foo").before(here()))  
  if h1.len() != 0 and h1.first().location().position().y > page.height * .5 {
    pagebreak()
  }
  it
}

= A

#lorem(500)

#metadata("foo")
= B

For additional reading, you might want to take a look at the following threads:

1 Like

Thank you so much for your answer.
So I did understand correctly why the warning was shown.

Sadly, that mean I cannot achieve what I want without a manual intervention ?
If the only way to pagebreak after mid-page is to use a metadata tag before every heading, I do think the best solution is to just write the whole document and manually add pagebreak at the end.

1 Like

I think the point in this case is that where is here() exactly calculated. I’m not sure since I did not read the source code, but I made an alternative my-location to make it work:

#let my-location = state("my-location", (:))

#let smartbreak(percentage) = (
  context {
    if my-location.get().y > page.height * (100% - percentage) {
      pagebreak()
    }
  }
)

#show heading.where(level: 1, outlined: true): it => {
  context my-location.update(here().position())
  smartbreak(50%)
  it
}

And the outline should be working properly using this impl.

However the compiler (Tinymist) is still telling me the layout does not converge in 5 attempts on may laptop, but not on another device. This is kinda weird and I’m not sure is this an issue about Tinymist or Typst. :grin:

3 Likes

I am very interested why this solution works and the original in the question does not.

Why doesn't this work?
#show heading.where(level: 1, outlined: true): it => {
  context if here().position().y > page.height * 50% {
    pagebreak()
  }
  it
}

They seem very similar. The only difference is that the original uses it.location() and this one uses here() and a state. I think an explanation of this phenomenon would provide valuable insights about typst’s location handling or probably the state machinery.

EDIT: or maybe I just don’t understand show rules.

1 Like

I think you have the correct understanding of show rules. @sjfhsjfh (thanks!) is using an independent state variable and updating it outside the context expression, which is probably why it works. It “forces” the hand of the compiler to get the correct location in between the steps of getting the page height.

As the original question is actually solved, but I have still open questions about why it works or rather why not, I opened a new question for this: Why do I need state for some (here some specific) show rules?

1 Like
#let my-location = state("my-location", (:))

#let smartbreak(percentage) = (
  context {
    if my-location.get().y > page.height * (100% - percentage) {
      pagebreak()
    }
  }
)

#show heading.where(level: 1, outlined: true): it => {
  context my-location.update(here().position())
  smartbreak(50%)
  it
}

= flora
#lorem(400)

= fauna
#lorem(400)

= ergo
#lorem(400)

This example shows that @sjfhsjfh 's solutions actually doesn’t work. It was just coincidence that it is sufficient for the above example. (the online editor gives me the same warning, layout does not converge.)

@quachpas No offense, but this indicates that we both don’t really understand what is going on.

2 Likes

@Nasmevka a solution has finally been found :slight_smile:

Credits go to @quachpas and @jbirnick

I would recommend the following commands in your file:

// threshold can be adjusted
#let threshold = 50%
// unique string so that you don't need to worry that other calls to metadata might interfere with this functionality
#let abc = "yfadslfkjlafjkwhtoii"

#show heading: it => {
  metadata(abc)
  context {
    let m = query(metadata.where(value: abc).before(here())).last()
    if m.location().position().y > page.height * threshold {
      pagebreak()
    }
  }
  it
}

That was a really nice question. Many lessons learned :)

3 Likes

It does work, the layout converges but not in 5 attempts. I guess it’s better to have a compile option to specify the maximum attempts. BTW, your solution generates the same warning as the count of heading increases.

2 Likes

Yeah good point, the number of iterations that this needs grows linearly with the number of headings. That’s very bad, making it unusable in practice.

It’s because say in iteration 2 one heading gets fixed. This moves all the content which comes later. So it introduces new bad headings. But those can only be fixed in iteration 3, and moving them introduces new bad headings, which can only be fixed in iteration 4, and so on.

Unfortunately, I think this problem is unavoidable with the current layout engine. (Because with context you can only ever look at past iterations, never the current iteration, so you can basically only fix one heading per iteration.)

You can decrease the number of iterations needed by:

  • making the threshold closer to 100% (because then less headings need to be fixed) (EDIT: actually this might not be fully accurate)
  • having headings on e.g. the highest level always make a pagebreak, so that fixing the lower level headings basically only moves the subsequent content within the current chapter (and then make the chapters not too long)
1 Like

This is a perfect example where a “linear” layout engine (like I think TeX employs it, maybe) that goes through the document from “start to finish” would be desirable. I think your heading thing is easily implementable in TeX with only a single iteration basically. A similar application are counters, a topic that I thought about a lot.

But Typst layout engine works differently, and also has advantages, as Laurenz was pointing out to me. I hope to understand it at some point and then I can maybe also see if it’s possible to find a solution to such problems.

2 Likes

How do you know that it works, but not in 5 attempts? Have you tried it with more attempts? (How do you do that :))

I’m sorry if my accusation was wrong and your solution actually does work. My reasoning for that is bsed on @jbirnick’s solution from the other topic: Why do I need state for some (here some specific) show rules? - #2 by jbirnick

The way I understood it, it made a lot of sense to me. But maybe your solution actually works and (I understood it wrong or @jbirnick is just wrong).

Would be nice to have that clarified as well, but as it seems to me we’d need a compilation with more than 5 attempts for that.

Typst is open-source so you just simply modify the limit and compile a custom one. I’m pretty sure this problem cannot be avoided since the requirement requires multiple iterations to work. Adding a CLI arg would be convenient for such demand.

2 Likes

If anyone is interested, here is a “natural” document which doesn’t converge in 5 iterations:

// threshold can be adjusted
#let threshold = 50%
// unique string so that you don't need to worry that other calls to metadata might interfere with this functionality
#let abc = "yfadslfkjlafjkwhtoii"

#show heading: it => {
  metadata(abc)
  context {
    let m = query(metadata.where(value: abc).before(here())).last()
    if m.location().position().y > page.height * threshold {
      pagebreak()
    }
  }
  it
}

#let parlengths = (597, 557, 560, 464, 676, 622, 241, 621, 627, 487, 234, 616, 536)

#for length in parlengths {
  heading("some heading")
  lorem(length)
}
1 Like

Thank you everyone for all those insights !
I’m sad there isn’t a “final” (and by that I mean working with every document) solution but will mark the metadata trick as the solution for now.

Thanks again !

As it doesn’t seem possible (yet) to do that with a conditional pagebreak, I propose a very different solution, whose idea originally came from @PgBiel as a workaround for when sticky blocks weren’t a thing yet.

#show heading: it => {
  let threshold = 50%
  block(breakable: false, height: threshold, spacing: 0pt)
  v(-threshold)
  it
}

The block basically reserves the space that should be available to place the heading in. If that space isn’t available on the current page, it is moved to the next page. The negative vertical spacing then ensures that the block doesn’t actually take up the reserved space.

5 Likes