Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save gordonwoodhull/ed348861d29bf3a3119e3f62b85aa074 to your computer and use it in GitHub Desktop.

Select an option

Save gordonwoodhull/ed348861d29bf3a3119e3f62b85aa074 to your computer and use it in GitHub Desktop.
typst/typst#5765: why footnotes are lost inside place(float: true, scope: "parent") — root-cause analysis (LLM-assisted, see disclosure)

Why footnotes vanish inside place(float: true, scope: "parent")

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 with columns: 1 too, so the default single-column output is broken as well. (scope: "parent" without float: true is 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)

What's happening

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:

  1. float() lays the float out and calls self.footnotes(...), which puts the entry and its skip into column_insertions.
  2. float() pushes the float into page_insertions and returns Stop::Relayout(Parent).
  3. That propagates to page(), which re-runs the columns. page_insertions isn't part of work, so the float stays placed — that's the point of the retry.
  4. Each column re-enters column(), which starts with self.column_insertions = Insertions::default(). The entry and its skip are both discarded.
  5. Distribution reaches the same placed child again, and float() opens with if self.skipped(loc) { return Ok(()) }. skipped() also consults page_insertions, where the float's skip survived — so it returns before reaching the self.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=1float() 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 candidate fix

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:

https://github.com/typst/typst/compare/main...gordonwoodhull:typst:fix-parent-scoped-float-footnotes

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment