Skip to content

Commit

Permalink
imgmath: Allow embedding images in HTML as base64 (#10816)
Browse files Browse the repository at this point in the history
Co-authored-by: Adam Turner <[email protected]>
  • Loading branch information
jschueller and AA-Turner authored Sep 23, 2022
1 parent cb77162 commit ef01c5b
Show file tree
Hide file tree
Showing 4 changed files with 73 additions and 16 deletions.
1 change: 1 addition & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand Down
6 changes: 6 additions & 0 deletions doc/usage/extensions/math.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------------------------------------------------
Expand Down
64 changes: 48 additions & 16 deletions sphinx/ext/imgmath.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Render math in HTML via dvipng or dvisvgm."""

import base64
import posixpath
import re
import shutil
Expand Down Expand Up @@ -30,6 +31,8 @@

templates_path = path.join(package_dir, 'templates', 'imgmath')

__all__ = ()


class MathExtError(SphinxError):
category = 'Math extension error'
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -285,22 +298,31 @@ 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,
backrefs=[], source=node.astext())
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('<span class="math">%s</span>' %
self.encode(node.astext()).strip())
else:
c = ('<img class="math" src="%s"' % fname) + get_tooltip(self, node)
c = f'<img class="math" src="{img_src}"' + get_tooltip(self, node)
if depth is not None:
c += ' style="vertical-align: %dpx"' % (-depth)
c += f' style="vertical-align: {-depth:d}px"'
self.body.append(c + '/>')
raise nodes.SkipNode

Expand All @@ -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,
Expand All @@ -326,12 +348,21 @@ def html_visit_displaymath(self: HTMLTranslator, node: nodes.math_block) -> None
self.body.append('<span class="eqno">(%s)' % number)
self.add_permalink_ref(node, _('Permalink to this equation'))
self.body.append('</span>')
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('<span class="math">%s</span></p>\n</div>' %
self.encode(node.astext()).strip())
else:
self.body.append(('<img src="%s"' % fname) + get_tooltip(self, node) +
self.body.append(f'<img src="{img_src}"' + get_tooltip(self, node) +
'/></p>\n</div>')
raise nodes.SkipNode

Expand All @@ -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}
18 changes: 18 additions & 0 deletions tests/test_ext_math.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'<img src="data:image/svg\+xml;base64,[\w\+/=]+"'
assert re.search(html, content, re.DOTALL)


@pytest.mark.sphinx('html', testroot='ext-math',
confoverrides={'extensions': ['sphinx.ext.mathjax'],
'mathjax_options': {'integrity': 'sha384-0123456789'}})
Expand Down

0 comments on commit ef01c5b

Please sign in to comment.