pythonqtpyqtqgraphicsitemqgraphicstextitem

pyqt QGraphicsTextItem Outside Outline


I'm trying to add an outside outline to my text, I've got it to work but the issue is that its painting in an infinite loop even when there is no changes made. I'm pretty sure I'm on the right lines but if there is a more efficient way to do this so the paint doesn't run in a loop, please let me know, thanks.


import sys
from PySide6.QtCore import *
from PySide6.QtGui import *
from PySide6.QtWidgets import *
from PySide6.QtSvgWidgets import *

class OutlinedGraphicsTextItem(QGraphicsTextItem):
    def paint(self, painter, option, widget):
        format = QTextCharFormat()
        format.setFontPointSize(80)
        format.setTextOutline(QPen(Qt.red, 10, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) # Color and width of outline
        cursor = QTextCursor(self.document())
        cursor.select(QTextCursor.Document)
        cursor.mergeCharFormat(format)
        super().paint(painter, option, widget)
        format.setTextOutline(QPen(Qt.transparent))
        cursor.mergeCharFormat(format)
        super().paint(painter, option, widget)
        print("Painting")

# Example usage:
if __name__ == "__main__":
    import sys
    app = QApplication(sys.argv)
    
    text_item = OutlinedGraphicsTextItem()
    text_item.setPlainText("Hello, World!")
    
    scene = QGraphicsScene()
    scene.addItem(text_item)
    view = QGraphicsView(scene)
    view.show()
    
    sys.exit(app.exec_())


Solution

  • One of the fundamental rules that should always remembered is that paint functions should always and only do what they're expected: paint. Anything that may cause geometry changes or alter the contents in ways that would require painting will potentially cause recursion, and should therefore be avoided at all costs.

    In your paint() override, you're actually updating the contents of the document, which automatically triggers a repaint (the same happened in the previous version of your code), and this is obviously wrong, since what done within that paint() override actually requires an update request due to the changed contents (the format).

    Now, for a standard, thin outline, this could be easily achieved: we can create a function that updates the current format with the outline. Since we're using standard plain text, we could simply overwrite (not override, since it's not a virtual function) setPlainText():

        def setPlainText(self, text):
            super().setPlainText(text)
            if text:
                format = QTextCharFormat()
                format.setFontPointSize(80)
                format.setTextOutline(QPen(
                    Qt.GlobalColor.red, 2, 
                    Qt.PenStyle.SolidLine, 
                    Qt.PenCapStyle.RoundCap, 
                    Qt.PenJoinStyle.RoundJoin
                ))
                cursor = QTextCursor(self.document())
                cursor.select(QTextCursor.SelectionType.Document)
                cursor.mergeCharFormat(format)
    

    Then we can leave the default paint() function do its job, without overriding it:

    Thin outline

    Unfortunately, this has unwanted results with a thick outline:

    Thick outline

    Despite the recursion issue, your original attempt has a correct approach in principle: draw the contents again, but without the outline.

    In order to achieve this, we can update the contents within paint(), but we have to be absolutely sure that the changes do not trigger the recursion.

    Similarly to many text based widgets in Qt, QGraphicsTextItem internally uses a private QWidgetTextControl class that manages the QTextDocument appearance, layout and editing. QGraphicsTextItem connects to its signals in order to be notified whenever the contents of the document require updates in the item geometry or a repaint is required.

    That object is not directly accessible through the public API, but it can be found by using QObject.findChildren(). Since the class is private, we cannot use isinstance() (otherwise we could've used findChild(classname)), but we can retrieve the class name by using the QMetaObject interface.

    Once that object is found, it's just a matter of blocking its signals when we apply changes to the document.

    For simplicity, I created the formats as instance attributes, which should slightly improve performance by avoiding unnecessary creation of objects at every repaint.

    class OutlinedGraphicsTextItem(QGraphicsTextItem):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
    
            self.outlineFormat = QTextCharFormat()
            self.outlineFormat.setTextOutline(QPen(
                Qt.GlobalColor.red, 10, 
                Qt.PenStyle.SolidLine, 
                Qt.PenCapStyle.RoundCap, 
                Qt.PenJoinStyle.RoundJoin
            ))
            self.dummyFormat = QTextCharFormat()
            self.dummyFormat.setTextOutline(QPen(Qt.GlobalColor.transparent))
    
            children = self.findChildren(QObject)
            if not children:
                super().setPlainText('')
                children = self.findChildren(QObject)
            if self.toPlainText():
                # ensure we call our version of setPlainText()
                self.setPlainText(self.toPlainText())
    
            for obj in children:
                if obj.metaObject().className() == 'QWidgetTextControl':
                    self.textControl = obj
                    break
    
        def setPlainText(self, text):
            super().setPlainText(text)
            if text:
                format = QTextCharFormat()
                format.setFontPointSize(80)
                cursor = QTextCursor(self.document())
                cursor.select(QTextCursor.SelectionType.Document)
                cursor.mergeCharFormat(format)
    
        def paint(self, painter, option, widget):
            with QSignalBlocker(self.textControl):
                cursor = QTextCursor(self.document())
                cursor.select(QTextCursor.SelectionType.Document)
                cursor.mergeCharFormat(self.outlineFormat)
                super().paint(painter, option, widget)
                cursor.mergeCharFormat(self.dummyFormat)
                super().paint(painter, option, widget)
    

    And here is the result, with the paint() only called when actually necessary:

    proper outline drawing

    There is an alternative, though, which is fundamentally simple: use a child item that uses the same font and contents, but does not draw the outline; since it's a child item, it's always stacked above the parent, ensuring that the "standard" font is properly drawn over the outlined one.

    class OutlinedGraphicsTextItem2(QGraphicsTextItem):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.childItem = QGraphicsTextItem(self)
            if self.toPlainText():
                self.setPlainText(self.toPlainText())
    
        def setPlainText(self, text):
            super().setPlainText(text)
            self.childItem.setPlainText(text)
            if text:
                format = QTextCharFormat()
                format.setFontPointSize(80)
                cursor1 = QTextCursor(self.document())
                cursor2 = QTextCursor(self.childItem.document())
                for cursor in (cursor1, cursor2):
                    cursor.select(QTextCursor.SelectionType.Document)
                    cursor.mergeCharFormat(format)
                format.setTextOutline(QPen(
                    Qt.GlobalColor.red, 10, 
                    Qt.PenStyle.SolidLine, 
                    Qt.PenCapStyle.RoundCap, 
                    Qt.PenJoinStyle.RoundJoin
                ))
                cursor1.mergeCharFormat(format)
    

    For simple text items that don't require edit interaction and geometry changes (text widths, transformations, etc.), this solution is probably better other than simpler.