Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1r""" 

2This module supports embedded TeX expressions in matplotlib via dvipng 

3and dvips for the raster and postscript backends. The tex and 

4dvipng/dvips information is cached in ~/.matplotlib/tex.cache for reuse between 

5sessions 

6 

7Requirements: 

8 

9* latex 

10* \*Agg backends: dvipng>=1.6 

11* PS backend: psfrag, dvips, and Ghostscript>=8.60 

12 

13Backends: 

14 

15* \*Agg 

16* PS 

17* PDF 

18 

19For raster output, you can get RGBA numpy arrays from TeX expressions 

20as follows:: 

21 

22 texmanager = TexManager() 

23 s = ('\TeX\ is Number ' 

24 '$\displaystyle\sum_{n=1}^\infty\frac{-e^{i\pi}}{2^n}$!') 

25 Z = texmanager.get_rgba(s, fontsize=12, dpi=80, rgb=(1, 0, 0)) 

26 

27To enable tex rendering of all text in your matplotlib figure, set 

28:rc:`text.usetex` to True. 

29""" 

30 

31import copy 

32import functools 

33import glob 

34import hashlib 

35import logging 

36import os 

37from pathlib import Path 

38import re 

39import subprocess 

40 

41import numpy as np 

42 

43import matplotlib as mpl 

44from matplotlib import cbook, dviread, rcParams 

45 

46_log = logging.getLogger(__name__) 

47 

48 

49class TexManager: 

50 """ 

51 Convert strings to dvi files using TeX, caching the results to a directory. 

52 

53 Repeated calls to this constructor always return the same instance. 

54 """ 

55 

56 cachedir = mpl.get_cachedir() 

57 if cachedir is not None: 

58 texcache = os.path.join(cachedir, 'tex.cache') 

59 Path(texcache).mkdir(parents=True, exist_ok=True) 

60 else: 

61 # Should only happen in a restricted environment (such as Google App 

62 # Engine). Deal with this gracefully by not creating a cache directory. 

63 texcache = None 

64 

65 # Caches. 

66 rgba_arrayd = {} 

67 grey_arrayd = {} 

68 

69 serif = ('cmr', '') 

70 sans_serif = ('cmss', '') 

71 monospace = ('cmtt', '') 

72 cursive = ('pzc', r'\usepackage{chancery}') 

73 font_family = 'serif' 

74 font_families = ('serif', 'sans-serif', 'cursive', 'monospace') 

75 

76 font_info = { 

77 'new century schoolbook': ('pnc', r'\renewcommand{\rmdefault}{pnc}'), 

78 'bookman': ('pbk', r'\renewcommand{\rmdefault}{pbk}'), 

79 'times': ('ptm', r'\usepackage{mathptmx}'), 

80 'palatino': ('ppl', r'\usepackage{mathpazo}'), 

81 'zapf chancery': ('pzc', r'\usepackage{chancery}'), 

82 'cursive': ('pzc', r'\usepackage{chancery}'), 

83 'charter': ('pch', r'\usepackage{charter}'), 

84 'serif': ('cmr', ''), 

85 'sans-serif': ('cmss', ''), 

86 'helvetica': ('phv', r'\usepackage{helvet}'), 

87 'avant garde': ('pag', r'\usepackage{avant}'), 

88 'courier': ('pcr', r'\usepackage{courier}'), 

89 # Loading the type1ec package ensures that cm-super is installed, which 

90 # is necessary for unicode computer modern. (It also allows the use of 

91 # computer modern at arbitrary sizes, but that's just a side effect.) 

92 'monospace': ('cmtt', r'\usepackage{type1ec}'), 

93 'computer modern roman': ('cmr', r'\usepackage{type1ec}'), 

94 'computer modern sans serif': ('cmss', r'\usepackage{type1ec}'), 

95 'computer modern typewriter': ('cmtt', r'\usepackage{type1ec}')} 

96 

