mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
perf(common): rasterize outlined text once instead of nine times (#718)
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 and OUTLINE_CROSS. It rasterizes the string once and stamps the mask at each outline offset instead of one draw.text per offset: the same pixels as the nine-draw loop (an equivalence sweep across the bundled fonts, image and font modes, colours and positions pins it, on Windows and Linux), about 8x faster per outlined string. Fractional coordinates, multiline text, fonts other than a plain FreeTypeFont, other image modes and a replaced draw.text take the old loop. SportsCoreSharedMixin._draw_text_with_outline and TextHelper.draw_text_with_outline draw through it; the scoreboards' own game_renderer loops adopt it in a ledmatrix-plugins change after a core release. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+178
-10
@@ -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:
|
||||
"""
|
||||
|
||||
+579
-2
@@ -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),
|
||||
("<default>", 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 == "<default>":
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user