I'm working with displaying pdf documents in QPdfView and I need to paint an overlay over some areas in few pages.
I've managed to paint the overlay but I need some help adjusting how it's position is adjusted when scrolling.
Here's the code:
from PySide6.QtCore import QRectF
from PySide6.QtGui import QPainter, QColor
from PySide6.QtPdfWidgets import QPdfView
from PySide6.QtCore import Qt
from PySide6.QtPdf import QPdfDocument
class CustomPdfView(QPdfView):
def __init__(self, parent=None):
super().__init__(parent)
# Placeholder areas
self.highlights: dict[int, list[QRectF]] = {
i : [QRectF(150.0, 150.0, 300.0, 300.0)] for i in range(200)
}
def paintEvent(self, event):
super().paintEvent(event)
if self.document().status() == QPdfDocument.Status.Ready:
current_page_number = self.pageNavigator().currentPage()
if current_page_number in self.highlights.keys():
painter = QPainter(self.viewport())
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setBrush(QColor(255, 255, 0, 100))
painter.setPen(Qt.PenStyle.NoPen)
total_pages = self.document().pageCount()
current_page_size = self.document().pagePointSize(current_page_number)
viewport_size = self.viewport().size()
scroll_bar = self.verticalScrollBar()
max_scroll = scroll_bar.maximum()
scroll_per_page = max_scroll / total_pages
scroll_calculated = scroll_per_page * current_page_number
for rect in self.highlights[current_page_number]:
h_diff = scroll_bar.value() - scroll_calculated
scaled_rect = QRectF(
(rect.x() / current_page_size.width()) * viewport_size.width(),
rect.y() - h_diff,
(rect.width() / current_page_size.width()) * viewport_size.width(),
rect.height()
)
painter.drawRect(scaled_rect)
painter.end()
In best case I'd like to know how many pixels of the current page have been rendered in the viewport, so the h_diff becomes page_height - visible pixels
Your approach has multiple issues, but the following are the most important ones:
currentPage
of the navigator, but in MultiPage
mode more than one page can be visible at once, with the "current" only being the page which intersects an imaginary line at the upper half of the view (currently, a 2-pixel line at 0.4 of the viewport height);FitToWidth
/FitInView
modes;The QtPdf module is relatively new and still just basically implemented right now, therefore it unfortunately lacks lots of useful aspects (such as knowing the visual bounding rect of each page) that may be needed for further implementation, therefore these aspects have to be manually taken care of.
Luckily, we can study the official sources, and we can see that QPdfView internally creates a private "document layout" based on the current settings, considering the viewport size, the actual scale, the margins and page spacing. It fundamentally is a basic mapping that for each page has a specific QRect and scale based on the visual position.
By accessing that layout, we could then properly map each "highlight" based on the actual coordinates of each page. But since that object is private, we need to do it again on our own.
First, we need to create a function that computes all the document layout, and ensure that it's always updated whenever any of the following aspects has changed:
Ready
);setDocumentMargins()
);SinglePage
mode;resizeEvent()
);The layout uses a mapping in the form {page: (pageRect, scale)}
, and is computed by cycling through all visible pages: for each page it gets the original size, scales it (considering the zoom mode, screen DPI and possible custom zoom factor, or possibly accounting for horizontal margins), then creates the values for the dict mapping above, with a QRect using the new size and only vertically positioned considering the previous pages, and the scale based on the ratio between the new size and the original. In this pass, a minimum width is also accounted for, considering margins and actual page width.
After that, the pages are cycled once again in order to update each QRect x
, horizontally centering it based on the viewport size and the minimum width computed before; this second pass is necessary because PDF documents may contain pages with different sizes, therefore we can only center each page after all page widths have been considered.
Then we obviously need to paint the "highlights" in the paintEvent()
, which iterates through all the pages in our layout: when a page is currently in the viewport and it contains highlights, then maps the highlight rect based on the scale of that page: the x and y are translated to the page origin, then the highlight coordinates and sizes are multiplied by the page scale factor.
For obvious reasons, I only tried to address the logical space based on the actual scaling (eg: "region based" highlighting): if you want to display "notes" that only account for the scaling for their position but not for their size (like text notes), further implementation may be required, possibly using a data class that defines other aspects other than the possible geometry, such as background color, or whether scaling should be considered or not.
The following is a comprehensive example that uses a updateDocumentLayout
function based on the sources linked above (calculateDocumentLayout()
):
from collections import OrderedDict
from PySide6.QtCore import *
from PySide6.QtGui import *
from PySide6.QtWidgets import *
from PySide6.QtPdf import QPdfDocument
from PySide6.QtPdfWidgets import QPdfView
class CustomPdfView(QPdfView):
def __init__(self, parent=None):
super().__init__(parent)
self._highlights = {}
self.documentLayout = OrderedDict()
self.documentChanged.connect(self.updateDocumentLayout)
self.documentMarginsChanged.connect(self.updateDocumentLayout)
self.zoomFactorChanged.connect(self.updateDocumentLayout)
self.zoomModeChanged.connect(self.updateDocumentLayout)
self.pageSpacingChanged.connect(self.updateDocumentLayout)
self.pageNavigator().currentPageChanged.connect(
self.currentPageChanged)
def highlights(self):
return self._highlights
def setHighlights(self, highlights):
self._highlights.clear()
self._highlights.update(highlights)
def currentPageChanged(self, currentPage):
if self.pageMode() == self.PageMode.SinglePage:
self.updateDocumentLayout()
self.viewport().update()
def documentStatusChanged(self, status):
if status == QPdfDocument.Status.Ready:
self.updateDocumentLayout()
def updateDocumentLayout(self):
self.documentLayout.clear()
doc = self.document()
if doc is None:
return
elif doc.status() != QPdfDocument.Status.Ready:
# the document may have just been changed, so we need to
# connect its statusChanged signal, but only once
try:
doc.statusChanged.connect(
self.documentStatusChanged,
Qt.ConnectionType.UniqueConnection
)
except TypeError:
# older PyQt/PySide versions may raise a TypeError whenever a
# UniqueConnection fails
pass
# if a new document has been set but the previous one still
# exists and has a status change, it may still trigger this
# function, therefore we will try to disconnect it
sender = self.sender()
if doc != sender and isinstance(sender, QPdfDocument):
try:
sender.statusChanged.disconnect(
self.documentStatusChanged)
except TypeError:
pass
return
screenRes = QApplication.primaryScreen().logicalDotsPerInch() / 72
viewSize = self.viewport().size()
viewWidth = viewSize.width()
spacing = self.pageSpacing()
margins = self.documentMargins()
left = margins.left()
right = margins.right()
pageY = margins.top()
totalWidth = 0
if self.pageMode() == self.PageMode.SinglePage:
pageRange = range(
self.pageNavigator.currentPage(),
self.pageNavigator.currentPage() + 1
)
else:
pageRange = range(
0, doc.pageCount()
)
zoomMode = self.zoomMode()
zoomFactor = self.zoomFactor()
sizeFunc = doc.pagePointSize
for page in pageRange:
origSize = sizeFunc(page)
pageScale = zoomFactor
if zoomMode == self.ZoomMode.Custom:
pageSize = QSizeF(origSize * screenRes * zoomFactor).toSize()
elif zoomMode == self.ZoomMode.FitToWidth:
pageSize = QSizeF(origSize * screenRes).toSize()
pageScale = (viewWidth - left - right) / pageSize.width()
pageSize *= pageScale
else:
vsize = QSize(viewSize - QSize(left + right, spacing))
pageSize = QSizeF(origSize * screenRes).toSize()
scaledSize = pageSize.scaled(
vsize, Qt.AspectRatioMode.KeepAspectRatio)
pageScale = scaledSize.width() / pageSize.width()
pageSize = scaledSize
if pageSize.width() > totalWidth:
totalWidth = pageSize.width()
self.documentLayout[page] = (
QRect(QPoint(0, pageY), pageSize),
pageScale
)
pageY += pageSize.height() + spacing
totalWidth += left + right
# horizontally center each page based on the totalWidth
for pageRect, _ in self.documentLayout.values():
pageRect.moveLeft(
(max(totalWidth, viewWidth) - pageRect.width()) // 2)
def resizeEvent(self, event):
super().resizeEvent(event)
self.updateDocumentLayout()
def paintEvent(self, event):
super().paintEvent(event)
if not self.documentLayout:
return
viewRect = QRect(
self.horizontalScrollBar().value(),
self.verticalScrollBar().value(),
self.viewport().width(),
self.viewport().height()
)
bottom = viewRect.bottom()
qp = QPainter(self.viewport())
qp.setBrush(QColor(255, 255, 0, 100))
qp.setPen(Qt.PenStyle.NoPen)
qp.translate(-viewRect.x(), -viewRect.y())
for page, (pageRect, scale) in self.documentLayout.items():
if not pageRect.intersects(viewRect):
if pageRect.y() > bottom:
# the page is beyond the viewport bottom, there is no
# need to go further
return
continue
if page in self._highlights:
for rect in self._highlights[page]:
qp.drawRect(
pageRect.x() + rect.x() * scale,
pageRect.y() + rect.y() * scale,
rect.width() * scale,
rect.height() * scale
)
if __name__ == '__main__':
import sys
app = QApplication(sys.argv)
view = CustomPdfView()
view.setPageMode(view.PageMode.MultiPage)
view.setPageSpacing(50)
doc = QPdfDocument()
view.setDocument(doc)
doc.load('<path to pdf file>')
view.setHighlights({
i:[QRectF(150, 150, 300, 300)] for i in range(doc.pageCount())
})
view.show()
sys.exit(app.exec())
As said, the QtPdf module is in relatively early stages (even though the basic classes can be tracked back to 2018 at least) and is certainly not a priority in Qt development: I submitted a few related reports many months ago, and they haven't been addressed yet even if they may be considered quite relevant.
This means that anything the above code does is based upon implementations that may likely (and hopefully) change in future Qt versions, possibly breaking the results of my implementations.
I made a related feature request on the official Qt report system, proposing to make the internal "document layout" publicly available.