jekyll-theme-zer0 1.19.1 → 1.20.2
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +395 -0
- data/README.md +27 -19
- data/_data/authors.yml +154 -5
- data/_data/backlog.yml +5 -5
- data/_data/content_statistics.yml +273 -297
- data/_data/features.yml +4 -25
- data/_data/navigation/README.md +24 -0
- data/_data/navigation/about.yml +2 -0
- data/_data/navigation/main.yml +2 -7
- data/_data/roadmap.yml +86 -12
- data/_includes/components/author-avatar-url.html +28 -0
- data/_includes/components/author-bio.html +86 -0
- data/_includes/components/author-card.html +184 -121
- data/_includes/components/author-eeat.html +10 -4
- data/_includes/components/info-section.html +1 -1
- data/_includes/components/mermaid.html +0 -3
- data/_includes/components/post-card.html +19 -9
- data/_includes/content/giscus.html +3 -2
- data/_includes/core/footer-fabs.html +28 -0
- data/_includes/core/footer.html +7 -17
- data/_includes/core/head.html +2 -2
- data/_includes/navigation/breadcrumbs.html +20 -2
- data/_includes/navigation/local-graph.html +18 -2
- data/_includes/obsidian/full-graph.html +4 -6
- data/_layouts/article.html +44 -74
- data/_layouts/author.html +274 -0
- data/_layouts/authors.html +55 -0
- data/_layouts/news.html +3 -3
- data/_layouts/note.html +21 -6
- data/_layouts/notebook.html +21 -6
- data/_layouts/root.html +31 -17
- data/_layouts/section.html +3 -3
- data/_plugins/author_pages_generator.rb +121 -0
- data/_sass/components/_author.scss +219 -0
- data/_sass/components/_content-tables.scss +16 -1
- data/_sass/components/_notes-index.scss +102 -0
- data/_sass/components/_search-modal.scss +40 -0
- data/_sass/components/_ui-enhancements.scss +570 -0
- data/_sass/core/_docs-code-examples.scss +463 -0
- data/_sass/core/_docs-layout.scss +0 -453
- data/_sass/core/_navbar.scss +253 -0
- data/_sass/core/_sidebar-extras.scss +79 -0
- data/_sass/core/_toc.scss +87 -0
- data/_sass/core/_variables.scss +7 -142
- data/_sass/custom.scss +24 -1122
- data/_sass/layouts/_global-chrome.scss +59 -0
- data/assets/css/main.scss +19 -2
- data/assets/js/author-profile.js +190 -0
- data/assets/js/modules/navigation/navbar.js +104 -0
- data/assets/js/obsidian-graph.js +2 -2
- data/assets/js/obsidian-local-graph.js +11 -5
- data/assets/vendor/cytoscape/cytoscape.min.js +32 -0
- data/scripts/README.md +39 -0
- data/scripts/bin/validate +11 -1
- data/scripts/dev/css-diff.sh +49 -0
- data/scripts/dev/shot.js +37 -0
- data/scripts/features/generate-preview-images +110 -6
- data/scripts/features/pixelate-preview-images +126 -0
- data/scripts/features/pixelate_images.py +662 -0
- data/scripts/github-setup.sh +0 -0
- data/scripts/lib/preview_generator.py +47 -3
- data/scripts/pixelate-preview-images.sh +12 -0
- data/scripts/test/integration/auto-version +10 -8
- data/scripts/test/lib/run_tests.sh +2 -0
- data/scripts/test/lib/test_content_review.sh +205 -0
- data/scripts/test/lib/test_pixelate_images.sh +108 -0
- metadata +25 -20
- data/_data/hub.yml +0 -68
- data/_data/hub_index.yml +0 -203
- data/_data/navigation/hub.yml +0 -110
- data/assets/vendor/font-awesome/css/all.min.css +0 -9
- data/assets/vendor/font-awesome/webfonts/fa-brands-400.ttf +0 -0
- data/assets/vendor/font-awesome/webfonts/fa-brands-400.woff2 +0 -0
- data/assets/vendor/font-awesome/webfonts/fa-regular-400.ttf +0 -0
- data/assets/vendor/font-awesome/webfonts/fa-regular-400.woff2 +0 -0
- data/assets/vendor/font-awesome/webfonts/fa-solid-900.ttf +0 -0
- data/assets/vendor/font-awesome/webfonts/fa-solid-900.woff2 +0 -0
- data/assets/vendor/font-awesome/webfonts/fa-v4compatibility.ttf +0 -0
- data/assets/vendor/font-awesome/webfonts/fa-v4compatibility.woff2 +0 -0
- data/assets/vendor/jquery/jquery-3.7.1.min.js +0 -2
- data/scripts/lib/hub.rb +0 -208
- data/scripts/provision-org-sites.rb +0 -252
- data/scripts/provision-org-sites.sh +0 -23
- data/scripts/sync-hub-metadata.rb +0 -184
- data/scripts/sync-hub-metadata.sh +0 -22
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pixelate + palette-quantize PNG images to shrink file size while keeping quality.
|
|
3
|
+
|
|
4
|
+
File: scripts/features/pixelate_images.py
|
|
5
|
+
Purpose: Dependency-free (Python stdlib only) image optimizer for the Jekyll
|
|
6
|
+
preview banners under ``assets/images/previews``. The preview art is
|
|
7
|
+
AI-generated "retro pixel art / 8-bit" styling, so downsampling and
|
|
8
|
+
reducing the colour palette both shrinks the files dramatically *and*
|
|
9
|
+
reinforces the intended aesthetic.
|
|
10
|
+
|
|
11
|
+
Why pure stdlib: GitHub Pages / CI runners and contributor machines cannot be
|
|
12
|
+
assumed to have ImageMagick, ``pngquant`` or Pillow installed. The preview PNGs
|
|
13
|
+
are uniformly 8-bit, non-interlaced truecolour (colour type 2), which is cheap
|
|
14
|
+
to decode and re-encode with ``zlib`` alone.
|
|
15
|
+
|
|
16
|
+
Pipeline per image:
|
|
17
|
+
1. Decode the PNG (8-bit colour types 0/2/3/4/6, non-interlaced).
|
|
18
|
+
2. Pixelate: downsample to a target size (nearest or box filter).
|
|
19
|
+
3. Quantize: median-cut the colours to an N-colour palette.
|
|
20
|
+
4. Encode an indexed PNG-8 (+ ``tRNS`` when the source had alpha).
|
|
21
|
+
5. Keep the result only when it is actually smaller than the original.
|
|
22
|
+
|
|
23
|
+
Run ``python3 pixelate_images.py --help`` for CLI usage, or
|
|
24
|
+
``--selftest`` to exercise the round-trip on a synthetic image.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import os
|
|
31
|
+
import struct
|
|
32
|
+
import sys
|
|
33
|
+
import zlib
|
|
34
|
+
|
|
35
|
+
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
36
|
+
|
|
37
|
+
# Channels per PNG colour type (8-bit only).
|
|
38
|
+
CHANNELS = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class UnsupportedPNG(Exception):
|
|
42
|
+
"""Raised when a PNG uses a feature this minimal decoder does not handle."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Decoding
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def _iter_chunks(data: bytes):
|
|
50
|
+
if data[:8] != PNG_SIGNATURE:
|
|
51
|
+
raise UnsupportedPNG("not a PNG file")
|
|
52
|
+
pos = 8
|
|
53
|
+
n = len(data)
|
|
54
|
+
while pos + 8 <= n:
|
|
55
|
+
(length,) = struct.unpack(">I", data[pos:pos + 4])
|
|
56
|
+
ctype = data[pos + 4:pos + 8]
|
|
57
|
+
start = pos + 8
|
|
58
|
+
end = start + length
|
|
59
|
+
if end + 4 > n:
|
|
60
|
+
raise UnsupportedPNG("truncated chunk")
|
|
61
|
+
yield ctype, data[start:end]
|
|
62
|
+
pos = end + 4 # skip the 4-byte CRC
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _paeth(a: int, b: int, c: int) -> int:
|
|
66
|
+
p = a + b - c
|
|
67
|
+
pa = abs(p - a)
|
|
68
|
+
pb = abs(p - b)
|
|
69
|
+
pc = abs(p - c)
|
|
70
|
+
if pa <= pb and pa <= pc:
|
|
71
|
+
return a
|
|
72
|
+
if pb <= pc:
|
|
73
|
+
return b
|
|
74
|
+
return c
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _unfilter(raw: bytes, width: int, height: int, bpp: int) -> bytearray:
|
|
78
|
+
"""Reverse PNG scanline filtering. Returns flat per-channel byte buffer."""
|
|
79
|
+
stride = width * bpp
|
|
80
|
+
out = bytearray(stride * height)
|
|
81
|
+
prev = bytearray(stride)
|
|
82
|
+
pos = 0
|
|
83
|
+
for y in range(height):
|
|
84
|
+
ftype = raw[pos]
|
|
85
|
+
pos += 1
|
|
86
|
+
line = bytearray(raw[pos:pos + stride])
|
|
87
|
+
pos += stride
|
|
88
|
+
if ftype == 0:
|
|
89
|
+
pass
|
|
90
|
+
elif ftype == 1: # Sub
|
|
91
|
+
for i in range(bpp, stride):
|
|
92
|
+
line[i] = (line[i] + line[i - bpp]) & 0xFF
|
|
93
|
+
elif ftype == 2: # Up
|
|
94
|
+
for i in range(stride):
|
|
95
|
+
line[i] = (line[i] + prev[i]) & 0xFF
|
|
96
|
+
elif ftype == 3: # Average
|
|
97
|
+
for i in range(stride):
|
|
98
|
+
a = line[i - bpp] if i >= bpp else 0
|
|
99
|
+
line[i] = (line[i] + ((a + prev[i]) >> 1)) & 0xFF
|
|
100
|
+
elif ftype == 4: # Paeth
|
|
101
|
+
for i in range(stride):
|
|
102
|
+
a = line[i - bpp] if i >= bpp else 0
|
|
103
|
+
c = prev[i - bpp] if i >= bpp else 0
|
|
104
|
+
line[i] = (line[i] + _paeth(a, prev[i], c)) & 0xFF
|
|
105
|
+
else:
|
|
106
|
+
raise UnsupportedPNG(f"unknown filter type {ftype}")
|
|
107
|
+
out[y * stride:(y + 1) * stride] = line
|
|
108
|
+
prev = line
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Image:
|
|
113
|
+
"""In-memory RGB or RGBA image with a flat ``bytearray`` of pixel data."""
|
|
114
|
+
|
|
115
|
+
__slots__ = ("width", "height", "channels", "data")
|
|
116
|
+
|
|
117
|
+
def __init__(self, width: int, height: int, channels: int, data: bytearray):
|
|
118
|
+
self.width = width
|
|
119
|
+
self.height = height
|
|
120
|
+
self.channels = channels # 3 (RGB) or 4 (RGBA)
|
|
121
|
+
self.data = data
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def decode_png(data: bytes) -> Image:
|
|
125
|
+
"""Decode an 8-bit, non-interlaced PNG into an RGB/RGBA :class:`Image`."""
|
|
126
|
+
ihdr = None
|
|
127
|
+
palette = None
|
|
128
|
+
trns = None
|
|
129
|
+
idat = bytearray()
|
|
130
|
+
for ctype, chunk in _iter_chunks(data):
|
|
131
|
+
if ctype == b"IHDR":
|
|
132
|
+
width, height, bit_depth, color_type, comp, filt, interlace = struct.unpack(
|
|
133
|
+
">IIBBBBB", chunk
|
|
134
|
+
)
|
|
135
|
+
ihdr = (width, height, bit_depth, color_type, interlace)
|
|
136
|
+
elif ctype == b"PLTE":
|
|
137
|
+
palette = chunk
|
|
138
|
+
elif ctype == b"tRNS":
|
|
139
|
+
trns = chunk
|
|
140
|
+
elif ctype == b"IDAT":
|
|
141
|
+
idat += chunk
|
|
142
|
+
elif ctype == b"IEND":
|
|
143
|
+
break
|
|
144
|
+
|
|
145
|
+
if ihdr is None:
|
|
146
|
+
raise UnsupportedPNG("missing IHDR")
|
|
147
|
+
width, height, bit_depth, color_type, interlace = ihdr
|
|
148
|
+
if bit_depth != 8:
|
|
149
|
+
raise UnsupportedPNG(f"unsupported bit depth {bit_depth}")
|
|
150
|
+
if interlace != 0:
|
|
151
|
+
raise UnsupportedPNG("interlaced PNG not supported")
|
|
152
|
+
if color_type not in CHANNELS:
|
|
153
|
+
raise UnsupportedPNG(f"unsupported colour type {color_type}")
|
|
154
|
+
|
|
155
|
+
bpp = CHANNELS[color_type]
|
|
156
|
+
flat = _unfilter(zlib.decompress(bytes(idat)), width, height, bpp)
|
|
157
|
+
|
|
158
|
+
# Normalise to RGB (3) or RGBA (4).
|
|
159
|
+
npix = width * height
|
|
160
|
+
if color_type == 2: # RGB
|
|
161
|
+
return Image(width, height, 3, flat)
|
|
162
|
+
if color_type == 6: # RGBA
|
|
163
|
+
return Image(width, height, 4, flat)
|
|
164
|
+
if color_type == 0: # grayscale -> RGB
|
|
165
|
+
out = bytearray(npix * 3)
|
|
166
|
+
for i in range(npix):
|
|
167
|
+
v = flat[i]
|
|
168
|
+
out[i * 3] = out[i * 3 + 1] = out[i * 3 + 2] = v
|
|
169
|
+
return Image(width, height, 3, out)
|
|
170
|
+
if color_type == 4: # grayscale + alpha -> RGBA
|
|
171
|
+
out = bytearray(npix * 4)
|
|
172
|
+
for i in range(npix):
|
|
173
|
+
v = flat[i * 2]
|
|
174
|
+
out[i * 4] = out[i * 4 + 1] = out[i * 4 + 2] = v
|
|
175
|
+
out[i * 4 + 3] = flat[i * 2 + 1]
|
|
176
|
+
return Image(width, height, 4, out)
|
|
177
|
+
# color_type == 3: palette index -> RGB(A)
|
|
178
|
+
if palette is None:
|
|
179
|
+
raise UnsupportedPNG("indexed PNG missing PLTE")
|
|
180
|
+
has_alpha = trns is not None
|
|
181
|
+
chan = 4 if has_alpha else 3
|
|
182
|
+
out = bytearray(npix * chan)
|
|
183
|
+
for i in range(npix):
|
|
184
|
+
idx = flat[i]
|
|
185
|
+
out[i * chan] = palette[idx * 3]
|
|
186
|
+
out[i * chan + 1] = palette[idx * 3 + 1]
|
|
187
|
+
out[i * chan + 2] = palette[idx * 3 + 2]
|
|
188
|
+
if has_alpha:
|
|
189
|
+
out[i * chan + 3] = trns[idx] if idx < len(trns) else 255
|
|
190
|
+
return Image(width, height, chan, out)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
# Pixelate (downsample)
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def target_size(width: int, height: int, *, block=None, scale=None, max_width=None):
|
|
198
|
+
"""Resolve the output dimensions from the chosen pixelation knob."""
|
|
199
|
+
if block:
|
|
200
|
+
ow = max(1, round(width / block))
|
|
201
|
+
oh = max(1, round(height / block))
|
|
202
|
+
elif scale:
|
|
203
|
+
ow = max(1, round(width * scale))
|
|
204
|
+
oh = max(1, round(height * scale))
|
|
205
|
+
elif max_width and width > max_width:
|
|
206
|
+
ow = max_width
|
|
207
|
+
oh = max(1, round(height * max_width / width))
|
|
208
|
+
else:
|
|
209
|
+
ow, oh = width, height
|
|
210
|
+
return ow, oh
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def downsample_nearest(img: Image, ow: int, oh: int) -> Image:
|
|
214
|
+
src = img.data
|
|
215
|
+
c = img.channels
|
|
216
|
+
w, h = img.width, img.height
|
|
217
|
+
out = bytearray(ow * oh * c)
|
|
218
|
+
oi = 0
|
|
219
|
+
for oy in range(oh):
|
|
220
|
+
rowbase = (oy * h // oh) * w * c
|
|
221
|
+
for ox in range(ow):
|
|
222
|
+
si = rowbase + (ox * w // ow) * c
|
|
223
|
+
out[oi:oi + c] = src[si:si + c]
|
|
224
|
+
oi += c
|
|
225
|
+
return Image(ow, oh, c, out)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def downsample_box(img: Image, ow: int, oh: int) -> Image:
|
|
229
|
+
"""Area-average downsample. Higher quality than nearest, but slower."""
|
|
230
|
+
src = img.data
|
|
231
|
+
c = img.channels
|
|
232
|
+
w, h = img.width, img.height
|
|
233
|
+
out = bytearray(ow * oh * c)
|
|
234
|
+
oi = 0
|
|
235
|
+
for oy in range(oh):
|
|
236
|
+
sy0 = oy * h // oh
|
|
237
|
+
sy1 = max(sy0 + 1, (oy + 1) * h // oh)
|
|
238
|
+
for ox in range(ow):
|
|
239
|
+
sx0 = ox * w // ow
|
|
240
|
+
sx1 = max(sx0 + 1, (ox + 1) * w // ow)
|
|
241
|
+
count = (sy1 - sy0) * (sx1 - sx0)
|
|
242
|
+
sums = [0] * c
|
|
243
|
+
for sy in range(sy0, sy1):
|
|
244
|
+
base = (sy * w + sx0) * c
|
|
245
|
+
for _ in range(sx0, sx1):
|
|
246
|
+
for ch in range(c):
|
|
247
|
+
sums[ch] += src[base + ch]
|
|
248
|
+
base += c
|
|
249
|
+
for ch in range(c):
|
|
250
|
+
out[oi + ch] = sums[ch] // count
|
|
251
|
+
oi += c
|
|
252
|
+
return Image(ow, oh, c, out)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def upscale_nearest_indices(indices: bytearray, w: int, h: int, ow: int, oh: int) -> bytearray:
|
|
256
|
+
"""Nearest-neighbour upscale of an index buffer (cheap; flat blocks)."""
|
|
257
|
+
out = bytearray(ow * oh)
|
|
258
|
+
oi = 0
|
|
259
|
+
for oy in range(oh):
|
|
260
|
+
rowbase = (oy * h // oh) * w
|
|
261
|
+
for ox in range(ow):
|
|
262
|
+
out[oi] = indices[rowbase + (ox * w // ow)]
|
|
263
|
+
oi += 1
|
|
264
|
+
return out
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
# Quantize (median cut)
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def _histogram(img: Image, bits: int = 8):
|
|
272
|
+
"""Return ``{color_key: count}`` over all pixels.
|
|
273
|
+
|
|
274
|
+
``bits`` < 8 reduces colour precision (top ``bits`` per channel) before
|
|
275
|
+
counting. That collapses the near-unique colours typical of AI gradient art
|
|
276
|
+
into far fewer buckets, which keeps median-cut fast with no visible loss.
|
|
277
|
+
Colour keys are the (reduced) ``bytes`` of each pixel's channels.
|
|
278
|
+
"""
|
|
279
|
+
data = img.data
|
|
280
|
+
c = img.channels
|
|
281
|
+
hist = {}
|
|
282
|
+
get = hist.get
|
|
283
|
+
if bits >= 8:
|
|
284
|
+
for i in range(0, len(data), c):
|
|
285
|
+
key = bytes(data[i:i + c])
|
|
286
|
+
hist[key] = get(key, 0) + 1
|
|
287
|
+
else:
|
|
288
|
+
mask = (0xFF << (8 - bits)) & 0xFF
|
|
289
|
+
table = bytes((v & mask) for v in range(256))
|
|
290
|
+
for i in range(0, len(data), c):
|
|
291
|
+
key = bytes(data[i:i + c]).translate(table)
|
|
292
|
+
hist[key] = get(key, 0) + 1
|
|
293
|
+
return hist
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class _Box:
|
|
297
|
+
"""A median-cut colour box; range metadata is cached and only recomputed
|
|
298
|
+
for the two children produced by a split (never for every box each pass)."""
|
|
299
|
+
|
|
300
|
+
__slots__ = ("entries", "score", "widest")
|
|
301
|
+
|
|
302
|
+
def __init__(self, entries, channels):
|
|
303
|
+
self.entries = entries
|
|
304
|
+
self._measure(channels)
|
|
305
|
+
|
|
306
|
+
def _measure(self, channels):
|
|
307
|
+
entries = self.entries
|
|
308
|
+
if len(entries) < 2:
|
|
309
|
+
self.score = 0
|
|
310
|
+
self.widest = 0
|
|
311
|
+
return
|
|
312
|
+
widest = 0
|
|
313
|
+
score = -1
|
|
314
|
+
for ch in range(channels):
|
|
315
|
+
lo = 255
|
|
316
|
+
hi = 0
|
|
317
|
+
for key, _ in entries:
|
|
318
|
+
v = key[ch]
|
|
319
|
+
if v < lo:
|
|
320
|
+
lo = v
|
|
321
|
+
if v > hi:
|
|
322
|
+
hi = v
|
|
323
|
+
rng = hi - lo
|
|
324
|
+
if rng > score:
|
|
325
|
+
score = rng
|
|
326
|
+
widest = ch
|
|
327
|
+
self.score = score
|
|
328
|
+
self.widest = widest
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def median_cut(hist: dict, max_colors: int, channels: int):
|
|
332
|
+
"""Median-cut quantization. Returns (palette, color_key->index map)."""
|
|
333
|
+
entries = list(hist.items())
|
|
334
|
+
if len(entries) <= max_colors:
|
|
335
|
+
palette = [tuple(key) for key, _ in entries]
|
|
336
|
+
cmap = {key: idx for idx, (key, _) in enumerate(entries)}
|
|
337
|
+
return palette, cmap
|
|
338
|
+
|
|
339
|
+
boxes = [_Box(entries, channels)]
|
|
340
|
+
while len(boxes) < max_colors:
|
|
341
|
+
# Pick the splittable box with the widest single-channel spread.
|
|
342
|
+
target = max(boxes, key=lambda b: b.score)
|
|
343
|
+
if target.score <= 0:
|
|
344
|
+
break
|
|
345
|
+
boxes.remove(target)
|
|
346
|
+
ch = target.widest
|
|
347
|
+
ent = target.entries
|
|
348
|
+
ent.sort(key=lambda kc: kc[0][ch])
|
|
349
|
+
total = sum(cnt for _, cnt in ent)
|
|
350
|
+
acc = 0
|
|
351
|
+
split = 1
|
|
352
|
+
half = total / 2
|
|
353
|
+
for i in range(len(ent) - 1):
|
|
354
|
+
acc += ent[i][1]
|
|
355
|
+
if acc >= half:
|
|
356
|
+
split = i + 1
|
|
357
|
+
break
|
|
358
|
+
boxes.append(_Box(ent[:split], channels))
|
|
359
|
+
boxes.append(_Box(ent[split:], channels))
|
|
360
|
+
|
|
361
|
+
palette = []
|
|
362
|
+
cmap = {}
|
|
363
|
+
for idx, box in enumerate(boxes):
|
|
364
|
+
sums = [0] * channels
|
|
365
|
+
total = 0
|
|
366
|
+
for key, cnt in box.entries:
|
|
367
|
+
for ch in range(channels):
|
|
368
|
+
sums[ch] += key[ch] * cnt
|
|
369
|
+
total += cnt
|
|
370
|
+
cmap[key] = idx
|
|
371
|
+
palette.append(tuple(sums[ch] // total for ch in range(channels)))
|
|
372
|
+
return palette, cmap
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ---------------------------------------------------------------------------
|
|
376
|
+
# Encoding (PNG-8 indexed)
|
|
377
|
+
# ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
def _chunk(ctype: bytes, data: bytes) -> bytes:
|
|
380
|
+
return (
|
|
381
|
+
struct.pack(">I", len(data))
|
|
382
|
+
+ ctype
|
|
383
|
+
+ data
|
|
384
|
+
+ struct.pack(">I", zlib.crc32(ctype + data) & 0xFFFFFFFF)
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def encode_png8(width, height, indices: bytearray, palette, alpha=None, level=9) -> bytes:
|
|
389
|
+
plte = bytearray()
|
|
390
|
+
for color in palette:
|
|
391
|
+
plte += bytes((color[0], color[1], color[2]))
|
|
392
|
+
# Pad palette to power-of-two entries is not required; PNG allows any count.
|
|
393
|
+
|
|
394
|
+
raw = bytearray()
|
|
395
|
+
for y in range(height):
|
|
396
|
+
raw.append(0) # filter type 0 (None) — best for indexed data
|
|
397
|
+
raw += indices[y * width:(y + 1) * width]
|
|
398
|
+
idat = zlib.compress(bytes(raw), level)
|
|
399
|
+
|
|
400
|
+
out = bytearray(PNG_SIGNATURE)
|
|
401
|
+
out += _chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 3, 0, 0, 0))
|
|
402
|
+
out += _chunk(b"PLTE", bytes(plte))
|
|
403
|
+
if alpha is not None:
|
|
404
|
+
# tRNS may be shorter than the palette; trailing 255s are implied opaque.
|
|
405
|
+
trns = bytes(alpha)
|
|
406
|
+
trimmed = trns.rstrip(b"\xff")
|
|
407
|
+
out += _chunk(b"tRNS", trimmed if trimmed else b"\x00")
|
|
408
|
+
out += _chunk(b"IDAT", idat)
|
|
409
|
+
out += _chunk(b"IEND", b"")
|
|
410
|
+
return bytes(out)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# ---------------------------------------------------------------------------
|
|
414
|
+
# High-level pixelate
|
|
415
|
+
# ---------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
def pixelate_png(
|
|
418
|
+
data: bytes,
|
|
419
|
+
*,
|
|
420
|
+
colors=256,
|
|
421
|
+
block=None,
|
|
422
|
+
scale=None,
|
|
423
|
+
max_width=1024,
|
|
424
|
+
box_filter=False,
|
|
425
|
+
upscale=False,
|
|
426
|
+
bits=6,
|
|
427
|
+
level=9,
|
|
428
|
+
):
|
|
429
|
+
"""Pixelate + quantize PNG ``data``. Returns the optimized PNG bytes."""
|
|
430
|
+
img = decode_png(data)
|
|
431
|
+
ow, oh = target_size(img.width, img.height, block=block, scale=scale, max_width=max_width)
|
|
432
|
+
|
|
433
|
+
if (ow, oh) != (img.width, img.height):
|
|
434
|
+
small = downsample_box(img, ow, oh) if box_filter else downsample_nearest(img, ow, oh)
|
|
435
|
+
else:
|
|
436
|
+
small = img
|
|
437
|
+
|
|
438
|
+
colors = max(2, min(256, colors))
|
|
439
|
+
bits = max(1, min(8, bits))
|
|
440
|
+
hist = _histogram(small, bits)
|
|
441
|
+
palette, cmap = median_cut(hist, colors, small.channels)
|
|
442
|
+
|
|
443
|
+
c = small.channels
|
|
444
|
+
indices = bytearray(ow * oh)
|
|
445
|
+
src = small.data
|
|
446
|
+
if bits >= 8:
|
|
447
|
+
for p, i in enumerate(range(0, len(src), c)):
|
|
448
|
+
indices[p] = cmap[bytes(src[i:i + c])]
|
|
449
|
+
else:
|
|
450
|
+
mask = (0xFF << (8 - bits)) & 0xFF
|
|
451
|
+
table = bytes((v & mask) for v in range(256))
|
|
452
|
+
for p, i in enumerate(range(0, len(src), c)):
|
|
453
|
+
indices[p] = cmap[bytes(src[i:i + c]).translate(table)]
|
|
454
|
+
|
|
455
|
+
out_w, out_h = ow, oh
|
|
456
|
+
if upscale and (img.width, img.height) != (ow, oh):
|
|
457
|
+
indices = upscale_nearest_indices(indices, ow, oh, img.width, img.height)
|
|
458
|
+
out_w, out_h = img.width, img.height
|
|
459
|
+
|
|
460
|
+
alpha = None
|
|
461
|
+
if c == 4:
|
|
462
|
+
alpha = [color[3] for color in palette]
|
|
463
|
+
palette = [color[:3] for color in palette]
|
|
464
|
+
|
|
465
|
+
return encode_png8(out_w, out_h, indices, palette, alpha=alpha, level=level)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
# ---------------------------------------------------------------------------
|
|
469
|
+
# CLI
|
|
470
|
+
# ---------------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
def _human(n: float) -> str:
|
|
473
|
+
size = float(n)
|
|
474
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
475
|
+
if size < 1024 or unit == "GB":
|
|
476
|
+
return f"{size:.0f}{unit}" if unit == "B" else f"{size:.1f}{unit}"
|
|
477
|
+
size /= 1024
|
|
478
|
+
return f"{size:.1f}GB"
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _gather_targets(paths):
|
|
482
|
+
files = []
|
|
483
|
+
for p in paths:
|
|
484
|
+
if os.path.isdir(p):
|
|
485
|
+
for root, _dirs, names in os.walk(p):
|
|
486
|
+
for name in names:
|
|
487
|
+
if name.lower().endswith(".png"):
|
|
488
|
+
files.append(os.path.join(root, name))
|
|
489
|
+
elif p.lower().endswith(".png"):
|
|
490
|
+
files.append(p)
|
|
491
|
+
return sorted(set(files))
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def process_file(path, args):
|
|
495
|
+
"""Process one file. Returns (status, original_size, new_size)."""
|
|
496
|
+
original = os.path.getsize(path)
|
|
497
|
+
with open(path, "rb") as fh:
|
|
498
|
+
data = fh.read()
|
|
499
|
+
try:
|
|
500
|
+
out = pixelate_png(
|
|
501
|
+
data,
|
|
502
|
+
colors=args.colors,
|
|
503
|
+
block=args.block,
|
|
504
|
+
scale=args.scale,
|
|
505
|
+
max_width=args.max_width,
|
|
506
|
+
box_filter=(args.filter == "box"),
|
|
507
|
+
upscale=args.upscale,
|
|
508
|
+
bits=args.bits,
|
|
509
|
+
level=args.level,
|
|
510
|
+
)
|
|
511
|
+
except UnsupportedPNG as exc:
|
|
512
|
+
return ("unsupported", original, original, str(exc))
|
|
513
|
+
|
|
514
|
+
new_size = len(out)
|
|
515
|
+
if new_size >= original and not args.force:
|
|
516
|
+
return ("nogain", original, new_size, "")
|
|
517
|
+
|
|
518
|
+
if args.dry_run:
|
|
519
|
+
return ("would", original, new_size, "")
|
|
520
|
+
|
|
521
|
+
dest = path
|
|
522
|
+
if args.output_dir:
|
|
523
|
+
os.makedirs(args.output_dir, exist_ok=True)
|
|
524
|
+
dest = os.path.join(args.output_dir, os.path.basename(path))
|
|
525
|
+
if args.backup and dest == path:
|
|
526
|
+
backup = path + ".orig"
|
|
527
|
+
if not os.path.exists(backup):
|
|
528
|
+
with open(backup, "wb") as bf:
|
|
529
|
+
bf.write(data)
|
|
530
|
+
with open(dest, "wb") as fh:
|
|
531
|
+
fh.write(out)
|
|
532
|
+
return ("done", original, new_size, "")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _process_star(packed):
|
|
536
|
+
"""Top-level wrapper so ``ProcessPoolExecutor`` can pickle the call."""
|
|
537
|
+
path, args = packed
|
|
538
|
+
try:
|
|
539
|
+
return (path,) + process_file(path, args)
|
|
540
|
+
except Exception as exc: # never let one bad file abort the batch
|
|
541
|
+
size = os.path.getsize(path) if os.path.exists(path) else 0
|
|
542
|
+
return (path, "error", size, size, str(exc))
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def build_parser():
|
|
546
|
+
p = argparse.ArgumentParser(
|
|
547
|
+
description="Pixelate + palette-quantize PNGs to shrink them while keeping quality.",
|
|
548
|
+
)
|
|
549
|
+
p.add_argument("paths", nargs="*", help="PNG files and/or directories to process")
|
|
550
|
+
p.add_argument("--colors", type=int, default=256, help="palette size 2-256 (default: 256)")
|
|
551
|
+
p.add_argument("--bits", type=int, default=6,
|
|
552
|
+
help="colour precision 1-8 used while quantizing (default: 6; "
|
|
553
|
+
"lower = faster + smaller, may band smooth gradients)")
|
|
554
|
+
size = p.add_mutually_exclusive_group()
|
|
555
|
+
size.add_argument("--max-width", type=int, default=1024,
|
|
556
|
+
help="downscale so width <= N, preserving aspect (default: 1024)")
|
|
557
|
+
size.add_argument("--scale", type=float, help="scale factor, e.g. 0.5")
|
|
558
|
+
size.add_argument("--block", type=int,
|
|
559
|
+
help="pixel block size: average NxN source pixels per output pixel")
|
|
560
|
+
p.add_argument("--filter", choices=("nearest", "box"), default="nearest",
|
|
561
|
+
help="downsample filter (default: nearest; box = higher quality, slower)")
|
|
562
|
+
p.add_argument("--upscale", action="store_true",
|
|
563
|
+
help="restore original WxH (chunky pixels) instead of storing reduced size")
|
|
564
|
+
p.add_argument("--level", type=int, default=9, help="zlib compression level 0-9 (default: 9)")
|
|
565
|
+
p.add_argument("--output-dir", help="write outputs here instead of in place")
|
|
566
|
+
p.add_argument("-j", "--jobs", type=int, default=1,
|
|
567
|
+
help="parallel worker processes (default: 1)")
|
|
568
|
+
p.add_argument("--backup", action="store_true", help="keep <file>.orig when writing in place")
|
|
569
|
+
p.add_argument("--force", action="store_true", help="write even if the result is not smaller")
|
|
570
|
+
p.add_argument("-n", "--dry-run", action="store_true", help="report savings without writing")
|
|
571
|
+
p.add_argument("-q", "--quiet", action="store_true", help="only print the summary line")
|
|
572
|
+
p.add_argument("--selftest", action="store_true", help="run an internal round-trip test and exit")
|
|
573
|
+
return p
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def selftest():
|
|
577
|
+
"""Encode a synthetic gradient PNG, pixelate it, and verify it decodes."""
|
|
578
|
+
w = h = 64
|
|
579
|
+
body = bytearray()
|
|
580
|
+
for y in range(h):
|
|
581
|
+
body.append(0)
|
|
582
|
+
for x in range(w):
|
|
583
|
+
body += bytes(((x * 4) % 256, (y * 4) % 256, ((x + y) * 2) % 256))
|
|
584
|
+
src = bytearray(PNG_SIGNATURE)
|
|
585
|
+
src += _chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
|
586
|
+
src += _chunk(b"IDAT", zlib.compress(bytes(body), 9))
|
|
587
|
+
src += _chunk(b"IEND", b"")
|
|
588
|
+
|
|
589
|
+
out = pixelate_png(bytes(src), colors=16, block=4, max_width=None)
|
|
590
|
+
decoded = decode_png(out)
|
|
591
|
+
assert decoded.width == 16 and decoded.height == 16, (decoded.width, decoded.height)
|
|
592
|
+
assert decoded.channels == 3
|
|
593
|
+
# Verify the encoder produced a valid indexed PNG.
|
|
594
|
+
assert out[:8] == PNG_SIGNATURE
|
|
595
|
+
print("selftest OK: 64x64 RGB -> %s pixelated PNG-8 (%s)" % (
|
|
596
|
+
f"{decoded.width}x{decoded.height}", _human(len(out))))
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def main(argv=None):
|
|
600
|
+
args = build_parser().parse_args(argv)
|
|
601
|
+
if args.selftest:
|
|
602
|
+
selftest()
|
|
603
|
+
return 0
|
|
604
|
+
if not args.paths:
|
|
605
|
+
print("error: no paths given (use --help)", file=sys.stderr)
|
|
606
|
+
return 2
|
|
607
|
+
|
|
608
|
+
targets = _gather_targets(args.paths)
|
|
609
|
+
if not targets:
|
|
610
|
+
print("No .png files found in the given paths.", file=sys.stderr)
|
|
611
|
+
return 1
|
|
612
|
+
|
|
613
|
+
work = [(t, args) for t in targets]
|
|
614
|
+
if args.jobs and args.jobs > 1 and len(targets) > 1:
|
|
615
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
616
|
+
with ProcessPoolExecutor(max_workers=args.jobs) as ex:
|
|
617
|
+
results = list(ex.map(_process_star, work)) # map preserves order
|
|
618
|
+
else:
|
|
619
|
+
results = [_process_star(w) for w in work]
|
|
620
|
+
|
|
621
|
+
total_before = total_after = 0
|
|
622
|
+
written = skipped = unsupported = errored = 0
|
|
623
|
+
for path, status, before, after, *rest in results:
|
|
624
|
+
note = rest[0] if rest else ""
|
|
625
|
+
total_before += before
|
|
626
|
+
if status in ("done", "would"):
|
|
627
|
+
total_after += after
|
|
628
|
+
written += 1
|
|
629
|
+
verb = "would write" if status == "would" else "wrote"
|
|
630
|
+
pct = (1 - after / before) * 100 if before else 0
|
|
631
|
+
if not args.quiet:
|
|
632
|
+
print(f" {verb}: {os.path.basename(path):50} "
|
|
633
|
+
f"{_human(before):>8} -> {_human(after):>8} (-{pct:.0f}%)")
|
|
634
|
+
elif status == "nogain":
|
|
635
|
+
total_after += before
|
|
636
|
+
skipped += 1
|
|
637
|
+
if not args.quiet:
|
|
638
|
+
print(f" skip (no gain): {os.path.basename(path):42} {_human(before):>8}")
|
|
639
|
+
elif status == "unsupported":
|
|
640
|
+
total_after += before
|
|
641
|
+
unsupported += 1
|
|
642
|
+
if not args.quiet:
|
|
643
|
+
print(f" skip (unsupported: {note}): {os.path.basename(path)}")
|
|
644
|
+
else: # error
|
|
645
|
+
total_after += before
|
|
646
|
+
errored += 1
|
|
647
|
+
print(f" ERROR: {os.path.basename(path)}: {note}", file=sys.stderr)
|
|
648
|
+
|
|
649
|
+
saved = total_before - total_after
|
|
650
|
+
pct = (saved / total_before * 100) if total_before else 0
|
|
651
|
+
print("")
|
|
652
|
+
print(f"Processed {len(targets)} file(s): "
|
|
653
|
+
f"{written} optimized, {skipped} no-gain, "
|
|
654
|
+
f"{unsupported} unsupported, {errored} errored")
|
|
655
|
+
print(f"Total: {_human(total_before)} -> {_human(total_after)} "
|
|
656
|
+
f"(saved {_human(saved)}, -{pct:.0f}%)"
|
|
657
|
+
+ (" [dry run]" if args.dry_run else ""))
|
|
658
|
+
return 1 if errored else 0
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
if __name__ == "__main__":
|
|
662
|
+
raise SystemExit(main())
|
data/scripts/github-setup.sh
CHANGED
|
File without changes
|