From ef01c5b6bd84075cb6063b0ab609f6fda61e7b3a Mon Sep 17 00:00:00 2001
From: Julien Schueller
Date: Fri, 23 Sep 2022 18:11:21 +0200
Subject: [PATCH] imgmath: Allow embedding images in HTML as base64 (#10816)
Co-authored-by: Adam Turner <9087854+aa-turner@users.noreply.github.com>
---
CHANGES | 1 +
doc/usage/extensions/math.rst | 6 ++++
sphinx/ext/imgmath.py | 64 ++++++++++++++++++++++++++---------
tests/test_ext_math.py | 18 ++++++++++
4 files changed, 73 insertions(+), 16 deletions(-)
diff --git a/CHANGES b/CHANGES
index 9c9596fcc64..eb75defe400 100644
--- a/CHANGES
+++ b/CHANGES
@@ -29,6 +29,7 @@ Features added
* #6316, #10804: Add domain objects to the table of contents. Patch by Adam Turner
* #6692: HTML Search: Include explicit :rst:dir:`index` directive index entries
in the search index and search results. Patch by Adam Turner
+* #10816: imgmath: Allow embedding images in HTML as base64
Bugs fixed
----------
diff --git a/doc/usage/extensions/math.rst b/doc/usage/extensions/math.rst
index 62630608993..9ce3efe4f56 100644
--- a/doc/usage/extensions/math.rst
+++ b/doc/usage/extensions/math.rst
@@ -133,6 +133,12 @@ are built:
elements (cf the `dvisvgm FAQ`_). This option is used only when
``imgmath_image_format`` is ``'svg'``.
+.. confval:: imgmath_embed
+
+ Default: ``False``. If true, encode LaTeX output images within HTML files
+ (base64 encoded) and do not save separate png/svg files to disk.
+
+ .. versionadded:: 5.2
:mod:`sphinx.ext.mathjax` -- Render math via JavaScript
-------------------------------------------------------
diff --git a/sphinx/ext/imgmath.py b/sphinx/ext/imgmath.py
index f0096ff2cd7..5bfca8d4d03 100644
--- a/sphinx/ext/imgmath.py
+++ b/sphinx/ext/imgmath.py
@@ -1,5 +1,6 @@
"""Render math in HTML via dvipng or dvisvgm."""
+import base64
import posixpath
import re
import shutil
@@ -30,6 +31,8 @@
templates_path = path.join(package_dir, 'templates', 'imgmath')
+__all__ = ()
+
class MathExtError(SphinxError):
category = 'Math extension error'
@@ -204,13 +207,17 @@ def convert_dvi_to_svg(dvipath: str, builder: Builder) -> Tuple[str, Optional[in
return filename, depth
-def render_math(self: HTMLTranslator, math: str) -> Tuple[Optional[str], Optional[int]]:
+def render_math(
+ self: HTMLTranslator,
+ math: str,
+) -> Tuple[Optional[str], Optional[int], Optional[str], Optional[str]]:
"""Render the LaTeX math expression *math* using latex and dvipng or
dvisvgm.
Return the filename relative to the built document and the "depth",
that is, the distance of image bottom and baseline in pixels, if the
option to use preview_latex is switched on.
+ Also return the temporary and destination files.
Error handling may seem strange, but follows a pattern: if LaTeX or dvipng
(dvisvgm) aren't available, only a warning is generated (since that enables
@@ -235,19 +242,19 @@ def render_math(self: HTMLTranslator, math: str) -> Tuple[Optional[str], Optiona
depth = read_png_depth(outfn)
elif image_format == 'svg':
depth = read_svg_depth(outfn)
- return relfn, depth
+ return relfn, depth, None, outfn
# if latex or dvipng (dvisvgm) has failed once, don't bother to try again
if hasattr(self.builder, '_imgmath_warned_latex') or \
hasattr(self.builder, '_imgmath_warned_image_translator'):
- return None, None
+ return None, None, None, None
# .tex -> .dvi
try:
dvipath = compile_math(latex, self.builder)
except InvokeError:
self.builder._imgmath_warned_latex = True # type: ignore
- return None, None
+ return None, None, None, None
# .dvi -> .png/.svg
try:
@@ -257,13 +264,19 @@ def render_math(self: HTMLTranslator, math: str) -> Tuple[Optional[str], Optiona
imgpath, depth = convert_dvi_to_svg(dvipath, self.builder)
except InvokeError:
self.builder._imgmath_warned_image_translator = True # type: ignore
- return None, None
+ return None, None, None, None
+
+ return relfn, depth, imgpath, outfn
- # Move generated image on tempdir to build dir
- ensuredir(path.dirname(outfn))
- shutil.move(imgpath, outfn)
- return relfn, depth
+def render_maths_to_base64(image_format: str, outfn: Optional[str]) -> str:
+ with open(outfn, "rb") as f:
+ encoded = base64.b64encode(f.read()).decode(encoding='utf-8')
+ if image_format == 'png':
+ return f'data:image/png;base64,{encoded}'
+ if image_format == 'svg':
+ return f'data:image/svg+xml;base64,{encoded}'
+ raise MathExtError('imgmath_image_format must be either "png" or "svg"')
def cleanup_tempdir(app: Sphinx, exc: Exception) -> None:
@@ -285,7 +298,7 @@ def get_tooltip(self: HTMLTranslator, node: Element) -> str:
def html_visit_math(self: HTMLTranslator, node: nodes.math) -> None:
try:
- fname, depth = render_math(self, '$' + node.astext() + '$')
+ fname, depth, imgpath, outfn = render_math(self, '$' + node.astext() + '$')
except MathExtError as exc:
msg = str(exc)
sm = nodes.system_message(msg, type='WARNING', level=2,
@@ -293,14 +306,23 @@ def html_visit_math(self: HTMLTranslator, node: nodes.math) -> None:
sm.walkabout(self)
logger.warning(__('display latex %r: %s'), node.astext(), msg)
raise nodes.SkipNode from exc
- if fname is None:
+ if self.builder.config.imgmath_embed:
+ image_format = self.builder.config.imgmath_image_format.lower()
+ img_src = render_maths_to_base64(image_format, outfn)
+ else:
+ # Move generated image on tempdir to build dir
+ if imgpath is not None:
+ ensuredir(path.dirname(outfn))
+ shutil.move(imgpath, outfn)
+ img_src = fname
+ if img_src is None:
# something failed -- use text-only as a bad substitute
self.body.append('%s' %
self.encode(node.astext()).strip())
else:
- c = ('')
raise nodes.SkipNode
@@ -311,7 +333,7 @@ def html_visit_displaymath(self: HTMLTranslator, node: nodes.math_block) -> None
else:
latex = wrap_displaymath(node.astext(), None, False)
try:
- fname, depth = render_math(self, latex)
+ fname, depth, imgpath, outfn = render_math(self, latex)
except MathExtError as exc:
msg = str(exc)
sm = nodes.system_message(msg, type='WARNING', level=2,
@@ -326,12 +348,21 @@ def html_visit_displaymath(self: HTMLTranslator, node: nodes.math_block) -> None
self.body.append('(%s)' % number)
self.add_permalink_ref(node, _('Permalink to this equation'))
self.body.append('')
- if fname is None:
+ if self.builder.config.imgmath_embed:
+ image_format = self.builder.config.imgmath_image_format.lower()
+ img_src = render_maths_to_base64(image_format, outfn)
+ else:
+ # Move generated image on tempdir to build dir
+ if imgpath is not None:
+ ensuredir(path.dirname(outfn))
+ shutil.move(imgpath, outfn)
+ img_src = fname
+ if img_src is None:
# something failed -- use text-only as a bad substitute
self.body.append('%s
\n' %
self.encode(node.astext()).strip())
else:
- self.body.append(('\n')
raise nodes.SkipNode
@@ -354,5 +385,6 @@ def setup(app: Sphinx) -> Dict[str, Any]:
app.add_config_value('imgmath_latex_preamble', '', 'html')
app.add_config_value('imgmath_add_tooltips', True, 'html')
app.add_config_value('imgmath_font_size', 12, 'html')
+ app.add_config_value('imgmath_embed', False, 'html', [bool])
app.connect('build-finished', cleanup_tempdir)
return {'version': sphinx.__display_version__, 'parallel_read_safe': True}
diff --git a/tests/test_ext_math.py b/tests/test_ext_math.py
index 4e9cb3aa602..7b11ea3be56 100644
--- a/tests/test_ext_math.py
+++ b/tests/test_ext_math.py
@@ -56,6 +56,24 @@ def test_imgmath_svg(app, status, warning):
assert re.search(html, content, re.S)
+@pytest.mark.skipif(not has_binary('dvisvgm'),
+ reason='Requires dvisvgm" binary')
+@pytest.mark.sphinx('html', testroot='ext-math-simple',
+ confoverrides={'extensions': ['sphinx.ext.imgmath'],
+ 'imgmath_image_format': 'svg',
+ 'imgmath_embed': True})
+def test_imgmath_svg_embed(app, status, warning):
+ app.builder.build_all()
+ if "LaTeX command 'latex' cannot be run" in warning.getvalue():
+ pytest.skip('LaTeX command "latex" is not available')
+ if "dvisvgm command 'dvisvgm' cannot be run" in warning.getvalue():
+ pytest.skip('dvisvgm command "dvisvgm" is not available')
+
+ content = (app.outdir / 'index.html').read_text(encoding='utf8')
+ html = r'