Added text stroking

This commit is contained in:
Andrew Murray 2019-07-29 06:40:03 +10:00
parent f3f45cfec5
commit f93a5d0972
11 changed files with 355 additions and 55 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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.

View File

@ -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()
def getink(fill):
ink, fill = self._getink(fill)
if ink is None:
ink = fill
if ink is not 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

View File

@ -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(

View File

@ -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 = 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;
}
xx = im->xsize / 2 - glyph->bitmap.width / 2;
if (horizontal_dir) {
if (i == 0 && glyph_slot->metrics.horiBearingX < 0) {
x = -glyph_slot->metrics.horiBearingX;
}
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;
}