简体   繁体   中英

Overwrite paintEvent in QTextEdit to draw rectangle around word

I use QTextEdit from PyQt5 and I want to put a frame around selected words. As suggested by musicamante I tried to overwrite the paintEvent . The coordinates for the rectangle I want to extract from the cursor position. So, I put the cursor of my TextEditor at the beginning and at the end of the text and then tried to get the global coordinates from each the start and the end. With these coordinates a rectangle should be drawn. But when I run the code, the output coordinates are wrong and only a dash or a very small rectangle is drawn.

    import sys
from PyQt5.QtGui import QPainter, QPen
from PyQt5.QtWidgets import QTextEdit, QWidget, QApplication, QVBoxLayout
from PyQt5.QtCore import Qt


class TextEditor(QTextEdit):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.coordinates = []

    def paintEvent(self, event):
        painter = QPainter(self.viewport())
        painter.setPen(QPen(Qt.black, 4, Qt.SolidLine))
        if self.coordinates:
            for coordinate in self.coordinates:
                painter.drawRect(coordinate[0].x(), coordinate[0].y(), coordinate[1].x() - coordinate[0].x(), 10)
        super(TextEditor, self).paintEvent(event)

class Window(QWidget):
    def __init__(self):
        super(Window, self).__init__()
        edit = TextEditor(self)
        layout = QVBoxLayout(self)
        layout.addWidget(edit)
        self.boxes = []
        text = "Hello World"
        edit.setText(text)
        word = "World"
        start = text.find(word)
        end = start + len(word)
        edit.coordinates.append(self.emit_coorindate(start, end, edit))
        edit.viewport().update()

    def emit_coorindate(self, start, end, edit):
        cursor = edit.textCursor()
        cursor.setPosition(start)
        x = edit.cursorRect().topLeft()
        cursor.setPosition(end)
        y = edit.cursorRect().bottomRight()
        return (x, y)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = Window()
    window.setGeometry(800, 100, 1000, 1000)
    window.show()
    sys.exit(app.exec_())

Note: I'm basing this answer on an earlier version of the question which used QTextCharFormat to set the background of a text fragment. I added further support as I found myself looking for a valid solution for similar issue, but didn't have the opportunity to do it properly until now.

Premise

The laying out of text is quite complex, especially when dealing with rich text , including simple aspects like multiple lines.

While the Qt rich text engine allows setting the background of text, there is no support to draw a border around text.

For very basic cases, the answer provided for Getting the bounding box of QTextEdit selection will suffice, but it has some flaws.

First of all, if the text wraps on a new line (ie a very long selection), the complete bounding rect will be shown, which will include text that is not part of the selection. As shown in the above answer, you can see the result:

错误的矩形

Then, the proposed solution is only valid for static text: whenever the text is updated, the selection is not updated along with it. While it's possible to update the internal selection when the text is changed programmatically, user editing would make it much more complex and prone to errors or unexpected behavior.

Solution: using QTextCharFormat

While the following approach is clearly much more complex, it's more effective, and allows further customization (like setting the border color and width). It works by using existing features of the Qt rich text engine, setting a custom format property that will always be preserved, no matter if the text is changed. Once the format is set for the selected text fragment, what's left is implementing the part that will dynamically compute the rectangles of the borders and, obviously, their painting.

In order to achieve this, it is necessary to cycle through the whole document layout and get the exact coordinates of each text fragment that needs "highlighting". This is done by:

  1. iterating through all text blocks of the document;
  2. iterating through all text fragments of each block;
  3. get the possible lines that are part of that fragment (since word wrapping might force even single words to appear on more than one line);
  4. find the extents of the characters belonging to the fragments in those lines, which will be used as coordinates for the borders;

To provide such feature, I used a custom QTextFormat property with a simple QPen instance that will be used to draw the borders, and that property is set for a specific QTextCharFormat set for the wanted text fragment.

Then, a QTimer connected to the relevant signals will compute the geometry of the borders (if any) and eventually request a repaint: this is necessary because any change in the document layout (text contents, but also editor/document size) can potentially change the geometry of the borders.

The paintEvent() will then paint those borders whenever they are included in the event rectangle (for optimization reasons, QTextEdit only redraws portion of the text that actually needs repainting).

Here is the result of the following code:

