Skip to content

Commit 8c876e4

Browse files
committed
fix: preserve rendered text contrast
1 parent 8158047 commit 8c876e4

2 files changed

Lines changed: 133 additions & 5 deletions

File tree

quickthumb/_diagnostic_rules.py

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from collections.abc import Callable, Iterable
22
from dataclasses import dataclass
3+
from typing import cast
34

45
from PIL import Image, ImageChops, ImageStat
56
from typing_extensions import TypedDict
@@ -446,17 +447,83 @@ def worst_tile_contrast(
446447
tile_count = ((region.width + tile_size - 1) // tile_size) * (
447448
(region.height + tile_size - 1) // tile_size
448449
)
450+
background_region = background_image.crop(
451+
(region.x, region.y, region.right, region.bottom)
452+
).convert("RGBA")
453+
foreground_region = foreground_image.crop(
454+
(region.x, region.y, region.right, region.bottom)
455+
).convert("RGBA")
456+
foreground_alpha_region = foreground_alpha.crop(
457+
(region.x, region.y, region.right, region.bottom)
458+
)
449459

450460
worst: TiledContrastMeasurement | None = None
451461
for tile in _tiled_regions(region, tile_size):
452-
alpha_tile = foreground_alpha.crop((tile.x, tile.y, tile.right, tile.bottom))
462+
local_tile = BBox(tile.x - region.x, tile.y - region.y, tile.width, tile.height)
463+
alpha_tile = foreground_alpha_region.crop(
464+
(local_tile.x, local_tile.y, local_tile.right, local_tile.bottom)
465+
)
453466
if alpha_tile.getbbox() is None:
454467
continue
455468

456-
background = average_visible_background(background_image, tile, mask=foreground_alpha)
457-
foreground_crop = foreground_image.crop((tile.x, tile.y, tile.right, tile.bottom))
458-
foreground_mask = foreground_alpha.crop((tile.x, tile.y, tile.right, tile.bottom))
459-
foreground = tuple(ImageStat.Stat(foreground_crop, mask=foreground_mask).mean[:3])
469+
measurement = _tile_contrast(
470+
background_region,
471+
foreground_region,
472+
local_tile,
473+
tile=tile,
474+
tile_count=tile_count,
475+
)
476+
if measurement is not None and (worst is None or measurement.contrast < worst.contrast):
477+
worst = measurement
478+
return worst
479+
480+
481+
def _tile_contrast(
482+
background_region: Image.Image,
483+
foreground_region: Image.Image,
484+
region: BBox,
485+
*,
486+
tile: BBox,
487+
tile_count: int,
488+
) -> TiledContrastMeasurement | None:
489+
background_crop = background_region.crop((region.x, region.y, region.right, region.bottom))
490+
foreground_crop = foreground_region.crop((region.x, region.y, region.right, region.bottom))
491+
background_pixels = background_crop.load()
492+
foreground_pixels = foreground_crop.load()
493+
assert background_pixels is not None and foreground_pixels is not None
494+
groups: dict[tuple[int, int, int], list[float]] = {}
495+
496+
for y in range(region.height):
497+
for x in range(region.width):
498+
background_pixel = cast(tuple[int, int, int, int], background_pixels[x, y])
499+
foreground_pixel = cast(tuple[int, int, int, int], foreground_pixels[x, y])
500+
foreground_r, foreground_g, foreground_b, foreground_a = foreground_pixel
501+
if foreground_a == 0:
502+
continue
503+
504+
background_alpha = background_pixel[3] / 255
505+
visible_background = (
506+
background_pixel[0] * background_alpha + 255 * (1 - background_alpha),
507+
background_pixel[1] * background_alpha + 255 * (1 - background_alpha),
508+
background_pixel[2] * background_alpha + 255 * (1 - background_alpha),
509+
)
510+
key = (foreground_r, foreground_g, foreground_b)
511+
group = groups.setdefault(key, [0.0, 0.0, 0.0, 0.0, 0.0])
512+
group[0] += visible_background[0]
513+
group[1] += visible_background[1]
514+
group[2] += visible_background[2]
515+
group[3] += 1
516+
group[4] = max(group[4], foreground_a)
517+
518+
worst: TiledContrastMeasurement | None = None
519+
for foreground_raw, group in groups.items():
520+
background = (group[0] / group[3], group[1] / group[3], group[2] / group[3])
521+
opacity = group[4] / 255
522+
foreground = (
523+
foreground_raw[0] * opacity + background[0] * (1 - opacity),
524+
foreground_raw[1] * opacity + background[1] * (1 - opacity),
525+
foreground_raw[2] * opacity + background[2] * (1 - opacity),
526+
)
460527
contrast = contrast_ratio(foreground, background)
461528
if worst is None or contrast < worst.contrast:
462529
worst = TiledContrastMeasurement(

tests/test_diagnostics.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,67 @@ def test_should_use_default_text_color_for_worst_tile_contrast(self):
843843
assert [finding.code for finding in diagnostics] == ["low-contrast"]
844844
assert diagnostics[0].measured["foreground_rgb"] == (0.0, 0.0, 0.0)
845845

846+
def test_should_warn_for_low_opacity_text(self):
847+
"""Semi-transparent text is checked by its effective rendered contrast"""
848+
from quickthumb import Canvas
849+
850+
# given: faint black text renders close to white over a white background
851+
canvas = (
852+
Canvas(240, 120)
853+
.background(color="#FFFFFF")
854+
.text("faint", size=36, color="#000000", opacity=0.1, position=(20, 20))
855+
)
856+
857+
# when
858+
diagnostics = canvas.diagnose()
859+
860+
# then
861+
assert [finding.code for finding in diagnostics] == ["low-contrast"]
862+
assert diagnostics[0].measured == {
863+
"contrast": 1.2480715209939224,
864+
"threshold": 2.0,
865+
"method": "worst-tile",
866+
"tile_bbox": {"x": 20, "y": 20, "width": 32, "height": 28},
867+
"tile_count": 3,
868+
"tile_size": 32,
869+
"foreground_rgb": (230.0, 230.0, 230.0),
870+
"background_rgb": (255.0, 255.0, 255.0),
871+
}
872+
873+
def test_should_warn_for_low_contrast_rich_text_run_inside_tile(self):
874+
"""A high-contrast run cannot hide a low-contrast run in the same tile"""
875+
from quickthumb import Canvas
876+
877+
# given: white rich text is readable but the following black run is invisible
878+
canvas = (
879+
Canvas(260, 120)
880+
.background(color="#000000")
881+
.text(
882+
[
883+
{"text": "HELLO", "color": "#FFFFFF"},
884+
{"text": "I", "color": "#000000"},
885+
],
886+
size=36,
887+
position=(20, 20),
888+
)
889+
)
890+
891+
# when
892+
diagnostics = canvas.diagnose()
893+
894+
# then
895+
assert [finding.code for finding in diagnostics] == ["low-contrast"]
896+
assert diagnostics[0].measured == {
897+
"contrast": 1.0,
898+
"threshold": 2.0,
899+
"method": "worst-tile",
900+
"tile_bbox": {"x": 116, "y": 20, "width": 26, "height": 32},
901+
"tile_count": 8,
902+
"tile_size": 32,
903+
"foreground_rgb": (0.0, 0.0, 0.0),
904+
"background_rgb": (0.0, 0.0, 0.0),
905+
}
906+
846907
@pytest.mark.parametrize(
847908
"background,color",
848909
[

0 commit comments

Comments
 (0)