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.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +395 -0
  3. data/README.md +27 -19
  4. data/_data/authors.yml +154 -5
  5. data/_data/backlog.yml +5 -5
  6. data/_data/content_statistics.yml +273 -297
  7. data/_data/features.yml +4 -25
  8. data/_data/navigation/README.md +24 -0
  9. data/_data/navigation/about.yml +2 -0
  10. data/_data/navigation/main.yml +2 -7
  11. data/_data/roadmap.yml +86 -12
  12. data/_includes/components/author-avatar-url.html +28 -0
  13. data/_includes/components/author-bio.html +86 -0
  14. data/_includes/components/author-card.html +184 -121
  15. data/_includes/components/author-eeat.html +10 -4
  16. data/_includes/components/info-section.html +1 -1
  17. data/_includes/components/mermaid.html +0 -3
  18. data/_includes/components/post-card.html +19 -9
  19. data/_includes/content/giscus.html +3 -2
  20. data/_includes/core/footer-fabs.html +28 -0
  21. data/_includes/core/footer.html +7 -17
  22. data/_includes/core/head.html +2 -2
  23. data/_includes/navigation/breadcrumbs.html +20 -2
  24. data/_includes/navigation/local-graph.html +18 -2
  25. data/_includes/obsidian/full-graph.html +4 -6
  26. data/_layouts/article.html +44 -74
  27. data/_layouts/author.html +274 -0
  28. data/_layouts/authors.html +55 -0
  29. data/_layouts/news.html +3 -3
  30. data/_layouts/note.html +21 -6
  31. data/_layouts/notebook.html +21 -6
  32. data/_layouts/root.html +31 -17
  33. data/_layouts/section.html +3 -3
  34. data/_plugins/author_pages_generator.rb +121 -0
  35. data/_sass/components/_author.scss +219 -0
  36. data/_sass/components/_content-tables.scss +16 -1
  37. data/_sass/components/_notes-index.scss +102 -0
  38. data/_sass/components/_search-modal.scss +40 -0
  39. data/_sass/components/_ui-enhancements.scss +570 -0
  40. data/_sass/core/_docs-code-examples.scss +463 -0
  41. data/_sass/core/_docs-layout.scss +0 -453
  42. data/_sass/core/_navbar.scss +253 -0
  43. data/_sass/core/_sidebar-extras.scss +79 -0
  44. data/_sass/core/_toc.scss +87 -0
  45. data/_sass/core/_variables.scss +7 -142
  46. data/_sass/custom.scss +24 -1122
  47. data/_sass/layouts/_global-chrome.scss +59 -0
  48. data/assets/css/main.scss +19 -2
  49. data/assets/js/author-profile.js +190 -0
  50. data/assets/js/modules/navigation/navbar.js +104 -0
  51. data/assets/js/obsidian-graph.js +2 -2
  52. data/assets/js/obsidian-local-graph.js +11 -5
  53. data/assets/vendor/cytoscape/cytoscape.min.js +32 -0
  54. data/scripts/README.md +39 -0
  55. data/scripts/bin/validate +11 -1
  56. data/scripts/dev/css-diff.sh +49 -0
  57. data/scripts/dev/shot.js +37 -0
  58. data/scripts/features/generate-preview-images +110 -6
  59. data/scripts/features/pixelate-preview-images +126 -0
  60. data/scripts/features/pixelate_images.py +662 -0
  61. data/scripts/github-setup.sh +0 -0
  62. data/scripts/lib/preview_generator.py +47 -3
  63. data/scripts/pixelate-preview-images.sh +12 -0
  64. data/scripts/test/integration/auto-version +10 -8
  65. data/scripts/test/lib/run_tests.sh +2 -0
  66. data/scripts/test/lib/test_content_review.sh +205 -0
  67. data/scripts/test/lib/test_pixelate_images.sh +108 -0
  68. metadata +25 -20
  69. data/_data/hub.yml +0 -68
  70. data/_data/hub_index.yml +0 -203
  71. data/_data/navigation/hub.yml +0 -110
  72. data/assets/vendor/font-awesome/css/all.min.css +0 -9
  73. data/assets/vendor/font-awesome/webfonts/fa-brands-400.ttf +0 -0
  74. data/assets/vendor/font-awesome/webfonts/fa-brands-400.woff2 +0 -0
  75. data/assets/vendor/font-awesome/webfonts/fa-regular-400.ttf +0 -0
  76. data/assets/vendor/font-awesome/webfonts/fa-regular-400.woff2 +0 -0
  77. data/assets/vendor/font-awesome/webfonts/fa-solid-900.ttf +0 -0
  78. data/assets/vendor/font-awesome/webfonts/fa-solid-900.woff2 +0 -0
  79. data/assets/vendor/font-awesome/webfonts/fa-v4compatibility.ttf +0 -0
  80. data/assets/vendor/font-awesome/webfonts/fa-v4compatibility.woff2 +0 -0
  81. data/assets/vendor/jquery/jquery-3.7.1.min.js +0 -2
  82. data/scripts/lib/hub.rb +0 -208
  83. data/scripts/provision-org-sites.rb +0 -252
  84. data/scripts/provision-org-sites.sh +0 -23
  85. data/scripts/sync-hub-metadata.rb +0 -184
  86. 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())
File without changes