diff --git a/.gitignore b/.gitignore index 36d13934bcf0..faa897b4f1c9 100644 --- a/.gitignore +++ b/.gitignore @@ -80,6 +80,7 @@ result_images # Nose/Pytest generated files # ############################### +.pytest_cache/ .cache/ .coverage .coverage.* diff --git a/.travis.yml b/.travis.yml index bb5e37167177..1aba1fe913f9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -37,6 +37,7 @@ addons: - texlive-latex-extra - texlive-latex-recommended - texlive-xetex + - texlive-luatex env: global: diff --git a/doc/faq/howto_faq.rst b/doc/faq/howto_faq.rst index ab42bd303d10..cbfe0842433f 100644 --- a/doc/faq/howto_faq.rst +++ b/doc/faq/howto_faq.rst @@ -136,6 +136,10 @@ Finally, the multipage pdf object has to be closed:: pp.close() +The same can be done using the pgf backend:: + + from matplotlib.backends.backend_pgf import PdfPages + .. _howto-subplots-adjust: diff --git a/doc/users/next_whats_new/pgf_pdfpages.rst b/doc/users/next_whats_new/pgf_pdfpages.rst new file mode 100644 index 000000000000..7398019505e6 --- /dev/null +++ b/doc/users/next_whats_new/pgf_pdfpages.rst @@ -0,0 +1,19 @@ +Multipage PDF support for pgf backend +------------------------------------- + +The pgf backend now also supports multipage PDF files. + +.. code-block:: python + + from matplotlib.backends.backend_pgf import PdfPages + import matplotlib.pyplot as plt + + with PdfPages('multipage.pdf') as pdf: + # page 1 + plt.plot([2, 1, 3]) + pdf.savefig() + + # page 2 + plt.cla() + plt.plot([3, 1, 2]) + pdf.savefig() diff --git a/doc/users/whats_new.rst b/doc/users/whats_new.rst index 5c27d25b9326..df0aa9375632 100644 --- a/doc/users/whats_new.rst +++ b/doc/users/whats_new.rst @@ -14,12 +14,12 @@ revision, see the :ref:`github-stats`. .. For a release, add a new section after this, then comment out the include and toctree below by indenting them. Uncomment them after the release. - .. include:: next_whats_new/README.rst - .. toctree:: - :glob: - :maxdepth: 1 +.. include:: next_whats_new/README.rst +.. toctree:: + :glob: + :maxdepth: 1 - next_whats_new/* + next_whats_new/* New in Matplotlib 2.2 diff --git a/examples/misc/multipage_pdf.py b/examples/misc/multipage_pdf.py index 532d771849cb..9b49f1d8644f 100644 --- a/examples/misc/multipage_pdf.py +++ b/examples/misc/multipage_pdf.py @@ -5,6 +5,10 @@ This is a demo of creating a pdf file with several pages, as well as adding metadata and annotations to pdf files. + +If you want to use a multipage pdf file using LaTeX, you need +to use `from matplotlib.backends.backend_pgf import PdfPages`. +This version however does not support `attach_note`. """ import datetime diff --git a/lib/matplotlib/backends/backend_pgf.py b/lib/matplotlib/backends/backend_pgf.py index 94cd02ae92c3..f8d98c409a4b 100644 --- a/lib/matplotlib/backends/backend_pgf.py +++ b/lib/matplotlib/backends/backend_pgf.py @@ -17,13 +17,15 @@ import weakref import matplotlib as mpl -from matplotlib import _png, rcParams +from matplotlib import _png, rcParams, __version__ from matplotlib.backend_bases import ( _Backend, FigureCanvasBase, FigureManagerBase, GraphicsContextBase, RendererBase) from matplotlib.backends.backend_mixed import MixedModeRenderer from matplotlib.cbook import is_writable_file_like from matplotlib.path import Path +from matplotlib.figure import Figure +from matplotlib._pylab_helpers import Gcf ############################################################################### @@ -50,6 +52,11 @@ warnings.warn('error getting fonts from fc-list', UserWarning) +_luatex_version_re = re.compile( + 'This is LuaTeX, Version (?:beta-)?([0-9]+)\.([0-9]+)\.([0-9]+)' +) + + def get_texcommand(): """Get chosen TeX system from rc.""" texsystem_options = ["xelatex", "lualatex", "pdflatex"] @@ -57,6 +64,18 @@ def get_texcommand(): return texsystem if texsystem in texsystem_options else "xelatex" +def _get_lualatex_version(): + """Get version of luatex""" + output = subprocess.check_output(['lualatex', '--version']) + return _parse_lualatex_version(output.decode()) + + +def _parse_lualatex_version(output): + '''parse the lualatex version from the output of `lualatex --version`''' + match = _luatex_version_re.match(output) + return tuple(map(int, match.groups())) + + def get_fontspec(): """Build fontspec preamble from rc.""" latex_fontspec = [] @@ -990,4 +1009,221 @@ def _cleanup_all(): LatexManager._cleanup_remaining_instances() TmpDirCleaner.cleanup_remaining_tmpdirs() + atexit.register(_cleanup_all) + + +class PdfPages: + """ + A multi-page PDF file using the pgf backend + + Examples + -------- + + >>> import matplotlib.pyplot as plt + >>> # Initialize: + >>> with PdfPages('foo.pdf') as pdf: + ... # As many times as you like, create a figure fig and save it: + ... fig = plt.figure() + ... pdf.savefig(fig) + ... # When no figure is specified the current figure is saved + ... pdf.savefig() + """ + __slots__ = ( + '_outputfile', + 'keep_empty', + '_tmpdir', + '_basename', + '_fname_tex', + '_fname_pdf', + '_n_figures', + '_file', + 'metadata', + ) + + def __init__(self, filename, *, keep_empty=True, metadata=None): + """ + Create a new PdfPages object. + + Parameters + ---------- + + filename : str + Plots using :meth:`PdfPages.savefig` will be written to a file at + this location. Any older file with the same name is overwritten. + keep_empty : bool, optional + If set to False, then empty pdf files will be deleted automatically + when closed. + metadata : dictionary, optional + Information dictionary object (see PDF reference section 10.2.1 + 'Document Information Dictionary'), e.g.: + `{'Creator': 'My software', 'Author': 'Me', + 'Title': 'Awesome fig'}` + + The standard keys are `'Title'`, `'Author'`, `'Subject'`, + `'Keywords'`, `'Producer'`, `'Creator'` and `'Trapped'`. + Values have been predefined for `'Creator'` and `'Producer'`. + They can be removed by setting them to the empty string. + """ + self._outputfile = filename + self._n_figures = 0 + self.keep_empty = keep_empty + self.metadata = metadata or {} + + # create temporary directory for compiling the figure + self._tmpdir = tempfile.mkdtemp(prefix="mpl_pgf_pdfpages_") + self._basename = 'pdf_pages' + self._fname_tex = os.path.join(self._tmpdir, self._basename + ".tex") + self._fname_pdf = os.path.join(self._tmpdir, self._basename + ".pdf") + self._file = open(self._fname_tex, 'wb') + + def _write_header(self, width_inches, height_inches): + supported_keys = { + 'title', 'author', 'subject', 'keywords', 'creator', + 'producer', 'trapped' + } + infoDict = { + 'creator': 'matplotlib %s, https://matplotlib.org' % __version__, + 'producer': 'matplotlib pgf backend %s' % __version__, + } + metadata = {k.lower(): v for k, v in self.metadata.items()} + infoDict.update(metadata) + hyperref_options = '' + for k, v in infoDict.items(): + if k not in supported_keys: + raise ValueError( + 'Not a supported pdf metadata field: "{}"'.format(k) + ) + hyperref_options += 'pdf' + k + '={' + str(v) + '},' + + latex_preamble = get_preamble() + latex_fontspec = get_fontspec() + latex_header = r"""\PassOptionsToPackage{{ + {metadata} +}}{{hyperref}} +\RequirePackage{{hyperref}} +\documentclass[12pt]{{minimal}} +\usepackage[ + paperwidth={width}in, + paperheight={height}in, + margin=0in +]{{geometry}} +{preamble} +{fontspec} +\usepackage{{pgf}} +\setlength{{\parindent}}{{0pt}} + +\begin{{document}}%% +""".format( + width=width_inches, + height=height_inches, + preamble=latex_preamble, + fontspec=latex_fontspec, + metadata=hyperref_options, + ) + self._file.write(latex_header.encode('utf-8')) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + def close(self): + """ + Finalize this object, running LaTeX in a temporary directory + and moving the final pdf file to `filename`. + """ + self._file.write(rb'\end{document}\n') + self._file.close() + + if self._n_figures > 0: + try: + self._run_latex() + finally: + try: + shutil.rmtree(self._tmpdir) + except: + TmpDirCleaner.add(self._tmpdir) + elif self.keep_empty: + open(self._outputfile, 'wb').close() + + def _run_latex(self): + texcommand = get_texcommand() + cmdargs = [ + str(texcommand), + "-interaction=nonstopmode", + "-halt-on-error", + os.path.basename(self._fname_tex), + ] + try: + subprocess.check_output( + cmdargs, stderr=subprocess.STDOUT, cwd=self._tmpdir + ) + except subprocess.CalledProcessError as e: + raise RuntimeError( + "%s was not able to process your file.\n\nFull log:\n%s" + % (texcommand, e.output.decode('utf-8'))) + + # copy file contents to target + shutil.copyfile(self._fname_pdf, self._outputfile) + + def savefig(self, figure=None, **kwargs): + """ + Saves a :class:`~matplotlib.figure.Figure` to this file as a new page. + + Any other keyword arguments are passed to + :meth:`~matplotlib.figure.Figure.savefig`. + + Parameters + ---------- + + figure : :class:`~matplotlib.figure.Figure` or int, optional + Specifies what figure is saved to file. If not specified, the + active figure is saved. If a :class:`~matplotlib.figure.Figure` + instance is provided, this figure is saved. If an int is specified, + the figure instance to save is looked up by number. + """ + if not isinstance(figure, Figure): + if figure is None: + manager = Gcf.get_active() + else: + manager = Gcf.get_fig_manager(figure) + if manager is None: + raise ValueError("No figure {}".format(figure)) + figure = manager.canvas.figure + + try: + orig_canvas = figure.canvas + figure.canvas = FigureCanvasPgf(figure) + + width, height = figure.get_size_inches() + if self._n_figures == 0: + self._write_header(width, height) + else: + self._file.write(self._build_newpage_command(width, height)) + + figure.savefig(self._file, format="pgf", **kwargs) + self._n_figures += 1 + finally: + figure.canvas = orig_canvas + + def _build_newpage_command(self, width, height): + '''LuaLaTeX from version 0.85 removed the `\pdf*` primitives, + so we need to check the lualatex version and use `\pagewidth` if + the version is 0.85 or newer + ''' + texcommand = get_texcommand() + if texcommand == 'lualatex' and _get_lualatex_version() >= (0, 85, 0): + cmd = r'\page' + else: + cmd = r'\pdfpage' + + newpage = r'\newpage{cmd}width={w}in,{cmd}height={h}in%' + '\n' + return newpage.format(cmd=cmd, w=width, h=height).encode('utf-8') + + def get_pagecount(self): + """ + Returns the current number of pages in the multipage pdf file. + """ + return self._n_figures diff --git a/lib/matplotlib/tests/test_backend_pgf.py b/lib/matplotlib/tests/test_backend_pgf.py index c052e5e3b22c..b42d99e23a61 100644 --- a/lib/matplotlib/tests/test_backend_pgf.py +++ b/lib/matplotlib/tests/test_backend_pgf.py @@ -12,6 +12,7 @@ import matplotlib.pyplot as plt from matplotlib.testing.compare import compare_images, ImageComparisonFailure from matplotlib.testing.decorators import image_comparison, _image_directories +from matplotlib.backends.backend_pgf import PdfPages baseline_dir, result_dir = _image_directories(lambda: 'dummy func') @@ -40,6 +41,8 @@ def check_for(texsystem): reason='xelatex + pgf is required') needs_pdflatex = pytest.mark.skipif(not check_for('pdflatex'), reason='pdflatex + pgf is required') +needs_lualatex = pytest.mark.skipif(not check_for('lualatex'), + reason='lualatex + pgf is required') def compare_figure(fname, savefig_kwargs={}, tol=0): @@ -195,3 +198,118 @@ def test_bbox_inches(): bbox = ax1.get_window_extent().transformed(fig.dpi_scale_trans.inverted()) compare_figure('pgf_bbox_inches.pdf', savefig_kwargs={'bbox_inches': bbox}, tol=0) + + +@needs_pdflatex +@pytest.mark.style('default') +@pytest.mark.backend('pgf') +def test_pdf_pages(): + rc_pdflatex = { + 'font.family': 'serif', + 'pgf.rcfonts': False, + 'pgf.texsystem': 'pdflatex', + } + mpl.rcParams.update(rc_pdflatex) + + fig1 = plt.figure() + ax1 = fig1.add_subplot(1, 1, 1) + ax1.plot(range(5)) + fig1.tight_layout() + + fig2 = plt.figure(figsize=(3, 2)) + ax2 = fig2.add_subplot(1, 1, 1) + ax2.plot(range(5)) + fig2.tight_layout() + + with PdfPages(os.path.join(result_dir, 'pdfpages.pdf')) as pdf: + pdf.savefig(fig1) + pdf.savefig(fig2) + + +@needs_xelatex +@pytest.mark.style('default') +@pytest.mark.backend('pgf') +def test_pdf_pages_metadata(): + rc_pdflatex = { + 'font.family': 'serif', + 'pgf.rcfonts': False, + 'pgf.texsystem': 'xelatex', + } + mpl.rcParams.update(rc_pdflatex) + + fig = plt.figure() + ax = fig.add_subplot(1, 1, 1) + ax.plot(range(5)) + fig.tight_layout() + + md = {'author': 'me', 'title': 'Multipage PDF with pgf'} + path = os.path.join(result_dir, 'pdfpages_meta.pdf') + + with PdfPages(path, metadata=md) as pdf: + pdf.savefig(fig) + pdf.savefig(fig) + pdf.savefig(fig) + + assert pdf.get_pagecount() == 3 + + +@needs_lualatex +@pytest.mark.style('default') +@pytest.mark.backend('pgf') +def test_pdf_pages_lualatex(): + rc_pdflatex = { + 'font.family': 'serif', + 'pgf.rcfonts': False, + 'pgf.texsystem': 'lualatex' + } + mpl.rcParams.update(rc_pdflatex) + + fig = plt.figure() + ax = fig.add_subplot(1, 1, 1) + ax.plot(range(5)) + fig.tight_layout() + + md = {'author': 'me', 'title': 'Multipage PDF with pgf'} + path = os.path.join(result_dir, 'pdfpages_lua.pdf') + with PdfPages(path, metadata=md) as pdf: + pdf.savefig(fig) + pdf.savefig(fig) + + assert pdf.get_pagecount() == 2 + + +@needs_lualatex +def test_luatex_version(): + from matplotlib.backends.backend_pgf import _parse_lualatex_version + from matplotlib.backends.backend_pgf import _get_lualatex_version + + v1 = '''This is LuaTeX, Version 1.0.4 (TeX Live 2017) + +Execute 'luatex --credits' for credits and version details. + +There is NO warranty. Redistribution of this software is covered by +the terms of the GNU General Public License, version 2 or (at your option) +any later version. For more information about these matters, see the file +named COPYING and the LuaTeX source. + +LuaTeX is Copyright 2017 Taco Hoekwater and the LuaTeX Team. +''' + + v2 = '''This is LuaTeX, Version beta-0.76.0-2015112019 (TeX Live 2013) (rev 4627) + +Execute 'luatex --credits' for credits and version details. + +There is NO warranty. Redistribution of this software is covered by +the terms of the GNU General Public License, version 2 or (at your option) +any later version. For more information about these matters, see the file +named COPYING and the LuaTeX source. + +Copyright 2013 Taco Hoekwater, the LuaTeX Team. +''' + + assert _parse_lualatex_version(v1) == (1, 0, 4) + assert _parse_lualatex_version(v2) == (0, 76, 0) + + # just test if it is successful + version = _get_lualatex_version() + assert len(version) == 3 diff --git a/tutorials/text/pgf.py b/tutorials/text/pgf.py index 162f74807135..3b2682a723e8 100644 --- a/tutorials/text/pgf.py +++ b/tutorials/text/pgf.py @@ -56,6 +56,30 @@ .. _pgf-rcfonts: + +Multi-Page PDF Files +==================== + +The pgf backend also supports multipage pdf files using ``PdfPages`` + +.. code-block:: python + + from matplotlib.backends.backend_pgf import PdfPages + import matplotlib.pyplot as plt + + with PdfPages('multipage.pdf', metadata={'author': 'Me'}) as pdf: + + fig1 = plt.figure() + ax1 = fig1.add_subplot(1, 1, 1) + ax1.plot([1, 5, 3]) + pdf.savefig(fig1) + + fig2 = plt.figure() + ax2 = fig2.add_subplot(1, 1, 1) + ax2.plot([1, 5, 3]) + pdf.savefig(fig2) + + Font specification ==================