在qtextedit中覆盖paintEvent,以绘制矩形周围的矩形

发布于 2025-02-06 18:34:04 字数 1835 浏览 0 评论 0 原文

我从 pyqt5 中使用 qtextedit ,我想在选定的单词上放置一个框架。正如MusicAmante所建议的那样,我试图覆盖 PaintEvent 。我想从光标位置提取矩形的坐标。因此,我将我的 textededitor 的光标放在文本的开头和结尾处,然后尝试从每个开始和结束时获取全局坐标。使用这些坐标,应绘制矩形。但是,当我运行代码时,输​​出坐标是错误的,只绘制了一个破折号或一个很小的矩形。

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

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

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(2

偏爱自由 2025-02-13 18:34:04

注意:我将此答案基于该问题的早期版本,该问题使用QTEXTCHURFORMAT设置文本片段的背景。 但直到现在。

解决方案时,我增加了支持,

当我发现自己寻找有效的 em>丰富的文本,包括多行之类的简单方面。

虽然QT丰富的文本引擎允许设置文本的背景,但在文本周围绘制A border 没有支持。

对于非常基本情况,提供了获得QTEXTEDIT选择的边界盒 Will Affeice,但这有一些缺陷。

首先,如果文本包裹在新行上(即很长的选择),则将显示完整界限rect,其中将包括不属于选择的文本。如上所述,您可以看到结果:

“错误的矩形”

然后,提出的解决方案仅对 static 文本:每当更新文本时,选择都不会随之更新。虽然可以在编程更改文本时更新内部选择,但用户编辑将使其更加复杂,容易出现错误或意外行为。

解决方案:使用QTEXTCHARFORMAT,

而以下方法显然更为复杂,更有效,并且允许进一步自定义(例如设置边框颜色和宽度)。它通过使用QT丰富的文本引擎的现有功能来工作,设置自定义格式属性,该属性将始终保留,无论文本是否更改。一旦为选定的文本片段设置了格式,剩下的就是实现将动态计算边界矩形及其绘画的部分。

为了实现这一目标,有必要循环浏览整个文档布局,并获得每个文本片段的确切坐标,这些片段需要“突出显示”。这是通过:

  1. 通过文档的所有文本块进行迭代来完成;
  2. 通过每个块的所有文本片段迭代;
  3. 获取可能是该片段一部分的可能(因为单词包裹可能会迫使单个单词出现在多个行上);
  4. 在这些行中找到属于片段的字符的范围,将用作边界的坐标;

为了提供此类功能,我使用了一个简单的QPEN实例的自定义QTEXTFORMAT属性,该实例将用于绘制边界,并为特定的QTEXTCHARFORMAT设置了该属性,以用于所需的文本片段。

然后,连接到相关信号的QTIMER将计算边界的几何形状(如果有),并最终请求重新绘制:这是必要的,因为文档布局中的任何更改(文本内容,以及编辑器/文档大小)都可以可能更改边界的几何形状。

然后, paintEvent()将在事件矩形中包含在这些边界时(出于优化原因,qtextedit仅重新绘制了实际需要重新粉刷的文本部分)。

这是以下代码的结果:

”示例代码的屏幕截图

,这是打破“选择”中的行时发生的事情:

”两个线边框的屏幕截图

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点以一个像素移动)。

剪贴板问题

现在存在一个问题:由于QT使用系统剪贴板也用于内部切割/复制/粘贴操作,因此在尝试使用该基本功能时,所有格式数据都将丢失。

为了解决此问题,解决的工作是将自定义数据添加到剪贴板中,该剪贴板将格式的内容存储为HTML。请注意,我们不能更改HTML的内容,因为没有可靠的方法可以在生成的代码中找到“边框文本”的特定位置。自定义数据必须以其他方式存储。

qtextedit调用 insertfromimimedata() 用于粘贴操作调用函数。

使用上面的类似概念(通过选择的一部分的块骑自行车)并通过 JSON 模块序列化读取边框数据。然后,通过在粘贴之前跟踪先前的光标位置,通过不重新化数据(如果存在)来恢复。

注意:在以下解决方案中,我只是 append 将序列化数据添加到html(使用&lt;! - ... ----&gt; 注释),但是另一个选项是将使用自定义格式的更多数据添加到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
        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)

出于明显的原因,这将为 bordertextedit 或其子类别提供边界 的剪贴板支撑HTML数据。

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 (i.e. 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:

wrong rectangle

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:

screenshot of the example code

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

screenshot of two line borders

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.

情绪 2025-02-13 18:34:04

我找到了一个解决方案带有 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()
    window.setGeometry(800, 100, 1000, 1000)
    window.show()
    window.showBoxes()
    sys.exit(app.exec_())

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_())
~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文