Repository: python-pillow/Pillow

Issue:
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)``



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

Resolving patch:
diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py
--- 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 draw_text(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
+                draw_text(stroke_ink, stroke_width)
+
+                # Draw normal text
+                draw_text(ink, 0, (stroke_width, stroke_width))
+            else:
+                # Only draw normal text
+                draw_text(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
--- 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(


Test patch:
diff --git a/Tests/images/imagedraw_stroke_different.png b/Tests/images/imagedraw_stroke_different.png
new file mode 100644
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
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
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
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
--- 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
--- 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
--- 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)