add getbbox and getlength, with tests

Squashed commits:

[ec9ec31b] add tests for invalid anchor
(cherry picked from commit 9e50a6a47f79876ee56942152047f03fff03c49b)

[386a9170] fix lint and docs
(cherry picked from commit 2d0d5282fcfc3ee332a41e60b865ee766445cc3d)

[29f5d4c9] restore and document previous getsize behaviour
see discussion in issue 4789
(cherry picked from commit 9fbc94571ce0ed42fdd11e99f343a1613c9dc6d3)

[0ffd51a0] add getbbox and getlength, with tests
(cherry picked from commit c5f63737476a998c81e589e5819d21ca69bb7b46)
This commit is contained in:
nulano 2020-06-20 12:54:53 +02:00
parent 41d935a5a2
commit 395aa946a9
17 changed files with 344 additions and 13 deletions

View File

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

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -41,6 +41,7 @@ class TestImageFont:
"getters": (13, 16),
"mask": (107, 13),
"multiline-anchor": 6,
"getlength": (36, 27, 27, 33),
},
(">=2.7",): {
"multiline": 6.2,
@ -48,6 +49,7 @@ class TestImageFont:
"getters": (12, 16),
"mask": (108, 13),
"multiline-anchor": 4,
"getlength": (36, 21, 24, 33),
},
"Default": {
"multiline": 0.5,
@ -55,6 +57,7 @@ class TestImageFont:
"getters": (12, 16),
"mask": (108, 13),
"multiline-anchor": 4,
"getlength": (36, 24, 24, 33),
},
}
@ -198,6 +201,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)
@ -754,27 +785,42 @@ class TestImageFont:
self._check_text(font, "Tests/images/variation_tiny_axes.png", 32.5)
@pytest.mark.parametrize(
"anchor",
"anchor,left,left_old,top",
(
# test horizontal anchors
"ls",
"ms",
"rs",
("ls", 0, 0, -36),
("ms", -64, -65, -36),
("rs", -128, -129, -36),
# test vertical anchors
"ma",
"mt",
"mm",
"mb",
"md",
("ma", -64, -65, 16),
("mt", -64, -65, 0),
("mm", -64, -65, -17),
("mb", -64, -65, -44),
("md", -64, -65, -51),
),
ids=("ls", "ms", "rs", "ma", "mt", "mm", "mb", "md"),
)
def test_anchor(self, anchor):
def test_anchor(self, anchor, left, left_old, top):
name, text = "quick", "Quick"
path = f"Tests/images/test_anchor_{name}_{anchor}.png"
freetype = parse_version(features.version_module("freetype2"))
if freetype < parse_version("2.4"):
width, height = (129, 44)
left = left_old
elif self.LAYOUT_ENGINE == ImageFont.LAYOUT_RAQM:
width, height = (129, 44)
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")
@ -831,6 +877,7 @@ class TestImageFont:
for anchor in ["", "l", "a", "lax", "sa", "xa", "lx"]:
pytest.raises(ValueError, lambda: font.getmask2("hello", anchor=anchor))
pytest.raises(ValueError, lambda: font.getbbox("hello", anchor=anchor))
pytest.raises(ValueError, lambda: d.text((0, 0), "hello", anchor=anchor))
pytest.raises(
ValueError, lambda: d.multiline_text((0, 0), "foo\nbar", anchor=anchor)

View File

@ -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 parse_version(features.version_module("freetype2")) < parse_version("2.5.1"):
@ -298,6 +349,37 @@ def test_combine(name, text, dir, anchor, epsilon):
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 getlength, not getsize or getbbox
path = f"Tests/images/test_combine_multiline_{anchor}_{align}.png"
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)
def test_anchor_invalid_ttb():
font = ImageFont.truetype(FONT_PATH, FONT_SIZE)
im = Image.new("RGB", (100, 100), "white")
@ -308,6 +390,9 @@ def test_anchor_invalid_ttb():
pytest.raises(
ValueError, lambda: font.getmask2("hello", anchor=anchor, direction="ttb")
)
pytest.raises(
ValueError, lambda: font.getbbox("hello", anchor=anchor, direction="ttb")
)
pytest.raises(
ValueError, lambda: d.text((0, 0), "hello", anchor=anchor, direction="ttb")
)

