diff --git a/Tests/helper.py b/Tests/helper.py index 924b9733403..b62eef0b89a 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -10,6 +10,7 @@ import subprocess import sys import tempfile +from enum import IntEnum from functools import lru_cache from io import BytesIO @@ -33,6 +34,21 @@ uploader = "github_actions" +class SnapshotsMode(IntEnum): + NONE = 0 # no creation + CREATE = 1 # create if doesn't exist + UPDATE = 2 # create or update ("this is the new expected result") + OVERWRITE = 3 # always overwrite ("this is the new expected result") + + +try: + snapshots_mode = SnapshotsMode[ + os.environ.get("PILLOW_TEST_SNAPSHOTS", "NONE").upper() + ] +except ValueError as ve: + pytest.fail(f"Invalid value for PILLOW_TEST_SNAPSHOTS: {ve}") + + def upload(a: Image.Image, b: Image.Image) -> str | None: if uploader == "show": # local img.show for errors. @@ -95,7 +111,7 @@ def assert_image_equal(a: Image.Image, b: Image.Image, msg: str | None = None) - except Exception: pass - pytest.fail(msg or "got different content") + raise AssertionError(msg or "got different content") def assert_image_equal_tofile( @@ -104,9 +120,19 @@ def assert_image_equal_tofile( msg: str | None = None, mode: str | None = None, ) -> None: + if snapshots_mode == SnapshotsMode.OVERWRITE or ( + snapshots_mode and not os.path.isfile(filename) + ): + a.save(filename) with Image.open(filename) as im: converted_im = im.convert(mode) if mode else im - assert_image_equal(a, converted_im, msg) + try: + assert_image_equal(a, converted_im, msg) + except AssertionError: + if snapshots_mode == SnapshotsMode.UPDATE: + a.save(filename) + else: + raise def assert_image_similar( @@ -146,8 +172,18 @@ def assert_image_similar_tofile( epsilon: float, msg: str | None = None, ) -> None: + if snapshots_mode == SnapshotsMode.OVERWRITE or ( + snapshots_mode and not os.path.isfile(filename) + ): + a.save(filename) with Image.open(filename) as img: - assert_image_similar(a, img, epsilon, msg) + try: + assert_image_similar(a, img, epsilon, msg) + except AssertionError: + if snapshots_mode == SnapshotsMode.UPDATE: + a.save(filename) + else: + raise def assert_not_all_same(items: Sequence[Any], msg: str | None = None) -> None: diff --git a/Tests/images/bitmap_font_stroke_raqm.png b/Tests/images/bitmap_font_stroke_raqm.png index be273d7cb6b..0539575bd3f 100644 Binary files a/Tests/images/bitmap_font_stroke_raqm.png and b/Tests/images/bitmap_font_stroke_raqm.png differ diff --git a/Tests/images/default_font_freetype.png b/Tests/images/default_font_freetype.png index bc1654a253a..5212929a6ed 100644 Binary files a/Tests/images/default_font_freetype.png and b/Tests/images/default_font_freetype.png differ diff --git a/Tests/images/imagedraw_default_font_size.png b/Tests/images/imagedraw_default_font_size.png index f695b5cd6ff..173974ae1a0 100644 Binary files a/Tests/images/imagedraw_default_font_size.png and b/Tests/images/imagedraw_default_font_size.png differ diff --git a/Tests/images/test_arabictext_features.png b/Tests/images/test_arabictext_features.png index a03845acef3..83fb892a911 100644 Binary files a/Tests/images/test_arabictext_features.png and b/Tests/images/test_arabictext_features.png differ diff --git a/Tests/images/test_combine_caron.png b/Tests/images/test_combine_caron.png index 1097f4be59e..3729da40d97 100644 Binary files a/Tests/images/test_combine_caron.png and b/Tests/images/test_combine_caron.png differ diff --git a/Tests/images/test_combine_caron_below.png b/Tests/images/test_combine_caron_below.png index 6e7d88a92c7..c6f76c9b90b 100644 Binary files a/Tests/images/test_combine_caron_below.png and b/Tests/images/test_combine_caron_below.png differ diff --git a/Tests/images/test_combine_caron_below_lb.png b/Tests/images/test_combine_caron_below_lb.png index f59e722b2da..f0483e6514e 100644 Binary files a/Tests/images/test_combine_caron_below_lb.png and b/Tests/images/test_combine_caron_below_lb.png differ diff --git a/Tests/images/test_combine_caron_below_ld.png b/Tests/images/test_combine_caron_below_ld.png index 540ab7d4264..0493de7c3d9 100644 Binary files a/Tests/images/test_combine_caron_below_ld.png and b/Tests/images/test_combine_caron_below_ld.png differ diff --git a/Tests/images/test_combine_caron_below_ls.png b/Tests/images/test_combine_caron_below_ls.png index 1109b4ee670..819399bb5fb 100644 Binary files a/Tests/images/test_combine_caron_below_ls.png and b/Tests/images/test_combine_caron_below_ls.png differ diff --git a/Tests/images/test_combine_caron_la.png b/Tests/images/test_combine_caron_la.png index 1097f4be59e..3729da40d97 100644 Binary files a/Tests/images/test_combine_caron_la.png and b/Tests/images/test_combine_caron_la.png differ diff --git a/Tests/images/test_combine_caron_ls.png b/Tests/images/test_combine_caron_ls.png index 1a721873cad..94781596c37 100644 Binary files a/Tests/images/test_combine_caron_ls.png and b/Tests/images/test_combine_caron_ls.png differ diff --git a/Tests/images/test_combine_caron_lt.png b/Tests/images/test_combine_caron_lt.png index 91e50d45f1f..e3102290508 100644 Binary files a/Tests/images/test_combine_caron_lt.png and b/Tests/images/test_combine_caron_lt.png differ diff --git a/Tests/images/test_combine_double_breve_below.png b/Tests/images/test_combine_double_breve_below.png index 30252107faa..68b3c57548e 100644 Binary files a/Tests/images/test_combine_double_breve_below.png and b/Tests/images/test_combine_double_breve_below.png differ diff --git a/Tests/images/test_combine_double_breve_below_ma.png b/Tests/images/test_combine_double_breve_below_ma.png index aea09538f7e..3ad4f0a7bc6 100644 Binary files a/Tests/images/test_combine_double_breve_below_ma.png and b/Tests/images/test_combine_double_breve_below_ma.png differ diff --git a/Tests/images/test_combine_double_breve_below_ra.png b/Tests/images/test_combine_double_breve_below_ra.png index febd3ab670c..eba8df12bda 100644 Binary files a/Tests/images/test_combine_double_breve_below_ra.png and b/Tests/images/test_combine_double_breve_below_ra.png differ diff --git a/Tests/images/test_combine_multiline_lm_center.png b/Tests/images/test_combine_multiline_lm_center.png index 6a15130248a..afd1e221610 100644 Binary files a/Tests/images/test_combine_multiline_lm_center.png and b/Tests/images/test_combine_multiline_lm_center.png differ diff --git a/Tests/images/test_combine_multiline_lm_left.png b/Tests/images/test_combine_multiline_lm_left.png index 8eb254fdf26..5ddce6bb76f 100644 Binary files a/Tests/images/test_combine_multiline_lm_left.png and b/Tests/images/test_combine_multiline_lm_left.png differ diff --git a/Tests/images/test_combine_multiline_lm_right.png b/Tests/images/test_combine_multiline_lm_right.png index cb640a7409f..aa3d6ca5e39 100644 Binary files a/Tests/images/test_combine_multiline_lm_right.png and b/Tests/images/test_combine_multiline_lm_right.png differ diff --git a/Tests/images/test_combine_multiline_mm_center.png b/Tests/images/test_combine_multiline_mm_center.png index d1146b8b856..ff4d0ea294f 100644 Binary files a/Tests/images/test_combine_multiline_mm_center.png and b/Tests/images/test_combine_multiline_mm_center.png differ diff --git a/Tests/images/test_combine_multiline_mm_left.png b/Tests/images/test_combine_multiline_mm_left.png index f539a8e62e6..6b9219936b8 100644 Binary files a/Tests/images/test_combine_multiline_mm_left.png and b/Tests/images/test_combine_multiline_mm_left.png differ diff --git a/Tests/images/test_combine_multiline_mm_right.png b/Tests/images/test_combine_multiline_mm_right.png index 02634163e1c..f4f25ba4a87 100644 Binary files a/Tests/images/test_combine_multiline_mm_right.png and b/Tests/images/test_combine_multiline_mm_right.png differ diff --git a/Tests/images/test_combine_multiline_rm_center.png b/Tests/images/test_combine_multiline_rm_center.png index 4cce8f6a00e..4ab30549f5b 100644 Binary files a/Tests/images/test_combine_multiline_rm_center.png and b/Tests/images/test_combine_multiline_rm_center.png differ diff --git a/Tests/images/test_combine_multiline_rm_left.png b/Tests/images/test_combine_multiline_rm_left.png index 93d8162b3bf..d355813ef31 100644 Binary files a/Tests/images/test_combine_multiline_rm_left.png and b/Tests/images/test_combine_multiline_rm_left.png differ diff --git a/Tests/images/test_combine_multiline_rm_right.png b/Tests/images/test_combine_multiline_rm_right.png index 6c4634560ed..cf572ec320c 100644 Binary files a/Tests/images/test_combine_multiline_rm_right.png and b/Tests/images/test_combine_multiline_rm_right.png differ diff --git a/Tests/images/test_complex_unicode_text.png b/Tests/images/test_complex_unicode_text.png index 61174d75f68..d86b1dd48d2 100644 Binary files a/Tests/images/test_complex_unicode_text.png and b/Tests/images/test_complex_unicode_text.png differ diff --git a/Tests/images/test_direction_ltr.png b/Tests/images/test_direction_ltr.png index b30fcd5d819..2b23190800e 100644 Binary files a/Tests/images/test_direction_ltr.png and b/Tests/images/test_direction_ltr.png differ diff --git a/Tests/images/test_direction_rtl.png b/Tests/images/test_direction_rtl.png index 282eed88393..5c52b714fdc 100644 Binary files a/Tests/images/test_direction_rtl.png and b/Tests/images/test_direction_rtl.png differ diff --git a/Tests/images/text_float_coord.png b/Tests/images/text_float_coord.png deleted file mode 100644 index d2270826a5b..00000000000 Binary files a/Tests/images/text_float_coord.png and /dev/null differ diff --git a/Tests/images/text_float_coord_1_BASIC.png b/Tests/images/text_float_coord_1_BASIC.png new file mode 100644 index 00000000000..35a886d1baf Binary files /dev/null and b/Tests/images/text_float_coord_1_BASIC.png differ diff --git a/Tests/images/text_float_coord_1_RAQM.png b/Tests/images/text_float_coord_1_RAQM.png new file mode 100644 index 00000000000..35a886d1baf Binary files /dev/null and b/Tests/images/text_float_coord_1_RAQM.png differ diff --git a/Tests/images/text_float_coord_1_alt.png b/Tests/images/text_float_coord_1_alt.png deleted file mode 100644 index 2287071ffab..00000000000 Binary files a/Tests/images/text_float_coord_1_alt.png and /dev/null differ diff --git a/Tests/images/text_float_coord_L_BASIC.png b/Tests/images/text_float_coord_L_BASIC.png new file mode 100644 index 00000000000..266ee5492f9 Binary files /dev/null and b/Tests/images/text_float_coord_L_BASIC.png differ diff --git a/Tests/images/text_float_coord_L_RAQM.png b/Tests/images/text_float_coord_L_RAQM.png new file mode 100644 index 00000000000..266ee5492f9 Binary files /dev/null and b/Tests/images/text_float_coord_L_RAQM.png differ diff --git a/Tests/images/text_float_coord_RGBA_BASIC.png b/Tests/images/text_float_coord_RGBA_BASIC.png new file mode 100644 index 00000000000..266ee5492f9 Binary files /dev/null and b/Tests/images/text_float_coord_RGBA_BASIC.png differ diff --git a/Tests/images/text_float_coord_RGBA_RAQM.png b/Tests/images/text_float_coord_RGBA_RAQM.png new file mode 100644 index 00000000000..266ee5492f9 Binary files /dev/null and b/Tests/images/text_float_coord_RGBA_RAQM.png differ diff --git a/Tests/images/text_size_ladder_basic.png b/Tests/images/text_size_ladder_basic.png new file mode 100644 index 00000000000..663fee109fc Binary files /dev/null and b/Tests/images/text_size_ladder_basic.png differ diff --git a/Tests/images/text_size_ladder_raqm.png b/Tests/images/text_size_ladder_raqm.png new file mode 100644 index 00000000000..052d2da04a1 Binary files /dev/null and b/Tests/images/text_size_ladder_raqm.png differ diff --git a/Tests/test_font_pcf_charsets.py b/Tests/test_font_pcf_charsets.py index 58dec008174..51c6f992cb4 100644 --- a/Tests/test_font_pcf_charsets.py +++ b/Tests/test_font_pcf_charsets.py @@ -97,12 +97,12 @@ def test_textsize( tempname = save_font(request, tmp_path, encoding) font = ImageFont.load(tempname) for i in range(255): - ox, oy, dx, dy = font.getbbox(bytearray([i])) + ox, oy, dx, dy = font.getbbox(bytes([i])) assert ox == 0 assert oy == 0 assert dy == 20 assert dx in (0, 10) - assert font.getlength(bytearray([i])) == dx + assert font.getlength(bytes([i])) == dx message = charsets[encoding]["message"].encode(encoding) for i in range(len(message)): msg = message[: i + 1] diff --git a/Tests/test_image_resize.py b/Tests/test_image_resize.py index 98380ffd446..9766b7e6e1a 100644 --- a/Tests/test_image_resize.py +++ b/Tests/test_image_resize.py @@ -222,7 +222,7 @@ def test_reducing_gap_1( (52, 34), Image.Resampling.BICUBIC, box=box, reducing_gap=1.0 ) - with pytest.raises(pytest.fail.Exception): + with pytest.raises(AssertionError): assert_image_equal(ref, im) assert_image_similar(ref, im, epsilon) @@ -242,7 +242,7 @@ def test_reducing_gap_2( (52, 34), Image.Resampling.BICUBIC, box=box, reducing_gap=2.0 ) - with pytest.raises(pytest.fail.Exception): + with pytest.raises(AssertionError): assert_image_equal(ref, im) assert_image_similar(ref, im, epsilon) @@ -262,7 +262,7 @@ def test_reducing_gap_3( (52, 34), Image.Resampling.BICUBIC, box=box, reducing_gap=3.0 ) - with pytest.raises(pytest.fail.Exception): + with pytest.raises(AssertionError): assert_image_equal(ref, im) assert_image_similar(ref, im, epsilon) diff --git a/Tests/test_image_thumbnail.py b/Tests/test_image_thumbnail.py index 2ae230f3df1..f353d8596ae 100644 --- a/Tests/test_image_thumbnail.py +++ b/Tests/test_image_thumbnail.py @@ -149,7 +149,7 @@ def test_reducing_gap_values() -> None: ref = hopper() ref.thumbnail((18, 18), Image.Resampling.BICUBIC, reducing_gap=None) - with pytest.raises(pytest.fail.Exception): + with pytest.raises(AssertionError): assert_image_equal(ref, im) assert_image_similar(ref, im, 3.5) diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 680a68c302b..487757d67e8 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1534,12 +1534,12 @@ def draw_text() -> None: check(draw_text) def draw_textlength() -> None: - assert draw.textlength(text, font_size=16) == 216 + assert draw.textlength(text, font_size=16) == pytest.approx(205, rel=0.5) check(draw_textlength) def draw_textbbox() -> None: - assert draw.textbbox((0, 0), text, font_size=16) == (0, 3, 216, 19) + assert draw.textbbox((0, 0), text, font_size=16) == (0, 3, 205, 19) check(draw_textbbox) @@ -1555,7 +1555,7 @@ def draw_multiline_text() -> None: check(draw_multiline_text) def draw_multiline_textbbox() -> None: - assert draw.multiline_textbbox((0, 0), text, font_size=16) == (0, 3, 216, 19) + assert draw.multiline_textbbox((0, 0), text, font_size=16) == (0, 3, 205, 19) check(draw_multiline_textbbox) diff --git a/Tests/test_imagedraw2.py b/Tests/test_imagedraw2.py index 47943393fd4..43ed3833267 100644 --- a/Tests/test_imagedraw2.py +++ b/Tests/test_imagedraw2.py @@ -4,7 +4,7 @@ import pytest -from PIL import Image, ImageDraw, ImageDraw2, features +from PIL import Image, ImageDraw, ImageDraw2 from .helper import ( assert_image_equal, @@ -244,8 +244,7 @@ def test_textbbox() -> None: bbox = draw.textbbox((0, 0), "ImageDraw2", font) # Assert - right = 72 if features.check_feature("raqm") else 70 - assert bbox == (0, 2, right, 12) + assert bbox == (0, 2, 72, 12) @skip_unless_feature("freetype2") diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 536a985435b..f7df31949a7 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -12,7 +12,7 @@ import pytest -from PIL import Image, ImageDraw, ImageFont, features +from PIL import Image, ImageDraw, ImageFont, ImageText, features from .helper import ( assert_image_equal, @@ -181,12 +181,12 @@ def test_textbbox_equal(font: ImageFont.FreeTypeFont) -> None: # basic test ("text", "L", "FreeMono.ttf", 15, 36, 36), ("text", "1", "FreeMono.ttf", 15, 36, 36), - # issue 4177 - ("rrr", "L", "DejaVuSans/DejaVuSans.ttf", 18, 21, 22.21875), + # issue 4177; mode "1" is placed on whole pixels, so it still differs + ("rrr", "L", "DejaVuSans/DejaVuSans.ttf", 18, 22.21875, 22.21875), ("rrr", "1", "DejaVuSans/DejaVuSans.ttf", 18, 24, 22.21875), # test 'l' not including extra margin # using exact value 2047 / 64 for raqm, checked with debugger - ("ill", "L", "OpenSansCondensed-LightItalic.ttf", 63, 33, 31.984375), + ("ill", "L", "OpenSansCondensed-LightItalic.ttf", 63, 31.984375, 31.984375), ("ill", "1", "OpenSansCondensed-LightItalic.ttf", 63, 33, 31.984375), ), ) @@ -196,7 +196,7 @@ def test_getlength( fontname: str, size: int, layout_engine: ImageFont.Layout, - length_basic: int, + length_basic: float, length_raqm: float, ) -> None: f = ImageFont.truetype("Tests/fonts/" + fontname, size, layout_engine=layout_engine) @@ -213,6 +213,24 @@ def test_getlength( assert length == length_raqm +def test_size_ladder(layout_engine: ImageFont.Layout) -> None: + # See https://github.com/python-pillow/Pillow/issues/9898 + image = Image.new("RGBA", (220, 140), "#fff") + draw = ImageDraw.Draw(image) + + y = 5 + for size in (12, 13, 14, 15, 16, 20, 24): + font = ImageFont.truetype( + "Tests/fonts/NotoSans-Regular.ttf", size, layout_engine=layout_engine + ) + text = ImageText.Text(f"Power bolt ({size}px)", font) + draw.text((5, y), text, anchor="la", fill="#000") + y += size + + name = "basic" if layout_engine == ImageFont.Layout.BASIC else "raqm" + assert_image_similar_tofile(image, f"Tests/images/text_size_ladder_{name}.png", 4) + + def test_float_size(layout_engine: ImageFont.Layout) -> None: lengths = [] for size in (48, 48.5, 49): @@ -571,6 +589,24 @@ def test_getbbox_empty(font: ImageFont.FreeTypeFont) -> None: assert (0, 0, 0, 0) == font.getbbox("") +def test_bytearray_not_supported(font: ImageFont.FreeTypeFont) -> None: + # The C text layout only accepts str and bytes, so bytearray is rejected + # even though these methods used to be annotated as taking one. + for op in (font.getlength, font.getbbox, font.getmask): + assert op(b"A") is not None + with pytest.raises(TypeError, match="expected string or bytes"): + op(bytearray(b"A")) # type: ignore[arg-type] + + +def test_core_font_arguments_are_optional(font: ImageFont.FreeTypeFont) -> None: + # Every argument after the leading string is optional at the C level, + # and the mode argument additionally accepts None. + assert font.font.getlength("A") == font.font.getlength("A", "", None, None, None) + assert font.font.getlength("A", None) == font.font.getlength("A", "") + assert font.font.getsize("A") == font.font.getsize("A", "", None, None, None, None) + assert font.font.getsize("A", None) == font.font.getsize("A", "") + + def test_render_empty(font: ImageFont.FreeTypeFont) -> None: # issue 2666 im = Image.new(mode="RGB", size=(300, 100)) @@ -794,6 +830,17 @@ def test_variation_set_by_name(font: ImageFont.FreeTypeFont) -> None: _check_text(font, "Tests/images/variation_tiny_name.png", 40) +def test_truetype_bytes() -> None: + """Ensure bytestrings with the high bit set do not cause sign extension.""" + font = ImageFont.truetype( + "Tests/fonts/FreeMono.ttf", + 20, + # Have to use BASIC as RAQM would parse `b"\xe9"` as invalid Unicode + layout_engine=ImageFont.Layout.BASIC, + ) + assert font.getbbox(b"\xe9") == font.getbbox("\u00e9") + + def test_variation_set_by_axes(font: ImageFont.FreeTypeFont) -> None: with pytest.raises(OSError): font.set_variation_by_axes([500, 50]) @@ -807,6 +854,17 @@ def test_variation_set_by_axes(font: ImageFont.FreeTypeFont) -> None: _check_text(font, "Tests/images/variation_tiny_axes.png", 32.5) +def test_variation_set_by_fractional_axes() -> None: + font = ImageFont.truetype("Tests/fonts/AdobeVFPrototype.ttf", 200) + + def mask(weight: float) -> bytes: + font.set_variation_by_axes([weight, 0]) + return bytes(font.getmask("Hg", mode="L")) + + # a fractional value must not be truncated or rounded + assert mask(600) != mask(600.5) != mask(601) + + @pytest.mark.parametrize( "anchor, left, top", ( @@ -829,10 +887,7 @@ def test_anchor( name, text = "quick", "Quick" path = f"Tests/images/test_anchor_{name}_{anchor}.png" - if layout_engine == ImageFont.Layout.RAQM: - width, height = (129, 44) - else: - width, height = (128, 44) + width, height = (129, 44) bbox_expected = (left, top, left + width, top + height) @@ -887,7 +942,9 @@ def test_anchor_multiline( d.line(((300, 0), (300, 400)), "gray") d.multiline_text((300, 200), text, fill="black", anchor=anchor, font=f, align=align) - assert_image_similar_tofile(im, target, 4) + # the reference is shared between the layout engines, and they no longer + # differ by more than the GPOS kerning that basic layout cannot apply + assert_image_similar_tofile(im, target, 6) def test_anchor_invalid(font: ImageFont.FreeTypeFont) -> None: @@ -985,17 +1042,14 @@ def test_float_coord(layout_engine: ImageFont.Layout, fontmode: str) -> None: if fontmode == "1": d.fontmode = "1" - embedded_color = fontmode == "RGBA" - d.text((9.5, 9.5), txt, font=ttf, fill="#fa6", embedded_color=embedded_color) - try: - assert_image_similar_tofile(im, "Tests/images/text_float_coord.png", 3.9) - except AssertionError: - if fontmode == "1" and layout_engine == ImageFont.Layout.BASIC: - assert_image_similar_tofile( - im, "Tests/images/text_float_coord_1_alt.png", 1 - ) - else: - raise + d.text((9.5, 9.5), txt, font=ttf, fill="#fa6", embedded_color=(fontmode == "RGBA")) + assert_image_similar_tofile( + im, + f"Tests/images/text_float_coord_{fontmode}_{layout_engine.name}.png", + epsilon=( + 1 if fontmode == "1" and layout_engine == ImageFont.Layout.BASIC else 3.9 + ), + ) def test_cbdt(layout_engine: ImageFont.Layout) -> None: diff --git a/Tests/test_imagefontctl.py b/Tests/test_imagefontctl.py index 633f6756b0c..23009351b9d 100644 --- a/Tests/test_imagefontctl.py +++ b/Tests/test_imagefontctl.py @@ -183,7 +183,7 @@ def test_x_max_and_y_offset() -> None: draw.text((0, 0), "لح", font=ttf, fill=500) target = "Tests/images/test_x_max_and_y_offset.png" - assert_image_similar_tofile(im, target, 3.8) + assert_image_similar_tofile(im, target, 4.2) def test_language() -> None: diff --git a/Tests/test_imagetext.py b/Tests/test_imagetext.py index 507d8240918..03fa8dca55a 100644 --- a/Tests/test_imagetext.py +++ b/Tests/test_imagetext.py @@ -193,15 +193,15 @@ def test_wrap_shrink() -> None: assert text.font.size == 10 with pytest.raises(ValueError, match="Text could not be scaled"): - text.wrap(50, 15, ("shrink", 9)) + text.wrap(50, 15, ("shrink", 10)) assert text.wrap(50, 15, "shrink") is None - assert text.font.size == 8 + assert text.font.size == 9 text = ImageText.Text("Hello World!") assert text.wrap(50, 15, ("shrink", 7)) is None assert isinstance(text.font, ImageFont.FreeTypeFont) - assert text.font.size == 8 + assert text.font.size == 9 @skip_unless_feature("freetype2") diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index 45667d9ec07..ac49266d71d 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -90,9 +90,48 @@ TODO TODO +Bugfixes +======== + +Fractional variation axis values +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:meth:`~PIL.ImageFont.FreeTypeFont.set_variation_by_axes` truncated each value to a +whole number before applying it. Fractional values are now used as given. + +Kerning in the basic layout engine +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When libraqm is not available, kerning was applied at 1/64th of its intended size, +because a value in pixels was added to an advance in 26.6 fixed point precision. +The full kerning value is now applied even when libraqm is not available. + Other changes ============= +Text is placed at sub-pixel horizontal positions +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When drawing horizontal text, each glyph was previously snapped to a whole pixel +independently of its neighbours. Since a glyph's advance is rarely a whole number of +pixels, the distance between two glyphs could be off by up to a full pixel, which reads +as uneven kerning. Glyphs are now shifted by the fractional part of their pen position +before they are rasterized, so they land where the font's metrics say they should. + +This affects anti-aliased horizontal text. Vertical text, 1-bit text and text drawn with +a bitmap font are unchanged, since none of them can represent a sub-pixel shift. + +Spacing in the basic layout engine +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The basic layout engine now uses more precise (non-grid-fitted) metrics, +so error accumulates less with longer strings. + +Text measured with :py:meth:`~PIL.ImageFont.FreeTypeFont.getlength` may now be narrower, +and is no longer necessarily a whole number of pixels. + +1-bit text and text drawn with a bitmap font keep the grid-fitted metrics. + Python 3.15 ^^^^^^^^^^^ diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 1f5fe66197a..49c242e8807 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -72,7 +72,7 @@ class Layout(IntEnum): core = DeferredError.new(ex) -def _string_length_check(text: str | bytes | bytearray) -> None: +def _string_length_check(text: str | bytes) -> None: if MAX_STRING_LENGTH is not None and len(text) > MAX_STRING_LENGTH: msg = "too many characters in string" raise ValueError(msg) @@ -99,7 +99,7 @@ class BaseImageFont(abc.ABC): @abc.abstractmethod def getbbox( - self, text: str | bytes | bytearray, *args: Any, **kwargs: Any + self, text: str | bytes, *args: Any, **kwargs: Any ) -> tuple[float, float, float, float]: pass @@ -204,7 +204,7 @@ def getmask( return self.font.getmask(text, mode) def getbbox( - self, text: str | bytes | bytearray, *args: Any, **kwargs: Any + self, text: str | bytes, *args: Any, **kwargs: Any ) -> tuple[int, int, int, int]: """ Returns bounding box (in pixels) of given text. @@ -219,9 +219,7 @@ def getbbox( width, height = self.font.getsize(text) return 0, 0, width, height - def getlength( - self, text: str | bytes | bytearray, *args: Any, **kwargs: Any - ) -> int: + def getlength(self, text: str | bytes, *args: Any, **kwargs: Any) -> int: """ Returns length (in pixels) of given text. This is the amount by which following text should be offset. @@ -297,9 +295,7 @@ def load_from_bytes(f: IO[bytes]) -> None: with open(font, "rb") as f: load_from_bytes(f) return - self.font = core.getfont( - font, size, index, encoding, layout_engine=layout_engine - ) + self.font = core.getfont(font, size, index, encoding, b"", layout_engine) else: load_from_bytes(cast("IO[bytes]", font)) @@ -406,7 +402,7 @@ def getlength( def getbbox( self, - text: str | bytes | bytearray, + text: str | bytes, mode: str = "", direction: str | None = None, features: list[str] | None = None, @@ -774,7 +770,7 @@ def getmask( return im def getbbox( - self, text: str | bytes | bytearray, *args: Any, **kwargs: Any + self, text: str | bytes, *args: Any, **kwargs: Any ) -> tuple[int, int, float, float]: # TransposedFont doesn't support getmask2, move top-left point to (0, 0) # this has no effect on ImageFont and simulates anchor="lt" for FreeTypeFont diff --git a/src/PIL/_imagingft.pyi b/src/PIL/_imagingft.pyi index 2136810ba6a..cda075348e1 100644 --- a/src/PIL/_imagingft.pyi +++ b/src/PIL/_imagingft.pyi @@ -24,36 +24,36 @@ class Font: self, string: str | bytes, fill: Callable[[int, int], _imaging.ImagingCore], - mode: str, - dir: str | None, - features: list[str] | None, - lang: str | None, - stroke_width: float, - stroke_filled: bool, - anchor: str | None, - foreground_ink_long: int, - start: tuple[float, float], + mode: str | None = ..., + dir: str | None = ..., + features: list[str] | None = ..., + lang: str | None = ..., + stroke_width: float = ..., + stroke_filled: bool = ..., + anchor: str | None = ..., + foreground_ink_long: int = ..., + start: tuple[float, float] = ..., /, ) -> tuple[_imaging.ImagingCore, tuple[int, int]]: ... def getsize( self, - string: str | bytes | bytearray, - mode: str, - dir: str | None, - features: list[str] | None, - lang: str | None, - anchor: str | None, + string: str | bytes, + mode: str | None = ..., + dir: str | None = ..., + features: list[str] | None = ..., + lang: str | None = ..., + anchor: str | None = ..., /, ) -> tuple[tuple[int, int], tuple[int, int]]: ... def getlength( self, string: str | bytes, - mode: str, - dir: str | None, - features: list[str] | None, - lang: str | None, + mode: str | None = ..., + dir: str | None = ..., + features: list[str] | None = ..., + lang: str | None = ..., /, - ) -> float: ... + ) -> int: ... def getvarnames(self) -> list[bytes]: ... def getvaraxes(self) -> list[ImageFont.Axis]: ... def setvarname(self, instance_index: int, /) -> None: ... diff --git a/src/_imagingft.c b/src/_imagingft.c index 4b49eb04766..c1ded6e4e55 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -27,6 +27,7 @@ #include FT_FREETYPE_H #include FT_GLYPH_H #include FT_BITMAP_H +#include FT_OUTLINE_H #include FT_STROKER_H #include FT_MULTIPLE_MASTERS_H #include FT_SFNT_NAMES_H @@ -66,9 +67,16 @@ static int have_raqm = 0; #define LAYOUT_FALLBACK 0 #define LAYOUT_RAQM 1 +// These mirror raqm_glyph_t, which in turn mirrors harfbuzz's hb_glyph_info_t, +// with the addition of y_advance and y_offset for vertical text layout. +// Coordinate units are 26.6 fixed-point precision. typedef struct { - int index, x_offset, x_advance, y_offset, y_advance; - unsigned int cluster; + unsigned int index; // the index of the glyph in the font file + FT_F26Dot6 x_offset; // horizontal movement of the glyph from current point + FT_F26Dot6 x_advance; // glyph advance width in horizontal text + FT_F26Dot6 y_offset; // vertical movement of the glyph from current point + FT_F26Dot6 y_advance; // glyph advance height in vertical text + unsigned int cluster; // character index in original text } GlyphInfo; struct { @@ -96,6 +104,16 @@ static PyTypeObject Font_Type; /* round a 26.6 pixel coordinate to the nearest integer */ #define PIXEL(x) ((((x) + 32) & -64) >> 6) +/* round a 26.6 pixel coordinate down to integer */ +#define PIXEL_FLOOR(x) ((x) >> 6) +/* round a 26.6 pixel coordinate up to integer */ +#define PIXEL_CEIL(x) (((x) + 63) >> 6) +/* the sub-pixel part of a 26.6 pixel coordinate */ +#define PIXEL_FRAC(x) ((x) & 63) +/* convert from pixels to 26.6 fixed-point */ +#define PIXEL_TO_FIXED(x) ((x) * 64) +/* round a 16.16 fixed-point value (e.g. FT_Fixed) to 26.6 */ +#define FIXED_16_16_TO_26_6(x) (((x) + (1 << 9)) >> 10) static PyObject * geterror(int code) { @@ -125,8 +143,8 @@ getfont(PyObject *self_, PyObject *args, PyObject *kw) { FT_Long width; Py_ssize_t index = 0; Py_ssize_t layout_engine = 0; - unsigned char *encoding; - unsigned char *font_bytes; + unsigned char *encoding = NULL; + unsigned char *font_bytes = NULL; Py_ssize_t font_bytes_size = 0; static char *kwlist[] = { "filename", "size", "index", "encoding", "font_bytes", "layout_engine", NULL @@ -143,7 +161,7 @@ getfont(PyObject *self_, PyObject *args, PyObject *kw) { if (!PyArg_ParseTupleAndKeywords( args, kw, - "etf|nsy#n", + "etfnsy#n", kwlist, config.filesystem_encoding, &filename, @@ -162,7 +180,7 @@ getfont(PyObject *self_, PyObject *args, PyObject *kw) { if (!PyArg_ParseTupleAndKeywords( args, kw, - "etf|nsy#n", + "etfnsy#n", kwlist, Py_FileSystemDefaultEncoding, &filename, @@ -215,7 +233,7 @@ getfont(PyObject *self_, PyObject *args, PyObject *kw) { } if (!error) { - width = size * 64; + width = PIXEL_TO_FIXED(size); req.type = FT_SIZE_REQUEST_TYPE_NOMINAL; req.width = width; req.height = width; @@ -456,7 +474,7 @@ text_layout_fallback( } for (i = 0; i < count; i++) { if (buffer) { - ch = buffer[i]; + ch = (unsigned char)buffer[i]; } else { ch = PyUnicode_READ_CHAR(string, i); } @@ -469,21 +487,34 @@ text_layout_fallback( glyph = self->face->glyph; (*glyph_info)[i].x_offset = 0; (*glyph_info)[i].y_offset = 0; + + // Use non-grid-fitted metrics when eventual rendering will honour them. + // Grid-fitted metrics will make spacing between glyphs drift away + // from the font's design over longer spans. + // Monochrome and bitmap glyphs retain grid-fitted metrics so they render + // with suitable crispness. + int unfitted = !mask && glyph->format == FT_GLYPH_FORMAT_OUTLINE; + if (kerning && last_index && (*glyph_info)[i].index) { FT_Vector delta; if (FT_Get_Kerning( self->face, last_index, (*glyph_info)[i].index, - ft_kerning_default, + unfitted ? FT_KERNING_UNFITTED : FT_KERNING_DEFAULT, &delta ) == 0) { - (*glyph_info)[i - 1].x_advance += PIXEL(delta.x); - (*glyph_info)[i - 1].y_advance += PIXEL(delta.y); + (*glyph_info)[i - 1].x_advance += delta.x; + (*glyph_info)[i - 1].y_advance += delta.y; } } - (*glyph_info)[i].x_advance = glyph->metrics.horiAdvance; + // linearHoriAdvance is the unhinted advance, in 16.16 (IOW, 1/65536 of a px) + // (see https://freetype.org/freetype2/docs/tutorial/step2.html) + // and metrics.horiAdvance is in 26.6 (1/64 of a px). + (*glyph_info)[i].x_advance = unfitted + ? FIXED_16_16_TO_26_6(glyph->linearHoriAdvance) + : glyph->metrics.horiAdvance; // y_advance is only used in ttb, which is not supported by basic layout (*glyph_info)[i].y_advance = 0; last_index = (*glyph_info)[i].index; @@ -517,41 +548,61 @@ text_layout( return count; } +/** + * Parse the possibly NULL `dir` direction string + * and return whether it means horizontal or vertical text layout. + */ +static int +is_horizontal_direction(const char *dir) { + return dir && strcmp(dir, "ttb") == 0 ? 0 : 1; +} + +/** Calculate how far the pen advances over a given string. + * + * This is the sum of the glyph advances, which is not the width of the inked + * area that font_getsize_impl() reports: it includes side bearings and any + * trailing whitespace. + * + * Python parameters: + * - string: the text to measure + * - mode_name: imaging mode string + * - dir: text direction string + * - features: font features sequence + * - lang: language string + * + * Python return value: + * - length: advance along the primary axis, 26.6 precision integer. + * Unlike the other methods here this is not in pixels; + * ImageFont.FreeTypeFont.getlength() divides by 64. + */ static PyObject * font_getlength_impl(FontObject *self, PyObject *args) { - int length; /* length along primary axis, in 26.6 precision */ + FT_F26Dot6 length = 0; /* length along primary axis */ 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? */ - int color = 0; /* is FT_LOAD_COLOR enabled? */ + size_t count; /* glyph_info length */ const char *mode_name = NULL; 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|zzOz:getlength", &string, &mode_name, &dir, &features, &lang )) { return NULL; } - horizontal_dir = dir && strcmp(dir, "ttb") == 0 ? 0 : 1; - const ModeID mode = findModeID(mode_name); - mask = mode == IMAGING_MODE_1; - color = mode == IMAGING_MODE_RGBA; + int horizontal_dir = is_horizontal_direction(dir); // Drawing horizontally? + int mask = mode == IMAGING_MODE_1; // draw in monochrome (FT_LOAD_TARGET_MONO)? + int color = mode == IMAGING_MODE_RGBA; // can draw colored glyphs (FT_LOAD_COLOR)? count = text_layout(string, self, dir, features, lang, &glyph_info, mask, color); if (PyErr_Occurred()) { return NULL; } - length = 0; - for (i = 0; i < count; i++) { + for (size_t i = 0; i < count; i++) { if (horizontal_dir) { length += glyph_info[i].x_advance; } else { @@ -576,11 +627,61 @@ font_getlength(FontObject *self, PyObject *args) { return result; } +/* + * Load a single glyph and place it at pen position `x_pen`. + * + * FreeType returns glyph bitmaps at integer-pixel offsets. + * To preserve a fractional pen position, this translates a scalable glyph’s + * outline by the pen’s fractional remainder before rasterizing it. + * Otherwise, independently rounding glyph origin can introduce error + * in the spacing between neighbouring glyphs. + * + * Embedded bitmap glyphs cannot be translated this way, + * and monochrome output cannot represent fractional coverage smoothly, + * so bitmap glyphs and mode "1" text use rounded pen positions. + * + * `*px` receives the whole-pixel x position of the resulting glyph bitmap. + */ +static int +load_glyph( + FT_Face face, + FT_UInt index, + FT_Int32 load_flags, + int subpixel, // translate the outline for subpixel positioning? + int monochrome, // render in monochrome mode? + int render, // invoke rendering, or just translate? + FT_F26Dot6 x_pen, // pen position along primary axis + int *px // output whole-pixel X position (must be non-NULL) +) { + int error = FT_Load_Glyph(face, index, load_flags); + if (error) { + return error; + } + + if (subpixel && face->glyph->format == FT_GLYPH_FORMAT_OUTLINE) { + *px = (int)PIXEL_FLOOR(x_pen); + int frac = (int)(x_pen - PIXEL_TO_FIXED(*px)); + if (frac) { + FT_Outline_Translate(&face->glyph->outline, frac, 0); + } + } else { + *px = (int)PIXEL(x_pen); + } + + if (render) { + error = FT_Render_Glyph( + face->glyph, monochrome ? FT_RENDER_MODE_MONO : FT_RENDER_MODE_NORMAL + ); + } + return error; +} + static int bounding_box_and_anchors( FT_Face face, const char *anchor, int horizontal_dir, + int subpixel, GlyphInfo *glyph_info, size_t count, int load_flags, @@ -589,71 +690,79 @@ bounding_box_and_anchors( int *x_offset, int *y_offset ) { - long position; /* pen position along primary axis, in 26.6 precision */ - long advanced; /* pen position along primary axis, in pixels */ - int px, py; /* position of current glyph, in pixels */ + FT_F26Dot6 position; /* pen position along primary axis */ + FT_F26Dot6 x_pen; /* horizontal position of current glyph */ + int py; /* vertical position of current glyph, in pixels */ int x_min, x_max, y_min, y_max; /* text bounding box, in pixels */ int x_anchor, y_anchor; /* offset of point drawn at (0, 0), in pixels */ - int error; FT_Glyph glyph; FT_BBox bbox; /* glyph bounding box */ - size_t i; /* glyph_info index */ /* * text bounds are given by: * - bounding boxes of individual glyphs - * - pen line, i.e. 0 to `advanced` along primary axis + * - pen line, i.e. 0 to final position along primary axis * this means point (0, 0) is part of the text bounding box */ position = x_min = x_max = y_min = y_max = 0; - for (i = 0; i < count; i++) { + for (size_t i = 0; i < count; i++) { if (horizontal_dir) { - px = PIXEL(position + glyph_info[i].x_offset); + x_pen = position + glyph_info[i].x_offset; py = PIXEL(glyph_info[i].y_offset); position += glyph_info[i].x_advance; - advanced = PIXEL(position); - if (advanced > x_max) { - x_max = advanced; + int position_x = PIXEL(position); + if (position_x > x_max) { + x_max = position_x; } } else { - px = PIXEL(glyph_info[i].x_offset); + x_pen = glyph_info[i].x_offset; py = PIXEL(position + glyph_info[i].y_offset); position += glyph_info[i].y_advance; - advanced = PIXEL(position); - if (advanced < y_min) { - y_min = advanced; + int position_y = PIXEL(position); + if (position_y < y_min) { + y_min = position_y; } } - error = FT_Load_Glyph(face, glyph_info[i].index, load_flags); + int error = FT_Load_Glyph(face, glyph_info[i].index, load_flags); if (error) { geterror(error); return 1; } + if (!subpixel || face->glyph->format != FT_GLYPH_FORMAT_OUTLINE) { + // in non-subpixel-vector mode, the glyph will be drawn + // on a whole pixel by load_glyph(), so account for it here + // by truncating to whole pixels before summing into the bounding box + x_pen = PIXEL_TO_FIXED(PIXEL(x_pen)); + } + error = FT_Get_Glyph(face->glyph, &glyph); if (error) { geterror(error); return 1; } - FT_Glyph_Get_CBox(glyph, FT_GLYPH_BBOX_PIXELS, &bbox); - bbox.xMax += px; - if (bbox.xMax > x_max) { - x_max = bbox.xMax; + FT_Glyph_Get_CBox(glyph, FT_GLYPH_BBOX_GRIDFIT, &bbox); + // x_pen is in 26.6 format, so sum precisely before rounding + int glyph_x_min = (int)PIXEL_FLOOR(bbox.xMin + x_pen); + int glyph_x_max = (int)PIXEL_CEIL(bbox.xMax + x_pen); + // py is already whole pixels + int glyph_y_min = (int)PIXEL_FLOOR(bbox.yMin) + py; + int glyph_y_max = (int)PIXEL_CEIL(bbox.yMax) + py; + + if (glyph_x_min < x_min) { + x_min = glyph_x_min; } - bbox.xMin += px; - if (bbox.xMin < x_min) { - x_min = bbox.xMin; + if (glyph_x_max > x_max) { + x_max = glyph_x_max; } - bbox.yMax += py; - if (bbox.yMax > y_max) { - y_max = bbox.yMax; + if (glyph_y_min < y_min) { + y_min = glyph_y_min; } - bbox.yMin += py; - if (bbox.yMin < y_min) { - y_min = bbox.yMin; + if (glyph_y_max > y_max) { + y_max = glyph_y_max; } FT_Done_Glyph(glyph); @@ -754,17 +863,25 @@ bounding_box_and_anchors( return 1; } +/** Calculate size and bearing for a given string. + * + * Python parameters: + * - string: the text to measure + * - mode_name: imaging mode string + * - dir: text direction string + * - features: font features sequence + * - lang: language string + * - anchor: anchor string + * + * Python return value: + * - ((width, height), (x_offset, y_offset)): size and bearing of the text, in pixels + */ static PyObject * font_getsize_impl(FontObject *self, PyObject *args) { - int64_t width, height; - int x_offset, y_offset; - int load_flags; /* FreeType load_flags parameter */ - int error; + int64_t width, height; /* pixels */ + int x_offset, y_offset; /* pixels */ GlyphInfo *glyph_info = NULL; /* computed text layout */ size_t count; /* glyph_info 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_name = NULL; const char *dir = NULL; const char *lang = NULL; @@ -772,8 +889,6 @@ font_getsize_impl(FontObject *self, PyObject *args) { PyObject *features = Py_None; PyObject *string; - /* calculate size and bearing for a given string */ - if (!PyArg_ParseTuple( args, "O|zzOzz:getsize", @@ -787,18 +902,22 @@ font_getsize_impl(FontObject *self, PyObject *args) { return NULL; } - horizontal_dir = dir && strcmp(dir, "ttb") == 0 ? 0 : 1; - const ModeID mode = findModeID(mode_name); - mask = mode == IMAGING_MODE_1; - color = mode == IMAGING_MODE_RGBA; + int horizontal_dir = is_horizontal_direction(dir); // Drawing horizontally? + int mask = mode == IMAGING_MODE_1; // draw in monochrome (FT_LOAD_TARGET_MONO)? + int color = mode == IMAGING_MODE_RGBA; // can draw colored glyphs (FT_LOAD_COLOR)? + // Only do subpixel layout for horizontal text in non-1-bit modes. + // Vertical text is elided for now since FT glyphs are loaded + // in a mode that grid-fits horizontal features; doing subpixel + // layout in the vertical axis would likely blur those features. + int subpixel = !mask && horizontal_dir; count = text_layout(string, self, dir, features, lang, &glyph_info, mask, color); if (PyErr_Occurred()) { return NULL; } - load_flags = FT_LOAD_DEFAULT; + int load_flags = FT_LOAD_DEFAULT; if (mask) { load_flags |= FT_LOAD_TARGET_MONO; } @@ -806,10 +925,11 @@ font_getsize_impl(FontObject *self, PyObject *args) { load_flags |= FT_LOAD_COLOR; } - error = bounding_box_and_anchors( + int error = bounding_box_and_anchors( self->face, anchor, horizontal_dir, + subpixel, glyph_info, count, load_flags, @@ -838,11 +958,29 @@ font_getsize(FontObject *self, PyObject *args) { return result; } +/** + * Rasterize a string into an image buffer. + * + * Python parameters: + * - string: the text to render + * - fill: ??? + * - mode_name: imaging mode string + * - dir: text direction string + * - features: font features sequence + * - lang: language string + * - stroke_width: stroke width in pixels + * - stroke_filled: ??? + * - anchor: anchor string + * - foreground_ink_long: foreground color as a long integer + * - x_start: starting x position of the pen in pixels + * - y_start: starting y position of the pen in pixels + * + * Returns: a new image object containing the rendered text. + */ static PyObject * font_render_impl(FontObject *self, PyObject *args) { - int x, y; /* pen position, in 26.6 precision */ - int px, py; /* position of current glyph, in pixels */ - int x_min, y_max; /* text offset in 26.6 precision */ + FT_F26Dot6 x, y; /* pen position */ + int x_min, y_max; /* text offset, in pixels */ int load_flags; /* FreeType load_flags parameter */ int error; FT_Glyph glyph; @@ -853,7 +991,7 @@ font_render_impl(FontObject *self, PyObject *args) { 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 */ + size_t count; /* glyph_info 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 */ @@ -861,9 +999,7 @@ font_render_impl(FontObject *self, PyObject *args) { unsigned char convert_scale; /* scale factor for non-8bpp bitmaps */ PyObject *image; Imaging im; - int mask = 0; /* is FT_LOAD_TARGET_MONO enabled? */ - int color = 0; /* is FT_LOAD_COLOR enabled? */ - float stroke_width = 0; + float stroke_width = 0; /* stroke width, in possibly non-integer px */ int stroke_filled = 0; PY_LONG_LONG foreground_ink_long = 0; unsigned int foreground_ink; @@ -874,11 +1010,10 @@ font_render_impl(FontObject *self, PyObject *args) { PyObject *features = Py_None; PyObject *string; PyObject *fill; - float x_start = 0; - float y_start = 0; + float x_start = 0; /* starting position of the pen, in possibly non-integer px */ + float y_start = 0; /* starting position of the pen, in possibly non-integer px */ int64_t width, height; int x_offset, y_offset; - int horizontal_dir; /* is primary axis horizontal? */ /* render string into given buffer (the buffer *must* have the right size, or this will crash) */ @@ -903,8 +1038,14 @@ font_render_impl(FontObject *self, PyObject *args) { } const ModeID mode = findModeID(mode_name); - mask = mode == IMAGING_MODE_1; - color = mode == IMAGING_MODE_RGBA; + int horizontal_dir = is_horizontal_direction(dir); // Drawing horizontally? + int mask = mode == IMAGING_MODE_1; // draw in monochrome (FT_LOAD_TARGET_MONO)? + int color = mode == IMAGING_MODE_RGBA; // can draw colored glyphs (FT_LOAD_COLOR)? + // Only do subpixel layout for horizontal text in non-1-bit modes. + // Vertical text is elided for now since FT glyphs are loaded + // in a mode that grid-fits horizontal features; doing subpixel + // layout in the vertical axis would likely blur those features. + int subpixel = !mask && horizontal_dir; foreground_ink = foreground_ink_long; @@ -933,13 +1074,11 @@ font_render_impl(FontObject *self, PyObject *args) { if (color) { load_flags |= FT_LOAD_COLOR; } - - horizontal_dir = dir && strcmp(dir, "ttb") == 0 ? 0 : 1; - error = bounding_box_and_anchors( self->face, anchor, horizontal_dir, + subpixel, glyph_info, count, load_flags, @@ -990,10 +1129,10 @@ font_render_impl(FontObject *self, PyObject *args) { FT_Stroker_Set( stroker, - (FT_Fixed)round(stroke_width * 64), + (FT_F26Dot6)roundf(PIXEL_TO_FIXED(stroke_width)), FT_STROKER_LINECAP_ROUND, FT_STROKER_LINEJOIN_ROUND, - 0 + 0 // 16.16 units ); } @@ -1001,20 +1140,28 @@ font_render_impl(FontObject *self, PyObject *args) { * calculate x_min and y_max * must match font_getsize or there may be clipping! */ - x = y = x_min = y_max = 0; - for (i = 0; i < count; i++) { - px = PIXEL(x + glyph_info[i].x_offset); - py = PIXEL(y + glyph_info[i].y_offset); - - error = - FT_Load_Glyph(self->face, glyph_info[i].index, load_flags | FT_LOAD_RENDER); + x = PIXEL_FRAC((int)roundf(PIXEL_TO_FIXED(stroke_width + x_start))); + y = x_min = y_max = 0; + for (size_t i = 0; i < count; i++) { + int px; + int py = PIXEL(y + glyph_info[i].y_offset); + + error = load_glyph( + self->face, + glyph_info[i].index, + load_flags, + subpixel, + mask, + 1, + x + glyph_info[i].x_offset, + &px + ); if (error) { geterror(error); goto glyph_error; } glyph_slot = self->face->glyph; - bitmap = glyph_slot->bitmap; if (glyph_slot->bitmap_top + py > y_max) { y_max = glyph_slot->bitmap_top + py; @@ -1028,18 +1175,23 @@ font_render_impl(FontObject *self, PyObject *args) { } /* set pen position to text origin */ - x = round((-x_min + stroke_width + x_start) * 64); - y = round((-y_max + (-stroke_width) - y_start) * 64); - - if (stroker == NULL) { - load_flags |= FT_LOAD_RENDER; - } - - for (i = 0; i < count; i++) { - px = PIXEL(x + glyph_info[i].x_offset); - py = PIXEL(y + glyph_info[i].y_offset); - - error = FT_Load_Glyph(self->face, glyph_info[i].index, load_flags); + x = roundf(PIXEL_TO_FIXED(-x_min + stroke_width + x_start)); + y = roundf(PIXEL_TO_FIXED(-y_max + (-stroke_width) - y_start)); + + for (size_t i = 0; i < count; i++) { + int px; + int py = PIXEL(y + glyph_info[i].y_offset); + + error = load_glyph( + self->face, + glyph_info[i].index, + load_flags, + subpixel, + mask, + stroker == NULL, + x + glyph_info[i].x_offset, + &px + ); if (error) { geterror(error); goto glyph_error; @@ -1443,7 +1595,7 @@ font_setvaraxes_impl(FontObject *self, PyObject *args) { PyObject *axes, *item; Py_ssize_t i, num_coords; FT_Fixed *coords; - FT_Fixed coord; + double coord; if (!PyArg_ParseTuple(args, "O", &axes)) { return NULL; } @@ -1468,7 +1620,7 @@ font_setvaraxes_impl(FontObject *self, PyObject *args) { if (PyFloat_Check(item)) { coord = PyFloat_AS_DOUBLE(item); } else if (PyLong_Check(item)) { - coord = (float)PyLong_AS_LONG(item); + coord = (double)PyLong_AS_LONG(item); } else if (PyNumber_Check(item)) { coord = PyFloat_AsDouble(item); } else { @@ -1478,7 +1630,7 @@ font_setvaraxes_impl(FontObject *self, PyObject *args) { return NULL; } Py_DECREF(item); - coords[i] = coord * 65536; + coords[i] = (FT_Fixed)round(coord * 65536); /* 16.16 fixed point */ } error = FT_Set_Var_Design_Coordinates(self->face, num_coords, coords);