qtpyqtpyside6

Scrollable overlay in QPdfView


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


Solution

  • Your approach has multiple issues, but the following are the most important ones:

    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:

    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())
    

    Final notes

    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.