Search code examples
pythonpdfpyqtpyqt5pymupdf

How to auto resize QVBoxLayout according to its child contents inside a QScrollArea?


Recently, I am trying to use PyQT5 to make a PDF viewer. I adapted the code provided in this post (Image Viewer GUI fails to properly map coordinates for mouse press event). I created a QScrollArea that contains a QVBoxLayout in order to dynamically add multiple QLables into the scroll area. Then I will load the PDF pages as QImage (pixmap) into each individual QLabel. I have successfully loaded and display the PDF pages in the QLabels. However, I encountered a problem. The QLabel in the vertical layout with the PDF page images cannot expand to show the whole page (according to the size of QImage pixmap). So the outcome of using this way will be only showing a small part of page. I cannot scroll down all the page as well. I expected that the PDF pages can be loaded into QLabels and well expanded according to the contents. Then, the Qlabels can vertically grouped in the layout. The layout can auto expand and resize according to the QLable. Finally, I can scroll down the scrollArea to read all the PDF pages. Just like other PDF reader does.

Additionally, how can I capture the mouse position in each QLabel? Ultimately, I want to let the user to click on the specific location on the page to add the text on that position. After I got the coordinates from the QLabel and specific page number, I will pass the information to PyMuPDF to write the text to textBox and export a PDF file.

Here is my code so far:

import fitz
import cv2
import numpy as np
from PyQt5.QtCore import QDir, Qt, QPoint
from PyQt5.QtGui import QImage, QPainter, QPalette, QPixmap, QColor, QFont
from PyQt5.QtWidgets import (QAction, QApplication, QFileDialog, QLabel,
        QMainWindow, QMenu, QMessageBox, QScrollArea, QSizePolicy)
from PyQt5.QtPrintSupport import QPrintDialog, QPrinter


"""
class MyLabel(QLabel):
    def __init__(self):
        super(MyLabel, self).__init__()

    def paintEvent(self, event):
        super(MyLabel, self).paintEvent(event)
        if txt_cache:
            for c in txt_cache:
                print(c)
                pos, txt = c
                painter = QPainter(self)
                painter.setPen(QColor(255, 0, 0))
                painter.drawText(pos, txt)
"""


