From f93a5d09728adfa0ea7e3ae52f9234c097d5e824 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 29 Jul 2019 06:40:03 +1000 Subject: [PATCH] Added text stroking --- Tests/images/imagedraw_stroke_different.png | Bin 0 -> 2259 bytes Tests/images/imagedraw_stroke_multiline.png | Bin 0 -> 4061 bytes Tests/images/imagedraw_stroke_same.png | Bin 0 -> 1328 bytes Tests/images/test_direction_ttb_stroke.png | Bin 0 -> 3749 bytes Tests/test_imagedraw.py | 54 +++++++- Tests/test_imagefont.py | 15 +++ Tests/test_imagefontctl.py | 24 ++++ docs/reference/ImageDraw.rst | 23 +++- src/PIL/ImageDraw.py | 134 +++++++++++++++++--- src/PIL/ImageFont.py | 62 +++++++-- src/_imagingft.c | 98 ++++++++++---- 11 files changed, 355 insertions(+), 55 deletions(-) create mode 100644 Tests/images/imagedraw_stroke_different.png create mode 100644 Tests/images/imagedraw_stroke_multiline.png create mode 100644 Tests/images/imagedraw_stroke_same.png create mode 100644 Tests/images/test_direction_ttb_stroke.png diff --git a/Tests/images/imagedraw_stroke_different.png b/Tests/images/imagedraw_stroke_different.png new file mode 100644 index 0000000000000000000000000000000000000000..e58cbdc4e23ac1df1d865916a0ae10535fe18f04 GIT binary patch literal 2259 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 X<7$W=>Ca!;PGzWjPp3-DDeC_KbrPo| literal 0 HcmV?d00001 diff --git a/Tests/images/imagedraw_stroke_same.png b/Tests/images/imagedraw_stroke_same.png new file mode 100644 index 0000000000000000000000000000000000000000..8f2f3abe1a6f263e45b0ec721bfad51792348e59 GIT binary patch literal 1328 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 literal 0 HcmV?d00001 diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index ffe35a4fa..ed4291f53 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 @@ POINTS2 = [10, 10, 20, 40, 30, 30] 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 @@ class TestImageDraw(PillowTestCase): 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 8a23e6339..6a2d572a9 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -605,6 +605,21 @@ class TestImageFont(PillowTestCase): 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 afd45ce19..5b88f94cc 100644 --- a/Tests/test_imagefontctl.py +++ b/Tests/test_imagefontctl.py @@ -115,6 +115,30 @@ class TestImagecomplextext(PillowTestCase): 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 5fac7914b..51eaf925e 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 c9b277388..f51578c10 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -261,24 +261,95 @@ class ImageDraw(object): 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 @@ class ImageDraw(object): 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 @@ class ImageDraw(object): 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 e2e6af332..737ced472 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -207,7 +207,9 @@ class FreeTypeFont(object): """ 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 @@ class FreeTypeFont(object): .. 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 @@ class FreeTypeFont(object): .. 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 @@ class FreeTypeFont(object): """ 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 @@ class FreeTypeFont(object): .. 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 @@ class FreeTypeFont(object): direction=None, features=None, language=None, + stroke_width=0, *args, **kwargs ): @@ -413,13 +452,20 @@ class FreeTypeFont(object): .. 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 87376383e..7776e43f1 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 = self->face->glyph; - if (horizontal_dir) { - if (i == 0 && self->face->glyph->metrics.horiBearingX < 0) { - x = -self->face->glyph->metrics.horiBearingX; + glyph_slot = self->face->glyph; + if (stroker != NULL) { + error = FT_Get_Glyph(glyph_slot, &glyph); + if (!error) { + error = FT_Glyph_Stroke(&glyph, stroker, 1); } - xx = PIXEL(x) + glyph->bitmap_left; - xx += PIXEL(glyph_info[i].x_offset); + 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 { - if (self->face->glyph->metrics.vertBearingX < 0) { - x = -self->face->glyph->metrics.vertBearingX; + bitmap = glyph_slot->bitmap; + left = glyph_slot->bitmap_left; + } + + if (horizontal_dir) { + if (i == 0 && glyph_slot->metrics.horiBearingX < 0) { + x = -glyph_slot->metrics.horiBearingX; } - xx = im->xsize / 2 - glyph->bitmap.width / 2; + xx = PIXEL(x) + left; + xx += PIXEL(glyph_info[i].x_offset) + stroke_width; + } else { + if (glyph_slot->metrics.vertBearingX < 0) { + x = -glyph_slot->metrics.vertBearingX; + } + 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; }