Repository: python-pillow/Pillow

Issue:
Font spacing changes
### What did you do?

1. I'm using Pillow to create many individual images which I then stitch together to create a lyrics video for a song.
    - The app is online at https://www.rhymecraft.guru
1. One of the features of the videos created by my app is that, while showing an entire line of lyrics on-screen at once, the currently-pronounced syllable can be highlighted.
    - You can see an example here: https://www.youtube.com/watch?v=9u9ylseLAfU
1. The way I achieve this is by:
    1. first calculating where the X, Y coordinates of the line would start if the entire line of lyrics was drawn at once
    1. then drawing the text *preceding* the highlighted syllable and recording the width of that text,
    1. then drawing the highlighted syllable, using the original X, Y coordinates and the width of the preceding text to calculate where the highlighted syllable's X, Y coordinates should start,
    1. then proceeding in the same way to draw the text following the highlighted syllable.

### What did you expect to happen?

I expected that the current syllable would change color without any other visible changes.

### What actually happened?

The text spacing always doesn't seem to work totally right, so the lyrics sometimes get spaced out.

You can see it:
- caused by the word 'of' at 0:47: https://www.youtube.com/watch?v=9u9ylseLAfU&t=47s
- in the word 'paradise' at 1:03: https://www.youtube.com/watch?v=9u9ylseLAfU&t=1m3s

### What are your OS, Python and Pillow versions?

* OS: Windows 10 (local machine), Ubuntu (Digital Ocean)
* Python: 3.7
* Pillow: 5.0.0

Example code:

```python
from PIL import ImageDraw, Image, ImageFont

img = Image.new('RGBA', (200, 60), color=(0, 0, 0))
draw = ImageDraw.Draw(img)
font = ImageFont.truetype('arial.ttf', size=48)
first_font_color = (255, 0, 0)
second_font_color = (255, 255, 255)

draw.text((0, 0), 'paradise', first_font_color, font=font)

draw.text((0, 0), 'par', second_font_color, font=font)
par_width, par_height = draw.textsize('par', font=font)
draw.text((0 + par_width, 0), 'adise', second_font_color, font=font)

img.save('example.png')
```

There's first a red 'paradise' created, then a white 'paradise' created in the same spot.  If you zoom in you'll notice that the red one shows up underneath the white one starting with the 'a' and then that continues for the 'dise':

