diff --git a/Tests/fonts/LICENSE.txt b/Tests/fonts/LICENSE.txt index 528eed9ef..538862b97 100644 --- a/Tests/fonts/LICENSE.txt +++ b/Tests/fonts/LICENSE.txt @@ -9,6 +9,7 @@ ter-x20b.pcf, from http://terminus-font.sourceforge.net/ All of the above fonts are published under the SIL Open Font License (OFL) v1.1 (http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=OFL), which allows you to copy, modify, and redistribute them if you need to. +OpenSansCondensed-LightItalic.tt, from https://fonts.google.com/specimen/Open+Sans, under Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0) 10x20-ISO8859-1.pcf, from https://packages.ubuntu.com/xenial/xfonts-base diff --git a/Tests/fonts/OpenSansCondensed-LightItalic.ttf b/Tests/fonts/OpenSansCondensed-LightItalic.ttf new file mode 100644 index 000000000..b4ee4951f Binary files /dev/null and b/Tests/fonts/OpenSansCondensed-LightItalic.ttf differ diff --git a/Tests/images/test_anchor_multiline_lm_center.png b/Tests/images/test_anchor_multiline_lm_center.png new file mode 100644 index 000000000..6fff287e4 Binary files /dev/null and b/Tests/images/test_anchor_multiline_lm_center.png differ diff --git a/Tests/images/test_anchor_multiline_lm_left.png b/Tests/images/test_anchor_multiline_lm_left.png new file mode 100644 index 000000000..b76a81b81 Binary files /dev/null and b/Tests/images/test_anchor_multiline_lm_left.png differ diff --git a/Tests/images/test_anchor_multiline_lm_right.png b/Tests/images/test_anchor_multiline_lm_right.png new file mode 100644 index 000000000..c12a8d63e Binary files /dev/null and b/Tests/images/test_anchor_multiline_lm_right.png differ diff --git a/Tests/images/test_anchor_multiline_ma_center.png b/Tests/images/test_anchor_multiline_ma_center.png new file mode 100644 index 000000000..4f35d781f Binary files /dev/null and b/Tests/images/test_anchor_multiline_ma_center.png differ diff --git a/Tests/images/test_anchor_multiline_md_center.png b/Tests/images/test_anchor_multiline_md_center.png new file mode 100644 index 000000000..8290d045c Binary files /dev/null and b/Tests/images/test_anchor_multiline_md_center.png differ diff --git a/Tests/images/test_anchor_multiline_mm_center.png b/Tests/images/test_anchor_multiline_mm_center.png new file mode 100644 index 000000000..773cf2a4a Binary files /dev/null and b/Tests/images/test_anchor_multiline_mm_center.png differ diff --git a/Tests/images/test_anchor_multiline_mm_left.png b/Tests/images/test_anchor_multiline_mm_left.png new file mode 100644 index 000000000..87d56636a Binary files /dev/null and b/Tests/images/test_anchor_multiline_mm_left.png differ diff --git a/Tests/images/test_anchor_multiline_mm_right.png b/Tests/images/test_anchor_multiline_mm_right.png new file mode 100644 index 000000000..cf002b12c Binary files /dev/null and b/Tests/images/test_anchor_multiline_mm_right.png differ diff --git a/Tests/images/test_anchor_multiline_rm_center.png b/Tests/images/test_anchor_multiline_rm_center.png new file mode 100644 index 000000000..98073144b Binary files /dev/null and b/Tests/images/test_anchor_multiline_rm_center.png differ diff --git a/Tests/images/test_anchor_multiline_rm_left.png b/Tests/images/test_anchor_multiline_rm_left.png new file mode 100644 index 000000000..838fd7858 Binary files /dev/null and b/Tests/images/test_anchor_multiline_rm_left.png differ diff --git a/Tests/images/test_anchor_multiline_rm_right.png b/Tests/images/test_anchor_multiline_rm_right.png new file mode 100644 index 000000000..290f58417 Binary files /dev/null and b/Tests/images/test_anchor_multiline_rm_right.png differ diff --git a/Tests/images/test_combine_multiline_lm_center.png b/Tests/images/test_combine_multiline_lm_center.png new file mode 100644 index 000000000..02d28b27a Binary files /dev/null and b/Tests/images/test_combine_multiline_lm_center.png differ diff --git a/Tests/images/test_combine_multiline_lm_left.png b/Tests/images/test_combine_multiline_lm_left.png new file mode 100644 index 000000000..461483d00 Binary files /dev/null and b/Tests/images/test_combine_multiline_lm_left.png differ diff --git a/Tests/images/test_combine_multiline_lm_right.png b/Tests/images/test_combine_multiline_lm_right.png new file mode 100644 index 000000000..7cba6415f Binary files /dev/null and b/Tests/images/test_combine_multiline_lm_right.png differ diff --git a/Tests/images/test_combine_multiline_mm_center.png b/Tests/images/test_combine_multiline_mm_center.png new file mode 100644 index 000000000..dfb6b7b39 Binary files /dev/null and b/Tests/images/test_combine_multiline_mm_center.png differ diff --git a/Tests/images/test_combine_multiline_mm_left.png b/Tests/images/test_combine_multiline_mm_left.png new file mode 100644 index 000000000..1571cf155 Binary files /dev/null and b/Tests/images/test_combine_multiline_mm_left.png differ diff --git a/Tests/images/test_combine_multiline_mm_right.png b/Tests/images/test_combine_multiline_mm_right.png new file mode 100644 index 000000000..3e88d218a Binary files /dev/null and b/Tests/images/test_combine_multiline_mm_right.png differ diff --git a/Tests/images/test_combine_multiline_rm_center.png b/Tests/images/test_combine_multiline_rm_center.png new file mode 100644 index 000000000..7c743f228 Binary files /dev/null and b/Tests/images/test_combine_multiline_rm_center.png differ diff --git a/Tests/images/test_combine_multiline_rm_left.png b/Tests/images/test_combine_multiline_rm_left.png new file mode 100644 index 000000000..8aae219d3 Binary files /dev/null and b/Tests/images/test_combine_multiline_rm_left.png differ diff --git a/Tests/images/test_combine_multiline_rm_right.png b/Tests/images/test_combine_multiline_rm_right.png new file mode 100644 index 000000000..b5f7f0397 Binary files /dev/null and b/Tests/images/test_combine_multiline_rm_right.png differ diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 37587a0da..e58945524 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -34,9 +34,24 @@ class TestImageFont: # Freetype has different metrics depending on the version. # (and, other things, but first things first) METRICS = { - (">=2.3", "<2.4"): {"multiline": 30, "textsize": 12, "getters": (12, 16)}, - (">=2.7",): {"multiline": 6.2, "textsize": 2.5, "getters": (12, 16)}, - "Default": {"multiline": 0.5, "textsize": 0.5, "getters": (12, 16)}, + (">=2.3", "<2.4"): { + "multiline": 30, + "textsize": 12, + "multiline-anchor": 6, + "getlength": (36, 27, 27, 33), + }, + (">=2.7",): { + "multiline": 6.2, + "textsize": 2.5, + "multiline-anchor": 4, + "getlength": (36, 21, 24, 33), + }, + "Default": { + "multiline": 0.5, + "textsize": 0.5, + "multiline-anchor": 4, + "getlength": (36, 24, 24, 33), + }, } @classmethod @@ -179,6 +194,34 @@ class TestImageFont: # Epsilon ~.5 fails with FreeType 2.7 assert_image_similar(im, target_img, self.metrics["textsize"]) + @pytest.mark.parametrize( + "text,mode,font,size,length_basic_index,length_raqm", + ( + # basic test + ("text", "L", "FreeMono.ttf", 15, 0, 36), + ("text", "1", "FreeMono.ttf", 15, 0, 36), + # issue 4177 + ("rrr", "L", "DejaVuSans.ttf", 18, 1, 22.21875), + ("rrr", "1", "DejaVuSans.ttf", 18, 2, 22.21875), + # test 'l' not including extra margin + # using exact value 2047 / 64 for raqm, checked with debugger + ("ill", "L", "OpenSansCondensed-LightItalic.ttf", 63, 3, 31.984375), + ("ill", "1", "OpenSansCondensed-LightItalic.ttf", 63, 3, 31.984375), + ), + ) + def test_getlength(self, text, mode, font, size, length_basic_index, length_raqm): + f = ImageFont.truetype( + "Tests/fonts/" + font, size, layout_engine=self.LAYOUT_ENGINE + ) + + if self.LAYOUT_ENGINE == ImageFont.LAYOUT_BASIC: + length = f.getlength(text, mode) + assert length == self.metrics["getlength"][length_basic_index] + else: + # disable kerning, kerning metrics changed + length = f.getlength(text, mode, features=["-kern"]) + assert length == length_raqm + def test_render_multiline(self): im = Image.new(mode="RGB", size=(300, 100)) draw = ImageDraw.Draw(im) @@ -581,7 +624,7 @@ class TestImageFont: assert t.font.glyphs == 4177 assert t.getsize("A") == (12, 16) assert t.getsize("AB") == (24, 16) - assert t.getsize("M") == self.metrics["getters"] + assert t.getsize("M") == (12, 16) assert t.getsize("y") == (12, 20) assert t.getsize("a") == (12, 16) assert t.getsize_multiline("A") == (12, 16) @@ -735,35 +778,87 @@ class TestImageFont: self._check_text(font, "Tests/images/variation_tiny_axes.png", 32.5) @pytest.mark.parametrize( - "name,text,anchor", + "anchor,left,left_raqm,top", ( # test horizontal anchors - ("quick", "Quick", "ls"), - ("quick", "Quick", "ms"), - ("quick", "Quick", "rs"), + ("ls", 0, 0, -36), + ("ms", -64, -65, -36), + ("rs", -128, -129, -36), # test vertical anchors - ("quick", "Quick", "ma"), - ("quick", "Quick", "mt"), - ("quick", "Quick", "mm"), - ("quick", "Quick", "mb"), - ("quick", "Quick", "md"), + ("ma", -64, -65, 16), + ("mt", -64, -65, 0), + ("mm", -64, -65, -17), + ("mb", -64, -65, -44), + ("md", -64, -65, -51), ), ) - def test_anchor(self, name, text, anchor): - path = "Tests/images/test_anchor_%s_%s.png" % (name, anchor) + def test_anchor(self, anchor, left, left_raqm, top): + name, text = "quick", "Quick" + target = "Tests/images/test_anchor_%s_%s.png" % (name, anchor) + freetype = distutils.version.StrictVersion(ImageFont.core.freetype2_version) + + if self.LAYOUT_ENGINE == ImageFont.LAYOUT_RAQM or freetype < "2.4": + width, height = (129, 44) + left = left_raqm + else: + width, height = (128, 44) + f = ImageFont.truetype( "Tests/fonts/NotoSans-Regular.ttf", 48, layout_engine=self.LAYOUT_ENGINE ) + # test getbbox + assert f.getbbox(text, anchor=anchor) == (left, top, left + width, top + height) + + # test render im = Image.new("RGB", (200, 200), "white") d = ImageDraw.Draw(im) d.line(((0, 100), (200, 100)), "gray") d.line(((100, 0), (100, 200)), "gray") d.text((100, 100), text, fill="black", anchor=anchor, font=f) - with Image.open(path) as expected: + with Image.open(target) as expected: assert_image_similar(im, expected, 7) + @pytest.mark.parametrize( + "anchor,align", + ( + # test horizontal anchors + ("lm", "left"), + ("lm", "center"), + ("lm", "right"), + ("mm", "left"), + ("mm", "center"), + ("mm", "right"), + ("rm", "left"), + ("rm", "center"), + ("rm", "right"), + # test vertical anchors + ("ma", "center"), + # ("mm", "center"), + ("md", "center"), + ), + ) + def test_anchor_multiline(self, anchor, align): + target = "Tests/images/test_anchor_multiline_%s_%s.png" % (anchor, align) + text = "a\nlong\ntext sample" + + f = ImageFont.truetype( + "Tests/fonts/NotoSans-Regular.ttf", 48, layout_engine=self.LAYOUT_ENGINE + ) + + # test render + im = Image.new("RGB", (600, 400), "white") + d = ImageDraw.Draw(im) + d.line(((0, 200), (600, 200)), "gray") + d.line(((300, 0), (300, 400)), "gray") + d.multiline_text( + (300, 200), text, fill="black", anchor=anchor, font=f, align=align + ) + + with Image.open(target) as expected: + assert_image_similar(im, expected, self.metrics["multiline-anchor"]) + @skip_unless_feature("raqm") class TestImageFont_RaqmLayout(TestImageFont): diff --git a/Tests/test_imagefontctl.py b/Tests/test_imagefontctl.py index 0abd681fe..85f9cdc20 100644 --- a/Tests/test_imagefontctl.py +++ b/Tests/test_imagefontctl.py @@ -209,6 +209,57 @@ def test_language(): assert_image_similar(im, target_img, 0.5) +@pytest.mark.parametrize("mode", ("L", "1")) +@pytest.mark.parametrize( + "text,direction,expected", + ( + ("سلطنة عمان Oman", None, 173.703125), + ("سلطنة عمان Oman", "ltr", 173.703125), + ("Oman سلطنة عمان", "rtl", 173.703125), + ("English عربي", "rtl", 123.796875), + ("test", "ttb", 80.0), + ), + ids=("None", "ltr", "rtl2", "rtl", "ttb"), +) +def test_getlength(mode, text, direction, expected): + try: + ttf = ImageFont.truetype(FONT_PATH, FONT_SIZE) + + assert ttf.getlength(text, mode, direction) == expected + except ValueError as ex: + if ( + direction == "ttb" + and str(ex) == "libraqm 0.7 or greater required for 'ttb' direction" + ): + pytest.skip("libraqm 0.7 or greater not available") + + +@pytest.mark.parametrize("mode", ("L", "1")) +@pytest.mark.parametrize("direction", ("ltr", "ttb")) +@pytest.mark.parametrize( + "text", + ("i" + ("\u030C" * 15) + "i", "i" + "\u032C" * 15 + "i", "\u035Cii", "i\u0305i"), + ids=("caron-above", "caron-below", "double-breve", "overline"), +) +def test_getlength_combine(mode, direction, text): + if text == "i\u0305i" and direction == "ttb": + pytest.skip("fails with this font") + + ttf = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 48) + + try: + target = ttf.getlength("ii", mode, direction) + actual = ttf.getlength(text, mode, direction) + + assert actual == target + except ValueError as ex: + if ( + direction == "ttb" + and str(ex) == "libraqm 0.7 or greater required for 'ttb' direction" + ): + pytest.skip("libraqm 0.7 or greater not available") + + @pytest.mark.parametrize("anchor", ("lt", "mm", "rb", "sm")) def test_anchor_ttb(anchor): if distutils.version.StrictVersion(ImageFont.core.freetype2_version) < "2.5.1": @@ -291,8 +342,42 @@ def test_combine(name, text, dir, anchor, epsilon): try: d.text((200, 200), text, fill="black", anchor=anchor, direction=dir, font=f) except ValueError as ex: - if str(ex) == "libraqm 0.7 or greater required for 'ttb' direction": + if ( + dir == "ttb" + and str(ex) == "libraqm 0.7 or greater required for 'ttb' direction" + ): pytest.skip("libraqm 0.7 or greater not available") with Image.open(path) as expected: assert_image_similar(im, expected, epsilon) + + +@pytest.mark.parametrize( + "anchor,align", + ( + ("lm", "left"), # pass with getsize + ("lm", "center"), # fail at 2.12 + ("lm", "right"), # fail at 2.57 + ("mm", "left"), # fail at 2.12 + ("mm", "center"), # pass with getsize + ("mm", "right"), # fail at 2.12 + ("rm", "left"), # fail at 2.57 + ("rm", "center"), # fail at 2.12 + ("rm", "right"), # pass with getsize + ), +) +def test_combine_multiline(anchor, align): + # test that multiline text uses getline, not getsize or getbbox + + path = "Tests/images/test_combine_multiline_%s_%s.png" % (anchor, align) + f = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 48) + text = "i\u0305\u035C\ntext" # i with overline and double breve, and a word + + im = Image.new("RGB", (400, 400), "white") + d = ImageDraw.Draw(im) + d.line(((0, 200), (400, 200)), "gray") + d.line(((200, 0), (200, 400)), "gray") + d.multiline_text((200, 200), text, fill="black", anchor=anchor, font=f, align=align) + + with Image.open(path) as expected: + assert_image_similar(im, expected, 0.015) diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 07b3a640d..bef0d7964 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -379,6 +379,9 @@ class ImageDraw: elif anchor[1] in "tb": raise ValueError("anchor not supported for multiline text") + if font is None: + font = self.getfont() + widths = [] max_width = 0 lines = self._multiline_split(text) @@ -386,13 +389,12 @@ class ImageDraw: self.textsize("A", font=font, stroke_width=stroke_width)[1] + spacing ) for line in lines: - line_width, line_height = self.textsize( + line_width = font.getlength( line, - font, + self.fontmode, direction=direction, features=features, language=language, - stroke_width=stroke_width, ) widths.append(line_width) max_width = max(max_width, line_width) diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 5612270d4..533f239c4 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -215,6 +215,113 @@ class FreeTypeFont: """ return self.font.ascent, self.font.descent + def getlength(self, text, mode="", direction=None, features=None, language=None): + """ + Returns length (in pixels) of given text if rendered in font with + provided direction, features, and language. + + This is the amount by which following text should be offset. + Text bounding box may extend past the length in some fonts, + e.g. when using italics or accents. + + The result is returned as a float; it is a whole number if using basic layout. + + :param text: Text to measure. + :param mode: Used by some graphics drivers to indicate what mode the + driver prefers; if empty, the renderer may return either + mode. Note that the mode is always a string, to simplify + C-level implementations. + + :param direction: Direction of the text. It can be 'rtl' (right to + left), 'ltr' (left to right) or 'ttb' (top to bottom). + Requires libraqm. + + :param features: A list of OpenType font features to be used during text + layout. This is usually used to turn on optional + font features that are not enabled by default, + for example 'dlig' or 'ss01', but can be also + used to turn off default font features for + example '-liga' to disable ligatures or '-kern' + to disable kerning. To get all supported + features, see + https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist + Requires libraqm. + + :param language: Language of the text. Different languages may use + different glyph shapes or ligatures. This parameter tells + the font which language the text is in, and to apply the + correct substitutions as appropriate, if available. + It should be a `BCP 47 language code + ` + Requires libraqm. + + :return: Width for horizontal, height for vertical text. + """ + return ( + self.font.getlength(text, mode == "1", direction, features, language) / 64 + ) + + def getbbox( + self, + text, + mode="", + direction=None, + features=None, + language=None, + stroke_width=0, + anchor=None, + ): + """ + Returns bounding box (in pixels) of given text relative to given anchor + if rendered in font with provided direction, features, and language. + + Use :py:func`getlength()` to get the offset of following text. The bounding + box includes extra margins for some fonts, e.g. italics or accents. + + :param text: Text to render. + :param mode: Used by some graphics drivers to indicate what mode the + driver prefers; if empty, the renderer may return either + mode. Note that the mode is always a string, to simplify + C-level implementations. + + :param direction: Direction of the text. It can be 'rtl' (right to + left), 'ltr' (left to right) or 'ttb' (top to bottom). + Requires libraqm. + + :param features: A list of OpenType font features to be used during text + layout. This is usually used to turn on optional + font features that are not enabled by default, + for example 'dlig' or 'ss01', but can be also + used to turn off default font features for + example '-liga' to disable ligatures or '-kern' + to disable kerning. To get all supported + features, see + https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist + Requires libraqm. + + :param language: Language of the text. Different languages may use + different glyph shapes or ligatures. This parameter tells + the font which language the text is in, and to apply the + correct substitutions as appropriate, if available. + It should be a `BCP 47 language code + ` + Requires libraqm. + + :param stroke_width: The width of the text stroke. + + :param anchor: The text anchor alignment. Determines the relative location of + the anchor to the text. The default alignment is top left. + See :ref:`text-anchors` for valid values. + + :return: ``(left, top, right, bottom)`` bounding box + """ + size, offset = self.font.getsize( + text, mode == "1", direction, features, language, anchor + ) + left, top = offset[0] - stroke_width, offset[1] - stroke_width + width, height = size[0] + stroke_width, size[1] + stroke_width + return left, top, left + width, top + height + def getsize( self, text, direction=None, features=None, language=None, stroke_width=0 ): @@ -222,6 +329,9 @@ class FreeTypeFont: Returns width and height (in pixels) of given text if rendered in font with provided direction, features, and language. + Use :py:func:`getlength()` to measure the offset of following text. + Use :py:func:`getbbox()` to get the exact bounding box based on an anchor. + :param text: Text to measure. :param direction: Direction of the text. It can be 'rtl' (right to diff --git a/src/_imagingft.c b/src/_imagingft.c index ac4e28ebc..4e60de5c5 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -607,6 +607,50 @@ text_layout(PyObject* string, FontObject* self, const char* dir, PyObject *featu return count; } +static PyObject* +font_getlength(FontObject* self, PyObject* args) +{ + int length; + FT_Face face; + int horizontal_dir; + int mask = 0; + const char *dir = NULL; + const char *lang = NULL; + size_t i, count; + GlyphInfo *glyph_info = NULL; + PyObject *features = Py_None; + + /* calculate size and bearing for a given string */ + + PyObject* string; + if (!PyArg_ParseTuple(args, "O|izOz:getlength", &string, &mask, &dir, &features, &lang)) { + return NULL; + } + + horizontal_dir = dir && strcmp(dir, "ttb") == 0 ? 0 : 1; + + count = text_layout(string, self, dir, features, lang, &glyph_info, mask); + if (PyErr_Occurred()) { + return NULL; + } + + length = 0; + for (i = 0; i < count; i++) { + if (horizontal_dir) { + length += glyph_info[i].x_advance; + } else { + length -= glyph_info[i].y_advance; + } + } + + if (glyph_info) { + PyMem_Free(glyph_info); + glyph_info = NULL; + } + + return PyLong_FromLong(length); +} + static PyObject* font_getsize(FontObject* self, PyObject* args) { @@ -1169,6 +1213,7 @@ font_dealloc(FontObject* self) static PyMethodDef font_methods[] = { {"render", (PyCFunction) font_render, METH_VARARGS}, {"getsize", (PyCFunction) font_getsize, METH_VARARGS}, + {"getlength", (PyCFunction) font_getlength, METH_VARARGS}, #if FREETYPE_MAJOR > 2 ||\ (FREETYPE_MAJOR == 2 && FREETYPE_MINOR > 9) ||\ (FREETYPE_MAJOR == 2 && FREETYPE_MINOR == 9 && FREETYPE_PATCH == 1)