From e96284472af0872b807a46b6b28c5b797636286c Mon Sep 17 00:00:00 2001 From: Benjamin Johnson <42893476+bmjcode@users.noreply.github.com> Date: Tue, 5 May 2026 21:56:00 -0400 Subject: [PATCH 1/5] Fix several bugs in the background-painting logic Previously the background was painted or erased several times in separate parts of the code when rendering a PDF page. This was partly my own fault, as I did not fully understand the rendering logic when I first wrote the QtPDF backend (the previous Poppler backend I used as a reference also overrode significant parts of it). This new code only paints the background once, and only when necessary to do so. For example, it's not necessary or desirable to paint the background when printing. Another consequence of this fix is that it's possible now to render an image with a transparent background, for example in Frescobaldi's copy-to-image dialog. I believe this was how it worked in older versions of qpageview, and was also the intended behavior here all along. --- qpageview/pdf.py | 4 ---- qpageview/render.py | 19 ++++++++++--------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/qpageview/pdf.py b/qpageview/pdf.py index a765ea8..9822a7d 100644 --- a/qpageview/pdf.py +++ b/qpageview/pdf.py @@ -303,10 +303,6 @@ def draw(self, page, painter, key, tile, paperColor=None): Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation) - # Erase the target area and draw the image - painter.eraseRect(target) - if paperColor: - painter.fillRect(target, paperColor) painter.drawImage(target, image, QRectF(image.rect())) diff --git a/qpageview/render.py b/qpageview/render.py index 89d4b7a..fc53019 100644 --- a/qpageview/render.py +++ b/qpageview/render.py @@ -219,17 +219,15 @@ def render(self, page, key, tile, paperColor=None): The default implementation prepares the image, a painter and then calls draw() to actually draw the contents. - If the paperColor is not specified, it will be read from the Page's - paperColor attribute (if not None) or else from the renderer's - paperColor attribute. + If the paperColor is not specified, the returned image will have a + transparent background, and the caller is responsible to paint or + erase the background as needed before calling this method. """ - if paperColor is None: - paperColor = page.paperColor or self.paperColor - i = QImage(tile.w, tile.h, self.imageFormat) - if paperColor: - i.fill(paperColor) + # leave the background transparent if no paperColor is specified, + # since we don't necessarily want to fill it e.g. when printing + i.fill(paperColor or Qt.GlobalColor.transparent) painter = QPainter(i) # rotate the painter accordingly @@ -333,6 +331,7 @@ def paint(self, page, painter, rect, callback=None): region = QRegion() # painted region in tile coordinates info = self.info(page, painter.device(), rect) + paperColor = page.paperColor or self.paperColor for t, image in info.images: r = QRect(*t) & info.target # part of the tile that needs to be drawn @@ -365,12 +364,14 @@ def paint(self, page, painter, rect, callback=None): else: if QRegion(info.target).subtracted(region): # paint background, still partly uncovered - painter.fillRect(rect, page.paperColor or self.paperColor) + painter.fillRect(rect, paperColor) # draw lowest quality images first for (r, image, source) in reversed(images): # scale the target rect back to the paint device target = QRectF(r.x() / info.ratio, r.y() / info.ratio, r.width() / info.ratio, r.height() / info.ratio) + # remember render() does not fill the background unless requested + painter.fillRect(target, paperColor) painter.drawImage(target, image, source) def schedule(self, page, key, tiles, callback): From 69ebd8b188d1eccabadfa2a2ffc7e3f26a9b6292 Mon Sep 17 00:00:00 2001 From: Benjamin Johnson <42893476+bmjcode@users.noreply.github.com> Date: Fri, 8 May 2026 08:02:32 -0400 Subject: [PATCH 2/5] Pre-fill the background when rendering the cached image This is an optimization that eliminates the need to fill the background separately before calling drawImage(), which can cause flicker. We do this in job() rather than in render() so that it only affects the cached images used to paint the widget, not images rendered for other purposes such as printing or Frescobaldi's copy-to-image feature. --- qpageview/render.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/qpageview/render.py b/qpageview/render.py index fc53019..cad42bf 100644 --- a/qpageview/render.py +++ b/qpageview/render.py @@ -331,7 +331,6 @@ def paint(self, page, painter, rect, callback=None): region = QRegion() # painted region in tile coordinates info = self.info(page, painter.device(), rect) - paperColor = page.paperColor or self.paperColor for t, image in info.images: r = QRect(*t) & info.target # part of the tile that needs to be drawn @@ -364,14 +363,14 @@ def paint(self, page, painter, rect, callback=None): else: if QRegion(info.target).subtracted(region): # paint background, still partly uncovered - painter.fillRect(rect, paperColor) + painter.fillRect(rect, page.paperColor or self.paperColor) # draw lowest quality images first for (r, image, source) in reversed(images): # scale the target rect back to the paint device target = QRectF(r.x() / info.ratio, r.y() / info.ratio, r.width() / info.ratio, r.height() / info.ratio) - # remember render() does not fill the background unless requested - painter.fillRect(target, paperColor) + # job() already filled the background when rendering the page + # so we don't have to do that separately, which can cause flicker painter.drawImage(target, image, source) def schedule(self, page, key, tiles, callback): @@ -399,7 +398,9 @@ def job(self, page, key, tile): exception = [] def work(): try: - return self.render(page, key, tile) + # filling the background here is an optimization for paint() + return self.render(page, key, tile, + page.paperColor or self.paperColor) except Exception: exception.extend(sys.exc_info()) return QImage() From 5e06723c029a944e9b408d04b751b615425932df Mon Sep 17 00:00:00 2001 From: Benjamin Johnson <42893476+bmjcode@users.noreply.github.com> Date: Fri, 15 May 2026 09:42:00 -0400 Subject: [PATCH 3/5] Fix exporting grayscale images with a transparent background Converting a transparent image as-is usually results in black-on-black, which is less than ideal for readability. Instead, we render the image with a solid background, then use some trickery to manually restore the transparent areas. --- qpageview/export.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/qpageview/export.py b/qpageview/export.py index 3fb1e56..458fa90 100644 --- a/qpageview/export.py +++ b/qpageview/export.py @@ -243,11 +243,23 @@ def export(self): res = self.resolution if self.oversample != 1: res *= self.oversample - i = self.page().image(self._rect, res, res, self.paperColor) + if self.grayscale: + # convertToFormat() does weird things with the alpha channel, + # so we always render grayscale images with a solid background + # and handle transparent areas manually if needed + paperColor = self.paperColor or Qt.GlobalColor.white + else: + paperColor = self.paperColor + i = self.page().image(self._rect, res, res, paperColor) if self.oversample != 1: i = i.scaled(i.size() / self.oversample, transformMode=Qt.TransformationMode.SmoothTransformation) if self.grayscale: i = i.convertToFormat(QImage.Format.Format_Grayscale8) + if self.paperColor is None: + # restore the original transparent background + mask = i.copy() + mask.invertPixels(QImage.InvertMode.InvertRgb) + i.setAlphaChannel(mask) if self.autocrop: i = i.copy(util.autoCropRect(i)) # needed for correct resolution metadata; see issue #44 From d620e6c2ebf420f9195221705f5c434cefc7a3d2 Mon Sep 17 00:00:00 2001 From: Benjamin Johnson <42893476+bmjcode@users.noreply.github.com> Date: Fri, 15 May 2026 14:56:00 -0400 Subject: [PATCH 4/5] Eliminate an unnecessary copy in the grayscale conversion logic This is my favorite optimization yet because it looks so wrong at first glance, but then it makes sense once you've thought more about it. --- qpageview/export.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/qpageview/export.py b/qpageview/export.py index 458fa90..669af5e 100644 --- a/qpageview/export.py +++ b/qpageview/export.py @@ -257,9 +257,10 @@ def export(self): i = i.convertToFormat(QImage.Format.Format_Grayscale8) if self.paperColor is None: # restore the original transparent background - mask = i.copy() - mask.invertPixels(QImage.InvertMode.InvertRgb) - i.setAlphaChannel(mask) + i.setAlphaChannel(i) + # invert the alpha values so white, not black, is transparent + i.invertPixels(QImage.InvertMode.InvertRgba) + i.invertPixels(QImage.InvertMode.InvertRgb) if self.autocrop: i = i.copy(util.autoCropRect(i)) # needed for correct resolution metadata; see issue #44 From bce433968b5e55347dbbf8a2b955775bdd1d3d29 Mon Sep 17 00:00:00 2001 From: Benjamin Johnson <42893476+bmjcode@users.noreply.github.com> Date: Sat, 16 May 2026 08:22:00 -0400 Subject: [PATCH 5/5] Write and explain the transparency restoration more clearly Best not to risk confusing future maintainers -- especially if one of those future maintainers is me, and therefore prone to messing with anything whose workings are not immediately obvious at first glance. --- qpageview/export.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/qpageview/export.py b/qpageview/export.py index 669af5e..83916d5 100644 --- a/qpageview/export.py +++ b/qpageview/export.py @@ -257,9 +257,10 @@ def export(self): i = i.convertToFormat(QImage.Format.Format_Grayscale8) if self.paperColor is None: # restore the original transparent background + # since our alpha map here is just this image's negative, + # we save memory by doing this instead of making a copy() + i.invertPixels(QImage.InvertMode.InvertRgb) i.setAlphaChannel(i) - # invert the alpha values so white, not black, is transparent - i.invertPixels(QImage.InvertMode.InvertRgba) i.invertPixels(QImage.InvertMode.InvertRgb) if self.autocrop: i = i.copy(util.autoCropRect(i))