From 32919fdc7253b17689a5c120a73e00cf2a5cbef4 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Tue, 10 Nov 2026 14:04:30 +1100 Subject: [PATCH 1/3] FIX: Keep figures inside exercise and solution boxes from floating in LaTeX Sphinx's LaTeX writer increments no_latex_floats when it opens an admonition, so visit_figure writes \begin{figure}[H] rather than a float. The exercise and solution visitors open the same sphinxadmonition box but never touched the counter, so a captioned figure inside them was written as \begin{figure}[htbp] and LaTeX stopped with "Not in outer par mode". Open and close the box through a helper pair that also maintains the counter, used by all three LaTeX visit/depart pairs (exercise_node, exercise_enumerable_node and solution_node; gated blocks are transformed into these nodes). Add a latexfigure test root with a figure in each kind of box, plus one after them that must still float, so the counter is shown to be restored. Fixes #94 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++++ sphinx_exercise/nodes.py | 24 +++++++++---- tests/books/test-latexfigure/conf.py | 8 +++++ tests/books/test-latexfigure/index.rst | 46 ++++++++++++++++++++++++ tests/books/test-latexfigure/plot.png | Bin 0 -> 71 bytes tests/test_latex.py | 48 +++++++++++++++++++++++++ 6 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 tests/books/test-latexfigure/conf.py 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 9f5b655..3108fc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [Unreleased] + +### Fixes 🐛 + +- Fixed `LaTeX Error: Not in outer par mode` when building LaTeX/PDF output with a captioned figure inside an exercise or solution ([#94](https://github.com/executablebooks/sphinx-exercise/issues/94)) + - Figures inside exercise and solution boxes, including gated `exercise-start` / `solution-start` blocks, are now written as `\begin{figure}[H]` so they stay in place, as Sphinx already does for figures inside its own admonitions + - This also fixes `code-cell` outputs with `mystnb: figure: caption:` metadata inside a solution + - HTML output is unchanged + ## [v1.2.1](https://github.com/executablebooks/sphinx-exercise/tree/v1.2.1) (2025-11-17) ### Fixes 🐛 diff --git a/sphinx_exercise/nodes.py b/sphinx_exercise/nodes.py index 711500a..1f6d355 100644 --- a/sphinx_exercise/nodes.py +++ b/sphinx_exercise/nodes.py @@ -110,13 +110,25 @@ def is_extension_node(node): # Visit and Depart Functions +def visit_latex_admonition(self) -> None: + self.body.append(LaTeX.visit_admonition()) + # 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.depart_admonition()) + self.no_latex_floats -= 1 + + def visit_exercise_node(self, node: Node) -> None: if isinstance(self, LaTeXTranslator): label = ( "\\phantomsection \\label{" + f"exercise:{node.attributes['label']}" + "}" ) # TODO: Check this resolves. self.body.append(label) - self.body.append(LaTeX.visit_admonition()) + visit_latex_admonition(self) else: self.body.append(self.starttag(node, "div", CLASS="admonition")) self.body.append("\n") @@ -124,7 +136,7 @@ def visit_exercise_node(self, node: Node) -> None: def depart_exercise_node(self, node: Node) -> None: if isinstance(self, LaTeXTranslator): - self.body.append(LaTeX.depart_admonition()) + depart_latex_admonition(self) else: self.body.append("") @@ -139,7 +151,7 @@ def visit_exercise_enumerable_node(self, node: Node) -> None: "\\phantomsection \\label{" + f"exercise:{node.attributes['label']}" + "}\n" ) self.body.append(label) - self.body.append(LaTeX.visit_admonition()) + visit_latex_admonition(self) else: self.body.append(self.starttag(node, "div", CLASS="admonition")) self.body.append("\n") @@ -147,7 +159,7 @@ def visit_exercise_enumerable_node(self, node: Node) -> None: def depart_exercise_enumerable_node(self, node: Node) -> None: if isinstance(self, LaTeXTranslator): - self.body.append(LaTeX.depart_admonition()) + depart_latex_admonition(self) else: self.body.append("") self.body.append("\n") @@ -167,7 +179,7 @@ def visit_solution_node(self, node: Node) -> None: + "}\n" ) self.body.append(label) - self.body.append(LaTeX.visit_admonition()) + visit_latex_admonition(self) else: self.body.append(self.starttag(node, "div", CLASS="admonition")) self.body.append("\n") @@ -175,7 +187,7 @@ def visit_solution_node(self, node: Node) -> None: def depart_solution_node(self, node: Node) -> None: if isinstance(self, LaTeXTranslator): - self.body.append(LaTeX.depart_admonition()) + depart_latex_admonition(self) else: self.body.append("") self.body.append("\n") diff --git a/tests/books/test-latexfigure/conf.py b/tests/books/test-latexfigure/conf.py new file mode 100644 index 0000000..0aebf50 --- /dev/null +++ b/tests/books/test-latexfigure/conf.py @@ -0,0 +1,8 @@ +# Test root for captioned figures inside exercise and solution boxes in +# LaTeX output (https://github.com/executablebooks/sphinx-exercise/issues/94) + +project = "sphinx-exercise Test" +copyright = "2020, QuantEcon Developers" +author = "QuantEcon Developers" + +extensions = ["sphinx_exercise"] diff --git a/tests/books/test-latexfigure/index.rst b/tests/books/test-latexfigure/index.rst new file mode 100644 index 0000000..5f3cac6 --- /dev/null +++ b/tests/books/test-latexfigure/index.rst @@ -0,0 +1,46 @@ +Figures in exercise and solution boxes +====================================== + +.. exercise:: A numbered exercise + :label: ex-numbered + + .. figure:: plot.png + + Figure in a numbered exercise + +.. exercise:: An unnumbered exercise + :label: ex-nonumber + :nonumber: + + .. figure:: plot.png + + Figure in an unnumbered exercise + +.. solution:: ex-numbered + :label: sol-numbered + + .. figure:: plot.png + + Figure in a solution + +.. exercise-start:: A gated exercise + :label: ex-gated + +.. figure:: plot.png + + Figure in a gated exercise + +.. exercise-end:: + +.. solution-start:: ex-gated + :label: sol-gated + +.. figure:: plot.png + + Figure in a gated solution + +.. solution-end:: + +.. figure:: plot.png + + Figure outside any box 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 e29b5bc..f9949f9 100644 --- a/tests/test_latex.py +++ b/tests/test_latex.py @@ -1,3 +1,5 @@ +import re + from TexSoup import TexSoup import pytest import sphinx @@ -21,3 +23,49 @@ def test_latex_build(app, file_regression): file_regression.check( str(file_content.document), extension=f"{SPHINX_VERSION}.tex", encoding="utf8" ) + + +LATEX_FIGURE_TOKENS = re.compile( + r"\\(?Pbegin|end)\{sphinxadmonition\}" + r"|\\begin\{figure\}\[(?P[^\]]*)\]" + r"|\\caption\{(?P[^}]*)\}" +) + + +def latex_figures(tex): + """Map each figure caption to (figure placement, sphinxadmonition depth).""" + figures = {} + depth = 0 + pending = None + for match in LATEX_FIGURE_TOKENS.finditer(tex): + if match["env"] == "begin": + depth += 1 + elif match["env"] == "end": + depth -= 1 + elif match["align"] is not None: + pending = (match["align"], depth) + elif pending is not None: + figures[match["caption"]] = pending + pending = None + return figures + + +@pytest.mark.sphinx("latex", testroot="latexfigure") +def test_latex_figure_in_admonition(app): + """Figures inside exercise and solution boxes must not float (#94). + + A floating figure environment inside the sphinxadmonition box stops + LaTeX with "Not in outer par mode". + """ + app.build() + tex = (app.outdir / "sphinx-exercisetest.tex").read_text(encoding="utf8") + + assert latex_figures(tex) == { + "Figure in a numbered exercise": ("H", 1), + "Figure in an unnumbered exercise": ("H", 1), + "Figure in a solution": ("H", 1), + "Figure in a gated exercise": ("H", 1), + "Figure in a gated solution": ("H", 1), + # Figures after the boxes float again, so the counter was restored + "Figure outside any box": ("htbp", 0), + } From 9bfc9470215831ab559b4ecbc8c6c09610543bef Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Tue, 10 Nov 2026 14:06:05 +1100 Subject: [PATCH 2/3] DOCS: Link the CHANGELOG entry to the pull request Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3108fc2..1bc8e55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixes 🐛 -- Fixed `LaTeX Error: Not in outer par mode` when building LaTeX/PDF output with a captioned figure inside an exercise or solution ([#94](https://github.com/executablebooks/sphinx-exercise/issues/94)) +- Fixed `LaTeX Error: Not in outer par mode` when building LaTeX/PDF output with a captioned figure inside an exercise or solution ([#95](https://github.com/executablebooks/sphinx-exercise/pull/95)) - Figures inside exercise and solution boxes, including gated `exercise-start` / `solution-start` blocks, are now written as `\begin{figure}[H]` so they stay in place, as Sphinx already does for figures inside its own admonitions - This also fixes `code-cell` outputs with `mystnb: figure: caption:` metadata inside a solution - HTML output is unchanged From 7ddd67e073ff6e0b11c50550cb1235cd9781b81c Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Tue, 10 Nov 2026 14:38:29 +1100 Subject: [PATCH 3/3] TST: Cover myst-nb code-cell figures and match LaTeX figures by name Add a notebook to the latexfigure test root with a code-cell whose mystnb figure metadata puts a captioned figure inside a gated solution, which is how Jupyter Book projects usually hit the float bug. Its outputs are stored and nb_execution_mode is "off", so no kernel runs. Match figures by their :name: label instead of their caption. The caption regex stopped at the first closing brace, so a caption with markup such as \sphinxhyphen{} was misread, and a figure without a caption was paired with the next one. Co-Authored-By: Claude Opus 5.5 --- tests/books/test-latexfigure/conf.py | 5 +- tests/books/test-latexfigure/index.rst | 11 +++ tests/books/test-latexfigure/notebook.ipynb | 85 +++++++++++++++++++++ tests/test_latex.py | 46 +++++------ 4 files changed, 123 insertions(+), 24 deletions(-) create mode 100644 tests/books/test-latexfigure/notebook.ipynb diff --git a/tests/books/test-latexfigure/conf.py b/tests/books/test-latexfigure/conf.py index 0aebf50..31e7b40 100644 --- a/tests/books/test-latexfigure/conf.py +++ b/tests/books/test-latexfigure/conf.py @@ -5,4 +5,7 @@ copyright = "2020, QuantEcon Developers" author = "QuantEcon Developers" -extensions = ["sphinx_exercise"] +extensions = ["sphinx_exercise", "myst_nb"] + +# notebook.ipynb stores its outputs, so the test does not need a kernel +nb_execution_mode = "off" diff --git a/tests/books/test-latexfigure/index.rst b/tests/books/test-latexfigure/index.rst index 5f3cac6..01482ad 100644 --- a/tests/books/test-latexfigure/index.rst +++ b/tests/books/test-latexfigure/index.rst @@ -5,6 +5,7 @@ Figures in exercise and solution boxes :label: ex-numbered .. figure:: plot.png + :name: fig-numbered-exercise Figure in a numbered exercise @@ -13,6 +14,7 @@ Figures in exercise and solution boxes :nonumber: .. figure:: plot.png + :name: fig-unnumbered-exercise Figure in an unnumbered exercise @@ -20,6 +22,7 @@ Figures in exercise and solution boxes :label: sol-numbered .. figure:: plot.png + :name: fig-solution Figure in a solution @@ -27,6 +30,7 @@ Figures in exercise and solution boxes :label: ex-gated .. figure:: plot.png + :name: fig-gated-exercise Figure in a gated exercise @@ -36,11 +40,18 @@ Figures in exercise and solution boxes :label: sol-gated .. figure:: plot.png + :name: fig-gated-solution Figure in a gated solution .. solution-end:: .. figure:: plot.png + :name: fig-outside Figure outside any box + +.. toctree:: + :hidden: + + notebook diff --git a/tests/books/test-latexfigure/notebook.ipynb b/tests/books/test-latexfigure/notebook.ipynb new file mode 100644 index 0000000..b32965c --- /dev/null +++ b/tests/books/test-latexfigure/notebook.ipynb @@ -0,0 +1,85 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# Figures from code cells" + ] + }, + { + "cell_type": "markdown", + "id": "cell-1", + "metadata": {}, + "source": [ + "```{exercise} A notebook exercise\n", + ":label: ex-notebook\n", + "\n", + "Plot a line.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "cell-2", + "metadata": {}, + "source": [ + "```{solution-start} ex-notebook\n", + ":label: sol-notebook\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cell-3", + "metadata": { + "mystnb": { + "figure": { + "caption": "Figure in a code-cell output", + "name": "fig-code-cell" + } + } + }, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAAAAADhZOFXAAAADklEQVR42mNogAIGyhgAgIQgARDoahcAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "plt.plot([0, 1], [0, 1]);" + ] + }, + { + "cell_type": "markdown", + "id": "cell-4", + "metadata": {}, + "source": [ + "```{solution-end}\n", + "```" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/test_latex.py b/tests/test_latex.py index f9949f9..9cf6add 100644 --- a/tests/test_latex.py +++ b/tests/test_latex.py @@ -25,28 +25,26 @@ def test_latex_build(app, file_regression): ) -LATEX_FIGURE_TOKENS = re.compile( - r"\\(?Pbegin|end)\{sphinxadmonition\}" - r"|\\begin\{figure\}\[(?P[^\]]*)\]" - r"|\\caption\{(?P[^}]*)\}" +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 caption to (figure placement, sphinxadmonition depth).""" + """Map each figure's name to (figure placement, sphinxadmonition depth). + + A figure without a name is collected under None. + """ figures = {} - depth = 0 - pending = None - for match in LATEX_FIGURE_TOKENS.finditer(tex): - if match["env"] == "begin": - depth += 1 - elif match["env"] == "end": - depth -= 1 - elif match["align"] is not None: - pending = (match["align"], depth) - elif pending is not None: - figures[match["caption"]] = pending - pending = None + 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 @@ -61,11 +59,13 @@ def test_latex_figure_in_admonition(app): tex = (app.outdir / "sphinx-exercisetest.tex").read_text(encoding="utf8") assert latex_figures(tex) == { - "Figure in a numbered exercise": ("H", 1), - "Figure in an unnumbered exercise": ("H", 1), - "Figure in a solution": ("H", 1), - "Figure in a gated exercise": ("H", 1), - "Figure in a gated solution": ("H", 1), + "fig-numbered-exercise": ("H", 1), + "fig-unnumbered-exercise": ("H", 1), + "fig-solution": ("H", 1), + "fig-gated-exercise": ("H", 1), + "fig-gated-solution": ("H", 1), # Figures after the boxes float again, so the counter was restored - "Figure outside any box": ("htbp", 0), + "fig-outside": ("htbp", 0), + # A code-cell output with mystnb figure metadata in a gated solution + "fig-code-cell": ("H", 1), }