imgmath: Allow embedding images in HTML as base64 (#10816)

Co-authored-by: Adam Turner <9087854+aa-turner@users.noreply.github.com>
This commit is contained in:
Julien Schueller
2022-09-23 18:11:21 +02:00
committed by GitHub
parent cb77162144
commit ef01c5b6bd
4 changed files with 73 additions and 16 deletions

View File

@@ -29,6 +29,7 @@ Features added
* #6316, #10804: Add domain objects to the table of contents. Patch by Adam Turner * #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 * #6692: HTML Search: Include explicit :rst:dir:`index` directive index entries
in the search index and search results. Patch by Adam Turner in the search index and search results. Patch by Adam Turner
* #10816: imgmath: Allow embedding images in HTML as base64
Bugs fixed Bugs fixed
---------- ----------

View File

@@ -133,6 +133,12 @@ are built:
elements (cf the `dvisvgm FAQ`_). This option is used only when elements (cf the `dvisvgm FAQ`_). This option is used only when
``imgmath_image_format`` is ``'svg'``. ``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 :mod:`sphinx.ext.mathjax` -- Render math via JavaScript
------------------------------------------------------- -------------------------------------------------------

View File

@@ -1,5 +1,6 @@
"""Render math in HTML via dvipng or dvisvgm.""" """Render math in HTML via dvipng or dvisvgm."""
import base64
import posixpath import posixpath
import re import re
import shutil import shutil
@@ -30,6 +31,8 @@ logger = logging.getLogger(__name__)
templates_path = path.join(package_dir, 'templates', 'imgmath') templates_path = path.join(package_dir, 'templates', 'imgmath')
__all__ = ()
class MathExtError(SphinxError): class MathExtError(SphinxError):
category = 'Math extension error' category = 'Math extension error'
@@ -204,13 +207,17 @@ def convert_dvi_to_svg(dvipath: str, builder: Builder) -> Tuple[str, Optional[in
return filename, depth 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 """Render the LaTeX math expression *math* using latex and dvipng or
dvisvgm. dvisvgm.
Return the filename relative to the built document and the "depth", Return the filename relative to the built document and the "depth",
that is, the distance of image bottom and baseline in pixels, if the that is, the distance of image bottom and baseline in pixels, if the
option to use preview_latex is switched on. 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 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 (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) depth = read_png_depth(outfn)
elif image_format == 'svg': elif image_format == 'svg':
depth = read_svg_depth(outfn) 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 latex or dvipng (dvisvgm) has failed once, don't bother to try again
if hasattr(self.builder, '_imgmath_warned_latex') or \ if hasattr(self.builder, '_imgmath_warned_latex') or \
hasattr(self.builder, '_imgmath_warned_image_translator'): hasattr(self.builder, '_imgmath_warned_image_translator'):
return None, None return None, None, None, None
# .tex -> .dvi # .tex -> .dvi
try: try:
dvipath = compile_math(latex, self.builder) dvipath = compile_math(latex, self.builder)
except InvokeError: except InvokeError:
self.builder._imgmath_warned_latex = True # type: ignore self.builder._imgmath_warned_latex = True # type: ignore
return None, None return None, None, None, None
# .dvi -> .png/.svg # .dvi -> .png/.svg
try: 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) imgpath, depth = convert_dvi_to_svg(dvipath, self.builder)
except InvokeError: except InvokeError:
self.builder._imgmath_warned_image_translator = True # type: ignore self.builder._imgmath_warned_image_translator = True # type: ignore
return None, None return None, None, None, None
# Move generated image on tempdir to build dir return relfn, depth, imgpath, outfn
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: 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: def html_visit_math(self: HTMLTranslator, node: nodes.math) -> None:
try: try:
fname, depth = render_math(self, '$' + node.astext() + '$') fname, depth, imgpath, outfn = render_math(self, '$' + node.astext() + '$')
except MathExtError as exc: except MathExtError as exc:
msg = str(exc) msg = str(exc)
sm = nodes.system_message(msg, type='WARNING', level=2, 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) sm.walkabout(self)
logger.warning(__('display latex %r: %s'), node.astext(), msg) logger.warning(__('display latex %r: %s'), node.astext(), msg)
raise nodes.SkipNode from exc 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 # something failed -- use text-only as a bad substitute
self.body.append('<span class="math">%s</span>' % self.body.append('<span class="math">%s</span>' %
self.encode(node.astext()).strip()) self.encode(node.astext()).strip())
else: 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: if depth is not None:
c += ' style="vertical-align: %dpx"' % (-depth) c += f' style="vertical-align: {-depth:d}px"'
self.body.append(c + '/>') self.body.append(c + '/>')
raise nodes.SkipNode raise nodes.SkipNode
@@ -311,7 +333,7 @@ def html_visit_displaymath(self: HTMLTranslator, node: nodes.math_block) -> None
else: else:
latex = wrap_displaymath(node.astext(), None, False) latex = wrap_displaymath(node.astext(), None, False)
try: try:
fname, depth = render_math(self, latex) fname, depth, imgpath, outfn = render_math(self, latex)
except MathExtError as exc: except MathExtError as exc:
msg = str(exc) msg = str(exc)
sm = nodes.system_message(msg, type='WARNING', level=2, 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('<span class="eqno">(%s)' % number) self.body.append('<span class="eqno">(%s)' % number)
self.add_permalink_ref(node, _('Permalink to this equation')) self.add_permalink_ref(node, _('Permalink to this equation'))
self.body.append('</span>') 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 # something failed -- use text-only as a bad substitute
self.body.append('<span class="math">%s</span></p>\n</div>' % self.body.append('<span class="math">%s</span></p>\n</div>' %
self.encode(node.astext()).strip()) self.encode(node.astext()).strip())
else: 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>') '/></p>\n</div>')
raise nodes.SkipNode 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_latex_preamble', '', 'html')
app.add_config_value('imgmath_add_tooltips', True, 'html') app.add_config_value('imgmath_add_tooltips', True, 'html')
app.add_config_value('imgmath_font_size', 12, '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) app.connect('build-finished', cleanup_tempdir)
return {'version': sphinx.__display_version__, 'parallel_read_safe': True} return {'version': sphinx.__display_version__, 'parallel_read_safe': True}

View File

@@ -56,6 +56,24 @@ def test_imgmath_svg(app, status, warning):
assert re.search(html, content, re.S) 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', @pytest.mark.sphinx('html', testroot='ext-math',
confoverrides={'extensions': ['sphinx.ext.mathjax'], confoverrides={'extensions': ['sphinx.ext.mathjax'],
'mathjax_options': {'integrity': 'sha384-0123456789'}}) 'mathjax_options': {'integrity': 'sha384-0123456789'}})