在QTextEdit中覆盖paintEvent以在单词周围绘制矩形

2023-12-01

I use QTextEdit from 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_())

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

文本的布局相当复杂,尤其是在处理富文本,包括简单的方面,例如多行。

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

For very基本情况,提供的答案获取QTextEdit选择的边界框就足够了,但它有一些缺陷。

首先,如果文本换行(即非常长的选择),则complete将显示边界矩形,其中将包括不属于所选内容的文本。如上面的答案所示,可以看到结果:

wrong rectangle

那么,所提出的解决方案仅适用于static文本:每当文本更新时,选择内容不会随之更新。虽然可以在以编程方式更改文本时更新内部选择,但用户编辑会使文本变得更加复杂,并且容易出现错误或意外行为。

解决方案:使用QTextCharFormat

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

为了实现这一点,需要循环遍历整个文档布局并获取每个需要“突出显示”的文本片段的准确坐标。这是通过以下方式完成的:

  1. 遍历所有文本块文件的内容;
  2. 遍历所有文本片段每个块;
  3. 得到可能的lines是该片段的一部分(因为自动换行可能会强制甚至单个单词出现在多行上);
  4. 找到属于这些行中片段的字符范围,它将用作边界的坐标;

为了提供这样的功能,我使用了一个自定义 QTextFormat 属性和一个简单的 QPen 实例,该实例将用于绘制边框,并且该属性是为所需文本片段设置的特定 QTextCharFormat 设置的。

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

The paintEvent()然后,只要这些边框包含在事件矩形中,就会绘制这些边框(出于优化原因,QTextEdit 仅重绘实际需要重绘的文本部分)。

这是以下代码的结果:

screenshot of the example code

这是在“选择”中打破行时会发生的情况:

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

[*] - 边框应始终位于文本的边界矩形内,否则会出现重叠,因此矩形的右/下边框始终向左/上方调整 1 个像素;为了允许矩形连接,我们必须首先保留原始矩形,因此我们通过调整这些矩形的“剩余线”来修复结果路径:由于矩形总是顺时针绘制,因此我们调整从上到下的“右线” (通过将 x 点向左移动一个像素)和“底线”从右向左(y 点向上移动一个像素)。

剪贴板问题

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

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

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

使用上面类似的概念读取边界数据(循环选择属于选择一部分的块)并通过json模块。然后,通过反序列化数据(如果存在)来恢复它,同时跟踪先前的光标位置before粘贴。

注意:在下面的解决方案中,我只是append序列化数据到 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
        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)

出于显而易见的原因,这将为边框提供剪贴板支持only对于以下实例BorderTextEdit或其子类,并且在粘贴到其他程序时将不可用,即使它们接受 HTML 数据。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

在QTextEdit中覆盖paintEvent以在单词周围绘制矩形 的相关文章

