mathmpl.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. r"""
  2. A role and directive to display mathtext in Sphinx
  3. ==================================================
  4. The ``mathmpl`` Sphinx extension creates a mathtext image in Matplotlib and
  5. shows it in html output. Thus, it is a true and faithful representation of what
  6. you will see if you pass a given LaTeX string to Matplotlib (see
  7. :ref:`mathtext`).
  8. .. warning::
  9. In most cases, you will likely want to use one of `Sphinx's builtin Math
  10. extensions
  11. <https://www.sphinx-doc.org/en/master/usage/extensions/math.html>`__
  12. instead of this one. The builtin Sphinx math directive uses MathJax to
  13. render mathematical expressions, and addresses accessibility concerns that
  14. ``mathmpl`` doesn't address.
  15. Mathtext may be included in two ways:
  16. 1. Inline, using the role::
  17. This text uses inline math: :mathmpl:`\alpha > \beta`.
  18. which produces:
  19. This text uses inline math: :mathmpl:`\alpha > \beta`.
  20. 2. Standalone, using the directive::
  21. Here is some standalone math:
  22. .. mathmpl::
  23. \alpha > \beta
  24. which produces:
  25. Here is some standalone math:
  26. .. mathmpl::
  27. \alpha > \beta
  28. Options
  29. -------
  30. The ``mathmpl`` role and directive both support the following options:
  31. fontset : str, default: 'cm'
  32. The font set to use when displaying math. See :rc:`mathtext.fontset`.
  33. fontsize : float
  34. The font size, in points. Defaults to the value from the extension
  35. configuration option defined below.
  36. Configuration options
  37. ---------------------
  38. The mathtext extension has the following configuration options:
  39. mathmpl_fontsize : float, default: 10.0
  40. Default font size, in points.
  41. mathmpl_srcset : list of str, default: []
  42. Additional image sizes to generate when embedding in HTML, to support
  43. `responsive resolution images
  44. <https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images>`__.
  45. The list should contain additional x-descriptors (``'1.5x'``, ``'2x'``,
  46. etc.) to generate (1x is the default and always included.)
  47. """
  48. import hashlib
  49. from pathlib import Path
  50. from docutils import nodes
  51. from docutils.parsers.rst import Directive, directives
  52. import sphinx
  53. from sphinx.errors import ConfigError, ExtensionError
  54. import matplotlib as mpl
  55. from matplotlib import _api, mathtext
  56. from matplotlib.rcsetup import validate_float_or_None
  57. # Define LaTeX math node:
  58. class latex_math(nodes.General, nodes.Element):
  59. pass
  60. def fontset_choice(arg):
  61. return directives.choice(arg, mathtext.MathTextParser._font_type_mapping)
  62. def math_role(role, rawtext, text, lineno, inliner,
  63. options={}, content=[]):
  64. i = rawtext.find('`')
  65. latex = rawtext[i+1:-1]
  66. node = latex_math(rawtext)
  67. node['latex'] = latex
  68. node['fontset'] = options.get('fontset', 'cm')
  69. node['fontsize'] = options.get('fontsize',
  70. setup.app.config.mathmpl_fontsize)
  71. return [node], []
  72. math_role.options = {'fontset': fontset_choice,
  73. 'fontsize': validate_float_or_None}
  74. class MathDirective(Directive):
  75. """
  76. The ``.. mathmpl::`` directive, as documented in the module's docstring.
  77. """
  78. has_content = True
  79. required_arguments = 0
  80. optional_arguments = 0
  81. final_argument_whitespace = False
  82. option_spec = {'fontset': fontset_choice,
  83. 'fontsize': validate_float_or_None}
  84. def run(self):
  85. latex = ''.join(self.content)
  86. node = latex_math(self.block_text)
  87. node['latex'] = latex
  88. node['fontset'] = self.options.get('fontset', 'cm')
  89. node['fontsize'] = self.options.get('fontsize',
  90. setup.app.config.mathmpl_fontsize)
  91. return [node]
  92. # This uses mathtext to render the expression
  93. def latex2png(latex, filename, fontset='cm', fontsize=10, dpi=100):
  94. with mpl.rc_context({'mathtext.fontset': fontset, 'font.size': fontsize}):
  95. try:
  96. depth = mathtext.math_to_image(
  97. f"${latex}$", filename, dpi=dpi, format="png")
  98. except Exception:
  99. _api.warn_external(f"Could not render math expression {latex}")
  100. depth = 0
  101. return depth
  102. # LaTeX to HTML translation stuff:
  103. def latex2html(node, source):
  104. inline = isinstance(node.parent, nodes.TextElement)
  105. latex = node['latex']
  106. fontset = node['fontset']
  107. fontsize = node['fontsize']
  108. name = 'math-{}'.format(
  109. hashlib.md5(f'{latex}{fontset}{fontsize}'.encode()).hexdigest()[-10:])
  110. destdir = Path(setup.app.builder.outdir, '_images', 'mathmpl')
  111. destdir.mkdir(parents=True, exist_ok=True)
  112. dest = destdir / f'{name}.png'
  113. depth = latex2png(latex, dest, fontset, fontsize=fontsize)
  114. srcset = []
  115. for size in setup.app.config.mathmpl_srcset:
  116. filename = f'{name}-{size.replace(".", "_")}.png'
  117. latex2png(latex, destdir / filename, fontset, fontsize=fontsize,
  118. dpi=100 * float(size[:-1]))
  119. srcset.append(
  120. f'{setup.app.builder.imgpath}/mathmpl/{filename} {size}')
  121. if srcset:
  122. srcset = (f'srcset="{setup.app.builder.imgpath}/mathmpl/{name}.png, ' +
  123. ', '.join(srcset) + '" ')
  124. if inline:
  125. cls = ''
  126. else:
  127. cls = 'class="center" '
  128. if inline and depth != 0:
  129. style = 'style="position: relative; bottom: -%dpx"' % (depth + 1)
  130. else:
  131. style = ''
  132. return (f'<img src="{setup.app.builder.imgpath}/mathmpl/{name}.png"'
  133. f' {srcset}{cls}{style}/>')
  134. def _config_inited(app, config):
  135. # Check for srcset hidpi images
  136. for i, size in enumerate(app.config.mathmpl_srcset):
  137. if size[-1] == 'x': # "2x" = "2.0"
  138. try:
  139. float(size[:-1])
  140. except ValueError:
  141. raise ConfigError(
  142. f'Invalid value for mathmpl_srcset parameter: {size!r}. '
  143. 'Must be a list of strings with the multiplicative '
  144. 'factor followed by an "x". e.g. ["2.0x", "1.5x"]')
  145. else:
  146. raise ConfigError(
  147. f'Invalid value for mathmpl_srcset parameter: {size!r}. '
  148. 'Must be a list of strings with the multiplicative '
  149. 'factor followed by an "x". e.g. ["2.0x", "1.5x"]')
  150. def setup(app):
  151. setup.app = app
  152. app.add_config_value('mathmpl_fontsize', 10.0, True)
  153. app.add_config_value('mathmpl_srcset', [], True)
  154. try:
  155. app.connect('config-inited', _config_inited) # Sphinx 1.8+
  156. except ExtensionError:
  157. app.connect('env-updated', lambda app, env: _config_inited(app, None))
  158. # Add visit/depart methods to HTML-Translator:
  159. def visit_latex_math_html(self, node):
  160. source = self.document.attributes['source']
  161. self.body.append(latex2html(node, source))
  162. def depart_latex_math_html(self, node):
  163. pass
  164. # Add visit/depart methods to LaTeX-Translator:
  165. def visit_latex_math_latex(self, node):
  166. inline = isinstance(node.parent, nodes.TextElement)
  167. if inline:
  168. self.body.append('$%s$' % node['latex'])
  169. else:
  170. self.body.extend(['\\begin{equation}',
  171. node['latex'],
  172. '\\end{equation}'])
  173. def depart_latex_math_latex(self, node):
  174. pass
  175. app.add_node(latex_math,
  176. html=(visit_latex_math_html, depart_latex_math_html),
  177. latex=(visit_latex_math_latex, depart_latex_math_latex))
  178. app.add_role('mathmpl', math_role)
  179. app.add_directive('mathmpl', MathDirective)
  180. if sphinx.version_info < (1, 8):
  181. app.add_role('math', math_role)
  182. app.add_directive('math', MathDirective)
  183. metadata = {'parallel_read_safe': True, 'parallel_write_safe': True}
  184. return metadata