diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f5b655..1bc8e55 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 ([#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 + ## [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..31e7b40 --- /dev/null +++ b/tests/books/test-latexfigure/conf.py @@ -0,0 +1,11 @@ +# 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", "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 new file mode 100644 index 0000000..01482ad --- /dev/null +++ b/tests/books/test-latexfigure/index.rst @@ -0,0 +1,57 @@ +Figures in exercise and solution boxes +====================================== + +.. exercise:: A numbered exercise + :label: ex-numbered + + .. figure:: plot.png + :name: fig-numbered-exercise + + Figure in a numbered exercise + +.. exercise:: An unnumbered exercise + :label: ex-nonumber + :nonumber: + + .. figure:: plot.png + :name: fig-unnumbered-exercise + + Figure in an unnumbered exercise + +.. solution:: ex-numbered + :label: sol-numbered + + .. figure:: plot.png + :name: fig-solution + + Figure in a solution + +.. exercise-start:: A gated exercise + :label: ex-gated + +.. figure:: plot.png + :name: fig-gated-exercise + + Figure in a gated exercise + +.. exercise-end:: + +.. solution-start:: ex-gated + :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/books/test-latexfigure/plot.png b/tests/books/test-latexfigure/plot.png new file mode 100644 index 0000000..6904e5e Binary files /dev/null and b/tests/books/test-latexfigure/plot.png differ diff --git a/tests/test_latex.py b/tests/test_latex.py index e29b5bc..9cf6add 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 = 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 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) == { + "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 + "fig-outside": ("htbp", 0), + # A code-cell output with mystnb figure metadata in a gated solution + "fig-code-cell": ("H", 1), + }