Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(Py)Qt bug resulting in variable spacing between adjacent text boxes, scaling with string length #708

Open
Hemimastix opened this issue Sep 12, 2023 · 1 comment

Comments

@Hemimastix
Copy link

Hemimastix commented Sep 12, 2023

Hello,
I have a script for automating italicising binomial nomenclature while leaving accession numbers, strain names, etc, as regular font. This means the italicised and regular objects have to go as separate text boxes side-by-side. All's fine except... there is ugly spacing between the two pieces of text, which scales proportionately to string length -- that latter "feature" being particularly annoying. I spent a long time trying to dig to the bottom of this and came across this unresolved bug in Qt: https://bugreports.qt.io/browse/QTBUG-49892 This matches my problem almost exactly, so I made a workaround using a "fudge_factor" determined empirically by trial and error...which ended up being font size, font type, and machine specific. I'm now working on a more general solution for this problem, but I first wanted to check if someone else has already sorted this out, or maybe I missed some obvious fix?

Unfortunately, since Qt is written in C++, I can't trace this issue deeper and am stuck with somewhat silly "fudge factor" type fixes :-/

PS:
I think part of this issue can be seen here: if I divide QFontMetrics(font),boundingRect(text).width() by len(text) (for any text), I get a consistent 11.3333 for size 12 Arial, 9.55555 for size 10 Arial. I'm surprised it's smaller, because the bounding box appears to be over-estimated in the actual rendering step. I was also expecting slight variation as Arial is not a fixed width font. Something's off.

Minimal example, without even bothering with italics:

from ete3 import Tree, TreeStyle, NodeStyle, faces

t = Tree("(A:17, (bb:31, obnoxious_long_name:22):7, foo:22, (bar:33, C:12):40);")

def quick_style(node):
    leaf_text_1 = TextFace(node.name)
    leaf_text_2 = TextFace("x")	
    node.add_face(face=leaf_text_1, column=1)
    node.add_face(face=leaf_text_2, column=2)

tree_style = TreeStyle()
tree_style.layout_fn = quick_style
tree_style.show_leaf_name = False

t.render("example.svg", tree_style=tree_style, units="px")

Produces the following under ete version 3.1.2:

image

@Hemimastix
Copy link
Author

Hemimastix commented Sep 12, 2023

Update: I found a workaround of sorts without involving upgrading PyQt (to >=5.11 I think?) to one that supports QFontMetrics.horizontalAdvance(), which is apparently strongly recommended over now-deprecated width().

This workaround started from trying to find a way to measure (hopefully true!) text length independently of PyQt fontMetrics -- I used ImageFont from Pillow. The problem I'm now stuck on is that ImageFont requires an absolute path to the font, which PyQt apparently quietly keeps to itself. So this is mainly to illustrate where I think the problem is:

qt_rendering_fix.py:

from PIL import ImageFont
from ete3 import TextFace

try:  # directly from TextFact, just in case
    from urllib2 import urlopen
except ImportError:
    from urllib.request import urlopen

try:  # workaround for PyQt SVG textbox scaling error (https://bugreports.qt.io/browse/QTBUG-49892)
    from PyQt5.QtWidgets import QApplication  # to create empty application instance to avoid seg fault
    from PyQt5.QtGui import QFont, QFontMetrics
# some functions were moved to different modules from PyQt4 to PyQt5

    from PyQt5.QtWidgets import (QGraphicsRectItem,
                             QGraphicsLineItem,
                             QGraphicsPolygonItem, QGraphicsEllipseItem, QGraphicsSimpleTextItem, QGraphicsTextItem,
                             QGraphicsItem)
    from PyQt5.QtGui import (QPen, QColor, QBrush, QPolygonF, QFont,
                             QPixmap, QFontMetrics, QPainter,
                             QRadialGradient)

    from PyQt5.QtCore import Qt, QPointF, QRect, QRectF
except ImportError as err:
    print(err)  # none of the rest was tested
    from PyQt4.QtWidgets import QApplication
    from PyQt4.QtGui import (QFont, QFontMetrics)
    print("Using PyQt4")

    from PyQt4.QtGui import (QGraphicsRectItem, QGraphicsLineItem,
                             QGraphicsPolygonItem, QGraphicsEllipseItem,
                             QPen, QColor, QBrush, QPolygonF, QFont,
                             QPixmap, QFontMetrics, QPainter,
                             QRadialGradient, QGraphicsSimpleTextItem, QGraphicsTextItem,
                             QGraphicsItem)  
    from PyQt4.QtCore import Qt,  QPointF, QRect, QRectF

# also taken from source:
try:
    from numpy import isfinite as _isfinite, ceil
