Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for CBDT and COLR fonts #4955

Merged
merged 13 commits into from
Oct 12, 2020
4 changes: 4 additions & 0 deletions .github/workflows/test-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ jobs:
- name: Build dependencies / WebP
if: steps.build-cache.outputs.cache-hit != 'true'
run: "& winbuild\\build\\build_dep_libwebp.cmd"
# for FreeType CBDT font support
- name: Build dependencies / libpng
if: steps.build-cache.outputs.cache-hit != 'true'
run: "& winbuild\\build\\build_dep_libpng.cmd"
- name: Build dependencies / FreeType
if: steps.build-cache.outputs.cache-hit != 'true'
run: "& winbuild\\build\\build_dep_freetype.cmd"
Expand Down
Binary file added Tests/fonts/BungeeColor-Regular_colr_Windows.ttf
Binary file not shown.
Binary file added Tests/fonts/DejaVuSans-24-1-stripped.ttf
Binary file not shown.
Binary file added Tests/fonts/DejaVuSans-24-2-stripped.ttf
Binary file not shown.
Binary file added Tests/fonts/DejaVuSans-24-4-stripped.ttf
Binary file not shown.
Binary file added Tests/fonts/DejaVuSans-24-8-stripped.ttf
Binary file not shown.
5 changes: 5 additions & 0 deletions Tests/fonts/LICENSE.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,19 @@
NotoNastaliqUrdu-Regular.ttf and NotoSansSymbols-Regular.ttf, from https://github.com/googlei18n/noto-fonts
NotoSans-Regular.ttf, from https://www.google.com/get/noto/
NotoSansJP-Thin.otf, from https://www.google.com/get/noto/help/cjk/
NotoColorEmoji.ttf, from https://github.com/googlefonts/noto-emoji
AdobeVFPrototype.ttf, from https://github.com/adobe-fonts/adobe-variable-font-prototype
TINY5x3GX.ttf, from http://velvetyne.fr/fonts/tiny
ArefRuqaa-Regular.ttf, from https://github.com/google/fonts/tree/master/ofl/arefruqaa
ter-x20b.pcf, from http://terminus-font.sourceforge.net/
BungeeColor-Regular_colr_Windows.ttf, from https://github.com/djrrb/bungee

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.


DejaVuSans-24-{1,2,4,8}-stripped.ttf are based on DejaVuSans.ttf converted using FontForge to add bitmap strikes and keep only the ASCII range.


10x20-ISO8859-1.pcf, from https://packages.ubuntu.com/xenial/xfonts-base

"Public domain font. Share and enjoy."
Binary file added Tests/fonts/NotoColorEmoji.ttf
Binary file not shown.
Binary file added Tests/images/bitmap_font_1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Tests/images/bitmap_font_2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Tests/images/bitmap_font_4.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Tests/images/bitmap_font_8.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Tests/images/cbdt_notocoloremoji.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Tests/images/cbdt_notocoloremoji_mask.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Tests/images/colr_bungee.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Tests/images/colr_bungee_mask.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Tests/images/standard_embedded.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
96 changes: 96 additions & 0 deletions Tests/test_imagefont.py
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,102 @@ def test_anchor_invalid(self):
ValueError, lambda: d.multiline_text((0, 0), "foo\nbar", anchor=anchor)
)

def test_standard_embedded_color(self):
txt = "Hello World!"
ttf = ImageFont.truetype(FONT_PATH, 40, layout_engine=self.LAYOUT_ENGINE)
ttf.getsize(txt)

img = Image.new("RGB", (300, 64), "white")
d = ImageDraw.Draw(img)
d.text((10, 10), txt, font=ttf, fill="#fa6", embedded_color=True)

with Image.open("Tests/images/standard_embedded.png") as expected:
assert_image_similar(img, expected, max(self.metrics["multiline"], 3))
nulano marked this conversation as resolved.
Show resolved Hide resolved

@pytest.mark.xfail(is_pypy(), reason="failing on PyPy with Raqm")
def test_cbdt(self):
if parse_version(features.version_module("freetype2")) < parse_version("2.5.0"):
pytest.skip("Freetype 2.5.0 or newer required")
try:
font = ImageFont.truetype(
"Tests/fonts/NotoColorEmoji.ttf",
size=109,
layout_engine=self.LAYOUT_ENGINE,
)

im = Image.new("RGB", (150, 150), "white")
d = ImageDraw.Draw(im)

d.text((10, 10), "\U0001f469", embedded_color=True, font=font)

with Image.open("Tests/images/cbdt_notocoloremoji.png") as expected:
assert_image_similar(im, expected, self.metrics["multiline"])
except IOError as ex:
assert str(ex) in ("unimplemented feature", "unknown file format")
nulano marked this conversation as resolved.
Show resolved Hide resolved
pytest.skip("freetype compiled without libpng or unsupported")

@pytest.mark.xfail(is_pypy(), reason="failing on PyPy with Raqm")
def test_cbdt_mask(self):
if parse_version(features.version_module("freetype2")) < parse_version("2.5.0"):
pytest.skip("Freetype 2.5.0 or newer required")
try:
font = ImageFont.truetype(
"Tests/fonts/NotoColorEmoji.ttf",
size=109,
layout_engine=self.LAYOUT_ENGINE,
)

im = Image.new("RGB", (150, 150), "white")
d = ImageDraw.Draw(im)

d.text((10, 10), "\U0001f469", "black", font=font)

with Image.open("Tests/images/cbdt_notocoloremoji_mask.png") as expected:
assert_image_similar(im, expected, self.metrics["multiline"])
except IOError as ex:
assert str(ex) in ("unimplemented feature", "unknown file format")
nulano marked this conversation as resolved.
Show resolved Hide resolved
pytest.skip("freetype compiled without libpng or unsupported")

def test_colr(self):
if parse_version(features.version_module("freetype2")) < parse_version(
"2.10.0"
):
pytest.skip("Freetype 2.10.0 or newer required")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's quite a few Freetype version check and skips, let's put them into a helper, for example:

def skip_unless_freetype(required_version, message):

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went with the following to let the check be reusable for other features:

def skip_unless_feature_version(feature, version_required, reason=None):


font = ImageFont.truetype(
"Tests/fonts/BungeeColor-Regular_colr_Windows.ttf",
size=64,
layout_engine=self.LAYOUT_ENGINE,
)

im = Image.new("RGB", (300, 75), "white")
d = ImageDraw.Draw(im)

d.text((15, 5), "Bungee", embedded_color=True, font=font)

with Image.open("Tests/images/colr_bungee.png") as expected:
assert_image_similar(im, expected, 21)

def test_colr_mask(self):
if parse_version(features.version_module("freetype2")) < parse_version(
"2.10.0"
):
pytest.skip("Freetype 2.10.0 or newer required")

font = ImageFont.truetype(
"Tests/fonts/BungeeColor-Regular_colr_Windows.ttf",
size=64,
layout_engine=self.LAYOUT_ENGINE,
)

im = Image.new("RGB", (300, 75), "white")
d = ImageDraw.Draw(im)

d.text((15, 5), "Bungee", "black", font=font)

with Image.open("Tests/images/colr_bungee_mask.png") as expected:
assert_image_similar(im, expected, 22)


@skip_unless_feature("raqm")
class TestImageFont_RaqmLayout(TestImageFont):
Expand Down
23 changes: 21 additions & 2 deletions Tests/test_imagefont_bitmap.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from PIL import Image, ImageDraw, ImageFont

from .helper import assert_image_similar
from .helper import assert_image_equal_tofile, assert_image_similar, skip_unless_feature

image_font_installed = True
try:
Expand All @@ -26,6 +26,7 @@ def test_similar():
im_outline = im_bitmap.copy()
draw_bitmap = ImageDraw.Draw(im_bitmap)
draw_outline = ImageDraw.Draw(im_outline)
draw_outline.fontmode = "1" # disable anti-aliasing to match bitmap font

# Metrics are different on the bitmap and TTF fonts,
# more so on some platforms and versions of FreeType than others.
Expand All @@ -39,4 +40,22 @@ def test_similar():
fill=(0, 0, 0),
font=font_outline,
)
assert_image_similar(im_bitmap, im_outline, 20)
assert_image_similar(im_bitmap, im_outline, 4)


@skip_unless_feature("freetype2")
@pytest.mark.parametrize("bpp", (1, 2, 4, 8))
def test_bitmap_font(bpp):
nulano marked this conversation as resolved.
Show resolved Hide resolved
text = "Bitmap Font"
target = f"Tests/images/bitmap_font_{bpp}.png"
font = ImageFont.truetype(
f"Tests/fonts/DejaVuSans-24-{bpp}-stripped.ttf",
24,
layout_engine=ImageFont.LAYOUT_BASIC,
)

im = Image.new("RGB", (160, 35), "white")
draw = ImageDraw.Draw(im)
draw.text((2, 2), text, "black", font)

assert_image_equal_tofile(im, target)
22 changes: 20 additions & 2 deletions docs/reference/ImageDraw.rst
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ Methods

Draw a shape.

.. py:method:: ImageDraw.text(xy, text, fill=None, font=None, anchor=None, spacing=4, align="left", direction=None, features=None, language=None, stroke_width=0, stroke_fill=None)
.. py:method:: ImageDraw.text(xy, text, fill=None, font=None, anchor=None, spacing=4, align="left", direction=None, features=None, language=None, stroke_width=0, stroke_fill=None, embedded_color=False)

Draws the string at the given position.

Expand Down Expand Up @@ -352,7 +352,12 @@ Methods

.. versionadded:: 6.2.0

.. py:method:: ImageDraw.multiline_text(xy, text, fill=None, font=None, anchor=None, spacing=4, align="left", direction=None, features=None, language=None)
:param embedded_color: Whether to use font embedded color glyphs (COLR or CBDT).

.. versionadded:: 8.0.0


.. py:method:: ImageDraw.multiline_text(xy, text, fill=None, font=None, anchor=None, spacing=4, align="left", direction=None, features=None, language=None, stroke_width=0, stroke_fill=None, embedded_color=False)

Draws the string at the given position.

Expand Down Expand Up @@ -399,6 +404,19 @@ 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

:param embedded_color: Whether to use font embedded color glyphs (COLR or CBDT).

.. versionadded:: 8.0.0
nulano marked this conversation as resolved.
Show resolved Hide resolved

.. py:method:: 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.
Expand Down
26 changes: 23 additions & 3 deletions src/PIL/ImageDraw.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ def text(
language=None,
stroke_width=0,
stroke_fill=None,
embedded_color=False,
*args,
**kwargs,
):
Expand All @@ -299,8 +300,12 @@ def text(
language,
stroke_width,
stroke_fill,
embedded_color,
)

if embedded_color and self.mode not in ("RGB", "RGBA"):
raise ValueError("Embedded color supported only in RGB and RGBA modes")

if font is None:
font = self.getfont()

Expand All @@ -311,16 +316,20 @@ def getink(fill):
return ink