class ImageViewer(QMainWindow):
    def __init__(self):
        super(ImageViewer, self).__init__()

        self.original_pdf_img_cv = []
        self.qImg_pdf = []
        self.qLabels = []
        self.pageCount = 0

        self.printer = QPrinter()
        self.scaleFactor = 0.0

        self.imageLabel = QLabel()
        self.imageLabel.setBackgroundRole(QPalette.Base)
        self.imageLabel.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
        self.imageLabel.setScaledContents(True)

        self.content_widget = QtWidgets.QWidget()
        self.content_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
        self.scrollArea = QScrollArea(widgetResizable=True)
        self.scrollArea.setBackgroundRole(QPalette.Dark)
        self.scroll_layout = QtWidgets.QVBoxLayout(self.content_widget)
        self.scrollArea.setWidget(self.content_widget)
        self.setCentralWidget(self.scrollArea)

        self.createActions()
        self.createMenus()

        self.setWindowTitle("PDF Viewer")
        self.resize(500, 400)

    def open(self):
        fileName, _ = QFileDialog.getOpenFileName(self, "Open File", QDir.currentPath())
        if fileName:
            doc = fitz.open(fileName)
            self.pageCount = doc.pageCount
            print(self.pageCount)
            for page in doc:
                pix = page.getPixmap()
                im = self.pix2np(pix)
                self.original_pdf_img_cv.append(im)
                self.qImg_pdf.append(self.convert_cv(im))
            pp_num = 1
            for qimg in self.qImg_pdf:
                label = QLabel()
                label.setBackgroundRole(QPalette.Base)
                label.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
                label.setScaledContents(True)

                #self.scrollArea.setWidget(label)
                label.setPixmap(QPixmap.fromImage(qimg))

                self.scroll_layout.addWidget(label)

                label.setObjectName(str(pp_num))
                print(label.objectName())
                self.qLabels.append(label)
                pp_num += 1

            """
            image = QImage(fileName)
            if image.isNull():
                QMessageBox.information(self, "Image Viewer", "Cannot load %s." % fileName)
                return
            """

            #self.imageLabel.setPixmap(QPixmap.fromImage(image))
            self.scaleFactor = 1.0

            self.printAct.setEnabled(True)
            self.fitToWindowAct.setEnabled(True)
            self.updateActions()

            if not self.fitToWindowAct.isChecked():
                for qlabel in self.qLabels:
                    qlabel.adjustSize()
                #self.imageLabel.adjustSize()

    def print_(self):
        dialog = QPrintDialog(self.printer, self)
        if dialog.exec_():
            painter = QPainter(self.printer)
            rect = painter.viewport()
            size = self.imageLabel.pixmap().size()
            size.scale(rect.size(), Qt.KeepAspectRatio)
            painter.setViewport(rect.x(), rect.y(), size.width(), size.height())
            painter.setWindow(self.imageLabel.pixmap().rect())
            painter.drawPixmap(0, 0, self.imageLabel.pixmap())

    def zoomIn(self):
        self.scaleImage(1.25)

    def zoomOut(self):
        self.scaleImage(0.8)

    def normalSize(self):
        for qlabel in self.qLabels:
            qlabel.adjustSize()
        #self.imageLabel.adjustSize()
        self.scaleFactor = 1.0

    def fitToWindow(self):
        fitToWindow = self.fitToWindowAct.isChecked()
        self.scrollArea.setWidgetResizable(fitToWindow)
        if not fitToWindow:
            self.normalSize()

        self.updateActions()

    def about(self):
        QMessageBox.about(self, "About Image Viewer",
                "<p>The <b>Image Viewer</b> example shows how to combine "
                "QLabel and QScrollArea to display an image. QLabel is "
                "typically used for displaying text, but it can also display "
                "an image. QScrollArea provides a scrolling view around "
                "another widget. If the child widget exceeds the size of the "
                "frame, QScrollArea automatically provides scroll bars.</p>"
                "<p>The example demonstrates how QLabel's ability to scale "
                "its contents (QLabel.scaledContents), and QScrollArea's "
                "ability to automatically resize its contents "
                "(QScrollArea.widgetResizable), can be used to implement "
                "zooming and scaling features.</p>"
                "<p>In addition the example shows how to use QPainter to "
                "print an image.</p>")

    def createActions(self):
        self.openAct = QAction("&Open...", self, shortcut="Ctrl+O",
                triggered=self.open)

        self.printAct = QAction("&Print...", self, shortcut="Ctrl+P",
                enabled=False, triggered=self.print_)

        self.exitAct = QAction("E&xit", self, shortcut="Ctrl+Q",
                triggered=self.close)

        self.zoomInAct = QAction("Zoom &In (25%)", self, shortcut="Ctrl++",
                enabled=False, triggered=self.zoomIn)

        self.zoomOutAct = QAction("Zoom &Out (25%)", self, shortcut="Ctrl+-",
                enabled=False, triggered=self.zoomOut)

        self.normalSizeAct = QAction("&Normal Size", self, shortcut="Ctrl+S",
                enabled=False, triggered=self.normalSize)

        self.fitToWindowAct = QAction("&Fit to Window", self, enabled=False,
                checkable=True, shortcut="Ctrl+F", triggered=self.fitToWindow)

        self.aboutAct = QAction("&About", self, triggered=self.about)

        self.aboutQtAct = QAction("About &Qt", self,
                triggered=QApplication.instance().aboutQt)

    def createMenus(self):
        self.fileMenu = QMenu("&File", self)
        self.fileMenu.addAction(self.openAct)
        self.fileMenu.addAction(self.printAct)
        self.fileMenu.addSeparator()
        self.fileMenu.addAction(self.exitAct)

        self.viewMenu = QMenu("&View", self)
        self.viewMenu.addAction(self.zoomInAct)
        self.viewMenu.addAction(self.zoomOutAct)
        self.viewMenu.addAction(self.normalSizeAct)
        self.viewMenu.addSeparator()
        self.viewMenu.addAction(self.fitToWindowAct)

        self.helpMenu = QMenu("&Help", self)
        self.helpMenu.addAction(self.aboutAct)
        self.helpMenu.addAction(self.aboutQtAct)

        self.menuBar().addMenu(self.fileMenu)
        self.menuBar().addMenu(self.viewMenu)
        self.menuBar().addMenu(self.helpMenu)

    def updateActions(self):
        self.zoomInAct.setEnabled(not self.fitToWindowAct.isChecked())
        self.zoomOutAct.setEnabled(not self.fitToWindowAct.isChecked())
        self.normalSizeAct.setEnabled(not self.fitToWindowAct.isChecked())

    def scaleImage(self, factor):
        self.scaleFactor *= factor
        for qlabel in self.qLabels:
            qlabel.resize(self.scaleFactor * qlabel.pixmap().size())
        #self.imageLabel.resize(self.scaleFactor * self.imageLabel.pixmap().size())

        self.adjustScrollBar(self.scrollArea.horizontalScrollBar(), factor)
        self.adjustScrollBar(self.scrollArea.verticalScrollBar(), factor)

        self.zoomInAct.setEnabled(self.scaleFactor < 3.0)
        self.zoomOutAct.setEnabled(self.scaleFactor > 0.333)

    def adjustScrollBar(self, scrollBar, factor):
        scrollBar.setValue(int(factor * scrollBar.value()
                                + ((factor - 1) * scrollBar.pageStep()/2)))

    def mousePressEvent(self, event):
        self.originQPoint = self.imageLabel.mapFromGlobal(self.mapToGlobal(event.pos()))
        self.currentQRubberBand = QtWidgets.QRubberBand(QtWidgets.QRubberBand.Rectangle, self.imageLabel)
        self.currentQRubberBand.setGeometry(QtCore.QRect(self.originQPoint, QtCore.QSize()))
        self.currentQRubberBand.show()

    def mouseMoveEvent(self, event):
        p = self.imageLabel.mapFromGlobal(self.mapToGlobal(event.pos()))
        QtWidgets.QToolTip.showText(event.pos(), "X: {} Y: {}".format(p.x(), p.y()), self)
        if self.currentQRubberBand.isVisible() and self.imageLabel.pixmap() is not None:
            self.currentQRubberBand.setGeometry(
                QtCore.QRect(self.originQPoint, p).normalized() & self.imageLabel.rect())

    def mouseReleaseEvent(self, event):
        self.currentQRubberBand.hide()
        currentQRect = self.currentQRubberBand.geometry()
        self.currentQRubberBand.deleteLater()
        if self.imageLabel.pixmap() is not None:
            tr = QtGui.QTransform()
            if self.fitToWindowAct.isChecked():
                tr.scale(self.imageLabel.pixmap().width() / self.scrollArea.width(),
                         self.imageLabel.pixmap().height() / self.scrollArea.height())
            else:
                tr.scale(1 / self.scaleFactor, 1 / self.scaleFactor)
            r = tr.mapRect(currentQRect)



            txt_cache.append((QPoint(r.x(), r.y()), 'Test!!!!!!'))
            self.imageLabel.update()

            cropQPixmap = self.imageLabel.pixmap().copy(r)
            cropQPixmap.save('output.png')

    def pix2np(self, pix):
        im = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.h, pix.w, pix.n)
        im = np.ascontiguousarray(im[..., [2, 1, 0]])  # rgb to bgr
        return im

    def convert_cv(self, cvImg):
        height, width, channel = cvImg.shape
        bytesPerLine = 3 * width
        qImg = QImage(cvImg.data, width, height, bytesPerLine, QImage.Format_RGB888)
        return qImg