随机推荐

  • MS Access Query 不使用标准相等运算符区分平假名和片假名

    最近 我在 MS Access 查询中搜索包含日语文本的表时遇到了问题 日语有平假名和片假名两种字母 音值相同 但字符不同 例如 平假名 和 片假名 都发音为 a 对于我来说 这两个角色需要被视为截然不同的SELECT查询 但是当我运行以下
  • PHP Session 类类似于 CodeIgniter Session 类?

    PHP会话类类似于CodeIgniter会话类 存在吗 我尝试搜索 但没有得到有用的结果 我正在使用 CodeIgniter 会话类 它有几个功能 非常喜欢 存储用户的唯一会话 ID 用户的 IP 地址 用户的用户代理数据 上次活动和其他信
  • Javas Audio Clip 频繁播放蜂鸣声时出现问题

    我想在 GUI 触发操作成功和错误时播放短蜂鸣声 WAV 文件 我碰到javax sound sampled Clip 这似乎有效 这是我使用的基本代码 clip stop clip setFramePosition 0 clip star
  • 授予数据库用户文件夹访问权限

    我正在尝试使用以下查询从 mysql 创建数据的 csv 导出 SELECT INTO OUTFILE tmp result csv FIELDS TERMINATED BY OPTIONALLY ENCLOSED BY ESCAPED B
  • 比较 Unix/Linux IPC

    Unix Linux 提供了许多 IPC 管道 套接字 共享内存 dbus 消息队列 每种应用最适合的应用是什么 它们的性能如何 Unix IPC 以下是七大 Pipe 仅在作为父 子相关的进程中有用 称呼pipe 2 and fork 2
  • 如何将第二个模式添加到我的 html 页面

    我在上一篇文章中没有解释我的问题 所以我想在我的html页面中添加第二个模态 所以如果您单击 按钮1 它将打开 模态1 如果您单击 按钮2 它将打开 模态2 但是 按钮3 按钮4 按钮5 和 按钮6 打开 模态2 当我创建第二个模态并设置下
  • 反转字符串大小写

    我正在尝试编写一个函数 该函数接受字符串并将所有小写字母更改为大写字母 反之亦然 lower UPPER 将翻译为 LOWER upper 这是我所拥有的 var convertString function str var s var i
  • 如何禁用 Spring Jpa 异常转换器方面

    我正在从 Spring 2 5 6 迁移到 3 2 5 jar spring aspects 3 2 5 包含新方面 JpaExceptionTranslatorAspect 它将标准 JPA 异常转换为 Spring 异常 这似乎是 Ro
  • 使用CSS将div的底部弯曲到内部

    我想用 CSS 弯曲这个矩形 div 背景的底边 所以结果是这样的 Does someone have an idea perhaps how it could be achieved curved margin 0 auto height
  • 动态更改 paginate_by 的值

    我希望能够允许用户更改默认页面大小 paginate by 我当前的页面大小设置为10 我想要有 25 50 等等的按钮 我正在使用 postgresql 11 4 运行 Django 2 2 和 Python 3 73 我的views p
  • Glassfish 中是否有可能为不同包记录单独的文件

    我们使用 glassfish 作为我们的应用程序服务器 我们想要单独记录消息 例如 如果日志来自xxx company xxx service包 则日志文件命名为service log 如果日志来自xxx company xxx dao 则
  • E2099 转换或算术运算溢出

    我想将 int64 与这样的变量进行比较 const GB 1073741824 if DiskFile Size lt 1 GB then 它适用于 1 但不适用于 3 if DiskFile Size lt 3 GB then 这个帖子
  • 接收来自 HTTP 请求返回的 JSON 数据

    我有一个工作正常的网络请求 但它只是返回状态 OK 但我需要我要求它返回的对象 我不知道如何获取我请求的 json 值 我刚开始使用 HttpClient 对象 是否有我遗漏的属性 我真的需要返回的对象 谢谢你的帮助 拨打电话 运行良好会返
  • gcc 找不到 -lgcc, g++.exe: 错误: CreateProcess: 没有这样的文件或目录

    我正在尝试在 Windows 上使用 MingW 但是当我尝试编译我的 c c 文件时 使用 C gcc 编译时会出现以下错误 gt gcc c Users Administrator Desktop C C helloworld hell
  • 使用 Swift 在 Whatsapp 上分享图像

    我正在创建一个应用程序来通过社交媒体平台共享图像 尤其是在 WhatsApp 上 我尝试使用UIActivityViewController但当显示工作表时 它不会显示 WhatsApp 选项 我在网上搜索并找到下面的代码 显示工作表时显示
  • Laravel 5.3:如何在服务提供商中使用身份验证?

    我通过从表中获取值来传递共享视图中的值 为此我需要知道用户 ID 但是Auth check 返回假 我该怎么做 下面是代码 public function boot basket count 0 if Auth check always f
  • 使用去抖 onChange 处理程序设置输入值

    在我的 React Hooks 应用程序中 我需要让用户在输入字段中键入 1000 毫秒 当 1000 毫秒到期时 将发送带有输入值的 API 请求
  • 对象中没有定义的类型声明意味着什么?

    Scala 允许使用以下方式定义类型type关键字 根据声明时间的不同 其含义和用途通常略有不同 如果你使用type在对象或包对象内部 您可以定义类型别名 即另一种类型的更短 更清晰的名称 package object whatever t
  • CRM 2011 KeyNotFoundException异常

    我是 CRM 开发的新手 我有一个自定义实体 客户 该实体有一个名为 defaultcustomer 的字段 可以是 TRUE 或 FALSE 我正在开发一个插件 我需要将所有 客户 的 defaultcustomer 设置为 FALSE
  • 在QTextEdit中覆盖paintEvent以在单词周围绘制矩形

    I use QTextEdit from PyQt5我想在选定的单词周围放置一个框架 按照musicamante的建议 我尝试覆盖paintEvent 我想从光标位置提取的矩形的坐标 所以 我把光标放在TextEditor在文本的开头和结尾