@kirigami/php-prepros 1.1.0 → 1.6.0

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.
@@ -1,9 +1,26 @@
1
1
  <?php
2
2
 
3
+ if (!defined('IMAGETYPE_AVIF')) define('IMAGETYPE_AVIF', 19);
3
4
 
4
5
  class IMG
5
6
  {
6
7
 
8
+ /**
9
+ * Default raster size (in pixels) used for the longest side when
10
+ * rasterizing vector formats (SVG, EPS, AI, PDF) via Imagick. Vector
11
+ * files are resolution-independent, and GD only ever deals in pixels,
12
+ * so a fallback size is needed for files that don't declare their own
13
+ * explicit pixel dimensions -- otherwise the underlying delegate
14
+ * library (librsvg, ghostscript...) may rasterize at a tiny default
15
+ * (often 100x100). The other side is scaled to preserve the aspect
16
+ * ratio reported by the file (explicit width/height, or a viewBox);
17
+ * a file that does declare explicit dimensions is honored as-is.
18
+ */
19
+ private const VECTOR_DEFAULT_SIZE = 2000;
20
+
21
+ /** File extensions treated as vector formats for VECTOR_DEFAULT_SIZE purposes. */
22
+ private const VECTOR_EXTENSIONS = ['svg', 'eps', 'ai', 'pdf'];
23
+
7
24
  private GdImage|null $im = null;
8
25
  private array|null $info = null;
9
26
 
@@ -13,18 +30,99 @@ class IMG
13
30
  if (!is_file($file) || !is_readable($file)) throw new Exception("Source file unreadable.");
14
31
 
15
32
  $this->info = @getimagesize($file);
16
- if (!$this->info) throw new Exception("Invalid image file.");
17
33
 
18
- [$srcW, $srcH, $type] = $this->info;
19
- if ($srcW <= 0 || $srcH <= 0) throw new Exception("Invalid image file.");
34
+ if ($this->info) {
35
+ [$srcW, $srcH, $type] = $this->info;
36
+ if ($srcW <= 0 || $srcH <= 0) throw new Exception("Invalid image file.");
20
37
 
21
- $this->im = match ($type) {
22
- IMAGETYPE_JPEG => @imagecreatefromjpeg($file),
23
- IMAGETYPE_PNG => @imagecreatefrompng($file),
24
- IMAGETYPE_GIF => @imagecreatefromgif($file),
25
- IMAGETYPE_WEBP => (function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($file) : null),
26
- default => throw new Exception("Image format not supported."),
27
- };
38
+ $this->im = match ($type) {
39
+ IMAGETYPE_JPEG => @imagecreatefromjpeg($file),
40
+ IMAGETYPE_PNG => @imagecreatefrompng($file),
41
+ IMAGETYPE_GIF => @imagecreatefromgif($file),
42
+ IMAGETYPE_WEBP => (function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($file) : null),
43
+ IMAGETYPE_AVIF => (function_exists('imagecreatefromavif') ? @imagecreatefromavif($file) : null),
44
+ default => null,
45
+ };
46
+ }
47
+
48
+ // Fall back to Imagick for anything GD can't handle: a type GD
49
+ // doesn't recognize at all, a format GD was compiled without
50
+ // support for, or a format getimagesize() itself can't parse
51
+ // (e.g. HEIC, TIFF, BMP).
52
+ if (!$this->im) {
53
+ $this->im = $this->loadFromImagick($file);
54
+ }
55
+
56
+ if (!$this->im) throw new Exception("Image format not supported or GD extension missing support for this format.");
57
+
58
+ // getimagesize() failed but Imagick managed to decode the file:
59
+ // rebuild a minimal $info from the resulting GD image so the rest
60
+ // of the class (which reads $this->info[2]) keeps working.
61
+ if (!$this->info) {
62
+ $this->info = [imagesx($this->im), imagesy($this->im), IMAGETYPE_PNG];
63
+ }
64
+ }
65
+
66
+
67
+ /**
68
+ * Loads an image via Imagick and bridges it back to a GD resource.
69
+ * Used as a fallback for formats GD itself cannot decode (HEIC, TIFF,
70
+ * BMP, or any format missing from the compiled GD build). Imagick
71
+ * converts the image to a PNG blob in memory (preserving alpha), and
72
+ * GD then decodes that blob normally; nothing is written to disk.
73
+ */
74
+ private function loadFromImagick(string $file): ?GdImage
75
+ {
76
+ if (!class_exists('Imagick')) return null;
77
+
78
+ try {
79
+ $imagick = new Imagick();
80
+
81
+ $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
82
+ if (in_array($ext, self::VECTOR_EXTENSIONS, true)) {
83
+ // Vector formats have no intrinsic pixel size. Ping first
84
+ // to read whatever aspect ratio the file/delegate reports
85
+ // (an explicit width/height, or an SVG viewBox), then
86
+ // rasterize at VECTOR_DEFAULT_SIZE on the longest side
87
+ // while keeping that ratio, instead of forcing a square
88
+ // that could distort the image.
89
+ $probe = new Imagick();
90
+ $probe->pingImage($file);
91
+ $pw = $probe->getImageWidth();
92
+ $ph = $probe->getImageHeight();
93
+ $probe->clear();
94
+
95
+ if ($pw > 0 && $ph > 0) {
96
+ $scale = self::VECTOR_DEFAULT_SIZE / max($pw, $ph);
97
+ $targetW = max(1, (int) round($pw * $scale));
98
+ $targetH = max(1, (int) round($ph * $scale));
99
+ } else {
100
+ // Nothing usable to infer a ratio from: fall back to
101
+ // a square canvas.
102
+ $targetW = $targetH = self::VECTOR_DEFAULT_SIZE;
103
+ }
104
+
105
+ $imagick->setBackgroundColor(new ImagickPixel('transparent'));
106
+ $imagick->setSize($targetW, $targetH);
107
+ }
108
+
109
+ $imagick->readImage($file);
110
+
111
+ // If the source has multiple frames/pages (e.g. an animated
112
+ // HEIC sequence, or a multi-page PDF), keep only the first
113
+ // one, matching how GD would have loaded a static image.
114
+ if ($imagick->getNumberImages() > 1) {
115
+ $imagick->setIteratorIndex(0);
116
+ }
117
+ $imagick->setImageFormat('png32'); // 32-bit PNG, keeps alpha channel
118
+ $blob = $imagick->getImageBlob();
119
+ $imagick->clear();
120
+ } catch (\Throwable $e) {
121
+ return null;
122
+ }
123
+
124
+ $im = @imagecreatefromstring($blob);
125
+ return $im ?: null;
28
126
  }
29
127
 
30
128
 
@@ -49,6 +147,13 @@ class IMG
49
147
 
50
148
  public function resize(int $width, int $height = 0, bool $cover = false)
51
149
  {
150
+ // Never upscale. The AVIF encoder (and, less visibly, the others) can
151
+ // choke on an enlarged raster, and blowing pixels up gains nothing —
152
+ // so a target larger than the source is clamped down to it rather than
153
+ // throwing an opaque encode error. Ask for sizes <= the source.
154
+ if ($width > $this->width) $width = $this->width;
155
+ if ($height > $this->height) $height = $this->height;
156
+
52
157
  $srcRatio = $this->width / $this->height;
53
158
 
54
159
  if (!$height) {
@@ -90,7 +195,7 @@ class IMG
90
195
  private function prepare(int $x, int $y): GdImage
91
196
  {
92
197
  $img = imagecreatetruecolor($x, $y);
93
- $hasAlpha = in_array($this->info[2], [IMAGETYPE_PNG, IMAGETYPE_WEBP, IMAGETYPE_GIF], true);
198
+ $hasAlpha = in_array($this->info[2], [IMAGETYPE_PNG, IMAGETYPE_WEBP, IMAGETYPE_GIF, IMAGETYPE_AVIF], true);
94
199
  if ($hasAlpha) {
95
200
  imagealphablending($img, false);
96
201
  imagesavealpha($img, true);
@@ -104,19 +209,269 @@ class IMG
104
209
  }
105
210
 
106
211
 
107
- public function save(string $dest): self
212
+ /**
213
+ * Converts an sRGB color (0-255) to Lab (D65), a perceptually uniform
214
+ * color space: a Euclidean distance in Lab roughly matches a color
215
+ * difference as perceived by the eye, unlike RGB where two colors at
216
+ * the same numeric "distance" can look very different or identical
217
+ * depending on the hue.
218
+ */
219
+ private static function rgbToLab(int $r, int $g, int $b): array
220
+ {
221
+ $toLinear = fn(float $c) => ($c /= 255) <= 0.04045 ? $c / 12.92 : (($c + 0.055) / 1.055) ** 2.4;
222
+ [$rl, $gl, $bl] = [$toLinear($r), $toLinear($g), $toLinear($b)];
223
+
224
+ // Linear RGB -> XYZ (D65), then normalization against the white point.
225
+ $x = ($rl * 0.4124564 + $gl * 0.3575761 + $bl * 0.1804375) / 0.95047;
226
+ $y = $rl * 0.2126729 + $gl * 0.7151522 + $bl * 0.0721750;
227
+ $z = ($rl * 0.0193339 + $gl * 0.1191920 + $bl * 0.9503041) / 1.08883;
228
+
229
+ $f = fn(float $t) => $t > 0.008856 ? $t ** (1 / 3) : (7.787 * $t) + (16 / 116);
230
+ [$fx, $fy, $fz] = [$f($x), $f($y), $f($z)];
231
+
232
+ return [(116 * $fy) - 16, 500 * ($fx - $fy), 200 * ($fy - $fz)]; // [L, a, b]
233
+ }
234
+
235
+
236
+ /**
237
+ * Extracts the most representative colors from the image.
238
+ *
239
+ * Algorithm: median-cut quantization (imagetruecolortopalette, the
240
+ * same family of technique as Imagick::quantizeImage), heavily
241
+ * over-sampling the number of requested colors, followed by
242
+ * perceptual post-processing in Lab space:
243
+ * - merging colors that are too close (CIE76 distance in Lab): the
244
+ * quantization often produces several hues that look virtually
245
+ * identical to the eye, which we don't want as separate entries;
246
+ * - excluding near-pure white/black via the L lightness component
247
+ * (reliable regardless of hue, unlike a threshold on raw r/g/b).
248
+ *
249
+ * @param int $numColors Number of colors to return.
250
+ * @param float $mergeTolerance Lab distance below which two colors are merged (0-100, ~6-10 = "near identical").
251
+ * @param bool $excludeNearWhiteAndBlack Excludes near-white/near-black colors.
252
+ * @param float $lightnessThreshold Lab lightness threshold (0-100) above/below which a color is considered near-white/near-black.
253
+ * @return string[] Colors as "#rrggbb", sorted by descending frequency.
254
+ */
255
+ public function getRepresentativeColors(
256
+ int $numColors = 5,
257
+ float $mergeTolerance = 8.0,
258
+ bool $excludeNearWhiteAndBlack = true,
259
+ float $lightnessThreshold = 8.0
260
+ ): array {
261
+ $srcW = $this->width;
262
+ $srcH = $this->height;
263
+
264
+ // Analyzing at full resolution brings nothing for this kind of
265
+ // extraction and is expensive in compute time.
266
+ $maxDim = 150;
267
+ $scale = min(1, $maxDim / max($srcW, $srcH));
268
+ $w = max(1, (int) round($srcW * $scale));
269
+ $h = max(1, (int) round($srcH * $scale));
270
+
271
+ // Flatten onto a white background (as for JPEG export): otherwise
272
+ // the transparent areas of PNG/WEBP images would default to being
273
+ // counted as black on a truecolor canvas.
274
+ $sample = imagecreatetruecolor($w, $h);
275
+ imagealphablending($sample, true);
276
+ $white = imagecolorallocate($sample, 255, 255, 255);
277
+ imagefilledrectangle($sample, 0, 0, $w, $h, $white);
278
+ imagecopyresampled($sample, $this->im, 0, 0, 0, 0, $w, $h, $srcW, $srcH);
279
+
280
+ // We heavily over-sample the number of requested colors: this
281
+ // leaves enough room for the perceptual merge and the white/black
282
+ // exclusion below without running short of colors.
283
+ $buckets = max($numColors * 6, 24);
284
+ imagetruecolortopalette($sample, false, $buckets);
285
+
286
+ // Count occurrences of each color in the generated palette.
287
+ $counts = array_fill(0, imagecolorstotal($sample), 0);
288
+ for ($y = 0; $y < $h; $y++) {
289
+ for ($x = 0; $x < $w; $x++) {
290
+ $counts[imagecolorat($sample, $x, $y)]++;
291
+ }
292
+ }
293
+
294
+ $entries = [];
295
+ foreach ($counts as $index => $count) {
296
+ if ($count === 0) continue;
297
+ $rgb = imagecolorsforindex($sample, $index);
298
+ $lab = self::rgbToLab($rgb['red'], $rgb['green'], $rgb['blue']);
299
+
300
+ if ($excludeNearWhiteAndBlack && ($lab[0] > 100 - $lightnessThreshold || $lab[0] < $lightnessThreshold)) {
301
+ continue;
302
+ }
303
+
304
+ $entries[] = ['r' => $rgb['red'], 'g' => $rgb['green'], 'b' => $rgb['blue'], 'lab' => $lab, 'count' => $count];
305
+ }
306
+
307
+ usort($entries, fn($a, $b) => $b['count'] - $a['count']);
308
+
309
+ // Merge perceptually close colors by grouping their occurrences,
310
+ // always starting from the most frequent one.
311
+ $merged = [];
312
+ foreach ($entries as $entry) {
313
+ foreach ($merged as &$cluster) {
314
+ $dl = $cluster['lab'][0] - $entry['lab'][0];
315
+ $da = $cluster['lab'][1] - $entry['lab'][1];
316
+ $db = $cluster['lab'][2] - $entry['lab'][2];
317
+ if (sqrt($dl * $dl + $da * $da + $db * $db) <= $mergeTolerance) {
318
+ $cluster['count'] += $entry['count'];
319
+ continue 2;
320
+ }
321
+ }
322
+ unset($cluster);
323
+ $merged[] = $entry;
324
+ }
325
+
326
+ usort($merged, fn($a, $b) => $b['count'] - $a['count']);
327
+
328
+ return array_map(
329
+ fn($c) => sprintf('#%02x%02x%02x', $c['r'], $c['g'], $c['b']),
330
+ array_slice($merged, 0, $numColors)
331
+ );
332
+ }
333
+
334
+
335
+ /**
336
+ * The AVIF encoder (libavif/aom) used by imageavif() can fail with
337
+ * "Encoding of color planes failed" in two common cases:
338
+ * - odd width/height (4:2:0 chroma subsampling constraint)
339
+ * - image too large, causing the encoder to run out of memory for
340
+ * its internal buffers ("aom_codec_encode: Failed to allocate lag buffers")
341
+ * We fix both before encoding, on a temporary copy, without modifying
342
+ * $this->im.
343
+ */
344
+ private function prepareForAvif(GdImage $im, int $maxDimension = 4000): GdImage
345
+ {
346
+ $w = imagesx($im);
347
+ $h = imagesy($im);
348
+
349
+ // Cap the resolution if needed (encoder memory protection).
350
+ if ($w > $maxDimension || $h > $maxDimension) {
351
+ $scale = min($maxDimension / $w, $maxDimension / $h);
352
+ $newW = max(1, (int) round($w * $scale));
353
+ $newH = max(1, (int) round($h * $scale));
354
+ $resized = imagecreatetruecolor($newW, $newH);
355
+ imagealphablending($resized, false);
356
+ imagesavealpha($resized, true);
357
+ $transparent = imagecolorallocatealpha($resized, 0, 0, 0, 127);
358
+ imagefilledrectangle($resized, 0, 0, $newW, $newH, $transparent);
359
+ imagecopyresampled($resized, $im, 0, 0, 0, 0, $newW, $newH, $w, $h);
360
+ $im = $resized;
361
+ $w = $newW;
362
+ $h = $newH;
363
+ }
364
+
365
+ // Force even dimensions.
366
+ $w2 = $w % 2 ? $w + 1 : $w;
367
+ $h2 = $h % 2 ? $h + 1 : $h;
368
+ if ($w2 !== $w || $h2 !== $h) {
369
+ $padded = imagecreatetruecolor($w2, $h2);
370
+ imagealphablending($padded, false);
371
+ imagesavealpha($padded, true);
372
+ $transparent = imagecolorallocatealpha($padded, 0, 0, 0, 127);
373
+ imagefilledrectangle($padded, 0, 0, $w2, $h2, $transparent);
374
+ imagecopy($padded, $im, 0, 0, 0, 0, $w, $h);
375
+ $im = $padded;
376
+ }
377
+
378
+ return $im;
379
+ }
380
+
381
+
382
+ /**
383
+ * Encodes to AVIF with automatic fallback: if encoding fails (memory
384
+ * error or another internal aom issue), retry with a higher speed
385
+ * (less memory-hungry), then with a reduced resolution. Throws an
386
+ * explicit exception if everything fails, instead of letting a silent
387
+ * PHP warning through and leaving a corrupted/missing file behind.
388
+ */
389
+ private function encodeAvif(GdImage $im, string $dest, int $quality = 82): bool
390
+ {
391
+ $attempts = [
392
+ ['maxDimension' => 4000, 'speed' => 4],
393
+ ['maxDimension' => 4000, 'speed' => 8],
394
+ ['maxDimension' => 2000, 'speed' => 8],
395
+ ];
396
+
397
+ foreach ($attempts as $attempt) {
398
+ $prepared = $this->prepareForAvif($im, $attempt['maxDimension']);
399
+ $ok = @imageavif($prepared, $dest, $quality, $attempt['speed']);
400
+ if ($ok && is_file($dest) && filesize($dest) > 0) return true;
401
+ }
402
+
403
+ return false;
404
+ }
405
+
406
+
407
+ /**
408
+ * Encodes the current image to $dest, the format taken from the file
409
+ * extension (jpg/jpeg, png, gif, webp, avif).
410
+ *
411
+ * $quality (0-100) applies to the lossy formats (jpg, webp, avif); it is
412
+ * ignored for png (whose second arg is a 0-9 compression level) and gif.
413
+ * When null, each format keeps its own default (82 for jpg/webp/avif).
414
+ */
415
+ public function save(string $dest, ?int $quality = null): self
108
416
  {
109
417
  $ext = strtolower(pathinfo($dest, PATHINFO_EXTENSION));
110
418
  $dir = pathinfo($dest, PATHINFO_DIRNAME);
111
419
  if (!is_dir($dir) && !@mkdir($dir, 0777, true)) throw new Exception("Invalid destination.");
112
- match ($ext) {
113
- 'jpg', 'jpeg' => imagejpeg($this->im, $dest, 85),
420
+ $ok = match ($ext) {
421
+ 'jpg', 'jpeg' => imagejpeg($this->im, $dest, $quality ?? 82),
114
422
  'png' => imagepng($this->im, $dest, 6),
115
423
  'gif' => imagegif($this->im, $dest),
116
- 'webp' => (function_exists('imagewebp') ? imagewebp($this->im, $dest, 85) : false),
424
+ 'webp' => (function_exists('imagewebp') ? imagewebp($this->im, $dest, $quality ?? 82) : false),
425
+ 'avif' => (function_exists('imageavif') ? $this->encodeAvif($this->im, $dest, $quality ?? 82) : false),
117
426
  default => throw new Exception("Invalid output file type.")
118
427
  };
119
- // PREPROS::exportFile(realpath($dest));
428
+ if (!$ok) throw new Exception("Failed to encode image as '{$ext}'.");
429
+ PREPROS::exportFile(realpath($dest));
120
430
  return $this;
121
431
  }
122
- }
432
+
433
+
434
+ public static function asset(string $path, $width = 0, $height = 0, $cover = false, $backtrace = '')
435
+ {
436
+ $srcfile = FS::pathJoin(PREPROS::$config->image->source, $path);
437
+ if(!$srcinfo = PREPROS::fstat($srcfile)) throw new Exception("Invalid image file.");
438
+
439
+ $suffix = '';
440
+ if ($width && $height) $suffix = $cover ? '-' . $width . 'x' . $height . '-cover' : '-' . $width . 'x' . $height;
441
+ elseif ($width) $suffix = '-' . $width . 'w';
442
+ elseif ($height) $suffix = '-' . $height . 'h';
443
+ $destname = preg_replace('/\.[a-z]{2,4}+$/i', '', $path) . $suffix . '.' . PREPROS::$config->image->format;
444
+ $destfile = FS::pathJoin(PREPROS::$config->data->root, PREPROS::$config->image->dest, $destname);
445
+ $destinfo = PREPROS::fstat($destfile);
446
+
447
+ if(!$destinfo || ((strtotime($srcinfo->modifiedAt) - strtotime($destinfo->modifiedAt)) > 10)) {
448
+ if(!$localfile = current(PREPROS::mount($srcfile))) throw new Exception("Can't mount image.");
449
+ if(!is_file($localfile)) throw new Exception("Can't mount image.");
450
+ $img = new self($localfile);
451
+ if ($width && $height) $img->resize($width, $height, $cover);
452
+ elseif ($width) $img->resize($width);
453
+ elseif ($height) $img->resize((int) round($img->width * $height / $img->height), $height);
454
+ $img->save(FS::pathJoin('/project', $destfile));
455
+ }
456
+
457
+ if(!$backtrace) $backtrace = PREPROS::backtraceFile();
458
+ return FS::getRelativePath($backtrace, FS::pathJoin('/project', $destfile));
459
+ }
460
+
461
+
462
+ public static function palette(string $path, $colors = 5)
463
+ {
464
+ $srcfile = FS::pathJoin(PREPROS::$config->image->source, $path);
465
+ if(!$srcinfo = PREPROS::fstat($srcfile)) throw new Exception("Invalid image file.");
466
+ $key = 'palette_' . STR::shorthash("{$srcfile}:{$srcinfo->modifiedAt}:{$colors}");
467
+ if($palette = CACHE::get($key)) return $palette;
468
+ if(!$localfile = current(PREPROS::mount($srcfile))) throw new Exception("Can't mount image.");
469
+ if(!$palette = (new self($localfile))->getRepresentativeColors($colors)) throw new Exception("Can't extract palette from image \"{$path}\".");
470
+ CACHE::set($key, $palette);
471
+ return $palette;
472
+ }
473
+
474
+
475
+
476
+
477
+ }