@kirigami/php-prepros 1.1.0 → 1.2.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.
package/index.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * @example
11
11
  * ```js
12
- * import { render, sitemap } from '@kirigami/php-prepros';
12
+ * import { render, sitemap, runenv, mountPath } from '@kirigami/php-prepros';
13
13
  *
14
14
  * // Compile a single page
15
15
  * const result = await render('src/index.php');
@@ -19,6 +19,12 @@
19
19
  *
20
20
  * // Generate sitemap.xml
21
21
  * const sitemap = await sitemap();
22
+ *
23
+ * // Run an arbitrary PHP script in the same sandboxed environment
24
+ * const result = await runenv('scripts/purge-cache.php');
25
+ *
26
+ * // Mount a local file/directory into the sandbox before rendering
27
+ * await mountPath('assets/data/team.yaml');
22
28
  * ```
23
29
  */
24
30
 
@@ -106,4 +112,71 @@ export function render(file?: string): Promise<PreprosResult>;
106
112
  * @returns A {@link PreprosResult} with `files` containing the path to the
107
113
  * generated `sitemap.xml`.
108
114
  */
109
- export function sitemap(dir?: string): Promise<PreprosResult>;
115
+ export function sitemap(dir?: string): Promise<PreprosResult>;
116
+
117
+
118
+ /**
119
+ * Run an arbitrary PHP script — not a page template — inside the same
120
+ * sandboxed WASM environment used by {@link render}, with the full
121
+ * `php-prepros` class library autoloaded and `kirigami.yaml`'s `kirigami`
122
+ * block available as `PREPROS::$config->data`.
123
+ *
124
+ * Useful for one-off maintenance scripts, data migrations, or CLI-style
125
+ * tooling that needs `CACHE`, `SCRAPER`, `IMG`, etc. without going through
126
+ * the page-rendering pipeline.
127
+ *
128
+ * ```js
129
+ * // Run a standalone PHP script
130
+ * const result = await runenv('scripts/purge-cache.php');
131
+ *
132
+ * // Also mount extra local paths/files into the sandbox before running
133
+ * const result = await runenv('scripts/build-og-images.php', ['assets/photos']);
134
+ *
135
+ * // Extra arguments are appended and available as $argv[2], $argv[3], … in the script
136
+ * const result = await runenv('scripts/import.php', [], '--force');
137
+ * ```
138
+ *
139
+ * @param script Path to a PHP file inside the project, executed with
140
+ * `require_once`.
141
+ * @param paths Extra local paths (files or directories) to mount into the
142
+ * sandbox before the script runs.
143
+ * @param args Extra string arguments appended to the script's `$argv`.
144
+ *
145
+ * @returns A {@link PreprosResult} describing what was written. Call
146
+ * `PREPROS::exportFile()` inside the script for any file you want
147
+ * listed in `result.files`.
148
+ *
149
+ * @throws When no `script` path is given.
150
+ * @throws When `script` resolves outside the project root, or doesn't exist.
151
+ */
152
+ export function runenv(script: string, paths?: string[], ...args: string[]): Promise<PreprosResult>;
153
+
154
+
155
+ /**
156
+ * The JavaScript-side counterpart to `PREPROS::mount()`. Mounts a local
157
+ * file or directory — recursively, preserving structure — into the WASM
158
+ * sandbox's virtual filesystem, ahead of (or between) calls to
159
+ * {@link render}, {@link sitemap}, or {@link runenv}.
160
+ *
161
+ * Mounting a directory only copies files whose extension is one of the
162
+ * defaults (`.php`, `.json`, `.yaml`, `.yml`, `.md`, `.db`, `.txt`) or
163
+ * listed in `prepros.mountext`, same as automatic root mounting. Mounting a
164
+ * single file directly copies it regardless of extension.
165
+ *
166
+ * ```js
167
+ * // Mount a single file at its natural virtual path (/project/<relative path>)
168
+ * await mountPath('assets/data/team.yaml');
169
+ *
170
+ * // Mount a whole directory, at a custom virtual path
171
+ * await mountPath('vendor/fonts', '/project/fonts');
172
+ * ```
173
+ *
174
+ * @param localPath Path to a local file or directory. Relative paths are
175
+ * resolved against the project root.
176
+ * @param virtualDir Destination path inside the WASM filesystem. Defaults
177
+ * to `/project/<localPath relative to the project root>`
178
+ * when omitted.
179
+ * @param php WASM PHP instance to mount into. Defaults to the shared
180
+ * singleton instance (creating it if needed).
181
+ */
182
+ export function mountPath(localPath: string, virtualDir?: string, php?: unknown): Promise<void>;
package/index.js CHANGED
@@ -1 +1 @@
1
- export { render, sitemap, runenv } from "./src/prepros.js";
1
+ export { render, sitemap, runenv, mountPath } from "./src/prepros.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kirigami/php-prepros",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "PHP preprocessor for the Kirigami static site generator. Compile PHP page templates to clean, deployable HTML — with zero server dependency.",
5
5
  "keywords": [
6
6
  "kirigami",
@@ -28,7 +28,7 @@
28
28
  "README.md"
29
29
  ],
30
30
  "engines": {
31
- "node": ">=20.10.0",
31
+ "node": ">=24.0.0",
32
32
  "npm": ">=10.2.3"
33
33
  },
34
34
  "exports": {
@@ -44,8 +44,8 @@
44
44
  "test": "echo \"Error: no test specified\" && exit 1"
45
45
  },
46
46
  "dependencies": {
47
- "@kirigami/php-wasm": "8.5.10-3",
48
- "@kirigami/struct-walker": "1.0.3",
47
+ "@kirigami/php-wasm": "8.5.10-5",
48
+ "@kirigami/struct-walker": "1.0.4",
49
49
  "picomatch": "^4.0.7"
50
50
  },
51
51
  "repository": {
@@ -1,5 +1,10 @@
1
1
  <?php
2
2
 
3
+
4
+
5
+ if(!boolval(PREPROS::$config->network ?? false)) STD::error("CURL Error: Network option is not activated.");
6
+
7
+
3
8
  class CURL {
4
9
 
5
10
  const HEADERS = [
@@ -1,16 +1,16 @@
1
1
  <?php
2
2
 
3
3
  /**
4
- * HtmlFormatter — formateur HTML pour Kirigami
5
- * Utilise Dom\HTMLDocument (PHP 8.4, moteur Lexbor) pour parser,
6
- * puis re-sérialise avec indentation.
7
- * Les blocs <script> et <style> sont indentés au bon niveau mais non reformatés.
4
+ * HtmlFormatter — HTML formatter for Kirigami
5
+ * Uses Dom\HTMLDocument (PHP 8.4, Lexbor engine) to parse, then re-serializes
6
+ * with indentation.
7
+ * <script> and <style> blocks are indented at the right level but not reformatted.
8
8
  */
9
9
  class HTML
10
10
  {
11
11
  private const INDENT = 4;
12
12
 
13
- // Attributs booléens HTML5 — écrits sans valeur
13
+ // HTML5 boolean attributes — written without a value
14
14
  private const BOOLEAN_ATTRS = [
15
15
  'allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked',
16
16
  'controls', 'default', 'defer', 'disabled', 'formnovalidate',
@@ -19,7 +19,7 @@ class HTML
19
19
  'readonly', 'required', 'reversed', 'selected', 'webkit-playsinline',
20
20
  ];
21
21
 
22
- // Éléments inline — leur présence dans un parent n'empêche pas le rendu inline
22
+ // Inline elements — their presence in a parent doesn't prevent inline rendering
23
23
  private const INLINE = [
24
24
  'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'button', 'cite',
25
25
  'code', 'dfn', 'em', 'i', 'img', 'input', 'kbd', 'label', 'map',
@@ -78,7 +78,7 @@ class HTML
78
78
  $childPad = str_repeat(' ', ($depth + 1) * self::INDENT);
79
79
  $lines = explode("\n", $inner);
80
80
 
81
- // Calcule le niveau d'indentation minimal existant (ignore les lignes vides)
81
+ // Compute the existing minimum indentation level (ignoring blank lines)
82
82
  $minIndent = PHP_INT_MAX;
83
83
  foreach ($lines as $line) {
84
84
  if (trim($line) === '') continue;
@@ -86,7 +86,7 @@ class HTML
86
86
  }
87
87
  $minIndent = $minIndent === PHP_INT_MAX ? 0 : $minIndent;
88
88
 
89
- // Dédente puis ré-indente au bon niveau
89
+ // Dedent then re-indent at the right level
90
90
  $indented = implode("\n", array_map(
91
91
  static fn(string $line) => trim($line) !== ''
92
92
  ? $childPad . substr($line, $minIndent)
@@ -102,11 +102,11 @@ class HTML
102
102
  return "{$pad}<{$tag}{$attrs}></{$tag}>\n";
103
103
  }
104
104
 
105
- // Si tous les enfants sont inline ET que ça ressemble à un flux de texte
106
- // (du vrai texte, ou un seul élément enfant), on re-sérialise en une seule ligne.
107
- // Sans looksLikeTextFlow, un <section> qui contient plusieurs gros <a> côte à côte
108
- // (ex: une grille de logos) se retrouverait aussi collé sur une seule ligne,
109
- // puisque <a> est dans INLINE — ce n'est pas ce qu'on veut.
105
+ // If all children are inline AND it looks like a text flow (real text,
106
+ // or a single child element), re-serialize on a single line.
107
+ // Without looksLikeTextFlow, a <section> holding several large <a> side
108
+ // by side (e.g. a logo grid) would also end up crammed onto one line,
109
+ // since <a> is in INLINE — which is not what we want.
110
110
  if (static::hasOnlyInlineChildren($node) && static::looksLikeTextFlow($node)) {
111
111
  $inner = trim(static::renderInline($node));
112
112
  return "{$pad}<{$tag}{$attrs}>{$inner}</{$tag}>\n";
@@ -120,9 +120,9 @@ class HTML
120
120
  return "{$pad}<{$tag}{$attrs}>\n{$inner}{$pad}</{$tag}>\n";
121
121
  }
122
122
 
123
- // Sérialise le contenu inline d'un nœud sur une seule ligne, en réduisant
124
- // tout groupe d'espaces/tabs/retours à la ligne du texte source à une seule espace
125
- // (équivalent au comportement de collapse des espaces en HTML).
123
+ // Serializes a node's inline content on a single line, collapsing any run
124
+ // of spaces/tabs/newlines in the source text to a single space (equivalent
125
+ // to HTML's whitespace-collapsing behavior).
126
126
  private static function renderInline(Dom\Node $node): string
127
127
  {
128
128
  $out = '';
@@ -150,9 +150,10 @@ class HTML
150
150
  return $out;
151
151
  }
152
152
 
153
- // Distingue "du texte qui contient un peu d'inline" (Cliquez <a>ici</a>.) d'un
154
- // conteneur qui aligne simplement plusieurs blocs inline côte à côte (grille de <a><img></a>).
155
- // Vrai si : il y a du texte significatif parmi les enfants, OU un seul enfant élément.
153
+ // Tells "text that contains a bit of inline" (Click <a>here</a>.) apart from
154
+ // a container that just lines up several inline blocks side by side (a grid
155
+ // of <a><img></a>).
156
+ // True if: there is meaningful text among the children, OR a single child element.
156
157
  private static function looksLikeTextFlow(Dom\Node $node): bool
157
158
  {
158
159
  $elementCount = 0;
@@ -167,8 +168,8 @@ class HTML
167
168
  return $elementCount <= 1;
168
169
  }
169
170
 
170
- // Vérifie que tous les descendants directs sont inline (texte, void inline, éléments inline)
171
- // Les éléments inline eux-mêmes ne doivent pas contenir d'éléments block
171
+ // Checks that every direct descendant is inline (text, inline void, inline elements)
172
+ // The inline elements themselves must not contain block elements
172
173
  private static function hasOnlyInlineChildren(Dom\Node $node): bool
173
174
  {
174
175
  foreach ($node->childNodes as $child) {
@@ -181,7 +182,7 @@ class HTML
181
182
  if (!in_array(strtolower($child->nodeName), self::INLINE, true)) {
182
183
  return false;
183
184
  }
184
- // Récursif : l'élément inline ne doit pas contenir d'éléments block
185
+ // Recursive: the inline element must not contain block elements
185
186
  if (!static::hasOnlyInlineChildren($child)) {
186
187
  return false;
187
188
  }
@@ -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
 
@@ -90,7 +188,7 @@ class IMG
90
188
  private function prepare(int $x, int $y): GdImage
91
189
  {
92
190
  $img = imagecreatetruecolor($x, $y);
93
- $hasAlpha = in_array($this->info[2], [IMAGETYPE_PNG, IMAGETYPE_WEBP, IMAGETYPE_GIF], true);
191
+ $hasAlpha = in_array($this->info[2], [IMAGETYPE_PNG, IMAGETYPE_WEBP, IMAGETYPE_GIF, IMAGETYPE_AVIF], true);
94
192
  if ($hasAlpha) {
95
193
  imagealphablending($img, false);
96
194
  imagesavealpha($img, true);
@@ -104,19 +202,261 @@ class IMG
104
202
  }
105
203
 
106
204
 
205
+ /**
206
+ * Converts an sRGB color (0-255) to Lab (D65), a perceptually uniform
207
+ * color space: a Euclidean distance in Lab roughly matches a color
208
+ * difference as perceived by the eye, unlike RGB where two colors at
209
+ * the same numeric "distance" can look very different or identical
210
+ * depending on the hue.
211
+ */
212
+ private static function rgbToLab(int $r, int $g, int $b): array
213
+ {
214
+ $toLinear = fn(float $c) => ($c /= 255) <= 0.04045 ? $c / 12.92 : (($c + 0.055) / 1.055) ** 2.4;
215
+ [$rl, $gl, $bl] = [$toLinear($r), $toLinear($g), $toLinear($b)];
216
+
217
+ // Linear RGB -> XYZ (D65), then normalization against the white point.
218
+ $x = ($rl * 0.4124564 + $gl * 0.3575761 + $bl * 0.1804375) / 0.95047;
219
+ $y = $rl * 0.2126729 + $gl * 0.7151522 + $bl * 0.0721750;
220
+ $z = ($rl * 0.0193339 + $gl * 0.1191920 + $bl * 0.9503041) / 1.08883;
221
+
222
+ $f = fn(float $t) => $t > 0.008856 ? $t ** (1 / 3) : (7.787 * $t) + (16 / 116);
223
+ [$fx, $fy, $fz] = [$f($x), $f($y), $f($z)];
224
+
225
+ return [(116 * $fy) - 16, 500 * ($fx - $fy), 200 * ($fy - $fz)]; // [L, a, b]
226
+ }
227
+
228
+
229
+ /**
230
+ * Extracts the most representative colors from the image.
231
+ *
232
+ * Algorithm: median-cut quantization (imagetruecolortopalette, the
233
+ * same family of technique as Imagick::quantizeImage), heavily
234
+ * over-sampling the number of requested colors, followed by
235
+ * perceptual post-processing in Lab space:
236
+ * - merging colors that are too close (CIE76 distance in Lab): the
237
+ * quantization often produces several hues that look virtually
238
+ * identical to the eye, which we don't want as separate entries;
239
+ * - excluding near-pure white/black via the L lightness component
240
+ * (reliable regardless of hue, unlike a threshold on raw r/g/b).
241
+ *
242
+ * @param int $numColors Number of colors to return.
243
+ * @param float $mergeTolerance Lab distance below which two colors are merged (0-100, ~6-10 = "near identical").
244
+ * @param bool $excludeNearWhiteAndBlack Excludes near-white/near-black colors.
245
+ * @param float $lightnessThreshold Lab lightness threshold (0-100) above/below which a color is considered near-white/near-black.
246
+ * @return string[] Colors as "#rrggbb", sorted by descending frequency.
247
+ */
248
+ public function getRepresentativeColors(
249
+ int $numColors = 5,
250
+ float $mergeTolerance = 8.0,
251
+ bool $excludeNearWhiteAndBlack = true,
252
+ float $lightnessThreshold = 8.0
253
+ ): array {
254
+ $srcW = $this->width;
255
+ $srcH = $this->height;
256
+
257
+ // Analyzing at full resolution brings nothing for this kind of
258
+ // extraction and is expensive in compute time.
259
+ $maxDim = 150;
260
+ $scale = min(1, $maxDim / max($srcW, $srcH));
261
+ $w = max(1, (int) round($srcW * $scale));
262
+ $h = max(1, (int) round($srcH * $scale));
263
+
264
+ // Flatten onto a white background (as for JPEG export): otherwise
265
+ // the transparent areas of PNG/WEBP images would default to being
266
+ // counted as black on a truecolor canvas.
267
+ $sample = imagecreatetruecolor($w, $h);
268
+ imagealphablending($sample, true);
269
+ $white = imagecolorallocate($sample, 255, 255, 255);
270
+ imagefilledrectangle($sample, 0, 0, $w, $h, $white);
271
+ imagecopyresampled($sample, $this->im, 0, 0, 0, 0, $w, $h, $srcW, $srcH);
272
+
273
+ // We heavily over-sample the number of requested colors: this
274
+ // leaves enough room for the perceptual merge and the white/black
275
+ // exclusion below without running short of colors.
276
+ $buckets = max($numColors * 6, 24);
277
+ imagetruecolortopalette($sample, false, $buckets);
278
+
279
+ // Count occurrences of each color in the generated palette.
280
+ $counts = array_fill(0, imagecolorstotal($sample), 0);
281
+ for ($y = 0; $y < $h; $y++) {
282
+ for ($x = 0; $x < $w; $x++) {
283
+ $counts[imagecolorat($sample, $x, $y)]++;
284
+ }
285
+ }
286
+
287
+ $entries = [];
288
+ foreach ($counts as $index => $count) {
289
+ if ($count === 0) continue;
290
+ $rgb = imagecolorsforindex($sample, $index);
291
+ $lab = self::rgbToLab($rgb['red'], $rgb['green'], $rgb['blue']);
292
+
293
+ if ($excludeNearWhiteAndBlack && ($lab[0] > 100 - $lightnessThreshold || $lab[0] < $lightnessThreshold)) {
294
+ continue;
295
+ }
296
+
297
+ $entries[] = ['r' => $rgb['red'], 'g' => $rgb['green'], 'b' => $rgb['blue'], 'lab' => $lab, 'count' => $count];
298
+ }
299
+
300
+ usort($entries, fn($a, $b) => $b['count'] - $a['count']);
301
+
302
+ // Merge perceptually close colors by grouping their occurrences,
303
+ // always starting from the most frequent one.
304
+ $merged = [];
305
+ foreach ($entries as $entry) {
306
+ foreach ($merged as &$cluster) {
307
+ $dl = $cluster['lab'][0] - $entry['lab'][0];
308
+ $da = $cluster['lab'][1] - $entry['lab'][1];
309
+ $db = $cluster['lab'][2] - $entry['lab'][2];
310
+ if (sqrt($dl * $dl + $da * $da + $db * $db) <= $mergeTolerance) {
311
+ $cluster['count'] += $entry['count'];
312
+ continue 2;
313
+ }
314
+ }
315
+ unset($cluster);
316
+ $merged[] = $entry;
317
+ }
318
+
319
+ usort($merged, fn($a, $b) => $b['count'] - $a['count']);
320
+
321
+ return array_map(
322
+ fn($c) => sprintf('#%02x%02x%02x', $c['r'], $c['g'], $c['b']),
323
+ array_slice($merged, 0, $numColors)
324
+ );
325
+ }
326
+
327
+
328
+ /**
329
+ * The AVIF encoder (libavif/aom) used by imageavif() can fail with
330
+ * "Encoding of color planes failed" in two common cases:
331
+ * - odd width/height (4:2:0 chroma subsampling constraint)
332
+ * - image too large, causing the encoder to run out of memory for
333
+ * its internal buffers ("aom_codec_encode: Failed to allocate lag buffers")
334
+ * We fix both before encoding, on a temporary copy, without modifying
335
+ * $this->im.
336
+ */
337
+ private function prepareForAvif(GdImage $im, int $maxDimension = 4000): GdImage
338
+ {
339
+ $w = imagesx($im);
340
+ $h = imagesy($im);
341
+
342
+ // Cap the resolution if needed (encoder memory protection).
343
+ if ($w > $maxDimension || $h > $maxDimension) {
344
+ $scale = min($maxDimension / $w, $maxDimension / $h);
345
+ $newW = max(1, (int) round($w * $scale));
346
+ $newH = max(1, (int) round($h * $scale));
347
+ $resized = imagecreatetruecolor($newW, $newH);
348
+ imagealphablending($resized, false);
349
+ imagesavealpha($resized, true);
350
+ $transparent = imagecolorallocatealpha($resized, 0, 0, 0, 127);
351
+ imagefilledrectangle($resized, 0, 0, $newW, $newH, $transparent);
352
+ imagecopyresampled($resized, $im, 0, 0, 0, 0, $newW, $newH, $w, $h);
353
+ $im = $resized;
354
+ $w = $newW;
355
+ $h = $newH;
356
+ }
357
+
358
+ // Force even dimensions.
359
+ $w2 = $w % 2 ? $w + 1 : $w;
360
+ $h2 = $h % 2 ? $h + 1 : $h;
361
+ if ($w2 !== $w || $h2 !== $h) {
362
+ $padded = imagecreatetruecolor($w2, $h2);
363
+ imagealphablending($padded, false);
364
+ imagesavealpha($padded, true);
365
+ $transparent = imagecolorallocatealpha($padded, 0, 0, 0, 127);
366
+ imagefilledrectangle($padded, 0, 0, $w2, $h2, $transparent);
367
+ imagecopy($padded, $im, 0, 0, 0, 0, $w, $h);
368
+ $im = $padded;
369
+ }
370
+
371
+ return $im;
372
+ }
373
+
374
+
375
+ /**
376
+ * Encodes to AVIF with automatic fallback: if encoding fails (memory
377
+ * error or another internal aom issue), retry with a higher speed
378
+ * (less memory-hungry), then with a reduced resolution. Throws an
379
+ * explicit exception if everything fails, instead of letting a silent
380
+ * PHP warning through and leaving a corrupted/missing file behind.
381
+ */
382
+ private function encodeAvif(GdImage $im, string $dest, int $quality = 82): bool
383
+ {
384
+ $attempts = [
385
+ ['maxDimension' => 4000, 'speed' => 4],
386
+ ['maxDimension' => 4000, 'speed' => 8],
387
+ ['maxDimension' => 2000, 'speed' => 8],
388
+ ];
389
+
390
+ foreach ($attempts as $attempt) {
391
+ $prepared = $this->prepareForAvif($im, $attempt['maxDimension']);
392
+ $ok = @imageavif($prepared, $dest, $quality, $attempt['speed']);
393
+ if ($ok && is_file($dest) && filesize($dest) > 0) return true;
394
+ }
395
+
396
+ return false;
397
+ }
398
+
399
+
107
400
  public function save(string $dest): self
108
401
  {
109
402
  $ext = strtolower(pathinfo($dest, PATHINFO_EXTENSION));
110
403
  $dir = pathinfo($dest, PATHINFO_DIRNAME);
111
404
  if (!is_dir($dir) && !@mkdir($dir, 0777, true)) throw new Exception("Invalid destination.");
112
- match ($ext) {
113
- 'jpg', 'jpeg' => imagejpeg($this->im, $dest, 85),
405
+ $ok = match ($ext) {
406
+ 'jpg', 'jpeg' => imagejpeg($this->im, $dest, 82),
114
407
  'png' => imagepng($this->im, $dest, 6),
115
408
  'gif' => imagegif($this->im, $dest),
116
- 'webp' => (function_exists('imagewebp') ? imagewebp($this->im, $dest, 85) : false),
409
+ 'webp' => (function_exists('imagewebp') ? imagewebp($this->im, $dest, 82) : false),
410
+ 'avif' => (function_exists('imageavif') ? $this->encodeAvif($this->im, $dest, 82) : false),
117
411
  default => throw new Exception("Invalid output file type.")
118
412
  };
119
- // PREPROS::exportFile(realpath($dest));
413
+ if (!$ok) throw new Exception("Failed to encode image as '{$ext}'.");
414
+ PREPROS::exportFile(realpath($dest));
120
415
  return $this;
121
416
  }
122
- }
417
+
418
+
419
+ public static function asset(string $path, $width = 0, $height = 0, $cover = false, $backtrace = '')
420
+ {
421
+ $srcfile = FS::pathJoin(PREPROS::$config->image->source, $path);
422
+ if(!$srcinfo = PREPROS::fstat($srcfile)) throw new Exception("Invalid image file.");
423
+
424
+ $suffix = '';
425
+ if ($width && $height) $suffix = $cover ? '-' . $width . 'x' . $height . '-cover' : '-' . $width . 'x' . $height;
426
+ elseif ($width) $suffix = '-' . $width . 'w';
427
+ elseif ($height) $suffix = '-' . $height . 'h';
428
+ $destname = preg_replace('/\.[a-z]{2,4}+$/i', '', $path) . $suffix . '.' . PREPROS::$config->image->format;
429
+ $destfile = FS::pathJoin(PREPROS::$config->data->root, PREPROS::$config->image->dest, $destname);
430
+ $destinfo = PREPROS::fstat($destfile);
431
+
432
+ if(!$destinfo || ((strtotime($srcinfo->modifiedAt) - strtotime($destinfo->modifiedAt)) > 10)) {
433
+ if(!$localfile = current(PREPROS::mount($srcfile))) throw new Exception("Can't mount image.");
434
+ if(!is_file($localfile)) throw new Exception("Can't mount image.");
435
+ $img = new self($localfile);
436
+ if ($width && $height) $img->resize($width, $height, $cover);
437
+ elseif ($width) $img->resize($width);
438
+ elseif ($height) $img->resize((int) round($img->width * $height / $img->height), $height);
439
+ $img->save(FS::pathJoin('/project', $destfile));
440
+ }
441
+
442
+ if(!$backtrace) $backtrace = PREPROS::backtraceFile();
443
+ return FS::getRelativePath($backtrace, FS::pathJoin('/project', $destfile));
444
+ }
445
+
446
+
447
+ public static function palette(string $path, $colors = 5)
448
+ {
449
+ $srcfile = FS::pathJoin(PREPROS::$config->image->source, $path);
450
+ if(!$srcinfo = PREPROS::fstat($srcfile)) throw new Exception("Invalid image file.");
451
+ $key = 'palette_' . STR::shorthash("{$srcfile}:{$srcinfo->modifiedAt}:{$colors}");
452
+ if($palette = CACHE::get($key)) return $palette;
453
+ if(!$localfile = current(PREPROS::mount($srcfile))) throw new Exception("Can't mount image.");
454
+ if(!$palette = (new self($localfile))->getRepresentativeColors($colors)) throw new Exception("Can't extract palette from image \"{$path}\".");
455
+ CACHE::set($key, $palette);
456
+ return $palette;
457
+ }
458
+
459
+
460
+
461
+
462
+ }