Passed
Push — master ( 89f718...4b3ef6 )
by Greg
06:43
created

ImportThumbnailsData::scaledImagePixels()   A

Complexity

Conditions 3
Paths 1

Size

Total Lines 17
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 11
c 0
b 0
f 0
nc 1
nop 2
dl 0
loc 17
rs 9.9
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2021 webtrees development team
6
 * This program is free software: you can redistribute it and/or modify
7
 * it under the terms of the GNU General Public License as published by
8
 * the Free Software Foundation, either version 3 of the License, or
9
 * (at your option) any later version.
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 * You should have received a copy of the GNU General Public License
15
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16
 */
17
18
declare(strict_types=1);
19
20
namespace Fisharebest\Webtrees\Http\RequestHandlers;
21
22
use Fisharebest\Webtrees\I18N;
23
use Fisharebest\Webtrees\Media;
24
use Fisharebest\Webtrees\Mime;
25
use Fisharebest\Webtrees\Registry;
26
use Fisharebest\Webtrees\Services\SearchService;
27
use Illuminate\Support\Collection;
28
use Intervention\Image\ImageManager;
29
use League\Flysystem\FilesystemInterface;
30
use Psr\Http\Message\ResponseInterface;
31
use Psr\Http\Message\ServerRequestInterface;
32
use Psr\Http\Server\RequestHandlerInterface;
33
34
use function abs;
35
use function array_map;
36
use function e;
37
use function explode;
38
use function glob;
39
use function implode;
40
use function intdiv;
41
use function is_file;
42
use function max;
43
use function response;
44
use function route;
45
use function str_contains;
46
use function str_replace;
47
use function stripos;
48
use function substr;
49
use function substr_compare;
50
use function view;
51
52
use const GLOB_NOSORT;
53
54
/**
55
 * Import custom thumbnails from webtrees 1.x.
56
 */
