FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Should we use font metrics for line height instead of "lp"? · Issue #31220 · matplotlib/matplotlib · GitHub

Repository navigation

Should we use font metrics for line height instead of "lp"? #31220

Closed

Description

Currently, we set a minimum line height by measuring the height and depth of the "lp" string. Line spacing is then some multiple of the line height (by default 1.2×).

# Full vertical extent of font, including ascenders and descenders:
_, lp_h, lp_d = _get_text_metrics_with_cache(
renderer, "lp", self._fontproperties,
ismath="TeX" if self.get_usetex() else False,
dpi=self.get_figure(root=True).dpi)
min_dy = (lp_h - lp_d) * self._linespacing

Looking back into the history, this used to be "Tglp":
# Find full vertical extent, including ascenders and descenders:
tmp, heightt = renderer.get_text_width_height(
'Tglp', self._fontproperties, ismath=False)

and before that just a "T" was used for line height:
tmp, heightt = renderer.get_text_width_height(
'T', self._fontproperties, ismath=False)

and before that, the "T" was used only for empty lines:
if not len(line) and not self.is_math_text():
# approx the height of empty line with tall char
tmp, h = renderer.get_text_width_height(
'T', self._fontproperties, ismath=False)

Now that we are supporting more fonts and languages, this may not be ideal. For example, for the test in #30725, I only specified the OpenMoji font. This font contains only emoji and no Latin characters of any kind. This causes warnings that both l and p are not found even though they aren't used in any user strings. We then end up with the ascender/descender values for the Last Resort font. I haven't found a specific example, but I can imagine a user may be setting some language-specific font that contains no Latin characters and no fallback, and would trigger this warning as well, which might be a bit mysterious.

Microsoft's recommendation is to use the ascender value to measure from top to baseline and descender value from baseline to bottom, plus a line gap to produce the interline spacing (suggested to add up to 120% the height similar to our default.) Of course, fonts being fonts, there are 2 other sets of vertical metrics and various strategies for dealing with them.

Regardless of the multiple values to try, from the history, I don't think we went with the measurement strategy because it was thought that the embedded values were unreliable. Rather, it seems to have been from necessity of not having a font parser.

So the question now goes (with some of my thoughts in parentheses):

  1. Should we move to font metrics instead? (I think so.)
  2. If so, which strategy to use? (probably the webfont or Google ones.)
  3. Should we still try to measure lp and set some combined minimum or just trust the fonts aren't broken? (hopefully not required.)
  4. What do we do when fallbacks are specified? Currently we get the measurement from the first to match the "lp" string. Should we instead look at all the specified fonts and take a minimum of all of them (even if not used in the text)? Or measure all the fonts used in a line? Or measure all the fonts used in an entire text object? (probably not the first option, unsure whether the second or third.)

