diff --git a/CHANGELOG.md b/CHANGELOG.md index 02d153d1..a6d74f91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,30 @@ accepts both, but the store flags the old spelling as deprecated ## Unreleased +### Outlined text: one rasterization + +- New `draw_text_outlined(draw, xy, text, font, fill, outline_color=(0, 0, + 0), offsets=OUTLINE_SQUARE)` in `src/common/text_helper.py`, with + `OUTLINE_SQUARE` (the eight-sided outline the scoreboards draw) and + `OUTLINE_CROSS` (four sides). Outlined text was one `draw.text` per + outline offset plus one for the text, so FreeType rasterized the same + string nine times. This rasterizes it once and stamps the mask at each + offset: the same pixels, about 8x faster per outlined string (Pillow 12.3, + desktop). `test/test_text_helper.py` compares it with the nine-draw loop + across the bundled fonts, image and font modes, colours and positions, + and fails if it stops rasterizing once. Fractional coordinates, multiline + text, fonts other than a plain `FreeTypeFont`, image modes other than + RGB, RGBA and L, and a subclassed or replaced `draw.text` take the old + loop unchanged. A whole-pixel float such as `52.0`, which the scorebugs' + centring passes, is not fractional. +- `SportsCoreSharedMixin._draw_text_with_outline`, which eight of the nine + scoreboards inherit for their switch-mode scorebug (ufc has its own), and + `TextHelper.draw_text_with_outline` now draw through it. Scroll and Vegas + cards still use each plugin's own `game_renderer.py` loop, so building a + scroll strip costs the same until the plugins adopt `draw_text_outlined`, + importing it with an `ImportError` fallback to their own loop (a separate + ledmatrix-plugins change after a core release ships it). + ### Shared fetch service (stage 1) Core's own HTTP fetch paths now go through one service, so the plugins that diff --git a/src/common/README.md b/src/common/README.md index 9e9b7f6c..32798f1e 100644 --- a/src/common/README.md +++ b/src/common/README.md @@ -380,6 +380,19 @@ Created by `DisplayController`; works with any plugin. `get_text_dimensions()`, `center_text()`, `wrap_text()`, `draw_multiline_text()`, `create_text_image()`. +`draw_text_outlined(draw, xy, text, font, fill, outline_color=(0, 0, 0), +offsets=OUTLINE_SQUARE)` (Unreleased) draws the text in `outline_color` at +each offset, then in `fill` on top: the same pixels as one `draw.text` per +offset, but the string is rasterized once. `OUTLINE_SQUARE` is the +eight-sided one-pixel outline the scoreboards draw, `OUTLINE_CROSS` the +four-sided one. Fractional coordinates (a whole-pixel float such as `52.0` +is fine), multiline text, fonts other than a plain `FreeTypeFont`, image modes +other than RGB, RGBA and L, and a subclassed or replaced `draw.text` take +the `draw.text` loop unchanged. `TextHelper.draw_text_with_outline()` and +the scoreboards' `SportsCoreSharedMixin._draw_text_with_outline()` use it. +A plugin that also runs on older cores should guard the import and keep its +own loop as the fallback. + ## Logging Modules here create their logger with `logging.getLogger(__name__)`, which is diff --git a/src/common/sports_shared.py b/src/common/sports_shared.py index 23436957..c7a097f2 100644 --- a/src/common/sports_shared.py +++ b/src/common/sports_shared.py @@ -102,6 +102,7 @@ import requests from PIL import Image, ImageDraw from src.common import sports_card as _card from src.common.font_layout import load_truetype, resolve_asset_path +from src.common.text_helper import OUTLINE_SQUARE, draw_text_outlined logger = logging.getLogger(__name__) @@ -851,19 +852,12 @@ class SportsCoreSharedMixin: elif fill is None: fill = self._font_color(font) draw.fontmode = "1" - x, y = position - for dx, dy in [ - (-1, -1), - (-1, 0), - (-1, 1), - (0, -1), - (0, 1), - (1, -1), - (1, 0), - (1, 1), - ]: - draw.text((x + dx, y + dy), text, font=font, fill=outline_color) - draw.text((x, y), text, font=font, fill=fill) + # The eight-neighbour outline, then the text on top. Rasterized once + # and stamped nine times rather than drawn nine times; the pixels are + # the same (draw_text_outlined falls back to the nine draws wherever + # that is not proven). + draw_text_outlined(draw, position, text, font, fill, outline_color, + OUTLINE_SQUARE) def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: """True at most once per ``cooldown`` seconds, for rate-limiting a diff --git a/src/common/text_helper.py b/src/common/text_helper.py index be27772c..4de9a07a 100644 --- a/src/common/text_helper.py +++ b/src/common/text_helper.py @@ -7,7 +7,7 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins. import logging from pathlib import Path -from typing import Dict, List, Optional, Tuple, Union +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union from PIL import Image, ImageDraw, ImageFont from src.common.font_layout import load_truetype, resolve_asset_path @@ -15,6 +15,174 @@ from src.common.font_layout import load_truetype, resolve_asset_path # Shared throwaway draw surface for measuring text without a target canvas. _measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1))) +#: A one-pixel outline on all eight sides, in the order the scoreboards have +#: always drawn it (dx outer, dy inner). The order can change pixels only +#: where anti-aliased (fontmode "L") edges overlap; it is kept anyway. +OUTLINE_SQUARE: Tuple[Tuple[int, int], ...] = ( + (-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)) + +#: A one-pixel outline on the four edge sides only, leaving the diagonal +#: corners open: the thinner outline ufc's fight card draws. +OUTLINE_CROSS: Tuple[Tuple[int, int], ...] = ((-1, 0), (1, 0), (0, -1), (0, 1)) + +# What the stamping path in draw_text_outlined is proven pixel-identical for +# (test/test_text_helper.py compares it with the draw.text loop across every +# combination). Anything else takes the loop. Compared with ``in`` on tuples +# rather than sets so an unhashable fontmode falls back instead of raising. +_STAMP_DRAW_MODES = ("RGB", "RGBA", "L") +_STAMP_FONT_MODES = ("1", "L") + +# ImageDraw.text as Pillow defines it, which the stamping path stands in for. +# A draw whose text has been replaced since -- on the class or the instance, +# as a test recording the strings drawn does -- takes the loop, so the +# replacement still sees every call. +_PILLOW_DRAW_TEXT = ImageDraw.ImageDraw.text + + +def draw_text_outlined(draw: ImageDraw.ImageDraw, xy: Sequence[Any], text: Any, + font: Any, fill: Any, + outline_color: Any = (0, 0, 0), + offsets: Iterable[Sequence[Any]] = OUTLINE_SQUARE) -> None: + """Draw ``text`` in ``outline_color`` at each of ``offsets``, then in ``fill`` on top. + + The result is pixel-identical to the loop every outlined draw used to be:: + + x, y = xy + for dx, dy in offsets: + draw.text((x + dx, y + dy), text, font=font, fill=outline_color) + draw.text((x, y), text, font=font, fill=fill) + + but each ``draw.text`` rasterizes the whole string through FreeType again, + so the default nine draws did the same glyph work nine times, and on a + scoreboard card that text work is much of the render. Here the string is + rasterized once and the one mask is stamped at every offset, which is + what ``draw.text`` itself does with the mask, so the pixels are the same. + + That holds only where it has been checked: a plain ``ImageDraw`` whose + ``text`` is Pillow's, a ``FreeTypeFont``, one line of ``str``, whole-pixel + ``xy`` (an int, or a float with nothing after the point, which is what + centring on a measured ``textlength`` with ``// 2`` gives) and int + offsets, and the image and font modes in ``_STAMP_DRAW_MODES`` / + ``_STAMP_FONT_MODES``. Fractional coordinates change the raster itself + (Pillow rasterizes at the sub-pixel start), and multiline text is laid + out line by line. Every other case, and anything + the stamping path cannot prepare, runs the loop above unchanged, so it + behaves exactly as before, errors included. + + Args: + draw: The ``ImageDraw`` to draw on. + xy: Top-left (x, y) of the text, as for ``draw.text``. + text: The text. + font: The font, as for ``draw.text``. + fill: Colour of the text itself, drawn last. + outline_color: Colour of the outline. + offsets: (dx, dy) of each outline draw, in drawing order. + :data:`OUTLINE_SQUARE` (the default) or :data:`OUTLINE_CROSS`. + """ + x, y = xy + # Read once: the loop below may have to start over after the stamping + # path looked at them. + offsets = tuple(offsets) + if _can_stamp(draw, x, y, text, font, offsets): + if _stamp_outlined(draw, int(x), int(y), text, font, fill, + outline_color, offsets): + return + for dx, dy in offsets: + draw.text((x + dx, y + dy), text, font=font, fill=outline_color) + draw.text((x, y), text, font=font, fill=fill) + + +def _can_stamp(draw: Any, x: Any, y: Any, text: Any, font: Any, + offsets: Tuple[Any, ...]) -> bool: + """Whether draw_text_outlined may stamp one mask instead of drawing N times. + + Exact types for the draw and the font, and Pillow's own ``draw.text``: a + subclass may override ``text`` or ``getmask2``, or a test may replace + ``draw.text`` to record what is drawn, and stamping would skip either. + """ + return ( + type(draw) is ImageDraw.ImageDraw + and ImageDraw.ImageDraw.text is _PILLOW_DRAW_TEXT + and "text" not in vars(draw) + and type(font) is ImageFont.FreeTypeFont + and isinstance(text, str) + and "\n" not in text + and "\r" not in text + and _whole_pixel(x) + and _whole_pixel(y) + and all(isinstance(o, (tuple, list)) and len(o) == 2 + and isinstance(o[0], int) and isinstance(o[1], int) + for o in offsets) + and draw.mode in _STAMP_DRAW_MODES + and draw.fontmode in _STAMP_FONT_MODES + ) + + +def _whole_pixel(v: Any) -> bool: + """An int, or a float on a whole pixel, as a draw.text coordinate. + + For those, draw.text's ``int(x + dx)`` is ``int(x) + dx`` and its + sub-pixel start is 0 (or -0.0, which renders the same), so one mask fits + every offset. Floats are held well inside the range where ``x + dx`` is + exact; nothing that far out is on any canvas, so the loop the rest take + costs nothing that matters. + """ + if isinstance(v, int): + return True + return isinstance(v, float) and v.is_integer() and -2**31 < v < 2**31 + + +def _text_ink(draw: ImageDraw.ImageDraw, color: Any) -> Any: + """The ink ``ImageDraw.text`` resolves ``color`` to (its inner getink).""" + ink, fill_ink = draw._getink(color) + return fill_ink if ink is None else ink + + +def _stamp_outlined(draw: ImageDraw.ImageDraw, x: int, y: int, text: str, + font: ImageFont.FreeTypeFont, fill: Any, outline_color: Any, + offsets: Tuple[Sequence[Any], ...]) -> bool: + """Rasterize once and stamp; False, with nothing drawn, to take the loop. + + Replays what ``ImageDraw.text`` does for one line at an integer position + (Pillow 11 and 12): ``font.getmask2`` with these arguments, then + ``draw.draw.draw_bitmap`` at the position plus the mask's offset. + ``draw.draw`` and ``draw._getink`` are Pillow internals, so everything up + to the first pixel is guarded: if anything fails before then, nothing has + been drawn and the loop runs instead, which then fails (or not) exactly + as it always did -- a bad fill colour still raises after the outline is + drawn, as it did from the last ``draw.text``. + """ + try: + outline_ink = _text_ink(draw, outline_color) + text_ink = _text_ink(draw, fill) + # What draw.text passes for a single line with no anchor at a whole + # pixel position, by keyword so a getmask2 with another parameter + # order cannot shift them. ink only matters to an RGBA (colour-glyph) + # mask, which the font modes allowed here never produce. + mask, (ox, oy) = font.getmask2( + text, draw.fontmode, direction=None, features=None, + language=None, stroke_width=0, anchor="la", ink=text_ink, + start=(0.0, 0.0), stroke_filled=True) + draw_bitmap = draw.draw.draw_bitmap + except Exception: + return False + stamped = False + try: + # draw.text returns without drawing when its ink resolves to None. + if outline_ink is not None: + for dx, dy in offsets: + draw_bitmap((x + dx + ox, y + dy + oy), mask, outline_ink) + stamped = True + if text_ink is not None: + draw_bitmap((x + ox, y + oy), mask, text_ink) + except Exception: + # A rejected call draws nothing, but one that got through has: never + # draw the outline twice (anti-aliased edges would be blended twice). + if stamped: + raise + return False + return True + class TextHelper: """ @@ -103,15 +271,15 @@ class TextHelper: outline_width: Width of outline in pixels """ x, y = position - - # Draw outline by drawing text in outline color at offset positions - for dx in range(-outline_width, outline_width + 1): - for dy in range(-outline_width, outline_width + 1): - if dx != 0 or dy != 0: # Skip center position - draw.text((x + dx, y + dy), text, font=font, fill=outline_color) - - # Draw main text - draw.text((x, y), text, font=font, fill=fill) + + # Outline: every offset up to outline_width away on each axis, centre + # skipped, in the order this has always drawn them (OUTLINE_SQUARE at + # width 1). The main text is drawn last, on top. + offsets = [(dx, dy) + for dx in range(-outline_width, outline_width + 1) + for dy in range(-outline_width, outline_width + 1) + if dx != 0 or dy != 0] + draw_text_outlined(draw, (x, y), text, font, fill, outline_color, offsets) def get_text_width(self, text: str, font: ImageFont.ImageFont) -> int: """ diff --git a/test/test_text_helper.py b/test/test_text_helper.py index 0a585e42..401f9ac8 100644 --- a/test/test_text_helper.py +++ b/test/test_text_helper.py @@ -2,12 +2,24 @@ Tests for TextHelper class. Tests text rendering, font loading, and text positioning utilities. + +The second half covers ``draw_text_outlined``, which rasterizes outlined text +once and stamps the mask at each outline offset instead of calling +``draw.text`` once per offset. Its contract is that the pixels are exactly +those of the loop it replaced (``_reference`` below), so that loop is the +oracle for every combination the stamping path accepts, and every case it +does not accept must still go through ``draw.text``. """ +from pathlib import Path + import pytest from unittest.mock import MagicMock, patch -from PIL import Image, ImageDraw, ImageFont -from src.common.text_helper import TextHelper +from PIL import Image, ImageChops, ImageDraw, ImageFont, features +from src.common import text_helper as text_helper_module +from src.common.font_layout import load_truetype +from src.common.text_helper import ( + OUTLINE_CROSS, OUTLINE_SQUARE, TextHelper, draw_text_outlined) class TestTextHelper: @@ -140,3 +152,568 @@ class TestTextHelper: th.clear_font_cache() assert th.get_font_cache_stats()["cached_fonts"] == 0 assert th.load_fonts()["score"] is not first["score"] + + +# --------------------------------------------------------------------------- +# draw_text_outlined +# --------------------------------------------------------------------------- + +FONTS_DIR = Path(__file__).resolve().parents[1] / "assets" / "fonts" + +#: Every bundled TrueType face at the sizes the scoreboards draw, plus sizes +#: off their pixel grid (anti-aliased under fontmode "L"); BDF strikes, which +#: FreeType also loads as a FreeTypeFont at their native size; and Pillow's +#: own default face. 4x6 at 8 and the default face are the ones whose glyph +#: masks start left of or below the draw position (getmask2's offset), which +#: every stamp has to add back. +FONT_CASES = [ + ("PressStart2P-Regular.ttf", 8), + ("PressStart2P-Regular.ttf", 10), + ("PressStart2P-Regular.ttf", 16), + ("4x6-font.ttf", 6), + ("4x6-font.ttf", 7), + ("4x6-font.ttf", 8), + ("4x6-font.ttf", 14), + ("5by7.regular.ttf", 7), + ("5by7.regular.ttf", 8), + ("4x6.bdf", 6), + ("5x7.bdf", 7), + ("tom-thumb.bdf", 6), + ("", None), +] + +#: (fill, outline_color) for RGB/RGBA canvases: the scoreboard default, a +#: part-transparent outline, part-transparent text (only an RGBA canvas keeps +#: the alpha), and draw.text's own default ink. +RGB_COLOURS = [ + ((255, 200, 0), (0, 0, 0)), + ((250, 250, 250), (0, 0, 0, 128)), + ((10, 220, 30, 200), (200, 0, 0)), + (None, (0, 0, 0)), +] + +#: (image mode, Draw mode, background, colours). Backgrounds are not black, +#: so a black outline shows; the RGBA one is part transparent. draw.text +#: never blends ink alpha, even under Draw(img, "RGBA") on an RGB image: on +#: RGB it is dropped, on RGBA it lands in the alpha band. +SETUPS = [ + ("RGB", None, (40, 80, 120), RGB_COLOURS), + ("RGBA", None, (40, 80, 120, 100), RGB_COLOURS), + ("RGB", "RGBA", (40, 80, 120), RGB_COLOURS), + ("L", None, 90, [(230, 0), (200, 60), (None, 0)]), +] + +CANVAS = (48, 24) +#: Inside, both corners, past the far edge, and wholly off the canvas; then +#: whole-pixel floats, which is what the scoreboards pass (``(w - tw) // 2`` +#: on a measured width). draw.text gives the offsets of (-1.0, -1.0) a +#: sub-pixel start of -0.0 where they go negative; the stamp uses 0. +POSITIONS = [(5, 7), (0, 0), (-3, -2), (44, 20), (-60, -40), + (5.0, 7), (-1.0, -1.0)] +TEXTS = ["", " ", "21-17", "Q4 2:35", "(12-3)", "/x", "jgy|", "72°"] + + +def _font(name, size, engine=ImageFont.Layout.BASIC): + if name == "": + return ImageFont.load_default() + return load_truetype(str(FONTS_DIR / name), size, layout_engine=engine) + + +def _reference(draw, xy, text, font, fill, outline_color=(0, 0, 0), + offsets=OUTLINE_SQUARE): + """The loop draw_text_outlined replaced, as every caller wrote it. + + Frozen on purpose: it defines the pixels the stamping path must produce. + """ + x, y = xy + for dx, dy in offsets: + draw.text((x + dx, y + dy), text, font=font, fill=outline_color) + draw.text((x, y), text, font=font, fill=fill) + + +def _canvas(mode="RGB", draw_mode=None, background=(40, 80, 120), + fontmode="1", size=CANVAS): + image = Image.new(mode, size, background) + draw = ImageDraw.Draw(image, draw_mode) + draw.fontmode = fontmode + return image, draw + + +def _same_pixels(a, b): + """Every band of every pixel equal. + + ``alpha_only=False`` matters: on an RGBA image getbbox otherwise looks at + the alpha band alone and would miss a colour difference. + """ + assert (a.mode, a.size) == (b.mode, b.size) + return ImageChops.difference(a, b).getbbox(alpha_only=False) is None + + +def _count_rasterizations(monkeypatch, font): + """Record each getmask2 call on ``font``, the one place draw.text and the + stamping path rasterize. Patched on the instance, so the font's type (which + the fast path checks) is unchanged.""" + calls = [] + real = font.getmask2 + + def getmask2(*args, **kwargs): + calls.append(args[0] if args else kwargs.get("text")) + return real(*args, **kwargs) + + monkeypatch.setattr(font, "getmask2", getmask2) + return calls + + +def _font_id(case): + name, size = case + return f"{name}@{size}" + + +class TestDrawTextOutlinedMatchesTheLoop: + """The stamping path against the nine-draw loop, case by case.""" + + @pytest.mark.parametrize("fontmode", ["1", "L"]) + @pytest.mark.parametrize("setup", SETUPS, + ids=lambda s: f"{s[0]}-draw{s[1] or s[0]}") + @pytest.mark.parametrize("font_case", FONT_CASES, ids=_font_id) + def test_same_pixels_from_one_rasterization(self, monkeypatch, font_case, + setup, fontmode): + font = _font(*font_case) + calls = _count_rasterizations(monkeypatch, font) + mode, draw_mode, background, colours = setup + blank = Image.new(mode, CANVAS, background).tobytes() + visible = 0 + for fill, outline in colours: + for xy in POSITIONS: + for text in TEXTS: + for offsets in (OUTLINE_SQUARE, OUTLINE_CROSS): + case = (fill, outline, xy, text, len(offsets)) + expected, ref_draw = _canvas(mode, draw_mode, background, fontmode) + _reference(ref_draw, xy, text, font, fill, outline, offsets) + got, draw = _canvas(mode, draw_mode, background, fontmode) + before = len(calls) + draw_text_outlined(draw, xy, text, font, fill, outline, offsets) + # Once, not once per offset: a silent fallback to the + # loop would still match the pixels and lose the point. + assert len(calls) - before == 1, case + assert _same_pixels(got, expected), case + visible += expected.tobytes() != blank + # Not vacuous: most of these cases put pixels on the canvas. + assert visible > len(colours) * len(TEXTS) + + @pytest.mark.skipif(not features.check("raqm"), + reason="this Pillow has no libraqm") + @pytest.mark.parametrize("fontmode", ["1", "L"]) + @pytest.mark.parametrize("font_case", [("PressStart2P-Regular.ttf", 8), + ("4x6-font.ttf", 6)], ids=_font_id) + def test_the_raqm_layout_engine_too(self, monkeypatch, font_case, fontmode): + """Core pins Basic (font_layout), but a plugin may load with Raqm.""" + font = _font(*font_case, engine=ImageFont.Layout.RAQM) + calls = _count_rasterizations(monkeypatch, font) + for xy in POSITIONS: + for text in TEXTS: + expected, ref_draw = _canvas(fontmode=fontmode) + _reference(ref_draw, xy, text, font, (255, 200, 0)) + got, draw = _canvas(fontmode=fontmode) + before = len(calls) + draw_text_outlined(draw, xy, text, font, (255, 200, 0)) + assert len(calls) - before == 1, (xy, text) + assert _same_pixels(got, expected), (xy, text) + + @pytest.mark.parametrize("mode,background,fill,outline", [ + ("RGB", (40, 80, 120), "white", "black"), + ("RGB", (40, 80, 120), "#ffc800", "#00000080"), + ("RGBA", (40, 80, 120, 100), "yellow", "#00000080"), + ("L", 90, "white", "black"), + ("RGB", (40, 80, 120), 0xFFC800, 0), + ]) + @pytest.mark.parametrize("fontmode", ["1", "L"]) + def test_colour_names_and_ints(self, monkeypatch, mode, background, fill, + outline, fontmode): + """Colours draw.text resolves itself (ImageColor names, packed ints) + resolve the same way when stamped.""" + font = _font("PressStart2P-Regular.ttf", 10) + calls = _count_rasterizations(monkeypatch, font) + for xy in POSITIONS: + expected, ref_draw = _canvas(mode, None, background, fontmode) + _reference(ref_draw, xy, "Q4 2:35", font, fill, outline) + got, draw = _canvas(mode, None, background, fontmode) + before = len(calls) + draw_text_outlined(draw, xy, "Q4 2:35", font, fill, outline) + assert len(calls) - before == 1, xy + assert _same_pixels(got, expected), xy + + def test_no_offsets_is_just_the_text(self, monkeypatch): + font = _font("PressStart2P-Regular.ttf", 8) + calls = _count_rasterizations(monkeypatch, font) + expected, ref_draw = _canvas() + ref_draw.text((5, 7), "21-17", font=font, fill=(255, 200, 0)) + got, draw = _canvas() + before = len(calls) + draw_text_outlined(draw, (5, 7), "21-17", font, (255, 200, 0), offsets=()) + assert len(calls) - before == 1 + assert _same_pixels(got, expected) + + def test_the_outline_shapes(self): + assert OUTLINE_SQUARE == ((-1, -1), (-1, 0), (-1, 1), (0, -1), + (0, 1), (1, -1), (1, 0), (1, 1)) + assert OUTLINE_CROSS == ((-1, 0), (1, 0), (0, -1), (0, 1)) + + +class TestDrawTextOutlinedFallsBackToTheLoop: + """Every case the stamping path is not proven for draws through draw.text, + nine times, exactly as before.""" + + def _assert_loop(self, monkeypatch, font, xy, text, *, mode="RGB", + background=(40, 80, 120), fill=(255, 200, 0), + outline=(0, 0, 0, 128), offsets=OUTLINE_SQUARE, + fontmode="1"): + calls = _count_rasterizations(monkeypatch, font) + expected, ref_draw = _canvas(mode, None, background, fontmode) + _reference(ref_draw, xy, text, font, fill, outline, offsets) + loop_calls = len(calls) + got, draw = _canvas(mode, None, background, fontmode) + draw_text_outlined(draw, xy, text, font, fill, outline, offsets) + assert len(calls) - loop_calls == loop_calls, "expected the draw.text loop" + assert _same_pixels(got, expected) + + @pytest.mark.parametrize("xy", [(0.5, 0.5), (2.5, 3.5), (5.0, 7.5), + (-0.5, 0.75), (3, 2.25)]) + @pytest.mark.parametrize("fontmode", ["1", "L"]) + def test_fractional_positions(self, monkeypatch, xy, fontmode): + # Pillow rasterizes at the sub-pixel start and truncates x + dx, so a + # mask made for x does not fit x + dx. + for font_case in (("PressStart2P-Regular.ttf", 10), ("4x6-font.ttf", 6)): + self._assert_loop(monkeypatch, _font(*font_case), xy, "21-17", + fontmode=fontmode) + + @pytest.mark.parametrize("text", ["a\nb", "21\n17", "a\rb"]) + def test_multiline_text(self, monkeypatch, text): + self._assert_loop(monkeypatch, _font("PressStart2P-Regular.ttf", 8), + (5, 2), text) + + def test_bytes_text(self, monkeypatch): + self._assert_loop(monkeypatch, _font("PressStart2P-Regular.ttf", 8), + (5, 7), b"21-17") + + @pytest.mark.parametrize("mode,background,fill,outline", [ + ("1", 0, 1, 0), + ("P", 0, (255, 0, 0), (0, 0, 255)), + ]) + def test_image_modes_not_proven(self, monkeypatch, mode, background, fill, + outline): + self._assert_loop(monkeypatch, _font("PressStart2P-Regular.ttf", 8), + (5, 7), "21-17", mode=mode, background=background, + fill=fill, outline=outline) + + def test_colour_glyph_fontmode(self, monkeypatch): + self._assert_loop(monkeypatch, _font("PressStart2P-Regular.ttf", 8), + (5, 7), "21-17", mode="RGBA", + background=(40, 80, 120, 100), fontmode="RGBA") + + @pytest.mark.parametrize("offsets", [((0.5, 0), (-1, 0)), [[-1, 0], [1, 0.0]]]) + def test_offsets_that_are_not_whole_pixels(self, monkeypatch, offsets): + self._assert_loop(monkeypatch, _font("PressStart2P-Regular.ttf", 8), + (5, 7), "21-17", offsets=offsets) + + def test_a_non_freetype_font(self): + font = ImageFont.load_default_imagefont() + assert not isinstance(font, ImageFont.FreeTypeFont) + expected, ref_draw = _canvas() + _reference(ref_draw, (5, 7), "21-17", font, (255, 200, 0), (0, 0, 0, 128)) + got, draw = _canvas() + draw_text_outlined(draw, (5, 7), "21-17", font, (255, 200, 0), (0, 0, 0, 128)) + assert _same_pixels(got, expected) + assert expected.tobytes() != _canvas()[0].tobytes() + + def test_a_freetype_font_subclass(self, monkeypatch): + # Exact type, not isinstance: a subclass may override getmask2 (or + # whatever a later draw.text calls instead), and stamping would call + # it once where draw.text calls it once per draw. + class Subclassed(ImageFont.FreeTypeFont): + pass + + font = Subclassed(str(FONTS_DIR / "PressStart2P-Regular.ttf"), 8, + layout_engine=ImageFont.Layout.BASIC) + self._assert_loop(monkeypatch, font, (5, 7), "21-17") + + def test_a_transposed_font(self, monkeypatch): + base = _font("PressStart2P-Regular.ttf", 8) + font = ImageFont.TransposedFont(base, Image.Transpose.ROTATE_90) + calls = _count_rasterizations(monkeypatch, base) + expected, ref_draw = _canvas() + _reference(ref_draw, (5, 2), "21", font, (255, 200, 0)) + loop_calls = len(calls) + got, draw = _canvas() + draw_text_outlined(draw, (5, 2), "21", font, (255, 200, 0)) + assert len(calls) - loop_calls == loop_calls + assert _same_pixels(got, expected) + + def test_a_draw_subclass_keeps_its_text_override(self): + seen = [] + + class Recording(ImageDraw.ImageDraw): + def text(self, xy, text, *args, **kwargs): + seen.append(xy) + return super().text(xy, text, *args, **kwargs) + + font = _font("PressStart2P-Regular.ttf", 8) + draw = Recording(Image.new("RGB", CANVAS)) + draw_text_outlined(draw, (5, 7), "21-17", font, (255, 200, 0)) + assert seen == [(5 + dx, 7 + dy) for dx, dy in OUTLINE_SQUARE] + [(5, 7)] + + @pytest.mark.parametrize("where", ["class", "instance"]) + def test_a_replaced_draw_text_still_sees_every_call(self, monkeypatch, where): + """Plugin tests record the strings drawn by swapping ImageDraw.text + (ledmatrix-flights' overhead-card tests do it on the class).""" + font = _font("PressStart2P-Regular.ttf", 8) + expected, ref_draw = _canvas() + _reference(ref_draw, (5, 7), "21-17", font, (255, 200, 0), (0, 0, 0, 128)) + got, draw = _canvas() + seen = [] + pillow_text = ImageDraw.ImageDraw.text + + def recording_text(self, xy, text, *args, **kwargs): + seen.append((xy, text)) + return pillow_text(self, xy, text, *args, **kwargs) + + if where == "class": + monkeypatch.setattr(ImageDraw.ImageDraw, "text", recording_text) + else: + draw.text = recording_text.__get__(draw) + draw_text_outlined(draw, (5, 7), "21-17", font, (255, 200, 0), (0, 0, 0, 128)) + assert seen == ([((5 + dx, 7 + dy), "21-17") for dx, dy in OUTLINE_SQUARE] + + [((5, 7), "21-17")]) + assert _same_pixels(got, expected) + + def test_a_duck_typed_draw_gets_the_same_calls(self): + """Test doubles that record draw.text (the scoreboard tests use them).""" + class Recorder: + fontmode = "1" + + def __init__(self): + self.calls = [] + + def text(self, position, text, font=None, fill=None): + self.calls.append((position, text, font, fill)) + + draw = Recorder() + draw_text_outlined(draw, (5, 7), "21", None, (1, 2, 3), (9, 9, 9), + OUTLINE_CROSS) + assert draw.calls == [((4, 7), "21", None, (9, 9, 9)), + ((6, 7), "21", None, (9, 9, 9)), + ((5, 6), "21", None, (9, 9, 9)), + ((5, 8), "21", None, (9, 9, 9)), + ((5, 7), "21", None, (1, 2, 3))] + + def test_offsets_may_be_a_generator(self): + font = _font("PressStart2P-Regular.ttf", 8) + expected, ref_draw = _canvas() + _reference(ref_draw, (5, 7), "21", font, (255, 200, 0), (0, 0, 0, 128)) + got, draw = _canvas() + draw_text_outlined(draw, (5, 7), "21", font, (255, 200, 0), (0, 0, 0, 128), + (o for o in OUTLINE_SQUARE)) + assert _same_pixels(got, expected) + + +class TestDrawTextOutlinedErrors: + """A bad argument fails where the loop failed, with the same pixels drawn.""" + + @pytest.mark.parametrize("fill,outline,error", [ + ("nocolor", (0, 0, 0), ValueError), # after the outline is drawn + ([1, 2, 3], (0, 0, 0), TypeError), # after the outline is drawn + ((255, 200, 0), "nocolor", ValueError), # before anything is drawn + ]) + def test_bad_colours(self, fill, outline, error): + font = _font("PressStart2P-Regular.ttf", 8) + expected, ref_draw = _canvas() + with pytest.raises(error): + _reference(ref_draw, (5, 7), "21", font, fill, outline) + got, draw = _canvas() + with pytest.raises(error): + draw_text_outlined(draw, (5, 7), "21", font, fill, outline) + assert _same_pixels(got, expected) + + def test_a_position_that_is_not_a_pair(self): + font = _font("PressStart2P-Regular.ttf", 8) + with pytest.raises(ValueError): + draw_text_outlined(_canvas()[1], (1, 2, 3), "21", font, (255, 255, 255)) + + # Whether Pillow raises this far out depends on the platform: on Windows + # every one of these does, while on 64-bit Linux (CI) the 2**31 range + # draws nothing and only the 2**63 range raises, part way through the + # outline. Either way the stamping path must do what the loop does. + @pytest.mark.parametrize("x", [2 ** 31 - 1, 2 ** 31, 2.0 ** 31, 2 ** 32 + 5, + 2.0 ** 60, 2 ** 63 - 1, 2 ** 63]) + def test_a_position_far_off_the_canvas(self, x): + font = _font("PressStart2P-Regular.ttf", 8) + + def outcome(draw_fn, draw): + try: + draw_fn(draw, (x, 0), "21", font, (255, 200, 0)) + except Exception as e: + return type(e) + return None + + expected, ref_draw = _canvas() + got, draw = _canvas() + assert outcome(draw_text_outlined, draw) is outcome(_reference, ref_draw) + assert _same_pixels(got, expected) + + +class _DrawProxy: + """Stands in for ``ImageDraw.draw`` (the C drawing object) and fails + draw_bitmap on the calls listed, as a changed Pillow internal might.""" + + def __init__(self, real, fail_on): + self._real = real + self._fail_on = set(fail_on) + self.bitmaps = 0 + + def draw_bitmap(self, *args): + self.bitmaps += 1 + if self.bitmaps in self._fail_on: + raise TypeError("simulated: draw_bitmap signature changed") + return self._real.draw_bitmap(*args) + + def __getattr__(self, name): + return getattr(self._real, name) + + +class TestDrawTextOutlinedGuards: + """The stamping path leans on Pillow internals; if one changes, the loop + runs instead -- but never after a stamp has landed, or an anti-aliased + outline would be blended twice.""" + + def test_a_failing_rasterization_call_takes_the_loop(self, monkeypatch): + font = _font("PressStart2P-Regular.ttf", 8) + real = font.getmask2 + state = {"calls": 0} + + def getmask2(*args, **kwargs): + state["calls"] += 1 + if state["calls"] == 1: + raise TypeError("simulated: getmask2 signature changed") + return real(*args, **kwargs) + + expected, ref_draw = _canvas() + _reference(ref_draw, (5, 7), "21", font, (255, 200, 0), (0, 0, 0, 128)) + monkeypatch.setattr(font, "getmask2", getmask2) + got, draw = _canvas() + draw_text_outlined(draw, (5, 7), "21", font, (255, 200, 0), (0, 0, 0, 128)) + assert state["calls"] == 1 + 9 + assert _same_pixels(got, expected) + + def test_a_failing_first_stamp_takes_the_loop(self): + font = _font("PressStart2P-Regular.ttf", 8) + expected, ref_draw = _canvas() + _reference(ref_draw, (5, 7), "21", font, (255, 200, 0), (0, 0, 0, 128)) + got, draw = _canvas() + draw.draw = _DrawProxy(draw.draw, fail_on={1}) + draw_text_outlined(draw, (5, 7), "21", font, (255, 200, 0), (0, 0, 0, 128)) + assert draw.draw.bitmaps == 1 + 9 + assert _same_pixels(got, expected) + + def test_a_failure_after_a_stamp_is_raised_not_redrawn(self): + font = _font("PressStart2P-Regular.ttf", 8) + _, draw = _canvas() + draw.draw = _DrawProxy(draw.draw, fail_on={2}) + with pytest.raises(TypeError): + draw_text_outlined(draw, (5, 7), "21", font, (255, 200, 0), (0, 0, 0, 128)) + assert draw.draw.bitmaps == 2 + + def test_the_guarded_internals_exist_on_this_pillow(self): + """If this fails, every outlined draw is silently back on the slow loop.""" + _, draw = _canvas() + assert callable(getattr(draw, "_getink", None)) + assert callable(getattr(draw.draw, "draw_bitmap", None)) + assert text_helper_module._can_stamp( + draw, 0, 0, "21", _font("PressStart2P-Regular.ttf", 8), OUTLINE_SQUARE) + + +class TestTheOutlinedDrawSitesUseIt: + """The two core draw sites: same pixels as their old loops, one + rasterization each.""" + + # A whole-pixel float x is what the scorebug's centring arithmetic + # (``(width - draw.textlength(...)) // 2``) actually passes. + @pytest.mark.parametrize("position", [(2, 1), (2.0, 1)]) + # The caller's outline_color reaches the outline: the default black, a + # colour, and a part-transparent black on an RGBA canvas (draw.text does + # not blend ink alpha; it lands in the alpha band, so on RGB it would + # look like the default). + @pytest.mark.parametrize("outline,mode,background", [ + (None, "RGB", (40, 80, 120)), + ((200, 0, 0), "RGB", (40, 80, 120)), + ((0, 0, 0, 128), "RGBA", (40, 80, 120, 100)), + ], ids=["default", "red", "alpha"]) + def test_the_scoreboard_mixin(self, monkeypatch, position, outline, mode, + background): + from src.common.sports_shared import SportsCoreSharedMixin + + font = _font("PressStart2P-Regular.ttf", 10) + calls = _count_rasterizations(monkeypatch, font) + # The mixin forces fontmode "1" however the draw arrives. + expected, ref_draw = _canvas(mode, None, background, fontmode="1") + _reference(ref_draw, position, "6", font, (255, 200, 0), + (0, 0, 0) if outline is None else outline) + got, draw = _canvas(mode, None, background, fontmode="L") + before = len(calls) + # Unbound with self=None, as the plugins' anti-aliasing tests call it: + # an explicit fill reads nothing from the host. + SportsCoreSharedMixin._draw_text_with_outline( + None, draw, "6", position, font, fill=(255, 200, 0), + **({} if outline is None else {"outline_color": outline})) + assert draw.fontmode == "1" + assert len(calls) - before == 1 + assert _same_pixels(got, expected) + + def test_the_scoreboard_mixin_by_element(self, monkeypatch): + import logging + from src.common.sports_shared import SportsCoreSharedMixin + + class Host(SportsCoreSharedMixin): + config = {"customization": {"score_text": {"text_color": [1, 2, 3]}}} + fonts = {} + logger = logging.getLogger("test_text_helper") + + font = _font("4x6-font.ttf", 7) + calls = _count_rasterizations(monkeypatch, font) + expected, ref_draw = _canvas() + _reference(ref_draw, (3, 4), "21-17", font, (1, 2, 3)) + got, draw = _canvas() + before = len(calls) + Host()._draw_text_with_outline(draw, "21-17", (3, 4), font, + element="score_text") + assert len(calls) - before == 1 + assert _same_pixels(got, expected) + + @pytest.mark.parametrize("width", [-1, 0, 1, 2]) + # Pillow's default fontmode "L", with faces whose edges are anti-aliased + # at these sizes: a partly covered pixel lets what is under the text + # through, so an outline stamped under it as well would show. (In fontmode + # "1" the text covers it, whatever the alphas.) + @pytest.mark.parametrize("mode,background,font_case", [ + ("RGB", (40, 80, 120), ("4x6-font.ttf", 8)), + ("RGBA", (40, 80, 120, 100), ("PressStart2P-Regular.ttf", 7)), + ], ids=["RGB", "RGBA"]) + def test_text_helper_draw_text_with_outline(self, monkeypatch, width, mode, + background, font_case): + font = _font(*font_case) + calls = _count_rasterizations(monkeypatch, font) + expected, ref_draw = _canvas(mode, None, background, fontmode="L") + # TextHelper's own loop before it was routed: every offset up to + # outline_width away on each axis, dx outer, dy inner, centre skipped. + for dx in range(-width, width + 1): + for dy in range(-width, width + 1): + if dx != 0 or dy != 0: + ref_draw.text((5 + dx, 7 + dy), "Q4", font=font, + fill=(0, 0, 0, 128)) + ref_draw.text((5, 7), "Q4", font=font, fill=(255, 255, 255)) + got, draw = _canvas(mode, None, background, fontmode="L") + before = len(calls) + TextHelper().draw_text_with_outline(draw, "Q4", (5, 7), font, + outline_color=(0, 0, 0, 128), + outline_width=width) + assert len(calls) - before == 1 + assert _same_pixels(got, expected)