Skip to content

Commit b9bec7a

Browse files
committed
Use luatex's kpsewhich for speed.
The machinery for running an interactive tex session mostly already existed in backend_pgf, so directly reuse that (as a base class). On the matplotlib macos, this significantly speeds up ```sh python -c 'from pylab import *; mpl.use("pdf"); rcParams["text.usetex"] = True; plot(); savefig("/tmp/test.pdf", backend="pdf")' ``` from ~4.5s to ~2.5s. Note that filesystem encodings may be a bit iffy, we still need to check how things go through the various layers here; it may end up being best to make the process streams be binary and perform the encoding/decoding ourselves. (But luatex itself uses utf-8, not ascii.) We also need to figure out how to best advertise this (do we emit a warning suggesting to install luatex on windows and macos if luatex is not present?).
1 parent cdcb729 commit b9bec7a

File tree

4 files changed

+122
-95
lines changed

4 files changed

+122
-95
lines changed

doc/missing-references.json

+3
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,9 @@
406406
"matplotlib.projections.geo.MollweideAxes": [
407407
"doc/api/artist_api.rst:189"
408408
],
409+
"matplotlib.texmanager._InteractiveTex": [
410+
"lib/matplotlib/backends/backend_pgf.py:docstring of matplotlib.backends.backend_pgf.LatexManager:1"
411+
],
409412
"matplotlib.text._AnnotationBase": [
410413
"doc/api/artist_api.rst:189",
411414
"lib/matplotlib/offsetbox.py:docstring of matplotlib.offsetbox.AnnotationBbox:1",

lib/matplotlib/backends/backend_pgf.py

+15-94
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from PIL import Image
1717

1818
import matplotlib as mpl
19-
from matplotlib import _api, cbook, font_manager as fm
19+
from matplotlib import _api, cbook, font_manager as fm, texmanager
2020
from matplotlib.backend_bases import (
2121
_Backend, _check_savefig_extra_args, FigureCanvasBase, FigureManagerBase,
2222
GraphicsContextBase, RendererBase, _no_output_draw
@@ -188,25 +188,24 @@ def gs_convert(pdffile, pngfile, dpi):
188188
raise RuntimeError("No suitable pdf to png renderer found.")
189189

190190

191-
class LatexError(Exception):
192-
def __init__(self, message, latex_output=""):
193-
super().__init__(message)
194-
self.latex_output = latex_output
191+
LatexError = texmanager._InteractiveTex.TexError
195192

196-
def __str__(self):
197-
s, = self.args
198-
if self.latex_output:
199-
s += "\n" + self.latex_output
200-
return s
201193

202-
203-
class LatexManager:
194+
class LatexManager(texmanager._InteractiveTex):
204195
"""
205196
The LatexManager opens an instance of the LaTeX application for
206197
determining the metrics of text elements. The LaTeX environment can be
207198
modified by setting fonts and/or a custom preamble in `.rcParams`.
208199
"""
209200

201+
# Backcompat properties.
202+
tmpdir = property(lambda self: self._tmpdir.name)
203+
texcommand = property(lambda self: self._texcmd)
204+
latex = property(
205+
lambda self: self._tex if self._tex.poll() is None else None)
206+
latex_stdin_utf8 = _api.deprecated("3.3")(
207+
property(lambda self: self._tex.stdin))
208+
210209
@staticmethod
211210
def _build_latex_header():
212211
latex_preamble = get_preamble()
@@ -225,7 +224,6 @@ def _build_latex_header():
225224
latex_fontspec,
226225
r"\begin{document}",
227226
r"text $math \mu$", # force latex to load fonts now
228-
r"\typeout{pgf_backend_query_start}",
229227
]
230228
return "\n".join(latex_header)
231229

@@ -242,87 +240,10 @@ def _get_cached_or_new(cls):
242240
def _get_cached_or_new_impl(cls, header): # Helper for _get_cached_or_new.
243241
return cls()
244242

245-
def _stdin_writeln(self, s):
246-
if self.latex is None:
247-
self._setup_latex_process()
248-
self.latex.stdin.write(s)
249-
self.latex.stdin.write("\n")
250-
self.latex.stdin.flush()
251-
252-
def _expect(self, s):
253-
s = list(s)
254-
chars = []
255-
while True:
256-
c = self.latex.stdout.read(1)
257-
chars.append(c)
258-
if chars[-len(s):] == s:
259-
break
260-
if not c:
261-
self.latex.kill()
262-
self.latex = None
263-
raise LatexError("LaTeX process halted", "".join(chars))
264-
return "".join(chars)
265-
266-
def _expect_prompt(self):
267-
return self._expect("\n*")
268-
269243
def __init__(self):
270-
# create a tmp directory for running latex, register it for deletion
271-
self._tmpdir = TemporaryDirectory()
272-
self.tmpdir = self._tmpdir.name
273-
self._finalize_tmpdir = weakref.finalize(self, self._tmpdir.cleanup)
274-
275-
# test the LaTeX setup to ensure a clean startup of the subprocess
276-
self.texcommand = mpl.rcParams["pgf.texsystem"]
277-
self.latex_header = LatexManager._build_latex_header()
278-
latex_end = "\n\\makeatletter\n\\@@end\n"
279-
try:
280-
latex = subprocess.Popen(
281-
[self.texcommand, "-halt-on-error"],
282-
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
283-
encoding="utf-8", cwd=self.tmpdir)
284-
except FileNotFoundError as err:
285-
raise RuntimeError(
286-
f"{self.texcommand} not found. Install it or change "
287-
f"rcParams['pgf.texsystem'] to an available TeX "
288-
f"implementation.") from err
289-
except OSError as err:
290-
raise RuntimeError("Error starting process %r" %
291-
self.texcommand) from err
292-
test_input = self.latex_header + latex_end
293-
stdout, stderr = latex.communicate(test_input)
294-
if latex.returncode != 0:
295-
raise LatexError("LaTeX returned an error, probably missing font "
296-
"or error in preamble.", stdout)
297-
298-
self.latex = None # Will be set up on first use.
299244
self.str_cache = {} # cache for strings already processed
300-
301-
def _setup_latex_process(self):
302-
# Open LaTeX process for real work; register it for deletion. On
303-
# Windows, we must ensure that the subprocess has quit before being
304-
# able to delete the tmpdir in which it runs; in order to do so, we
305-
# must first `kill()` it, and then `communicate()` with it.
306-
self.latex = subprocess.Popen(
307-
[self.texcommand, "-halt-on-error"],
308-
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
309-
encoding="utf-8", cwd=self.tmpdir)
310-
311-
def finalize_latex(latex):
312-
latex.kill()
313-
latex.communicate()
314-
315-
self._finalize_latex = weakref.finalize(
316-
self, finalize_latex, self.latex)
317-
# write header with 'pgf_backend_query_start' token
318-
self._stdin_writeln(self._build_latex_header())
319-
# read all lines until our 'pgf_backend_query_start' token appears
320-
self._expect("*pgf_backend_query_start")
321-
self._expect_prompt()
322-
323-
@_api.deprecated("3.3")
324-
def latex_stdin_utf8(self):
325-
return self.latex.stdin
245+
self.latex_header = LatexManager._build_latex_header()
246+
super().__init__(mpl.rcParams["pgf.texsystem"], self.latex_header)
326247

327248
def get_width_height_descent(self, text, prop):
328249
"""
@@ -347,7 +268,7 @@ def get_width_height_descent(self, text, prop):
347268
.format(text, e.latex_output)) from e
348269

349270
# typeout width, height and text offset of the last textbox
350-
self._stdin_writeln(r"\typeout{\the\wd0,\the\ht0,\the\dp0}")
271+
self._stdin_writeln(r"\message{\the\wd0,\the\ht0,\the\dp0}")
351272
# read answer from latex and advance to the next prompt
352273
try:
353274
answer = self._expect_prompt()
@@ -357,7 +278,7 @@ def get_width_height_descent(self, text, prop):
357278

358279
# parse metrics from the answer string
359280
try:
360-
width, height, offset = answer.splitlines()[0].split(",")
281+
width, height, offset = answer.split(",")
361282
except Exception as err:
362283
raise ValueError("Error processing '{}'\nLaTeX Output:\n{}"
363284
.format(text, answer)) from err

lib/matplotlib/dviread.py

+32-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030

3131
import numpy as np
3232

33-
from matplotlib import _api, cbook, rcParams
33+
from matplotlib import _api, cbook, texmanager, rcParams
3434

3535
_log = logging.getLogger(__name__)
3636

@@ -1040,6 +1040,30 @@ def _parse_enc(path):
10401040
"Failed to parse {} as Postscript encoding".format(path))
10411041

10421042

1043+
class _LuatexKpathsea(texmanager._InteractiveTex):
1044+
@lru_cache()
1045+
def __new__(cls):
1046+
self = super().__new__(cls)
1047+
super(cls, self).__init__("luatex")
1048+
return self
1049+
1050+
def __init__(self):
1051+
pass # Skip the super().__init__.
1052+
1053+
def find_tex_file(self, filename, format=None):
1054+
if format is not None:
1055+
filename = f"{filename}.{format}"
1056+
if "\\" in filename or "'" in filename: # Skip unrealistic escapes.
1057+
raise ValueError(f"Invalid filename: {filename!r}")
1058+
# While kpse.find_file seems appropriate, it doesn't actually handle
1059+
# extensions in the filename (contrary to its docs), and mapping
1060+
# extension to format names (".pfb" -> "type1 fonts") would require a
1061+
# large hard-coded table.
1062+
self._stdin_writeln(r"\directlua{print(kpse.lookup('%s'))}" % filename)
1063+
val = self._expect_prompt().rstrip()
1064+
return "" if val == "nil" else val
1065+
1066+
10431067
@lru_cache()
10441068
def find_tex_file(filename, format=None):
10451069
"""
@@ -1072,6 +1096,13 @@ def find_tex_file(filename, format=None):
10721096
if isinstance(format, bytes):
10731097
format = format.decode('utf-8', errors='replace')
10741098

1099+
try:
1100+
lk = _LuatexKpathsea()
1101+
except FileNotFoundError:
1102+
pass
1103+
else:
1104+
return lk.find_tex_file(filename, format)
1105+
10751106
if os.name == 'nt':
10761107
# On Windows only, kpathsea can use utf-8 for cmd args and output.
10771108
# The `command_line_encoding` environment variable is set to force it

lib/matplotlib/texmanager.py

+72
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import re
3030
import subprocess
3131
from tempfile import TemporaryDirectory
32+
import weakref
3233

3334
import numpy as np
3435

@@ -401,3 +402,74 @@ def get_text_width_height_descent(self, tex, fontsize, renderer=None):
401402
page, = dvi
402403
# A total height (including the descent) needs to be returned.
403404
return page.width, page.height + page.descent, page.descent
405+
406+
407+
class _InteractiveTex:
408+
"""
409+
Interactive tex process supporting communication through stdin and stdout.
410+
"""
411+
412+
class TexError(Exception):
413+
def __init__(self, message, latex_output=""):
414+
super().__init__(message)
415+
self.latex_output = latex_output
416+
417+
def __str__(self):
418+
s, = self.args
419+
if self.latex_output:
420+
s += "\n" + self.latex_output
421+
return s
422+
423+
def __init__(self, cmd, header=""):
424+
self._tmpdir = TemporaryDirectory()
425+
self._finalize_tmpdir = weakref.finalize(self, self._tmpdir.cleanup)
426+
self._texcmd = cmd
427+
self._header = header
428+
self._setup_tex_process(cmd, header)
429+
430+
def _stdin_writeln(self, s):
431+
if self._tex.poll() is not None:
432+
self._setup_tex_process(self._texcmd, self._header)
433+
self._tex.stdin.write(s)
434+
self._tex.stdin.write("\n")
435+
self._tex.stdin.flush()
436+
437+
def _expect(self, s):
438+
s = list(s)
439+
chars = []
440+
while True:
441+
c = self._tex.stdout.read(1)
442+
chars.append(c)
443+
if chars[-len(s):] == s:
444+
break
445+
if not c:
446+
self._tex.kill()
447+
self._tex.communicate() # See _setup_tex_process.
448+
raise self.TexError("TeX process halted", "".join(chars))
449+
return "".join(chars)
450+
451+
def _expect_prompt(self):
452+
return self._expect("\n*")[:-2]
453+
454+
def _setup_tex_process(self, cmd, header):
455+
# Open TeX process; register it for deletion. On Windows, we must
456+
# ensure that the subprocess has quit before being able to delete the
457+
# tmpdir in which it runs; in order to do so, we must first `kill()`
458+
# it, and then `communicate()` with it.
459+
self._tex = subprocess.Popen(
460+
[cmd, "-halt-on-error"],
461+
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
462+
encoding="utf-8", cwd=self._tmpdir.name)
463+
464+
def finalize_tex(tex):
465+
tex.kill()
466+
tex.communicate()
467+
468+
self._finalize_tex = weakref.finalize(self, finalize_tex, self._tex)
469+
470+
self._stdin_writeln(header)
471+
# Emit a marker once the header is handled, and wait for it to appear.
472+
marker = "init-done"
473+
self._stdin_writeln(r"\immediate\write17{%s}" % marker)
474+
self._expect("*%s" % marker)
475+
self._expect_prompt()

0 commit comments

Comments
 (0)