注意:我的这个答案是基于这个问题的早期版本,它使用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)
出于明显的原因,这将为边界提供剪贴板支持。only为BorderTextEdit
或其子类的实例,在粘贴到其他程序时将不可用,即使它们接受HTML数据。