Repository: python-pillow/Pillow
Pull request: Added text stroking
Resolves #2209. Replacement for #2224

Adds `strokeWidth` and `strokeFill` arguments, such that -

```python
from PIL import Image, ImageDraw, ImageFont

font = ImageFont.truetype("Tests/fonts/FreeMono.ttf", 40)
font.getsize_multiline("A", strokeWidth=2)
font.getsize("ABC\nAaaa", strokeWidth=2)

im = Image.new("RGB", (100, 100))
draw = ImageDraw.Draw(im)
draw.textsize("A", font, strokeWidth=2)
draw.multiline_textsize("ABC\nAaaa", font, strokeWidth=2)
draw.text((10, 10), "A", "#f00", font, strokeWidth=2, strokeFill="#0f0")
draw.multiline_text((10, 10), "A\nB", "#f00", font, strokeWidth=2, strokeFill="#0f0")
```

Uses FreeType's stroking. See https://www.freetype.org/freetype2/docs/reference/ft2-glyph_stroker.html for more information, and part of https://www.freetype.org/freetype2/docs/tutorial/example2.cpp for example code.

Also renames a FT_GlyphSlot variable from 'glyph' to 'glyphSlot'.

Problem statement:
ImageFont/ImageDraw should support text stroking
Hey,

Something that I find myself doing quite often when drawing text to an image is to stroke the text with a high-contrast outline.
I find this helps improve readability over cluttered backgrounds.

Neither pillow.ImageDraw nor pillow.ImageFont currently includes a function while allows ImageDraw to draw stroked text with a specific color outline.

It'd be pretty cool if ImageDraw.text() would include two additional method parameters.

- One to indicate the size of the outline in pixels
- One to indicate the colour of the outline (similar to the current fill parameter)

For example:

``draw.text((10, 10), "hello", font=font, outline=BLACK, outline_size=2)``

Hints from issue discussion:
https://github.com/rougier/freetype-py have a few examples doing that.
I agree, `ImageFont` could use some love! :s

Commit message: Added text stroking

Patch to review:
diff --git a/Tests/images/imagedraw_stroke_different.png b/Tests/images/imagedraw_stroke_different.png
new file mode 100644
index 00000000000..e58cbdc4e23
Binary files /dev/null and b/Tests/images/imagedraw_stroke_different.png differ
diff --git a/Tests/images/imagedraw_stroke_multiline.png b/Tests/images/imagedraw_stroke_multiline.png
new file mode 100644
index 00000000000..fc5e07c8679
Binary files /dev/null and b/Tests/images/imagedraw_stroke_multiline.png differ
diff --git a/Tests/images/imagedraw_stroke_same.png b/Tests/images/imagedraw_stroke_same.png
new file mode 100644
index 00000000000..8f2f3abe1a6
Binary files /dev/null and b/Tests/images/imagedraw_stroke_same.png differ
diff --git a/Tests/images/test_direction_ttb_stroke.png b/Tests/images/test_direction_ttb_stroke.png
new file mode 100644
index 00000000000..3fa844e9a89
Binary files /dev/null and b/Tests/images/test_direction_ttb_stroke.png differ
diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py
index ffe35a4fa4f..ed4291f53ff 100644
--- a/Tests/test_imagedraw.py
+++ b/Tests/test_imagedraw.py
@@ -1,8 +1,8 @@
 import os.path
 
-from PIL import Image, ImageColor, ImageDraw
+from PIL import Image, ImageColor, ImageDraw, ImageFont, features
 
-from .helper import PillowTestCase, hopper
+from .helper import PillowTestCase, hopper, unittest
 
 BLACK = (0, 0, 0)
 WHITE = (255, 255, 255)
@@ -29,6 +29,8 @@
 
 KITE_POINTS = [(10, 50), (70, 10), (90, 50), (70, 90), (10, 50)]
 
+HAS_FREETYPE = features.check("freetype2")
+
 
 class TestImageDraw(PillowTestCase):
     def test_sanity(self):
@@ -771,6 +773,54 @@ def test_textsize_empty_string(self):
         draw.textsize("\n")
         draw.textsize("test\n")
 
