From 663f2b3294c9e7754954f19cacc0c43e11bdef1a Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Fri, 12 Feb 2021 20:15:38 +0100 Subject: [PATCH] Implement bbox_inches="tight" as a post-processing step. The current implementation of bbox_inches="tight" is to no-output-draw the figure, compute a tight bbox, and then shift all artists to their "new" position in tight-bbox-coordinates, draw, and shift back -- which causes all kinds of grief. Instead, we can perform the real draw the first time, and then crop the resulting image (either actually crop it for raster backends, or just adjust the viewport for vector ones). This PR is a proof-of-concept for such an approach (tests fail, that's expected). Currently it writes the first file to the filesystem and then edits it; likely the first file should be just kept in memory instead (after all the final output could also just be an im-memory buffer). This would also avoid having to dupe the metadata handling (for agg). Perhaps the method should be on the renderer instead (the exact API is needs to be discussed). Extra points if the API is designed in such a way that mplcairo can directly call into it. Currently not implemented for postscript because bbox_inches="tight" doesn't work there anyways (although we should fix it for postscript). Not sure about pgf output, but that's probably workable... Final version should keep some backcompat layer, not present here to improve readability. --- lib/matplotlib/backend_bases.py | 39 +++++++++----------------- lib/matplotlib/backends/backend_agg.py | 7 +++++ lib/matplotlib/backends/backend_pdf.py | 26 +++++++++++++++++ lib/matplotlib/backends/backend_svg.py | 12 ++++++++ 4 files changed, 58 insertions(+), 26 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 872550018f3f..cd96fb92c084 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -45,7 +45,7 @@ import matplotlib as mpl from matplotlib import ( _api, backend_tools as tools, cbook, colors, docstring, textpath, - tight_bbox, transforms, widgets, get_backend, is_interactive, rcParams) + transforms, widgets, get_backend, is_interactive, rcParams) from matplotlib._pylab_helpers import Gcf from matplotlib.backend_managers import ToolManager from matplotlib.cbook import _setattr_cm @@ -2273,11 +2273,7 @@ def print_figure( if not cbook._str_equal(color, "auto"): stack.enter_context(self.figure._cm_set(**{prop: color})) - if bbox_inches is None: - bbox_inches = rcParams['savefig.bbox'] - - if (self.figure.get_constrained_layout() or - bbox_inches == "tight"): + if self.figure.get_constrained_layout(): # we need to trigger a draw before printing to make sure # CL works. "tight" also needs a draw to get the right # locations: @@ -2289,22 +2285,6 @@ def print_figure( with getattr(renderer, "_draw_disabled", nullcontext)(): self.figure.draw(renderer) - if bbox_inches: - if bbox_inches == "tight": - bbox_inches = self.figure.get_tightbbox( - renderer, bbox_extra_artists=bbox_extra_artists) - if pad_inches is None: - pad_inches = rcParams['savefig.pad_inches'] - bbox_inches = bbox_inches.padded(pad_inches) - - # call adjust_bbox to save only the given area - restore_bbox = tight_bbox.adjust_bbox(self.figure, bbox_inches, - canvas.fixed_dpi) - - _bbox_inches_restore = (bbox_inches, restore_bbox) - else: - _bbox_inches_restore = None - # we have already done CL above, so turn it off: stack.enter_context(self.figure._cm_set(constrained_layout=False)) try: @@ -2316,12 +2296,19 @@ def print_figure( facecolor=facecolor, edgecolor=edgecolor, orientation=orientation, - bbox_inches_restore=_bbox_inches_restore, **kwargs) + if bbox_inches is None: + bbox_inches = rcParams["savefig.bbox"] + if bbox_inches == "tight": + bbox_inches = self.figure.get_tightbbox( + self.figure._cachedRenderer, + bbox_extra_artists=bbox_extra_artists) + if pad_inches is None: + pad_inches = rcParams["savefig.pad_inches"] + bbox_inches = bbox_inches.padded(pad_inches) + if bbox_inches: + canvas.adjust_bbox(filename, bbox_inches) finally: - if bbox_inches and restore_bbox: - restore_bbox() - self.figure.set_canvas(self) return result diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index 74f1d911b561..ad34e8e7e4c9 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -562,6 +562,13 @@ def print_tif(self, filename_or_obj, *, pil_kwargs=None): print_tiff = print_tif + def adjust_bbox(self, filename, bbox_inches): + bbox = self.figure.dpi_scale_trans.transform_bbox(bbox_inches) + h = self.figure.bbox.height + img = Image.open(filename) + img = img.crop((bbox.x0, h - bbox.y1, bbox.x1, h - bbox.y0)) + img.save(filename, format=img.format) # TODO: also copy metadata + @_Backend.export class _BackendAgg(_Backend): diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 2eb6f090db26..bdf0974e8536 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -2719,6 +2719,32 @@ def draw(self): self.figure.draw_no_output() return super().draw() + def adjust_bbox(self, filename, bbox_inches): + bbox = self.figure.dpi_scale_trans.transform_bbox(bbox_inches) + # What about PdfPages? + with open(filename, "a+b") as file: + file.seek(0) + buf = file.read() + page_pos = file.tell() + pageid = self.figure._cachedRenderer.file.pageList[-1].id + page_match = re.search( + rb"(?s)%d 0 obj\n.*?/MediaBox.*?\nendobj\n" % pageid, buf) + file.write(re.sub(br"/MediaBox \[[^]]*\]", b"/MediaBox %s" + % pdfRepr(bbox.extents.tolist()), page_match[0])) + startxref_pos = file.tell() + file.write(b"xref\n") + file.write(b"0 1\n0000000000 65535 f \n") + file.write(b"%d 1\n%010d 00000 n \n" % (pageid, page_pos)) + file.write(b"trailer\n") + trailer_match = re.search( + rb"(?s)trailer\n<< (.*) >>\nstartxref\n(\d+)\n%%EOF", + buf[buf.rfind(b"trailer\n"):]) + file.write( + b"<< %s /Prev %s >>\n" % (trailer_match[1], trailer_match[2])) + file.write(b"startxref\n") + file.write(b"%d\n" % startxref_pos) + file.write(b"%%EOF\n") + FigureManagerPdf = FigureManagerBase diff --git a/lib/matplotlib/backends/backend_svg.py b/lib/matplotlib/backends/backend_svg.py index a1a11061b978..cbd156f7fdea 100644 --- a/lib/matplotlib/backends/backend_svg.py +++ b/lib/matplotlib/backends/backend_svg.py @@ -6,6 +6,7 @@ import itertools import logging import os +import pathlib import re import uuid @@ -1345,6 +1346,17 @@ def draw(self): self.figure.draw_no_output() return super().draw() + def adjust_bbox(self, filename, bbox_inches): + bbox = self.figure.dpi_scale_trans.transform_bbox(bbox_inches) + buf = pathlib.Path(filename).read_text() + w, h, x, y = map(short_float_fmt, [ + bbox.width, bbox.height, + bbox.x0, self.figure.bbox.height - bbox.y1]) + buf = re.sub('width="[^"]*"', f'width="{w}pt"', buf, 1) + buf = re.sub('height="[^"]*"', f'height="{h}pt"', buf, 1) + buf = re.sub('viewBox="[^"]*"', f'viewBox="{x} {y} {w} {h}"', buf, 1) + pathlib.Path(filename).write_text(buf) + FigureManagerSVG = FigureManagerBase