test_backend_pdf.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. import datetime
  2. import decimal
  3. import io
  4. import os
  5. from pathlib import Path
  6. import numpy as np
  7. import pytest
  8. import matplotlib as mpl
  9. from matplotlib import (
  10. pyplot as plt, rcParams, font_manager as fm
  11. )
  12. from matplotlib.cbook import _get_data_path
  13. from matplotlib.ft2font import FT2Font
  14. from matplotlib.font_manager import findfont, FontProperties
  15. from matplotlib.backends._backend_pdf_ps import get_glyphs_subset, font_as_file
  16. from matplotlib.backends.backend_pdf import PdfPages
  17. from matplotlib.patches import Rectangle
  18. from matplotlib.testing.decorators import check_figures_equal, image_comparison
  19. from matplotlib.testing._markers import needs_usetex
  20. @image_comparison(['pdf_use14corefonts.pdf'])
  21. def test_use14corefonts():
  22. rcParams['pdf.use14corefonts'] = True
  23. rcParams['font.family'] = 'sans-serif'
  24. rcParams['font.size'] = 8
  25. rcParams['font.sans-serif'] = ['Helvetica']
  26. rcParams['pdf.compression'] = 0
  27. text = '''A three-line text positioned just above a blue line
  28. and containing some French characters and the euro symbol:
  29. "Merci pépé pour les 10 €"'''
  30. fig, ax = plt.subplots()
  31. ax.set_title('Test PDF backend with option use14corefonts=True')
  32. ax.text(0.5, 0.5, text, horizontalalignment='center',
  33. verticalalignment='bottom',
  34. fontsize=14)
  35. ax.axhline(0.5, linewidth=0.5)
  36. @pytest.mark.parametrize('fontname, fontfile', [
  37. ('DejaVu Sans', 'DejaVuSans.ttf'),
  38. ('WenQuanYi Zen Hei', 'wqy-zenhei.ttc'),
  39. ])
  40. @pytest.mark.parametrize('fonttype', [3, 42])
  41. def test_embed_fonts(fontname, fontfile, fonttype):
  42. if Path(findfont(FontProperties(family=[fontname]))).name != fontfile:
  43. pytest.skip(f'Font {fontname!r} may be missing')
  44. rcParams['pdf.fonttype'] = fonttype
  45. fig, ax = plt.subplots()
  46. ax.plot([1, 2, 3])
  47. ax.set_title('Axes Title', font=fontname)
  48. fig.savefig(io.BytesIO(), format='pdf')
  49. def test_multipage_pagecount():
  50. with PdfPages(io.BytesIO()) as pdf:
  51. assert pdf.get_pagecount() == 0
  52. fig, ax = plt.subplots()
  53. ax.plot([1, 2, 3])
  54. fig.savefig(pdf, format="pdf")
  55. assert pdf.get_pagecount() == 1
  56. pdf.savefig()
  57. assert pdf.get_pagecount() == 2
  58. def test_multipage_properfinalize():
  59. pdfio = io.BytesIO()
  60. with PdfPages(pdfio) as pdf:
  61. for i in range(10):
  62. fig, ax = plt.subplots()
  63. ax.set_title('This is a long title')
  64. fig.savefig(pdf, format="pdf")
  65. s = pdfio.getvalue()
  66. assert s.count(b'startxref') == 1
  67. assert len(s) < 40000
  68. def test_multipage_keep_empty(tmp_path):
  69. # An empty pdf deletes itself afterwards.
  70. fn = tmp_path / "a.pdf"
  71. with PdfPages(fn) as pdf:
  72. pass
  73. assert not fn.exists()
  74. # Test pdf files with content, they should never be deleted.
  75. fn = tmp_path / "b.pdf"
  76. with PdfPages(fn) as pdf:
  77. pdf.savefig(plt.figure())
  78. assert fn.exists()
  79. def test_composite_image():
  80. # Test that figures can be saved with and without combining multiple images
  81. # (on a single set of axes) into a single composite image.
  82. X, Y = np.meshgrid(np.arange(-5, 5, 1), np.arange(-5, 5, 1))
  83. Z = np.sin(Y ** 2)
  84. fig, ax = plt.subplots()
  85. ax.set_xlim(0, 3)
  86. ax.imshow(Z, extent=[0, 1, 0, 1])
  87. ax.imshow(Z[::-1], extent=[2, 3, 0, 1])
  88. plt.rcParams['image.composite_image'] = True
  89. with PdfPages(io.BytesIO()) as pdf:
  90. fig.savefig(pdf, format="pdf")
  91. assert len(pdf._file._images) == 1
  92. plt.rcParams['image.composite_image'] = False
  93. with PdfPages(io.BytesIO()) as pdf:
  94. fig.savefig(pdf, format="pdf")
  95. assert len(pdf._file._images) == 2
  96. def test_indexed_image():
  97. # An image with low color count should compress to a palette-indexed format.
  98. pikepdf = pytest.importorskip('pikepdf')
  99. data = np.zeros((256, 1, 3), dtype=np.uint8)
  100. data[:, 0, 0] = np.arange(256) # Maximum unique colours for an indexed image.
  101. rcParams['pdf.compression'] = True
  102. fig = plt.figure()
  103. fig.figimage(data, resize=True)
  104. buf = io.BytesIO()
  105. fig.savefig(buf, format='pdf', dpi='figure')
  106. with pikepdf.Pdf.open(buf) as pdf:
  107. page, = pdf.pages
  108. image, = page.images.values()
  109. pdf_image = pikepdf.PdfImage(image)
  110. assert pdf_image.indexed
  111. pil_image = pdf_image.as_pil_image()
  112. rgb = np.asarray(pil_image.convert('RGB'))
  113. np.testing.assert_array_equal(data, rgb)
  114. def test_savefig_metadata(monkeypatch):
  115. pikepdf = pytest.importorskip('pikepdf')
  116. monkeypatch.setenv('SOURCE_DATE_EPOCH', '0')
  117. fig, ax = plt.subplots()
  118. ax.plot(range(5))
  119. md = {
  120. 'Author': 'me',
  121. 'Title': 'Multipage PDF',
  122. 'Subject': 'Test page',
  123. 'Keywords': 'test,pdf,multipage',
  124. 'ModDate': datetime.datetime(
  125. 1968, 8, 1, tzinfo=datetime.timezone(datetime.timedelta(0))),
  126. 'Trapped': 'True'
  127. }
  128. buf = io.BytesIO()
  129. fig.savefig(buf, metadata=md, format='pdf')
  130. with pikepdf.Pdf.open(buf) as pdf:
  131. info = {k: str(v) for k, v in pdf.docinfo.items()}
  132. assert info == {
  133. '/Author': 'me',
  134. '/CreationDate': 'D:19700101000000Z',
  135. '/Creator': f'Matplotlib v{mpl.__version__}, https://matplotlib.org',
  136. '/Keywords': 'test,pdf,multipage',
  137. '/ModDate': 'D:19680801000000Z',
  138. '/Producer': f'Matplotlib pdf backend v{mpl.__version__}',
  139. '/Subject': 'Test page',
  140. '/Title': 'Multipage PDF',
  141. '/Trapped': '/True',
  142. }
  143. def test_invalid_metadata():
  144. fig, ax = plt.subplots()
  145. with pytest.warns(UserWarning,
  146. match="Unknown infodict keyword: 'foobar'."):
  147. fig.savefig(io.BytesIO(), format='pdf', metadata={'foobar': 'invalid'})
  148. with pytest.warns(UserWarning,
  149. match='not an instance of datetime.datetime.'):
  150. fig.savefig(io.BytesIO(), format='pdf',
  151. metadata={'ModDate': '1968-08-01'})
  152. with pytest.warns(UserWarning,
  153. match='not one of {"True", "False", "Unknown"}'):
  154. fig.savefig(io.BytesIO(), format='pdf', metadata={'Trapped': 'foo'})
  155. with pytest.warns(UserWarning, match='not an instance of str.'):
  156. fig.savefig(io.BytesIO(), format='pdf', metadata={'Title': 1234})
  157. def test_multipage_metadata(monkeypatch):
  158. pikepdf = pytest.importorskip('pikepdf')
  159. monkeypatch.setenv('SOURCE_DATE_EPOCH', '0')
  160. fig, ax = plt.subplots()
  161. ax.plot(range(5))
  162. md = {
  163. 'Author': 'me',
  164. 'Title': 'Multipage PDF',
  165. 'Subject': 'Test page',
  166. 'Keywords': 'test,pdf,multipage',
  167. 'ModDate': datetime.datetime(
  168. 1968, 8, 1, tzinfo=datetime.timezone(datetime.timedelta(0))),
  169. 'Trapped': 'True'
  170. }
  171. buf = io.BytesIO()
  172. with PdfPages(buf, metadata=md) as pdf:
  173. pdf.savefig(fig)
  174. pdf.savefig(fig)
  175. with pikepdf.Pdf.open(buf) as pdf:
  176. info = {k: str(v) for k, v in pdf.docinfo.items()}
  177. assert info == {
  178. '/Author': 'me',
  179. '/CreationDate': 'D:19700101000000Z',
  180. '/Creator': f'Matplotlib v{mpl.__version__}, https://matplotlib.org',
  181. '/Keywords': 'test,pdf,multipage',
  182. '/ModDate': 'D:19680801000000Z',
  183. '/Producer': f'Matplotlib pdf backend v{mpl.__version__}',
  184. '/Subject': 'Test page',
  185. '/Title': 'Multipage PDF',
  186. '/Trapped': '/True',
  187. }
  188. def test_text_urls():
  189. pikepdf = pytest.importorskip('pikepdf')
  190. test_url = 'https://test_text_urls.matplotlib.org/'
  191. fig = plt.figure(figsize=(2, 1))
  192. fig.text(0.1, 0.1, 'test plain 123', url=f'{test_url}plain')
  193. fig.text(0.1, 0.4, 'test mathtext $123$', url=f'{test_url}mathtext')
  194. with io.BytesIO() as fd:
  195. fig.savefig(fd, format='pdf')
  196. with pikepdf.Pdf.open(fd) as pdf:
  197. annots = pdf.pages[0].Annots
  198. # Iteration over Annots must occur within the context manager,
  199. # otherwise it may fail depending on the pdf structure.
  200. for y, fragment in [('0.1', 'plain'), ('0.4', 'mathtext')]:
  201. annot = next(
  202. (a for a in annots if a.A.URI == f'{test_url}{fragment}'),
  203. None)
  204. assert annot is not None
  205. assert getattr(annot, 'QuadPoints', None) is None
  206. # Positions in points (72 per inch.)
  207. assert annot.Rect[1] == decimal.Decimal(y) * 72
  208. def test_text_rotated_urls():
  209. pikepdf = pytest.importorskip('pikepdf')
  210. test_url = 'https://test_text_urls.matplotlib.org/'
  211. fig = plt.figure(figsize=(1, 1))
  212. fig.text(0.1, 0.1, 'N', rotation=45, url=f'{test_url}')
  213. with io.BytesIO() as fd:
  214. fig.savefig(fd, format='pdf')
  215. with pikepdf.Pdf.open(fd) as pdf:
  216. annots = pdf.pages[0].Annots
  217. # Iteration over Annots must occur within the context manager,
  218. # otherwise it may fail depending on the pdf structure.
  219. annot = next(
  220. (a for a in annots if a.A.URI == f'{test_url}'),
  221. None)
  222. assert annot is not None
  223. assert getattr(annot, 'QuadPoints', None) is not None
  224. # Positions in points (72 per inch)
  225. assert annot.Rect[0] == \
  226. annot.QuadPoints[6] - decimal.Decimal('0.00001')
  227. @needs_usetex
  228. def test_text_urls_tex():
  229. pikepdf = pytest.importorskip('pikepdf')
  230. test_url = 'https://test_text_urls.matplotlib.org/'
  231. fig = plt.figure(figsize=(2, 1))
  232. fig.text(0.1, 0.7, 'test tex $123$', usetex=True, url=f'{test_url}tex')
  233. with io.BytesIO() as fd:
  234. fig.savefig(fd, format='pdf')
  235. with pikepdf.Pdf.open(fd) as pdf:
  236. annots = pdf.pages[0].Annots
  237. # Iteration over Annots must occur within the context manager,
  238. # otherwise it may fail depending on the pdf structure.
  239. annot = next(
  240. (a for a in annots if a.A.URI == f'{test_url}tex'),
  241. None)
  242. assert annot is not None
  243. # Positions in points (72 per inch.)
  244. assert annot.Rect[1] == decimal.Decimal('0.7') * 72
  245. def test_pdfpages_fspath():
  246. with PdfPages(Path(os.devnull)) as pdf:
  247. pdf.savefig(plt.figure())
  248. @image_comparison(['hatching_legend.pdf'])
  249. def test_hatching_legend():
  250. """Test for correct hatching on patches in legend"""
  251. fig = plt.figure(figsize=(1, 2))
  252. a = Rectangle([0, 0], 0, 0, facecolor="green", hatch="XXXX")
  253. b = Rectangle([0, 0], 0, 0, facecolor="blue", hatch="XXXX")
  254. fig.legend([a, b, a, b], ["", "", "", ""])
  255. @image_comparison(['grayscale_alpha.pdf'])
  256. def test_grayscale_alpha():
  257. """Masking images with NaN did not work for grayscale images"""
  258. x, y = np.ogrid[-2:2:.1, -2:2:.1]
  259. dd = np.exp(-(x**2 + y**2))
  260. dd[dd < .1] = np.nan
  261. fig, ax = plt.subplots()
  262. ax.imshow(dd, interpolation='none', cmap='gray_r')
  263. ax.set_xticks([])
  264. ax.set_yticks([])
  265. @mpl.style.context('default')
  266. @check_figures_equal(extensions=["pdf", "eps"])
  267. def test_pdf_eps_savefig_when_color_is_none(fig_test, fig_ref):
  268. ax_test = fig_test.add_subplot()
  269. ax_test.set_axis_off()
  270. ax_test.plot(np.sin(np.linspace(-5, 5, 100)), "v", c="none")
  271. ax_ref = fig_ref.add_subplot()
  272. ax_ref.set_axis_off()
  273. @needs_usetex
  274. def test_failing_latex():
  275. """Test failing latex subprocess call"""
  276. plt.xlabel("$22_2_2$", usetex=True) # This fails with "Double subscript"
  277. with pytest.raises(RuntimeError):
  278. plt.savefig(io.BytesIO(), format="pdf")
  279. def test_empty_rasterized():
  280. # Check that empty figures that are rasterised save to pdf files fine
  281. fig, ax = plt.subplots()
  282. ax.plot([], [], rasterized=True)
  283. fig.savefig(io.BytesIO(), format="pdf")
  284. @image_comparison(['kerning.pdf'])
  285. def test_kerning():
  286. fig = plt.figure()
  287. s = "AVAVAVAVAVAVAVAV€AAVV"
  288. fig.text(0, .25, s, size=5)
  289. fig.text(0, .75, s, size=20)
  290. def test_glyphs_subset():
  291. fpath = str(_get_data_path("fonts/ttf/DejaVuSerif.ttf"))
  292. chars = "these should be subsetted! 1234567890"
  293. # non-subsetted FT2Font
  294. nosubfont = FT2Font(fpath)
  295. nosubfont.set_text(chars)
  296. # subsetted FT2Font
  297. with get_glyphs_subset(fpath, chars) as subset:
  298. subfont = FT2Font(font_as_file(subset))
  299. subfont.set_text(chars)
  300. nosubcmap = nosubfont.get_charmap()
  301. subcmap = subfont.get_charmap()
  302. # all unique chars must be available in subsetted font
  303. assert {*chars} == {chr(key) for key in subcmap}
  304. # subsetted font's charmap should have less entries
  305. assert len(subcmap) < len(nosubcmap)
  306. # since both objects are assigned same characters
  307. assert subfont.get_num_glyphs() == nosubfont.get_num_glyphs()
  308. @image_comparison(["multi_font_type3.pdf"], tol=4.6)
  309. def test_multi_font_type3():
  310. fp = fm.FontProperties(family=["WenQuanYi Zen Hei"])
  311. if Path(fm.findfont(fp)).name != "wqy-zenhei.ttc":
  312. pytest.skip("Font may be missing")
  313. plt.rc('font', family=['DejaVu Sans', 'WenQuanYi Zen Hei'], size=27)
  314. plt.rc('pdf', fonttype=3)
  315. fig = plt.figure()
  316. fig.text(0.15, 0.475, "There are 几个汉字 in between!")
  317. @image_comparison(["multi_font_type42.pdf"], tol=2.2)
  318. def test_multi_font_type42():
  319. fp = fm.FontProperties(family=["WenQuanYi Zen Hei"])
  320. if Path(fm.findfont(fp)).name != "wqy-zenhei.ttc":
  321. pytest.skip("Font may be missing")
  322. plt.rc('font', family=['DejaVu Sans', 'WenQuanYi Zen Hei'], size=27)
  323. plt.rc('pdf', fonttype=42)
  324. fig = plt.figure()
  325. fig.text(0.15, 0.475, "There are 几个汉字 in between!")
  326. @pytest.mark.parametrize('family_name, file_name',
  327. [("Noto Sans", "NotoSans-Regular.otf"),
  328. ("FreeMono", "FreeMono.otf")])
  329. def test_otf_font_smoke(family_name, file_name):
  330. # checks that there's no segfault
  331. fp = fm.FontProperties(family=[family_name])
  332. if Path(fm.findfont(fp)).name != file_name:
  333. pytest.skip(f"Font {family_name} may be missing")
  334. plt.rc('font', family=[family_name], size=27)
  335. fig = plt.figure()
  336. fig.text(0.15, 0.475, "Привет мир!")
  337. fig.savefig(io.BytesIO(), format="pdf")
  338. @image_comparison(["truetype-conversion.pdf"])
  339. # mpltest.ttf does not have "l"/"p" glyphs so we get a warning when trying to
  340. # get the font extents.
  341. def test_truetype_conversion(recwarn):
  342. mpl.rcParams['pdf.fonttype'] = 3
  343. fig, ax = plt.subplots()
  344. ax.text(0, 0, "ABCDE",
  345. font=Path(__file__).with_name("mpltest.ttf"), fontsize=80)
  346. ax.set_xticks([])
  347. ax.set_yticks([])