Skip to content

Fix infinite recursion in the \caption redefinition (#316) - #317

Open
wkjarosz wants to merge 1 commit into
fmarotta:masterfrom
wkjarosz:fix-caption-recursion-316
Open

Fix infinite recursion in the \caption redefinition (#316)#317
wkjarosz wants to merge 1 commit into
fmarotta:masterfrom
wkjarosz:fix-caption-recursion-316

Conversation

@wkjarosz

@wkjarosz wkjarosz commented Aug 14, 2026

Copy link
Copy Markdown

Disclaimer: this fix was written with the help of Claude Opus 5, and while I have been writing LaTeX for 20+ years, I do not have much experience writing complex templates. I therefore do not personally understand every detail of the mechanism it touches. I am opening this PR anyway because it makes a problem go away, it is small, and I have checked the results fairly carefully — but please review it with that in mind rather than taking my word for it. I am happy to hand it over, adjust it, or close it if you would rather fix this differently.

The problem

On LaTeX 2026-06-01 with KOMA-Script 3.49.2, any float carrying a caption aborts the run with

! TeX capacity exceeded, sorry [parameter stack size=20000].

Two of the four bundled examples — documentation and machine_learning_project — currently fail to build for this reason. This is issue #316, and #308 looks like the same thing.

Why it happens

kao.sty needs to know whether a float contains a \caption, so that uncaptioned floats can be reset with \RawFloats. It worked this out by saving \caption under another name and replacing it, from inside \AtBeginEnvironment for figure and table:

\let\oldcaption\caption
\RenewDocumentCommand{\caption}{s o m}{... \oldcaption ...}

The save happens every time a float begins. On these newer releases the hook can run at a moment when \caption has already been replaced — so the replacement gets saved as its own target. It then calls itself, forever.

The fix

Stop replacing \caption per float. Attach the flag-raising to \caption once, and leave it there. Nothing is ever re-saved, so the recursion cannot occur at all.

Three details are needed to make that work. Each of them silently breaks the detection in a different way if you get it wrong, so they are commented in the source:

  1. Attach the hook after the preamble. The caption package installs its own \caption from an \AtBeginDocument hook, and discards anything attached before that. Instrumenting an earlier attempt showed the hook firing zero times while captions were plainly being typeset. \AfterEndPreamble (etoolbox, already a dependency) is used rather than \AddToHook{begindocument/end} so that nothing newer than the current \NeedsTeXFormat{LaTeX2e} is required.
  2. Set the flag \global. \caption runs several groups deep inside floatrow's boxes, so a group-local flag is restored before the end-of-environment hook can read it. That failure mode shows up as floatrow's Caption(s) lost.
  3. Clear the flag when a float begins, not when it ends. Otherwise a captioned marginfigure leaves it raised and the next uncaptioned float is mistaken for a captioned one.

One API change: the etoolbox toggle kaocaption becomes a plain \newif\ifkaosawcaption, because etoolbox toggles are group-local by design and expose no public global setter. Happy to keep the old name defined as well if you would prefer not to drop it.

Testing

Bundled examples, before and after, LaTeX 2026-06-01 / KOMA-Script 3.49.2:

example before after
documentation fails, no PDF 42 pages, no errors
machine_learning_project fails, no PDF 5 pages, no errors
minimal_book 10 pages 10 pages, pixel-identical
minimal_report 2 pages 2 pages, pixel-identical

The two that already built are byte-identical when rendered to images page by page, so this restores the detection without changing any output.

Also exercised, all with no errors: kaobook one-sided and two-sided, kaohandt, a4/11pt, lstlisting and marginlisting, and a document covering captioned and uncaptioned figures and tables, a captioned marginfigure followed by an uncaptioned figure, and \caption*.

Separately, on a ~90 page book of my own: it could not be compiled locally at all before this change, and afterwards renders byte-identically to a reference build with the whole detection mechanism removed. That book builds under both pdfLaTeX and XeLaTeX.

Tested under TeX Live 2026 locally, and under TeX Live 2025, 2022 and 2021 via Overleaf.

What I have not tested

  • pdfLaTeX and XeLaTeX only, not LuaLaTeX (I do not have the unicode fonts installed).
  • I have not run the instructions/ material.

A note on the suggestion in #316

The \pretocmd-based approach sketched in the issue is the right idea, but as written it runs into points 1 and 2 above: attached from \AtBeginDocument it is discarded by the caption package, and with a group-local toggle it produces Caption(s) lost instead of the crash. Recording that here in case it saves someone the same detour.

kao.sty detects whether a float contains a \caption so that uncaptioned
floats can be reset with \RawFloats. This was implemented by saving \caption
and replacing it from inside \AtBeginEnvironment for figure and table:

    \let\oldcaption\caption
    \RenewDocumentCommand{\caption}{s o m}{... \oldcaption ...}

Because the save happens every time a float begins, the hook can run at a
point where \caption has already been replaced, so the replacement is saved
as its own target and calls itself indefinitely. On LaTeX 2026-06-01 with
KOMA-Script 3.49.2 this happens for any float carrying a caption, and the
run aborts with

    ! TeX capacity exceeded, sorry [parameter stack size=20000].

Two of the four bundled examples, documentation and
machine_learning_project, currently fail to build for this reason.

Instead of re-wrapping \caption per float, the flag is now raised by a hook
attached to \caption once. Nothing is re-saved, so the recursion cannot
arise. Three details are required for this to work and are documented beside
the code:

  * The hook must be attached after the preamble: the caption package
    installs its own \caption from an \AtBeginDocument hook and would
    otherwise discard it. \AfterEndPreamble is used rather than
    \AddToHook{begindocument/end} so that no LaTeX newer than the current
    \NeedsTeXFormat{LaTeX2e} is required.
  * The flag must be set \global, since \caption runs several groups deep
    inside floatrow's boxes; a local flag is restored before the
    end-of-environment hook reads it, which surfaces as floatrow's
    "Caption(s) lost".
  * The flag is cleared when a float begins rather than when it ends, so a
    captioned marginfigure cannot leave it raised for the next float.

The etoolbox toggle is replaced by a plain \newif, because etoolbox toggles
are group-local by design and expose no public global setter.
@wkjarosz
wkjarosz marked this pull request as ready for review August 14, 2026 21:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant