test_backend_pgf.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. import datetime
  2. from io import BytesIO
  3. import os
  4. import shutil
  5. import numpy as np
  6. from packaging.version import parse as parse_version
  7. import pytest
  8. import matplotlib as mpl
  9. import matplotlib.pyplot as plt
  10. from matplotlib.testing import _has_tex_package, _check_for_pgf
  11. from matplotlib.testing.exceptions import ImageComparisonFailure
  12. from matplotlib.testing.compare import compare_images
  13. from matplotlib.backends.backend_pgf import PdfPages
  14. from matplotlib.testing.decorators import (
  15. _image_directories, check_figures_equal, image_comparison)
  16. from matplotlib.testing._markers import (
  17. needs_ghostscript, needs_pgf_lualatex, needs_pgf_pdflatex,
  18. needs_pgf_xelatex)
  19. baseline_dir, result_dir = _image_directories(lambda: 'dummy func')
  20. def compare_figure(fname, savefig_kwargs={}, tol=0):
  21. actual = os.path.join(result_dir, fname)
  22. plt.savefig(actual, **savefig_kwargs)
  23. expected = os.path.join(result_dir, "expected_%s" % fname)
  24. shutil.copyfile(os.path.join(baseline_dir, fname), expected)
  25. err = compare_images(expected, actual, tol=tol)
  26. if err:
  27. raise ImageComparisonFailure(err)
  28. @needs_pgf_xelatex
  29. @needs_ghostscript
  30. @pytest.mark.backend('pgf')
  31. def test_tex_special_chars(tmp_path):
  32. fig = plt.figure()
  33. fig.text(.5, .5, "%_^ $a_b^c$")
  34. buf = BytesIO()
  35. fig.savefig(buf, format="png", backend="pgf")
  36. buf.seek(0)
  37. t = plt.imread(buf)
  38. assert not (t == 1).all() # The leading "%" didn't eat up everything.
  39. def create_figure():
  40. plt.figure()
  41. x = np.linspace(0, 1, 15)
  42. # line plot
  43. plt.plot(x, x ** 2, "b-")
  44. # marker
  45. plt.plot(x, 1 - x**2, "g>")
  46. # filled paths and patterns
  47. plt.fill_between([0., .4], [.4, 0.], hatch='//', facecolor="lightgray",
  48. edgecolor="red")
  49. plt.fill([3, 3, .8, .8, 3], [2, -2, -2, 0, 2], "b")
  50. # text and typesetting
  51. plt.plot([0.9], [0.5], "ro", markersize=3)
  52. plt.text(0.9, 0.5, 'unicode (ü, °, \N{Section Sign}) and math ($\\mu_i = x_i^2$)',
  53. ha='right', fontsize=20)
  54. plt.ylabel('sans-serif, blue, $\\frac{\\sqrt{x}}{y^2}$..',
  55. family='sans-serif', color='blue')
  56. plt.text(1, 1, 'should be clipped as default clip_box is Axes bbox',
  57. fontsize=20, clip_on=True)
  58. plt.xlim(0, 1)
  59. plt.ylim(0, 1)
  60. # test compiling a figure to pdf with xelatex
  61. @needs_pgf_xelatex
  62. @pytest.mark.backend('pgf')
  63. @image_comparison(['pgf_xelatex.pdf'], style='default')
  64. def test_xelatex():
  65. rc_xelatex = {'font.family': 'serif',
  66. 'pgf.rcfonts': False}
  67. mpl.rcParams.update(rc_xelatex)
  68. create_figure()
  69. try:
  70. _old_gs_version = \
  71. mpl._get_executable_info('gs').version < parse_version('9.50')
  72. except mpl.ExecutableNotFoundError:
  73. _old_gs_version = True
  74. # test compiling a figure to pdf with pdflatex
  75. @needs_pgf_pdflatex
  76. @pytest.mark.skipif(not _has_tex_package('type1ec'), reason='needs type1ec.sty')
  77. @pytest.mark.skipif(not _has_tex_package('ucs'), reason='needs ucs.sty')
  78. @pytest.mark.backend('pgf')
  79. @image_comparison(['pgf_pdflatex.pdf'], style='default',
  80. tol=11.71 if _old_gs_version else 0)
  81. def test_pdflatex():
  82. rc_pdflatex = {'font.family': 'serif',
  83. 'pgf.rcfonts': False,
  84. 'pgf.texsystem': 'pdflatex',
  85. 'pgf.preamble': ('\\usepackage[utf8x]{inputenc}'
  86. '\\usepackage[T1]{fontenc}')}
  87. mpl.rcParams.update(rc_pdflatex)
  88. create_figure()
  89. # test updating the rc parameters for each figure
  90. @needs_pgf_xelatex
  91. @needs_pgf_pdflatex
  92. @mpl.style.context('default')
  93. @pytest.mark.backend('pgf')
  94. def test_rcupdate():
  95. rc_sets = [{'font.family': 'sans-serif',
  96. 'font.size': 30,
  97. 'figure.subplot.left': .2,
  98. 'lines.markersize': 10,
  99. 'pgf.rcfonts': False,
  100. 'pgf.texsystem': 'xelatex'},
  101. {'font.family': 'monospace',
  102. 'font.size': 10,
  103. 'figure.subplot.left': .1,
  104. 'lines.markersize': 20,
  105. 'pgf.rcfonts': False,
  106. 'pgf.texsystem': 'pdflatex',
  107. 'pgf.preamble': ('\\usepackage[utf8x]{inputenc}'
  108. '\\usepackage[T1]{fontenc}'
  109. '\\usepackage{sfmath}')}]
  110. tol = [0, 13.2] if _old_gs_version else [0, 0]
  111. for i, rc_set in enumerate(rc_sets):
  112. with mpl.rc_context(rc_set):
  113. for substring, pkg in [('sfmath', 'sfmath'), ('utf8x', 'ucs')]:
  114. if (substring in mpl.rcParams['pgf.preamble']
  115. and not _has_tex_package(pkg)):
  116. pytest.skip(f'needs {pkg}.sty')
  117. create_figure()
  118. compare_figure(f'pgf_rcupdate{i + 1}.pdf', tol=tol[i])
  119. # test backend-side clipping, since large numbers are not supported by TeX
  120. @needs_pgf_xelatex
  121. @mpl.style.context('default')
  122. @pytest.mark.backend('pgf')
  123. def test_pathclip():
  124. np.random.seed(19680801)
  125. mpl.rcParams.update({'font.family': 'serif', 'pgf.rcfonts': False})
  126. fig, axs = plt.subplots(1, 2)
  127. axs[0].plot([0., 1e100], [0., 1e100])
  128. axs[0].set_xlim(0, 1)
  129. axs[0].set_ylim(0, 1)
  130. axs[1].scatter([0, 1], [1, 1])
  131. axs[1].hist(np.random.normal(size=1000), bins=20, range=[-10, 10])
  132. axs[1].set_xscale('log')
  133. fig.savefig(BytesIO(), format="pdf") # No image comparison.
  134. # test mixed mode rendering
  135. @needs_pgf_xelatex
  136. @pytest.mark.backend('pgf')
  137. @image_comparison(['pgf_mixedmode.pdf'], style='default')
  138. def test_mixedmode():
  139. mpl.rcParams.update({'font.family': 'serif', 'pgf.rcfonts': False})
  140. Y, X = np.ogrid[-1:1:40j, -1:1:40j]
  141. plt.pcolor(X**2 + Y**2).set_rasterized(True)
  142. # test bbox_inches clipping
  143. @needs_pgf_xelatex
  144. @mpl.style.context('default')
  145. @pytest.mark.backend('pgf')
  146. def test_bbox_inches():
  147. mpl.rcParams.update({'font.family': 'serif', 'pgf.rcfonts': False})
  148. fig, (ax1, ax2) = plt.subplots(1, 2)
  149. ax1.plot(range(5))
  150. ax2.plot(range(5))
  151. plt.tight_layout()
  152. bbox = ax1.get_window_extent().transformed(fig.dpi_scale_trans.inverted())
  153. compare_figure('pgf_bbox_inches.pdf', savefig_kwargs={'bbox_inches': bbox},
  154. tol=0)
  155. @mpl.style.context('default')
  156. @pytest.mark.backend('pgf')
  157. @pytest.mark.parametrize('system', [
  158. pytest.param('lualatex', marks=[needs_pgf_lualatex]),
  159. pytest.param('pdflatex', marks=[needs_pgf_pdflatex]),
  160. pytest.param('xelatex', marks=[needs_pgf_xelatex]),
  161. ])
  162. def test_pdf_pages(system):
  163. rc_pdflatex = {
  164. 'font.family': 'serif',
  165. 'pgf.rcfonts': False,
  166. 'pgf.texsystem': system,
  167. }
  168. mpl.rcParams.update(rc_pdflatex)
  169. fig1, ax1 = plt.subplots()
  170. ax1.plot(range(5))
  171. fig1.tight_layout()
  172. fig2, ax2 = plt.subplots(figsize=(3, 2))
  173. ax2.plot(range(5))
  174. fig2.tight_layout()
  175. path = os.path.join(result_dir, f'pdfpages_{system}.pdf')
  176. md = {
  177. 'Author': 'me',
  178. 'Title': 'Multipage PDF with pgf',
  179. 'Subject': 'Test page',
  180. 'Keywords': 'test,pdf,multipage',
  181. 'ModDate': datetime.datetime(
  182. 1968, 8, 1, tzinfo=datetime.timezone(datetime.timedelta(0))),
  183. 'Trapped': 'Unknown'
  184. }
  185. with PdfPages(path, metadata=md) as pdf:
  186. pdf.savefig(fig1)
  187. pdf.savefig(fig2)
  188. pdf.savefig(fig1)
  189. assert pdf.get_pagecount() == 3
  190. @mpl.style.context('default')
  191. @pytest.mark.backend('pgf')
  192. @pytest.mark.parametrize('system', [
  193. pytest.param('lualatex', marks=[needs_pgf_lualatex]),
  194. pytest.param('pdflatex', marks=[needs_pgf_pdflatex]),
  195. pytest.param('xelatex', marks=[needs_pgf_xelatex]),
  196. ])
  197. def test_pdf_pages_metadata_check(monkeypatch, system):
  198. # Basically the same as test_pdf_pages, but we keep it separate to leave
  199. # pikepdf as an optional dependency.
  200. pikepdf = pytest.importorskip('pikepdf')
  201. monkeypatch.setenv('SOURCE_DATE_EPOCH', '0')
  202. mpl.rcParams.update({'pgf.texsystem': system})
  203. fig, ax = plt.subplots()
  204. ax.plot(range(5))
  205. md = {
  206. 'Author': 'me',
  207. 'Title': 'Multipage PDF with pgf',
  208. 'Subject': 'Test page',
  209. 'Keywords': 'test,pdf,multipage',
  210. 'ModDate': datetime.datetime(
  211. 1968, 8, 1, tzinfo=datetime.timezone(datetime.timedelta(0))),
  212. 'Trapped': 'True'
  213. }
  214. path = os.path.join(result_dir, f'pdfpages_meta_check_{system}.pdf')
  215. with PdfPages(path, metadata=md) as pdf:
  216. pdf.savefig(fig)
  217. with pikepdf.Pdf.open(path) as pdf:
  218. info = {k: str(v) for k, v in pdf.docinfo.items()}
  219. # Not set by us, so don't bother checking.
  220. if '/PTEX.FullBanner' in info:
  221. del info['/PTEX.FullBanner']
  222. if '/PTEX.Fullbanner' in info:
  223. del info['/PTEX.Fullbanner']
  224. # Some LaTeX engines ignore this setting, and state themselves as producer.
  225. producer = info.pop('/Producer')
  226. assert producer == f'Matplotlib pgf backend v{mpl.__version__}' or (
  227. system == 'lualatex' and 'LuaTeX' in producer)
  228. assert info == {
  229. '/Author': 'me',
  230. '/CreationDate': 'D:19700101000000Z',
  231. '/Creator': f'Matplotlib v{mpl.__version__}, https://matplotlib.org',
  232. '/Keywords': 'test,pdf,multipage',
  233. '/ModDate': 'D:19680801000000Z',
  234. '/Subject': 'Test page',
  235. '/Title': 'Multipage PDF with pgf',
  236. '/Trapped': '/True',
  237. }
  238. @needs_pgf_xelatex
  239. def test_multipage_keep_empty(tmp_path):
  240. # An empty pdf deletes itself afterwards.
  241. fn = tmp_path / "a.pdf"
  242. with PdfPages(fn) as pdf:
  243. pass
  244. assert not fn.exists()
  245. # Test pdf files with content, they should never be deleted.
  246. fn = tmp_path / "b.pdf"
  247. with PdfPages(fn) as pdf:
  248. pdf.savefig(plt.figure())
  249. assert fn.exists()
  250. @needs_pgf_xelatex
  251. def test_tex_restart_after_error():
  252. fig = plt.figure()
  253. fig.suptitle(r"\oops")
  254. with pytest.raises(ValueError):
  255. fig.savefig(BytesIO(), format="pgf")
  256. fig = plt.figure() # start from scratch
  257. fig.suptitle(r"this is ok")
  258. fig.savefig(BytesIO(), format="pgf")
  259. @needs_pgf_xelatex
  260. def test_bbox_inches_tight():
  261. fig, ax = plt.subplots()
  262. ax.imshow([[0, 1], [2, 3]])
  263. fig.savefig(BytesIO(), format="pdf", backend="pgf", bbox_inches="tight")
  264. @needs_pgf_xelatex
  265. @needs_ghostscript
  266. def test_png_transparency(): # Actually, also just testing that png works.
  267. buf = BytesIO()
  268. plt.figure().savefig(buf, format="png", backend="pgf", transparent=True)
  269. buf.seek(0)
  270. t = plt.imread(buf)
  271. assert (t[..., 3] == 0).all() # fully transparent.
  272. @needs_pgf_xelatex
  273. def test_unknown_font(caplog):
  274. with caplog.at_level("WARNING"):
  275. mpl.rcParams["font.family"] = "this-font-does-not-exist"
  276. plt.figtext(.5, .5, "hello, world")
  277. plt.savefig(BytesIO(), format="pgf")
  278. assert "Ignoring unknown font: this-font-does-not-exist" in [
  279. r.getMessage() for r in caplog.records]
  280. @check_figures_equal(extensions=["pdf"])
  281. @pytest.mark.parametrize("texsystem", ("pdflatex", "xelatex", "lualatex"))
  282. @pytest.mark.backend("pgf")
  283. def test_minus_signs_with_tex(fig_test, fig_ref, texsystem):
  284. if not _check_for_pgf(texsystem):
  285. pytest.skip(texsystem + ' + pgf is required')
  286. mpl.rcParams["pgf.texsystem"] = texsystem
  287. fig_test.text(.5, .5, "$-1$")
  288. fig_ref.text(.5, .5, "$\N{MINUS SIGN}1$")
  289. @pytest.mark.backend("pgf")
  290. def test_sketch_params():
  291. fig, ax = plt.subplots(figsize=(3, 3))
  292. ax.set_xticks([])
  293. ax.set_yticks([])
  294. ax.set_frame_on(False)
  295. handle, = ax.plot([0, 1])
  296. handle.set_sketch_params(scale=5, length=30, randomness=42)
  297. with BytesIO() as fd:
  298. fig.savefig(fd, format='pgf')
  299. buf = fd.getvalue().decode()
  300. baseline = r"""\pgfpathmoveto{\pgfqpoint{0.375000in}{0.300000in}}%
  301. \pgfpathlineto{\pgfqpoint{2.700000in}{2.700000in}}%
  302. \usepgfmodule{decorations}%
  303. \usepgflibrary{decorations.pathmorphing}%
  304. \pgfkeys{/pgf/decoration/.cd, """ \
  305. r"""segment length = 0.150000in, amplitude = 0.100000in}%
  306. \pgfmathsetseed{42}%
  307. \pgfdecoratecurrentpath{random steps}%
  308. \pgfusepath{stroke}%"""
  309. # \pgfdecoratecurrentpath must be after the path definition and before the
  310. # path is used (\pgfusepath)
  311. assert baseline in buf
  312. # test to make sure that the document font size is set consistently (see #26892)
  313. @needs_pgf_xelatex
  314. @pytest.mark.skipif(
  315. not _has_tex_package('unicode-math'), reason='needs unicode-math.sty'
  316. )
  317. @pytest.mark.backend('pgf')
  318. @image_comparison(['pgf_document_font_size.pdf'], style='default', remove_text=True)
  319. def test_document_font_size():
  320. mpl.rcParams.update({
  321. 'pgf.texsystem': 'xelatex',
  322. 'pgf.rcfonts': False,
  323. 'pgf.preamble': r'\usepackage{unicode-math}',
  324. })
  325. plt.figure()
  326. plt.plot([],
  327. label=r'$this is a very very very long math label a \times b + 10^{-3}$ '
  328. r'and some text'
  329. )
  330. plt.plot([],
  331. label=r'\normalsize the document font size is \the\fontdimen6\font'
  332. )
  333. plt.legend()