Notes on typst/typst#5765, written
up while chasing the same bug downstream in pandoc
(jgm/pandoc#11807;
quarto-dev/quarto-cli#14484
has the same root cause). pandoc's default Typst template puts the title block
in a parent-scoped float so that it spans columns, and a thanks footnote on
the title then renders a marker with no entry.
Two things that aren't in the original report:
- It has nothing to do with columns.
place(float: true, scope: "parent")drops the entry withcolumns: 1too, so the default single-column output is broken as well. (scope: "parent"withoutfloat: trueis rejected outright, so the two can't be separated.) - Still reproduces on main as of 35417aa76, and on 0.14.2 and 0.15.1.
The place docs example with a footnote added is enough:
#set page(height: 150pt, columns: 2)
#place(top + center, scope: "parent", float: true,
rect(width: 80%, fill: aqua)[Placed#footnote[Lost.]])
#lorem(25)
The composer keeps two insertion buckets. Floats go in one or the other by
scope, but footnotes always go in the column one — footnote() hardcodes
let area = &mut self.column_insertions, and it stores two things there: the
laid-out entry, and a skip record marking the footnote as handled.
For a parent-scoped float:
float()lays the float out and callsself.footnotes(...), which puts the entry and its skip intocolumn_insertions.float()pushes the float intopage_insertionsand returnsStop::Relayout(Parent).- That propagates to
page(), which re-runs the columns.page_insertionsisn't part ofwork, so the float stays placed — that's the point of the retry. - Each column re-enters
column(), which starts withself.column_insertions = Insertions::default(). The entry and its skip are both discarded. - Distribution reaches the same placed child again, and
float()opens withif self.skipped(loc) { return Ok(()) }.skipped()also consultspage_insertions, where the float's skip survived — so it returns before reaching theself.footnotes(...)call.
The entry is never laid out again. The marker survives because it was baked
into the float's frame in step 1, and that frame is in the surviving
page_insertions.
Column-scoped floats are fine only because Stop::Relayout(Column) is caught
inside column(), in a loop that sits after the reset — so nothing is thrown
away. The asymmetry is the whole bug: the float's skip outlives the retry, the
footnote's doesn't.
I confirmed this with prints in float()/footnote()/column()/page().
The parent-scoped run shows, in order: entry stored in column insertions →
float pushed to page insertions → Relayout(Parent) → column() resets with
footnotes=1, skips=1 → float() returns early as skipped. The column-scoped
run is identical except it never crosses a reset.
Possibly relevant: the comment on footnote_spill / footnote_queue
("These are here because they have to survive relayout (we could lose the
footnotes otherwise) ... this is not super clean") is about the same hazard.
Those two got rescued; entries that were already laid out didn't.
A small patch against float() re-collects the footnotes when it skips a
parent-scoped float, together with two suite tests (one and two columns) that
go red without it. The full footnote suite passes with it, and the reported
pandoc case renders correctly with an unmodified pandoc template:
One open question in that patch: it passes migratable through to
self.footnotes(...) for consistency with the neighbouring call, but the
migration branch looks unreachable on this path, and column_contents sets it
to false in the comparable situation. I could not construct a document where
the two values behave differently.
Provenance: this investigation — reading the layout code, building an instrumented Typst, and drafting this write-up — was done with an LLM (Claude Code). The empirical parts are all reproducible from the snippet above; the reasoning about the code deserves review rather than trust.