92 lines
5.6 KiB
Python
92 lines
5.6 KiB
Python
"""Read browser PNG backgrounds beside text; never edit production images.
|
|
|
|
Sample positions cover DOM text line bounds, with a clear padding-column probe
|
|
for the current vertical opaque gradients. Pixel sampling and the sRGB CSS model
|
|
are distinct evidence; unsupported/clipped painting is NOT VERIFIED.
|
|
"""
|
|
import json
|
|
import math
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from PIL import Image
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
OUT = ROOT / 'test-results' / 'org-chart-quality'
|
|
report = json.loads((OUT / (sys.argv[1] if len(sys.argv) > 1 else 'browser.json')).read_text(encoding='utf-8'))
|
|
|
|
def rgb(value):
|
|
match = re.fullmatch(r'rgba?\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)(?:,\s*(1))?\)', value)
|
|
return tuple(map(float, match.groups()[:3])) if match else None
|
|
|
|
def luminance(color):
|
|
def linear(channel):
|
|
encoded = channel / 255
|
|
return encoded / 12.92 if encoded <= .04045 else ((encoded + .055) / 1.055) ** 2.4
|
|
return sum(weight * linear(channel) for weight, channel in zip((.2126, .7152, .0722), color))
|
|
|
|
def contrast(first, second):
|
|
a, b = sorted((luminance(first), luminance(second)))
|
|
return (b + .05) / (a + .05)
|
|
|
|
evidence = []
|
|
for measurement in report['measurements']:
|
|
image = Image.open(OUT / measurement['screenshot']).convert('RGB')
|
|
for item in measurement['colors']:
|
|
foreground = rgb(item['color'])
|
|
behind = item['behind']
|
|
entry = dict(label=measurement['label'], width=measurement['width'], selector=item['className'],
|
|
organizationId=item.get('organizationId'), variant=item.get('variant'), target=item.get('target'),
|
|
foreground=item['color'], fontSize=item['fontSize'], fontWeight=item['fontWeight'])
|
|
threshold = 3 if item['fontSize'] >= 24 or (float(item['fontWeight']) >= 700 and item['fontSize'] >= 14 * 96 / 72) else 4.5
|
|
entry['threshold'] = threshold
|
|
if not foreground or not behind:
|
|
entry.update(status='NOT VERIFIED', reason='Unknown foreground/background composition')
|
|
elif behind['gradient'] == 'none':
|
|
background = rgb(behind['background'])
|
|
if background:
|
|
value = contrast(foreground, background)
|
|
entry.update(status='PASS' if value >= threshold else 'REVIEW', method='computed opaque solid', ratio=value, background=behind['background'])
|
|
else:
|
|
entry.update(status='NOT VERIFIED', reason='Transparent/unsupported solid composition')
|
|
else:
|
|
stops = re.findall(r'rgb\((\d+),\s*(\d+),\s*(\d+)\)\s+(0|100)%', behind['gradient'])
|
|
box, lines, clip = behind['box'], item['ranges'], item.get('clip')
|
|
angle = re.search(r'linear-gradient\(([^r][^,]*),', behind['gradient'])
|
|
if len(stops) != 2 or not lines or (angle and angle.group(1).strip() != '180deg'):
|
|
entry.update(status='NOT VERIFIED', reason='Unsupported gradient/text range')
|
|
else:
|
|
colors = [tuple(map(int, stop[:3])) for stop in stops]
|
|
# Probe an empty padding column, inside corners and before text.
|
|
text_left = min(line['x'] for line in lines)
|
|
x = math.floor(min(text_left - 2, box['x'] + max(4, (text_left - box['x']) / 2)))
|
|
samples, deltas = [], []
|
|
for line in lines:
|
|
for y in range(math.ceil(line['y']), math.floor(line['y'] + line['height']) + 1):
|
|
if not (0 <= x < image.width and 0 <= y < image.height):
|
|
continue
|
|
if clip and not (clip['x'] + 1 <= x < clip['x'] + clip['width'] - 1 and clip['y'] + 1 <= y < clip['y'] + clip['height'] - 1):
|
|
continue
|
|
offset = (y + .5 - box['y']) / box['height']
|
|
if not 0 <= offset <= 1:
|
|
continue
|
|
pixel = image.getpixel((x, y))
|
|
modeled = tuple(a + (b - a) * offset for a, b in zip(*colors))
|
|
delta = max(abs(a - b) for a, b in zip(pixel, modeled))
|
|
deltas.append(delta)
|
|
samples.append(dict(x=x, y=y, background=pixel, contrast=contrast(foreground, pixel), modelDelta=delta))
|
|
if not samples or max(deltas) > 2:
|
|
entry.update(status='NOT VERIFIED', reason='Probe clipped, overpainted or disagrees with supported sRGB painting model', sampled=len(samples), maxModelDelta=max(deltas) if deltas else None)
|
|
else:
|
|
minimum = min(samples, key=lambda sample: sample['contrast'])
|
|
entry.update(status='PASS' if minimum['contrast'] >= threshold else 'REVIEW', method='PNG background beside actual DOM text lines',
|
|
ratio=minimum['contrast'], sampled=len(samples), maxModelDelta=max(deltas), weakest=minimum,
|
|
conservativeStopMinimum=min(contrast(foreground, color) for color in colors))
|
|
evidence.append(entry)
|
|
|
|
result = dict(method='DPR1 Chrome PNG; opaque foreground from computed CSS; padding-column gradient probes over DOM Range line bounds',
|
|
thresholds='Unrounded 4.5 normal / 3 large; no logo recoloring; no full WCAG certification',
|
|
results=evidence, statusCounts={status: sum(item['status'] == status for item in evidence) for status in ('PASS', 'REVIEW', 'NOT VERIFIED')})
|
|
(OUT / (sys.argv[2] if len(sys.argv) > 2 else 'contrast-pixels.json')).write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
print(json.dumps(result['statusCounts']))
|