My Home

2024-10-23

Check page for content

For my upcoming thesis I need to use a specific template provided by the university. As you can imagine this is provided for LaTeX only, and choosing to use anything else is frowned upon (because mostly this means people choosing to write their thesis in Word). Anyway, my guess is, that I can just recreate the template in Typst, and nobody will notice.

Here's the solution to an issue I had.

Chapters need to start on odd pages. This is easy to achieve with the newest release of typst (currently 0.12.0):

#show heading.where(level: 1): it => {
    pagebreak(weak: true, to: "odd");
    it
}

However, the template also features completely empty pages if there happens to be no content on them. No header, no footer, no pagenumbers.

If typst had the feature to track empty pages built-in, this would be the end of the story. As it doesn't, it is just the beginning.

The first step to the solution I found on a discussion on the github project page of typst. Its idea being to insert end-of-chapter and start-of-chapter markers into the page, and then counting them to check if the current page is empty or not. This is not trivial to think through, understand, and implement without bugs on edge-cases.

#show heading.where(level: 1): it => {
    place[#[] <end-of-chapter>]
    pagebreak(weak: true, to: "odd");
    place[#[] <start-of-chapter>]
    it
}

This works reasonably well. It however presents a two-fold caveat: typst will mark and link the first piece of content created inside a heading show rule as 'the header'. This means the automatic #outline() now shows and links to potentially wrong page numbers, which can of course be fixed by hand (albeit tediously). Two-fold, because this also affects the links created for the bookmarks that your pdf-viewer picks up. And these cannot be fixed by hand.

Which means that scrolling and clicking through your document will work as expected, unless you use the features of your pdf-viewer that are meant to make life easier for you. This is unacceptable.

Now that I understood the issue, it was finally relatively straight-forward to come up with the fix: instead of inserting labels (which count as content and are therefore problematic), just switch a state-variable! These do not insert content, but their value can be checked as a function of the location in the document.

#show heading.where(level: 1): it => {
    state("empty.switch").update(true);
    pagebreak(weak: true, to: "odd");
    state("empty.switch").update(false);
    it
}

The only thing that remains is to figure out which pages are actually empty. This can be done in each pages' header, as any content that would be on a page would come after the header. This happens again in two steps: checking if the page contains the beginning of a chapter heading (which means it is surely not empty), and then checking the state variable:

#set page(
    header: context {
        let this-page = here().page()
        let is-start = query(heading.where(level: 1))
                        .map( it => it.location().page())
                        .contains(this-page)
        if is-start { return }
        if state("empty.switch", true).get() {
            state("empty.pages", (0,)).update(it => {
                it.push(this-page)
                return it
            })
        }
    },
)

Wherever you are in your document now you can check if the current page includes content by using

    state("empty.pages", (0,)).get().contains(here().page())

And that's how you check pages for content in typst.

tl,dr

Just use

#show heading.where(level: 1): it => {
    state("empty.switch").update(true);
    pagebreak(weak: true, to: "odd");
    state("empty.switch").update(false);
    it
}
#set page(
    header: context {
        let this-page = here().page()
        let is-start = query(heading.where(level: 1))
                        .map( it => it.location().page())
                        .contains(this-page)
        if is-start { return }
        if state("empty.switch", true).get() {
            state("empty.pages", (0,)).update(it => {
                it.push(this-page)
                return it
            })
        }
    },
    footer: context {
        let is-empty = state("empty.pages", (0,))
                .get().contains(here().page())
        if not is-empty {
            align(center, counter(page).display())
        }
    },
)

= Example heading
= Next example
#lorem(50)

Example output: