add getbbox and getlength, with tests

This commit is contained in:
nulano 2020-06-20 12:54:53 +02:00
parent 9947d70c48
commit 573fb980f1
27 changed files with 358 additions and 20 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: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

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

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

View File

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

View File

@ -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
<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: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
<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] + 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

View File

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