From 740c5d7fe0a4875e1b5fb5d50122aff482805fe7 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Tue, 8 Aug 2023 21:24:55 -0400 Subject: [PATCH 1/2] ps: Default to using figure size as paper We already set the bounding box to coincide with the figure size, but setting an overall paper size can sometimes cause the resulting file to be cropped by the viewer. We additionally need to pass this through to the distiller. --- .../next_api_changes/behavior/26479-ES.rst | 6 +++ lib/matplotlib/backends/backend_ps.py | 49 +++++++++++-------- lib/matplotlib/mpl-data/matplotlibrc | 2 +- lib/matplotlib/rcsetup.py | 6 +-- lib/matplotlib/tests/test_backend_ps.py | 30 ++++++++++-- 5 files changed, 65 insertions(+), 28 deletions(-) create mode 100644 doc/api/next_api_changes/behavior/26479-ES.rst 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..4d23c8a87f9e 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,7 @@ '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): mpl.rcParams.update(rcParams) fig, ax = plt.subplots() @@ -61,8 +62,10 @@ def test_savefig_to_stringio(format, use_log, rcParams, orientation): 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 +74,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. From 96b26fea5a631b1d76de42df4ad08b00db8055a0 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 9 Aug 2023 17:13:39 -0400 Subject: [PATCH 2/2] TST: Correctly skip missing distillers When a distiller is not found, that step is simply skipped by `_try_distill`, so these tests were not actually skipped as intended. --- lib/matplotlib/tests/test_backend_ps.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/tests/test_backend_ps.py b/lib/matplotlib/tests/test_backend_ps.py index 4d23c8a87f9e..954d0955a760 100644 --- a/lib/matplotlib/tests/test_backend_ps.py +++ b/lib/matplotlib/tests/test_backend_ps.py @@ -40,6 +40,18 @@ 'eps with usetex' ]) 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() @@ -55,8 +67,6 @@ def test_savefig_to_stringio(format, use_log, rcParams, orientation, papersize): 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"):