@kirigami/php-prepros 1.0.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 ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * @kirigami/php-prepros
3
+ *
4
+ * PHP preprocessor for the Kirigami static site generator.
5
+ * Runs PHP files through a WebAssembly PHP runtime to produce
6
+ * ready-to-deploy HTML pages.
7
+ *
8
+ * Requires a `kirigami.yaml` configuration file at the project root.
9
+ *
10
+ * @example
11
+ * ```js
12
+ * import { render, sitemap } from '@kirigami/php-prepros';
13
+ *
14
+ * // Compile a single page
15
+ * const result = await render('src/index.php');
16
+ *
17
+ * // Compile every page in the source directory
18
+ * const result = await render('src/');
19
+ *
20
+ * // Generate sitemap.xml
21
+ * const sitemap = await sitemap();
22
+ * ```
23
+ */
24
+
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Return types
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Returned by every prepros operation.
32
+ */
33
+ export interface PreprosResult {
34
+ /** `true` when the operation completed without errors. */
35
+ success: boolean;
36
+
37
+ /**
38
+ * Paths of every file written to disk by this operation (relative to the
39
+ * project root). Includes the compiled HTML page(s) and any side-effect
40
+ * files such as `.cache.db` or resized images produced by `IMG::save()`.
41
+ */
42
+ files: string[];
43
+
44
+ /** Human-readable error message. Only present when `success` is `false`. */
45
+ error?: string;
46
+
47
+ /**
48
+ * Raw PHP stdout / stderr, useful for debugging.
49
+ * Only present when response parsing fails.
50
+ */
51
+ response?: string;
52
+ }
53
+
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Public API
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Compile one PHP source file **or** an entire directory tree.
61
+ *
62
+ * - **Single file** — `_index.php` → `index.html` (leading underscore is
63
+ * stripped from the output filename).
64
+ * - **Directory** — every `_*.php` file found recursively is compiled.
65
+ * Files inside directories whose name starts with `_` are skipped.
66
+ *
67
+ * The PHPDOC block at the top of each source file drives the page:
68
+ *
69
+ * ```php
70
+ * <?php
71
+ * /**
72
+ * * @name index
73
+ * * @title My Page Title
74
+ * * @abstract Short description used as <meta name="description">
75
+ * * @articles _articles.yaml ← auto-parsed and injected as $articles
76
+ * * @data _data.json ← auto-parsed and injected as $data
77
+ * *\/
78
+ * ?>
79
+ * ```
80
+ *
81
+ * Any `@tag filename` annotation whose extension is `.yaml`, `.yml`,
82
+ * `.json`, or `.md` is automatically loaded and made available as a PHP
83
+ * variable with the same name as the tag. Remote URLs are supported when
84
+ * `network: true` is set in `kirigami.yaml`.
85
+ *
86
+ * @param file Path to a `.php` source file or a directory, relative to the
87
+ * project root. Defaults to `.` (the entire source tree).
88
+ *
89
+ * @returns A {@link PreprosResult} describing what was written.
90
+ *
91
+ * @throws When the `kirigami.yaml` config is missing or malformed.
92
+ * @throws When `kirigami.root` does not exist on disk.
93
+ */
94
+ export function render(file?: string): Promise<PreprosResult>;
95
+
96
+
97
+ /**
98
+ * Generate a `sitemap.xml` at the source root.
99
+ *
100
+ * Scans every `_index.php` found in the source tree and produces a
101
+ * standard Sitemaps 0.9 XML document. Priority is calculated from depth
102
+ * (root = 1.0, each extra level −0.1). `changefreq` is set to `weekly`.
103
+ *
104
+ * The base URL is taken from `kirigami.baseurl` in `kirigami.yaml`.
105
+ *
106
+ * @returns A {@link PreprosResult} with `files` containing the path to the
107
+ * generated `sitemap.xml`.
108
+ */
109
+ export function sitemap(dir?: string): Promise<PreprosResult>;
package/index.js ADDED
@@ -0,0 +1 @@
1
+ export { render, sitemap } from "./src/prepros.js";
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@kirigami/php-prepros",
3
+ "version": "1.0.0",
4
+ "description": "PHP preprocessor for the Kirigami static site generator. Compile PHP page templates to clean, deployable HTML — with zero server dependency.",
5
+ "keywords": [
6
+ "kirigami",
7
+ "php",
8
+ "preprocessor",
9
+ "static-site-generator",
10
+ "ssg",
11
+ "wasm",
12
+ "php-wasm",
13
+ "markdown",
14
+ "yaml",
15
+ "github-pages",
16
+ "pages"
17
+ ],
18
+ "license": "MIT",
19
+ "author": "Maxime Larrivée-Roy",
20
+ "type": "module",
21
+ "main": "index.js",
22
+ "types": "index.d.ts",
23
+ "files": [
24
+ "index.js",
25
+ "index.d.ts",
26
+ "src/",
27
+ "LICENSE",
28
+ "README.md"
29
+ ],
30
+ "engines": {
31
+ "node": ">=20.10.0",
32
+ "npm": ">=10.2.3"
33
+ },
34
+ "exports": {
35
+ ".": {
36
+ "import": "./index.js"
37
+ },
38
+ "./package.json": "./package.json"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "scripts": {
44
+ "test": "echo \"Error: no test specified\" && exit 1"
45
+ },
46
+ "dependencies": {
47
+ "@kirigami/php-wasm": "8.5.7-1",
48
+ "js-yaml": "^4.2.0"
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "https://github.com/php-kirigami/kirigami"
53
+ },
54
+ "homepage": "https://github.com/php-kirigami/kirigami/#readme",
55
+ "bugs": {
56
+ "url": "https://github.com/php-kirigami/kirigami/issues"
57
+ }
58
+ }
@@ -0,0 +1,101 @@
1
+ <?php
2
+
3
+ // SYS::requireExtension('sqlite3');
4
+
5
+
6
+ class CACHE
7
+ {
8
+ const DB_TIMEOUT = 10000;
9
+
10
+
11
+ private static function db(): SQLite3
12
+ {
13
+ static $db = null;
14
+ if($db !== null) return $db;
15
+
16
+ if(!empty($_SERVER['NODE_PROJECT'])) $root = $_SERVER['NODE_PROJECT'] . '/';
17
+ elseif(!empty(PREPROS::$config->root)) $root = rtrim(PREPROS::$config->root, '/') . '/';
18
+ else throw new Exception("Can't find project root.");
19
+
20
+ if(!is_file(($dbfile = self::getPath()))) $create = true;
21
+ if(!$db = new SQLite3($dbfile)) throw new Exception("Can't open .cache.db.");
22
+
23
+ $db->busyTimeout(static::DB_TIMEOUT);
24
+ if(!empty($create) && !$db->exec('
25
+ CREATE TABLE "data" (
26
+ "key" TEXT(128) NOT NULL,
27
+ "val" TEXT DEFAULT NULL,
28
+ "inserted_at" INTEGER NOT NULL DEFAULT 0,
29
+ "ttl" INTEGER NOT NULL DEFAULT 0,
30
+ PRIMARY KEY ("key")
31
+ );
32
+ CREATE UNIQUE INDEX "key" ON "data" ("key");
33
+ ')) throw new Exception("Can't create .cache.db.");
34
+ return $db;
35
+ }
36
+
37
+
38
+ private static function getPath() {
39
+ return '/project/.cache.db';
40
+ }
41
+
42
+
43
+ public static function get(string $key): mixed
44
+ {
45
+ static $query = null;
46
+ if(!$query) $query = self::db()->prepare(
47
+ "SELECT val FROM data
48
+ WHERE key = ?
49
+ AND (ttl = 0 OR inserted_at + ttl >= :now)"
50
+ );
51
+ $query->bindValue(1, $key, SQLITE3_TEXT);
52
+ $query->bindValue(':now', time(), SQLITE3_INTEGER);
53
+ $result = $query->execute();
54
+ $query->reset();
55
+ if(!$result) return null;
56
+ $row = $result->fetchArray(SQLITE3_ASSOC);
57
+ $result->finalize();
58
+ if($row === false) return null;
59
+ return unserialize($row['val']);
60
+ }
61
+
62
+
63
+ public static function set(string $key, mixed $val, int $ttl = 0): bool
64
+ {
65
+ static $query = null;
66
+ if(!$query) $query = self::db()->prepare(
67
+ "INSERT OR REPLACE INTO data(key, val, inserted_at, ttl)
68
+ VALUES(?, ?, ?, ?)"
69
+ );
70
+ $query->bindValue(1, $key, SQLITE3_TEXT);
71
+ $query->bindValue(2, serialize($val), SQLITE3_TEXT);
72
+ $query->bindValue(3, time(), SQLITE3_INTEGER);
73
+ $query->bindValue(4, $ttl, SQLITE3_INTEGER);
74
+ $success = (bool)@$query->execute();
75
+ $query->reset();
76
+ PREPROS::exportFile(self::getPath());
77
+ return $success;
78
+ }
79
+
80
+
81
+ public static function purge(): bool
82
+ {
83
+ PREPROS::exportFile(self::getPath());
84
+ return (bool)self::db()->exec(
85
+ "DELETE FROM data WHERE ttl > 0 AND inserted_at + ttl < " . time()
86
+ );
87
+ }
88
+
89
+
90
+ public static function delete(string $key): bool
91
+ {
92
+ static $query = null;
93
+ if(!$query) $query = self::db()->prepare("DELETE FROM data WHERE key = ?");
94
+ $query->bindValue(1, $key, SQLITE3_TEXT);
95
+ $success = (bool)@$query->execute();
96
+ $query->reset();
97
+ PREPROS::exportFile(self::getPath());
98
+ return $success;
99
+ }
100
+
101
+ }
@@ -0,0 +1,133 @@
1
+ <?php
2
+
3
+
4
+ class FS
5
+ {
6
+
7
+
8
+ public static function dig(string $path): iterable
9
+ {
10
+ $patt = pathinfo($path, PATHINFO_BASENAME);
11
+ $path = pathinfo($path, PATHINFO_DIRNAME);
12
+ if ($path = realpath($path)) {
13
+ $path .= '/';
14
+ foreach (glob($path . $patt) as $file) {
15
+ if (!is_dir($file)) yield $file;
16
+ }
17
+ foreach (glob($path . '*', GLOB_ONLYDIR) as $dir) {
18
+ foreach (call_user_func(__METHOD__, $dir . '/' . $patt) as $file) yield $file;
19
+ }
20
+ }
21
+ }
22
+
23
+
24
+ public static function getRelativePath(string $from, string $to): string
25
+ {
26
+ $from = is_dir($from) ? rtrim($from, '\/') . '/' : $from;
27
+ $to = is_dir($to) ? rtrim($to, '\/') . '/' : $to;
28
+ $from = str_replace('\\', '/', $from);
29
+ $to = str_replace('\\', '/', $to);
30
+ $from = explode('/', $from);
31
+ $to = explode('/', $to);
32
+ $relPath = $to;
33
+
34
+ foreach ($from as $depth => $dir) {
35
+ if ($dir === $to[$depth]) {
36
+ array_shift($relPath);
37
+ } else {
38
+ $remaining = count($from) - $depth;
39
+ if ($remaining > 1) {
40
+ $padLength = (count($relPath) + $remaining - 1) * -1;
41
+ $relPath = array_pad($relPath, $padLength, '..');
42
+ break;
43
+ } else {
44
+ $relPath[0] = './' . $relPath[0];
45
+ }
46
+ }
47
+ }
48
+ return $relPath ? implode('/', $relPath) : './';
49
+ }
50
+
51
+
52
+ public static function phpFileInfo(string $file): object|bool
53
+ {
54
+ static $files = [];
55
+ if (!$file = realpath($file)) return false;
56
+ if (!isset($files[$file])) {
57
+ $tokens = token_get_all(file_get_contents($file));
58
+ foreach ($tokens as $tok) {
59
+ if (!is_array($tok)) continue;
60
+ if ($tok[0] == T_DOC_COMMENT) {
61
+ $block = $tok[1];
62
+ break;
63
+ }
64
+ }
65
+ if (empty($block)) return new stdClass;
66
+ if (!preg_match_all('#@([a-z0-9]+)[\s\t]+([^\n]+)#msi', $block, $m)) $files[$file] = new stdClass;
67
+ else {
68
+ $info = [];
69
+ foreach ($m[1] as $k => $v) $info[trim($v)] = trim($m[2][$k]);
70
+ $files[$file] = (object)$info;
71
+ }
72
+ }
73
+ return $files[$file];
74
+ }
75
+
76
+
77
+ public static function rmdir(string $dir, bool $removeSelf = true): bool
78
+ {
79
+ if (!file_exists($dir)) return true;
80
+ if (is_file($dir) || is_link($dir)) return @unlink($dir);
81
+ $items = @scandir($dir);
82
+ if ($items === false) return false;
83
+ foreach ($items as $item) {
84
+ if ($item === '.' || $item === '..') continue;
85
+ $path = $dir . DIRECTORY_SEPARATOR . $item;
86
+ if (is_dir($path) && !is_link($path)) {
87
+ if (!call_user_func(__METHOD__, $path, true)) return false;
88
+ } else {
89
+ if (!@unlink($path)) return false;
90
+ }
91
+ }
92
+ return $removeSelf ? @rmdir($dir) : true;
93
+ }
94
+
95
+
96
+ public static function pathJoin(string ...$parts): string
97
+ {
98
+ if (empty($parts)) return '';
99
+ $isAbsolute = str_starts_with($parts[0], '/');
100
+ $hasTrailingSlash = str_ends_with(end($parts), '/');
101
+
102
+ $prefix = '';
103
+ $isUrl = (bool) preg_match('#^[a-zA-Z][a-zA-Z0-9+\-.]*://#', $parts[0]);
104
+ if ($isUrl) {
105
+ preg_match('#^([a-zA-Z][a-zA-Z0-9+\-.]*://[^/]*)(.*)$#', $parts[0], $m);
106
+ $prefix = $m[1];
107
+ $parts[0] = $m[2] ?? '';
108
+ }
109
+
110
+ $merged = implode('/', $parts);
111
+ $merged = preg_replace('#/+#', '/', $merged);
112
+
113
+ $segments = explode('/', $merged);
114
+ $resolved = [];
115
+ foreach ($segments as $seg) {
116
+ if ($seg === '' || $seg === '.') {
117
+ continue;
118
+ }
119
+ if ($seg === '..') {
120
+ if (!empty($resolved)) {
121
+ array_pop($resolved);
122
+ }
123
+ } else {
124
+ $resolved[] = $seg;
125
+ }
126
+ }
127
+
128
+ $path = implode('/', $resolved);
129
+ if ($isAbsolute || $isUrl) $path = '/' . $path;
130
+ if ($hasTrailingSlash && !str_ends_with($path, '/')) $path .= '/';
131
+ return $prefix . $path;
132
+ }
133
+ }
@@ -0,0 +1,176 @@
1
+ <?php
2
+
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.
8
+ */
9
+ class HTML
10
+ {
11
+ private const INDENT = 4;
12
+
13
+ // Attributs booléens HTML5 — écrits sans valeur
14
+ private const BOOLEAN_ATTRS = [
15
+ 'allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked',
16
+ 'controls', 'default', 'defer', 'disabled', 'formnovalidate',
17
+ 'hidden', 'ismap', 'loop', 'multiple', 'muted', 'nomodule',
18
+ 'noopener', 'noreferrer', 'novalidate', 'open', 'playsinline',
19
+ 'readonly', 'required', 'reversed', 'selected', 'webkit-playsinline',
20
+ ];
21
+
22
+ // Éléments inline — leur présence dans un parent n'empêche pas le rendu inline
23
+ private const INLINE = [
24
+ 'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'button', 'cite',
25
+ 'code', 'dfn', 'em', 'i', 'img', 'input', 'kbd', 'label', 'map',
26
+ 'mark', 'object', 'output', 'q', 's', 'samp', 'select', 'small',
27
+ 'span', 'strong', 'sub', 'sup', 'textarea', 'time', 'tt', 'u', 'var',
28
+ ];
29
+
30
+ private const RAW = ['script', 'style'];
31
+ private const VOID = [
32
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
33
+ 'link', 'meta', 'source', 'track', 'wbr',
34
+ ];
35
+
36
+ public static function format(string $html): string
37
+ {
38
+ $dom = Dom\HTMLDocument::createFromString($html, LIBXML_NOERROR);
39
+
40
+ $output = '';
41
+ foreach ($dom->childNodes as $node) {
42
+ $output .= static::renderNode($node, 0);
43
+ }
44
+
45
+ $output = preg_replace('/\n{3,}/', "\n\n", $output);
46
+
47
+ return rtrim($output) . "\n";
48
+ }
49
+
50
+ private static function renderNode(Dom\Node $node, int $depth): string
51
+ {
52
+ return match ($node->nodeType) {
53
+ XML_TEXT_NODE => static::renderText($node, $depth),
54
+ XML_COMMENT_NODE => static::renderComment($node, $depth),
55
+ XML_DOCUMENT_TYPE_NODE => static::renderDoctype(),
56
+ default => static::renderElement($node, $depth),
57
+ };
58
+ }
59
+
60
+ private static function renderElement(Dom\Node $node, int $depth): string
61
+ {
62
+ $tag = strtolower($node->nodeName);
63
+ $pad = str_repeat(' ', $depth * self::INDENT);
64
+ $attrs = static::renderAttrs($node);
65
+ $isVoid = in_array($tag, self::VOID, true);
66
+ $isRaw = in_array($tag, self::RAW, true);
67
+
68
+ if ($isVoid) {
69
+ return "{$pad}<{$tag}{$attrs}>\n";
70
+ }
71
+
72
+ if ($isRaw) {
73
+ $inner = trim($node->innerHTML, "\n\r");
74
+ if (trim($inner) === '') {
75
+ return "{$pad}<{$tag}{$attrs}></{$tag}>\n";
76
+ }
77
+ $childPad = str_repeat(' ', ($depth + 1) * self::INDENT);
78
+ $lines = explode("\n", $inner);
79
+
80
+ // Calcule le niveau d'indentation minimal existant (ignore les lignes vides)
81
+ $minIndent = PHP_INT_MAX;
82
+ foreach ($lines as $line) {
83
+ if (trim($line) === '') continue;
84
+ $minIndent = min($minIndent, strlen($line) - strlen(ltrim($line)));
85
+ }
86
+ $minIndent = $minIndent === PHP_INT_MAX ? 0 : $minIndent;
87
+
88
+ // Dédente puis ré-indente au bon niveau
89
+ $indented = implode("\n", array_map(
90
+ static fn(string $line) => trim($line) !== ''
91
+ ? $childPad . substr($line, $minIndent)
92
+ : '',
93
+ $lines
94
+ ));
95
+ return "{$pad}<{$tag}{$attrs}>\n" . rtrim($indented) . "\n{$pad}</{$tag}>\n";
96
+ }
97
+
98
+ $children = iterator_to_array($node->childNodes);
99
+
100
+ if (empty($children)) {
101
+ return "{$pad}<{$tag}{$attrs}></{$tag}>\n";
102
+ }
103
+
104
+ // Si tous les enfants sont inline, on utilise innerHTML tel quel — pas de reconstruction
105
+ if (static::hasOnlyInlineChildren($node)) {
106
+ $inner = trim($node->innerHTML);
107
+ return "{$pad}<{$tag}{$attrs}>{$inner}</{$tag}>\n";
108
+ }
109
+
110
+ $inner = '';
111
+ foreach ($children as $child) {
112
+ $inner .= static::renderNode($child, $depth + 1);
113
+ }
114
+
115
+ return "{$pad}<{$tag}{$attrs}>\n{$inner}{$pad}</{$tag}>\n";
116
+ }
117
+
118
+ // Vérifie que tous les descendants directs sont inline (texte, void inline, éléments inline)
119
+ // Les éléments inline eux-mêmes ne doivent pas contenir d'éléments block
120
+ private static function hasOnlyInlineChildren(Dom\Node $node): bool
121
+ {
122
+ foreach ($node->childNodes as $child) {
123
+ if ($child->nodeType === XML_TEXT_NODE) {
124
+ continue;
125
+ }
126
+ if ($child->nodeType !== XML_ELEMENT_NODE) {
127
+ return false;
128
+ }
129
+ if (!in_array(strtolower($child->nodeName), self::INLINE, true)) {
130
+ return false;
131
+ }
132
+ // Récursif : l'élément inline ne doit pas contenir d'éléments block
133
+ if (!static::hasOnlyInlineChildren($child)) {
134
+ return false;
135
+ }
136
+ }
137
+ return true;
138
+ }
139
+
140
+ private static function renderText(Dom\Node $node, int $depth): string
141
+ {
142
+ $text = trim($node->nodeValue);
143
+ if ($text === '') {
144
+ return '';
145
+ }
146
+ $pad = str_repeat(' ', $depth * self::INDENT);
147
+ return $pad . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8') . "\n";
148
+ }
149
+
150
+ private static function renderComment(Dom\Node $node, int $depth): string
151
+ {
152
+ $pad = str_repeat(' ', $depth * self::INDENT);
153
+ return "{$pad}<!--{$node->nodeValue}-->\n";
154
+ }
155
+
156
+ private static function renderDoctype(): string
157
+ {
158
+ return "<!DOCTYPE html>\n";
159
+ }
160
+
161
+ private static function renderAttrs(Dom\Node $node): string
162
+ {
163
+ $out = '';
164
+ if (!$node->hasAttributes()) {
165
+ return $out;
166
+ }
167
+ foreach ($node->attributes as $attr) {
168
+ if (in_array($attr->name, self::BOOLEAN_ATTRS, true)) {
169
+ $out .= ' ' . $attr->name;
170
+ } else {
171
+ $out .= ' ' . $attr->name . '="' . htmlspecialchars($attr->value, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '"';
172
+ }
173
+ }
174
+ return $out;
175
+ }
176
+ }
@@ -0,0 +1,122 @@
1
+ <?php
2
+
3
+
4
+ class IMG
5
+ {
6
+
7
+ private GdImage|null $im = null;
8
+ private array|null $info = null;
9
+
10
+
11
+ public function __construct(string $file)
12
+ {
13
+ if (!is_file($file) || !is_readable($file)) throw new Exception("Source file unreadable.");
14
+
15
+ $this->info = @getimagesize($file);
16
+ if (!$this->info) throw new Exception("Invalid image file.");
17
+
18
+ [$srcW, $srcH, $type] = $this->info;
19
+ if ($srcW <= 0 || $srcH <= 0) throw new Exception("Invalid image file.");
20
+
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
+ };
28
+ }
29
+
30
+
31
+ public function __get(string $name)
32
+ {
33
+ switch (strtolower($name)) {
34
+ case "w":
35
+ case "width":
36
+ return imagesx($this->im);
37
+ case "h":
38
+ case "height":
39
+ return imagesy($this->im);
40
+ }
41
+ }
42
+
43
+
44
+ private function setNew(GdImage $im) {
45
+ $this->im = $im;
46
+ return $this;
47
+ }
48
+
49
+
50
+ public function resize(int $width, int $height = 0, bool $cover = false)
51
+ {
52
+ $srcRatio = $this->width / $this->height;
53
+
54
+ if (!$height) {
55
+ $height = $this->height * $width / $this->width;
56
+ $dstRatio = $srcRatio;
57
+ } else {
58
+ $dstRatio = $width / $height;
59
+ }
60
+
61
+ if ($cover) { // ===> Cover
62
+ if ($srcRatio > $dstRatio) {
63
+ $cropH = $this->height;
64
+ $cropW = (int) round($this->height * $dstRatio);
65
+ } else {
66
+ $cropW = $this->width;
67
+ $cropH = (int) round($this->width / $dstRatio);
68
+ }
69
+
70
+ $srcX = (int) max(0, floor(($this->width - $cropW) / 2));
71
+ $srcY = (int) max(0, floor(($this->height - $cropH) / 2));
72
+ $outW = $width;
73
+ $outH = $height;
74
+ $dst = $this->prepare($outW, $outH);
75
+ $ok = imagecopyresampled($dst, $this->im, 0, 0, $srcX, $srcY, $outW, $outH, $cropW, $cropH);
76
+ } else { // ===> Contain
77
+ $scale = min($width / $this->width, $height / $this->height);
78
+ $outW = (int) max(1, round($this->width * $scale));
79
+ $outH = (int) max(1, round($this->height * $scale));
80
+ $dst = $this->prepare($outW, $outH);
81
+ $ok = imagecopyresampled($dst, $this->im, 0, 0, 0, 0, $outW, $outH, $this->width, $this->height);
82
+ }
83
+
84
+ if (!$ok) throw new Exception("Can't resample source image.");
85
+
86
+ return $this->setNew($dst);
87
+ }
88
+
89
+
90
+ private function prepare(int $x, int $y): GdImage
91
+ {
92
+ $img = imagecreatetruecolor($x, $y);
93
+ $hasAlpha = in_array($this->info[2], [IMAGETYPE_PNG, IMAGETYPE_WEBP, IMAGETYPE_GIF], true);
94
+ if ($hasAlpha) {
95
+ imagealphablending($img, false);
96
+ imagesavealpha($img, true);
97
+ $transparent = imagecolorallocatealpha($img, 0, 0, 0, 127);
98
+ imagefilledrectangle($img, 0, 0, imagesx($img), imagesy($img), $transparent);
99
+ } else {
100
+ $white = imagecolorallocate($img, 255, 255, 255);
101
+ imagefilledrectangle($img, 0, 0, imagesx($img), imagesy($img), $white);
102
+ }
103
+ return $img;
104
+ }
105
+
106
+
107
+ public function save(string $dest): self
108
+ {
109
+ $ext = strtolower(pathinfo($dest, PATHINFO_EXTENSION));
110
+ $dir = pathinfo($dest, PATHINFO_DIRNAME);
111
+ if (!is_dir($dir) && !@mkdir($dir, 0777, true)) throw new Exception("Invalid destination.");
112
+ match ($ext) {
113
+ 'jpg', 'jpeg' => imagejpeg($this->im, $dest, 85),
114
+ 'png' => imagepng($this->im, $dest, 6),
115
+ 'gif' => imagegif($this->im, $dest),
116
+ 'webp' => (function_exists('imagewebp') ? imagewebp($this->im, $dest, 85) : false),
117
+ default => throw new Exception("Invalid output file type.")
118
+ };
119
+ PREPROS::exportFile(realpath($dest));
120
+ return $this;
121
+ }
122
+ }