From bd693f7ecf8eee9e05d7c4be9b376e86710ac997 Mon Sep 17 00:00:00 2001 From: nulano Date: Tue, 14 Apr 2020 13:28:09 +0200 Subject: [PATCH] implement text anchor for truetype fonts --- docs/reference/ImageDraw.rst | 82 ++++++++++++++++++++++++++++-- src/PIL/ImageDraw.py | 40 ++++++++++++--- src/PIL/ImageFont.py | 18 ++++++- src/_imagingft.c | 96 ++++++++++++++++++++++++++++++++---- 4 files changed, 216 insertions(+), 20 deletions(-) diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst index 782b0434b..b6c64ac97 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -74,6 +74,61 @@ To load a OpenType/TrueType font, use the truetype function in the :py:mod:`~PIL.ImageFont` module. Note that this function depends on third-party libraries, and may not available in all PIL builds. +Text anchors +^^^^^^^^^^^^ + +The ``anchor`` parameter determines the position of the ``xy`` coordinates relative to the text. +It consists of two characters, the horizontal and vertical alignment. + +The default value is ``la`` for horizontal text and ``lt`` for vertical text. + +This parameter is ignored for legacy PIL fonts, where the anchor is always top-left. + ++---+-----------------------+-------------------------------------------------------+ +| Horizontal anchor alignment | ++===+=======================+=======================================================+ +| l | left | Anchor is to the left of the text. | ++---+-----------------------+-------------------------------------------------------+ +| m | middle | Anchor is horizontally centered with the text. | ++---+-----------------------+-------------------------------------------------------+ +| r | right | Anchor is to the right of the text. | ++---+-----------------------+-------------------------------------------------------+ +| s | baseline | **(vertical text only)** | +| | | Anchor is at the baseline (middle) of the text. | +| | | The exact alignment depends on the font. | ++---+-----------------------+-------------------------------------------------------+ + ++---+-----------------------+-------------------------------------------------------+ +| Vertical anchor alignment | ++===+=======================+=======================================================+ +| a | ascender (top) | **(horizontal text only)** | +| | | Anchor is at the ascender line (top) | +| | | of the first line of text. | ++---+-----------------------+-------------------------------------------------------+ +| t | top | **(single-line text only)** | +| | | Anchor is at the top of the text. | ++---+-----------------------+-------------------------------------------------------+ +| m | middle | Anchor is vertically centered with the text. | +| | | For horizontal text this is the midpoint of the | +| | | first ascender line and the last descender line. | ++---+-----------------------+-------------------------------------------------------+ +| s | baseline | **(horizontal text only)** | +| | | Anchor is at the baseline (bottom) | +| | | of the first line of text, only | +| | | descenders extend below the anchor. | ++---+-----------------------+-------------------------------------------------------+ +| b | bottom | **(single-line text only)** | +| | | Anchor is at the bottom of the text. | ++---+-----------------------+-------------------------------------------------------+ +| d | descender (bottom) | **(horizontal text only)** | +| | | Anchor is at the descender line (bottom) | +| | | of the last line of text. | ++---+-----------------------+-------------------------------------------------------+ + +See `Font metrics on Wikipedia `_ +for more information on the specific terms used. + + Example: Draw Partial Opacity Text ---------------------------------- @@ -277,18 +332,27 @@ Methods Draws the string at the given position. - :param xy: Top left corner of the text. + :param xy: The anchor coordinates of the text. :param text: Text to be drawn. If it contains any newline characters, the text is passed on to :py:meth:`~PIL.ImageDraw.ImageDraw.multiline_text`. :param fill: Color to use for the text. :param font: An :py:class:`~PIL.ImageFont.ImageFont` instance. + :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. This parameter is + ignored for legacy PIL fonts. + + .. note:: This parameter was present in earlier versions + of Pillow, but implemented only in version 7.2.0. + :param spacing: If the text is passed on to :py:meth:`~PIL.ImageDraw.ImageDraw.multiline_text`, the number of pixels between lines. :param align: If the text is passed on to :py:meth:`~PIL.ImageDraw.ImageDraw.multiline_text`, - ``"left"``, ``"center"`` or ``"right"``. + ``"left"``, ``"center"`` or ``"right"``. Determines the relative alignment of lines. + Use the ``anchor`` parameter to specify the alignment to ``xy``. :param direction: Direction of the text. It can be ``"rtl"`` (right to left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom). Requires libraqm. @@ -329,12 +393,22 @@ Methods Draws the string at the given position. - :param xy: Top left corner of the text. + :param xy: The anchor coordinates of the text. :param text: Text to be drawn. :param fill: Color to use for the text. :param font: An :py:class:`~PIL.ImageFont.ImageFont` instance. + + :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. This parameter is + ignored for legacy PIL fonts. + + .. note:: This parameter was present in earlier versions + of Pillow, but implemented only in version 7.2.0. + :param spacing: The number of pixels between lines. - :param align: ``"left"``, ``"center"`` or ``"right"``. + :param align: ``"left"``, ``"center"`` or ``"right"``. Determines the relative alignment of lines. + Use the ``anchor`` parameter to specify the alignment to ``xy``. :param direction: Direction of the text. It can be ``"rtl"`` (right to left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom). Requires libraqm. diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index cbecf652d..d0695fd84 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -314,6 +314,7 @@ class ImageDraw: features=features, language=language, stroke_width=stroke_width, + anchor=anchor, *args, **kwargs, ) @@ -327,6 +328,7 @@ class ImageDraw: features, language, stroke_width, + anchor, *args, **kwargs, ) @@ -347,7 +349,7 @@ class ImageDraw: draw_text(stroke_ink, stroke_width) # Draw normal text - draw_text(ink, 0, (stroke_width, stroke_width)) + draw_text(ink, 0) else: # Only draw normal text draw_text(ink) @@ -367,6 +369,16 @@ class ImageDraw: stroke_width=0, stroke_fill=None, ): + if direction == "ttb": + raise ValueError("ttb direction is unsupported for multiline text") + + if anchor is None: + anchor = "la" + elif len(anchor) != 2: + raise ValueError("anchor must be a 2 character string") + elif anchor[1] in "tb": + raise ValueError("anchor not supported for multiline text") + widths = [] max_width = 0 lines = self._multiline_split(text) @@ -384,16 +396,33 @@ class ImageDraw: ) widths.append(line_width) max_width = max(max_width, line_width) - left, top = xy + + top = xy[1] + if anchor[1] == 'm': + top -= (len(lines) - 1) * line_spacing / 2.0 + elif anchor[1] == 'd': + top -= (len(lines) - 1) * line_spacing + for idx, line in enumerate(lines): + left = xy[0] + width_difference = max_width - widths[idx] + + # first align left by anchor + if anchor[0] == 'm': + left -= width_difference / 2.0 + elif anchor[0] == 'r': + left -= width_difference + + # then align by align parameter if align == "left": - pass # left = x + pass elif align == "center": - left += (max_width - widths[idx]) / 2.0 + left += width_difference / 2.0 elif align == "right": - left += max_width - widths[idx] + left += width_difference else: raise ValueError('align must be "left", "center" or "right"') + self.text( (left, top), line, @@ -407,7 +436,6 @@ class ImageDraw: stroke_fill=stroke_fill, ) top += line_spacing - left = xy[0] def textsize( self, diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 617a7e5a7..c9278d0ac 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -345,6 +345,7 @@ class FreeTypeFont: features=None, language=None, stroke_width=0, + anchor=None, ): """ Create a bitmap for the text. @@ -393,6 +394,12 @@ class FreeTypeFont: .. versionadded:: 6.2.0 + :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. + + .. versionadded:: 7.2.0 + :return: An internal PIL storage memory instance as defined by the :py:mod:`PIL.Image.core` interface module. """ @@ -403,6 +410,7 @@ class FreeTypeFont: features=features, language=language, stroke_width=stroke_width, + anchor=anchor, )[0] def getmask2( @@ -414,6 +422,7 @@ class FreeTypeFont: features=None, language=None, stroke_width=0, + anchor=None, *args, **kwargs ): @@ -464,14 +473,21 @@ class FreeTypeFont: .. versionadded:: 6.2.0 + :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. + + .. versionadded:: 7.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, mode == "1", direction, features, language + text, mode == "1", direction, features, language, anchor ) size = size[0] + stroke_width * 2, size[1] + stroke_width * 2 + offset = offset[0] - stroke_width, offset[1] - stroke_width im = fill("L", size, 0) self.font.render( text, im.id, mode == "1", direction, features, language, stroke_width diff --git a/src/_imagingft.c b/src/_imagingft.c index 330ec48ad..1caf8573e 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -612,12 +612,13 @@ font_getsize(FontObject* self, PyObject* args) int position, advanced; int x_max, x_min, y_max, y_min; FT_Face face; - int xoffset, yoffset; + int x_anchor, y_anchor; int horizontal_dir; int mask = 0; int load_flags; const char *dir = NULL; const char *lang = NULL; + const char *anchor = NULL; size_t i, count; GlyphInfo *glyph_info = NULL; PyObject *features = Py_None; @@ -625,10 +626,19 @@ font_getsize(FontObject* self, PyObject* args) /* calculate size and bearing for a given string */ PyObject* string; - if (!PyArg_ParseTuple(args, "O|izOz:getsize", &string, &mask, &dir, &features, &lang)) { + if (!PyArg_ParseTuple(args, "O|izOzzz:getsize", &string, &mask, &dir, &features, &lang, &anchor)) { return NULL; } + horizontal_dir = dir && strcmp(dir, "ttb") == 0 ? 0 : 1; + + if (anchor == NULL) { + anchor = horizontal_dir ? "la" : "lt"; + } + if (strlen(anchor) != 2) { + goto bad_anchor; + } + count = text_layout(string, self, dir, features, lang, &glyph_info, mask); if (PyErr_Occurred()) { return NULL; @@ -636,7 +646,6 @@ font_getsize(FontObject* self, PyObject* args) face = NULL; position = x_max = x_min = y_max = y_min = 0; - horizontal_dir = dir && strcmp(dir, "ttb") == 0 ? 0 : 1; for (i = 0; i < count; i++) { int index, error, offset; FT_BBox bbox; @@ -733,21 +742,90 @@ font_getsize(FontObject* self, PyObject* args) glyph_info = NULL; } + x_anchor = y_anchor = 0; if (face) { if (horizontal_dir) { - xoffset = 0; - yoffset = self->face->size->metrics.ascender - y_max; + switch (anchor[0]) { + case 'l': // left + x_anchor = 0; + break; + case 'm': // middle (left + right) / 2 + x_anchor = position / 2; + break; + case 'r': // right + x_anchor = position; + break; + case 's': // vertical baseline + default: + goto bad_anchor; + } + switch (anchor[1]) { + case 'a': // ascender + y_anchor = self->face->size->metrics.ascender; + break; + case 't': // top + y_anchor = y_max; + break; + case 'm': // middle (ascender + descender) / 2 + y_anchor = (self->face->size->metrics.ascender + self->face->size->metrics.descender) / 2; + break; + case 's': // horizontal baseline + y_anchor = 0; + break; + case 'b': // bottom + y_anchor = y_min; + break; + case 'd': // descender + y_anchor = self->face->size->metrics.descender; + break; + default: + goto bad_anchor; + } } else { - xoffset = 0; - yoffset = -y_max; + switch (anchor[0]) { + case 'l': // left + x_anchor = x_min; + break; + case 'm': // middle (left + right) / 2 + x_anchor = (x_min + x_max) / 2; + break; + case 'r': // right + x_anchor = x_max; + break; + case 's': // vertical baseline + x_anchor = 0; + break; + default: + goto bad_anchor; + } + switch (anchor[1]) { + case 't': // top + y_anchor = 0; + break; + case 'm': // middle (top + bottom) / 2 + y_anchor = position / 2; + break; + case 'b': // bottom + y_anchor = position; + break; + case 'a': // ascender + case 's': // horizontal baseline + case 'd': // descender + default: + goto bad_anchor; + } } } return Py_BuildValue( "(ii)(ii)", PIXEL(x_max - x_min), PIXEL(y_max - y_min), - PIXEL(xoffset), PIXEL(yoffset) - ); + PIXEL(-x_anchor + x_min), PIXEL(y_anchor - y_max) + ); + +bad_anchor: + PyErr_Format(PyExc_ValueError, "bad anchor specified: %s", anchor); + return NULL; } static PyObject*