示例代码的屏幕截图

And here is what happens when breaking the line in the "selection":

两条线边框的屏幕截图

from PyQt5 import QtCore, QtGui, QtWidgets

BorderProperty = QtGui.QTextFormat.UserProperty + 100

class BorderTextEdit(QtWidgets.QTextEdit):
    _joinBorders = True
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self._borderData = []
        self._updateBorders()

        self._updateBordersTimer = QtCore.QTimer(self, singleShot=True, 
            interval=0, timeout=self._updateBorders)

        self.document().documentLayout().updateBlock.connect(
            self.scheduleUpdateBorders)
        self.document().contentsChange.connect(
            self.scheduleUpdateBorders)

    def scheduleUpdateBorders(self):
        self._updateBordersTimer.start()

    @QtCore.pyqtProperty(bool)
    def joinBorders(self):
        '''
        When the *same* border format spans more than one line (due to line
        wrap/break) some rectangles can be contiguous.
        If this property is False, those borders will always be shown as
        separate rectangles.
        If this property is True, try to merge contiguous rectangles to
        create unique regions.
        '''
        return self._joinBorders

    @joinBorders.setter
    def joinBorders(self, join):
        if self._joinBorders != join:
            self._joinBorders = join
            self._updateBorders()

    @QtCore.pyqtSlot(bool)
    def setBordersJoined(self, join):
        self.joinBorders = join

    def _updateBorders(self):
        if not self.toPlainText():
            if self._borderData:
                self._borderData.clear()
                self.viewport().update()
            return
        doc = self.document()
        block = doc.begin()
        end = doc.end()
        docLayout = doc.documentLayout()

        borderRects = []
        lastBorderRects = []
        lastBorder = None
        while block != end:
            if not block.text():
                block = block.next()
                continue

            blockRect = docLayout.blockBoundingRect(block)
            blockX = blockRect.x()
            blockY = blockRect.y()

            it = block.begin()
            while not it.atEnd():
                fragment = it.fragment()
                fmt = fragment.charFormat()
                border = fmt.property(BorderProperty)
                if lastBorder != border and lastBorderRects:
                    borderRects.append((lastBorderRects, lastBorder))
                    lastBorderRects = []

                if isinstance(border, QtGui.QPen):
                    lastBorder = border
                    blockLayout = block.layout()
                    fragPos = fragment.position() - block.position()
                    fragEnd = fragPos + fragment.length()
                    while True:
                        line = blockLayout.lineForTextPosition(
                            fragPos)
                        if line.isValid():
                            x, _ = line.cursorToX(fragPos)
                            right, lineEnd = line.cursorToX(fragEnd)
                            rect = QtCore.QRectF(
                                blockX + x, blockY + line.y(), 
                                right - x, line.height()
                            )
                            lastBorderRects.append(rect)
                            if lineEnd != fragEnd:
                                fragPos = lineEnd
                            else:
                                break
                        else:
                            break
                it += 1
                
            block = block.next()

        borderData = []
        if lastBorderRects and lastBorder:
            borderRects.append((lastBorderRects, lastBorder))
        if not self._joinBorders:
            for rects, border in borderRects:
                path = QtGui.QPainterPath()
                for rect in rects:
                    path.addRect(rect.adjusted(0, 0, -1, -1))
                path.translate(.5, .5)
                borderData.append((border, path))
        else:
            for rects, border in borderRects:
                path = QtGui.QPainterPath()
                for rect in rects:
                    path.addRect(rect)
                path.translate(.5, .5)
                path = path.simplified()
                fixPath = QtGui.QPainterPath()
                last = None
                # see the [*] note below for this block
                for e in range(path.elementCount()):
                    element = path.elementAt(e)
                    if element.type != path.MoveToElement:
                        if element.x < last.x:
                            last.y -= 1
                            element.y -= 1
                        elif element.y > last.y:
                            last.x -= 1
                            element.x -= 1
                    if last:
                        if last.isMoveTo():
                            fixPath.moveTo(last.x, last.y)
                        else:
                            fixPath.lineTo(last.x, last.y)
                    last = element
                if last.isLineTo():
                    fixPath.lineTo(last.x, last.y)
                borderData.append((border, fixPath))

        if self._borderData != borderData:
            self._borderData[:] = borderData
            # we need to schedule a repainting on the whole viewport
            self.viewport().update()

    def paintEvent(self, event):
        if self._borderData:
            offset = QtCore.QPointF(
                -self.horizontalScrollBar().value(), 
                -self.verticalScrollBar().value())
            rect = QtCore.QRectF(event.rect()).translated(-offset)
            if self._borderData[-1][1].boundingRect().bottom() >= rect.y():
                toDraw = []
                for border, path in self._borderData:
                    if not path.intersects(rect):
                        if path.boundingRect().y() > rect.y():
                            break
                        continue
                    toDraw.append((border, path))
                if toDraw:
                    qp = QtGui.QPainter(self.viewport())
                    qp.setRenderHint(qp.Antialiasing)
                    qp.translate(offset)
                    for border, path in toDraw:
                        qp.setPen(border)
                        qp.drawPath(path)
        super().paintEvent(event)


if __name__ == '__main__':
    import sys
    app = QtWidgets.QApplication(sys.argv)
    editor = BorderTextEdit()
    text = 'Hello World'
    editor.setText(text)
    cursor = editor.textCursor()
    word = "World"
    start_index = text.find(word)
    cursor.setPosition(start_index)
    cursor.setPosition(start_index + len(word), cursor.KeepAnchor)
    format = QtGui.QTextCharFormat()
    format.setForeground(QtGui.QBrush(QtCore.Qt.green))
    format.setProperty(BorderProperty, QtGui.QPen(QtCore.Qt.red))
    cursor.mergeCharFormat(format)
    editor.show()
    sys.exit(app.exec_())

[*] - the border should always be within the bounding rect of the text, otherwise there would be overlapping, so the rectangles are always adjusted by 1 pixel left/above for the right/bottom borders; to allow rectangle joining, we must preserve the original rectangles first, so we fix the resulting paths by adjusting the "remaining lines" of those rectangles: since rectangles are always drawn clockwise, we adjust the "right lines" that go from top to bottom (by moving their x points left by one pixel) and the "bottom lines" from right to left (y points moved above by one pixel).

The clipboard issue

Now, there is a problem: since Qt uses the system clipboard also for internal cut/copy/paste operations, all that format data will be lost when trying to use that basic feature.

In order to solve this, a work around is to add the custom data to the clipboard, which stores the formatted contents as HTML. Note that we cannot change the contents of the HTML, becase there is no reliable way to find the specific position of the "border text" in the generated code. The custom data must be stored in other ways.

QTextEdit calls createMimeDataFromSelection() whenever it has to cut/copy a selection, so we can override that function by adding custom data to the returned mimedata object, and eventually read it back when the related insertFromMimeData() function is called for the paste operation.

The border data is read using a similar concept above (cycling through the blocks that are part of the selection) and serialized through the json module. Then, it gets restored by unserializing the data (if it exists) while keeping track of the previous cursor position before pasting.

Note: in the following solution, I just append the serialized data to the HTML (using the <!-- ... ---> comments), but another option is to add further data with a custom format to the mimeData object.

import json

BorderProperty = QtGui.QTextFormat.UserProperty + 100
BorderDataStart = "<!-- BorderData='"
BorderDataEnd = "' -->"