View File

@ -403,6 +403,15 @@ Methods
Return the size of the given string, in pixels.
You can use :meth:`.FreeTypeFont.getlength` to measure text length
with 1/64 pixel precision.
.. note:: For historical reasons this function measures text height from
the ascender line instead of the top, see :ref:`text-anchors`.
If you wish to measure text height from the top, it is recommended
to use :meth:`.FreeTypeFont.getbbox` with ``anchor='lt'`` instead.
:param text: Text to be measured. If it contains any newline characters,
the text is passed on to :py:meth:`~PIL.ImageDraw.ImageDraw.multiline_textsize`.
:param font: An :py:class:`~PIL.ImageFont.ImageFont` instance.

View File

@ -385,6 +385,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)
@ -392,13 +395,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)

View File

@ -215,6 +215,140 @@ class FreeTypeFont:
"""
return self.font.ascent, self.font.descent
def getlength(self, text, mode="", direction=None, features=None, language=None):
"""
Returns length (in pixels with 1/64 precision) 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.
Note that the sum of two lengths may not equal the length of a concatenated
string due to kerning. If you need to adjust for kerning, include the following
character and subtract its length.
For example, instead of
.. code-block:: python
hello = font.getlength("Hello")
world = font.getlength("World")
hello_world = hello + world # not adjusted for kerning
assert hello_world == font.getlength("HelloWorld") # may fail
use
.. code-block:: python
hello = font.getlength("HelloW") - font.getlength("W") # adjusted for kerning
world = font.getlength("World")
hello_world = hello + world # adjusted for kerning
assert hello_world == font.getlength("HelloWorld") # True
.. versionadded:: 8.0.0
: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
<https://www.w3.org/International/articles/language-tags/>`
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:meth`getlength()` to get the offset of following text with
1/64 pixel precision. The bounding box includes extra margins for
some fonts, e.g. italics or accents.
.. versionadded:: 8.0.0
: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
<https://www.w3.org/International/articles/language-tags/>`
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] + 2 * stroke_width, size[1] + 2 * stroke_width
return left, top, left + width, top + height
def getsize(
self, text, direction=None, features=None, language=None, stroke_width=0
):
@ -222,6 +356,15 @@ class FreeTypeFont:
Returns width and height (in pixels) of given text if rendered in font with
provided direction, features, and language.
Use :py:meth:`getlength()` to measure the offset of following text with
1/64 pixel precision.
Use :py:meth:`getbbox()` to get the exact bounding box based on an anchor.
.. note:: For historical reasons this function measures text height from
the ascender line instead of the top, see :ref:`text-anchors`.
If you wish to measure text height from the top, it is recommended
to use the bottom value of :meth:`getbbox` with ``anchor='lt'`` instead.
:param text: Text to measure.
:param direction: Direction of the text. It can be 'rtl' (right to

View File

@ -607,6 +607,49 @@ text_layout(PyObject* string, FontObject* self, const char* dir, PyObject *featu
return count;
}
static PyObject*
font_getlength(FontObject* self, PyObject* args)
{
int length; /* length along primary axis, in 26.6 precision */
GlyphInfo *glyph_info = NULL; /* computed text layout */
size_t i, count; /* glyph_info index and length */
int horizontal_dir; /* is primary axis horizontal? */
int mask = 0; /* is FT_LOAD_TARGET_MONO enabled? */
const char *dir = NULL;
const char *lang = NULL;
PyObject *features = Py_None;
PyObject *string;
/* calculate size and bearing for a given 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)
{
@ -1176,6 +1219,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)