From c973552b49d6d6c583a52d1ac838bc0748462666 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Sun, 30 Jan 2022 01:07:17 +0100 Subject: [PATCH 1/4] Switch TeX baseline detection to use a dvi special. Insert a `\special` at the end of the TeX output and record the baseline position there. This should be more robust than trying to guess the format of the dvi preamble, and more importantly this will also allow detecting the last baseline of *multiline* TeX output, which will then allow letting TeX handle multiline strings itself (while keeping the correct alignment). --- lib/matplotlib/dviread.py | 31 ++----------------------------- lib/matplotlib/texmanager.py | 2 +- 2 files changed, 3 insertions(+), 30 deletions(-) diff --git a/lib/matplotlib/dviread.py b/lib/matplotlib/dviread.py index 7f90a13f1086..4c2adabd1bb1 100644 --- a/lib/matplotlib/dviread.py +++ b/lib/matplotlib/dviread.py @@ -291,40 +291,11 @@ def _read(self): Read one page from the file. Return True if successful, False if there were no more pages. """ - # Pages appear to start with the sequence - # bop (begin of page) - # xxx comment - # # if using chemformula - # down - # push - # down - # # if using xcolor - # down - # push - # down (possibly multiple) - # push <= here, v is the baseline position. - # etc. - # (dviasm is useful to explore this structure.) - # Thus, we use the vertical position at the first time the stack depth - # reaches 3, while at least three "downs" have been executed (excluding - # those popped out (corresponding to the chemformula preamble)), as the - # baseline (the "down" count is necessary to handle xcolor). - down_stack = [0] self._baseline_v = None while True: byte = self.file.read(1)[0] self._dtable[byte](self, byte) name = self._dtable[byte].__name__ - if name == "_push": - down_stack.append(down_stack[-1]) - elif name == "_pop": - down_stack.pop() - elif name == "_down": - down_stack[-1] += 1 - if (self._baseline_v is None - and len(getattr(self, "stack", [])) == 3 - and down_stack[-1] >= 4): - self._baseline_v = self.v if byte == 140: # end of page return True if self.state is _dvistate.post_post: # end of file @@ -457,6 +428,8 @@ def _fnt_num(self, new_f): @_dispatch(min=239, max=242, args=('ulen1',)) def _xxx(self, datalen): special = self.file.read(datalen) + if special == b'matplotlibbaselinemarker': + self._baseline_v = self.v _log.debug( 'Dvi._xxx: encountered special: %s', ''.join([chr(ch) if 32 <= ch < 127 else '<%02x>' % ch diff --git a/lib/matplotlib/texmanager.py b/lib/matplotlib/texmanager.py index 5f94c52df237..ad27c1a12c72 100644 --- a/lib/matplotlib/texmanager.py +++ b/lib/matplotlib/texmanager.py @@ -217,7 +217,7 @@ def make_tex(self, tex, fontsize): %% when using psfrag which gets confused by it. \fontsize{%(fontsize)f}{%(baselineskip)f}%% \ifdefined\psfrag\else\hbox{}\fi%% -{%(fontcmd)s %(tex)s} +{%(fontcmd)s %(tex)s}\special{matplotlibbaselinemarker} \end{document} """ Path(texfile).write_text(tex_template % { From 157b0bed5349d0ec56c3d8bfcb12a3c1a5052cf1 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Sun, 30 Jan 2022 01:15:20 +0100 Subject: [PATCH 2/4] Let TeX handle newlines itself. Note that it may be worth passing the baselineskip (`Text.get_linespacing()`) as argument to texmanager, but that'll wait for a bigger refactor... for now, let's stick to the old default of 1.25. test_metrics_cache changes as the cache hit pattern changed for usetex strings. --- lib/matplotlib/tests/test_text.py | 21 +++------------------ lib/matplotlib/texmanager.py | 2 +- lib/matplotlib/text.py | 3 ++- 3 files changed, 6 insertions(+), 20 deletions(-) diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 0b703adadc25..70210c9d84fd 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -776,26 +776,11 @@ def test_metrics_cache(): fig = plt.figure() fig.text(.3, .5, "foo\nbar") + fig.text(.5, .5, "foo\nbar") fig.text(.3, .5, "foo\nbar", usetex=True) fig.text(.5, .5, "foo\nbar", usetex=True) fig.canvas.draw() - renderer = fig._cachedRenderer - ys = {} # mapping of strings to where they were drawn in y with draw_tex. - - def call(*args, **kwargs): - renderer, x, y, s, *_ = args - ys.setdefault(s, set()).add(y) - - renderer.draw_tex = call - fig.canvas.draw() - assert [*ys] == ["foo", "bar"] - # Check that both TeX strings were drawn with the same y-position for both - # single-line substrings. Previously, there used to be an incorrect cache - # collision with the non-TeX string (drawn first here) whose metrics would - # get incorrectly reused by the first TeX string. - assert len(ys["foo"]) == len(ys["bar"]) == 1 info = mpl.text._get_text_metrics_with_cache_impl.cache_info() - # Every string gets a miss for the first layouting (extents), then a hit - # when drawing, but "foo\nbar" gets two hits as it's drawn twice. - assert info.hits > info.misses + # Each string gets drawn twice, so the second draw results in a hit. + assert info.hits == info.misses diff --git a/lib/matplotlib/texmanager.py b/lib/matplotlib/texmanager.py index ad27c1a12c72..e202971a4336 100644 --- a/lib/matplotlib/texmanager.py +++ b/lib/matplotlib/texmanager.py @@ -217,7 +217,7 @@ def make_tex(self, tex, fontsize): %% when using psfrag which gets confused by it. \fontsize{%(fontsize)f}{%(baselineskip)f}%% \ifdefined\psfrag\else\hbox{}\fi%% -{%(fontcmd)s %(tex)s}\special{matplotlibbaselinemarker} +{\obeylines%(fontcmd)s %(tex)s}\special{matplotlibbaselinemarker} \end{document} """ Path(texfile).write_text(tex_template % { diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index a98b534d2ef6..f10f4bcfa8d1 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -295,7 +295,8 @@ def _get_layout(self, renderer): of a rotated text when necessary. """ thisx, thisy = 0.0, 0.0 - lines = self.get_text().split("\n") # Ensures lines is not empty. + text = self.get_text() + lines = [text] if self.get_usetex() else text.split("\n") # Not empty. ws = [] hs = [] From 4ea309aac5b6b638967d19107db297b5c7bde551 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Sun, 30 Jan 2022 22:40:49 +0100 Subject: [PATCH 3/4] Pick TeX cache name based on entire TeX source. This allows invalidating the cache when the source generation algorithm changes. --- lib/matplotlib/texmanager.py | 48 +++++++++++++++--------------------- 1 file changed, 20 insertions(+), 28 deletions(-) diff --git a/lib/matplotlib/texmanager.py b/lib/matplotlib/texmanager.py index e202971a4336..58a83df4bad5 100644 --- a/lib/matplotlib/texmanager.py +++ b/lib/matplotlib/texmanager.py @@ -161,10 +161,9 @@ def get_basefile(self, tex, fontsize, dpi=None): """ Return a filename based on a hash of the string, fontsize, and dpi. """ - s = ''.join([tex, self.get_font_config(), '%f' % fontsize, - self.get_custom_preamble(), str(dpi or '')]) + src = self._get_tex_source(tex, fontsize) + str(dpi) return os.path.join( - self.texcache, hashlib.md5(s.encode('utf-8')).hexdigest()) + self.texcache, hashlib.md5(src.encode('utf-8')).hexdigest()) def get_font_preamble(self): """ @@ -176,7 +175,13 @@ def get_custom_preamble(self): """Return a string containing user additions to the tex preamble.""" return rcParams['text.latex.preamble'] - def _get_preamble(self): + def _get_tex_source(self, tex, fontsize): + """Return the complete TeX source for processing a TeX string.""" + self.get_font_config() # Updates self._font_preamble. + baselineskip = 1.25 * fontsize + fontcmd = (r'\sffamily' if self._font_family == 'sans-serif' else + r'\ttfamily' if self._font_family == 'monospace' else + r'\rmfamily') return "\n".join([ r"\documentclass{article}", # Pass-through \mathdefault, which is used in non-usetex mode to @@ -196,6 +201,15 @@ def _get_preamble(self): # Custom packages (e.g. newtxtext) may already have loaded textcomp # with different options. _usepackage_if_not_loaded("textcomp"), + r"\pagestyle{empty}", + r"\begin{document}", + r"% The empty hbox ensures that a page is printed even for empty ", + r"% inputs, except when using psfrag which gets confused by it.", + r"\fontsize{%f}{%f}%%" % (fontsize, baselineskip), + r"\ifdefined\psfrag\else\hbox{}\fi%", + r"{\obeylines%s %s}\special{matplotlibbaselinemarker}" + % (fontcmd, tex), + r"\end{document}", ]) def make_tex(self, tex, fontsize): @@ -204,30 +218,8 @@ def make_tex(self, tex, fontsize): Return the file name. """ - basefile = self.get_basefile(tex, fontsize) - texfile = '%s.tex' % basefile - fontcmd = (r'\sffamily' if self._font_family == 'sans-serif' else - r'\ttfamily' if self._font_family == 'monospace' else - r'\rmfamily') - tex_template = r""" -%(preamble)s -\pagestyle{empty} -\begin{document} -%% The empty hbox ensures that a page is printed even for empty inputs, except -%% when using psfrag which gets confused by it. -\fontsize{%(fontsize)f}{%(baselineskip)f}%% -\ifdefined\psfrag\else\hbox{}\fi%% -{\obeylines%(fontcmd)s %(tex)s}\special{matplotlibbaselinemarker} -\end{document} -""" - Path(texfile).write_text(tex_template % { - "preamble": self._get_preamble(), - "fontsize": fontsize, - "baselineskip": fontsize * 1.25, - "fontcmd": fontcmd, - "tex": tex, - }, encoding="utf-8") - + texfile = self.get_basefile(tex, fontsize) + ".tex" + Path(texfile).write_text(self._get_tex_source(tex, fontsize)) return texfile def _run_checked_subprocess(self, command, tex, *, cwd=None): From 3aaf81b123c37baaef9bd5fb7391712d19300316 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Wed, 2 Feb 2022 12:27:15 +0100 Subject: [PATCH 4/4] Slightly reformat tex source generation. --- lib/matplotlib/texmanager.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/lib/matplotlib/texmanager.py b/lib/matplotlib/texmanager.py index 58a83df4bad5..8a7f4834147a 100644 --- a/lib/matplotlib/texmanager.py +++ b/lib/matplotlib/texmanager.py @@ -184,31 +184,34 @@ def _get_tex_source(self, tex, fontsize): r'\rmfamily') return "\n".join([ r"\documentclass{article}", - # Pass-through \mathdefault, which is used in non-usetex mode to - # use the default text font but was historically suppressed in - # usetex mode. + r"% Pass-through \mathdefault, which is used in non-usetex mode", + r"% to use the default text font but was historically suppressed", + r"% in usetex mode.", r"\newcommand{\mathdefault}[1]{#1}", self._font_preamble, r"\usepackage[utf8]{inputenc}", r"\DeclareUnicodeCharacter{2212}{\ensuremath{-}}", - # geometry is loaded before the custom preamble as convert_psfrags - # relies on a custom preamble to change the geometry. + r"% geometry is loaded before the custom preamble as ", + r"% convert_psfrags relies on a custom preamble to change the ", + r"% geometry.", r"\usepackage[papersize=72in, margin=1in]{geometry}", self.get_custom_preamble(), - # Use `underscore` package to take care of underscores in text - # The [strings] option allows to use underscores in file names + r"% Use `underscore` package to take care of underscores in text.", + r"% The [strings] option allows to use underscores in file names.", _usepackage_if_not_loaded("underscore", option="strings"), - # Custom packages (e.g. newtxtext) may already have loaded textcomp - # with different options. + r"% Custom packages (e.g. newtxtext) may already have loaded ", + r"% textcomp with different options.", _usepackage_if_not_loaded("textcomp"), r"\pagestyle{empty}", r"\begin{document}", - r"% The empty hbox ensures that a page is printed even for empty ", + r"% The empty hbox ensures that a page is printed even for empty", r"% inputs, except when using psfrag which gets confused by it.", - r"\fontsize{%f}{%f}%%" % (fontsize, baselineskip), + r"% matplotlibbaselinemarker is used by dviread to detect the", + r"% last line's baseline.", + rf"\fontsize{{{fontsize}}}{{{baselineskip}}}%", r"\ifdefined\psfrag\else\hbox{}\fi%", - r"{\obeylines%s %s}\special{matplotlibbaselinemarker}" - % (fontcmd, tex), + rf"{{\obeylines{fontcmd} {tex}}}%", + r"\special{matplotlibbaselinemarker}%", r"\end{document}", ])