From 2fe49b0601c6928d786ebdbb9959bc140c1ffc80 Mon Sep 17 00:00:00 2001 From: Benjamin Johnson <42893476+bmjcode@users.noreply.github.com> Date: Sun, 17 May 2026 19:37:01 -0400 Subject: [PATCH 1/2] Reuse the QImage returned by QPdfDocument.render() when possible Because QPdfDocument.render() already returns a QImage, we can save memory in PdfRenderer.render() by reusing that image rather than creating a second QImage to draw it onto. This can reduce peak memory usage by hundreds of MB when rendering at higher zoom levels. --- qpageview/pdf.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/qpageview/pdf.py b/qpageview/pdf.py index d94f737..db7eb9f 100644 --- a/qpageview/pdf.py +++ b/qpageview/pdf.py @@ -28,6 +28,7 @@ import platform from PyQt6.QtCore import Qt, QByteArray, QModelIndex, QRect, QRectF, QSize, QUrl +from PyQt6.QtGui import QPainter, QTransform from PyQt6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions # Check for PDF link support (added in Qt 6.6) @@ -230,12 +231,32 @@ def tiles(self, width, height): """ yield render.Tile(0, 0, width, height) + def render(self, page, key, tile, paperColor=None): + """Generate a QImage for tile of the Page.""" + if key.rotation: + # this is only doable by drawing on a second QImage + return super().render(page, key, tile, paperColor) + else: + # reuse the QImage returned by QPdfDocument.render() to save memory + image = self._render(page, key, tile) + if paperColor: + painter = QPainter(image) + painter.setCompositionMode( + QPainter.CompositionMode.CompositionMode_DestinationOver) + painter.fillRect(image.rect(), paperColor) + return image + def draw(self, page, painter, key, tile, paperColor=None): """Draw a tile on the painter. The painter is already at the right position and rotation. """ + matrix = painter.deviceTransform() + painter.drawImage(0, 0, self._render(page, key, tile, matrix)) + + def _render(self, page, key, tile, matrix=None): + """The actual rendering logic shared by render() and draw().""" pageSize = page.pageSize() # in points # key and tile coordinates scale with device resolution and zoom level source = QRectF(0, 0, key.width, key.height) @@ -248,7 +269,8 @@ def draw(self, page, painter, key, tile, paperColor=None): num = page.pageNumber # We use this to scale from key/tile to device coordinates - matrix = painter.deviceTransform() + if matrix is None: + matrix = QTransform() # When we are displaying an image on screen, our painter coordinates # are "actual size" and scaling is our responsibility. When printing, @@ -298,7 +320,7 @@ def draw(self, page, painter, key, tile, paperColor=None): Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation) - painter.drawImage(target, image, QRectF(image.rect())) + return image def load(source): From 43b928dbc9947639e5d67af26db6e789aee69944 Mon Sep 17 00:00:00 2001 From: Benjamin Johnson <42893476+bmjcode@users.noreply.github.com> Date: Fri, 15 May 2026 20:20:30 -0400 Subject: [PATCH 2/2] Move oversampling calculations to util.py This makes the rendering code easier to read, and allows for future reuse in other backends if needed. While I was at it, I also documented why 96 DPI was chosen as the threshold. (It was inherited from the old Poppler code, where no explanation was given, but experimenting with different values led me to conclude it was based on screen density as noted here.) --- qpageview/pdf.py | 15 +++------------ qpageview/util.py | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/qpageview/pdf.py b/qpageview/pdf.py index db7eb9f..7d0c19d 100644 --- a/qpageview/pdf.py +++ b/qpageview/pdf.py @@ -48,6 +48,7 @@ from . import link from . import locking from . import render +from . import util class Link(link.Link): @@ -219,8 +220,6 @@ def document(self): class PdfRenderer(render.AbstractRenderer): - oversampleThreshold = 96 # DPI of a standard PC screen - def tiles(self, width, height): """Yield four-tuples Tile(x, y, w, h) describing the tiles to render. @@ -281,15 +280,7 @@ def _render(self, page, key, tile, matrix=None): # Oversampling produces more readable output at lower resolutions # when painting at "actual size" - if actualSize: - # If our effective pixel density at this zoom level is below - # our threshold, render at double size then downscale - xres = 72.0 * key.width / pageSize.width() - yres = 72.0 * key.height / pageSize.height() - xMultiplier = 2 if xres < self.oversampleThreshold else 1 - yMultiplier = 2 if yres < self.oversampleThreshold else 1 - else: - xMultiplier = yMultiplier = 1 + multiplier = util.oversampleFactor(key, pageSize) if actualSize else 1 # Set rendering options RenderFlag = QPdfDocumentRenderOptions.RenderFlag @@ -305,7 +296,7 @@ def _render(self, page, key, tile, matrix=None): # Render the image at the output device's resolution (or double # that if we are oversampling) - s = matrix.scale(xMultiplier, yMultiplier).mapRect(source) + s = matrix.scale(multiplier, multiplier).mapRect(source) renderSize = QSize(int(s.width()), int(s.height())) with locking.lock(doc): image = doc.render(num, renderSize, renderOptions) diff --git a/qpageview/util.py b/qpageview/util.py index 26ecd21..c62d239 100644 --- a/qpageview/util.py +++ b/qpageview/util.py @@ -304,6 +304,31 @@ def autoCropRect(image): return QRegion(QBitmap.fromImage(mask)).boundingRect() +def oversampleFactor(key, pageSize): + """Return the optimal scale factor for oversampling. + + Oversampling (rendering the image at higher resolution, then + downscaling) can help with readability at lower pixel densities. + + The pageSize argument specifies the page dimensions in points as + returned by page.pageSize(). + + If this returns 1, render the image directly at your desired size. + Otherwise, multiply the page dimensions by the returned value before + rendering, then downscale to your desired size. + + """ + # the choice of threshold here is arbitrary, but was found to produce + # subjectively pleasing results across a variety of devices in testing + threshold = 96 # DPI of a standard PC screen + # calculate the effective pixel density at the current zoom level + # (key dimensions scale with zoom level, while pageSize is constant) + xres = 72.0 * key.width / pageSize.width() + yres = 72.0 * key.height / pageSize.height() + # recommend oversampling if we are below our threshold + return 2 if xres < threshold or yres < threshold else 1 + + def tempdir(): """Return a temporary directory that is erased on app quit.""" import tempfile