except ImportError:
    pass
else:
    isfinite = lambda n: n and _isfinite(n)

# function to get text size via Pillow:
def get_pil_text_size(text, font_size, font_name):
    font = ImageFont.truetype(font_name, font_size)
    size = font.getsize(text)
    return size

# now modifying TextFace._load_bounding_rect()  # <-- THIS is where the problem happens
class TextFace(TextFace):
    def _load_bounding_rect(self, txt=None):
        if txt is None:
            txt= self.get_text()
        fm = QFontMetrics(self._get_font())
        # fudge_factor = 1 #0.675 <-- this was to fix the spacing issue using an empirically-determined "fudge factor" NOTE: This can be noticeably font-specific!
        #tx_w = fm.width(txt) * fudge_factor   # *0.69     
        # 0.668 for Times 10pt, 0.688 18pt Verdana  
        # tx_w = sum([fm.widthChar(c)*0.6666 for c in txt]) # was testing if the character widths alone were fine...they're not

        tx_w = get_pil_text_size(txt, self._get_font().pointSize(), f"/mnt/c/Windows/Fonts/verdana.ttf")[0]*(4/3) # 4/3 must be px-point conversion  # this fixes the problem exactly!

        sys.stdout.write(f"width of {txt}: {fm.width(txt)}, m is {fm.widthChar('m')}, PIL m is {get_pil_text_size('m', self._get_font().pointSize(), f'/mnt/c/Windows/Fonts/times.ttf')[0]}, after scaling {tx_w}\n")  # to get measurements for debugging, don't need
        sys.stdout.flush()#

        #print(f"height of {txt}: {fm.height()}")

        if self.tight_text:
            textr = fm.tightBoundingRect(self.get_text())#
            down = textr.height() + textr.y()
            up = textr.height() - down
            asc = fm.ascent()
            des = fm.descent()
            center = (asc + des) / 2.0
            xcenter = ((up+down)/2.0) + asc - up
            self._bounding_rect = QRectF(0, asc - up, tx_w, textr.height())  # there's a vertical scaling issue here for later ## PS: no vertical scaling issue if using ImageFont approach
            self._real_rect = QRectF(0, 0, tx_w, textr.height())
        else:
            textr = fm.boundingRect(QRect(0, 0, 0, 0), 0, txt) # see issue 241  # newline issue

            self._bounding_rect = QRectF(0, 0, tx_w, textr.height())  # replaced textr.width()*fudge_factor with tx_w
            self._real_rect = QRectF(0, 0, tx_w, textr.height())   # replaced textr.width() with tx_w  # this doesn't seem to do anything and might be incorrect

Then I call this in a minimal example script that looks like this:

from ete3 import Tree, TreeStyle, NodeStyle, faces #TextFace
from qt_rendering_fix import TextFace  # import modified TextFace instead

try:  # workaround for PyQt SVG textbox scaling error (https://bugreports.qt.io/browse/QTBUG-49892)
    from PyQt5.QtWidgets import QApplication  # to create empty application instance to avoid seg fault
    from PyQt5.QtGui import QFont, QFontMetrics
except ImportError:
    from PyQt4.QtWidgets import QApplication
    from PyQt4.QtGui import (QFont, QFontMetrics)
    print("Using PyQt4")

foo = QApplication(["foo"])  # to get around a weird segfault issue...

t = Tree("(A:17, (bb:31, obnoxiously_long_name_that_goes_on:22):7," 
        "an_even_dumber_and_longer_name_lalala_seriously_no_phylogenetics_software_would_accept_this|oooh|a_pipe:22,"

         "(bar:33, C:12):40);")

def quick_style(node):
    leaf_text_1 = TextFace(node.name, fsize=42, ftype="Verdana")  # changing font here MUST be accompanied by changing font path for ImageFont! This is far from ideal...
    leaf_text_2 = TextFace("x")
    #leaf_text_1.tight_text = True
    node.add_face(face=leaf_text_1, column=1)
    node.add_face(face=leaf_text_2, column=2)

tree_style = TreeStyle()

tree_style.layout_fn = quick_style
tree_style.show_leaf_name = False

t.render("example.svg", tree_style=tree_style, units="px")

Overall, I suspect the ideal bug fix would be something like tx_w = fm.width(txt) becoming tx_w = fm.horizontalAdvance(txt) after upgrading the PyQt version, but that's just an untested suggestion.

Interestingly, taking ImageFont width for 'm', multiplying by 4/3, and dividing by QFontMetrics.width for the same 'm' gets us our 'fudge_factor' exactly!

This is far more than I've ever wanted to know about measuring text boxes...


Here's the extreme example (with very tiny x's, in the right place):
image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant