-
-
Notifications
You must be signed in to change notification settings - Fork 1
/
ThumbHash.php
207 lines (170 loc) · 6.25 KB
/
ThumbHash.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
<?php
namespace tobimori;
use Kirby\Cms\File;
use Kirby\Filesystem\Asset;
use Thumbhash\Thumbhash as THEncoder;
class ThumbHash
{
/**
* Creates thumb for an image based on the ThumbHash algorithm, returns a data URI with an SVG filter.
*/
public static function thumb(Asset|File $file, array $options = []): string
{
$hash = self::encode($file, $options); // Encode image with ThumbHash Algorithm
$rgba = self::decode($hash); // Decode ThumbHash to RGBA array
return self::uri($rgba, $options); // Output image as data URI with SVG blur
}
/**
* Returns the ThumbHash for a Kirby file object.
*/
public static function encode(Asset|File $file, array $options = []): string
{
$kirby = kirby();
$id = self::getId($file);
$options['ratio'] ??= $file->ratio();
$cache = $kirby->cache('tobimori.thumbhash.encode');
if (($cacheData = $cache->get($id)) !== null) {
return $cacheData;
}
// Generate a sample image for encode to avoid memory issues.
$max = $kirby->option('tobimori.thumbhash.sampleMaxSize'); // Max width or height
$height = round($file->height() > $file->width() ? $max : $max / $options['ratio']);
$width = round($file->width() > $file->height() ? $max : $max * $options['ratio']);
$options = [
'width' => $width,
'height' => $height,
'crop' => true,
'quality' => 70,
];
// Create a GD image from the file.
$image = imagecreatefromstring($file->thumb($options)->read()); // TODO: allow Imagick encoder
$height = imagesy($image);
$width = imagesx($image);
$pixels = [];
for ($y = 0; $y < $height; $y++) {
for ($x = 0; $x < $width; $x++) {
$color_index = imagecolorat($image, $x, $y);
$color = imagecolorsforindex($image, $color_index);
$alpha = 255 - ceil($color['alpha'] * (255 / 127)); // GD only supports 7-bit alpha channel
$pixels[] = $color['red'];
$pixels[] = $color['green'];
$pixels[] = $color['blue'];
$pixels[] = $alpha;
}
}
$hashArray = THEncoder::RGBAToHash($width, $height, $pixels);
$thumbhash = THEncoder::convertHashToString($hashArray);
$cache->set($id, $thumbhash);
return $thumbhash;
}
/**
* Decodes a ThumbHash string or array to an array of RGBA values, and width & height
*/
public static function decode(string|array $thumbhash): array
{
$kirby = kirby();
$cache = $kirby->cache('tobimori.thumbhash.decode');
$id = is_array($thumbhash) ? THEncoder::convertHashToString($thumbhash) : $thumbhash;
$thumbhash = is_string($thumbhash) ? THEncoder::convertStringToHash($thumbhash) : $thumbhash;
if (($cacheData = $cache->get($id)) !== null) {
return $cacheData;
}
$image = THEncoder::hashToRGBA($thumbhash);
// check if any alpha value in RGBA array is less than 255
$transparent = array_reduce(array_chunk($image['rgba'], 4), function ($carry, $item) {
return $carry || $item[3] < 255;
}, false);
$dataUri = THEncoder::rgbaToDataURL($image['w'], $image['h'], $image['rgba']);
$data = [
'uri' => $dataUri,
'width' => $image['w'],
'height' => $image['h'],
'transparent' => $transparent,
];
$cache->set($id, $data);
return $data;
}
/**
* Clears encoding cache for a file.
*/
public static function clearCache(Asset|File $file)
{
$cache = kirby()->cache('tobimori.thumbhash.encode');
$id = self::getId($file);
$cache->remove($id);
}
/**
* Returns an optimized URI-encoded string of an SVG for using in a src attribute.
* Based on https://github.com/johannschopplich/kirby-blurry-placeholder/blob/main/BlurryPlaceholder.php#L65
*/
private static function svgToUri(string $data): string
{
// Optimizes the data URI length by deleting line breaks and
// removing unnecessary spaces
$data = preg_replace('/\s+/', ' ', $data);
$data = preg_replace('/> </', '><', $data);
$data = rawurlencode($data);
// Back-decode certain characters to improve compression
// except '%20' to be compliant with W3C guidelines
$data = str_replace(
['%2F', '%3A', '%3D'],
['/', ':', '='],
$data
);
return 'data:image/svg+xml;charset=utf-8,' . $data;
}
/**
* Applies SVG filter and base64-encoding to binary image.
* Based on https://github.com/johannschopplich/kirby-blurry-placeholder/blob/main/BlurryPlaceholder.php#L10
*/
private static function svgFilter(array $image, array $options = []): string
{
$svgHeight = number_format($image['height'], 2, '.', '');
$svgWidth = number_format($image['width'], 2, '.', '');
// Wrap the blurred image in a SVG to avoid rasterizing the filter
$alphaFilter = '';
// If the image doesn't include an alpha channel itself, apply an additional filter
// to remove the alpha channel from the blur at the edges
if (!$image['transparent']) {
$alphaFilter = <<<EOD
<feComponentTransfer>
<feFuncA type="discrete" tableValues="1 1"></feFuncA>
</feComponentTransfer>
EOD;
}
// Wrap the blurred image in a SVG to avoid rasterizing the filter
$svg = <<<EOD
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {$svgWidth} {$svgHeight}">
<filter id="b" color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="{$options['blurRadius']}"></feGaussianBlur>
{$alphaFilter}
</filter>
<image filter="url(#b)" x="0" y="0" width="100%" height="100%" href="{$image['uri']}"></image>
</svg>
EOD;
return $svg;
}
/**
* Returns a decoded BlurHash as a URI-encoded SVG with blur filter applied.
*/
public static function uri(array $image, array $options = []): string
{
$uri = $image['uri'];
$options['blurRadius'] ??= kirby()->option('tobimori.thumbhash.blurRadius') ?? 1;
if ($options['blurRadius'] !== 0) {
$svg = self::svgFilter($image, $options);
$uri = self::svgToUri($svg);
}
return $uri;
}
/**
* Returns the uuid for a File, or its mediaHash for Assets.
*/
private static function getId(Asset|File $file): string
{
if ($file instanceof Asset) {
return $file->mediaHash();
}
return $file->uuid()->id() ?? $file->id();
}
}