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将显示边界矩形,其中将包括不属于所选内容的文本。如上面的答案所示,可以看到结果:
那么,所提出的解决方案仅适用于static文本:每当文本更新时,选择内容不会随之更新。虽然可以在以编程方式更改文本时更新内部选择,但用户编辑会使文本变得更加复杂,并且容易出现错误或意外行为。
解决方案:使用QTextCharFormat
虽然以下方法显然要复杂得多,但它更有效,并且允许进一步自定义(例如设置边框颜色和宽度)。它的工作原理是使用 Qt 富文本引擎的现有功能,设置始终保留的自定义格式属性,无论文本是否更改。一旦为选定的文本片段设置了格式,剩下的就是实现动态计算边框矩形以及它们的绘画的部分。
为了实现这一点,需要循环遍历整个文档布局并获取每个需要“突出显示”的文本片段的准确坐标。这是通过以下方式完成的:
- 遍历所有文本块文件的内容;
- 遍历所有文本片段每个块;
- 得到可能的lines是该片段的一部分(因为自动换行可能会强制甚至单个单词出现在多行上);
- 找到属于这些行中片段的字符范围,它将用作边界的坐标;
为了提供这样的功能,我使用了一个自定义 QTextFormat 属性和一个简单的 QPen 实例,该实例将用于绘制边框,并且该属性是为所需文本片段设置的特定 QTextCharFormat 设置的。
然后,连接到相关信号的 QTimer 将计算边框的几何形状(如果有)并最终请求重新绘制:这是必要的,因为文档布局(文本内容,还有编辑器/文档大小)的任何更改都可能会发生变化边界的几何形状。
The 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。请注意,我们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 数据。