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..58b8a4be073 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -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. @@ -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 #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; }