diff --git a/doc/users/whats_new/metadata_savefig_kwarg.rst b/doc/users/whats_new/metadata_savefig_kwarg.rst new file mode 100644 index 000000000000..3167cac2c4f9 --- /dev/null +++ b/doc/users/whats_new/metadata_savefig_kwarg.rst @@ -0,0 +1,20 @@ +Metadata savefig kwarg +---------------------- + +:func:`~matplotlib.pyplot.savefig` now accepts `metadata` as a keyword argument. +It can be used to store key/value pairs in the image metadata. + +Supported formats and backends +`````````````````````````````` +* 'png' with Agg backend +* 'pdf' with PDF backend (see + :func:`~matplotlib.backends.backend_pdf.PdfFile.writeInfoDict` for a list of + supported keywords) +* 'eps' and 'ps' with PS backend (only 'Creator' key is accepted) + +Example +``````` +:: + + plt.savefig('test.png', metadata={'Software': 'My awesome software'}) + diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index 7a19aebc3a29..8f3bce4396b5 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -26,8 +26,9 @@ import threading import numpy as np +from collections import OrderedDict from math import radians, cos, sin -from matplotlib import verbose, rcParams +from matplotlib import verbose, rcParams, __version__ from matplotlib.backend_bases import (RendererBase, FigureManagerBase, FigureCanvasBase) from matplotlib.cbook import is_string_like, maxdict, restrict_dict @@ -554,8 +555,16 @@ def print_png(self, filename_or_obj, *args, **kwargs): else: close = False + version_str = 'matplotlib version ' + __version__ + \ + ', http://matplotlib.org/' + metadata = OrderedDict({'Software': version_str}) + user_metadata = kwargs.pop("metadata", None) + if user_metadata is not None: + metadata.update(user_metadata) + try: - _png.write_png(renderer._renderer, filename_or_obj, self.figure.dpi) + _png.write_png(renderer._renderer, filename_or_obj, + self.figure.dpi, metadata=metadata) finally: if close: filename_or_obj.close() diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 8f0a3a295bc1..f6fa7ea12345 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -428,7 +428,7 @@ def _flush(self): class PdfFile(object): """PDF file object.""" - def __init__(self, filename): + def __init__(self, filename, metadata=None): self.nextObject = 1 # next free object id self.xrefTable = [[0, 65535, 'the zero object']] self.passed_in_file_object = False @@ -486,7 +486,9 @@ def __init__(self, filename): 'Creator': 'matplotlib %s, http://matplotlib.org' % __version__, 'Producer': 'matplotlib pdf backend%s' % revision, 'CreationDate': source_date - } + } + if metadata is not None: + self.infoDict.update(metadata) self.fontNames = {} # maps filenames to internal font names self.nextFont = 1 # next free internal font name @@ -2438,22 +2440,27 @@ class PdfPages(object): """ __slots__ = ('_file', 'keep_empty') - def __init__(self, filename, keep_empty=True): + def __init__(self, filename, keep_empty=True, metadata=None): """ Create a new PdfPages object. Parameters ---------- - filename: str + filename : str Plots using :meth:`PdfPages.savefig` will be written to a file at this location. The file is opened at once and any older file with the same name is overwritten. - keep_empty: bool, optional + 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'}` """ - self._file = PdfFile(filename) + self._file = PdfFile(filename, metadata=metadata) self.keep_empty = keep_empty def __enter__(self): @@ -2492,7 +2499,7 @@ def savefig(self, figure=None, **kwargs): Parameters ---------- - figure: :class:`~matplotlib.figure.Figure` or int, optional + 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, @@ -2556,7 +2563,7 @@ def print_pdf(self, filename, **kwargs): if isinstance(filename, PdfPages): file = filename._file else: - file = PdfFile(filename) + file = PdfFile(filename, metadata=kwargs.pop("metadata", None)) try: file.newPage(width, height) _bbox_inches_restore = kwargs.pop("bbox_inches_restore", None) diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index 871f2a95b927..d5591049578c 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -965,7 +965,7 @@ def _print_ps(self, outfile, format, *args, **kwargs): def _print_figure(self, outfile, format, dpi=72, facecolor='w', edgecolor='w', orientation='portrait', isLandscape=False, papertype=None, - **kwargs): + metadata=None, **kwargs): """ Render the figure to hardcopy. Set the figure patch face and edge colors. This is useful because some of the GUIs have a @@ -978,6 +978,9 @@ def _print_figure(self, outfile, format, dpi=72, facecolor='w', edgecolor='w', If outfile is a file object, a stand-alone PostScript file is written into this file object. + + metadata must be a dictionary. Currently, only the value for + the key 'Creator' is used. """ isEPSF = format == 'eps' passed_in_file_object = False @@ -1059,13 +1062,18 @@ def write(self, *kl, **kwargs): self.figure.set_facecolor(origfacecolor) self.figure.set_edgecolor(origedgecolor) + # check for custom metadata + if metadata is not None and 'Creator' in metadata: + creator_str = metadata['Creator'] + else: + creator_str = "matplotlib version " + __version__ + \ + ", http://matplotlib.org/" def print_figure_impl(): # write the PostScript headers if isEPSF: print("%!PS-Adobe-3.0 EPSF-3.0", file=fh) else: print("%!PS-Adobe-3.0", file=fh) if title: print("%%Title: "+title, file=fh) - print(("%%Creator: matplotlib version " - +__version__+", http://matplotlib.org/"), file=fh) + print("%%Creator: " + creator_str, file=fh) # get source date from SOURCE_DATE_EPOCH, if set # See https://reproducible-builds.org/specs/source-date-epoch/ source_date_epoch = os.getenv("SOURCE_DATE_EPOCH") @@ -1189,12 +1197,15 @@ def do_nothing(): os.chmod(outfile, mode) def _print_figure_tex(self, outfile, format, dpi, facecolor, edgecolor, - orientation, isLandscape, papertype, + orientation, isLandscape, papertype, metadata=None, **kwargs): """ If text.usetex is True in rc, a temporary pair of tex/eps files are created to allow tex to manage the text layout via the PSFrags package. These files are processed to yield the final ps or eps file. + + metadata must be a dictionary. Currently, only the value for + the key 'Creator' is used. """ isEPSF = format == 'eps' if is_string_like(outfile): @@ -1249,14 +1260,20 @@ def write(self, *kl, **kwargs): self.figure.set_facecolor(origfacecolor) self.figure.set_edgecolor(origedgecolor) + # check for custom metadata + if metadata is not None and 'Creator' in metadata: + creator_str = metadata['Creator'] + else: + creator_str = "matplotlib version " + __version__ + \ + ", http://matplotlib.org/" + # write to a temp file, we'll move it to outfile when done fd, tmpfile = mkstemp() with io.open(fd, 'w', encoding='latin-1') as fh: # write the Encapsulated PostScript headers print("%!PS-Adobe-3.0 EPSF-3.0", file=fh) if title: print("%%Title: "+title, file=fh) - print(("%%Creator: matplotlib version " - +__version__+", http://matplotlib.org/"), file=fh) + print("%%Creator: " + creator_str, file=fh) # get source date from SOURCE_DATE_EPOCH, if set # See https://reproducible-builds.org/specs/source-date-epoch/ source_date_epoch = os.getenv("SOURCE_DATE_EPOCH") diff --git a/src/_png.cpp b/src/_png.cpp index 955a2188af75..f5c25deeb2fc 100644 --- a/src/_png.cpp +++ b/src/_png.cpp @@ -114,6 +114,24 @@ const char *Py_write_png__doc__ = " If not provided, libpng will try to automatically determine the\n" " best filter on a line-by-line basis.\n" "\n" + "metadata : dictionary\n" + " The keyword-text pairs that are stored as comments in the image.\n" + " Keys must be shorter than 79 chars. The only supported encoding\n" + " for both keywords and values is Latin-1 (ISO 8859-1).\n" + " Examples given in the PNG Specification are:\n" + " - Title: Short (one line) title or caption for image\n" + " - Author: Name of image's creator\n" + " - Description: Description of image (possibly long)\n" + " - Copyright: Copyright notice\n" + " - Creation Time: Time of original image creation\n" + " (usually RFC 1123 format, see below)\n" + " - Software: Software used to create the image\n" + " - Disclaimer: Legal disclaimer\n" + " - Warning: Warning of nature of content\n" + " - Source: Device used to create the image\n" + " - Comment: Miscellaneous comment; conversion\n" + " from other image format\n" + "\n" "Returns\n" "-------\n" "buffer : bytes or None\n" @@ -124,10 +142,16 @@ static PyObject *Py_write_png(PyObject *self, PyObject *args, PyObject *kwds) { numpy::array_view buffer; PyObject *filein; + PyObject *metadata = NULL; + PyObject *meta_key, *meta_val; + png_text *text; + Py_ssize_t pos = 0; + int meta_pos = 0; + Py_ssize_t meta_size; double dpi = 0; int compression = 6; int filter = -1; - const char *names[] = { "buffer", "file", "dpi", "compression", "filter", NULL }; + const char *names[] = { "buffer", "file", "dpi", "compression", "filter", "metadata", NULL }; // We don't need strict contiguity, just for each row to be // contiguous, and libpng has special handling for getting RGB out @@ -135,14 +159,15 @@ static PyObject *Py_write_png(PyObject *self, PyObject *args, PyObject *kwds) // enforce contiguity using array_view::converter_contiguous. if (!PyArg_ParseTupleAndKeywords(args, kwds, - "O&O|dii:write_png", + "O&O|diiO:write_png", (char **)names, &buffer.converter_contiguous, &buffer, &filein, &dpi, &compression, - &filter)) { + &filter, + &metadata)) { return NULL; } @@ -276,6 +301,51 @@ static PyObject *Py_write_png(PyObject *self, PyObject *args, PyObject *kwds) png_set_pHYs(png_ptr, info_ptr, dots_per_meter, dots_per_meter, PNG_RESOLUTION_METER); } +#ifdef PNG_TEXT_SUPPORTED + // Save the metadata + if (metadata != NULL) { + meta_size = PyDict_Size(metadata); + text = new png_text[meta_size]; + + while (PyDict_Next(metadata, &pos, &meta_key, &meta_val)) { + text[meta_pos].compression = PNG_TEXT_COMPRESSION_NONE; +#if PY3K + if (PyUnicode_Check(meta_key)) { + PyObject *temp_key = PyUnicode_AsEncodedString(meta_key, "latin_1", "strict"); + if (temp_key != NULL) { + text[meta_pos].key = PyBytes_AsString(temp_key); + } + } else if (PyBytes_Check(meta_key)) { + text[meta_pos].key = PyBytes_AsString(meta_key); + } else { + char invalid_key[79]; + sprintf(invalid_key,"INVALID KEY %d", meta_pos); + text[meta_pos].key = invalid_key; + } + if (PyUnicode_Check(meta_val)) { + PyObject *temp_val = PyUnicode_AsEncodedString(meta_val, "latin_1", "strict"); + if (temp_val != NULL) { + text[meta_pos].text = PyBytes_AsString(temp_val); + } + } else if (PyBytes_Check(meta_val)) { + text[meta_pos].text = PyBytes_AsString(meta_val); + } else { + text[meta_pos].text = (char *)"Invalid value in metadata"; + } +#else + text[meta_pos].key = PyString_AsString(meta_key); + text[meta_pos].text = PyString_AsString(meta_val); +#endif +#ifdef PNG_iTXt_SUPPORTED + text[meta_pos].lang = NULL; +#endif + meta_pos++; + } + png_set_text(png_ptr, info_ptr, text, meta_size); + delete[] text; + } +#endif + sig_bit.alpha = 0; switch (png_color_type) { case PNG_COLOR_TYPE_GRAY: