From 2f280572a016e81caf068735fcd4d1397edf1459 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Tue, 10 Nov 2026 16:39:52 +1100 Subject: [PATCH] FIX: Keep figures inside prf boxes from floating in LaTeX A captioned figure inside a prf: directive box, numbered or :nonumber:, broke the LaTeX/PDF build with "LaTeX Error: Not in outer par mode". Sphinx's LaTeX writer increments no_latex_floats whenever it opens one of its own admonitions, and visit_figure then writes \begin{figure}[H]. The enumerable and unenumerable visitors open the same sphinxadmonition box but never touched the counter, so figures inside it were written as floating \begin{figure}[htbp], which LaTeX rejects there. Open and close the box through a small visit_latex_admonition / depart_latex_admonition helper pair that also increments and decrements the counter. prf:proof was already unaffected: it wraps a docutils admonition node that Sphinx's own visitor handles. HTML output is unchanged. Add the tests/books/test-latexfigure root and tests/test_latex.py::test_latex_figure_in_admonition, and a CHANGELOG entry. Mirrors the change in executablebooks/sphinx-exercise#95. Fixes #169 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WT8bMZoSvoQTbzkdmZiMnd --- CHANGELOG.md | 8 +++ sphinx_proof/nodes.py | 20 +++++-- tests/books/test-latexfigure/conf.py | 10 ++++ tests/books/test-latexfigure/figures.rst | 67 +++++++++++++++++++++++ tests/books/test-latexfigure/index.rst | 6 ++ tests/books/test-latexfigure/plot.png | Bin 0 -> 71 bytes tests/test_latex.py | 50 +++++++++++++++++ 7 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 tests/books/test-latexfigure/conf.py create mode 100644 tests/books/test-latexfigure/figures.rst create mode 100644 tests/books/test-latexfigure/index.rst create mode 100644 tests/books/test-latexfigure/plot.png diff --git a/CHANGELOG.md b/CHANGELOG.md index f6cabd9..2e5b929 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### Fixed + +- Captioned figures inside `prf:` directive boxes breaking the LaTeX/PDF build with `LaTeX Error: Not in outer par mode`, fixing [\#169](https://github.com/executablebooks/sphinx-proof/issues/169). + - Figures inside every `prf:` box, numbered or `:nonumber:`, are now written as `\begin{figure}[H]` so they stay in place, as Sphinx already does for figures inside its own admonitions + - HTML output is unchanged + ## v0.4.0 (2026-01-27) ### Added diff --git a/sphinx_proof/nodes.py b/sphinx_proof/nodes.py index d77b257..756a044 100644 --- a/sphinx_proof/nodes.py +++ b/sphinx_proof/nodes.py @@ -26,11 +26,23 @@ latex_admonition_end = "\\end{sphinxadmonition}" + CR +def visit_latex_admonition(self) -> None: + self.body.append(latex_admonition_start) + # Write figures inside the box as [H] rather than floats, which LaTeX + # rejects there, as Sphinx does for its own admonitions + self.no_latex_floats += 1 + + +def depart_latex_admonition(self) -> None: + self.body.append(latex_admonition_end) + self.no_latex_floats -= 1 + + def visit_enumerable_node(self, node: Node) -> None: if isinstance(self, LaTeXTranslator): docname = find_parent(self.builder.env, node, "section") self.body.append("\\label{" + f"{docname}:{node.attributes['label']}" + "}") - self.body.append(latex_admonition_start) + visit_latex_admonition(self) else: self.body.append(self.starttag(node, "div", CLASS="admonition")) @@ -42,7 +54,7 @@ def depart_enumerable_node(self, node: Node) -> None: number = get_node_number(self, node, countertyp) idx = list_rindex(self.body, latex_admonition_start) + 2 self.body.insert(idx, f"{realtyp.title()} {number}") - self.body.append(latex_admonition_end) + depart_latex_admonition(self) else: # Find index in list of 'Proof #' number = get_node_number(self, node, countertyp) @@ -55,7 +67,7 @@ def visit_unenumerable_node(self, node: Node) -> None: if isinstance(self, LaTeXTranslator): docname = find_parent(self.builder.env, node, "section") self.body.append("\\label{" + f"{docname}:{node.attributes['label']}" + "}") - self.body.append(latex_admonition_start) + visit_latex_admonition(self) else: self.body.append(self.starttag(node, "div", CLASS="admonition")) @@ -66,7 +78,7 @@ def depart_unenumerable_node(self, node: Node) -> None: if isinstance(self, LaTeXTranslator): idx = list_rindex(self.body, latex_admonition_start) + 2 self.body.insert(idx, f"{realtyp.title()}") - self.body.append(latex_admonition_end) + depart_latex_admonition(self) else: # use the id to find the correct title location search_str = f'