+    @unittest.skipUnless(HAS_FREETYPE, "ImageFont not available")
+    def test_textsize_stroke(self):
+        # Arrange
+        im = Image.new("RGB", (W, H))
+        draw = ImageDraw.Draw(im)
+        font = ImageFont.truetype("Tests/fonts/FreeMono.ttf", 20)
+
+        # Act / Assert
+        self.assertEqual(draw.textsize("A", font, stroke_width=2), (16, 20))
+        self.assertEqual(
+            draw.multiline_textsize("ABC\nAaaa", font, stroke_width=2), (52, 44)
+        )
+
+    @unittest.skipUnless(HAS_FREETYPE, "ImageFont not available")
+    def test_stroke(self):
+        for suffix, stroke_fill in {"same": None, "different": "#0f0"}.items():
+            # Arrange
+            im = Image.new("RGB", (120, 130))
+            draw = ImageDraw.Draw(im)
+            font = ImageFont.truetype("Tests/fonts/FreeMono.ttf", 120)
+
+            # Act
+            draw.text(
+                (10, 10), "A", "#f00", font, stroke_width=2, stroke_fill=stroke_fill
+            )
+
+            # Assert
+            self.assert_image_similar(
+                im, Image.open("Tests/images/imagedraw_stroke_" + suffix + ".png"), 2.8
+            )
+
+    @unittest.skipUnless(HAS_FREETYPE, "ImageFont not available")
+    def test_stroke_multiline(self):
+        # Arrange
+        im = Image.new("RGB", (100, 250))
+        draw = ImageDraw.Draw(im)
+        font = ImageFont.truetype("Tests/fonts/FreeMono.ttf", 120)
+
+        # Act
+        draw.multiline_text(
+            (10, 10), "A\nB", "#f00", font, stroke_width=2, stroke_fill="#0f0"
+        )
+
+        # Assert
+        self.assert_image_similar(
+            im, Image.open("Tests/images/imagedraw_stroke_multiline.png"), 3.3
+        )
+
     def test_same_color_outline(self):
         # Prepare shape
         x0, y0 = 5, 5
diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py
index 8a23e6339a3..6a2d572a954 100644
--- a/Tests/test_imagefont.py
+++ b/Tests/test_imagefont.py
@@ -605,6 +605,21 @@ def test_imagefont_getters(self):
         self.assertEqual(t.getsize_multiline("ABC\nA"), (36, 36))
         self.assertEqual(t.getsize_multiline("ABC\nAaaa"), (48, 36))
 
+    def test_getsize_stroke(self):
+        # Arrange
+        t = self.get_font()
+
+        # Act / Assert
+        for stroke_width in [0, 2]:
+            self.assertEqual(
+                t.getsize("A", stroke_width=stroke_width),
+                (12 + stroke_width * 2, 16 + stroke_width * 2),
+            )
+            self.assertEqual(
+                t.getsize_multiline("ABC\nAaaa", stroke_width=stroke_width),
+                (48 + stroke_width * 2, 36 + stroke_width * 4),
+            )
+
     def test_complex_font_settings(self):
         # Arrange
         t = self.get_font()
diff --git a/Tests/test_imagefontctl.py b/Tests/test_imagefontctl.py
index afd45ce1982..5b88f94cced 100644
--- a/Tests/test_imagefontctl.py
+++ b/Tests/test_imagefontctl.py
@@ -115,6 +115,30 @@ def test_text_direction_ttb(self):
 
         self.assert_image_similar(im, target_img, 1.15)
 
+    def test_text_direction_ttb_stroke(self):
+        ttf = ImageFont.truetype("Tests/fonts/NotoSansJP-Regular.otf", 50)
+
+        im = Image.new(mode="RGB", size=(100, 300))
+        draw = ImageDraw.Draw(im)
+        try:
+            draw.text(
+                (25, 25),
+                "あい",
+                font=ttf,
+                fill=500,
+                direction="ttb",
+                stroke_width=2,
+                stroke_fill="#0f0",
+            )
+        except ValueError as ex:
+            if str(ex) == "libraqm 0.7 or greater required for 'ttb' direction":
+                self.skipTest("libraqm 0.7 or greater not available")
+
+        target = "Tests/images/test_direction_ttb_stroke.png"
+        target_img = Image.open(target)
+
+        self.assert_image_similar(im, target_img, 12.4)
+
     def test_ligature_features(self):
         ttf = ImageFont.truetype(FONT_PATH, FONT_SIZE)
 
diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst
index 5fac7914b6f..51eaf925ec6 100644
--- a/docs/reference/ImageDraw.rst
+++ b/docs/reference/ImageDraw.rst
@@ -255,7 +255,7 @@ Methods
 
     Draw a shape.
 
-.. py:method:: PIL.ImageDraw.ImageDraw.text(xy, text, fill=None, font=None, anchor=None, spacing=0, align="left", direction=None, features=None, language=None)
+.. py:method:: PIL.ImageDraw.ImageDraw.text(xy, text, fill=None, font=None, anchor=None, spacing=0, align="left", direction=None, features=None, language=None, stroke_width=0, stroke_fill=None)
 
     Draws the string at the given position.
 
@@ -297,6 +297,15 @@ Methods
 
                      .. versionadded:: 6.0.0
 
+    :param stroke_width: The width of the text stroke.
+
+                     .. versionadded:: 6.2.0
+
+    :param stroke_fill: Color to use for the text stroke. If not given, will default to
+                        the ``fill`` parameter.
+
+                     .. versionadded:: 6.2.0
+
 .. py:method:: PIL.ImageDraw.ImageDraw.multiline_text(xy, text, fill=None, font=None, anchor=None, spacing=0, align="left", direction=None, features=None, language=None)
 
     Draws the string at the given position.
@@ -336,7 +345,7 @@ Methods
 
                      .. versionadded:: 6.0.0
 
-.. py:method:: PIL.ImageDraw.ImageDraw.textsize(text, font=None, spacing=4, direction=None, features=None, language=None)
+.. py:method:: PIL.ImageDraw.ImageDraw.textsize(text, font=None, spacing=4, direction=None, features=None, language=None, stroke_width=0)
 
     Return the size of the given string, in pixels.
 
@@ -372,7 +381,11 @@ Methods
 
                      .. versionadded:: 6.0.0
 
-.. py:method:: PIL.ImageDraw.ImageDraw.multiline_textsize(text, font=None, spacing=4, direction=None, features=None, language=None)
+    :param stroke_width: The width of the text stroke.
+
+                     .. versionadded:: 6.2.0
+
+.. py:method:: PIL.ImageDraw.ImageDraw.multiline_textsize(text, font=None, spacing=4, direction=None, features=None, language=None, stroke_width=0)
 
     Return the size of the given string, in pixels.
 
@@ -408,6 +421,10 @@ Methods
 
                      .. versionadded:: 6.0.0
 
+    :param stroke_width: The width of the text stroke.
+
+                     .. versionadded:: 6.2.0
+
 .. py:method:: PIL.ImageDraw.getdraw(im=None, hints=None)
 
     .. warning:: This method is experimental.
diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py
index c9b2773881a..f51578c10f1 100644
--- a/src/PIL/ImageDraw.py
+++ b/src/PIL/ImageDraw.py
@@ -261,24 +261,95 @@ def _multiline_split(self, text):
 
         return text.split(split_character)
 
-    def text(self, xy, text, fill=None, font=None, anchor=None, *args, **kwargs):
+    def text(
+        self,
+        xy,
+        text,
+        fill=None,
+        font=None,
+        anchor=None,
+        spacing=4,
+        align="left",
+        direction=None,
+        features=None,
+        language=None,
+        stroke_width=0,
+        stroke_fill=None,
+        *args,
+        **kwargs
+    ):
         if self._multiline_check(text):
-            return self.multiline_text(xy, text, fill, font, anchor, *args, **kwargs)
-        ink, fill = self._getink(fill)
+            return self.multiline_text(
+                xy,
+                text,
+                fill,
+                font,
+                anchor,
+                spacing,
+                align,
+                direction,
+                features,
+                language,
+                stroke_width,
+                stroke_fill,
+            )
+
         if font is None:
             font = self.getfont()
-        if ink is None:
-            ink = fill
-        if ink is not None:
+
+        def getink(fill):
+            ink, fill = self._getink(fill)
+            if ink is None:
+                return fill
+            return ink
+
+        def drawText(ink, stroke_width=0, stroke_offset=None):
+            coord = xy
             try:
-                mask, offset = font.getmask2(text, self.fontmode, *args, **kwargs)
-                xy = xy[0] + offset[0], xy[1] + offset[1]
+                mask, offset = font.getmask2(
+                    text,
+                    self.fontmode,
+                    direction=direction,
+                    features=features,
+                    language=language,
+                    stroke_width=stroke_width,
+                    *args,
+                    **kwargs
+                )
+                coord = coord[0] + offset[0], coord[1] + offset[1]
             except AttributeError:
                 try:
-                    mask = font.getmask(text, self.fontmode, *args, **kwargs)
+                    mask = font.getmask(
+                        text,
+                        self.fontmode,
+                        direction,
+                        features,
+                        language,
+                        stroke_width,
+                        *args,
+                        **kwargs
+                    )
                 except TypeError:
                     mask = font.getmask(text)
