From 7adf0337f6f894006dd36cb9637a9be6ed930cbf Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 23 Oct 2026 10:57:15 +0200 Subject: [PATCH 1/6] feat: lightweight math renderer --- docs/api.md | 5 ++ docs/api/math_core.md | 5 ++ docs/conf.py | 14 ++++- pyproject.toml | 4 +- src/scverse_doc/__init__.py | 5 +- src/scverse_doc/math_core.css | 7 +++ src/scverse_doc/math_core.py | 113 ++++++++++++++++++++++++++++++++++ tests/test_math_core.py | 83 +++++++++++++++++++++++++ 8 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 docs/api/math_core.md create mode 100644 src/scverse_doc/math_core.css create mode 100644 src/scverse_doc/math_core.py create mode 100644 tests/test_math_core.py diff --git a/docs/api.md b/docs/api.md index 2936d5c..441398e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -15,6 +15,7 @@ api/source.md api/registry.md api/autodoc.md api/napoleon.md +api/math_core.md api/release_notes.md api/typed_fields.md api/xref_overrides.md @@ -61,6 +62,10 @@ and private bases left out of `:show-inheritance:`. Prose `Returns` sections in NumPy-style docstrings. +## {doc}`api/math_core` + +Math rendered to MathML at build time, via [math-core](https://github.com/tmke8/math-core) – no JavaScript needed. + ## {doc}`api/release_notes` A `release-notes` directive, rendering a directory of per-version files grouped by minor version. diff --git a/docs/api/math_core.md b/docs/api/math_core.md new file mode 100644 index 0000000..2c3e14b --- /dev/null +++ b/docs/api/math_core.md @@ -0,0 +1,5 @@ +# `scverse_doc.math_core` + +```{eval-rst} +.. automodule:: scverse_doc.math_core +``` diff --git a/docs/conf.py b/docs/conf.py index 01448d1..707d8f7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,8 +1,15 @@ # SPDX-License-Identifier: MPL-2.0 """Sphinx docs configuration.""" +from sphinx.application import Sphinx + project = "scverse-doc" -extensions = ["scverse_doc", "sphinxcontrib.bibtex", "sphinx.ext.linkcode"] +extensions = [ + "scverse_doc", + "sphinxcontrib.bibtex", + "sphinx.ext.linkcode", + "sphinx_toolbox.rest_example", +] html_logo = "_static/scverse.svg" html_theme_options = {"announcement": ""} source_repository = "https://github.com/scverse/scverse-doc" @@ -13,3 +20,8 @@ from scverse_doc.registry import intersphinx # noqa: E402 intersphinx_mapping = intersphinx() | {"sphinx": ("https://www.sphinx-doc.org/en/master/", None)} + + +def setup(app: Sphinx) -> None: # noqa: D103 + # `sphinx_toolbox.rest_example` needs this, but only gets it from parsing an .rst file, and ours are all MyST + app.config.docutils_tab_width = 8 diff --git a/pyproject.toml b/pyproject.toml index fb84c60..43f01e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,8 @@ optional-dependencies.config = [ "sphinxext-opengraph", ] # Everything, as the `scverse_doc` extension needs. -optional-dependencies.full = [ "scverse-doc[config,theme]" ] +optional-dependencies.full = [ "scverse-doc[config,math-core,theme]" ] +optional-dependencies.math-core = [ "math-core" ] # `html_theme = "scverse"`, i.e. `scverse_doc.theme`. optional-dependencies.theme = [ "pydata-sphinx-theme>=0.23" ] # https://docs.pypi.org/project_metadata/#project-urls @@ -62,6 +63,7 @@ doc = [ "ipython", "scverse-misc[sphinx]>=0.1.2", "sphinx-autodoc-typehints", + "sphinx-toolbox", # `rest-example` directive "sphinxcontrib-bibtex>=1", ] typecheck = [ "mypy", "types-docutils", "wenmode" ] # wenmode for scripts/check_changelog.py diff --git a/src/scverse_doc/__init__.py b/src/scverse_doc/__init__.py index 7bc1110..fad10a1 100644 --- a/src/scverse_doc/__init__.py +++ b/src/scverse_doc/__init__.py @@ -16,6 +16,8 @@ The layout of the API pages. :mod:`scverse_doc.config` The extension stack and the shared defaults. +:mod:`scverse_doc.math_core` + Math rendered to MathML at build time, via math-core. :mod:`scverse_doc.napoleon` Prose return sections in NumPy-style docstrings. :mod:`scverse_doc.registry` @@ -40,7 +42,7 @@ from sphinx.util.typing import ExtensionMetadata -from . import autodoc, config, napoleon, registry, release_notes, source, theme, typed_fields, xref_overrides +from . import autodoc, config, math_core, napoleon, registry, release_notes, source, theme, typed_fields, xref_overrides from .config import _is_set_by_user if TYPE_CHECKING: @@ -50,6 +52,7 @@ __all__ = [ "autodoc", "config", + "math_core", "napoleon", "registry", "release_notes", diff --git a/src/scverse_doc/math_core.css b/src/scverse_doc/math_core.css new file mode 100644 index 0000000..f20e8b8 --- /dev/null +++ b/src/scverse_doc/math_core.css @@ -0,0 +1,7 @@ +/* SPDX-License-Identifier: MPL-2.0 */ +/* LaTeX that math-core couldn’t convert: the source, with the error as tooltip */ +.math-core-error { + color: var(--pst-color-danger, #d32f2f); + text-decoration: underline wavy; + cursor: help; +} diff --git a/src/scverse_doc/math_core.py b/src/scverse_doc/math_core.py new file mode 100644 index 0000000..8749fe8 --- /dev/null +++ b/src/scverse_doc/math_core.py @@ -0,0 +1,113 @@ +# SPDX-License-Identifier: MPL-2.0 +r"""Math rendering via ``math-core``. + +Browsers support MathML today, +so math-core_ is the most lightweight way to render math on the web. + +Examples +-------- +Inline math, and a block equation referenced via its ``:label:``: + +.. rest-example:: + + :math:`e^{i\pi} = -1`, see :eq:`math-example`. + + .. math:: + :label: math-example + + \operatorname{AA}(c_\text{fg}, c_\text{bg}) \geq 4.5 + +Equations separated by blank lines, or rows separated by ``\\``, are aligned at ``&``: + +.. rest-example:: + + .. math:: + + a &= b + c + + d &= e + +``:no-wrap:`` passes the LaTeX as is, e.g. for your own environment: + +.. rest-example:: + + .. math:: + :no-wrap: + + \begin{cases} 1 & x > 0 \\ 0 & \text{otherwise} \end{cases} + +.. _math-core: https://github.com/tmke8/math-core +""" + +from pathlib import Path + +from docutils import nodes +from sphinx.application import Sphinx +from sphinx.config import Config +from sphinx.locale import _ +from sphinx.util import logging +from sphinx.util.math import get_node_equation_number +from sphinx.util.typing import ExtensionMetadata +from sphinx.writers.html5 import HTML5Translator + +logger = logging.getLogger(__name__) + + +class _MathVisitor: + def __init__(self): + from math_core import LatexToMathML + + self.converter = LatexToMathML() + self.fallback = LatexToMathML(continue_on_error=True) + + def visit(self, renderer: HTML5Translator, node: nodes.math | nodes.math_block): + """Handles inline math `` :math:`…` `` and block math ``.. math:: …``.""" + from math_core import LatexError + + tex = node.astext() + if display_style := isinstance(node, nodes.math_block): + # `starttag` sets expected `id` for `:eq:` references; markup mirrors `sphinx.ext.mathjax` + renderer.body.append(renderer.starttag(node, "div", CLASS="math")) + if node["number"]: + renderer.body.append(f'({get_node_equation_number(renderer, node)})') + renderer.add_permalink_ref(node, _("Link to this equation")) + renderer.body.append("") + if not node.get("no-wrap", node.get("nowrap", False)): + tex = _wrap(tex) + try: + out = self.converter.convert_all([(tex, display_style)]) + except LatexError as e: + logger.warning("LaTeX error:%s", e, location=node) + out = self.fallback.convert_all([(tex, display_style)]) + renderer.body.extend(out) + if display_style: + renderer.body.append("\n") + raise nodes.SkipNode + + +def _wrap(tex: str) -> str: + """Align multiple equations or rows, like `sphinx.ext.mathjax`.""" + # uses ``aligned``, as math-core lacks ``split``: + # https://github.com/tmke8/math-core/issues/154 + # TODO: once split exists, we should use Sphinx’ `wrap_displaymath` instead of this + parts = [part.strip() for part in tex.split("\n\n") if part.strip()] + if len(parts) == 1 and r"\\" not in parts[0]: + return parts[0] + return r"\begin{aligned}" + r"\\".join(parts) + r"\end{aligned}" + + +def _add_css(app: Sphinx, config: Config) -> None: + config.html_static_path = [*config.html_static_path, str(_CSS)] + app.add_css_file(_CSS.name) + + +_CSS = Path(__file__).with_suffix(".css") + + +def setup(app: Sphinx): + """Register the custom math-core HTML renderer.""" + visitor = _MathVisitor() + # we raise SkipNode, so no need for depart (the `None`s) + app.add_html_math_renderer("mathcore", (visitor.visit, None), (visitor.visit, None)) + app.connect("config-inited", _add_css) + return ExtensionMetadata(parallel_read_safe=True) diff --git a/tests/test_math_core.py b/tests/test_math_core.py new file mode 100644 index 0000000..10a035a --- /dev/null +++ b/tests/test_math_core.py @@ -0,0 +1,83 @@ +# SPDX-License-Identifier: MPL-2.0 +"""Math rendered to MathML, with Sphinx’s equation numbers and error fallback.""" + +from __future__ import annotations + +import re + +import pytest +from sphinx.testing.util import SphinxTestApp + +pytest.importorskip("math_core") + + +@pytest.fixture(scope="module") +def build(tmp_path_factory: pytest.TempPathFactory) -> tuple[str, str]: + """Build a page with inline, labelled and broken math, and return its HTML and warnings.""" + srcdir = tmp_path_factory.mktemp("math_core") + (srcdir / "conf.py").write_text('extensions = ["scverse_doc.math_core"]\n') + (srcdir / "index.rst").write_text( + r"""Inline :math:`a^2`, see :eq:`foo`. + +.. math:: e = mc^2 + :label: foo + +.. math:: x<\frac{a + +.. math:: + + p &= q + + r &= s + +.. math:: + :no-wrap: + + \begin{cases} 1 \\ 0 \end{cases} +""" + ) + app = SphinxTestApp("html", srcdir=srcdir) + try: + app.build() + finally: + app.cleanup() + assert (app.outdir / "_static" / "math_core.css").is_file() + return (app.outdir / "index.html").read_text(), app.warning.getvalue() + + +def test_inline(build: tuple[str, str]) -> None: + html, _ = build + assert "a2" in html + + +def test_label(build: tuple[str, str]) -> None: + html, _ = build + assert '(1)' in html + assert '
\n(1)' in html + assert '' in html + + +def test_error(build: tuple[str, str]) -> None: + html, warnings = build + assert "index.rst:6: WARNING: LaTeX error:" in warnings + assert 'class="math-core-error"' in html + assert r"x<\frac{a" in html + + +def test_css(build: tuple[str, str]) -> None: + html, _ = build + assert 'href="_static/math_core.css' in html + + +def test_wrap(build: tuple[str, str]) -> None: + html, warnings = build + # both equations in one `aligned` table, without numbers + [block] = re.findall(r"]*>(?:(?!).)*p.*?", html) + assert "r" in block + assert "(" not in html + assert warnings.count("LaTeX error") == 1 # broken \frac + + +def test_no_wrap(build: tuple[str, str]) -> None: + html, _ = build + assert '{' in html or "{" in html From b217014680ab6e9c57e06b83840288873012fddb Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 23 Oct 2026 11:00:21 +0200 Subject: [PATCH 2/6] relnote --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 860ab21..bb1cc08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning][]. ## [Unreleased] +### Added + +- `scverse_doc.math_core` renders math to MathML at build time via [math-core](https://github.com/tmke8/math-core) instead of MathJax (#75) + ## [0.3.0] ### Added From cd667934216f4ee5bcb73efb769cd27babf14e60 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 23 Oct 2026 11:03:40 +0200 Subject: [PATCH 3/6] fix docs --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 43f01e7..eee0c55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,7 @@ doc = [ "ipython", "scverse-misc[sphinx]>=0.1.2", "sphinx-autodoc-typehints", - "sphinx-toolbox", # `rest-example` directive + "sphinx-toolbox>=4.3.1", # `rest-example` directive "sphinxcontrib-bibtex>=1", ] typecheck = [ "mypy", "types-docutils", "wenmode" ] # wenmode for scripts/check_changelog.py From 8e30c69c704550146e72f9330382d5747c4aec3b Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 23 Oct 2026 11:12:05 +0200 Subject: [PATCH 4/6] prevent MyST fuckups --- docs/api.md | 16 ++++++++-------- docs/api/autodoc.md | 5 ----- docs/api/autodoc.rst | 4 ++++ docs/api/{config.md => config.rst} | 5 ++--- docs/api/math_core.md | 5 ----- docs/api/math_core.rst | 4 ++++ docs/api/napoleon.md | 5 ----- docs/api/napoleon.rst | 4 ++++ docs/api/{registry.md => registry.rst} | 5 ++--- docs/api/{release_notes.md => release_notes.rst} | 5 ++--- docs/api/typed_fields.md | 5 ----- docs/api/typed_fields.rst | 4 ++++ .../{xref_overrides.md => xref_overrides.rst} | 5 ++--- docs/conf.py | 7 ------- 14 files changed, 32 insertions(+), 47 deletions(-) delete mode 100644 docs/api/autodoc.md create mode 100644 docs/api/autodoc.rst rename docs/api/{config.md => config.rst} (72%) delete mode 100644 docs/api/math_core.md create mode 100644 docs/api/math_core.rst delete mode 100644 docs/api/napoleon.md create mode 100644 docs/api/napoleon.rst rename docs/api/{registry.md => registry.rst} (76%) rename docs/api/{release_notes.md => release_notes.rst} (54%) delete mode 100644 docs/api/typed_fields.md create mode 100644 docs/api/typed_fields.rst rename docs/api/{xref_overrides.md => xref_overrides.rst} (54%) diff --git a/docs/api.md b/docs/api.md index 441398e..7698d8b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -9,16 +9,16 @@ ```{toctree} :hidden: -api/config.md +api/config.rst api/theme.md api/source.md -api/registry.md -api/autodoc.md -api/napoleon.md -api/math_core.md -api/release_notes.md -api/typed_fields.md -api/xref_overrides.md +api/registry.rst +api/autodoc.rst +api/napoleon.rst +api/math_core.rst +api/release_notes.rst +api/typed_fields.rst +api/xref_overrides.rst ``` ## {doc}`api/config` diff --git a/docs/api/autodoc.md b/docs/api/autodoc.md deleted file mode 100644 index 510a822..0000000 --- a/docs/api/autodoc.md +++ /dev/null @@ -1,5 +0,0 @@ -# `scverse_doc.autodoc` - -```{eval-rst} -.. automodule:: scverse_doc.autodoc -``` diff --git a/docs/api/autodoc.rst b/docs/api/autodoc.rst new file mode 100644 index 0000000..de87a06 --- /dev/null +++ b/docs/api/autodoc.rst @@ -0,0 +1,4 @@ +``scverse_doc.autodoc`` +======================= + +.. automodule:: scverse_doc.autodoc diff --git a/docs/api/config.md b/docs/api/config.rst similarity index 72% rename from docs/api/config.md rename to docs/api/config.rst index cc6ed3d..b93e716 100644 --- a/docs/api/config.md +++ b/docs/api/config.rst @@ -1,6 +1,6 @@ -# `scverse_doc.config` +``scverse_doc.config`` +====================== -```{eval-rst} .. automodule:: scverse_doc.config .. autodata:: EXTENSIONS @@ -8,4 +8,3 @@ .. autodata:: MYST_ENABLE_EXTENSIONS .. autodata:: DEFAULTS -``` diff --git a/docs/api/math_core.md b/docs/api/math_core.md deleted file mode 100644 index 2c3e14b..0000000 --- a/docs/api/math_core.md +++ /dev/null @@ -1,5 +0,0 @@ -# `scverse_doc.math_core` - -```{eval-rst} -.. automodule:: scverse_doc.math_core -``` diff --git a/docs/api/math_core.rst b/docs/api/math_core.rst new file mode 100644 index 0000000..b1ac574 --- /dev/null +++ b/docs/api/math_core.rst @@ -0,0 +1,4 @@ +``scverse_doc.math_core`` +========================= + +.. automodule:: scverse_doc.math_core diff --git a/docs/api/napoleon.md b/docs/api/napoleon.md deleted file mode 100644 index 1116885..0000000 --- a/docs/api/napoleon.md +++ /dev/null @@ -1,5 +0,0 @@ -# `scverse_doc.napoleon` - -```{eval-rst} -.. automodule:: scverse_doc.napoleon -``` diff --git a/docs/api/napoleon.rst b/docs/api/napoleon.rst new file mode 100644 index 0000000..5b24ef7 --- /dev/null +++ b/docs/api/napoleon.rst @@ -0,0 +1,4 @@ +``scverse_doc.napoleon`` +======================== + +.. automodule:: scverse_doc.napoleon diff --git a/docs/api/registry.md b/docs/api/registry.rst similarity index 76% rename from docs/api/registry.md rename to docs/api/registry.rst index 230bbb0..0ff97e6 100644 --- a/docs/api/registry.md +++ b/docs/api/registry.rst @@ -1,6 +1,6 @@ -# `scverse_doc.registry` +``scverse_doc.registry`` +======================== -```{eval-rst} .. automodule:: scverse_doc.registry .. autodata:: packages @@ -11,4 +11,3 @@ .. autoclass:: Package :members: -``` diff --git a/docs/api/release_notes.md b/docs/api/release_notes.rst similarity index 54% rename from docs/api/release_notes.md rename to docs/api/release_notes.rst index 2e4daf6..eeca292 100644 --- a/docs/api/release_notes.md +++ b/docs/api/release_notes.rst @@ -1,6 +1,5 @@ -# `scverse_doc.release_notes` +``scverse_doc.release_notes`` +============================= -```{eval-rst} .. automodule:: scverse_doc.release_notes :members: FULL_VERSION_RE -``` diff --git a/docs/api/typed_fields.md b/docs/api/typed_fields.md deleted file mode 100644 index 7ccc3ad..0000000 --- a/docs/api/typed_fields.md +++ /dev/null @@ -1,5 +0,0 @@ -# `scverse_doc.typed_fields` - -```{eval-rst} -.. automodule:: scverse_doc.typed_fields -``` diff --git a/docs/api/typed_fields.rst b/docs/api/typed_fields.rst new file mode 100644 index 0000000..a4bb0c4 --- /dev/null +++ b/docs/api/typed_fields.rst @@ -0,0 +1,4 @@ +``scverse_doc.typed_fields`` +============================ + +.. automodule:: scverse_doc.typed_fields diff --git a/docs/api/xref_overrides.md b/docs/api/xref_overrides.rst similarity index 54% rename from docs/api/xref_overrides.md rename to docs/api/xref_overrides.rst index 57ac1a3..501220b 100644 --- a/docs/api/xref_overrides.md +++ b/docs/api/xref_overrides.rst @@ -1,6 +1,5 @@ -# `scverse_doc.xref_overrides` +``scverse_doc.xref_overrides`` +============================== -```{eval-rst} .. automodule:: scverse_doc.xref_overrides :members: DEFAULT_OVERRIDES -``` diff --git a/docs/conf.py b/docs/conf.py index 707d8f7..3048c1c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,8 +1,6 @@ # SPDX-License-Identifier: MPL-2.0 """Sphinx docs configuration.""" -from sphinx.application import Sphinx - project = "scverse-doc" extensions = [ "scverse_doc", @@ -20,8 +18,3 @@ from scverse_doc.registry import intersphinx # noqa: E402 intersphinx_mapping = intersphinx() | {"sphinx": ("https://www.sphinx-doc.org/en/master/", None)} - - -def setup(app: Sphinx) -> None: # noqa: D103 - # `sphinx_toolbox.rest_example` needs this, but only gets it from parsing an .rst file, and ours are all MyST - app.config.docutils_tab_width = 8 From 096939e3d1073287bfd82039ff2fe552083d1bed Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 23 Oct 2026 12:06:34 +0200 Subject: [PATCH 5/6] center math --- src/scverse_doc/math_core.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/scverse_doc/math_core.css b/src/scverse_doc/math_core.css index f20e8b8..0580ac5 100644 --- a/src/scverse_doc/math_core.css +++ b/src/scverse_doc/math_core.css @@ -5,3 +5,9 @@ text-decoration: underline wavy; cursor: help; } + +/* pydata-sphinx-theme lays out `div.math` as a reversed flex row and only grows MathJax’s container */ +div.math > :not(.eqno) { + flex-grow: 1; + overflow: auto hidden; +} From d63b3e5b50868a501f361cf903d9583c2ce05249 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 27 Oct 2026 09:26:51 +0100 Subject: [PATCH 6/6] fixes --- .github/workflows/sync.yaml | 8 +- CHANGELOG.md | 6 ++ biome.jsonc | 1 + docs/api/math_core.rst | 19 ++++ pyproject.toml | 2 +- ...{sync_brand_tokens.py => sync_upstream.py} | 17 +++- src/scverse_doc/math_core.py | 24 +++-- src/scverse_doc/registry.py | 2 +- src/scverse_doc/{ => static}/math_core.css | 0 src/scverse_doc/static/mathmlfixes.css | 93 +++++++++++++++++++ .../theme/scverse/static/styles/_tokens.css | 2 +- tests/test_math_core.py | 13 ++- 12 files changed, 167 insertions(+), 20 deletions(-) rename scripts/{sync_brand_tokens.py => sync_upstream.py} (88%) rename src/scverse_doc/{ => static}/math_core.css (100%) create mode 100644 src/scverse_doc/static/mathmlfixes.css diff --git a/.github/workflows/sync.yaml b/.github/workflows/sync.yaml index 3812b14..0d82f14 100644 --- a/.github/workflows/sync.yaml +++ b/.github/workflows/sync.yaml @@ -8,6 +8,7 @@ on: paths: - src/scverse_doc/theme/scverse/static/styles/_tokens.css - src/scverse_doc/theme/scverse/static/scverse-fa.svg + - src/scverse_doc/static/mathmlfixes.css - scripts/** permissions: @@ -26,5 +27,10 @@ jobs: repository: scverse/scverse.github.io path: .upstream/website persist-credentials: false + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: tmke8/math-core + path: .upstream/math-core + persist-credentials: false - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 - - run: uv run scripts/sync_brand_tokens.py --website .upstream/website --check + - run: uv run scripts/sync_upstream.py --website .upstream/website --math-core .upstream/math-core --check diff --git a/CHANGELOG.md b/CHANGELOG.md index 259026d..2420188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,15 @@ and this project adheres to [Semantic Versioning][]. ## [Unreleased] +### Changed + +- Sphinx 9.1 or newer is required + ### Added - `scverse_doc.math_core` renders math to MathML at build time via [math-core](https://github.com/tmke8/math-core) instead of MathJax (#75) + - `math_core_options` configures the converter, e.g. with macros + - math-core’s `mathmlfixes.css` is included, fixing e.g. `\boxed` and `\cancel` in Chromium ### Fixed diff --git a/biome.jsonc b/biome.jsonc index d7adb9e..595b5b7 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -7,6 +7,7 @@ "**", "!src/scverse_doc/registry.json", "!src/scverse_doc/_accents.json", + "!src/scverse_doc/static/mathmlfixes.css", "!src/scverse_doc/theme/scverse/static/styles/_tokens.css", ], }, diff --git a/docs/api/math_core.rst b/docs/api/math_core.rst index b1ac574..0228c0f 100644 --- a/docs/api/math_core.rst +++ b/docs/api/math_core.rst @@ -2,3 +2,22 @@ ========================= .. automodule:: scverse_doc.math_core + +Configuration +------------- + +.. confval:: math_core_options + :type: dict + :default: ``{}`` + + Keyword arguments for math-core’s |LatexToMathML|_, e.g. macros + (named without the backslash, arguments as ``#1`` etc.): + + .. code-block:: python + + math_core_options = dict( + macros={"R": r"\mathbb{R}", "vec": r"\mathbf{#1}"}, + ) + +.. |LatexToMathML| replace:: ``LatexToMathML`` +.. _LatexToMathML: https://github.com/tmke8/math-core/tree/main/crates/math-core-python#latextomathml diff --git a/pyproject.toml b/pyproject.toml index d20d0f3..41aaa3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ "Typing :: Typed", ] dynamic = [ "readme", "version" ] -dependencies = [ "packaging", "sphinx>=8.1" ] +dependencies = [ "packaging", "sphinx>=9.1" ] # The extension stack of `scverse_doc.config`. optional-dependencies.config = [ "myst-nb>=1.1", diff --git a/scripts/sync_brand_tokens.py b/scripts/sync_upstream.py similarity index 88% rename from scripts/sync_brand_tokens.py rename to scripts/sync_upstream.py index 6f80cad..c64d17f 100755 --- a/scripts/sync_brand_tokens.py +++ b/scripts/sync_upstream.py @@ -4,7 +4,7 @@ # requires-python = ">=3.12" # dependencies = [] # /// -"""Refresh the generated brand assets from the scverse website. +"""Refresh the files generated or copied from upstream: the scverse website and math-core. The website is the brand’s source of truth, so transcribing its colours into this repository by hand @@ -20,9 +20,11 @@ The scverse logo used for the navbar link back to the website is copied verbatim for the same reason, as is ``accents.json``, which the registry reads the package accents from. +math-core’s stylesheet fixing browsers’ MathML rendering is copied verbatim from its repository. + Usage ----- - uv run scripts/sync_brand_tokens.py --website ../scverse.github.io + uv run scripts/sync_upstream.py --website ../scverse.github.io --math-core ../math-core """ from __future__ import annotations @@ -43,6 +45,11 @@ Path("assets/accents.json"): HERE.parent / "src" / "scverse_doc" / "_accents.json", } +#: math-core file -> package file, copied verbatim. +MATH_CORE_ASSETS = { + Path("css/mathmlfixes.css"): HERE.parent / "src" / "scverse_doc" / "static" / "mathmlfixes.css", +} + #: CSS custom property emitted here -> website custom property it is resolved from. #: The pairing mirrors the one scverse/scverse.github.io#329 used when replacing its SCSS variables. TOKEN_MAP = { @@ -66,7 +73,7 @@ "--scverse-color-gradient-end": ("scverse", "sky-blue"), } -BEGIN = "/* BEGIN GENERATED BRAND TOKENS – DO NOT EDIT; regenerate with scripts/sync_brand_tokens.py */" +BEGIN = "/* BEGIN GENERATED BRAND TOKENS – DO NOT EDIT; regenerate with scripts/sync_upstream.py */" END = "/* END GENERATED BRAND TOKENS */" #: The fenced region, captured together with the indentation of its opening marker. @@ -114,6 +121,7 @@ def main() -> int: """Write the refreshed token stylesheet to disk.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--website", type=Path, required=True, help="checkout of scverse/scverse.github.io") + parser.add_argument("--math-core", type=Path, required=True, help="checkout of tmke8/math-core") parser.add_argument("--check", action="store_true", help="fail instead of writing if the output would change") args = parser.parse_args() @@ -122,10 +130,11 @@ def main() -> int: updates = { TARGET: render(props, accents, TARGET.read_text()).encode(), **{dst: (args.website / src).read_bytes() for src, dst in ASSETS.items()}, + **{dst: (args.math_core / src).read_bytes() for src, dst in MATH_CORE_ASSETS.items()}, } if args.check: if outdated := [str(dst) for dst, new in updates.items() if not dst.is_file() or dst.read_bytes() != new]: - print(f"{', '.join(outdated)} out of date; rerun scripts/sync_brand_tokens.py", file=sys.stderr) + print(f"{', '.join(outdated)} out of date; rerun scripts/sync_upstream.py", file=sys.stderr) return 1 return 0 for dst, new in updates.items(): diff --git a/src/scverse_doc/math_core.py b/src/scverse_doc/math_core.py index 8749fe8..78db5fe 100644 --- a/src/scverse_doc/math_core.py +++ b/src/scverse_doc/math_core.py @@ -36,6 +36,9 @@ \begin{cases} 1 & x > 0 \\ 0 & \text{otherwise} \end{cases} +Configure the converter, e.g. with your own macros, via :confval:`math_core_options`. +math-core’s ``mathmlfixes.css`` is included, mainly to fix Chromium’s rendering. + .. _math-core: https://github.com/tmke8/math-core """ @@ -54,11 +57,12 @@ class _MathVisitor: - def __init__(self): + def configure(self, app: Sphinx, config: Config) -> None: + """Create the converters from :confval:`math_core_options`.""" from math_core import LatexToMathML - self.converter = LatexToMathML() - self.fallback = LatexToMathML(continue_on_error=True) + self.converter = LatexToMathML(**config.math_core_options) + self.fallback = LatexToMathML(**{**config.math_core_options, "continue_on_error": True}) def visit(self, renderer: HTML5Translator, node: nodes.math | nodes.math_block): """Handles inline math `` :math:`…` `` and block math ``.. math:: …``.""" @@ -96,12 +100,8 @@ def _wrap(tex: str) -> str: return r"\begin{aligned}" + r"\\".join(parts) + r"\end{aligned}" -def _add_css(app: Sphinx, config: Config) -> None: - config.html_static_path = [*config.html_static_path, str(_CSS)] - app.add_css_file(_CSS.name) - - -_CSS = Path(__file__).with_suffix(".css") +# `mathmlfixes.css` is synced from math-core by `scripts/sync_upstream.py` +_STATIC = Path(__file__).with_name("static") def setup(app: Sphinx): @@ -109,5 +109,9 @@ def setup(app: Sphinx): visitor = _MathVisitor() # we raise SkipNode, so no need for depart (the `None`s) app.add_html_math_renderer("mathcore", (visitor.visit, None), (visitor.visit, None)) - app.connect("config-inited", _add_css) + app.add_config_value("math_core_options", {}, "env", types=frozenset({dict})) + app.connect("config-inited", visitor.configure) + app.add_static_dir(_STATIC) + app.add_css_file("mathmlfixes.css") + app.add_css_file("math_core.css") return ExtensionMetadata(parallel_read_safe=True) diff --git a/src/scverse_doc/registry.py b/src/scverse_doc/registry.py index 2df8109..d19bd83 100644 --- a/src/scverse_doc/registry.py +++ b/src/scverse_doc/registry.py @@ -55,7 +55,7 @@ def _build_cache(app: Sphinx) -> Path: return _cache_dir -#: Brand accents from the website, copied by ``scripts/sync_brand_tokens.py``: +#: Brand accents from the website, copied by ``scripts/sync_upstream.py``: #: each key lists ``[name, [L, C, H]]`` pairs, primary first. _ACCENTS: dict[str, list[str]] = { pkg: ["oklch({} {} {})".format(*lch) for _, lch in colors] diff --git a/src/scverse_doc/math_core.css b/src/scverse_doc/static/math_core.css similarity index 100% rename from src/scverse_doc/math_core.css rename to src/scverse_doc/static/math_core.css diff --git a/src/scverse_doc/static/mathmlfixes.css b/src/scverse_doc/static/mathmlfixes.css new file mode 100644 index 0000000..1837369 --- /dev/null +++ b/src/scverse_doc/static/mathmlfixes.css @@ -0,0 +1,93 @@ +/* Style for unknown command; feel free to customize it */ + +merror.math-core-unknown-cmd { + border: unset; /* `merror` default is `1px solid red` */ + background-color: unset; /* `merror` default is `lightYellow` */ + color: #c00; /* `merror` default is `unset` */ +} + +/* Styles for all browsers */ + +mtd { + /* This is the default padding for mtd elements as defined in the MathML Core specification. */ + padding: 0.5ex 0.4em; +} + +mtr > mtd:first-child { + padding-left: 0; +} + +mtr > mtd:last-child { + padding-right: 0; +} + +/* Styles for Chromium only */ +@supports (not (-webkit-backdrop-filter: blur(1px))) and + (not (-moz-appearance: none)) { + menclose { + position: relative; + padding: 0.5ex 0; + } + menclose[notation~="box" i] { + border: 0.04em solid; + padding: 0.3rem; + } + menclose[notation~="actuarial" i] { + padding: 0.03889rem 0.03889rem 0 0.03889rem; + border-width: 0.08em 0.08em 0em 0em; + border-style: solid; + margin-right: 0.03889rem; + } + menclose[notation~="phasorangle" i] { + border-bottom: 0.067em solid; + padding: 0.2em 0.2em 0.1em 0.6em; + } + mrow.menclose-phasorangle { + display: inline-block; + position: absolute; + left: 0.5px; + bottom: -0.04em; + height: 100%; + aspect-ratio: 0.5; + background-color: currentcolor; + clip-path: polygon( + 0.05em 100%, + 0 calc(100% - 0.05em), + calc(100% - 0.05em) 0, + 100% 0.05em + ); + } + mrow.menclose-updiagonalstrike, + mrow.menclose-downdiagonalstrike { + display: inline-block; + position: absolute; + left: 0.5px; + bottom: 0; + width: 100%; + height: 100%; + background-color: currentcolor; + } + mrow.menclose-updiagonalstrike { + clip-path: polygon( + 0.05em 100%, + 0 calc(100% - 0.05em), + calc(100% - 0.05em) 0, + 100% 0.05em + ); + } + mrow.menclose-downdiagonalstrike { + clip-path: polygon( + 0em 0.05em, + 0.05em 0em, + 100% calc(100% - 0.05em), + calc(100% - 0.05em) 100% + ); + } +} + +/* Styles for Chromium and Safari */ +@supports (not (-moz-appearance: none)) { + mover[accent="true" i] > mo:nth-child(2) { + margin-bottom: 0.15ex; + } +} diff --git a/src/scverse_doc/theme/scverse/static/styles/_tokens.css b/src/scverse_doc/theme/scverse/static/styles/_tokens.css index dd6c0b0..f64de56 100644 --- a/src/scverse_doc/theme/scverse/static/styles/_tokens.css +++ b/src/scverse_doc/theme/scverse/static/styles/_tokens.css @@ -8,7 +8,7 @@ */ :root { - /* BEGIN GENERATED BRAND TOKENS – DO NOT EDIT; regenerate with scripts/sync_brand_tokens.py */ + /* BEGIN GENERATED BRAND TOKENS – DO NOT EDIT; regenerate with scripts/sync_upstream.py */ --scverse-color-gradient-start: oklch(0.405 0.205 270); --scverse-color-gradient-end: oklch(0.799 0.108 236); --scverse-color-background: light-dark(#fff,color-mix(in oklch, #000 76%, oklch(60% .02 245))); diff --git a/tests/test_math_core.py b/tests/test_math_core.py index 10a035a..91bae6c 100644 --- a/tests/test_math_core.py +++ b/tests/test_math_core.py @@ -15,9 +15,11 @@ def build(tmp_path_factory: pytest.TempPathFactory) -> tuple[str, str]: """Build a page with inline, labelled and broken math, and return its HTML and warnings.""" srcdir = tmp_path_factory.mktemp("math_core") - (srcdir / "conf.py").write_text('extensions = ["scverse_doc.math_core"]\n') + (srcdir / "conf.py").write_text( + 'extensions = ["scverse_doc.math_core"]\nmath_core_options = {"macros": {"R": r"\\mathbb{R}"}}\n' + ) (srcdir / "index.rst").write_text( - r"""Inline :math:`a^2`, see :eq:`foo`. + r"""Inline :math:`a^2`, see :eq:`foo`, in :math:`\R`. .. math:: e = mc^2 :label: foo @@ -42,6 +44,7 @@ def build(tmp_path_factory: pytest.TempPathFactory) -> tuple[str, str]: finally: app.cleanup() assert (app.outdir / "_static" / "math_core.css").is_file() + assert (app.outdir / "_static" / "mathmlfixes.css").is_file() return (app.outdir / "index.html").read_text(), app.warning.getvalue() @@ -67,6 +70,12 @@ def test_error(build: tuple[str, str]) -> None: def test_css(build: tuple[str, str]) -> None: html, _ = build assert 'href="_static/math_core.css' in html + assert 'href="_static/mathmlfixes.css' in html + + +def test_options(build: tuple[str, str]) -> None: + html, _ = build + assert "ℝ" in html def test_wrap(build: tuple[str, str]) -> None: