覆盖QTextEdit中的paintEvent,在单词周围绘制矩形。

0 人关注

我使用的是 QTextEdit ,来自 PyQt5 ,我想在选定的单词周围加上一个框架。正如musicamante所建议的,我尝试覆盖 paintEvent 。我想从光标位置提取矩形的坐标。因此,我把我的 TextEditor 的光标放在文本的开头和结尾处,然后试图从每个开头和结尾处获得全局坐标。用这些坐标应该可以画出一个矩形。但当我运行代码时,输出的坐标是错误的,只画了一个破折号或一个非常小的矩形。

    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_())
    
1 个评论
Mh,QtextDocument不支持这样的功能,引擎只能 "填充 "文本区域的边界矩形,你可以尝试的唯一简单的解决方案是通过一个单格表,但这显然对多字选择的文字包装效果不好。我能想到的唯一替代方案是为文本格式设置一个自定义属性,然后重写绘制事件,以匹配视口可见区域内可能出现的情况。
python
pyqt5
Mazze
Mazze
发布于 2022-06-13
2 个回答
musicamante
musicamante
发布于 2022-06-14
已采纳
0 人赞同

注意:我的这个答案是基于这个问题的早期版本,它使用QTextCharFormat来设置文本片段的背景。我增加了进一步的支持,因为我发现自己正在寻找类似问题的有效解决方案,但直到现在才有机会好好做。

Premise

文本的布局是相当复杂的,特别是在涉及到 rich text ,包括简单的方面,如多线。

虽然Qt富文本引擎允许设置文本的背景,但并不支持绘制一个 border 围绕文本。

For very 基本情况下,提供的答案为 Getting the bounding box of QTextEdit selection 就可以了,但它有一些缺陷。

首先,如果文本在一个新的行上包裹(即一个非常长的选择),则 complete 边界矩形将被显示出来,这将包括不属于选择范围的文本。如上面的答案所示,你可以看到结果。

那么,建议的解决方案只对以下情况有效 静电 文本:每当文本被更新时,选择不会被一起更新。虽然有可能在文本改变时以编程方式更新内部选择,但用户编辑会使它变得更加复杂,容易出现错误或意外行为。

Solution: using QTextCharFormat

虽然下面的方法显然要复杂得多,但它更有效,并允许进一步的自定义(如设置边框的颜色和宽度)。它通过使用Qt富文本引擎的现有功能来工作,设置一个自定义的格式属性,无论文本是否被改变,都会被保留下来。一旦为选定的文本片段设置了格式,剩下的就是实现动态计算边框的矩形的部分,显然还有它们的绘画。

为了实现这一目标,有必要循环浏览整个文档布局,并获得需要 "突出显示 "的每个文本片段的准确坐标。这可以通过以下方式实现。

  • iterating through all text blocks of the document;
  • iterating through all text fragments of each block;
  • 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);
  • find the extents of the characters belonging to the fragments in those lines, which will be used as coordinates for the borders;
  • 为了提供这样的功能,我使用了一个自定义的QTextFormat属性和一个简单的QPen实例,它将被用来绘制边框,并且该属性被设置为一个特定的QTextCharFormat,为想要的文本片段设置。

    然后,连接到相关信号的QTimer将计算边框的几何形状(如果有的话),并最终请求重绘:这是必要的,因为文档布局的任何变化(文本内容,也包括编辑器/文档大小)都可能改变边框的几何形状。

    然后, paintEvent() 将绘制这些边界,只要它们被包括在事件矩形中(出于优化的原因,QTextEdit只重新绘制实际需要重新绘制的部分文本)。

    Here is the result of the following code:

    下面是在 "选择 "中断线时的情况。

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

    [*] - 边界应该总是在文本的边界矩形内,否则就会出现重叠,所以矩形的右/下边界总是向左/上方调整1个像素;为了允许矩形连接,我们必须首先保留原始矩形,所以我们通过调整这些矩形的 "剩余线 "来固定产生的路径。由于矩形总是顺时针绘制的,我们调整从上到下的 "右线"(将其X点向左移动一个像素)和从右到左的 "底线"(将Y点向上移动一个像素)。

    The clipboard issue

    现在,有一个问题:由于Qt将系统剪贴板也用于内部剪切/复制/粘贴操作,当试图使用这一基本功能时,所有的格式数据都会丢失。

    为了解决这个问题,一个解决方法是将自定义数据添加到剪贴板,将格式化的内容存储为HTML。请注意,我们不能改变HTML的内容,因为没有可靠的方法可以在生成的代码中找到 "边界文本 "的具体位置。自定义数据must以其他方式存储。

    QTextEdit调用createMimeDataFromSelection()每当它需要剪切/复制一个选择时,我们可以通过向返回的mimedata对象添加自定义数据来覆盖该函数,并最终在相关的insertFromMimeData()函数被调用来进行粘贴操作。

    边界数据使用上述类似的概念来读取(循环读取属于选择范围的块),并通过json模块进行序列化。然后,通过取消序列化数据(如果它存在的话)来恢复它,同时保持对前一个光标位置的跟踪before粘贴。

    Note: in the following solution, I just 追加串行化的数据到HTML(使用<!-- ... --->注释),但另一个选择是进一步添加具有自定义格式的数据到mimeData对象。

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

    出于明显的原因,这将为边界提供剪贴板支持。onlyBorderTextEdit或其子类的实例,在粘贴到其他程序时将不可用,即使它们接受HTML数据。

    Mazze
    Mazze
    发布于 2022-06-14
    0 人赞同

    我发现了一个 solution QRubberband ,这与我想要的相当接近。

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