@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/README.md +1114 -869
- package/index.d.ts +75 -2
- package/index.js +1 -1
- package/package.json +4 -4
- package/src/libraries/curl.class.php +5 -0
- package/src/libraries/html.class.php +23 -22
- package/src/libraries/img.class.php +356 -16
- package/src/libraries/md.class.php +476 -170
- package/src/libraries/md.plugins.php +19 -19
- package/src/libraries/normalizer.class.php +310 -0
- package/src/libraries/prepros.class.php +36 -7
- package/src/libraries/prepros.plugins.php +24 -0
- package/src/libraries/schema.class.php +550 -0
- package/src/libraries/scraper.class.php +64 -59
- package/src/libraries/str.class.php +49 -15
- package/src/libraries/yaml.class.php +66 -66
- package/src/phpjs/fstat.js +9 -0
- package/src/prepros.js +51 -44
- package/src/utils/getfilestats.js +30 -0
- package/src/utils/isbinary.js +14 -14
- package/src/utils.inc.php +9 -8
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.
|
|
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": ">=
|
|
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-
|
|
48
|
-
"@kirigami/struct-walker": "1.0.
|
|
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,16 +1,16 @@
|
|
|
1
1
|
<?php
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* HtmlFormatter —
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
// (
|
|
109
|
-
//
|
|
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
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
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
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
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
|
-
//
|
|
171
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
+
}
|