class BorderTextEdit(QtWidgets.QTextEdit):
    # ...
    def createMimeDataFromSelection(self):
        mime = super().createMimeDataFromSelection()
        cursor = self.textCursor()

        if cursor.hasSelection():
            selStart = cursor.selectionStart()
            selEnd = cursor.selectionEnd()
            block = self.document().findBlock(selStart)
            borderData = []
            while block.isValid() and block.position() < selEnd:
                it = block.begin()
                while not it.atEnd():
                    fragment = it.fragment()
                    fragStart = fragment.position()
                    fragEnd = fragStart + fragment.length()
                    if fragEnd >= selStart and fragStart < selEnd:
                        fmt = fragment.charFormat()
                        border = fmt.property(BorderProperty)
                        if isinstance(border, QtGui.QPen):
                            start = max(0, fragStart - selStart)
                            end = min(selEnd, fragEnd)
                            borderDict = {
                                'start': start, 
                                'length': end - (selStart + start), 
                                'color': border.color().name(), 
                                'width': border.width()
                            }
                            if border.width() != 1:
                                borderDict['width'] = border.width()
                            borderData.append(borderDict)
                    it += 1
                block = block.next()

            if borderData:
                mime.setHtml(mime.html()
                    + BorderDataStart 
                    + json.dumps(borderData) 
                    + BorderDataEnd)

        return mime

    def insertFromMimeData(self, source):
        cursor = self.textCursor()
        # merge the paste operation to avoid multiple levels of editing
        cursor.beginEditBlock()
        self._customPaste(source, cursor.selectionStart())
        cursor.endEditBlock()

    def _customPaste(self, data, cursorPos):
        super().insertFromMimeData(data)
        if not data.hasHtml():
            return
        html = data.html()
        htmlEnd = html.rfind('</html>')
        if htmlEnd < 0:
            return
        hasBorderData = html.find(BorderDataStart)
        if hasBorderData < 0:
            return
        end = html.find(BorderDataEnd)
        if end < 0:
            return
        try:
            borderData = json.loads(
                html[hasBorderData + len(BorderDataStart):end])
        except ValueError:
            return
        cursor = self.textCursor()
        keys = set(('start', 'length', 'color'))
        for data in borderData:
            if not isinstance(data, dict) or keys & set(data) != keys:
                continue

            start = cursorPos + data['start']
            cursor.setPosition(start)
            oldFormat = cursor.charFormat()
            cursor.setPosition(start + data['length'], cursor.KeepAnchor)

            newBorder = QtGui.QPen(QtGui.QColor(data['color']))
            width = data.get('width')
            if width:
                newBorder.setWidth(width)

            if oldFormat.property(BorderProperty) != newBorder:
                fmt = QtGui.QTextCharFormat()
            else:
                fmt = oldFormat

            fmt.setProperty(BorderProperty, newBorder)
            cursor.mergeCharFormat(fmt)

For obvious reasons, this will provide clipboard support for the borders only for instances of BorderTextEdit or its subclasses, and will not be available when pasting into other programs, even if they accept HTML data.

I found a solution with QRubberband , which is pretty close to what I wanted:

import sys
from PyQt5.QtGui import QTextCursor
from PyQt5.QtWidgets import QTextEdit, QWidget, QApplication, QVBoxLayout
from PyQt5.Qt import QRubberBand


class TextEditor(QTextEdit):
    def __init__(self, parent=None):
        super().__init__(parent)

        text = "Hello World"
        self.setText(text)
        word = "World"
        start_index = text.find(word)
        end_index = start_index + len(word)
        self.set = set()
        self.set.add((start_index, end_index))

    def getBoundingRect(self, start, end):
        cursor = self.textCursor()
        cursor.setPosition(end)
        last_rect = end_rect = self.cursorRect(cursor)
        cursor.setPosition(start)
        first_rect = start_rect = self.cursorRect(cursor)
        if start_rect.y() != end_rect.y():
            cursor.movePosition(QTextCursor.StartOfLine)
            first_rect = last_rect = self.cursorRect(cursor)
            while True:
                cursor.movePosition(QTextCursor.EndOfLine)
                rect = self.cursorRect(cursor)
                if rect.y() < end_rect.y() and rect.x() > last_rect.x():
                    last_rect = rect
                moved = cursor.movePosition(QTextCursor.NextCharacter)
                if not moved or rect.y() > end_rect.y():
                    break
            last_rect = last_rect.united(end_rect)
        return first_rect.united(last_rect)

class Window(QWidget):
    def __init__(self):
        super(Window, self).__init__()
        self.edit = TextEditor(self)
        layout = QVBoxLayout(self)
        layout.addWidget(self.edit)
        self.boxes = []

    def showBoxes(self):
        while self.boxes:
            self.boxes.pop().deleteLater()
        viewport = self.edit.viewport()
        for start, end in self.edit.set:
            print(start, end)
            rect = self.edit.getBoundingRect(start, end)
            box = QRubberBand(QRubberBand.Rectangle, viewport)
            box.setGeometry(rect)
            box.show()
            self.boxes.append(box)

    def resizeEvent(self, event):
        self.showBoxes()
        super().resizeEvent(event)

if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = Window()
    window.setGeometry(800, 100, 1000, 1000)
    window.show()
    window.showBoxes()
    sys.exit(app.exec_())

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM