From b39e9cfa4aa4c0f0d7f8e049e68b7c4a756f6906 Mon Sep 17 00:00:00 2001 From: saimskywalker <15120878+saimskywalker@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:33:09 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20NEW:=20Reference=20amsmath=20en?= =?UTF-8?q?vironments=20by=20their=20`\label`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/syntax/math.md | 14 +++ docs/syntax/optional.md | 18 +++- myst_parser/mdit_to_docutils/base.py | 32 ++++++ myst_parser/mdit_to_docutils/sphinx_.py | 11 +- myst_parser/sphinx_ext/main.py | 28 ++++- myst_parser/warnings_.py | 2 + tests/test_renderers/fixtures/amsmath.md | 100 ++++++++++++++++++ .../fixtures/docutil_syntax_extensions.txt | 31 ++++++ tests/test_renderers/test_fixtures_sphinx.py | 14 ++- .../sourcedirs/extended_syntaxes/index.md | 6 ++ tests/test_sphinx/test_sphinx_builds.py | 16 +++ .../test_extended_syntaxes.html | 17 +++ .../test_extended_syntaxes.xml | 10 ++ .../test_extended_syntaxes_text.txt | 4 + 14 files changed, 289 insertions(+), 14 deletions(-) diff --git a/docs/syntax/math.md b/docs/syntax/math.md index 355881bd..7eaea1b7 100644 --- a/docs/syntax/math.md +++ b/docs/syntax/math.md @@ -91,3 +91,17 @@ a_{21}& =b_{21}& \end{align} ::: + +A numbered environment can contain a single `\label`, to reference it with the `eq` role: + +:::{syntax-example} +:highlight: latex + +\begin{equation} +e^{i\pi} + 1 = 0 \label{eq:euler} +\end{equation} + +The equation {eq}`eq:euler` is Euler's identity. +::: + +See [](#syntax/amsmath) for more details. diff --git a/docs/syntax/optional.md b/docs/syntax/optional.md index 367154e2..a280910f 100644 --- a/docs/syntax/optional.md +++ b/docs/syntax/optional.md @@ -247,10 +247,20 @@ a_{21}& =b_{21}& ::: -:::{note} -`\labels` inside the environment are not currently identified, and so cannot be referenced. -We hope to implement this in a future update (see [executablebooks/MyST-Parser#202](https://github.com/executablebooks/MyST-Parser/issues/202))! -::: +A numbered environment can be given a label with `\label{name}`, +which can then be referenced with the `eq` role, e.g. `` {eq}`name` ``: + +```latex +\begin{equation} +e^{i\pi} + 1 = 0 \label{eq:euler} +\end{equation} +``` + +The whole environment is given a single equation number, +so only one `\label` is supported per environment. +If an environment contains more than one `\label`, a `myst.math_label` warning is emitted and none of them are used +(see [executablebooks/MyST-Parser#202](https://github.com/executablebooks/MyST-Parser/issues/202)). +Labels in unnumbered (`*`) environments, or in LaTeX `%` comments, are ignored. This syntax will also work when nested in other block elements, like lists or quotes: diff --git a/myst_parser/mdit_to_docutils/base.py b/myst_parser/mdit_to_docutils/base.py index 68cfae72..d0423976 100644 --- a/myst_parser/mdit_to_docutils/base.py +++ b/myst_parser/mdit_to_docutils/base.py @@ -84,6 +84,9 @@ def make_document(source_path="notset", parser_cls=RSTParser) -> nodes.document: r"{{\s*(uri|scheme|netloc|path|params|query|fragment)\s*}}" ) REGEX_DIRECTIVE_START = re.compile(r"^[\s]{0,3}([`]{3,10}|[~]{3,10}|[:]{3,10})\{") +# a ``%`` comment, unless the ``%`` is escaped (but ``\\%`` is a line break + comment) +REGEX_LATEX_COMMENT = re.compile(r"(? int: @@ -1521,6 +1524,32 @@ def render_math_block_label(self, token: SyntaxTreeNode) -> None: self.document.note_explicit_target(node, node) self.current_node.append(node) + def get_amsmath_label(self, token: SyntaxTreeNode) -> str | None: + """Return the ``\\label{...}`` of a numbered amsmath environment. + + Only a single label per environment is supported, + since the whole environment is given a single equation number. + If there are multiple labels, a warning is emitted and none are used. + """ + if token.meta["numbered"] == "*": + return None + content = REGEX_LATEX_COMMENT.sub("", token.content) + labels = [ + label.strip() + for label in REGEX_LATEX_LABEL.findall(content) + if label.strip() + ] + if len(labels) > 1: + self.create_warning( + "Only one \\label is supported per amsmath environment, " + f"ignoring: {', '.join(labels)}", + MystWarnings.MATH_LABEL, + line=token_line(token, 0), + append_to=self.current_node, + ) + return None + return labels[0] if labels else None + def render_amsmath(self, token: SyntaxTreeNode) -> None: # note docutils does not currently support the nowrap attribute # or equation numbering, so this is overridden in the sphinx renderer @@ -1530,6 +1559,9 @@ def render_amsmath(self, token: SyntaxTreeNode) -> None: if token.meta["numbered"] != "*": node["numbered"] = True self.add_line_and_source_path(node, token) + if label := self.get_amsmath_label(token): + node["names"].append(nodes.fully_normalize_name(label)) + self.document.note_explicit_target(node, node) self.current_node.append(node) def render_footnote_ref(self, token: SyntaxTreeNode) -> None: diff --git a/myst_parser/mdit_to_docutils/sphinx_.py b/myst_parser/mdit_to_docutils/sphinx_.py index 1569131f..f4216876 100644 --- a/myst_parser/mdit_to_docutils/sphinx_.py +++ b/myst_parser/mdit_to_docutils/sphinx_.py @@ -201,10 +201,9 @@ def render_amsmath(self, token: SyntaxTreeNode) -> None: content = token.content if token.meta["numbered"] != "*": - # TODO how to parse and reference labels within environment? - # for now we give create a unique hash, so the equation will be numbered - # but there will be no reference clashes - label = self._random_label() + # use the environment's \label, if it has one, so that it can be referenced, + # otherwise create a unique hash, so the equation will still be numbered + label = self.get_amsmath_label(token) or self._random_label() node = nodes.math_block( content, content, @@ -213,6 +212,8 @@ def render_amsmath(self, token: SyntaxTreeNode) -> None: classes=["amsmath"], label=label, ) + # set the line first, so that duplicate label warnings have a location + self.add_line_and_source_path(node, token) target = self.add_math_target(node) self.add_line_and_source_path(target, token) self.current_node.append(target) @@ -220,7 +221,7 @@ def render_amsmath(self, token: SyntaxTreeNode) -> None: node = nodes.math_block( content, content, nowrap=True, number=None, classes=["amsmath"] ) - self.add_line_and_source_path(node, token) + self.add_line_and_source_path(node, token) self.current_node.append(node) def add_math_target(self, node: nodes.math_block) -> nodes.target: diff --git a/myst_parser/sphinx_ext/main.py b/myst_parser/sphinx_ext/main.py index ef17c883..c48770ba 100644 --- a/myst_parser/sphinx_ext/main.py +++ b/myst_parser/sphinx_ext/main.py @@ -1,6 +1,7 @@ """The setup for the sphinx extension.""" -from typing import Any +import re +from typing import TYPE_CHECKING, Any from docutils import nodes from sphinx.application import Sphinx @@ -16,6 +17,9 @@ from myst_parser.parsers.mdit import linkify_available from myst_parser.warnings_ import MystWarnings +if TYPE_CHECKING: + from sphinx.writers.latex import LaTeXTranslator + def setup_sphinx(app: Sphinx, load_parser: bool = False) -> None: """Initialize all settings and transforms in Sphinx. @@ -58,6 +62,10 @@ def setup_sphinx(app: Sphinx, load_parser: bool = False) -> None: html=(visit_container_html, depart_container_html), ) + # override the latex writer visit method for math_block, + # so that the label of an amsmath environment is placed within it + app.add_node(nodes.math_block, override=True, latex=(visit_math_block_latex, None)) + for name, default, field in MdParserConfig().as_triple(): if "sphinx" not in field.metadata.get("omit", []): # TODO add types? @@ -67,6 +75,24 @@ def setup_sphinx(app: Sphinx, load_parser: bool = False) -> None: app.connect("builder-inited", override_mathjax) +def visit_math_block_latex(self: "LaTeXTranslator", node: nodes.math_block) -> None: + """Write amsmath blocks, that use their own ``\\label``, to LaTeX. + + For ``nowrap`` math, sphinx writes the label before the content, + where it would not refer to the equation number of the amsmath environment. + So instead we rename the ``\\label`` within the environment. + """ + label = node.get("label") + if "amsmath" in node["classes"] and label: + regex = re.compile(r"\\label\s*\{\s*" + re.escape(label) + r"\s*\}") + content = node.astext() + if regex.search(content): + new_label = f"\\label{{equation:{node['docname']}:{label}}}" + self.body.append(regex.sub(lambda _: new_label, content)) + raise nodes.SkipNode + type(self).visit_math_block(self, node) + + def create_myst_config(app): """Create the myst config object and add it to the sphinx environment.""" from sphinx.util import logging diff --git a/myst_parser/warnings_.py b/myst_parser/warnings_.py index 99b9072f..66ab9f53 100644 --- a/myst_parser/warnings_.py +++ b/myst_parser/warnings_.py @@ -66,6 +66,8 @@ class MystWarnings(Enum): """Invalid attribute value.""" SUBSTITUTION = "substitution" """Substitution could not be resolved.""" + MATH_LABEL = "math_label" + """A label in LaTeX math could not be used.""" def _is_suppressed_warning( diff --git a/tests/test_renderers/fixtures/amsmath.md b/tests/test_renderers/fixtures/amsmath.md index 34d7f064..09bce762 100644 --- a/tests/test_renderers/fixtures/amsmath.md +++ b/tests/test_renderers/fixtures/amsmath.md @@ -50,3 +50,103 @@ In list: a = 1 \end{equation} . + +With label: +. +\begin{equation} +a = 1 \label{eq:my-label} +\end{equation} +. + + + + \begin{equation} + a = 1 \label{eq:my-label} + \end{equation} +. + +With label and reference: +. +\begin{align} +\label{eq1} +a &= 1 \\ +b &= 2 +\end{align} + +{eq}`eq1` +. + + + + \begin{align} + \label{eq1} + a &= 1 \\ + b &= 2 + \end{align} + + + + eq1 +. + +Label in comment is ignored: +. +\begin{equation} +a = 1 % \label{eq1} +\end{equation} +. + + + + \begin{equation} + a = 1 % \label{eq1} + \end{equation} +. + +Label after escaped percent: +. +\begin{equation} +a = 1\% \label{eq1} +\end{equation} +. + + + + \begin{equation} + a = 1\% \label{eq1} + \end{equation} +. + +Label in no number environment is ignored: +. +\begin{equation*} +a = 1 \label{eq1} +\end{equation*} +. + + + \begin{equation*} + a = 1 \label{eq1} + \end{equation*} +. + +Multiple labels: +. +\begin{align} +a &= 1 \label{eq1} \\ +b &= 2 \label{eq2} +\end{align} +. + + + + Only one \label is supported per amsmath environment, ignoring: eq1, eq2 [myst.math_label] + + + \begin{align} + a &= 1 \label{eq1} \\ + b &= 2 \label{eq2} + \end{align} + +/index.md:1: WARNING: Only one \label is supported per amsmath environment, ignoring: eq1, eq2 [myst.math_label] +. diff --git a/tests/test_renderers/fixtures/docutil_syntax_extensions.txt b/tests/test_renderers/fixtures/docutil_syntax_extensions.txt index cc0723e5..257ad732 100644 --- a/tests/test_renderers/fixtures/docutil_syntax_extensions.txt +++ b/tests/test_renderers/fixtures/docutil_syntax_extensions.txt @@ -60,6 +60,37 @@ a \end{equation*} . +[amsmath-label] --myst-enable-extensions=amsmath +. +\begin{equation} +a = 1 \label{eq:a} +\end{equation} + +\begin{align} +a &= 1 \label{eq1} \\ +b &= 2 \label{eq2} +\end{align} + +[link](#eq:a) +. + + + \begin{equation} + a = 1 \label{eq:a} + \end{equation} + + + Only one \label is supported per amsmath environment, ignoring: eq1, eq2 [myst.math_label] + + \begin{align} + a &= 1 \label{eq1} \\ + b &= 2 \label{eq2} + \end{align} + + + link +. + [deflist] --myst-enable-extensions=deflist . term diff --git a/tests/test_renderers/test_fixtures_sphinx.py b/tests/test_renderers/test_fixtures_sphinx.py index 4a8e1710..291b2c8e 100644 --- a/tests/test_renderers/test_fixtures_sphinx.py +++ b/tests/test_renderers/test_fixtures_sphinx.py @@ -14,6 +14,7 @@ from docutils.core import Publisher from pytest_param_files import ParamTestData from sphinx.transforms import SphinxTransformer +from sphinx.util.console import strip_colors from sphinx_pytest.plugin import CreateDoctree from myst_parser.mdit_to_docutils.sphinx_ import SphinxRenderer @@ -179,12 +180,17 @@ def test_amsmath( ): monkeypatch.setattr(SphinxRenderer, "_random_label", lambda self: "mock-uuid") sphinx_doctree_no_tr.set_conf( - {"extensions": ["myst_parser"], "myst_enable_extensions": ["amsmath"]} + { + "extensions": ["myst_parser"], + "myst_enable_extensions": ["amsmath"], + "show_warning_types": True, + } ) result = sphinx_doctree_no_tr(file_params.content, "index.md") - file_params.assert_expected( - normalize_doctree_xml(result.pformat("index")), rstrip_lines=True - ) + outcome = normalize_doctree_xml(result.pformat("index")) + if warnings := strip_colors(result.warnings).strip(): + outcome += "\n\n" + warnings + file_params.assert_expected(outcome, rstrip_lines=True) @pytest.mark.param_file(FIXTURE_PATH / "containers.md") diff --git a/tests/test_sphinx/sourcedirs/extended_syntaxes/index.md b/tests/test_sphinx/sourcedirs/extended_syntaxes/index.md index fd757f41..24056c7e 100644 --- a/tests/test_sphinx/sourcedirs/extended_syntaxes/index.md +++ b/tests/test_sphinx/sourcedirs/extended_syntaxes/index.md @@ -16,6 +16,12 @@ a $$c=3$$ b b=2 \end{equation} +\begin{equation} +c=4 \label{eq:labelled} +\end{equation} + +Reference to {eq}`eq:labelled` + ```{math} c=3 diff --git a/tests/test_sphinx/test_sphinx_builds.py b/tests/test_sphinx/test_sphinx_builds.py index 333b24dc..4b9aeee5 100644 --- a/tests/test_sphinx/test_sphinx_builds.py +++ b/tests/test_sphinx/test_sphinx_builds.py @@ -747,6 +747,22 @@ def test_texinfo(app, status, warning): assert warnings == "" +@pytest.mark.sphinx( + buildername="latex", + srcdir=os.path.join(SOURCE_DIR, "extended_syntaxes"), + freshenv=True, +) +def test_extended_syntaxes_latex(app, status, warning): + """Test that amsmath labels are written within the environment.""" + app.build() + assert "build succeeded" in status.getvalue() # Build succeeded + (tex_path,) = Path(app.outdir).glob("*.tex") + content = tex_path.read_text("utf8") + assert "c=4 \\label{equation:index:eq:labelled}" in content + assert "\\eqref{equation:index:eq:labelled}" in content + assert "\\label{eq:labelled}" not in content + + @pytest.mark.sphinx( buildername="html", srcdir=os.path.join(SOURCE_DIR, "includes"), diff --git a/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.html b/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.html index 962b72cc..1c3f96f5 100644 --- a/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.html +++ b/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.html @@ -49,6 +49,23 @@

b=2 \end{equation}\] +
+ + (3) + + ΒΆ + + + \[\begin{equation} +c=4 \label{eq:labelled} +\end{equation}\] +
+

+ Reference to + + (3) + +

\[ \begin{align}\begin{aligned}c=3\\d=4\end{aligned}\end{align} \]
diff --git a/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.xml b/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.xml index f00cb508..fa045428 100644 --- a/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.xml +++ b/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.xml @@ -26,6 +26,16 @@ \begin{equation} b=2 \end{equation} + + + \begin{equation} + c=4 \label{eq:labelled} + \end{equation} + + Reference to + + + eq:labelled c=3 diff --git a/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes_text.txt b/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes_text.txt index e35e2598..34145186 100644 --- a/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes_text.txt +++ b/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes_text.txt @@ -19,6 +19,10 @@ a \begin{equation} b=2 \end{equation} + \begin{equation} c=4 \label{eq:labelled} \end{equation} + +Reference to (3) + c=3 d=4 Term **1** From 93760af6a56c4f8675ecf5bc26d929641e117ac8 Mon Sep 17 00:00:00 2001 From: saimskywalker <15120878+saimskywalker@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:43:11 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=94=A7=20Ignore=20the=20LaTeXTranslat?= =?UTF-8?q?or=20nitpick=20in=20the=20docs=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/conf.py b/docs/conf.py index bf7f4586..588ef053 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -90,6 +90,7 @@ ("py:exc", "MarkupError"), ("py:class", "sphinx.util.typing.Inventory"), ("py:class", "sphinx.writers.html.HTMLTranslator"), + ("py:class", "sphinx.writers.latex.LaTeXTranslator"), ("py:obj", "sphinx.transforms.post_transforms.ReferencesResolver"), ]