diff --git a/doc/api/next_api_changes/behavior/26479-ES.rst b/doc/api/next_api_changes/behavior/26479-ES.rst new file mode 100644 index 000000000000..5299ebe985f8 --- /dev/null +++ b/doc/api/next_api_changes/behavior/26479-ES.rst @@ -0,0 +1,6 @@ +PostScript paper type adds option to use figure size +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The :rc:`ps.papertype` rcParam can now be set to ``'figure'``, which will use +a paper size that corresponds exactly with the size of the figure that is being +saved. diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index 6aa9862d9e8a..a757bcf8d3be 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -841,7 +841,7 @@ def _print_ps( if papertype is None: papertype = mpl.rcParams['ps.papersize'] papertype = papertype.lower() - _api.check_in_list(['auto', *papersize], papertype=papertype) + _api.check_in_list(['figure', 'auto', *papersize], papertype=papertype) orientation = _api.check_getitem( _Orientation, orientation=orientation.lower()) @@ -873,24 +873,16 @@ def _print_figure( width, height = self.figure.get_size_inches() if papertype == 'auto': _api.warn_deprecated("3.8", name="papertype='auto'", - addendum="Pass an explicit paper type, or omit the " - "*papertype* argument entirely.") + addendum="Pass an explicit paper type, 'figure', or " + "omit the *papertype* argument entirely.") papertype = _get_papertype(*orientation.swap_if_landscape((width, height))) - if is_eps: + if is_eps or papertype == 'figure': paper_width, paper_height = width, height else: paper_width, paper_height = orientation.swap_if_landscape( papersize[papertype]) - if mpl.rcParams['ps.usedistiller']: - # distillers improperly clip eps files if pagesize is too small - if width > paper_width or height > paper_height: - papertype = _get_papertype( - *orientation.swap_if_landscape((width, height))) - paper_width, paper_height = orientation.swap_if_landscape( - papersize[papertype]) - # center the figure on the paper xo = 72 * 0.5 * (paper_width - width) yo = 72 * 0.5 * (paper_height - height) @@ -921,10 +913,10 @@ def print_figure_impl(fh): if is_eps: print("%!PS-Adobe-3.0 EPSF-3.0", file=fh) else: - print(f"%!PS-Adobe-3.0\n" - f"%%DocumentPaperSizes: {papertype}\n" - f"%%Pages: 1\n", - end="", file=fh) + print("%!PS-Adobe-3.0", file=fh) + if papertype != 'figure': + print(f"%%DocumentPaperSizes: {papertype}", file=fh) + print("%%Pages: 1", file=fh) print(f"%%LanguageLevel: 3\n" f"{dsc_comments}\n" f"%%Orientation: {orientation.name}\n" @@ -1061,7 +1053,7 @@ def _print_figure_tex( # set the paper size to the figure size if is_eps. The # resulting ps file has the given size with correct bounding # box so that there is no need to call 'pstoeps' - if is_eps: + if is_eps or papertype == 'figure': paper_width, paper_height = orientation.swap_if_landscape( self.figure.get_size_inches()) else: @@ -1160,9 +1152,14 @@ def gs_distill(tmpfile, eps=False, ptype='letter', bbox=None, rotated=False): """ if eps: - paper_option = "-dEPSCrop" + paper_option = ["-dEPSCrop"] + elif ptype == "figure": + # The bbox will have its lower-left corner at (0, 0), so upper-right + # corner corresponds with paper size. + paper_option = [f"-dDEVICEWIDTHPOINTS={bbox[2]}", + f"-dDEVICEHEIGHTPOINTS={bbox[3]}"] else: - paper_option = "-sPAPERSIZE=%s" % ptype + paper_option = [f"-sPAPERSIZE={ptype}"] psfile = tmpfile + '.ps' dpi = mpl.rcParams['ps.distiller.res'] @@ -1170,7 +1167,7 @@ def gs_distill(tmpfile, eps=False, ptype='letter', bbox=None, rotated=False): cbook._check_and_log_subprocess( [mpl._get_executable_info("gs").executable, "-dBATCH", "-dNOPAUSE", "-r%d" % dpi, "-sDEVICE=ps2write", - paper_option, "-sOutputFile=%s" % psfile, tmpfile], + *paper_option, f"-sOutputFile={psfile}", tmpfile], _log) os.remove(tmpfile) @@ -1196,6 +1193,16 @@ def xpdf_distill(tmpfile, eps=False, ptype='letter', bbox=None, rotated=False): mpl._get_executable_info("gs") # Effectively checks for ps2pdf. mpl._get_executable_info("pdftops") + if eps: + paper_option = ["-dEPSCrop"] + elif ptype == "figure": + # The bbox will have its lower-left corner at (0, 0), so upper-right + # corner corresponds with paper size. + paper_option = [f"-dDEVICEWIDTHPOINTS#{bbox[2]}", + f"-dDEVICEHEIGHTPOINTS#{bbox[3]}"] + else: + paper_option = [f"-sPAPERSIZE#{ptype}"] + with TemporaryDirectory() as tmpdir: tmppdf = pathlib.Path(tmpdir, "tmp.pdf") tmpps = pathlib.Path(tmpdir, "tmp.ps") @@ -1208,7 +1215,7 @@ def xpdf_distill(tmpfile, eps=False, ptype='letter', bbox=None, rotated=False): "-sAutoRotatePages#None", "-sGrayImageFilter#FlateEncode", "-sColorImageFilter#FlateEncode", - "-dEPSCrop" if eps else "-sPAPERSIZE#%s" % ptype, + *paper_option, tmpfile, tmppdf], _log) cbook._check_and_log_subprocess( ["pdftops", "-paper", "match", "-level3", tmppdf, tmpps], _log) diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index f58cc28ab8b8..2c53651da3d6 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -709,7 +709,7 @@ #tk.window_focus: False # Maintain shell focus for TkAgg ### ps backend params -#ps.papersize: letter # {letter, legal, ledger, A0-A10, B0-B10} +#ps.papersize: letter # {figure, letter, legal, ledger, A0-A10, B0-B10} #ps.useafm: False # use AFM fonts, results in small files #ps.usedistiller: False # {ghostscript, xpdf, None} # Experimental: may produce smaller files. diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 46329ce64422..276bb9f812a9 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -441,13 +441,13 @@ def validate_ps_distiller(s): def _validate_papersize(s): # Re-inline this validator when the 'auto' deprecation expires. s = ValidateInStrings("ps.papersize", - ["auto", "letter", "legal", "ledger", + ["figure", "auto", "letter", "legal", "ledger", *[f"{ab}{i}" for ab in "ab" for i in range(11)]], ignorecase=True)(s) if s == "auto": _api.warn_deprecated("3.8", name="ps.papersize='auto'", - addendum="Pass an explicit paper type, or omit the " - "*ps.papersize* rcParam entirely.") + addendum="Pass an explicit paper type, figure, or omit " + "the *ps.papersize* rcParam entirely.") return s diff --git a/lib/matplotlib/tests/test_backend_ps.py b/lib/matplotlib/tests/test_backend_ps.py index a7a3338d2b3a..954d0955a760 100644 --- a/lib/matplotlib/tests/test_backend_ps.py +++ b/lib/matplotlib/tests/test_backend_ps.py @@ -20,6 +20,7 @@ # This tests tends to hit a TeX cache lock on AppVeyor. @pytest.mark.flaky(reruns=3) +@pytest.mark.parametrize('papersize', ['letter', 'figure']) @pytest.mark.parametrize('orientation', ['portrait', 'landscape']) @pytest.mark.parametrize('format, use_log, rcParams', [ ('ps', False, {}), @@ -38,7 +39,19 @@ 'eps afm', 'eps with usetex' ]) -def test_savefig_to_stringio(format, use_log, rcParams, orientation): +def test_savefig_to_stringio(format, use_log, rcParams, orientation, papersize): + if rcParams.get("ps.usedistiller") == "ghostscript": + try: + mpl._get_executable_info("gs") + except mpl.ExecutableNotFoundError as exc: + pytest.skip(str(exc)) + elif rcParams.get("ps.userdistiller") == "xpdf": + try: + mpl._get_executable_info("gs") # Effectively checks for ps2pdf. + mpl._get_executable_info("pdftops") + except mpl.ExecutableNotFoundError as exc: + pytest.skip(str(exc)) + mpl.rcParams.update(rcParams) fig, ax = plt.subplots() @@ -54,15 +67,15 @@ def test_savefig_to_stringio(format, use_log, rcParams, orientation): title += " \N{MINUS SIGN}\N{EURO SIGN}" ax.set_title(title) allowable_exceptions = [] - if rcParams.get("ps.usedistiller"): - allowable_exceptions.append(mpl.ExecutableNotFoundError) if rcParams.get("text.usetex"): allowable_exceptions.append(RuntimeError) if rcParams.get("ps.useafm"): allowable_exceptions.append(mpl.MatplotlibDeprecationWarning) try: - fig.savefig(s_buf, format=format, orientation=orientation) - fig.savefig(b_buf, format=format, orientation=orientation) + fig.savefig(s_buf, format=format, orientation=orientation, + papertype=papersize) + fig.savefig(b_buf, format=format, orientation=orientation, + papertype=papersize) except tuple(allowable_exceptions) as exc: pytest.skip(str(exc)) @@ -71,6 +84,27 @@ def test_savefig_to_stringio(format, use_log, rcParams, orientation): s_val = s_buf.getvalue().encode('ascii') b_val = b_buf.getvalue() + if format == 'ps': + # Default figsize = (8, 6) inches = (576, 432) points = (203.2, 152.4) mm. + # Landscape orientation will swap dimensions. + if rcParams.get("ps.usedistiller") == "xpdf": + # Some versions specifically show letter/203x152, but not all, + # so we can only use this simpler test. + if papersize == 'figure': + assert b'letter' not in s_val.lower() + else: + assert b'letter' in s_val.lower() + elif rcParams.get("ps.usedistiller") or rcParams.get("text.usetex"): + width = b'432.0' if orientation == 'landscape' else b'576.0' + wanted = (b'-dDEVICEWIDTHPOINTS=' + width if papersize == 'figure' + else b'-sPAPERSIZE') + assert wanted in s_val + else: + if papersize == 'figure': + assert b'%%DocumentPaperSizes' not in s_val + else: + assert b'%%DocumentPaperSizes' in s_val + # Strip out CreationDate: ghostscript and cairo don't obey # SOURCE_DATE_EPOCH, and that environment variable is already tested in # test_determinism.