Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 27 additions & 14 deletions qpageview/pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import platform

from PyQt6.QtCore import Qt, QByteArray, QModelIndex, QRect, QRectF, QSize, QUrl
from PyQt6.QtGui import QPainter, QTransform
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from PyQt6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions

# Check for PDF link support (added in Qt 6.6)
Expand All @@ -47,6 +48,7 @@
from . import link
from . import locking
from . import render
from . import util


class Link(link.Link):
Expand Down Expand Up @@ -218,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.

Expand All @@ -230,12 +230,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
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For clarification: the default implementation in render.py creates a new QImage, fills its background if needed, then calls draw() to add the content. But QtPDF already returns the content as a QImage (on new line 302), so there's no need to create a second QImage most of the time. This can save a considerable amount of memory.

We do need two images when performing manipulations like rotating the page, which is implemented by using QPainter to draw the content rotated on top of the background image.

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)
Expand All @@ -248,7 +268,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,
Expand All @@ -259,15 +280,7 @@ def draw(self, page, painter, key, tile, paperColor=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
Expand All @@ -283,7 +296,7 @@ def draw(self, page, painter, key, tile, paperColor=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)
Expand All @@ -298,7 +311,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):
Expand Down
25 changes: 25 additions & 0 deletions qpageview/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,31 @@ def autoCropRect(image):
return QRegion(QBitmap.fromImage(mask)).boundingRect()


def oversampleFactor(key, pageSize):
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought I was going to have to duplicate a lot more code when I refactored the rendering logic. That turned out not to be the case, but separating this out seemed like a good idea anyway for code clarity and reuse.

"""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
Expand Down
Loading