57
class ImportThumbnailsData implements RequestHandlerInterface
58
{
59
    private const FINGERPRINT_PIXELS = 10;
60
61
    /** @var SearchService */
62
    private $search_service;
63
64
    /**
65
     * ImportThumbnailsData constructor.
66
     *
67
     * @param SearchService $search_service
68
     */
69
    public function __construct(SearchService $search_service)
70
    {
71
        $this->search_service = $search_service;
72
    }
73
74
    /**
75
     * Import custom thumbnails from webtrees 1.x.
76
     *
77
     * @param ServerRequestInterface $request
78
     *
79
     * @return ResponseInterface
80
     */
81
    public function handle(ServerRequestInterface $request): ResponseInterface
82
    {
83
        $data_filesystem = Registry::filesystem()->data();
84
85
        $start  = (int) $request->getQueryParams()['start'];
86
        $length = (int) $request->getQueryParams()['length'];
87
        $search = $request->getQueryParams()['search']['value'];
88
89
        // Fetch all thumbnails
90
        $thumbnails = Collection::make($data_filesystem->listContents('', true))
91
            ->filter(static function (array $metadata): bool {
92
                return $metadata['type'] === 'file' && str_contains($metadata['path'], '/thumbs/');
93
            })
94
            ->map(static function (array $metadata): string {
95
                return $metadata['path'];
96
            });
97
98
        $recordsTotal = $thumbnails->count();
99
100
        if ($search !== '') {
101
            $thumbnails = $thumbnails->filter(static function (string $thumbnail) use ($search): bool {
102
                return stripos($thumbnail, $search) !== false;
103
            });
104
        }
105
106
        $recordsFiltered = $thumbnails->count();
107
108
        $data = $thumbnails
109
            ->slice($start, $length)
110
            ->map(function (string $thumbnail) use ($data_filesystem): array {
111
                // Turn each filename into a row for the table
112
                $original = $this->findOriginalFileFromThumbnail($thumbnail);
113
114
                $original_url  = route(AdminMediaFileThumbnail::class, ['path' => $original]);
115
                $thumbnail_url = route(AdminMediaFileThumbnail::class, ['path' => $thumbnail]);
116
117
                $difference = $this->imageDiff($data_filesystem, $thumbnail, $original);
118
119
                $media = $this->search_service->findMediaObjectsForMediaFile($original);
120
121
                $media_links = array_map(static function (Media $media): string {
122
                    return '<a href="' . e($media->url()) . '">' . $media->fullName() . '</a>';
123
                }, $media);
124
125
                $media_links = implode('<br>', $media_links);
126
127
                $action = view('admin/webtrees1-thumbnails-form', [
128
                    'difference' => $difference,
129
                    'media'      => $media,
130
                    'thumbnail'  => $thumbnail,
131
                ]);
132
133
                return [
134
                    '<img src="' . e($thumbnail_url) . '" title="' . e($thumbnail) . '">',
135
                    '<img src="' . e($original_url) . '" title="' . e($original) . '">',
136
                    $media_links,
137
                    I18N::percentage($difference / 100.0),
138
                    $action,
139
                ];
140
            });
141
142
        return response([
143
            'draw'            => (int) $request->getQueryParams()['draw'],
144
            'recordsTotal'    => $recordsTotal,
145
            'recordsFiltered' => $recordsFiltered,
146
            'data'            => $data->values()->all(),
147
        ]);
148
    }
149
150
    /**
151
     * Find the original image that corresponds to a (webtrees 1.x) thumbnail file.
152
     *
153
     * @param string $thumbnail
154
     *
155
     * @return string
156
     */
157
    private function findOriginalFileFromThumbnail(string $thumbnail): string
158
    {
159
        // First option - a file with the same name
160
        $original = str_replace('/thumbs/', '/', $thumbnail);
161
162
        // Second option - a .PNG thumbnail for some other image type
163
        if (substr_compare($original, '.png', -4, 4) === 0) {
164
            $pattern = substr($original, 0, -3) . '*';
165
            $matches = glob($pattern, GLOB_NOSORT);
166
            if ($matches !== [] && is_file($matches[0])) {
167
                $original = $matches[0];
168
            }
169
        }
170
171
        return $original;
172
    }
173
174
    /**
175
     * Compare two images, and return a quantified difference.
176
     * 0 (different) ... 100 (same)
177
     *
178
     * @param FilesystemInterface $data_filesystem
179
     * @param string              $thumbnail
180
     * @param string              $original
181
     *
182
     * @return int
183
     */
184
    private function imageDiff(FilesystemInterface $data_filesystem, string $thumbnail, string $original): int
185
    {
186
        // The original filename was generated from the thumbnail filename.
187
        // It may not actually exist.
188
        if (!$data_filesystem->has($original)) {
189
            return 100;
190
        }
191
192
        $thumbnail_type = explode('/', $data_filesystem->getMimetype($thumbnail) ?: Mime::DEFAULT_TYPE)[0];
193
        $original_type  = explode('/', $data_filesystem->getMimetype($original) ?: Mime::DEFAULT_TYPE)[0];
194
195
        if ($thumbnail_type !== 'image') {
196
            // If the thumbnail file is not an image then similarity is unimportant.
197
            // Response with an exact match, so the GUI will recommend deleting it.
198
            return 100;
199
        }
200
201
        if ($original_type !== 'image') {
202
            // If the original file is not an image then similarity is unimportant .
203
            // Response with an exact mismatch, so the GUI will recommend importing it.
204
            return 0;
205
        }
206
207
        $pixels1 = $this->scaledImagePixels($data_filesystem, $thumbnail);
208
        $pixels2 = $this->scaledImagePixels($data_filesystem, $original);
209
210
        $max_difference = 0;
211
212
        foreach ($pixels1 as $x => $row) {
213
            foreach ($row as $y => $pixel) {
214
                $max_difference = max($max_difference, abs($pixel - $pixels2[$x][$y]));
215
            }
216
        }
217
218
        // The maximum difference is 255 (black versus white).
219
        return 100 - intdiv($max_difference * 100, 255);
220
    }
221
222
    /**
223
     * Scale an image to 10x10 and read the individual pixels.
224
     * This is a slow operation, add we will do it many times on
225
     * the "import webtrees 1 thumbnails" page so cache the results.
226
     *
227
     * @param FilesystemInterface $filesystem
228
     * @param string              $path
229
     *
230
     * @return int[][]
231
     */
232
    private function scaledImagePixels(FilesystemInterface $filesystem, string $path): array
233
    {
234
        return Registry::cache()->file()->remember('pixels-' . $path, static function () use ($filesystem, $path): array {
235
            $blob    = $filesystem->read($path);
236
            $manager = new ImageManager();
237
            $image   = $manager->make($blob)->resize(self::FINGERPRINT_PIXELS, self::FINGERPRINT_PIXELS);
238
239
            $pixels = [];
240
            for ($x = 0; $x < self::FINGERPRINT_PIXELS; ++$x) {
241
                $pixels[$x] = [];
242
                for ($y = 0; $y < self::FINGERPRINT_PIXELS; ++$y) {
243
                    $pixel          = $image->pickColor($x, $y);
244
                    $pixels[$x][$y] = (int) (($pixel[0] + $pixel[1] + $pixel[2]) / 3);
245
                }
246
            }
247
248
            return $pixels;
249
        });
250
    }
251
}
252