Activity

  1. added this to the v3.11.0 milestone on Mar 1, 2026
  2. moved this from Waiting for other PR to In Progress in Font and text overhaulon Mar 1, 2026
  3. anntzer commented on Mar 1, 2026

    Contributor

    1. yes, 2. webfont or Google looks good (from reading your link, not from any original research), 3. let's hope not, but I guess this should be reasonably pluggable? 4. probably all fonts actually used in a text object, to have consistent interline spacing?

  4. QuLogic commented on Mar 2, 2026

    MemberAuthor

    I suppose the troublesome part here is figuring out the API to assess this across the backends. Currently, the measurement is done by the backend and the actual ft2font objects are not exposed to Text (or possibly may not exist for some backends that embed the text directly.)

    For font metrics, we could extend get_text_width_height_descent to accept None as the string to measure and have that return the global metrics. It's a bit of a type mixing, but at least arguable, though I'm not sure it's so easy to inspect/fallback for other backends.

    Trying to figure out the used fonts seems like a whole new API, or some kind of change to the measurements.

    Perhaps, it would be better to extend the backend API to process multiple lines itself instead of in Text, and it could decide how to handle everything? On the other hand, there's also multi-line alignment to consider and maybe we don't want to push all that work to the backend.

    While trying to implement this, I do think that we need to work out some API for the backends, and this might require some design work or trial-and-error. Perhaps as a stop-gap for now, we can capture the warnings about l/p and if that occurs, set the minimums to 0? This does mean that multiple lines may not be consistent, but at least they won't be spaced according to the (mostly invisible to the user) Last Resort font, and users won't get a mysterious warning. Then we can spend some time working out how exactly to implement the desired behaviour.

  5. anntzer commented on Mar 2, 2026

    Contributor

    Good points. It looks like we're headed towards just passing a Text instance to the backend and let it do whatever it needs with it (renderer.draw_text_instance(textobj, ...) where it may be good to think carefully about what extra arguments (if any) we want to pass in ..., given that the implementation of draw_text_instance can already access all textobj properties). This is something we already discussed in #29695, and already how the pgf backend handles alignment (#1081). I was a bit reluctant to go that way, but perhaps we should indeed push all the work to the backends...

  6. tacaswell commented on Mar 3, 2026

    Member

    How long will this take to sort out? 3.11 is already way later than we wanted and I'm worried about adding another big re-design step to it. We have a history of letting the perfect be the enemy of the good.

    If this is less than a week, then lets do it, if more, then what is the cost of leaving this as-is for now and then re-doing this in a year? If it is "regenerate a dozen images" lets delay, if it is "regenerate every image with text" lets do it now.

  7. QuLogic commented on Mar 3, 2026

    MemberAuthor

    I made a silly attempt of just multiplying lp_h and lp_d by 2:

    diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py
    index 403baefa39..7a3d330ee3 100644
    --- a/lib/matplotlib/text.py
    +++ b/lib/matplotlib/text.py
    @@ -438,6 +438,8 @@ class Text(Artist):
                 renderer, "lp", self._fontproperties,
                 ismath="TeX" if self.get_usetex() else False,
                 dpi=self.get_figure(root=True).dpi)
    +        lp_h *= 2
    +        lp_d *= 2
             lp_a = lp_h - lp_d
             min_dy = lp_a * self._linespacing

    which causes 1798 test failures, mostly caused by all ticks moving vertically to re-align. Of course, this is an extreme change and not reflective of what will really happen, but should be the upper bound of how many images could be affected.

  8. QuLogic commented on Mar 3, 2026

    MemberAuthor

    I put together a small test with #31226:

    from matplotlib.font_manager import FontProperties, fontManager, get_font
    from matplotlib.ft2font import LoadFlags
    
    FONTSIZE = 12
    DPI = 100
    
    fonts = fontManager._find_fonts_by_props(FontProperties())
    print(fonts)
    font = get_font(fonts)
    font.set_size(FONTSIZE, DPI)
    font.set_text('lp', 0, flags=LoadFlags.NO_HINTING)
    width, height = font.get_width_height()
    descent = font.get_descent()
    ascent = height - descent
    print(width/64, height/64, ascent/64, descent/64)
    
    unitsPerEm = font.get_sfnt_table('head')['unitsPerEm']
    os2 = font.get_sfnt_table('OS/2')
    sTypoAscender = os2['sTypoAscender']
    sTypoDescender = os2['sTypoDescender']
    sTypoLineGap = os2['sTypoLineGap']
    print(unitsPerEm, sTypoAscender, sTypoDescender, sTypoLineGap)
    
    print(sTypoAscender / unitsPerEm * FONTSIZE*DPI/72)
    print(sTypoDescender / unitsPerEm * FONTSIZE*DPI/72)

    which produces:

    [FontPath('.../lib/matplotlib/mpl-data/fonts/ttf/DejaVuSans.ttf', 0)]
    15.203125 16.140625 12.671875 3.46875
    2048 1556 -492 410
    12.662760416666666
    -4.00390625
    

    So the ascent for l is slightly bigger than the design size, but the descent for p is noticeably smaller than the design size by over half a pixel.

    As a first pass, I have taken those metrics and applied them using:

    diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py
    index 403baefa39..e69eb1c8ac 100644
    --- a/lib/matplotlib/text.py
    +++ b/lib/matplotlib/text.py
    @@ -433,12 +433,27 @@ class Text(Artist):
             xs = []
             ys = []
     
    -        # Full vertical extent of font, including ascenders and descenders:
    -        _, lp_h, lp_d = _get_text_metrics_with_cache(
    -            renderer, "lp", self._fontproperties,
    -            ismath="TeX" if self.get_usetex() else False,
    -            dpi=self.get_figure(root=True).dpi)
    -        lp_a = lp_h - lp_d
    +        # Determine full vertical extent of font, including ascenders and descenders:
    +        if not self.get_usetex():
    +            from .font_manager import fontManager, get_font
    +            fonts = fontManager._find_fonts_by_props(self._fontproperties)
    +            font = get_font(fonts)
    +            unitsPerEm = font.get_sfnt_table('head')['unitsPerEm']
    +            os2 = font.get_sfnt_table('OS/2')
    +            sTypoAscender = os2['sTypoAscender']
    +            sTypoDescender = os2['sTypoDescender']
    +            # sTypoLineGap = os2['sTypoLineGap']
    +            fontsize = self._fontproperties.get_size_in_points()
    +            dpi = self.get_figure(root=True).dpi
    +            lp_a = sTypoAscender / unitsPerEm * fontsize*dpi/72
    +            lp_d = -sTypoDescender / unitsPerEm * fontsize*dpi/72
    +            lp_h = lp_a + lp_d
    +        else:
    +            _, lp_h, lp_d = _get_text_metrics_with_cache(
    +                renderer, "lp", self._fontproperties,
    +                ismath="TeX" if self.get_usetex() else False,
    +                dpi=self.get_figure(root=True).dpi)
    +            lp_a = lp_h - lp_d
             min_dy = lp_a * self._linespacing
     
             for i, line in enumerate(lines):

    This bypasses the backend for measurement, but maybe that's okay until we can do further API refinement.

    Effectively, this will measure the first font, but could be extended to take the maximum of all requested fonts easily. I have not applied the line gap measurement as I haven't yet determined how best to square that with our line spacing setting.

    With this change, we get about 720 test image failures. These are all mostly subtle changes of half-pixel alignment, bounding boxes being a bit larger (affecting some FancyBboxPatch tests and most legend boxes), and more obviously any test with multiline text.

  9. QuLogic commented on Mar 3, 2026

    MemberAuthor

    Also an interesting thing I found is that most of the mathtext images changed, even the ones that are purely Computer Modern. It turns out since we don't check that text is pure math before hand, we don't measure the math font, only the default (DejaVu Sans).

    Of course, using the font metrics would change Computer Modern as well, had it been used before:

    [FontPath('/home/elliott/code/matplotlib/lib/matplotlib/mpl-data/fonts/ttf/cmr10.ttf', 0)]
    13.859375 14.796875 11.5625 3.234375
    2048 1444 -422 0
    11.751302083333334
    -3.4342447916666665
    0.0
    

    but it's slightly different from what DejaVu Sans would have been.

  10. QuLogic commented on Mar 12, 2026

    MemberAuthor

    I've spent some time reading CSS Inline Layout and CSS Fonts. We aren't of course using CSS or have all the same configuration, but it's probably the closest to an open standard that we can follow. A short summary of the relevant bits of the algorithm are:

    1. Firstly, lines that are split explicitly or due to wrapping become separate line boxes that are sized independently. (See this image.) So following this means we don't need to process the entire string to see all the fonts used to make some overall line height.
    2. There are many options for baseline alignment, but we probably won't bother with those right now.
    3. Ascent and descent should come from sTypoAscender and sTypoDescender from the OS/2 table, with fallback to Ascent / Descent from the hhea table.
    4. The font also has a line gap metric as sTypoLineGap from OS/2 or lineGap from hhea.
    5. If font metrics are not available, then there are some suggestions for synthesizing them. Interestingly, even though x-height is described as the height of the x, they suggest using the total height of an o to measure it. There aren't any suggestions for ascent/descent though.
    6. Calculation of the logical height of a line box depends on the line-height property:
      1. If 'normal', then it's the highest ascent to lowest descent, plus a half-leading around them (assuming the default leading value for the text-edge property.)
      2. If a length/percentage, then the ascent/descent is found from the "first available font", and half-leading is applied to create the desired line height.

    In contrast, what we seem to be doing seems to correspond to the following CSS options, with some differences:

    1. text-height defaults to 1.2 (but we call it linespacing.) However, unlike CSS' use of the first available font, we actually look at all glyphs, which would be more in line with it's 'normal' setting.
    2. text-edge is set to text instead of leading (except instead of the metrics, we look at the ascent/descent of the actual glyphs used, or "lp" in the first font.)

    If we want to be more consistent with CSS, we would have to:

    1. Add a 'normal' setting to what we call linespacing, and make it the default.
    2. Fetch the ascent/descent values from the font for glyphs used when in this mode, or fetch from the first available font otherwise.
    3. Apply leading around the line, either from the font metric, or from linespacing calculations.
  11. QuLogic commented on Apr 10, 2026

    MemberAuthor

    The text-overhaul branch is now merged to main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions


    Back | FazBrowse Home | New Git URL