97 _rc_cache = None 

98 _rc_cache_keys = ( 

99 ('text.latex.preamble', 'text.latex.unicode', 'text.latex.preview', 

100 'font.family') + tuple('font.' + n for n in font_families)) 

101 

102 @functools.lru_cache() # Always return the same instance. 

103 def __new__(cls): 

104 self = object.__new__(cls) 

105 self._reinit() 

106 return self 

107 

108 def _reinit(self): 

109 if self.texcache is None: 

110 raise RuntimeError('Cannot create TexManager, as there is no ' 

111 'cache directory available') 

112 

113 Path(self.texcache).mkdir(parents=True, exist_ok=True) 

114 ff = rcParams['font.family'] 

115 if len(ff) == 1 and ff[0].lower() in self.font_families: 

116 self.font_family = ff[0].lower() 

117 elif isinstance(ff, str) and ff.lower() in self.font_families: 

118 self.font_family = ff.lower() 

119 else: 

120 _log.info('font.family must be one of (%s) when text.usetex is ' 

121 'True. serif will be used by default.', 

122 ', '.join(self.font_families)) 

123 self.font_family = 'serif' 

124 

125 fontconfig = [self.font_family] 

126 for font_family in self.font_families: 

127 font_family_attr = font_family.replace('-', '_') 

128 for font in rcParams['font.' + font_family]: 

129 if font.lower() in self.font_info: 

130 setattr(self, font_family_attr, 

131 self.font_info[font.lower()]) 

132 _log.debug('family: %s, font: %s, info: %s', 

133 font_family, font, self.font_info[font.lower()]) 

134 break 

135 else: 

136 _log.debug('%s font is not compatible with usetex.', 

137 font_family) 

138 else: 

139 _log.info('No LaTeX-compatible font found for the %s font ' 

140 'family in rcParams. Using default.', font_family) 

141 setattr(self, font_family_attr, self.font_info[font_family]) 

142 fontconfig.append(getattr(self, font_family_attr)[0]) 

143 # Add a hash of the latex preamble to self._fontconfig so that the 

144 # correct png is selected for strings rendered with same font and dpi 

145 # even if the latex preamble changes within the session 

146 preamble_bytes = self.get_custom_preamble().encode('utf-8') 

147 fontconfig.append(hashlib.md5(preamble_bytes).hexdigest()) 

148 self._fontconfig = ''.join(fontconfig) 

149 

150 # The following packages and commands need to be included in the latex 

151 # file's preamble: 

152 cmd = [self.serif[1], self.sans_serif[1], self.monospace[1]] 

153 if self.font_family == 'cursive': 

154 cmd.append(self.cursive[1]) 

155 self._font_preamble = '\n'.join( 

156 [r'\usepackage{type1cm}'] + cmd + [r'\usepackage{textcomp}']) 

157 

158 def get_basefile(self, tex, fontsize, dpi=None): 

159 """ 

160 Return a filename based on a hash of the string, fontsize, and dpi. 

161 """ 

162 s = ''.join([tex, self.get_font_config(), '%f' % fontsize, 

163 self.get_custom_preamble(), str(dpi or '')]) 

164 return os.path.join( 

165 self.texcache, hashlib.md5(s.encode('utf-8')).hexdigest()) 

166 

167 def get_font_config(self): 

168 """Reinitializes self if relevant rcParams on have changed.""" 

169 if self._rc_cache is None: 

170 self._rc_cache = dict.fromkeys(self._rc_cache_keys) 

171 changed = [par for par in self._rc_cache_keys 

172 if rcParams[par] != self._rc_cache[par]] 

173 if changed: 

174 _log.debug('following keys changed: %s', changed) 

175 for k in changed: 

176 _log.debug('%-20s: %-10s -> %-10s', 

177 k, self._rc_cache[k], rcParams[k]) 

178 # deepcopy may not be necessary, but feels more future-proof 

179 self._rc_cache[k] = copy.deepcopy(rcParams[k]) 

180 _log.debug('RE-INIT\nold fontconfig: %s', self._fontconfig) 

181 self._reinit() 

182 _log.debug('fontconfig: %s', self._fontconfig) 

183 return self._fontconfig 

184 

185 def get_font_preamble(self): 

186 """ 

187 Return a string containing font configuration for the tex preamble. 

188 """ 

189 return self._font_preamble 

190 

191 def get_custom_preamble(self): 

192 """Return a string containing user additions to the tex preamble.""" 

193 return rcParams['text.latex.preamble'] 

194 

195 def _get_preamble(self): 

196 unicode_preamble = "\n".join([ 

197 r"\usepackage[utf8]{inputenc}", 

198 r"\DeclareUnicodeCharacter{2212}{\ensuremath{-}}", 

199 ]) if rcParams["text.latex.unicode"] else "" 

200 return "\n".join([ 

201 r"\documentclass{article}", 

202 # Pass-through \mathdefault, which is used in non-usetex mode to 

203 # use the default text font but was historically suppressed in 

204 # usetex mode. 

205 r"\newcommand{\mathdefault}[1]{#1}", 

206 self._font_preamble, 

207 unicode_preamble, 

208 # Needs to come early so that the custom preamble can change the 

209 # geometry, e.g. in convert_psfrags. 

210 r"\usepackage[papersize=72in,body=70in,margin=1in]{geometry}", 

211 self.get_custom_preamble(), 

212 ]) 

213 

214 def make_tex(self, tex, fontsize): 

215 """ 

216 Generate a tex file to render the tex string at a specific font size. 

217 

218 Return the file name. 

219 """ 

220 basefile = self.get_basefile(tex, fontsize) 

221 texfile = '%s.tex' % basefile 

222 fontcmd = {'sans-serif': r'{\sffamily %s}', 

223 'monospace': r'{\ttfamily %s}'}.get(self.font_family, 

224 r'{\rmfamily %s}') 

225 tex = fontcmd % tex 

226 

227 s = r""" 

228%s 

229\pagestyle{empty} 

230\begin{document} 

231%% The empty hbox ensures that a page is printed even for empty inputs, except 

232%% when using psfrag which gets confused by it. 

233\fontsize{%f}{%f}%% 

234\ifdefined\psfrag\else\hbox{}\fi%% 

235%s 

236\end{document} 

237""" % (self._get_preamble(), fontsize, fontsize * 1.25, tex) 

238 with open(texfile, 'wb') as fh: 

239 if rcParams['text.latex.unicode']: 

240 fh.write(s.encode('utf8')) 

241 else: 

242 try: 

243 fh.write(s.encode('ascii')) 

244 except UnicodeEncodeError: 

245 _log.info("You are using unicode and latex, but have not " 

246 "enabled the 'text.latex.unicode' rcParam.") 

247 raise 

248 

249 return texfile 

250 

251 _re_vbox = re.compile( 

252 r"MatplotlibBox:\(([\d.]+)pt\+([\d.]+)pt\)x([\d.]+)pt") 

253 

254 def make_tex_preview(self, tex, fontsize): 

255 """ 

256 Generate a tex file to render the tex string at a specific font size. 

257 

258 It uses the preview.sty to determine the dimension (width, height, 

259 descent) of the output. 

260 

261 Return the file name. 

262 """ 

263 basefile = self.get_basefile(tex, fontsize) 

264 texfile = '%s.tex' % basefile 

265 fontcmd = {'sans-serif': r'{\sffamily %s}', 

266 'monospace': r'{\ttfamily %s}'}.get(self.font_family, 

267 r'{\rmfamily %s}') 

268 tex = fontcmd % tex 

269 

270 # newbox, setbox, immediate, etc. are used to find the box 

271 # extent of the rendered text. 

272 

