diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index a6e85b688cc..7b3bc20aad8 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -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" diff --git a/Tests/fonts/BungeeColor-Regular_colr_Windows.ttf b/Tests/fonts/BungeeColor-Regular_colr_Windows.ttf new file mode 100644 index 00000000000..d8eabb3b6a3 Binary files /dev/null and b/Tests/fonts/BungeeColor-Regular_colr_Windows.ttf differ diff --git a/Tests/fonts/DejaVuSans-24-1-stripped.ttf b/Tests/fonts/DejaVuSans-24-1-stripped.ttf new file mode 100644 index 00000000000..8eaf1ee0811 Binary files /dev/null and b/Tests/fonts/DejaVuSans-24-1-stripped.ttf differ diff --git a/Tests/fonts/DejaVuSans-24-2-stripped.ttf b/Tests/fonts/DejaVuSans-24-2-stripped.ttf new file mode 100644 index 00000000000..23366725106 Binary files /dev/null and b/Tests/fonts/DejaVuSans-24-2-stripped.ttf differ diff --git a/Tests/fonts/DejaVuSans-24-4-stripped.ttf b/Tests/fonts/DejaVuSans-24-4-stripped.ttf new file mode 100644 index 00000000000..9accc9ebcaf Binary files /dev/null and b/Tests/fonts/DejaVuSans-24-4-stripped.ttf differ diff --git a/Tests/fonts/DejaVuSans-24-8-stripped.ttf b/Tests/fonts/DejaVuSans-24-8-stripped.ttf new file mode 100644 index 00000000000..0f93442678d Binary files /dev/null and b/Tests/fonts/DejaVuSans-24-8-stripped.ttf differ diff --git a/Tests/fonts/DejaVuSans-bitmap.ttf b/Tests/fonts/DejaVuSans-bitmap.ttf deleted file mode 100644 index 702cce37de2..00000000000 Binary files a/Tests/fonts/DejaVuSans-bitmap.ttf and /dev/null differ diff --git a/Tests/fonts/LICENSE.txt b/Tests/fonts/LICENSE.txt index 528eed9ef22..c4704dbda21 100644 --- a/Tests/fonts/LICENSE.txt +++ b/Tests/fonts/LICENSE.txt @@ -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." diff --git a/Tests/fonts/NotoColorEmoji.ttf b/Tests/fonts/NotoColorEmoji.ttf new file mode 100644 index 00000000000..ef7b725758c Binary files /dev/null and b/Tests/fonts/NotoColorEmoji.ttf differ diff --git a/Tests/helper.py b/Tests/helper.py index c8cbb80e1e1..3da2571f25e 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -11,6 +11,7 @@ from io import BytesIO import pytest +from packaging.version import parse as parse_version from PIL import Image, ImageMath, features @@ -162,6 +163,16 @@ def skip_unless_feature(feature): return pytest.mark.skipif(not features.check(feature), reason=reason) +def skip_unless_feature_version(feature, version_required, reason=None): + if not features.check(feature): + return pytest.mark.skip(f"{feature} not available") + if reason is None: + reason = f"{feature} is older than {version_required}" + version_required = parse_version(version_required) + version_available = parse_version(features.version(feature)) + return pytest.mark.skipif(version_available < version_required, reason=reason) + + @pytest.mark.skipif(sys.platform.startswith("win32"), reason="Requires Unix or macOS") class PillowLeakTestCase: # requires unix/macOS diff --git a/Tests/images/bitmap_font_1_basic.png b/Tests/images/bitmap_font_1_basic.png new file mode 100644 index 00000000000..01a05606c0a Binary files /dev/null and b/Tests/images/bitmap_font_1_basic.png differ diff --git a/Tests/images/bitmap_font_1_raqm.png b/Tests/images/bitmap_font_1_raqm.png new file mode 100644 index 00000000000..560efb68598 Binary files /dev/null and b/Tests/images/bitmap_font_1_raqm.png differ diff --git a/Tests/images/bitmap_font_2_basic.png b/Tests/images/bitmap_font_2_basic.png new file mode 100644 index 00000000000..44d137dd67c Binary files /dev/null and b/Tests/images/bitmap_font_2_basic.png differ diff --git a/Tests/images/bitmap_font_2_raqm.png b/Tests/images/bitmap_font_2_raqm.png new file mode 100644 index 00000000000..7a40bd6c239 Binary files /dev/null and b/Tests/images/bitmap_font_2_raqm.png differ diff --git a/Tests/images/bitmap_font_4_basic.png b/Tests/images/bitmap_font_4_basic.png new file mode 100644 index 00000000000..e79d86aa886 Binary files /dev/null and b/Tests/images/bitmap_font_4_basic.png differ diff --git a/Tests/images/bitmap_font_4_raqm.png b/Tests/images/bitmap_font_4_raqm.png new file mode 100644 index 00000000000..d98a3bc3ee1 Binary files /dev/null and b/Tests/images/bitmap_font_4_raqm.png differ diff --git a/Tests/images/bitmap_font_8_basic.png b/Tests/images/bitmap_font_8_basic.png new file mode 100644 index 00000000000..15a7c980914 Binary files /dev/null and b/Tests/images/bitmap_font_8_basic.png differ diff --git a/Tests/images/bitmap_font_8_raqm.png b/Tests/images/bitmap_font_8_raqm.png new file mode 100644 index 00000000000..1ad088c9362 Binary files /dev/null and b/Tests/images/bitmap_font_8_raqm.png differ diff --git a/Tests/images/cbdt_notocoloremoji.png b/Tests/images/cbdt_notocoloremoji.png new file mode 100644 index 00000000000..1da12fba115 Binary files /dev/null and b/Tests/images/cbdt_notocoloremoji.png differ diff --git a/Tests/images/cbdt_notocoloremoji_mask.png b/Tests/images/cbdt_notocoloremoji_mask.png new file mode 100644 index 00000000000..6d036a0b6ba Binary files /dev/null and b/Tests/images/cbdt_notocoloremoji_mask.png differ diff --git a/Tests/images/colr_bungee.png b/Tests/images/colr_bungee.png new file mode 100644 index 00000000000..b10a60be057 Binary files /dev/null and b/Tests/images/colr_bungee.png differ diff --git a/Tests/images/colr_bungee_mask.png b/Tests/images/colr_bungee_mask.png new file mode 100644 index 00000000000..f13e1767749 Binary files /dev/null and b/Tests/images/colr_bungee_mask.png differ diff --git a/Tests/images/standard_embedded.png b/Tests/images/standard_embedded.png new file mode 100644 index 00000000000..8905325317f Binary files /dev/null and b/Tests/images/standard_embedded.png differ diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index e47cb05c1ab..389df91519b 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -18,6 +18,7 @@ is_pypy, is_win32, skip_unless_feature, + skip_unless_feature_version, ) FONT_PATH = "Tests/fonts/FreeMono.ttf" @@ -840,18 +841,120 @@ def test_anchor_invalid(self): ValueError, lambda: d.multiline_text((0, 0), "foo\nbar", anchor=anchor) ) + @skip_unless_feature("freetype2") + @pytest.mark.parametrize("bpp", (1, 2, 4, 8)) + def test_bitmap_font(self, bpp): + text = "Bitmap Font" + layout_name = ["basic", "raqm"][self.LAYOUT_ENGINE] + target = f"Tests/images/bitmap_font_{bpp}_{layout_name}.png" + font = ImageFont.truetype( + f"Tests/fonts/DejaVuSans-24-{bpp}-stripped.ttf", + 24, + layout_engine=self.LAYOUT_ENGINE, + ) + + im = Image.new("RGB", (160, 35), "white") + draw = ImageDraw.Draw(im) + draw.text((2, 2), text, "black", font) + + assert_image_equal_tofile(im, target) + + def test_standard_embedded_color(self): + txt = "Hello World!" + ttf = ImageFont.truetype(FONT_PATH, 40, layout_engine=self.LAYOUT_ENGINE) + ttf.getsize(txt) + + im = Image.new("RGB", (300, 64), "white") + d = ImageDraw.Draw(im) + 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(im, expected, max(self.metrics["multiline"], 3)) + + @skip_unless_feature_version("freetype2", "2.5.0") + @pytest.mark.xfail(is_pypy(), reason="failing on PyPy with Raqm") + def test_cbdt(self): + 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 e: + assert str(e) in ("unimplemented feature", "unknown file format") + pytest.skip("freetype compiled without libpng or unsupported") + + @skip_unless_feature_version("freetype2", "2.5.0") + @pytest.mark.xfail(is_pypy(), reason="failing on PyPy with Raqm") + def test_cbdt_mask(self): + 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 e: + assert str(e) in ("unimplemented feature", "unknown file format") + pytest.skip("freetype compiled without libpng or unsupported") + + @skip_unless_feature_version("freetype2", "2.10.0") + def test_colr(self): + 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) + + @skip_unless_feature_version("freetype2", "2.10.0") + def test_colr_mask(self): + 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): LAYOUT_ENGINE = ImageFont.LAYOUT_RAQM +@skip_unless_feature_version("freetype2", "2.4", "Different metrics") def test_render_mono_size(): # issue 4177 - if parse_version(ImageFont.core.freetype2_version) < parse_version("2.4"): - pytest.skip("Different metrics") - im = Image.new("P", (100, 30), "white") draw = ImageDraw.Draw(im) ttf = ImageFont.truetype( diff --git a/Tests/test_imagefont_bitmap.py b/Tests/test_imagefont_bitmap.py deleted file mode 100644 index 0ba6828858d..00000000000 --- a/Tests/test_imagefont_bitmap.py +++ /dev/null @@ -1,42 +0,0 @@ -import pytest - -from PIL import Image, ImageDraw, ImageFont - -from .helper import assert_image_similar - -image_font_installed = True -try: - ImageFont.core.getfont -except ImportError: - image_font_installed = False - - -@pytest.mark.skipif(not image_font_installed, reason="Image font not installed") -def test_similar(): - text = "EmbeddedBitmap" - font_outline = ImageFont.truetype(font="Tests/fonts/DejaVuSans.ttf", size=24) - font_bitmap = ImageFont.truetype(font="Tests/fonts/DejaVuSans-bitmap.ttf", size=24) - size_outline = font_outline.getsize(text) - size_bitmap = font_bitmap.getsize(text) - size_final = ( - max(size_outline[0], size_bitmap[0]), - max(size_outline[1], size_bitmap[1]), - ) - im_bitmap = Image.new("RGB", size_final, (255, 255, 255)) - im_outline = im_bitmap.copy() - draw_bitmap = ImageDraw.Draw(im_bitmap) - draw_outline = ImageDraw.Draw(im_outline) - - # Metrics are different on the bitmap and TTF fonts, - # more so on some platforms and versions of FreeType than others. - # Mac has a 1px difference, Linux doesn't. - draw_bitmap.text( - (0, size_final[1] - size_bitmap[1]), text, fill=(0, 0, 0), font=font_bitmap - ) - draw_outline.text( - (0, size_final[1] - size_outline[1]), - text, - fill=(0, 0, 0), - font=font_outline, - ) - assert_image_similar(im_bitmap, im_outline, 20) diff --git a/Tests/test_imagefontctl.py b/Tests/test_imagefontctl.py index 0012d6bd0a6..3fa227e62c7 100644 --- a/Tests/test_imagefontctl.py +++ b/Tests/test_imagefontctl.py @@ -3,7 +3,11 @@ from PIL import Image, ImageDraw, ImageFont, features -from .helper import assert_image_similar, skip_unless_feature +from .helper import ( + assert_image_similar, + skip_unless_feature, + skip_unless_feature_version, +) FONT_SIZE = 20 FONT_PATH = "Tests/fonts/DejaVuSans.ttf" @@ -209,13 +213,13 @@ def test_language(): assert_image_similar(im, target_img, 0.5) +# FreeType 2.5.1 README: Miscellaneous Changes: +# Improved computation of emulated vertical metrics for TrueType fonts. +@skip_unless_feature_version( + "freetype2", "2.5.1", "FreeType <2.5.1 has incompatible ttb metrics" +) @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"): - # FreeType 2.5.1 README: Miscellaneous Changes: - # Improved computation of emulated vertical metrics for TrueType fonts. - pytest.skip("FreeType <2.5.1 has incompatible ttb metrics") - text = "f" path = f"Tests/images/test_anchor_ttb_{text}_{anchor}.png" f = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 120) diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst index c2615f0db78..3337688853b 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -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. @@ -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. @@ -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 + .. 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. diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 820dcc31db4..2caec4849b3 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -282,6 +282,7 @@ def text( language=None, stroke_width=0, stroke_fill=None, + embedded_color=False, *args, **kwargs, ): @@ -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() @@ -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, ) @@ -329,12 +338,13 @@ 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, ) @@ -342,7 +352,15 @@ def draw_text(ink, stroke_width=0, stroke_offset=None): 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: @@ -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") @@ -440,6 +459,7 @@ def multiline_text( language=language, stroke_width=stroke_width, stroke_fill=stroke_fill, + embedded_color=embedded_color, ) top += line_spacing diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 1fe19ac528f..187ef34cf5a 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -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], @@ -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 @@ -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. """ @@ -413,6 +419,7 @@ def getmask( language=language, stroke_width=stroke_width, anchor=anchor, + ink=ink, )[0] def getmask2( @@ -425,6 +432,7 @@ def getmask2( language=None, stroke_width=0, anchor=None, + ink=0, *args, **kwargs, ): @@ -432,7 +440,8 @@ def getmask2( 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 @@ -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 diff --git a/src/_imagingft.c b/src/_imagingft.c index 2435fbab4e7..7e9c1fb16b0 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -25,9 +25,13 @@ #include #include FT_FREETYPE_H #include FT_GLYPH_H +#include FT_BITMAP_H #include FT_STROKER_H #include FT_MULTIPLE_MASTERS_H #include FT_SFNT_NAMES_H +#ifdef FT_COLOR_H +#include FT_COLOR_H +#endif #define KEEP_PY_UNICODE @@ -350,7 +354,7 @@ font_getchar(PyObject* string, int index, FT_ULong* char_out) static size_t text_layout_raqm(PyObject* string, FontObject* self, const char* dir, PyObject *features, - const char* lang, GlyphInfo **glyph_info, int mask) + const char* lang, GlyphInfo **glyph_info, int mask, int color) { size_t i = 0, count = 0, start = 0; raqm_t *rq; @@ -529,7 +533,7 @@ text_layout_raqm(PyObject* string, FontObject* self, const char* dir, PyObject * static size_t text_layout_fallback(PyObject* string, FontObject* self, const char* dir, PyObject *features, - const char* lang, GlyphInfo **glyph_info, int mask) + const char* lang, GlyphInfo **glyph_info, int mask, int color) { int error, load_flags; FT_ULong ch; @@ -561,10 +565,15 @@ text_layout_fallback(PyObject* string, FontObject* self, const char* dir, PyObje return 0; } - load_flags = FT_LOAD_NO_BITMAP; + load_flags = FT_LOAD_DEFAULT; if (mask) { load_flags |= FT_LOAD_TARGET_MONO; } +#ifdef FT_LOAD_COLOR + if (color) { + load_flags |= FT_LOAD_COLOR; + } +#endif for (i = 0; font_getchar(string, i, &ch); i++) { (*glyph_info)[i].index = FT_Get_Char_Index(self->face, ch); error = FT_Load_Glyph(self->face, (*glyph_info)[i].index, load_flags); @@ -595,14 +604,14 @@ text_layout_fallback(PyObject* string, FontObject* self, const char* dir, PyObje static size_t text_layout(PyObject* string, FontObject* self, const char* dir, PyObject *features, - const char* lang, GlyphInfo **glyph_info, int mask) + const char* lang, GlyphInfo **glyph_info, int mask, int color) { size_t count; if (p_raqm.raqm && self->layout_engine == LAYOUT_RAQM) { - count = text_layout_raqm(string, self, dir, features, lang, glyph_info, mask); + count = text_layout_raqm(string, self, dir, features, lang, glyph_info, mask, color); } else { - count = text_layout_fallback(string, self, dir, features, lang, glyph_info, mask); + count = text_layout_fallback(string, self, dir, features, lang, glyph_info, mask, color); } return count; } @@ -624,6 +633,8 @@ font_getsize(FontObject* self, PyObject* args) 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? */ + int color = 0; /* is FT_LOAD_COLOR enabled? */ + const char *mode = NULL; const char *dir = NULL; const char *lang = NULL; const char *anchor = NULL; @@ -632,12 +643,15 @@ font_getsize(FontObject* self, PyObject* args) /* calculate size and bearing for a given string */ - if (!PyArg_ParseTuple(args, "O|izOzz:getsize", &string, &mask, &dir, &features, &lang, &anchor)) { + if (!PyArg_ParseTuple(args, "O|zzOzz:getsize", &string, &mode, &dir, &features, &lang, &anchor)) { return NULL; } horizontal_dir = dir && strcmp(dir, "ttb") == 0 ? 0 : 1; + mask = mode && strcmp(mode, "1") == 0; + color = mode && strcmp(mode, "RGBA") == 0; + if (anchor == NULL) { anchor = horizontal_dir ? "la" : "lt"; } @@ -645,18 +659,20 @@ font_getsize(FontObject* self, PyObject* args) goto bad_anchor; } - count = text_layout(string, self, dir, features, lang, &glyph_info, mask); + count = text_layout(string, self, dir, features, lang, &glyph_info, mask, color); if (PyErr_Occurred()) { return NULL; } - /* Note: bitmap fonts within ttf fonts do not work, see #891/pr#960 - * Yifu Yu, 2014-10-15 - */ - load_flags = FT_LOAD_NO_BITMAP; + load_flags = FT_LOAD_DEFAULT; if (mask) { load_flags |= FT_LOAD_TARGET_MONO; } +#ifdef FT_LOAD_COLOR + if (color) { + load_flags |= FT_LOAD_COLOR; + } +#endif /* * text bounds are given by: @@ -822,19 +838,26 @@ font_render(FontObject* self, PyObject* args) FT_Glyph glyph; FT_GlyphSlot glyph_slot; FT_Bitmap bitmap; + FT_Bitmap bitmap_converted; /* initialized lazily, for non-8bpp fonts */ FT_BitmapGlyph bitmap_glyph; FT_Stroker stroker = NULL; + int bitmap_converted_ready = 0; /* has bitmap_converted been initialized */ GlyphInfo *glyph_info = NULL; /* computed text layout */ size_t i, count; /* glyph_info index and length */ int xx, yy; /* pixel offset of current glyph bitmap */ int x0, x1; /* horizontal bounds of glyph bitmap to copy */ unsigned int bitmap_y; /* glyph bitmap y index */ unsigned char *source; /* glyph bitmap source buffer */ + unsigned char convert_scale; /* scale factor for non-8bpp bitmaps */ Imaging im; Py_ssize_t id; int horizontal_dir; /* is primary axis horizontal? */ int mask = 0; /* is FT_LOAD_TARGET_MONO enabled? */ + int color = 0; /* is FT_LOAD_COLOR enabled? */ int stroke_width = 0; + PY_LONG_LONG foreground_ink_long = 0; + unsigned int foreground_ink; + const char *mode = NULL; const char *dir = NULL; const char *lang = NULL; PyObject *features = Py_None; @@ -843,14 +866,31 @@ font_render(FontObject* self, PyObject* args) /* render string into given buffer (the buffer *must* have the right size, or this will crash) */ - if (!PyArg_ParseTuple(args, "On|izOzi:render", &string, &id, &mask, &dir, &features, &lang, - &stroke_width)) { + if (!PyArg_ParseTuple(args, "On|zzOziL:render", &string, &id, &mode, &dir, &features, &lang, + &stroke_width, &foreground_ink_long)) { return NULL; } horizontal_dir = dir && strcmp(dir, "ttb") == 0 ? 0 : 1; - count = text_layout(string, self, dir, features, lang, &glyph_info, mask); + mask = mode && strcmp(mode, "1") == 0; + color = mode && strcmp(mode, "RGBA") == 0; + + foreground_ink = foreground_ink_long; + +#ifdef FT_COLOR_H + if (color) { + FT_Color foreground_color; + FT_Byte* ink = (FT_Byte*)&foreground_ink; + foreground_color.red = ink[0]; + foreground_color.green = ink[1]; + foreground_color.blue = ink[2]; + foreground_color.alpha = (FT_Byte) 255; /* ink alpha is handled in ImageDraw.text */ + FT_Palette_Set_Foreground_Color(self->face, foreground_color); + } +#endif + + count = text_layout(string, self, dir, features, lang, &glyph_info, mask, color); if (PyErr_Occurred()) { return NULL; } @@ -868,12 +908,15 @@ font_render(FontObject* self, PyObject* args) } im = (Imaging) id; - - /* Note: bitmap fonts within ttf fonts do not work, see #891/pr#960 */ - load_flags = FT_LOAD_NO_BITMAP; + load_flags = FT_LOAD_DEFAULT; if (mask) { load_flags |= FT_LOAD_TARGET_MONO; } +#ifdef FT_LOAD_COLOR + if (color) { + load_flags |= FT_LOAD_COLOR; + } +#endif /* * calculate x_min and y_max @@ -945,6 +988,55 @@ font_render(FontObject* self, PyObject* args) yy = -(py + glyph_slot->bitmap_top); } + /* convert non-8bpp bitmaps */ + switch (bitmap.pixel_mode) { + case FT_PIXEL_MODE_MONO: + convert_scale = 255; + break; + case FT_PIXEL_MODE_GRAY2: + convert_scale = 255 / 3; + break; + case FT_PIXEL_MODE_GRAY4: + convert_scale = 255 / 15; + break; + default: + convert_scale = 1; + } + switch (bitmap.pixel_mode) { + case FT_PIXEL_MODE_MONO: + case FT_PIXEL_MODE_GRAY2: + case FT_PIXEL_MODE_GRAY4: + if (!bitmap_converted_ready) { + +#if FREETYPE_MAJOR > 2 ||\ + (FREETYPE_MAJOR == 2 && FREETYPE_MINOR > 6) + FT_Bitmap_Init(&bitmap_converted); +#else + FT_Bitmap_New(&bitmap_converted); +#endif + bitmap_converted_ready = 1; + } + error = FT_Bitmap_Convert(library, &bitmap, &bitmap_converted, 1); + if (error) { + geterror(error); + goto glyph_error; + } + bitmap = bitmap_converted; + /* bitmap is now FT_PIXEL_MODE_GRAY, fall through */ + case FT_PIXEL_MODE_GRAY: + break; +#ifdef FT_LOAD_COLOR + case FT_PIXEL_MODE_BGRA: + if (color) { + break; + } + /* we didn't ask for color, fall through to default */ +#endif + default: + PyErr_SetString(PyExc_IOError, "unsupported bitmap pixel mode"); + goto glyph_error; + } + /* clip glyph bitmap width to target image bounds */ x0 = 0; x1 = bitmap.width; @@ -959,28 +1051,54 @@ font_render(FontObject* self, PyObject* args) for (bitmap_y = 0; bitmap_y < bitmap.rows; bitmap_y++, yy++) { /* clip glyph bitmap height to target image bounds */ if (yy >= 0 && yy < im->ysize) { - // blend this glyph into the buffer - unsigned char *target = im->image8[yy] + xx; - if (mask) { - // use monochrome mask (on palette images, etc) - int j, k, m = 128; - for (j = k = 0; j < x1; j++) { - if (j >= x0 && (source[k] & m)) { - target[j] = 255; - } - if (!(m >>= 1)) { - m = 128; - k++; - } - } + /* blend this glyph into the buffer */ + int k; + unsigned char v; + unsigned char* target; + if (color) { + /* target[RGB] returns the color, target[A] returns the mask */ + /* target bands get split again in ImageDraw.text */ + target = im->image[yy] + xx * 4; } else { - // use antialiased rendering - int k; + target = im->image8[yy] + xx; + } +#ifdef FT_LOAD_COLOR + if (color && bitmap.pixel_mode == FT_PIXEL_MODE_BGRA) { + /* paste color glyph */ for (k = x0; k < x1; k++) { - if (target[k] < source[k]) { - target[k] = source[k]; + if (target[k * 4 + 3] < source[k * 4 + 3]) { + /* unpremultiply BGRa to RGBA */ + target[k * 4 + 0] = CLIP8((255 * (int)source[k * 4 + 2]) / source[k * 4 + 3]); + target[k * 4 + 1] = CLIP8((255 * (int)source[k * 4 + 1]) / source[k * 4 + 3]); + target[k * 4 + 2] = CLIP8((255 * (int)source[k * 4 + 0]) / source[k * 4 + 3]); + target[k * 4 + 3] = source[k * 4 + 3]; } } + } else +#endif + if (bitmap.pixel_mode == FT_PIXEL_MODE_GRAY) { + if (color) { + unsigned char* ink = (unsigned char*)&foreground_ink; + for (k = x0; k < x1; k++) { + v = source[k] * convert_scale; + if (target[k * 4 + 3] < v) { + target[k * 4 + 0] = ink[0]; + target[k * 4 + 1] = ink[1]; + target[k * 4 + 2] = ink[2]; + target[k * 4 + 3] = v; + } + } + } else { + for (k = x0; k < x1; k++) { + v = source[k] * convert_scale; + if (target[k] < v) { + target[k] = v; + } + } + } + } else { + PyErr_SetString(PyExc_IOError, "unsupported bitmap pixel mode"); + goto glyph_error; } } source += bitmap.pitch; @@ -992,9 +1110,23 @@ font_render(FontObject* self, PyObject* args) } } + if (bitmap_converted_ready) { + FT_Bitmap_Done(library, &bitmap_converted); + } FT_Stroker_Done(stroker); PyMem_Del(glyph_info); Py_RETURN_NONE; + +glyph_error: + if (stroker != NULL) { + FT_Done_Glyph(glyph); + } + if (bitmap_converted_ready) { + FT_Bitmap_Done(library, &bitmap_converted); + } + FT_Stroker_Done(stroker); + PyMem_Del(glyph_info); + return NULL; } #if FREETYPE_MAJOR > 2 ||\ diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index d66a78b07ec..0239131eb12 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -169,6 +169,20 @@ def cmd_msbuild( ], "libs": [r"output\release-static\{architecture}\lib\*.lib"], }, + "libpng": { + "url": SF_MIRROR + "/project/libpng/libpng16/1.6.37/lpng1637.zip", + "filename": "lpng1637.zip", + "dir": "lpng1637", + "build": [ + # lint: do not inline + cmd_cmake(("-DPNG_SHARED:BOOL=OFF", "-DPNG_TESTS:BOOL=OFF")), + cmd_nmake(target="clean"), + cmd_nmake(), + cmd_copy("libpng16_static.lib", "libpng16.lib"), + ], + "headers": [r"png*.h"], + "libs": [r"libpng16.lib"], + }, "freetype": { "url": "https://download.savannah.gnu.org/releases/freetype/freetype-2.10.2.tar.gz", # noqa: E501 "filename": "freetype-2.10.2.tar.gz", @@ -181,8 +195,10 @@ def cmd_msbuild( '': '\n $(WindowsSDKVersion)', # noqa: E501 }, r"builds\windows\vc2010\freetype.user.props": { - "": "FT_CONFIG_OPTION_USE_HARFBUZZ", # noqa: E501 - "": r"{dir_harfbuzz}\src", # noqa: E501 + "": "FT_CONFIG_OPTION_SYSTEM_ZLIB;FT_CONFIG_OPTION_USE_PNG;FT_CONFIG_OPTION_USE_HARFBUZZ", # noqa: E501 + "": r"{dir_harfbuzz}\src;{inc_dir}", # noqa: E501 + "": "{lib_dir}", # noqa: E501 + "": "zlib.lib;libpng16.lib", # noqa: E501 }, r"src/autofit/afshaper.c": { # link against harfbuzz.lib once it becomes available