fxcss 0.6.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fxcss/__init__.py +3 -0
- fxcss/__main__.py +6 -0
- fxcss/audit.py +595 -0
- fxcss/catalogue.py +385 -0
- fxcss/cli.py +558 -0
- fxcss/compare.py +189 -0
- fxcss/core.py +999 -0
- fxcss/fetch.py +255 -0
- fxcss/probe.py +220 -0
- fxcss-0.6.1.dist-info/METADATA +572 -0
- fxcss-0.6.1.dist-info/RECORD +15 -0
- fxcss-0.6.1.dist-info/WHEEL +5 -0
- fxcss-0.6.1.dist-info/entry_points.txt +2 -0
- fxcss-0.6.1.dist-info/licenses/LICENSE +21 -0
- fxcss-0.6.1.dist-info/top_level.txt +1 -0
fxcss/compare.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Turn two sets of theme screenshots into before/after/diff images.
|
|
3
|
+
|
|
4
|
+
Reads a base/ and a head/ directory of PNGs produced by render.py, compares
|
|
5
|
+
each matching view, and writes one stacked comparison image per view that
|
|
6
|
+
changed, plus a summary.json describing what changed and by how much.
|
|
7
|
+
|
|
8
|
+
Views that render identically are reported but not drawn -- the point is to put
|
|
9
|
+
the reviewer's eye straight on the difference.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
python3 compose.py --base base/ --head head/ --out out/ --platform macos
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from PIL import Image, ImageChops, ImageDraw, ImageFont
|
|
21
|
+
|
|
22
|
+
# Width each panel is normalised to. GitHub renders comment images at roughly
|
|
23
|
+
# 800px, so this stays sharp without making the comment enormous.
|
|
24
|
+
PANEL_WIDTH = 960
|
|
25
|
+
LABEL_HEIGHT = 34
|
|
26
|
+
GAP = 10
|
|
27
|
+
# Per-channel difference below this is treated as noise (antialiasing, subpixel
|
|
28
|
+
# text rendering) rather than a real change.
|
|
29
|
+
NOISE_THRESHOLD = 24
|
|
30
|
+
HIGHLIGHT = (255, 0, 128)
|
|
31
|
+
|
|
32
|
+
FRIENDLY_NAMES = {
|
|
33
|
+
"01-window": "Browser window",
|
|
34
|
+
"02-urlbar": "Address bar focused",
|
|
35
|
+
"03-findbar": "Find bar",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_font(size):
|
|
40
|
+
try:
|
|
41
|
+
return ImageFont.load_default(size=size)
|
|
42
|
+
except TypeError: # older Pillow: default font has no size argument
|
|
43
|
+
return ImageFont.load_default()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def normalise(im, width=PANEL_WIDTH):
|
|
47
|
+
im = im.convert("RGB")
|
|
48
|
+
if im.width == width:
|
|
49
|
+
return im
|
|
50
|
+
height = round(im.height * width / im.width)
|
|
51
|
+
return im.resize((width, height), Image.LANCZOS)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def diff_stats(base, head):
|
|
55
|
+
"""Return (changed_pixel_count, total, mask) ignoring sub-threshold noise."""
|
|
56
|
+
delta = ImageChops.difference(base, head).convert("L")
|
|
57
|
+
mask = delta.point(lambda v: 255 if v >= NOISE_THRESHOLD else 0)
|
|
58
|
+
changed = mask.histogram()[255]
|
|
59
|
+
return changed, base.width * base.height, mask
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def render_diff_panel(base, mask):
|
|
63
|
+
"""Faded base image with changed regions painted in a hot highlight."""
|
|
64
|
+
faded = Image.blend(base, Image.new("RGB", base.size, (255, 255, 255)), 0.72)
|
|
65
|
+
overlay = Image.new("RGB", base.size, HIGHLIGHT)
|
|
66
|
+
# Grow the mask slightly so single-pixel changes stay visible once scaled.
|
|
67
|
+
from PIL import ImageFilter
|
|
68
|
+
grown = mask.filter(ImageFilter.MaxFilter(5))
|
|
69
|
+
return Image.composite(overlay, faded, grown)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def label_bar(width, text, bg, fg=(255, 255, 255)):
|
|
73
|
+
bar = Image.new("RGB", (width, LABEL_HEIGHT), bg)
|
|
74
|
+
draw = ImageDraw.Draw(bar)
|
|
75
|
+
draw.text((12, LABEL_HEIGHT // 2), text, fill=fg, font=load_font(20), anchor="lm")
|
|
76
|
+
return bar
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def stack(panels, width):
|
|
80
|
+
"""Vertically stack (label, image) pairs onto one canvas."""
|
|
81
|
+
height = sum(LABEL_HEIGHT + im.height + GAP for _, im in panels) + GAP
|
|
82
|
+
canvas = Image.new("RGB", (width, height), (240, 241, 243))
|
|
83
|
+
y = GAP
|
|
84
|
+
for (text, bg), im in panels:
|
|
85
|
+
canvas.paste(label_bar(width, text, bg), (0, y))
|
|
86
|
+
y += LABEL_HEIGHT
|
|
87
|
+
canvas.paste(im, (0, y))
|
|
88
|
+
y += im.height + GAP
|
|
89
|
+
return canvas
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def compare_view(name, base_path, head_path, outdir):
|
|
93
|
+
base = normalise(Image.open(base_path))
|
|
94
|
+
head = normalise(Image.open(head_path))
|
|
95
|
+
|
|
96
|
+
if base.size != head.size:
|
|
97
|
+
# Different window heights between runs: pad the shorter one so the
|
|
98
|
+
# comparison is still meaningful rather than failing outright.
|
|
99
|
+
height = max(base.height, head.height)
|
|
100
|
+
|
|
101
|
+
def pad(im):
|
|
102
|
+
if im.height == height:
|
|
103
|
+
return im
|
|
104
|
+
padded = Image.new("RGB", (im.width, height), (255, 255, 255))
|
|
105
|
+
padded.paste(im, (0, 0))
|
|
106
|
+
return padded
|
|
107
|
+
|
|
108
|
+
base, head = pad(base), pad(head)
|
|
109
|
+
|
|
110
|
+
changed, total, mask = diff_stats(base, head)
|
|
111
|
+
pct = 100.0 * changed / total if total else 0.0
|
|
112
|
+
|
|
113
|
+
result = {
|
|
114
|
+
"view": name,
|
|
115
|
+
"title": FRIENDLY_NAMES.get(name.split("-", 1)[1], name),
|
|
116
|
+
"mode": name.split("-", 1)[0],
|
|
117
|
+
"changed_pixels": changed,
|
|
118
|
+
"total_pixels": total,
|
|
119
|
+
"percent": round(pct, 4),
|
|
120
|
+
"image": None,
|
|
121
|
+
}
|
|
122
|
+
if changed == 0:
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
comparison = stack(
|
|
126
|
+
[
|
|
127
|
+
(("BEFORE · base branch", (90, 96, 104)), base),
|
|
128
|
+
(("AFTER · this PR", (32, 113, 62)), head),
|
|
129
|
+
((f"CHANGED · {pct:.2f}% of pixels", (176, 21, 92)), render_diff_panel(base, mask)),
|
|
130
|
+
],
|
|
131
|
+
base.width,
|
|
132
|
+
)
|
|
133
|
+
out_path = outdir / f"{name}.png"
|
|
134
|
+
comparison.save(out_path, optimize=True)
|
|
135
|
+
result["image"] = out_path.name
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def run(base_dir: Path, head_dir: Path, out: Path, platform: str):
|
|
140
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
|
|
142
|
+
base_shots = {p.stem: p for p in sorted(base_dir.glob("*.png"))}
|
|
143
|
+
head_shots = {p.stem: p for p in sorted(head_dir.glob("*.png"))}
|
|
144
|
+
shared = sorted(set(base_shots) & set(head_shots))
|
|
145
|
+
|
|
146
|
+
summary = {
|
|
147
|
+
"platform": platform,
|
|
148
|
+
"views": [],
|
|
149
|
+
"only_in_base": sorted(set(base_shots) - set(head_shots)),
|
|
150
|
+
"only_in_head": sorted(set(head_shots) - set(base_shots)),
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for name in shared:
|
|
154
|
+
result = compare_view(name, base_shots[name], head_shots[name], out)
|
|
155
|
+
summary["views"].append(result)
|
|
156
|
+
state = "changed" if result["changed_pixels"] else "identical"
|
|
157
|
+
print(f" {name}: {state} ({result['percent']:.3f}%)", flush=True)
|
|
158
|
+
|
|
159
|
+
# Always ship the head screenshots too, so a reviewer can see the whole UI
|
|
160
|
+
# even when a change is subtle or when nothing differs at all.
|
|
161
|
+
full_dir = out / "full"
|
|
162
|
+
full_dir.mkdir(exist_ok=True)
|
|
163
|
+
for name, path in head_shots.items():
|
|
164
|
+
normalise(Image.open(path)).save(full_dir / f"{name}.png", optimize=True)
|
|
165
|
+
|
|
166
|
+
summary["changed_views"] = [v for v in summary["views"] if v["changed_pixels"]]
|
|
167
|
+
summary["any_change"] = bool(summary["changed_views"])
|
|
168
|
+
|
|
169
|
+
src = head_dir / "render-info.json"
|
|
170
|
+
if src.exists():
|
|
171
|
+
summary["render_info"] = json.loads(src.read_text(encoding="utf-8"))
|
|
172
|
+
|
|
173
|
+
(out / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
|
174
|
+
print(f"\n{len(summary['changed_views'])} of {len(shared)} views changed on {platform}")
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def main():
|
|
179
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
180
|
+
ap.add_argument("--base", required=True, type=Path)
|
|
181
|
+
ap.add_argument("--head", required=True, type=Path)
|
|
182
|
+
ap.add_argument("--out", required=True, type=Path)
|
|
183
|
+
ap.add_argument("--platform", required=True)
|
|
184
|
+
args = ap.parse_args()
|
|
185
|
+
return run(args.base, args.head, args.out, args.platform)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
if __name__ == "__main__":
|
|
189
|
+
sys.exit(main())
|