273 s = r""" 

274%s 

275\usepackage[active,showbox,tightpage]{preview} 

276 

277%% we override the default showbox as it is treated as an error and makes 

278%% the exit status not zero 

279\def\showbox#1%% 

280{\immediate\write16{MatplotlibBox:(\the\ht#1+\the\dp#1)x\the\wd#1}} 

281 

282\begin{document} 

283\begin{preview} 

284{\fontsize{%f}{%f}%s} 

285\end{preview} 

286\end{document} 

287""" % (self._get_preamble(), fontsize, fontsize * 1.25, tex) 

288 with open(texfile, 'wb') as fh: 

289 if rcParams['text.latex.unicode']: 

290 fh.write(s.encode('utf8')) 

291 else: 

292 try: 

293 fh.write(s.encode('ascii')) 

294 except UnicodeEncodeError: 

295 _log.info("You are using unicode and latex, but have not " 

296 "enabled the 'text.latex.unicode' rcParam.") 

297 raise 

298 

299 return texfile 

300 

301 def _run_checked_subprocess(self, command, tex): 

302 _log.debug(cbook._pformat_subprocess(command)) 

303 try: 

304 report = subprocess.check_output(command, 

305 cwd=self.texcache, 

306 stderr=subprocess.STDOUT) 

307 except FileNotFoundError as exc: 

308 raise RuntimeError( 

309 'Failed to process string with tex because {} could not be ' 

310 'found'.format(command[0])) from exc 

311 except subprocess.CalledProcessError as exc: 

312 raise RuntimeError( 

313 '{prog} was not able to process the following string:\n' 

314 '{tex!r}\n\n' 

315 'Here is the full report generated by {prog}:\n' 

316 '{exc}\n\n'.format( 

317 prog=command[0], 

318 tex=tex.encode('unicode_escape'), 

319 exc=exc.output.decode('utf-8'))) from exc 

320 _log.debug(report) 

321 return report 

322 

323 def make_dvi(self, tex, fontsize): 

324 """ 

325 Generate a dvi file containing latex's layout of tex string. 

326 

327 Return the file name. 

328 """ 

329 

330 if rcParams['text.latex.preview']: 

331 return self.make_dvi_preview(tex, fontsize) 

332 

333 basefile = self.get_basefile(tex, fontsize) 

334 dvifile = '%s.dvi' % basefile 

335 if not os.path.exists(dvifile): 

336 texfile = self.make_tex(tex, fontsize) 

337 with cbook._lock_path(texfile): 

338 self._run_checked_subprocess( 

339 ["latex", "-interaction=nonstopmode", "--halt-on-error", 

340 texfile], tex) 

341 for fname in glob.glob(basefile + '*'): 

342 if not fname.endswith(('dvi', 'tex')): 

343 try: 

344 os.remove(fname) 

345 except OSError: 

346 pass 

347 

348 return dvifile 

349 

350 def make_dvi_preview(self, tex, fontsize): 

351 """ 

352 Generate a dvi file containing latex's layout of tex string. 

353 

354 It calls make_tex_preview() method and store the size information 

355 (width, height, descent) in a separate file. 

356 

357 Return the file name. 

358 """ 

359 basefile = self.get_basefile(tex, fontsize) 

360 dvifile = '%s.dvi' % basefile 

361 baselinefile = '%s.baseline' % basefile 

362 

363 if not os.path.exists(dvifile) or not os.path.exists(baselinefile): 

364 texfile = self.make_tex_preview(tex, fontsize) 

365 report = self._run_checked_subprocess( 

366 ["latex", "-interaction=nonstopmode", "--halt-on-error", 

367 texfile], tex) 

368 

369 # find the box extent information in the latex output 

370 # file and store them in ".baseline" file 

371 m = TexManager._re_vbox.search(report.decode("utf-8")) 

372 with open(basefile + '.baseline', "w") as fh: 

373 fh.write(" ".join(m.groups())) 

