You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

468 lines
17 KiB

4 years ago
  1. r"""
  2. This module supports embedded TeX expressions in matplotlib via dvipng
  3. and dvips for the raster and postscript backends. The tex and
  4. dvipng/dvips information is cached in ~/.matplotlib/tex.cache for reuse between
  5. sessions
  6. Requirements:
  7. * latex
  8. * \*Agg backends: dvipng>=1.6
  9. * PS backend: psfrag, dvips, and Ghostscript>=8.60
  10. Backends:
  11. * \*Agg
  12. * PS
  13. * PDF
  14. For raster output, you can get RGBA numpy arrays from TeX expressions
  15. as follows::
  16. texmanager = TexManager()
  17. s = ('\TeX\ is Number '
  18. '$\displaystyle\sum_{n=1}^\infty\frac{-e^{i\pi}}{2^n}$!')
  19. Z = texmanager.get_rgba(s, fontsize=12, dpi=80, rgb=(1,0,0))
  20. To enable tex rendering of all text in your matplotlib figure, set
  21. :rc:`text.usetex` to True.
  22. """
  23. import copy
  24. import glob
  25. import hashlib
  26. import logging
  27. import os
  28. from pathlib import Path
  29. import re
  30. import subprocess
  31. import numpy as np
  32. import matplotlib as mpl
  33. from matplotlib import _png, cbook, dviread, rcParams
  34. _log = logging.getLogger(__name__)
  35. class TexManager(object):
  36. """
  37. Convert strings to dvi files using TeX, caching the results to a directory.
  38. """
  39. cachedir = mpl.get_cachedir()
  40. if cachedir is not None:
  41. texcache = os.path.join(cachedir, 'tex.cache')
  42. Path(texcache).mkdir(parents=True, exist_ok=True)
  43. else:
  44. # Should only happen in a restricted environment (such as Google App
  45. # Engine). Deal with this gracefully by not creating a cache directory.
  46. texcache = None
  47. # Caches.
  48. rgba_arrayd = {}
  49. grey_arrayd = {}
  50. postscriptd = property(mpl.cbook.deprecated("2.2")(lambda self: {}))
  51. pscnt = property(mpl.cbook.deprecated("2.2")(lambda self: 0))
  52. serif = ('cmr', '')
  53. sans_serif = ('cmss', '')
  54. monospace = ('cmtt', '')
  55. cursive = ('pzc', r'\usepackage{chancery}')
  56. font_family = 'serif'
  57. font_families = ('serif', 'sans-serif', 'cursive', 'monospace')
  58. font_info = {
  59. 'new century schoolbook': ('pnc', r'\renewcommand{\rmdefault}{pnc}'),
  60. 'bookman': ('pbk', r'\renewcommand{\rmdefault}{pbk}'),
  61. 'times': ('ptm', r'\usepackage{mathptmx}'),
  62. 'palatino': ('ppl', r'\usepackage{mathpazo}'),
  63. 'zapf chancery': ('pzc', r'\usepackage{chancery}'),
  64. 'cursive': ('pzc', r'\usepackage{chancery}'),
  65. 'charter': ('pch', r'\usepackage{charter}'),
  66. 'serif': ('cmr', ''),
  67. 'sans-serif': ('cmss', ''),
  68. 'helvetica': ('phv', r'\usepackage{helvet}'),
  69. 'avant garde': ('pag', r'\usepackage{avant}'),
  70. 'courier': ('pcr', r'\usepackage{courier}'),
  71. 'monospace': ('cmtt', ''),
  72. 'computer modern roman': ('cmr', ''),
  73. 'computer modern sans serif': ('cmss', ''),
  74. 'computer modern typewriter': ('cmtt', '')}
  75. _rc_cache = None
  76. _rc_cache_keys = (
  77. ('text.latex.preamble', 'text.latex.unicode', 'text.latex.preview',
  78. 'font.family') + tuple('font.' + n for n in font_families))
  79. def __init__(self):
  80. if self.texcache is None:
  81. raise RuntimeError('Cannot create TexManager, as there is no '
  82. 'cache directory available')
  83. Path(self.texcache).mkdir(parents=True, exist_ok=True)
  84. ff = rcParams['font.family']
  85. if len(ff) == 1 and ff[0].lower() in self.font_families:
  86. self.font_family = ff[0].lower()
  87. elif isinstance(ff, str) and ff.lower() in self.font_families:
  88. self.font_family = ff.lower()
  89. else:
  90. _log.info('font.family must be one of (%s) when text.usetex is '
  91. 'True. serif will be used by default.',
  92. ', '.join(self.font_families))
  93. self.font_family = 'serif'
  94. fontconfig = [self.font_family]
  95. for font_family in self.font_families:
  96. font_family_attr = font_family.replace('-', '_')
  97. for font in rcParams['font.' + font_family]:
  98. if font.lower() in self.font_info:
  99. setattr(self, font_family_attr,
  100. self.font_info[font.lower()])
  101. _log.debug('family: %s, font: %s, info: %s',
  102. font_family, font, self.font_info[font.lower()])
  103. break
  104. else:
  105. _log.debug('%s font is not compatible with usetex.',
  106. font_family)
  107. else:
  108. _log.info('No LaTeX-compatible font found for the %s font '
  109. 'family in rcParams. Using default.', font_family)
  110. setattr(self, font_family_attr, self.font_info[font_family])
  111. fontconfig.append(getattr(self, font_family_attr)[0])
  112. # Add a hash of the latex preamble to self._fontconfig so that the
  113. # correct png is selected for strings rendered with same font and dpi
  114. # even if the latex preamble changes within the session
  115. preamble_bytes = self.get_custom_preamble().encode('utf-8')
  116. fontconfig.append(hashlib.md5(preamble_bytes).hexdigest())
  117. self._fontconfig = ''.join(fontconfig)
  118. # The following packages and commands need to be included in the latex
  119. # file's preamble:
  120. cmd = [self.serif[1], self.sans_serif[1], self.monospace[1]]
  121. if self.font_family == 'cursive':
  122. cmd.append(self.cursive[1])
  123. self._font_preamble = '\n'.join(
  124. [r'\usepackage{type1cm}'] + cmd + [r'\usepackage{textcomp}'])
  125. def get_basefile(self, tex, fontsize, dpi=None):
  126. """
  127. Return a filename based on a hash of the string, fontsize, and dpi.
  128. """
  129. s = ''.join([tex, self.get_font_config(), '%f' % fontsize,
  130. self.get_custom_preamble(), str(dpi or '')])
  131. return os.path.join(
  132. self.texcache, hashlib.md5(s.encode('utf-8')).hexdigest())
  133. def get_font_config(self):
  134. """Reinitializes self if relevant rcParams on have changed."""
  135. if self._rc_cache is None:
  136. self._rc_cache = dict.fromkeys(self._rc_cache_keys)
  137. changed = [par for par in self._rc_cache_keys
  138. if rcParams[par] != self._rc_cache[par]]
  139. if changed:
  140. _log.debug('following keys changed: %s', changed)
  141. for k in changed:
  142. _log.debug('%-20s: %-10s -> %-10s',
  143. k, self._rc_cache[k], rcParams[k])
  144. # deepcopy may not be necessary, but feels more future-proof
  145. self._rc_cache[k] = copy.deepcopy(rcParams[k])
  146. _log.debug('RE-INIT\nold fontconfig: %s', self._fontconfig)
  147. self.__init__()
  148. _log.debug('fontconfig: %s', self._fontconfig)
  149. return self._fontconfig
  150. def get_font_preamble(self):
  151. """
  152. Return a string containing font configuration for the tex preamble.
  153. """
  154. return self._font_preamble
  155. def get_custom_preamble(self):
  156. """Return a string containing user additions to the tex preamble."""
  157. return '\n'.join(rcParams['text.latex.preamble'])
  158. def make_tex(self, tex, fontsize):
  159. """
  160. Generate a tex file to render the tex string at a specific font size.
  161. Return the file name.
  162. """
  163. basefile = self.get_basefile(tex, fontsize)
  164. texfile = '%s.tex' % basefile
  165. custom_preamble = self.get_custom_preamble()
  166. fontcmd = {'sans-serif': r'{\sffamily %s}',
  167. 'monospace': r'{\ttfamily %s}'}.get(self.font_family,
  168. r'{\rmfamily %s}')
  169. tex = fontcmd % tex
  170. if rcParams['text.latex.unicode']:
  171. unicode_preamble = r"""
  172. \usepackage[utf8]{inputenc}"""
  173. else:
  174. unicode_preamble = ''
  175. s = r"""
  176. \documentclass{article}
  177. %s
  178. %s
  179. %s
  180. \usepackage[papersize={72in,72in},body={70in,70in},margin={1in,1in}]{geometry}
  181. \pagestyle{empty}
  182. \begin{document}
  183. \fontsize{%f}{%f}%s
  184. \end{document}
  185. """ % (self._font_preamble, unicode_preamble, custom_preamble,
  186. fontsize, fontsize * 1.25, tex)
  187. with open(texfile, 'wb') as fh:
  188. if rcParams['text.latex.unicode']:
  189. fh.write(s.encode('utf8'))
  190. else:
  191. try:
  192. fh.write(s.encode('ascii'))
  193. except UnicodeEncodeError as err:
  194. _log.info("You are using unicode and latex, but have not "
  195. "enabled the 'text.latex.unicode' rcParam.")
  196. raise
  197. return texfile
  198. _re_vbox = re.compile(
  199. r"MatplotlibBox:\(([\d.]+)pt\+([\d.]+)pt\)x([\d.]+)pt")
  200. def make_tex_preview(self, tex, fontsize):
  201. """
  202. Generate a tex file to render the tex string at a specific font size.
  203. It uses the preview.sty to determine the dimension (width, height,
  204. descent) of the output.
  205. Return the file name.
  206. """
  207. basefile = self.get_basefile(tex, fontsize)
  208. texfile = '%s.tex' % basefile
  209. custom_preamble = self.get_custom_preamble()
  210. fontcmd = {'sans-serif': r'{\sffamily %s}',
  211. 'monospace': r'{\ttfamily %s}'}.get(self.font_family,
  212. r'{\rmfamily %s}')
  213. tex = fontcmd % tex
  214. if rcParams['text.latex.unicode']:
  215. unicode_preamble = r"""
  216. \usepackage[utf8]{inputenc}"""
  217. else:
  218. unicode_preamble = ''
  219. # newbox, setbox, immediate, etc. are used to find the box
  220. # extent of the rendered text.
  221. s = r"""
  222. \documentclass{article}
  223. %s
  224. %s
  225. %s
  226. \usepackage[active,showbox,tightpage]{preview}
  227. \usepackage[papersize={72in,72in},body={70in,70in},margin={1in,1in}]{geometry}
  228. %% we override the default showbox as it is treated as an error and makes
  229. %% the exit status not zero
  230. \def\showbox#1%%
  231. {\immediate\write16{MatplotlibBox:(\the\ht#1+\the\dp#1)x\the\wd#1}}
  232. \begin{document}
  233. \begin{preview}
  234. {\fontsize{%f}{%f}%s}
  235. \end{preview}
  236. \end{document}
  237. """ % (self._font_preamble, unicode_preamble, custom_preamble,
  238. fontsize, fontsize * 1.25, tex)
  239. with open(texfile, 'wb') as fh:
  240. if rcParams['text.latex.unicode']:
  241. fh.write(s.encode('utf8'))
  242. else:
  243. try:
  244. fh.write(s.encode('ascii'))
  245. except UnicodeEncodeError as err:
  246. _log.info("You are using unicode and latex, but have not "
  247. "enabled the 'text.latex.unicode' rcParam.")
  248. raise
  249. return texfile
  250. def _run_checked_subprocess(self, command, tex):
  251. _log.debug(command)
  252. try:
  253. report = subprocess.check_output(command,
  254. cwd=self.texcache,
  255. stderr=subprocess.STDOUT)
  256. except subprocess.CalledProcessError as exc:
  257. raise RuntimeError(
  258. '{prog} was not able to process the following string:\n'
  259. '{tex!r}\n\n'
  260. 'Here is the full report generated by {prog}:\n'
  261. '{exc}\n\n'.format(
  262. prog=command[0],
  263. tex=tex.encode('unicode_escape'),
  264. exc=exc.output.decode('utf-8')))
  265. _log.debug(report)
  266. return report
  267. def make_dvi(self, tex, fontsize):
  268. """
  269. Generate a dvi file containing latex's layout of tex string.
  270. Return the file name.
  271. """
  272. if rcParams['text.latex.preview']:
  273. return self.make_dvi_preview(tex, fontsize)
  274. basefile = self.get_basefile(tex, fontsize)
  275. dvifile = '%s.dvi' % basefile
  276. if not os.path.exists(dvifile):
  277. texfile = self.make_tex(tex, fontsize)
  278. with cbook._lock_path(texfile):
  279. self._run_checked_subprocess(
  280. ["latex", "-interaction=nonstopmode", "--halt-on-error",
  281. texfile], tex)
  282. for fname in glob.glob(basefile + '*'):
  283. if not fname.endswith(('dvi', 'tex')):
  284. try:
  285. os.remove(fname)
  286. except OSError:
  287. pass
  288. return dvifile
  289. def make_dvi_preview(self, tex, fontsize):
  290. """
  291. Generate a dvi file containing latex's layout of tex string.
  292. It calls make_tex_preview() method and store the size information
  293. (width, height, descent) in a separate file.
  294. Return the file name.
  295. """
  296. basefile = self.get_basefile(tex, fontsize)
  297. dvifile = '%s.dvi' % basefile
  298. baselinefile = '%s.baseline' % basefile
  299. if not os.path.exists(dvifile) or not os.path.exists(baselinefile):
  300. texfile = self.make_tex_preview(tex, fontsize)
  301. report = self._run_checked_subprocess(
  302. ["latex", "-interaction=nonstopmode", "--halt-on-error",
  303. texfile], tex)
  304. # find the box extent information in the latex output
  305. # file and store them in ".baseline" file
  306. m = TexManager._re_vbox.search(report.decode("utf-8"))
  307. with open(basefile + '.baseline', "w") as fh:
  308. fh.write(" ".join(m.groups()))
  309. for fname in glob.glob(basefile + '*'):
  310. if not fname.endswith(('dvi', 'tex', 'baseline')):
  311. try:
  312. os.remove(fname)
  313. except OSError:
  314. pass
  315. return dvifile
  316. def make_png(self, tex, fontsize, dpi):
  317. """
  318. Generate a png file containing latex's rendering of tex string.
  319. Return the file name.
  320. """
  321. basefile = self.get_basefile(tex, fontsize, dpi)
  322. pngfile = '%s.png' % basefile
  323. # see get_rgba for a discussion of the background
  324. if not os.path.exists(pngfile):
  325. dvifile = self.make_dvi(tex, fontsize)
  326. self._run_checked_subprocess(
  327. ["dvipng", "-bg", "Transparent", "-D", str(dpi),
  328. "-T", "tight", "-o", pngfile, dvifile], tex)
  329. return pngfile
  330. @mpl.cbook.deprecated("2.2")
  331. def make_ps(self, tex, fontsize):
  332. """
  333. Generate a postscript file containing latex's rendering of tex string.
  334. Return the file name.
  335. """
  336. basefile = self.get_basefile(tex, fontsize)
  337. psfile = '%s.epsf' % basefile
  338. if not os.path.exists(psfile):
  339. dvifile = self.make_dvi(tex, fontsize)
  340. self._run_checked_subprocess(
  341. ["dvips", "-q", "-E", "-o", psfile, dvifile], tex)
  342. return psfile
  343. @mpl.cbook.deprecated("2.2")
  344. def get_ps_bbox(self, tex, fontsize):
  345. """
  346. Return a list of PS bboxes for latex's rendering of the tex string.
  347. """
  348. psfile = self.make_ps(tex, fontsize)
  349. with open(psfile) as ps:
  350. for line in ps:
  351. if line.startswith('%%BoundingBox:'):
  352. return [int(val) for val in line.split()[1:]]
  353. raise RuntimeError('Could not parse %s' % psfile)
  354. def get_grey(self, tex, fontsize=None, dpi=None):
  355. """Return the alpha channel."""
  356. key = tex, self.get_font_config(), fontsize, dpi
  357. alpha = self.grey_arrayd.get(key)
  358. if alpha is None:
  359. pngfile = self.make_png(tex, fontsize, dpi)
  360. X = _png.read_png(os.path.join(self.texcache, pngfile))
  361. self.grey_arrayd[key] = alpha = X[:, :, -1]
  362. return alpha
  363. def get_rgba(self, tex, fontsize=None, dpi=None, rgb=(0, 0, 0)):
  364. """Return latex's rendering of the tex string as an rgba array."""
  365. if not fontsize:
  366. fontsize = rcParams['font.size']
  367. if not dpi:
  368. dpi = rcParams['savefig.dpi']
  369. r, g, b = rgb
  370. key = tex, self.get_font_config(), fontsize, dpi, tuple(rgb)
  371. Z = self.rgba_arrayd.get(key)
  372. if Z is None:
  373. alpha = self.get_grey(tex, fontsize, dpi)
  374. Z = np.dstack([r, g, b, alpha])
  375. self.rgba_arrayd[key] = Z
  376. return Z
  377. def get_text_width_height_descent(self, tex, fontsize, renderer=None):
  378. """Return width, height and descent of the text."""
  379. if tex.strip() == '':
  380. return 0, 0, 0
  381. dpi_fraction = renderer.points_to_pixels(1.) if renderer else 1
  382. if rcParams['text.latex.preview']:
  383. # use preview.sty
  384. basefile = self.get_basefile(tex, fontsize)
  385. baselinefile = '%s.baseline' % basefile
  386. if not os.path.exists(baselinefile):
  387. dvifile = self.make_dvi_preview(tex, fontsize)
  388. with open(baselinefile) as fh:
  389. l = fh.read().split()
  390. height, depth, width = [float(l1) * dpi_fraction for l1 in l]
  391. return width, height + depth, depth
  392. else:
  393. # use dviread. It sometimes returns a wrong descent.
  394. dvifile = self.make_dvi(tex, fontsize)
  395. with dviread.Dvi(dvifile, 72 * dpi_fraction) as dvi:
  396. page = next(iter(dvi))
  397. # A total height (including the descent) needs to be returned.
  398. return page.width, page.height + page.descent, page.descent