-            self.draw.draw_bitmap(xy, mask, ink)
+            if stroke_offset:
+                coord = coord[0] + stroke_offset[0], coord[1] + stroke_offset[1]
+            self.draw.draw_bitmap(coord, mask, ink)
+
+        ink = getink(fill)
+        if ink is not None:
+            stroke_ink = None
+            if stroke_width:
+                stroke_ink = getink(stroke_fill) if stroke_fill is not None else ink
+
+            if stroke_ink is not None:
+                # Draw stroked text
+                drawText(stroke_ink, stroke_width)
+
+                # Draw normal text
+                drawText(ink, 0, (stroke_width, stroke_width))
+            else:
+                # Only draw normal text
+                drawText(ink)
 
     def multiline_text(
         self,
@@ -292,14 +363,23 @@ def multiline_text(
         direction=None,
         features=None,
         language=None,
+        stroke_width=0,
+        stroke_fill=None,
     ):
         widths = []
         max_width = 0
         lines = self._multiline_split(text)
-        line_spacing = self.textsize("A", font=font)[1] + spacing
+        line_spacing = (
+            self.textsize("A", font=font, stroke_width=stroke_width)[1] + spacing
+        )
         for line in lines:
             line_width, line_height = self.textsize(
-                line, font, direction=direction, features=features, language=language
+                line,
+                font,
+                direction=direction,
+                features=features,
+                language=language,
+                stroke_width=stroke_width,
             )
             widths.append(line_width)
             max_width = max(max_width, line_width)
@@ -322,32 +402,50 @@ def multiline_text(
                 direction=direction,
                 features=features,
                 language=language,
+                stroke_width=stroke_width,
+                stroke_fill=stroke_fill,
             )
             top += line_spacing
             left = xy[0]
 
     def textsize(
-        self, text, font=None, spacing=4, direction=None, features=None, language=None
+        self,
+        text,
+        font=None,
+        spacing=4,
+        direction=None,
+        features=None,
+        language=None,
+        stroke_width=0,
     ):
         """Get the size of a given string, in pixels."""
         if self._multiline_check(text):
             return self.multiline_textsize(
-                text, font, spacing, direction, features, language
+                text, font, spacing, direction, features, language, stroke_width
             )
 
         if font is None:
             font = self.getfont()
-        return font.getsize(text, direction, features, language)
+        return font.getsize(text, direction, features, language, stroke_width)
 
     def multiline_textsize(
-        self, text, font=None, spacing=4, direction=None, features=None, language=None
+        self,
+        text,
+        font=None,
+        spacing=4,
+        direction=None,
+        features=None,
+        language=None,
+        stroke_width=0,
     ):
         max_width = 0
         lines = self._multiline_split(text)
-        line_spacing = self.textsize("A", font=font)[1] + spacing
+        line_spacing = (
+            self.textsize("A", font=font, stroke_width=stroke_width)[1] + spacing
+        )
         for line in lines:
             line_width, line_height = self.textsize(
-                line, font, spacing, direction, features, language
+                line, font, spacing, direction, features, language, stroke_width
             )
             max_width = max(max_width, line_width)
         return max_width, len(lines) * line_spacing - spacing
diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py
index e2e6af33254..737ced4724a 100644
--- a/src/PIL/ImageFont.py
+++ b/src/PIL/ImageFont.py
@@ -207,7 +207,9 @@ def getmetrics(self):
         """
         return self.font.ascent, self.font.descent
 
-    def getsize(self, text, direction=None, features=None, language=None):
+    def getsize(
+        self, text, direction=None, features=None, language=None, stroke_width=0
+    ):
         """
         Returns width and height (in pixels) of given text if rendered in font with
         provided direction, features, and language.
@@ -243,13 +245,26 @@ def getsize(self, text, direction=None, features=None, language=None):
 
                          .. versionadded:: 6.0.0
 
+        :param stroke_width: The width of the text stroke.
+
+                         .. versionadded:: 6.2.0
+
         :return: (width, height)
         """
         size, offset = self.font.getsize(text, direction, features, language)
-        return (size[0] + offset[0], size[1] + offset[1])
+        return (
+            size[0] + stroke_width * 2 + offset[0],
+            size[1] + stroke_width * 2 + offset[1],
+        )
 
     def getsize_multiline(
-        self, text, direction=None, spacing=4, features=None, language=None
+        self,
+        text,
+        direction=None,
+        spacing=4,
+        features=None,
+        language=None,
+        stroke_width=0,
     ):
         """
         Returns width and height (in pixels) of given text if rendered in font
@@ -285,13 +300,19 @@ def getsize_multiline(
 
                          .. versionadded:: 6.0.0
 
+        :param stroke_width: The width of the text stroke.
+
+                         .. versionadded:: 6.2.0
+
         :return: (width, height)
         """
         max_width = 0
         lines = self._multiline_split(text)
-        line_spacing = self.getsize("A")[1] + spacing
+        line_spacing = self.getsize("A", stroke_width=stroke_width)[1] + spacing
         for line in lines:
-            line_width, line_height = self.getsize(line, direction, features, language)
+            line_width, line_height = self.getsize(
+                line, direction, features, language, stroke_width
+            )
             max_width = max(max_width, line_width)
 
         return max_width, len(lines) * line_spacing - spacing
@@ -308,7 +329,15 @@ def getoffset(self, text):
         """
         return self.font.getsize(text)[1]
 
-    def getmask(self, text, mode="", direction=None, features=None, language=None):
+    def getmask(
+        self,
+        text,
+        mode="",
+        direction=None,
+        features=None,
+        language=None,
+        stroke_width=0,
+    ):
         """
         Create a bitmap for the text.
 
@@ -352,11 +381,20 @@ def getmask(self, text, mode="", direction=None, features=None, language=None):
 
                          .. versionadded:: 6.0.0
 
+        :param stroke_width: The width of the text stroke.
+
+                         .. versionadded:: 6.2.0
+
         :return: An internal PIL storage memory instance as defined by the
                  :py:mod:`PIL.Image.core` interface module.
         """
         return self.getmask2(
-            text, mode, direction=direction, features=features, language=language
+            text,
+            mode,
+            direction=direction,
+            features=features,
+            language=language,
+            stroke_width=stroke_width,
         )[0]
 
     def getmask2(
@@ -367,6 +405,7 @@ def getmask2(
         direction=None,
         features=None,
         language=None,
+        stroke_width=0,
         *args,
         **kwargs
     ):
@@ -413,13 +452,20 @@ def getmask2(
 
                          .. versionadded:: 6.0.0
 
+        :param stroke_width: The width of the text stroke.
+
+                         .. versionadded:: 6.2.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
         """
         size, offset = self.font.getsize(text, direction, features, language)
+        size = size[0] + stroke_width * 2, size[1] + stroke_width * 2
         im = fill("L", size, 0)
-        self.font.render(text, im.id, mode == "1", direction, features, language)
+        self.font.render(
+            text, im.id, mode == "1", direction, features, language, stroke_width
+        )
         return im, offset
 
     def font_variant(
diff --git a/src/_imagingft.c b/src/_imagingft.c
index 87376383e3b..7776e43f1b7 100644
--- a/src/_imagingft.c
+++ b/src/_imagingft.c
@@ -25,6 +25,7 @@
 #include <ft2build.h>
 #include FT_FREETYPE_H
 #include FT_GLYPH_H
+#include FT_STROKER_H
 #include FT_MULTIPLE_MASTERS_H
 #include FT_SFNT_NAMES_H
 
@@ -790,7 +791,13 @@ font_render(FontObject* self, PyObject* args)
     int index, error, ascender, horizontal_dir;
     int load_flags;
     unsigned char *source;
-    FT_GlyphSlot glyph;
+    FT_Glyph glyph;
+    FT_GlyphSlot glyph_slot;
+    FT_Bitmap bitmap;
+    FT_BitmapGlyph bitmap_glyph;
+    int stroke_width = 0;
+    FT_Stroker stroker = NULL;
+    FT_Int left;
     /* render string into given buffer (the buffer *must* have
        the right size, or this will crash) */
     PyObject* string;
@@ -806,7 +813,8 @@ font_render(FontObject* self, PyObject* args)
     GlyphInfo *glyph_info;
     PyObject *features = NULL;
 
-    if (!PyArg_ParseTuple(args, "On|izOz:render", &string,  &id, &mask, &dir, &features, &lang)) {
+    if (!PyArg_ParseTuple(args, "On|izOzi:render", &string,  &id, &mask, &dir, &features, &lang,
+                                                   &stroke_width)) {
         return NULL;
     }
 
@@ -819,21 +827,37 @@ font_render(FontObject* self, PyObject* args)
         Py_RETURN_NONE;
     }
 
+    if (stroke_width) {
+        error = FT_Stroker_New(library, &stroker);
+        if (error) {
+            return geterror(error);
+        }
+
+        FT_Stroker_Set(stroker, (FT_Fixed)stroke_width*64, FT_STROKER_LINECAP_ROUND, FT_STROKER_LINEJOIN_ROUND, 0);
+    }
+
     im = (Imaging) id;
     /* Note: bitmap fonts within ttf fonts do not work, see #891/pr#960 */
-    load_flags = FT_LOAD_RENDER|FT_LOAD_NO_BITMAP;
-    if (mask)
+    load_flags = FT_LOAD_NO_BITMAP;
+    if (stroker == NULL) {
+        load_flags |= FT_LOAD_RENDER;
+    }
+    if (mask) {
         load_flags |= FT_LOAD_TARGET_MONO;
+    }
 
     ascender = 0;
     for (i = 0; i < count; i++) {
         index = glyph_info[i].index;
         error = FT_Load_Glyph(self->face, index, load_flags);
-        if (error)
+        if (error) {
             return geterror(error);
+        }
 
-        glyph = self->face->glyph;
-        temp = glyph->bitmap.rows - glyph->bitmap_top;
+        glyph_slot = self->face->glyph;
+        bitmap = glyph_slot->bitmap;
+
+        temp = bitmap.rows - glyph_slot->bitmap_top;
         temp -= PIXEL(glyph_info[i].y_offset);
         if (temp > ascender)
             ascender = temp;
@@ -844,37 +868,62 @@ font_render(FontObject* self, PyObject* args)
     for (i = 0; i < count; i++) {
         index = glyph_info[i].index;
         error = FT_Load_Glyph(self->face, index, load_flags);
-        if (error)
+        if (error) {
             return geterror(error);
+        }
+
+        glyph_slot = self->face->glyph;
+        if (stroker != NULL) {
+            error = FT_Get_Glyph(glyph_slot, &glyph);
+            if (!error) {
+                error = FT_Glyph_Stroke(&glyph, stroker, 1);
+            }
+            if (!error) {
+                FT_Vector origin = {0, 0};
+                error = FT_Glyph_To_Bitmap(&glyph, FT_RENDER_MODE_NORMAL, &origin, 1);
+            }
+            if (error) {
+                return geterror(error);
+            }
+
+            bitmap_glyph = (FT_BitmapGlyph)glyph;
+
+            bitmap = bitmap_glyph->bitmap;
+            left = bitmap_glyph->left;
+
+            FT_Done_Glyph(glyph);
+        } else {
+            bitmap = glyph_slot->bitmap;
+            left = glyph_slot->bitmap_left;
+        }
 
-        glyph = self->face->glyph;
         if (horizontal_dir) {
-            if (i == 0 && self->face->glyph->metrics.horiBearingX < 0) {
-                x = -self->face->glyph->metrics.horiBearingX;
+            if (i == 0 && glyph_slot->metrics.horiBearingX < 0) {
+                x = -glyph_slot->metrics.horiBearingX;
             }
-            xx = PIXEL(x) + glyph->bitmap_left;
-            xx += PIXEL(glyph_info[i].x_offset);
+            xx = PIXEL(x) + left;
+            xx += PIXEL(glyph_info[i].x_offset) + stroke_width;
         } else {
-            if (self->face->glyph->metrics.vertBearingX < 0) {
-                x = -self->face->glyph->metrics.vertBearingX;
+            if (glyph_slot->metrics.vertBearingX < 0) {
+                x = -glyph_slot->metrics.vertBearingX;
             }
-            xx = im->xsize / 2 - glyph->bitmap.width / 2;
+            xx = im->xsize / 2 - bitmap.width / 2;
         }
 
         x0 = 0;
-        x1 = glyph->bitmap.width;
+        x1 = bitmap.width;
         if (xx < 0)
             x0 = -xx;
         if (xx + x1 > im->xsize)
             x1 = im->xsize - xx;
 
-        source = (unsigned char*) glyph->bitmap.buffer;
-        for (bitmap_y = 0; bitmap_y < glyph->bitmap.rows; bitmap_y++) {
+        source = (unsigned char*) bitmap.buffer;
+        for (bitmap_y = 0; bitmap_y < bitmap.rows; bitmap_y++) {
             if (horizontal_dir) {
-                yy = bitmap_y + im->ysize - (PIXEL(glyph->metrics.horiBearingY) + ascender);
-                yy -= PIXEL(glyph_info[i].y_offset);
+                yy = bitmap_y + im->ysize - (PIXEL(glyph_slot->metrics.horiBearingY) + ascender);
+                yy -= PIXEL(glyph_info[i].y_offset) + stroke_width * 2;
             } else {
-                yy = bitmap_y + PIXEL(y + glyph->metrics.vertBearingY) + ascender;
+                yy = bitmap_y + PIXEL(y + glyph_slot->metrics.vertBearingY) + ascender;
                 yy += PIXEL(glyph_info[i].y_offset);
             }
             if (yy >= 0 && yy < im->ysize) {
@@ -900,12 +949,13 @@ font_render(FontObject* self, PyObject* args)
                     }
                 }
             }
-            source += glyph->bitmap.pitch;
+            source += bitmap.pitch;
         }
         x += glyph_info[i].x_advance;
         y -= glyph_info[i].y_advance;
     }
 
+    FT_Stroker_Done(stroker);
     PyMem_Del(glyph_info);
     Py_RETURN_NONE;
 }


Code review comments:
diff_hunk: @@ -261,24 +261,95 @@ def _multiline_split(self, text):
 
         return text.split(split_character)
 
-    def text(self, xy, text, fill=None, font=None, anchor=None, *args, **kwargs):
+    def text(
+        self,
+        xy,
+        text,
+        fill=None,
+        font=None,
+        anchor=None,
+        spacing=4,
+        align="left",
+        direction=None,
+        features=None,
+        language=None,
+        stroke_width=0,
+        stroke_fill=None,
+        *args,
+        **kwargs
+    ):
         if self._multiline_check(text):
-            return self.multiline_text(xy, text, fill, font, anchor, *args, **kwargs)
-        ink, fill = self._getink(fill)
+            return self.multiline_text(
+                xy,
+                text,
+                fill,
+                font,
+                anchor,
+                spacing,
+                align,
+                direction,
+                features,
+                language,
+                stroke_width,
+                stroke_fill,
+            )
+
         if font is None:
             font = self.getfont()
-        if ink is None:
-            ink = fill
-        if ink is not None:
+
+        def getink(fill):
+            ink, fill = self._getink(fill)
+            if ink is None:
+                return fill
+            return ink
+
+        def drawText(ink, stroke_width=0, stroke_offset=None):
+            coord = xy
             try:
-                mask, offset = font.getmask2(text, self.fontmode, *args, **kwargs)
-                xy = xy[0] + offset[0], xy[1] + offset[1]
+                mask, offset = font.getmask2(
+                    text,
+                    self.fontmode,
+                    direction=direction,
+                    features=features,
+                    language=language,
+                    stroke_width=stroke_width,
+                    *args,
+                    **kwargs
+                )
+                coord = coord[0] + offset[0], coord[1] + offset[1]
             except AttributeError:
                 try:
-                    mask = font.getmask(text, self.fontmode, *args, **kwargs)
+                    mask = font.getmask(
+                        text,
+                        self.fontmode,
+                        direction,
+                        features,
+                        language,
+                        stroke_width,
+                        *args,
+                        **kwargs
+                    )
                 except TypeError:
                     mask = font.getmask(text)
-            self.draw.draw_bitmap(xy, mask, ink)
+            if stroke_offset:
+                coord = coord[0] + stroke_offset[0], coord[1] + stroke_offset[1]
+            self.draw.draw_bitmap(coord, mask, ink)
+
+        ink = getink(fill)
+        if ink is not None:
+            stroke_ink = None
+            if stroke_width:
+                stroke_ink = getink(stroke_fill) if stroke_fill is not None else ink
+
+            if stroke_ink is not None:
+                # Draw stroked text
+                drawText(stroke_ink, stroke_width)
+
+                # Draw normal text
+                drawText(ink, 0, (stroke_width, stroke_width))
+            else:
+                # Only draw normal text
+                drawText(ink)
path: src/PIL/ImageDraw.py
text: @user1:
```suggestion
                draw_text(ink)
```

diff_hunk: @@ -261,24 +261,95 @@ def _multiline_split(self, text):
 
         return text.split(split_character)
 
-    def text(self, xy, text, fill=None, font=None, anchor=None, *args, **kwargs):
+    def text(
+        self,
+        xy,
+        text,
+        fill=None,
+        font=None,
+        anchor=None,
+        spacing=4,
+        align="left",
+        direction=None,
+        features=None,
+        language=None,
+        stroke_width=0,
+        stroke_fill=None,
+        *args,
+        **kwargs
+    ):
         if self._multiline_check(text):
-            return self.multiline_text(xy, text, fill, font, anchor, *args, **kwargs)
-        ink, fill = self._getink(fill)
+            return self.multiline_text(
+                xy,
+                text,
+                fill,
+                font,
+                anchor,
+                spacing,
+                align,
+                direction,
+                features,
+                language,
+                stroke_width,
+                stroke_fill,
+            )
+
         if font is None:
             font = self.getfont()
-        if ink is None:
-            ink = fill
-        if ink is not None:
+
+        def getink(fill):
+            ink, fill = self._getink(fill)
+            if ink is None:
+                return fill
+            return ink
+
+        def drawText(ink, stroke_width=0, stroke_offset=None):
path: src/PIL/ImageDraw.py
text: @user1:
```suggestion
        def draw_text(ink, stroke_width=0, stroke_offset=None):
```

diff_hunk: @@ -261,24 +261,95 @@ def _multiline_split(self, text):
 
         return text.split(split_character)
 
-    def text(self, xy, text, fill=None, font=None, anchor=None, *args, **kwargs):
+    def text(
+        self,
+        xy,
+        text,
+        fill=None,
+        font=None,
+        anchor=None,
+        spacing=4,
+        align="left",
+        direction=None,
+        features=None,
+        language=None,
+        stroke_width=0,
+        stroke_fill=None,
+        *args,
+        **kwargs
+    ):
         if self._multiline_check(text):
-            return self.multiline_text(xy, text, fill, font, anchor, *args, **kwargs)
-        ink, fill = self._getink(fill)
+            return self.multiline_text(
+                xy,
+                text,
+                fill,
+                font,
+                anchor,
+                spacing,
+                align,
+                direction,
+                features,
+                language,
+                stroke_width,
+                stroke_fill,
+            )
+
         if font is None:
             font = self.getfont()
-        if ink is None:
-            ink = fill
-        if ink is not None:
+
+        def getink(fill):
+            ink, fill = self._getink(fill)
+            if ink is None:
+                return fill
+            return ink
+
+        def drawText(ink, stroke_width=0, stroke_offset=None):
+            coord = xy
             try:
-                mask, offset = font.getmask2(text, self.fontmode, *args, **kwargs)
-                xy = xy[0] + offset[0], xy[1] + offset[1]
+                mask, offset = font.getmask2(
+                    text,
+                    self.fontmode,
+                    direction=direction,
+                    features=features,
+                    language=language,
+                    stroke_width=stroke_width,
+                    *args,
+                    **kwargs
+                )
+                coord = coord[0] + offset[0], coord[1] + offset[1]
             except AttributeError:
                 try:
-                    mask = font.getmask(text, self.fontmode, *args, **kwargs)
+                    mask = font.getmask(
+                        text,
+                        self.fontmode,
+                        direction,
+                        features,
+                        language,
+                        stroke_width,
+                        *args,
+                        **kwargs
+                    )
                 except TypeError:
                     mask = font.getmask(text)
-            self.draw.draw_bitmap(xy, mask, ink)
+            if stroke_offset:
+                coord = coord[0] + stroke_offset[0], coord[1] + stroke_offset[1]
+            self.draw.draw_bitmap(coord, mask, ink)
+
+        ink = getink(fill)
+        if ink is not None:
+            stroke_ink = None
+            if stroke_width:
+                stroke_ink = getink(stroke_fill) if stroke_fill is not None else ink
+
+            if stroke_ink is not None:
+                # Draw stroked text
+                drawText(stroke_ink, stroke_width)
+
+                # Draw normal text
+                drawText(ink, 0, (stroke_width, stroke_width))
path: src/PIL/ImageDraw.py
text: @user1:
```suggestion
                draw_text(ink, 0, (stroke_width, stroke_width))
```

diff_hunk: @@ -261,24 +261,95 @@ def _multiline_split(self, text):
 
         return text.split(split_character)
 
-    def text(self, xy, text, fill=None, font=None, anchor=None, *args, **kwargs):
+    def text(
+        self,
+        xy,
+        text,
+        fill=None,
+        font=None,
+        anchor=None,
+        spacing=4,
+        align="left",
+        direction=None,
+        features=None,
+        language=None,
+        stroke_width=0,
+        stroke_fill=None,
+        *args,
+        **kwargs
+    ):
         if self._multiline_check(text):
-            return self.multiline_text(xy, text, fill, font, anchor, *args, **kwargs)
-        ink, fill = self._getink(fill)
+            return self.multiline_text(
+                xy,
+                text,
+                fill,
+                font,
+                anchor,
+                spacing,
+                align,
+                direction,
+                features,
+                language,
+                stroke_width,
+                stroke_fill,
+            )
+
         if font is None:
             font = self.getfont()
-        if ink is None:
-            ink = fill
-        if ink is not None:
+
+        def getink(fill):
+            ink, fill = self._getink(fill)
+            if ink is None:
+                return fill
+            return ink
+
+        def drawText(ink, stroke_width=0, stroke_offset=None):
+            coord = xy
             try:
-                mask, offset = font.getmask2(text, self.fontmode, *args, **kwargs)
-                xy = xy[0] + offset[0], xy[1] + offset[1]
+                mask, offset = font.getmask2(
+                    text,
+                    self.fontmode,
+                    direction=direction,
+                    features=features,
+                    language=language,
+                    stroke_width=stroke_width,
+                    *args,
+                    **kwargs
+                )
+                coord = coord[0] + offset[0], coord[1] + offset[1]
             except AttributeError:
                 try:
-                    mask = font.getmask(text, self.fontmode, *args, **kwargs)
+                    mask = font.getmask(
+                        text,
+                        self.fontmode,
+                        direction,
+                        features,
+                        language,
+                        stroke_width,
+                        *args,
+                        **kwargs
+                    )
                 except TypeError:
                     mask = font.getmask(text)
-            self.draw.draw_bitmap(xy, mask, ink)
+            if stroke_offset:
+                coord = coord[0] + stroke_offset[0], coord[1] + stroke_offset[1]
+            self.draw.draw_bitmap(coord, mask, ink)
+
+        ink = getink(fill)
+        if ink is not None:
+            stroke_ink = None
+            if stroke_width:
+                stroke_ink = getink(stroke_fill) if stroke_fill is not None else ink
+
+            if stroke_ink is not None:
+                # Draw stroked text
+                drawText(stroke_ink, stroke_width)
path: src/PIL/ImageDraw.py
text: @user1:
```suggestion
                draw_text(stroke_ink, stroke_width)
```