374 

375 for fname in glob.glob(basefile + '*'): 

376 if not fname.endswith(('dvi', 'tex', 'baseline')): 

377 try: 

378 os.remove(fname) 

379 except OSError: 

380 pass 

381 

382 return dvifile 

383 

384 def make_png(self, tex, fontsize, dpi): 

385 """ 

386 Generate a png file containing latex's rendering of tex string. 

387 

388 Return the file name. 

389 """ 

390 basefile = self.get_basefile(tex, fontsize, dpi) 

391 pngfile = '%s.png' % basefile 

392 # see get_rgba for a discussion of the background 

393 if not os.path.exists(pngfile): 

394 dvifile = self.make_dvi(tex, fontsize) 

395 cmd = ["dvipng", "-bg", "Transparent", "-D", str(dpi), 

396 "-T", "tight", "-o", pngfile, dvifile] 

397 # When testing, disable FreeType rendering for reproducibility; but 

398 # dvipng 1.16 has a bug (fixed in f3ff241) that breaks --freetype0 

399 # mode, so for it we keep FreeType enabled; the image will be 

400 # slightly off. 

401 if (getattr(mpl, "_called_from_pytest", False) 

402 and mpl._get_executable_info("dvipng").version != "1.16"): 

403 cmd.insert(1, "--freetype0") 

404 self._run_checked_subprocess(cmd, tex) 

405 return pngfile 

406 

407 def get_grey(self, tex, fontsize=None, dpi=None): 

408 """Return the alpha channel.""" 

409 from matplotlib import _png 

410 key = tex, self.get_font_config(), fontsize, dpi 

411 alpha = self.grey_arrayd.get(key) 

412 if alpha is None: 

413 pngfile = self.make_png(tex, fontsize, dpi) 

414 with open(os.path.join(self.texcache, pngfile), "rb") as file: 

415 X = _png.read_png(file) 

416 self.grey_arrayd[key] = alpha = X[:, :, -1] 

417 return alpha 

418 

419 def get_rgba(self, tex, fontsize=None, dpi=None, rgb=(0, 0, 0)): 

420 """Return latex's rendering of the tex string as an rgba array.""" 

421 if not fontsize: 

422 fontsize = rcParams['font.size'] 

423 if not dpi: 

424 dpi = rcParams['savefig.dpi'] 

425 r, g, b = rgb 

426 key = tex, self.get_font_config(), fontsize, dpi, tuple(rgb) 

427 Z = self.rgba_arrayd.get(key) 

428 

429 if Z is None: 

430 alpha = self.get_grey(tex, fontsize, dpi) 

431 Z = np.dstack([r, g, b, alpha]) 

432 self.rgba_arrayd[key] = Z 

433 

434 return Z 

435 

436 def get_text_width_height_descent(self, tex, fontsize, renderer=None): 

437 """Return width, height and descent of the text.""" 

438 if tex.strip() == '': 

439 return 0, 0, 0 

440 

441 dpi_fraction = renderer.points_to_pixels(1.) if renderer else 1 

442 

443 if rcParams['text.latex.preview']: 

444 # use preview.sty 

445 basefile = self.get_basefile(tex, fontsize) 

446 baselinefile = '%s.baseline' % basefile 

447 

448 if not os.path.exists(baselinefile): 

449 dvifile = self.make_dvi_preview(tex, fontsize) 

450 

451 with open(baselinefile) as fh: 

452 l = fh.read().split() 

453 height, depth, width = [float(l1) * dpi_fraction for l1 in l] 

454 return width, height + depth, depth 

455 

456 else: 

457 # use dviread. 

458 dvifile = self.make_dvi(tex, fontsize) 

459 with dviread.Dvi(dvifile, 72 * dpi_fraction) as dvi: 

460 page, = dvi 

461 # A total height (including the descent) needs to be returned. 

462 return page.width, page.height + page.descent, page.descent