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

Draw font text line gap seems incorrect #6469

Closed
TakWolf opened this issue Aug 1, 2022 · 10 comments
Closed

Draw font text line gap seems incorrect #6469

TakWolf opened this issue Aug 1, 2022 · 10 comments

Comments

@TakWolf
Copy link

TakWolf commented Aug 1, 2022

Python: 3.9
Pillow: 9.2.0

I use this font: ark-pixel-font-12px-otf v2022.07.05
https://github.com/TakWolf/ark-pixel-font/releases

The font has zero line_gap:
font_size = 12
upm = 1200
line_height = 1200 (ascent - descent)
line_gap = 0

(xml gen use https://github.com/fonttools/fonttools ttx tools)

  <hhea>
    <tableVersion value="0x00010000"/>
    <ascent value="1000"/>
    <descent value="-200"/>
    <lineGap value="0"/>      <----- This
    <advanceWidthMax value="1200"/>
    <minLeftSideBearing value="0"/>
    <minRightSideBearing value="0"/>
    <xMaxExtent value="1200"/>
    <caretSlopeRise value="1"/>
    <caretSlopeRun value="0"/>
    <caretOffset value="0"/>
    <reserved0 value="0"/>
    <reserved1 value="0"/>
    <reserved2 value="0"/>
    <reserved3 value="0"/>
    <metricDataFormat value="0"/>
    <numberOfHMetrics value="9437"/>
  </hhea>

and

  <OS_2>
    ............
    <sTypoAscender value="1000"/>
    <sTypoDescender value="-200"/>
    <sTypoLineGap value="0"/>     <----- and this
    <usWinAscent value="1000"/>
    <usWinDescent value="200"/>
    <ulCodePageRange1 value="00000000 00000000 00000000 00000000"/>
    <ulCodePageRange2 value="00000000 00000000 00000000 00000000"/>
    <sxHeight value="600"/>
    <sCapHeight value="800"/>
    <usDefaultChar value="0"/>
    <usBreakChar value="32"/>
    <usMaxContext value="0"/>
  </OS_2>

In pillow:

from PIL import ImageFont, Image, ImageDraw

if __name__ == '__main__':
    font = ImageFont.truetype('build/outputs/ark-pixel-12px-latin.otf', 12)
    image = Image.new('RGBA', (400, 400), (255, 255, 255))
    draw = ImageDraw.Draw(image)
    text = '方舟像\n素字体'

    text_size = draw.textsize(text, font=font, spacing=0)
    print(text_size)

    text_bbox = draw.textbbox((0, 0), text, font=font, spacing=0)
    print(text_bbox)

    draw.text((0, 0), text, fill=(0, 0, 0), font=font, spacing=0)
    image.save('build/outputs/test.png')

image

Actually there is -2 line_gap

result:

(36, 20)    -> expect (36, 24)
(0, 1, 36, 22) -> expect (_, _, 36, 24)
@radarhere radarhere changed the title Draw font text line gap seems not correct Draw font text line gap seems incorrect Aug 1, 2022
@TakWolf
Copy link
Author

TakWolf commented Aug 1, 2022

In css, the text draw is correct:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <style>
        * {
            margin: 0;
            padding: 0;
        }
        @font-face {
            font-family: Ark-Pixel-12px-latin;
            src: url("ark-pixel-12px-latin.otf");
        }
        .content {
            font-family: Ark-Pixel-12px-latin, serif;
            font-size: 12px;
            line-height: 12px; /* default is 12 */
        }
    </style>
</head>
<body>
<div class="content">
    方舟像<br>素字体
</div>
</body>
</html>

image

@TakWolf
Copy link
Author

TakWolf commented Aug 1, 2022

I view the src, found:

https://github.com/python-pillow/Pillow/blob/9.2.0/src/PIL/ImageDraw.py#L377-L388

def _multiline_spacing(self, font, spacing, stroke_width):
    # this can be replaced with self.textbbox(...)[3] when textsize is removed
    with warnings.catch_warnings():
        warnings.filterwarnings("ignore", category=DeprecationWarning)
        return (
            self.textsize(
                "A",
                font=font,
                stroke_width=stroke_width,
            )[1]
            + spacing
        )

is used by:

https://github.com/python-pillow/Pillow/blob/9.2.0/src/PIL/ImageDraw.py#L529

It seems that line_spacing = line_height + spacing

self.textsize(
    "A",
    font=font,
    stroke_width=stroke_width,
)[1]  #   <---- height
+ spacing

I use the same font (ark-pixel-font-12px-otf):

from PIL import ImageFont, Image, ImageDraw

if __name__ == '__main__':
    font = ImageFont.truetype('build/outputs/ark-pixel-12px-latin.otf', 12)
    image = Image.new('RGBA', (400, 400), (255, 255, 255))
    draw = ImageDraw.Draw(image)

    size_a = draw.textsize('A', font=font)
    print(f'A: {size_a}')
    size_fang = draw.textsize('方', font=font)
    print(f'方: {size_fang}')

or

from PIL import ImageFont

if __name__ == '__main__':
    font = ImageFont.truetype('build/outputs/ark-pixel-12px-latin.otf', 12)

    size_a = font.getsize('A')
    print(f'A: {size_a}')
    size_fang = font.getsize('方')
    print(f'方: {size_fang}')

result:

A: (6, 10)   ->  height incorrect , expect (6, 12), this is why every line lack 2px
方: (12, 12)

@TakWolf
Copy link
Author

TakWolf commented Aug 1, 2022

Source-han-sans

https://github.com/adobe-fonts/source-han-sans

from PIL import ImageFont

if __name__ == '__main__':
    font = ImageFont.truetype('build/outputs/SourceHanSansHW-Regular-SC.otf', 40)

    size_a = font.getsize('A')
    print(f'A: {size_a}')
    size_fang = font.getsize('方')
    print(f'方: {size_fang}')

result height not equal

A: (20, 47)
方: (40, 51)

@radarhere
Copy link
Member

@nulano are you interested in answering this one?

@nulano
Copy link
Contributor

nulano commented Aug 2, 2022

I'm going to call this a duplicate of #1646.

I agree that the current method for calculating line height is subpar, but I'm not sure what is a good way to fix this without breaking backwards compatibility.

Pillow uses FreeType internally to work with TrueType fonts. From FreeType documentation, the correct way to calculate the linespace (distance between two baselines) is linespace = ascent - descent + linegap (the descent height is expressed as a negative value in FreeType). The function font.getmetrics() returns a tuple of (ascent, -descent), while linegap is ignored by Pillow.

However, as you found, Pillow calculates the linespace as the height of the letter A plus a user-specified spacing (4 by default). The height of A approximates the ascent (it is exact when the letter A has no descender), while the spacing parameter is a guess for the descent and line_gap values.

>>> from PIL import ImageFont
>>> font = ImageFont.truetype(r"D:\Downloads\ark-pixel-font-12px-otf-v2022.07.05\ark-pixel-12px-latin.otf", 12)
>>> font.getmetrics()
(10, 2)
>>> font.getbbox("A")  # no descender, bottom coordinate is the ascent height
(0, 2, 6, 10)
>>> font.getbbox("Q")  # has descender, bottom coordinate is larger than ascent height
(0, 2, 6, 12)

To get the expected results, for now you should pass spacing = linespace - line_height to the font rendering functions, where line_height=font.getbbox("A")[3] and linespace=sum(font.getmetrics(), line_gap), with the line_gap value calculated without Pillow's help.

from PIL import Image, ImageDraw, ImageFont
font = ImageFont.truetype(r"D:\Downloads\ark-pixel-font-12px-otf-v2022.07.05\ark-pixel-12px-latin.otf", 12)
im = Image.new("RGBA", (50, 50), "white")
draw = ImageDraw.Draw(im)
text = '\u65B9\u821F\u50CF\n\u7D20\u5B57\u4F53'

line_height = font.getbbox("A")[3]
line_gap = 0  # looked up elsewhere
linespace = sum(font.getmetrics(), line_gap)
spacing = linespace - line_height
print(spacing)  # 2

bbox = draw.textbbox((0, 0), text, font=font, spacing=spacing)
print(bbox)  # (0, 1, 36, 24)

draw.text((0, 0), text, fill="black", font=font, spacing=spacing)
im.save(r"D:\Downloads\ark-pixel-font-12px-otf-v2022.07.05\test.png")

test

Note that the bottom value of textbbox might be different than 24 as it depends on the text being drawn. In your example, your text fills the descent height exactly, but other text might not (e.g. the letter A), or it might exceed it also (probably not with this font).

@TakWolf
Copy link
Author

TakWolf commented Aug 2, 2022

The clear fix code, I verified it is worked.

ascent, descent = font.getmetrics()
line_gap = 0  # need to be set manually
line_height = ascent + descent + line_gap  # the correct way
spacing = line_height - font.getsize('A')[1]

font.getsize('A')[1] is actually font.getbbox('A')[3] ?

Will the apis keeps for a long time? Otherwise we can just fix like:

def _multiline_spacing(self, font, spacing, stroke_width):
        return  sum(font.getmetrics(), spacing)

Although we are still lost font line_gap, but at least can set spacing, just the name different.

@nulano
Copy link
Contributor

nulano commented Aug 2, 2022

I have just looked at the FreeType API and it seems not to expose line_gap publically: https://freetype.org/freetype2/docs/reference/ft2-base_interface.html#ft_size_metrics

However, the parameter height in that structure seems to be the correct linespace value:

The height in 26.6 fractional pixels, rounded to an integer value. See FT_FaceRec [below] for the details.

This value is the vertical distance between two consecutive baselines, expressed in font units. It is always positive. Only relevant for scalable formats.

If you want the global glyph height, use ascender - descender.

This value is also not exposed by Pillow publically, but it is available via the private parameter font.font.height (but unused).

>>> font.font.height
12

@nulano
Copy link
Contributor

nulano commented Aug 2, 2022

font.getsize('A')[1] is actually font.getbbox('A')[3] ?

Yes. This is documented in the documentation of getsize():

Note

For historical reasons this function measures text height from the ascender line instead of the top, see Text anchors.

And also noted in a comment in the source code:

Pillow/src/PIL/ImageDraw.py

Lines 377 to 388 in 7591395

def _multiline_spacing(self, font, spacing, stroke_width):
# this can be replaced with self.textbbox(...)[3] when textsize is removed
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=DeprecationWarning)
return (
self.textsize(
"A",
font=font,
stroke_width=stroke_width,
)[1]
+ spacing
)


Will the apis keeps for a long time? Otherwise we can just fix like: ...

_multiline_spacing(self, font, spacing, stroke_width) is a private function, so there is no guarantee it will be kept forever. However, I expect it will be kept as-is until Pillow 10 (2023-07-01) when textsize will be removed. (no guarantees!)

Similarly, font.font.height is effectively private so no guarantees about it either. Edit: but it has been around for years, so it is "safer" to use.

@radarhere
Copy link
Member

@TakWolf are you happy for this to be closed as a duplicate of #1646?

@TakWolf TakWolf closed this as completed Aug 4, 2022
@radarhere
Copy link
Member

Thanks @nulano for your thoughts

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

No branches or pull requests

3 participants