182 lines
6.4 KiB
Python
182 lines
6.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Portable macOS app icon checks. Uses only the Python standard library."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
import struct
|
|
import unittest
|
|
import zlib
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
SOURCE = ROOT / "Resources/IconArtwork.jpg"
|
|
MASTER = ROOT / "Resources/AppIcon.png"
|
|
PREVIEW = ROOT / "Resources/AppIcon-preview.png"
|
|
LAYOUT = ROOT / "Resources/AppIconLayout.json"
|
|
SVG = ROOT / "Resources/AppIconArtwork.svg"
|
|
ICONSET = ROOT / "build/macoc.iconset"
|
|
|
|
SIZE_NAMES = {
|
|
"icon_16x16.png": 16,
|
|
"icon_16x16@2x.png": 32,
|
|
"icon_32x32.png": 32,
|
|
"icon_32x32@2x.png": 64,
|
|
"icon_128x128.png": 128,
|
|
"icon_128x128@2x.png": 256,
|
|
"icon_256x256.png": 256,
|
|
"icon_256x256@2x.png": 512,
|
|
"icon_512x512.png": 512,
|
|
"icon_512x512@2x.png": 1024,
|
|
}
|
|
|
|
|
|
def jpeg_dimensions(path: Path) -> tuple[int, int]:
|
|
data = path.read_bytes()
|
|
if not data.startswith(b"\xff\xd8"):
|
|
raise ValueError(f"not a JPEG: {path}")
|
|
index = 2
|
|
while index < len(data):
|
|
if data[index] != 0xFF:
|
|
index += 1
|
|
continue
|
|
while index < len(data) and data[index] == 0xFF:
|
|
index += 1
|
|
marker = data[index]
|
|
index += 1
|
|
if marker in (0xD8, 0xD9, 0x00) or 0xD0 <= marker <= 0xD7:
|
|
continue
|
|
if index + 2 > len(data):
|
|
break
|
|
segment_length = struct.unpack(">H", data[index:index+2])[0]
|
|
if marker in (0xC0, 0xC1, 0xC2, 0xC3):
|
|
height, width = struct.unpack(">HH", data[index+3:index+7])
|
|
return width, height
|
|
index += segment_length
|
|
raise ValueError(f"no JPEG SOF marker: {path}")
|
|
|
|
|
|
def png_info(path: Path) -> tuple[int, int, int]:
|
|
header = path.read_bytes()[:26]
|
|
if header[:8] != b"\x89PNG\r\n\x1a\n" or header[12:16] != b"IHDR":
|
|
raise ValueError(f"not a PNG: {path}")
|
|
width, height = struct.unpack(">II", header[16:24])
|
|
return width, height, header[25]
|
|
|
|
|
|
def alpha_at(path: Path, x: int, y: int) -> int:
|
|
"""Decode an RGBA PNG scanline, supporting all five PNG row filters."""
|
|
data = path.read_bytes()
|
|
width, height, color = png_info(path)
|
|
if color != 6 or data[24] != 8:
|
|
raise ValueError("expected 8-bit RGBA PNG")
|
|
if not (0 <= x < width and 0 <= y < height):
|
|
raise ValueError("pixel outside PNG")
|
|
|
|
offset = 8
|
|
compressed = []
|
|
while offset < len(data):
|
|
size = struct.unpack(">I", data[offset:offset+4])[0]
|
|
kind = data[offset+4:offset+8]
|
|
if kind == b"IDAT":
|
|
compressed.append(data[offset+8:offset+8+size])
|
|
offset += 12 + size
|
|
if kind == b"IEND":
|
|
break
|
|
|
|
raw = zlib.decompress(b"".join(compressed))
|
|
stride = width * 4
|
|
previous = bytearray(stride)
|
|
position = 0
|
|
|
|
for row_number in range(y + 1):
|
|
filter_type = raw[position]
|
|
position += 1
|
|
row = bytearray(raw[position:position+stride])
|
|
position += stride
|
|
|
|
for i in range(stride):
|
|
left = row[i-4] if i >= 4 else 0
|
|
above = previous[i]
|
|
upper_left = previous[i-4] if i >= 4 else 0
|
|
|
|
if filter_type == 1:
|
|
predictor = left
|
|
elif filter_type == 2:
|
|
predictor = above
|
|
elif filter_type == 3:
|
|
predictor = (left + above) // 2
|
|
elif filter_type == 4:
|
|
prediction = left + above - upper_left
|
|
da = abs(prediction - left)
|
|
db = abs(prediction - above)
|
|
dc = abs(prediction - upper_left)
|
|
predictor = left if da <= db and da <= dc else (
|
|
above if db <= dc else upper_left
|
|
)
|
|
elif filter_type == 0:
|
|
predictor = 0
|
|
else:
|
|
raise ValueError(f"unsupported PNG filter: {filter_type}")
|
|
|
|
row[i] = (row[i] + predictor) & 0xFF
|
|
|
|
previous = row
|
|
|
|
return previous[x * 4 + 3]
|
|
|
|
|
|
class AppIconAssetTests(unittest.TestCase):
|
|
def test_artwork_source_is_square(self):
|
|
w, h = jpeg_dimensions(SOURCE)
|
|
self.assertEqual(w, h)
|
|
self.assertGreaterEqual(w, 256)
|
|
|
|
def test_reference_layout_uses_centered_1024_grid(self):
|
|
layout = json.loads(LAYOUT.read_text())
|
|
self.assertEqual(layout["canvasPixels"], 1024)
|
|
self.assertEqual(layout["bodyPixels"], 824)
|
|
self.assertEqual(layout["bodyOrigin"], [100, 100])
|
|
self.assertGreater(layout["superellipseExponent"], 3)
|
|
self.assertLess(layout["superellipseExponent"], 6)
|
|
self.assertEqual(len(layout["sourceCrop"]), 4)
|
|
self.assertTrue(all(0 <= x <= 1 for x in layout["sourceCrop"]))
|
|
self.assertLess(layout["sourceCrop"][0], layout["sourceCrop"][2])
|
|
self.assertLess(layout["sourceCrop"][1], layout["sourceCrop"][3])
|
|
|
|
def test_master_has_transparent_corners(self):
|
|
self.assertEqual(png_info(MASTER), (1024, 1024, 6))
|
|
for point in [(0, 0), (1023, 0), (0, 1023), (1023, 1023)]:
|
|
with self.subTest(corner=point):
|
|
self.assertEqual(alpha_at(MASTER, *point), 0)
|
|
self.assertEqual(alpha_at(MASTER, 512, 512), 255)
|
|
|
|
def test_preview_is_real_png_and_linked_from_readme(self):
|
|
w, h, color = png_info(PREVIEW)
|
|
self.assertEqual((w, h, color), (256, 256, 6))
|
|
self.assertEqual(alpha_at(PREVIEW, 0, 0), 0)
|
|
readme = (ROOT / "README.md").read_text()
|
|
self.assertIn("Resources/AppIcon-preview.png", readme)
|
|
|
|
def test_svg_is_editable_and_pipeline_uses_master(self):
|
|
svg = SVG.read_text()
|
|
self.assertIn('viewBox="0 0 1024 1024"', svg)
|
|
self.assertIn('clipPath id="icon-body"', svg)
|
|
self.assertIn('IconArtwork.jpg', svg)
|
|
script = (ROOT / "scripts/icon.sh").read_text()
|
|
self.assertIn("Resources/AppIcon.png", script)
|
|
self.assertIn("iconutil -c icns", script)
|
|
self.assertIn("sips -s format png", script)
|
|
|
|
@unittest.skipUnless(ICONSET.exists(), "generated macOS iconset unavailable")
|
|
def test_iconset_contains_all_ten_variants(self):
|
|
self.assertEqual({p.name for p in ICONSET.glob("*.png")}, set(SIZE_NAMES))
|
|
for name, pixels in SIZE_NAMES.items():
|
|
with self.subTest(filename=name):
|
|
w, h, color = png_info(ICONSET / name)
|
|
self.assertEqual((w, h), (pixels, pixels))
|
|
self.assertIn(color, (2, 6))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|