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'