Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 🐛
Expand Down
24 changes: 18 additions & 6 deletions sphinx_exercise/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,21 +110,33 @@ 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")


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("</div>")

Expand All @@ -139,15 +151,15 @@ 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")


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("</div>")
self.body.append("\n")
Expand All @@ -167,15 +179,15 @@ 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")


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("</div>")
self.body.append("\n")
Expand Down
11 changes: 11 additions & 0 deletions tests/books/test-latexfigure/conf.py
Original file line number Diff line number Diff line change
@@ -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"
57 changes: 57 additions & 0 deletions tests/books/test-latexfigure/index.rst
Original file line number Diff line number Diff line change
@@ -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
85 changes: 85 additions & 0 deletions tests/books/test-latexfigure/notebook.ipynb
Original file line number Diff line number Diff line change
@@ -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": [
"<Figure size 640x480 with 1 Axes>"
]
},
"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
}
Binary file added tests/books/test-latexfigure/plot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
48 changes: 48 additions & 0 deletions tests/test_latex.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import re

from TexSoup import TexSoup
import pytest
import sphinx
Expand All @@ -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<align>[^\]]*)\](?P<body>.*?)\\end\{figure\}", re.DOTALL
)
# A figure's :name: becomes \label{\detokenize{<docname>:<name>}}
LATEX_FIGURE_NAME = re.compile(r"\\label\{\\detokenize\{[^:}]*:(?P<name>[^}]+)\}\}")


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),
}
Loading