![image](https://user-images.githubusercontent.com/1755791/61449529-fdd67180-a909-11e9-8dca-db3bdd0fa883.png)



Discussion:
Could you put together a simple script demonstrating the problem?
@radarhere I've updated the first post with a simple example.
What do you think of working from the end instead, subtracting the width of 'adise' from the width of 'paradise'?

```python
from PIL import ImageDraw, Image, ImageFont

img = Image.new('RGBA', (200, 60), color=(0, 0, 0))
draw = ImageDraw.Draw(img)
font = ImageFont.truetype('arial.ttf', size=48)
first_font_color = (255, 0, 0)
second_font_color = (255, 255, 255)

draw.text((0, 0), 'paradise', first_font_color, font=font)

draw.text((0, 0), 'par', second_font_color, font=font)
par_width = draw.textsize('paradise', font=font)[0] - draw.textsize('adise', font=font)[0]
draw.text((0 + par_width, 0), 'adise', second_font_color, font=font)

img.save('example.png')
```

As to why this works better, I think it's because not all gaps between letters are the same. If you look at the pixels, there is zero gap between 'r' and the following 'a', but there are 3 pixels between the second 'a' and the following 'd'. On an abstract level, I think this also better describes your goal - you're not writing 'adise' after 'par' - you're writing 'aside' at the end of 'paradise'.
@radarhere Thanks for the suggestion, I'll give that a try.  I didn't think to try that because I didn't know it would make a difference.  I'll close the ticket for now and reopen it if it doesn't work for some reason (although I can see that for this example it does work).
That proposed fix does not seem to work in every case.  As an example, consider the string "Ayo", with the "A" and "yo" being drawn separately:

```python
from PIL import ImageDraw, Image, ImageFont

img = Image.new('RGBA', (200, 60), color=(0, 0, 0))
draw = ImageDraw.Draw(img)
font = ImageFont.truetype('arial.ttf', size=48)
first_font_color = (255, 0, 0)
second_font_color = (255, 255, 255)

draw.text((0, 0), 'Ayo', first_font_color, font=font)

draw.text((0, 0), 'A', second_font_color, font=font)
par_width = draw.textsize('Ayo', font=font)[0] - draw.textsize('yo', font=font)[0]
draw.text((0 + par_width, 0), 'yo', second_font_color, font=font)

img.save('example.png')
```
Here's the result:
![image](https://user-images.githubusercontent.com/1755791/61576027-043f2780-aa89-11e9-8512-28a3720a9be5.png)

I'm not able to replicate this on my macOS machine. Does this happen for you on Ubuntu?

While unlikely, I don't suppose that the problem is just that some of the pixels being drawn are translucent?
@radarhere I just confirmed it's happening on my Digital Ocean (Ubuntu) machine.

The issue seems to be the kerning between the letters 'A' and 'y'.  I tried a fix where I would manually subtract from the preceding_text_width if the preceding_text_width + following_text_width was larger than the full_text_width (as recommended [here](https://stackoverflow.com/a/2106256/4115031)), but that also does not work consistently, as different letter pairs seem to have different amounts of kerning even after making that adjustment.
You may or may not be interested in this - as a workaround for your situation, instead of drawing 'Ayo' in red and then 'A' and 'yo' in white and finding they don't quite match, you could instead draw 'A' and 'yo' in red and then 'A' and 'yo' in white, always breaking the writing up into the final segments.
@radarhere That workaround had occurred to me, and I'll keep it in mind, but I'm going to see if I can make it work with the kerning.  I looked around and I think I found a way to extract the kerning values from the font's TTF file using the 'fontforge' library, and I'm going to see if I can use that to reliably adjust the distance between the two characters.
Despite not being able to replicate, how's this for another idea?

If the problem is that the 'y' in 'Ayo' is moved right by the presence of the 'A', then what if we always wrote 'Ayo', and cropped and pasted the result to get the effect?

```python
from PIL import ImageDraw, Image, ImageFont

img = Image.new('RGBA', (200, 220), color=(0, 0, 0))

draw = ImageDraw.Draw(img)
font = ImageFont.truetype('Arial.ttf', size=48)
first_font_color = (255, 0, 0)
second_font_color = (255, 255, 255)

for i, parts in enumerate([
	['Ayo',''],
	['A','yo'],
	['Ay','o'],
	['','Ayo']
]):
	first, second = parts
	x = 0
	y = i*50
	if first and second:
		draw.text((x, y), 'Ayo', first_font_color, font=font)

		im2 = Image.new('RGBA', (200, 220))
		draw2 = ImageDraw.Draw(im2)
		draw2.text((0, 0), 'Ayo', second_font_color, font=font)
		w = draw2.textsize('Ayo', font=font)[0] - draw2.textsize(second, font=font)[0]
		cropped_im = im2.crop((w, 0, 220, 220))
		img.paste(cropped_im, (x+w, y), cropped_im)
	elif first:
		draw.text((x, y), 'Ayo', first_font_color, font=font)
	else:
		draw.text((x, y), 'Ayo', second_font_color, font=font)

img.save('example.png')
```
Thank you for the suggestion!  One issue with this method is that sometimes one of the letters at the border will have a mix of the two colors (see the example below, I suspect the issue will be more dramatic in, for example, script-style fonts, which I'd like to be able to support):

![image](https://user-images.githubusercontent.com/1755791/63211821-4623b380-c0b1-11e9-878b-97ff69c06894.png)

I've taken a short break from this problem but my current idea is still to try to use pangocairocffi to solve this, see the linked cairocffi thread to see the problem I'm currently dealing with with that library (not that it concerns you; just if you're interested).
Ok. If you're looking into other libraries, can this issue be closed for Pillow, or are you still interested in seeing a solution here?
@radarhere Yeah it's fine to close it, I'll reopen it if the other library doesn't work.  But it would be nice if Pillow had this functionality.
The problem, at least for me, is that I can't replicate this - for my machine, this can be done with Pillow.

If you've moved on from using Pillow for this, then maybe you aren't interested in hearing my hit-and-miss ideas on how to address this - I don't currently have another way to figure out if this is solved apart from talking here. If you haven't moved on, then you can re-open this issue.
@radarhere I would prefer to use Pillow since it would involve changing less of my code, but after learning more about kerning it seems to me that this problem/use-case is one that would require new code/features added to Pillow to get it working.

When you say you can't replicate it, does that include the "VA" example I gave above?
When I run my code with 'VA', there is a problem, but the pixels do not exactly match yours. However, that seems irrelevant, since that approach requires separation between the letters, and it now sounds like you want to use slanted text.
Just to be clear: I don't want to *solely* use slanted text, but I do want to be able to *support* slanted text.  I want to be able to support all of the Google free fonts.
I'm pretty sure the cause of this issue is that sometimes glyphs extend outside their advance width.

I'll use the terms from the following diagram to explain this:
![Glyph metrics (FreeType tutorial)](https://www.freetype.org/freetype2/docs/tutorial/metrics.png)
(from https://www.freetype.org/freetype2/docs/tutorial/step2.html#section-1)

The origin refers to the position at which a glyph is supposed to be rendered. For the first glyph, this refers to the xy parameter of the `draw.text` call, for subsequent glyphs it is incremented by the `advance` value. Typically, `getsize` should return the distance from the first origin to the last origin (sum of glyph advance values). There are two exceptions:
* If the first glyph has a negative bearingX. In this case, the C code will add the amount by which the glyph extends in front of its origin to the width, but it will also return the amount by which it is adjusted, which is then subtracted in Python code. ~**Therefore this case doesn't cause issues.**~ **Edit: In #4789 I realized that this value is added *twice* in C code. To account for this, you have to add the (negative) X offset from `getoffset` to the returned width.**
* If bearingX plus width of the last glyph sum to a value larger than its advance. In this case C code adds the extra margin to the returned width, but it is not subtracted in Python code. This offset makes it impossible to use Pillow to reliably measure text width. *Specifically, the glyph for 'r' in Arial has (rounded) bearingX=3px, width=14px, advance=16px. This gives an error of (14+3)-16=1px.* **I'm not sure if there is a good way to handle this with the current API while preserving backwards compatibility without adding a new function. https://github.com/python-pillow/Pillow/pull/784#issuecomment-48213245 looks like a good suggestion.**

Kerning could also affect this spacing, but the current implementation looks buggy to me. Specifically, kerning is being scaled twice, once in the basic layout function and a second time in the getsize and render functions. The effect is that the delta is scaled to 1/64 of the intended offset. The PIXEL macro call on the following lines looks unnecessary and wrong (added in #2576 with #2284):
https://github.com/python-pillow/Pillow/blob/394f7a04495ec63b66ed28d3b1c93e68e1d6af03/src/_imagingft.c#L568-L569


*Note that there are further complications if you use Raqm layout instead of basic layout, see https://github.com/python-pillow/Pillow/issues/4483#issuecomment-617319843.*
With the code from the original post, on my machine at least, I get
![original](https://user-images.githubusercontent.com/3112309/124383763-8e820400-dd11-11eb-9e17-64154ebc8132.png)

If I replace
```python
par_width, par_height = draw.textsize('par', font=font)
```
with
```python
par_width = draw.textlength('par', font=font)
```
I get
![example](https://user-images.githubusercontent.com/3112309/124383774-a22d6a80-dd11-11eb-953b-57db09e9cd99.png)

Is that it?
@radarhere Thank you for letting me know about `textlength`!  I switched the code to use it, and while the wiggling is significantly less than before, it's still wiggling.  You can see an example here: https://youtu.be/LcNC5JRNL3o
I've figured out the last piece, creating PR #6722 to resolve this.

Using `textlength` from my previous comment with that PR, the red vertical line at the end of 'i' is gone.

![example](https://user-images.githubusercontent.com/3112309/200427616-f94950db-a1cb-4217-a302-17a7c7665fe3.png)

Resolving patch:
diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py
--- a/src/PIL/ImageDraw.py
+++ b/src/PIL/ImageDraw.py
@@ -452,7 +452,11 @@ def draw_text(ink, stroke_width=0, stroke_offset=None):
             mode = self.fontmode
             if stroke_width == 0 and embedded_color:
                 mode = "RGBA"
-            coord = xy
+            coord = []
+            start = []
+            for i in range(2):
+                coord.append(int(xy[i]))
+                start.append(math.modf(xy[i])[0])
             try:
                 mask, offset = font.getmask2(
                     text,
@@ -463,6 +467,7 @@ def draw_text(ink, stroke_width=0, stroke_offset=None):
                     stroke_width=stroke_width,
                     anchor=anchor,
                     ink=ink,
+                    start=start,
                     *args,
                     **kwargs,
                 )
@@ -478,6 +483,7 @@ def draw_text(ink, stroke_width=0, stroke_offset=None):
                         stroke_width,
                         anchor,
                         ink,
+                        start=start,
                         *args,
                         **kwargs,
                     )
@@ -490,7 +496,7 @@ def draw_text(ink, stroke_width=0, stroke_offset=None):
                 # extract mask and set text alpha
                 color, mask = mask, mask.getband(3)
                 color.fillband(3, (ink >> 24) & 0xFF)
-                x, y = (int(c) for c in coord)
+                x, y = coord
                 self.im.paste(color, (x, y, x + mask.size[0], y + mask.size[1]), mask)
             else:
                 self.draw.draw_bitmap(coord, mask, ink)
diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py
--- a/src/PIL/ImageFont.py
+++ b/src/PIL/ImageFont.py
@@ -26,6 +26,7 @@
 #
 
 import base64
+import math
 import os
 import sys
 import warnings
@@ -588,6 +589,7 @@ def getmask(
         stroke_width=0,
         anchor=None,
         ink=0,
+        start=None,
     ):
         """
         Create a bitmap for the text.
@@ -647,6 +649,11 @@ def getmask(
 
                          .. versionadded:: 8.0.0
 
+        :param start: Tuple of horizontal and vertical offset, as text may render
+                      differently when starting at fractional coordinates.
+
+                         .. versionadded:: 9.4.0
+
         :return: An internal PIL storage memory instance as defined by the
                  :py:mod:`PIL.Image.core` interface module.
         """
@@ -659,6 +666,7 @@ def getmask(
             stroke_width=stroke_width,
             anchor=anchor,
             ink=ink,
+            start=start,
         )[0]
 
     def getmask2(
@@ -672,6 +680,7 @@ def getmask2(
         stroke_width=0,
         anchor=None,
         ink=0,
+        start=None,
         *args,
         **kwargs,
     ):
@@ -739,6 +748,11 @@ def getmask2(
 
                          .. versionadded:: 8.0.0
 
+        :param start: Tuple of horizontal and vertical offset, as text may render
+                      differently when starting at fractional coordinates.
+
+                         .. versionadded:: 9.4.0
+
         :return: A tuple of an internal PIL storage memory instance as defined by the
                  :py:mod:`PIL.Image.core` interface module, and the text offset, the
                  gap between the starting coordinate and the first marking
@@ -750,12 +764,23 @@ def getmask2(
         size, offset = self.font.getsize(
             text, mode, direction, features, language, anchor
         )
-        size = size[0] + stroke_width * 2, size[1] + stroke_width * 2
+        if start is None:
+            start = (0, 0)
+        size = tuple(math.ceil(size[i] + stroke_width * 2 + start[i]) for i in range(2))
         offset = offset[0] - stroke_width, offset[1] - stroke_width
         Image._decompression_bomb_check(size)
         im = fill("RGBA" if mode == "RGBA" else "L", size, 0)
         self.font.render(
-            text, im.id, mode, direction, features, language, stroke_width, ink
+            text,
+            im.id,
+            mode,
+            direction,
+            features,
+            language,
+            stroke_width,
+            ink,
+            start[0],
+            start[1],
         )
         return im, offset
 


Test patch:
diff --git a/Tests/images/test_anchor_multiline_mm_right.png b/Tests/images/test_anchor_multiline_mm_right.png
Binary files a/Tests/images/test_anchor_multiline_mm_right.png and b/Tests/images/test_anchor_multiline_mm_right.png differ
diff --git a/Tests/images/test_combine_multiline_lm_center.png b/Tests/images/test_combine_multiline_lm_center.png
Binary files a/Tests/images/test_combine_multiline_lm_center.png and b/Tests/images/test_combine_multiline_lm_center.png differ
diff --git a/Tests/images/test_combine_multiline_lm_left.png b/Tests/images/test_combine_multiline_lm_left.png
Binary files a/Tests/images/test_combine_multiline_lm_left.png and b/Tests/images/test_combine_multiline_lm_left.png differ
diff --git a/Tests/images/test_combine_multiline_lm_right.png b/Tests/images/test_combine_multiline_lm_right.png
Binary files a/Tests/images/test_combine_multiline_lm_right.png and b/Tests/images/test_combine_multiline_lm_right.png differ
diff --git a/Tests/images/test_combine_multiline_mm_center.png b/Tests/images/test_combine_multiline_mm_center.png
Binary files a/Tests/images/test_combine_multiline_mm_center.png and b/Tests/images/test_combine_multiline_mm_center.png differ
diff --git a/Tests/images/test_combine_multiline_mm_left.png b/Tests/images/test_combine_multiline_mm_left.png
Binary files a/Tests/images/test_combine_multiline_mm_left.png and b/Tests/images/test_combine_multiline_mm_left.png differ
diff --git a/Tests/images/test_combine_multiline_mm_right.png b/Tests/images/test_combine_multiline_mm_right.png
Binary files a/Tests/images/test_combine_multiline_mm_right.png and b/Tests/images/test_combine_multiline_mm_right.png differ
diff --git a/Tests/images/test_combine_multiline_rm_center.png b/Tests/images/test_combine_multiline_rm_center.png
Binary files a/Tests/images/test_combine_multiline_rm_center.png and b/Tests/images/test_combine_multiline_rm_center.png differ
diff --git a/Tests/images/test_combine_multiline_rm_left.png b/Tests/images/test_combine_multiline_rm_left.png
Binary files a/Tests/images/test_combine_multiline_rm_left.png and b/Tests/images/test_combine_multiline_rm_left.png differ
diff --git a/Tests/images/test_combine_multiline_rm_right.png b/Tests/images/test_combine_multiline_rm_right.png
Binary files a/Tests/images/test_combine_multiline_rm_right.png and b/Tests/images/test_combine_multiline_rm_right.png differ
diff --git a/Tests/images/text_float_coord.png b/Tests/images/text_float_coord.png
Binary files a/Tests/images/text_float_coord.png and b/Tests/images/text_float_coord.png differ
diff --git a/Tests/images/text_float_coord_1_alt.png b/Tests/images/text_float_coord_1_alt.png
Binary files a/Tests/images/text_float_coord_1_alt.png and b/Tests/images/text_float_coord_1_alt.png differ
diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py
--- a/Tests/test_imagedraw.py
+++ b/Tests/test_imagedraw.py
@@ -1238,6 +1238,27 @@ def test_stroke_descender():
     assert_image_similar_tofile(im, "Tests/images/imagedraw_stroke_descender.png", 6.76)
 
 
+@skip_unless_feature("freetype2")
+def test_split_word():
+    # Arrange
+    im = Image.new("RGB", (230, 55))
+    expected = im.copy()
+    expected_draw = ImageDraw.Draw(expected)
+    font = ImageFont.truetype("Tests/fonts/FreeMono.ttf", 48)
+    expected_draw.text((0, 0), "paradise", font=font)
+
+    draw = ImageDraw.Draw(im)
+
+    # Act
+    draw.text((0, 0), "par", font=font)
+
+    length = draw.textlength("par", font=font)
+    draw.text((length, 0), "adise", font=font)
+
+    # Assert
+    assert_image_equal(im, expected)
+
+
 @skip_unless_feature("freetype2")
 def test_stroke_multiline():
     # Arrange