' diff --git a/tests/books/test-latexfigure/conf.py b/tests/books/test-latexfigure/conf.py new file mode 100644 index 0000000..d73e591 --- /dev/null +++ b/tests/books/test-latexfigure/conf.py @@ -0,0 +1,10 @@ +# Test root for captioned figures inside prf directive boxes in LaTeX output +# (https://github.com/executablebooks/sphinx-proof/issues/169). The boxes are +# in a chapter, figures.rst, as in a book: boxes in the root document itself +# lose their numbers in LaTeX output. + +project = "sphinx-proof Test" +copyright = "2020, QuantEcon Developers" +author = "QuantEcon Developers" + +extensions = ["sphinx_proof"] diff --git a/tests/books/test-latexfigure/figures.rst b/tests/books/test-latexfigure/figures.rst new file mode 100644 index 0000000..489bc2b --- /dev/null +++ b/tests/books/test-latexfigure/figures.rst @@ -0,0 +1,67 @@ +Figures +======= + +.. prf:theorem:: A numbered theorem + :label: thm-numbered + + .. figure:: plot.png + :name: fig-numbered-theorem + + Figure in a numbered theorem + +.. prf:lemma:: An unnumbered lemma + :label: lem-nonumber + :nonumber: + + .. figure:: plot.png + :name: fig-unnumbered-lemma + + Figure in an unnumbered lemma + +.. prf:proof:: + + A proof with a figure. + + .. figure:: plot.png + :name: fig-proof + + Figure in a proof + +.. note:: + + .. figure:: plot.png + :name: fig-note + + Figure in a note + +.. prf:theorem:: A theorem with a nested lemma + :label: thm-nested + + .. prf:lemma:: A lemma inside a theorem + :label: lem-nested + :nonumber: + + .. figure:: plot.png + :name: fig-nested-lemma + + Figure in a lemma inside a theorem + + .. figure:: plot.png + :name: fig-after-nested-lemma + + Figure in a theorem after its nested lemma + +.. note:: + + .. prf:definition:: A definition inside a note + :label: def-in-note + + .. figure:: plot.png + :name: fig-definition-in-note + + Figure in a definition inside a note + +.. figure:: plot.png + :name: fig-outside + + Figure outside any box diff --git a/tests/books/test-latexfigure/index.rst b/tests/books/test-latexfigure/index.rst new file mode 100644 index 0000000..cd2016f --- /dev/null +++ b/tests/books/test-latexfigure/index.rst @@ -0,0 +1,6 @@ +Figures in proof boxes +====================== + +.. toctree:: + + figures diff --git a/tests/books/test-latexfigure/plot.png b/tests/books/test-latexfigure/plot.png new file mode 100644 index 0000000000000000000000000000000000000000..6904e5ed5dd914cff1c77777f94f3a8a5ec68d18 GIT binary patch literal 71 zcmeAS@N?(olHy`uVBq!ia0vp^93TuL7#^lP31V R$Px#s@O1TaS?83{1OVSp54r#V literal 0 HcmV?d00001 diff --git a/tests/test_latex.py b/tests/test_latex.py index 1820cb2..cabc1f2 100644 --- a/tests/test_latex.py +++ b/tests/test_latex.py @@ -1,3 +1,5 @@ +import re + from TexSoup import TexSoup import pytest @@ -12,3 +14,51 @@ def test_latex_build(app, file_regression): # get content markup file_content = TexSoup(path_algo_directive.read_text(encoding="utf8")) file_regression.check(str(file_content.document), extension=".tex", encoding="utf8") + + +LATEX_FIGURE = re.compile( + r"\\begin\{figure\}\[(?P[^\]]*)\](?P.*?)\\end\{figure\}", re.DOTALL +) +# A figure's :name: becomes \label{\detokenize{:}} +LATEX_FIGURE_NAME = re.compile(r"\\label\{\\detokenize\{[^:}]*:(?P[^}]+)\}\}") + + +def latex_figures(tex): + """Map each figure's name to (figure placement, sphinxadmonition depth). + + A figure without a name is collected under None. + """ + figures = {} + for figure in LATEX_FIGURE.finditer(tex): + before = tex[: figure.start()] + depth = before.count(r"\begin{sphinxadmonition}") - before.count( + r"\end{sphinxadmonition}" + ) + name = LATEX_FIGURE_NAME.search(figure["body"]) + figures[name["name"] if name else None] = (figure["align"], depth) + return figures + + +@pytest.mark.sphinx("latex", testroot="latexfigure") +def test_latex_figure_in_admonition(app): + """Figures inside prf directive boxes must not float (#169). + + A floating figure environment inside the sphinxadmonition box stops + LaTeX with "Not in outer par mode". + """ + app.build() + tex = (app.outdir / "sphinx-prooftest.tex").read_text(encoding="utf8") + + assert latex_figures(tex) == { + "fig-numbered-theorem": ("H", 1), + "fig-unnumbered-lemma": ("H", 1), + # prf:proof and note boxes come from Sphinx's own admonition visitors + "fig-proof": ("H", 1), + "fig-note": ("H", 1), + # Figures after a nested box closes are still inside the outer box + "fig-nested-lemma": ("H", 2), + "fig-after-nested-lemma": ("H", 1), + "fig-definition-in-note": ("H", 2), + # Figures after the boxes float again, so the counter was restored + "fig-outside": ("htbp", 0), + }