def draw_text(ink, stroke_width=0, stroke_offset=None):
mode = self.fontmode
if stroke_width == 0 and embedded_color:
mode = "RGBA"
coord = xy
try:
mask, offset = font.getmask2(
text,
self.fontmode,
mode,
direction=direction,
features=features,
language=language,
stroke_width=stroke_width,
anchor=anchor,
ink=ink,
*args,
**kwargs,
)
Expand All @@ -329,20 +338,29 @@ def draw_text(ink, stroke_width=0, stroke_offset=None):
try:
mask = font.getmask(
text,
self.fontmode,
mode,
direction,
features,
language,
stroke_width,
anchor,
ink,
*args,
**kwargs,
)
except TypeError:
mask = font.getmask(text)
if stroke_offset:
coord = coord[0] + stroke_offset[0], coord[1] + stroke_offset[1]
self.draw.draw_bitmap(coord, mask, ink)
if mode == "RGBA":
# font.getmask2(mode="RGBA") returns color in RGB bands and mask in A
# extract mask and set text alpha
color, mask = mask, mask.getband(3)
color.fillband(3, (ink >> 24) & 0xFF)
coord2 = coord[0] + mask.size[0], coord[1] + mask.size[1]
self.im.paste(color, coord + coord2, mask)
else:
self.draw.draw_bitmap(coord, mask, ink)

ink = getink(fill)
if ink is not None:
Expand Down Expand Up @@ -374,6 +392,7 @@ def multiline_text(
language=None,
stroke_width=0,
stroke_fill=None,
embedded_color=False,
):
if direction == "ttb":
raise ValueError("ttb direction is unsupported for multiline text")
Expand Down Expand Up @@ -440,6 +459,7 @@ def multiline_text(
language=language,
stroke_width=stroke_width,
stroke_fill=stroke_fill,
embedded_color=embedded_color,
)
top += line_spacing

Expand Down
25 changes: 19 additions & 6 deletions src/PIL/ImageFont.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ def getsize(
"""
# vertical offset is added for historical reasons
# see https://github.com/python-pillow/Pillow/pull/4910#discussion_r486682929
size, offset = self.font.getsize(text, False, direction, features, language)
size, offset = self.font.getsize(text, "L", direction, features, language)
return (
size[0] + stroke_width * 2,
size[1] + stroke_width * 2 + offset[1],
Expand Down Expand Up @@ -348,12 +348,14 @@ def getmask(
language=None,
stroke_width=0,
anchor=None,
ink=0,
):
"""
Create a bitmap for the text.

If the font uses antialiasing, the bitmap should have mode ``L`` and use a
maximum value of 255. Otherwise, it should have mode ``1``.
maximum value of 255. If the font has embedded color data, the bitmap
should have mode ``RGBA``. Otherwise, it should have mode ``1``.

:param text: Text to render.
:param mode: Used by some graphics drivers to indicate what mode the
Expand Down Expand Up @@ -402,6 +404,10 @@ def getmask(

.. versionadded:: 8.0.0

:param ink: Foreground ink for rendering in RGBA mode.

.. versionadded:: 8.0.0

:return: An internal PIL storage memory instance as defined by the
:py:mod:`PIL.Image.core` interface module.
"""
Expand All @@ -413,6 +419,7 @@ def getmask(
language=language,
stroke_width=stroke_width,
anchor=anchor,
ink=ink,
)[0]

def getmask2(
Expand All @@ -425,14 +432,16 @@ def getmask2(
language=None,
stroke_width=0,
anchor=None,
ink=0,
*args,
**kwargs,
):
"""
Create a bitmap for the text.

If the font uses antialiasing, the bitmap should have mode ``L`` and use a
maximum value of 255. Otherwise, it should have mode ``1``.
maximum value of 255. If the font has embedded color data, the bitmap
should have mode ``RGBA``. Otherwise, it should have mode ``1``.

:param text: Text to render.
:param mode: Used by some graphics drivers to indicate what mode the
Expand Down Expand Up @@ -481,18 +490,22 @@ def getmask2(

.. versionadded:: 8.0.0

:param ink: Foreground ink for rendering in RGBA mode.

.. versionadded:: 8.0.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, anchor
text, mode, 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)
im = fill("RGBA" if mode == "RGBA" else "L", size, 0)
self.font.render(
text, im.id, mode == "1", direction, features, language, stroke_width
text, im.id, mode, direction, features, language, stroke_width, ink
)
return im, offset

Expand Down
Loading