if __name__ == '__main__':
    import sys
    from PyQt5 import QtGui, QtCore, QtWidgets

    app = QApplication(sys.argv)
    imageViewer = ImageViewer()
    imageViewer.show()
    sys.exit(app.exec_())

Solution

  • Do not use QScrollArea + QLabel as it complicates the task a lot, instead it is better to use QGraphicsView, QGraphicsScene and items. Based on my previous answer and implemented the following logic, I have also created the clicked signal that carries the information of the pressed page and the position of the click on the page:

    from PyQt5 import QtCore, QtGui, QtWidgets
    
    import fitz
    
    
    class PageItem(QtWidgets.QGraphicsPixmapItem):
        def __init__(self, page, pixmap):
            super().__init__(pixmap)
            self._page = page
    
        @property
        def page(self):
            return self._page
    
    
    class PdfViewer(QtWidgets.QGraphicsView):
        clicked = QtCore.pyqtSignal(int, QtCore.QPoint)
    
        def __init__(self, parent=None):
            super().__init__(parent)
            self.setBackgroundRole(QtGui.QPalette.Dark)
            self.setAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignTop)
            self.setScene(QtWidgets.QGraphicsScene(self))
            self.setDragMode(QtWidgets.QGraphicsView.ScrollHandDrag)
            self._filename = ""
            self._page_count = 0
    
        def load_pdf(self, filename):
            self.scene().clear()
            self._filename = filename
            try:
                doc = fitz.open(filename)
            except RuntimeError:
                return False
            self._page_count = doc.pageCount
            spaces = 10
            tl = spaces
            width = 0
            for i, page in enumerate(doc):
                pix = page.getPixmap()
                fmt = (
                    QtGui.QImage.Format_RGBA8888
                    if pix.alpha
                    else QtGui.QImage.Format_RGB888
                )
                qtimg = QtGui.QImage(pix.samples, pix.width, pix.height, pix.stride, fmt)
                it = PageItem(i, QtGui.QPixmap(qtimg))
                self.scene().addItem(it)
                it.setPos(QtCore.QPointF(0, tl))
                tl += qtimg.height() + spaces
                width = max(width, qtimg.width())
            self.setSceneRect(QtCore.QRectF(0, 0, width, tl))
            return True
    
        @property
        def page_count(self):
            return self._page_count
    
        def zoomIn(self):
            self.scale(1.25, 1.25)
    
        def zoomOut(self):
            self.scale(0.8, 0.8)
    
        def resetZoom(self):
            self.resetTransform()
    
        def fitToWindow(self):
            self.fitInView(self.sceneRect(), QtCore.Qt.KeepAspectRatio)
    
        def mousePressEvent(self, event):
            vp = event.pos()
            sp = self.mapToScene(vp)
    
            for it in self.items(vp):
                if isinstance(it, PageItem):
                    self.clicked.emit(it.page, it.mapFromScene(sp).toPoint())
            super().mousePressEvent(event)
    
    
    class MainWindow(QtWidgets.QMainWindow):
        def __init__(self, parent=None):
            super().__init__(parent)
    
            self.view = PdfViewer()
            self.setCentralWidget(self.view)
    
            self.createActions()
            self.createMenus()
    
            self.resize(640, 480)
    
            self.view.clicked.connect(self.on_clicked)
    
        @QtCore.pyqtSlot(int, QtCore.QPoint)
        def on_clicked(self, page, pos):
            print(page, pos)
    
        def open(self):
            fileName, _ = QtWidgets.QFileDialog.getOpenFileName(
                self, "Open File", QtCore.QDir.currentPath()
            )
            if fileName:
                is_loaded = self.view.load_pdf(fileName)
                self.printAct.setEnabled(is_loaded)
                self.fitToWindowAct.setEnabled(is_loaded)
                self.updateActions()
    
        def print_(self):
            dialog = QtPrintSupport.QPrintDialog(self.printer, self)
            if dialog.exec_():
                pass
    
        def fitToWindow(self):
            if self.fitToWindowAct.isChecked():
                self.view.fitToWindow()
            else:
                self.view.resetZoom()
            self.updateActions()
    
        def about(self):
            QtWidgets.QMessageBox.about(
                self,
                "About Image Viewer",
                "<p>The <b>Image Viewer</b> example shows how to combine "
                "QLabel and QScrollArea to display an image. QLabel is "
                "typically used for displaying text, but it can also display "
                "an image. QScrollArea provides a scrolling view around "
                "another widget. If the child widget exceeds the size of the "
                "frame, QScrollArea automatically provides scroll bars.</p>"
                "<p>The example demonstrates how QLabel's ability to scale "
                "its contents (QLabel.scaledContents), and QScrollArea's "
                "ability to automatically resize its contents "
                "(QScrollArea.widgetResizable), can be used to implement "
                "zooming and scaling features.</p>"
                "<p>In addition the example shows how to use QPainter to "
                "print an image.</p>",
            )
    
        def createActions(self):
            self.openAct = QtWidgets.QAction(
                "&Open...", self, shortcut="Ctrl+O", triggered=self.open
            )
            self.printAct = QtWidgets.QAction(
                "&Print...", self, shortcut="Ctrl+P", enabled=False, triggered=self.print_
            )
            self.exitAct = QtWidgets.QAction(
                "E&xit", self, shortcut="Ctrl+Q", triggered=self.close
            )
            self.zoomInAct = QtWidgets.QAction(
                "Zoom &In (25%)",
                self,
                shortcut="Ctrl++",
                enabled=False,
                triggered=self.view.zoomIn,
            )
            self.zoomOutAct = QtWidgets.QAction(
                "Zoom &Out (25%)",
                self,
                shortcut="Ctrl+-",
                enabled=False,
                triggered=self.view.zoomOut,
            )
            self.normalSizeAct = QtWidgets.QAction(
                "&Normal Size",
                self,
                shortcut="Ctrl+S",
                enabled=False,
                triggered=self.view.resetZoom,
            )
            self.fitToWindowAct = QtWidgets.QAction(
                "&Fit to Window",
                self,
                enabled=False,
                checkable=True,
                shortcut="Ctrl+F",
                triggered=self.fitToWindow,
            )
            self.aboutAct = QtWidgets.QAction("&About", self, triggered=self.about)
            self.aboutQtAct = QtWidgets.QAction(
                "About &Qt", self, triggered=QtWidgets.qApp.aboutQt
            )
    
        def createMenus(self):
            self.fileMenu = QtWidgets.QMenu("&File", self)
            self.fileMenu.addAction(self.openAct)
            self.fileMenu.addAction(self.printAct)
            self.fileMenu.addSeparator()
            self.fileMenu.addAction(self.exitAct)
    
            self.viewMenu = QtWidgets.QMenu("&View", self)
            self.viewMenu.addAction(self.zoomInAct)
            self.viewMenu.addAction(self.zoomOutAct)
            self.viewMenu.addAction(self.normalSizeAct)
            self.viewMenu.addSeparator()
            self.viewMenu.addAction(self.fitToWindowAct)
    
            self.helpMenu = QtWidgets.QMenu("&Help", self)
            self.helpMenu.addAction(self.aboutAct)
            self.helpMenu.addAction(self.aboutQtAct)
    
            self.menuBar().addMenu(self.fileMenu)
            self.menuBar().addMenu(self.viewMenu)
            self.menuBar().addMenu(self.helpMenu)
    
        def updateActions(self):
            self.zoomInAct.setEnabled(not self.fitToWindowAct.isChecked())
            self.zoomOutAct.setEnabled(not self.fitToWindowAct.isChecked())
            self.normalSizeAct.setEnabled(not self.fitToWindowAct.isChecked())
    
    
    if __name__ == "__main__":
        import sys
    
        app = QtWidgets.QApplication(sys.argv)
        w = MainWindow()
        w.show()
        sys.exit(app.exec_())
    

    enter image description here