mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:27:16 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Flutter repos now fail the repo contract when a launcher icon's ink is not on the shared 340px circle (png_ink), the launcher label breaks the naming rule (capital, ASCII letters, <= 10 chars, <= 522 wide in GoogleSansFlex), or an applicationIdSuffix variant still carries the LAUNCHER category. hook_sync rewrites a repo's stale repo-contract `files:` line to the widened template. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017k9j5dbjenpfZj7dh2faPN
155 lines
5.5 KiB
Python
155 lines
5.5 KiB
Python
"""Rule 4: every launcher glyph fills the same circle as the shared generator's.
|
|
|
|
The shared icon generator (`~/src/testsAndMisc/python_pkg/app_icons`, `fit.py`)
|
|
scales each glyph so its furthest inked pixel (alpha > 127) sits exactly
|
|
`style.INK_RADIUS` = 340px from the centre of its ink box, on a 1024px canvas,
|
|
with that centre on the canvas centre. This module re-measures a committed
|
|
`assets/icon/icon_foreground.png` the same way, so a hand-edited or stale
|
|
foreground cannot drift back to a different size.
|
|
|
|
Stdlib only (zlib + struct): the per-repo CI runner installs nothing, so Pillow
|
|
is not an option. Only the generator's own output format is decoded (8-bit
|
|
RGBA, non-interlaced, 1024x1024); anything else fails closed with a message.
|
|
Only the alpha channel is unfiltered: PNG filters work byte-wise with a
|
|
one-pixel offset, so alpha bytes depend only on other alpha bytes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import struct
|
|
import zlib
|
|
from pathlib import Path
|
|
|
|
CANVAS = 1024
|
|
INK_RADIUS = 340 # app_icons style.INK_RADIUS
|
|
RADIUS_TOLERANCE = CANVAS * 0.01
|
|
CENTRE_TOLERANCE = 2.0
|
|
INK_ALPHA = 127 # app_icons fit._INK_ALPHA: above this is ink
|
|
_SIG = b"\x89PNG\r\n\x1a\n"
|
|
_INKED = bytes(1 if v > INK_ALPHA else 0 for v in range(256))
|
|
_FIX = "regenerate it with ~/src/testsAndMisc/python_pkg/app_icons"
|
|
|
|
|
|
class PngError(ValueError):
|
|
"""The PNG is not one this check can measure."""
|
|
|
|
|
|
def _chunks(data: bytes) -> tuple[tuple[int, ...], bytes]:
|
|
"""(IHDR fields, concatenated IDAT) of a PNG byte string."""
|
|
if not data.startswith(_SIG):
|
|
raise PngError("is not a PNG")
|
|
pos, header, idat = len(_SIG), None, []
|
|
while pos + 8 <= len(data):
|
|
(size,) = struct.unpack_from(">I", data, pos)
|
|
kind, body = data[pos + 4 : pos + 8], data[pos + 8 : pos + 8 + size]
|
|
pos += 12 + size
|
|
if kind == b"IHDR":
|
|
header = struct.unpack(">IIBBBBB", body)
|
|
elif kind == b"IDAT":
|
|
idat.append(body)
|
|
elif kind == b"IEND":
|
|
break
|
|
if header is None:
|
|
raise PngError("has no IHDR chunk")
|
|
return header, b"".join(idat)
|
|
|
|
|
|
def _paeth(a: int, b: int, c: int) -> int:
|
|
p = a + b - c
|
|
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
|
|
if pa <= pb and pa <= pc:
|
|
return a
|
|
return b if pb <= pc else c
|
|
|
|
|
|
def _unfilter(kind: int, cur: bytes, prior: bytes) -> bytes:
|
|
"""Undo one row's PNG filter on a single channel (neighbour = 1 byte)."""
|
|
if kind == 0:
|
|
return cur
|
|
if kind == 2:
|
|
return bytes((x + p) & 255 for x, p in zip(cur, prior))
|
|
if kind not in (1, 3, 4):
|
|
raise PngError(f"has an invalid PNG filter type {kind}")
|
|
out = bytearray(cur)
|
|
for i in range(len(out)):
|
|
a = out[i - 1] if i else 0
|
|
if kind == 1:
|
|
pred = a
|
|
elif kind == 3:
|
|
pred = (a + prior[i]) // 2
|
|
else:
|
|
pred = _paeth(a, prior[i], prior[i - 1] if i else 0)
|
|
out[i] = (out[i] + pred) & 255
|
|
return bytes(out)
|
|
|
|
|
|
def alpha_rows(data: bytes) -> list[bytes]:
|
|
"""The alpha channel of a 1024x1024 8-bit RGBA PNG, one bytes per row."""
|
|
(width, height, depth, colour, _, _, interlace), idat = _chunks(data)
|
|
if (width, height) != (CANVAS, CANVAS):
|
|
raise PngError(f"is {width}x{height}; the family canvas is {CANVAS}x{CANVAS}")
|
|
if depth != 8 or colour != 6 or interlace:
|
|
raise PngError(
|
|
"must be 8-bit RGBA, non-interlaced "
|
|
f"(bit depth {depth}, colour type {colour}, interlace {interlace})"
|
|
)
|
|
try:
|
|
raw = zlib.decompress(idat)
|
|
except zlib.error as exc:
|
|
raise PngError(f"has corrupt image data: {exc}") from exc
|
|
stride = width * 4 + 1
|
|
if len(raw) != stride * height:
|
|
raise PngError("has truncated image data")
|
|
rows, prior = [], bytes(width)
|
|
for y in range(height):
|
|
line = raw[y * stride : (y + 1) * stride]
|
|
prior = _unfilter(line[0], line[4::4], prior)
|
|
rows.append(prior)
|
|
return rows
|
|
|
|
|
|
def ink_geometry(path: Path) -> tuple[float, float, float]:
|
|
"""(radius, cx, cy) exactly as app_icons `fit.ink_geometry` measures it.
|
|
|
|
The furthest inked pixel of a row is always its first or last one, so
|
|
only those two are measured per row.
|
|
"""
|
|
spans = []
|
|
for y, row in enumerate(alpha_rows(path.read_bytes())):
|
|
mask = row.translate(_INKED)
|
|
first = mask.find(1)
|
|
if first >= 0:
|
|
spans.append((y, first, mask.rfind(1)))
|
|
if not spans:
|
|
raise PngError(f"has no inked pixel (alpha > {INK_ALPHA})")
|
|
cx = (min(s[1] for s in spans) + max(s[2] for s in spans) + 1) / 2
|
|
cy = (spans[0][0] + spans[-1][0] + 1) / 2
|
|
radius = max(
|
|
math.hypot(x + 0.5 - cx, y + 0.5 - cy) for y, *ends in spans for x in ends
|
|
)
|
|
return radius, cx, cy
|
|
|
|
|
|
def icon_problems(path: Path, rel: str) -> list[str]:
|
|
"""Violations of the family ink circle for the foreground at `path`."""
|
|
try:
|
|
radius, cx, cy = ink_geometry(path)
|
|
except PngError as exc:
|
|
return [f"{rel} {exc}"]
|
|
except OSError as exc:
|
|
return [f"{rel} is unreadable: {exc}"]
|
|
out = []
|
|
if abs(radius - INK_RADIUS) > RADIUS_TOLERANCE:
|
|
out.append(
|
|
f"{rel}: ink radius {radius:.1f}px, the family circle is {INK_RADIUS}px"
|
|
f" ±{RADIUS_TOLERANCE:.1f}px: {_FIX}"
|
|
)
|
|
off = math.hypot(cx - CANVAS / 2, cy - CANVAS / 2)
|
|
if off > CENTRE_TOLERANCE:
|
|
out.append(
|
|
f"{rel}: ink centre ({cx:.1f}, {cy:.1f}) is {off:.1f}px from"
|
|
f" ({CANVAS // 2}, {CANVAS // 2}); at most {CENTRE_TOLERANCE:.0f}px"
|
|
)
|
|
return out
|