• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

TYPO3-Headless / headless / 20167694178

12 Dec 2025 01:06PM UTC coverage: 73.307% (+0.04%) from 73.272%
20167694178

push

github

web-flow
[BUGFIX] Fix tests for gif handling (#860)

1126 of 1536 relevant lines covered (73.31%)

8.18 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.95
/Classes/Utility/FileUtility.php
1
<?php
2

3
/*
4
 * This file is part of the "headless" Extension for TYPO3 CMS.
5
 *
6
 * For the full copyright and license information, please read the
7
 * LICENSE.md file that was distributed with this source code.
8
 */
9

10
declare(strict_types=1);
11

12
namespace FriendsOfTYPO3\Headless\Utility;
13

14
use FriendsOfTYPO3\Headless\Event\EnrichFileDataEvent;
15
use FriendsOfTYPO3\Headless\Event\FileDataAfterCropVariantProcessingEvent;
16
use FriendsOfTYPO3\Headless\Utility\File\ProcessingConfiguration;
17
use InvalidArgumentException;
18
use Psr\EventDispatcher\EventDispatcherInterface;
19
use RuntimeException;
20
use Throwable;
21
use TYPO3\CMS\Core\Configuration\Features;
22
use TYPO3\CMS\Core\Http\NormalizedParams;
23
use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection;
24
use TYPO3\CMS\Core\Resource\FileInterface;
25
use TYPO3\CMS\Core\Resource\ProcessedFile;
26
use TYPO3\CMS\Core\Resource\Rendering\RendererRegistry;
27
use TYPO3\CMS\Core\Utility\ArrayUtility;
28
use TYPO3\CMS\Core\Utility\GeneralUtility;
29
use TYPO3\CMS\Extbase\Service\ImageService;
30
use TYPO3\CMS\Extbase\Utility\LocalizationUtility;
31

32
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
33
use TYPO3\CMS\Frontend\Typolink\LinkResultInterface;
34
use UnexpectedValueException;
35

36
use function array_key_exists;
37
use function array_merge;
38
use function in_array;
39

40
class FileUtility
41
{
42
    /**
43
     * @var array<string, array<string, string>>
44
     */
45
    protected array $errors = [];
46

47
    public function __construct(
48
        private readonly ContentObjectRenderer $contentObjectRenderer,
49
        private readonly RendererRegistry $rendererRegistry,
50
        private readonly ImageService $imageService,
51
        private readonly EventDispatcherInterface $eventDispatcher,
52
        private readonly Features $features
53
    ) {}
24✔
54

55
    public function processFile(
56
        FileInterface $fileReference,
57
        array $arguments = [],
58
        string $cropVariant = 'default',
59
        bool $delayProcessing = false
60
    ): array {
61
        $arguments['legacyReturn'] = 1;
1✔
62
        $arguments['delayProcessing'] = $delayProcessing;
1✔
63
        $arguments['cropVariant'] = $cropVariant;
1✔
64

65
        return $this->process($fileReference, ProcessingConfiguration::fromOptions($arguments));
1✔
66
    }
67

68
    public function process(FileInterface $fileReference, ProcessingConfiguration $processingConfiguration): array
69
    {
70
        $originalFileReference = clone $fileReference;
22✔
71
        $originalFileUrl = $fileReference->getPublicUrl();
22✔
72
        $fileReferenceUid = $fileReference->getUid();
22✔
73
        $uidLocal = $fileReference->getProperty('uid_local');
22✔
74
        $fileRenderer = $this->rendererRegistry->getRenderer($fileReference);
22✔
75
        $crop = $fileReference->getProperty('crop');
22✔
76
        $link = $fileReference->getProperty('link');
22✔
77
        $linkData = null;
22✔
78

79
        if (!empty($link)) {
22✔
80
            $linkData = $this->contentObjectRenderer->typoLink('', ['parameter' => $link, 'returnLast' => 'result']);
1✔
81
            $link = $linkData instanceof LinkResultInterface ? $linkData->getUrl() : null;
1✔
82
        }
83

84
        $originalProperties = [
22✔
85
            'title' => $fileReference->getProperty('title'),
22✔
86
            'alternative' => $fileReference->getProperty('alternative'),
22✔
87
            'description' => $fileReference->getProperty('description'),
22✔
88
            'link' => $link === '' ? null : $link,
22✔
89
            'linkData' => $linkData ?? null,
22✔
90
        ];
22✔
91

92
        if (!$processingConfiguration->legacyReturn) {
22✔
93
            unset($originalProperties['linkData']);
1✔
94
            $linkValue = $processingConfiguration->linkResult ? $linkData : $link;
1✔
95
            $originalProperties['link'] = $linkValue === '' ? null : $linkValue;
1✔
96
        }
97

98
        if ($fileRenderer === null && GeneralUtility::inList(
22✔
99
            $GLOBALS['TYPO3_CONF_VARS']['GFX']['imagefile_ext'],
22✔
100
            $fileReference->getExtension()
22✔
101
        )) {
22✔
102
            $disableProcessingFor = [];
22✔
103

104
            if (!$processingConfiguration->processPdfAsImage) {
22✔
105
                $disableProcessingFor[] = 'application/pdf';
22✔
106
            }
107

108
            if (!$processingConfiguration->processSvg) {
22✔
109
                $disableProcessingFor[] = 'image/svg+xml';
22✔
110
            }
111

112
            if (!$processingConfiguration->processGif) {
22✔
113
                $disableProcessingFor[] = 'image/gif';
22✔
114
            }
115

116
            if (!$processingConfiguration->delayProcessing && !in_array(
22✔
117
                $fileReference->getMimeType(),
22✔
118
                $disableProcessingFor,
22✔
119
                true
22✔
120
            )) {
22✔
121
                $fileReference = $this->processImageFile($fileReference, $processingConfiguration);
2✔
122
            }
123
            $publicUrl = $this->imageService->getImageUri($fileReference, true);
22✔
124
        } elseif ($fileRenderer !== null) {
1✔
125
            $publicUrl = $fileRenderer->render($fileReference, '', '', ['returnUrl' => true]);
1✔
126
        } else {
127
            $publicUrl = $this->getAbsoluteUrl($fileReference->getPublicUrl());
1✔
128
        }
129

130
        $processedProperties = [
22✔
131
            'mimeType' => $fileReference->getMimeType(),
22✔
132
            'type' => explode('/', $fileReference->getMimeType())[0],
22✔
133
            'filename' => $fileReference->getProperty('name'),
22✔
134
            'originalUrl' => $originalFileUrl,
22✔
135
            'uidLocal' => $uidLocal,
22✔
136
            'fileReferenceUid' => $fileReferenceUid,
22✔
137
            'size' => $this->calculateKilobytesToFileSize((int)$fileReference->getSize()),
22✔
138
            'dimensions' => [
22✔
139
                'width' => $fileReference->getProperty('width'),
22✔
140
                'height' => $fileReference->getProperty('height'),
22✔
141
            ],
22✔
142
            'cropDimensions' => [
22✔
143
                'width' => $this->getCroppedDimensionalProperty(
22✔
144
                    $fileReference,
22✔
145
                    'width',
22✔
146
                    $processingConfiguration->cropVariant
22✔
147
                ),
22✔
148
                'height' => $this->getCroppedDimensionalProperty(
22✔
149
                    $fileReference,
22✔
150
                    'height',
22✔
151
                    $processingConfiguration->cropVariant
22✔
152
                ),
22✔
153
            ],
22✔
154
            'crop' => $crop,
22✔
155
            'autoplay' => $fileReference->getProperty('autoplay'),
22✔
156
            'extension' => $fileReference->getProperty('extension'),
22✔
157
        ];
22✔
158

159
        $processedProperties = array_merge(
22✔
160
            $originalProperties,
22✔
161
            $processedProperties
22✔
162
        );
22✔
163

164
        if ($processingConfiguration->propertiesByType) {
22✔
165
            $processedProperties = $this->filterProperties($processingConfiguration, $processedProperties);
1✔
166
        }
167

168
        $event = $this->eventDispatcher->dispatch(
22✔
169
            new EnrichFileDataEvent(
22✔
170
                $originalFileReference,
22✔
171
                $fileReference,
22✔
172
                $processingConfiguration,
22✔
173
                $processedProperties
22✔
174
            )
22✔
175
        );
22✔
176

177
        $processedProperties = $event->getProperties();
22✔
178

179
        if ($processingConfiguration->includeProperties !== []) {
22✔
180
            $processedProperties = $this->onDemandProperties($processingConfiguration, $processedProperties);
1✔
181
        }
182

183
        $cacheBuster = '';
22✔
184

185
        if (($this->features->isFeatureEnabled('headless.assetsCacheBusting') || $processingConfiguration->cacheBusting) &&
22✔
186
            !in_array($fileReference->getMimeType(), ['video/youtube', 'video/vimeo'], true)) {
22✔
187
            $modified = $event->getProcessed()->getProperty('modification_date');
2✔
188

189
            if (!$modified) {
2✔
190
                $modified = $event->getProcessed()->getProperty('tstamp');
1✔
191
            }
192

193
            $cacheBuster = '?' . $modified;
2✔
194
        }
195

196
        $processedFile = [($processingConfiguration->legacyReturn ? 'publicUrl' : 'url') => $publicUrl . $cacheBuster];
22✔
197

198
        if ($processingConfiguration->legacyReturn && !isset($processedProperties['properties'])) {
22✔
199
            $processedProperties = ['properties' => $processedProperties];
21✔
200
        }
201

202
        $processedFile = array_merge($processedFile, $processedProperties);
22✔
203

204
        if ($processingConfiguration->autogenerate !== []) {
22✔
205
            $processedFile = $this->processAutogenerate(
1✔
206
                $originalFileReference,
1✔
207
                $fileReference,
1✔
208
                $processedFile,
1✔
209
                $processingConfiguration
1✔
210
            );
1✔
211
        }
212

213
        return $processedFile;
22✔
214
    }
215

216
    private function onDemandProperties(ProcessingConfiguration $processingConfiguration, array $properties): array
217
    {
218
        $processed = [];
1✔
219
        $props = [];
1✔
220

221
        foreach ($processingConfiguration->includeProperties as $prop) {
1✔
222
            if ($prop === 'publicUrl') {
1✔
223
                continue;
×
224
            }
225

226
            $propName = $prop;
1✔
227

228
            if (str_contains($prop, ' as ')) {
1✔
229
                [$prop, $propName] = GeneralUtility::trimExplode(' as ', $prop, true);
1✔
230

231
                if ($propName === '') {
1✔
232
                    $propName = $prop;
×
233
                }
234
            }
235

236
            if (in_array($prop, ['width', 'height'], true)) {
1✔
237
                $value = $properties['dimensions'][$prop] ?? 0;
1✔
238

239
                if ($processingConfiguration->flattenProperties) {
1✔
240
                    $props[$propName] = $value;
1✔
241
                } else {
242
                    $props['dimensions'][$propName] = $value;
1✔
243
                }
244
            } else {
245
                $props[$propName] = $properties[$prop] ?? null;
1✔
246
            }
247
        }
248

249
        return array_merge($processed, $props);
1✔
250
    }
251

252
    private function filterProperties(ProcessingConfiguration $processingConfiguration, array $properties): array
253
    {
254
        $allowedDefault = $processingConfiguration->defaultFieldsByType !== [] ? $processingConfiguration->defaultFieldsByType : [
1✔
255
            'type',
1✔
256
            'size',
1✔
257
            'title',
1✔
258
            'alternative',
1✔
259
            'description',
1✔
260
            'uidLocal',
1✔
261
            'fileReferenceUid',
1✔
262
            'mimeType',
1✔
263
        ];
1✔
264
        $allowedForImages = array_merge(
1✔
265
            $allowedDefault,
1✔
266
            $processingConfiguration->defaultImageFields !== [] ? $processingConfiguration->defaultImageFields : [
1✔
267
                'dimensions',
1✔
268
                'link',
1✔
269
                'linkData',
1✔
270
            ]
1✔
271
        );
1✔
272
        $allowedForVideo = array_merge(
1✔
273
            $allowedDefault,
1✔
274
            $processingConfiguration->defaultVideoFields !== [] ? $processingConfiguration->defaultVideoFields : [
1✔
275
                'dimensions',
1✔
276
                'autoplay',
1✔
277
                'originalUrl',
1✔
278
            ]
1✔
279
        );
1✔
280

281
        $allowed = match ($properties['type']) {
1✔
282
            'video' => $allowedForVideo,
×
283
            'image' => $allowedForImages,
1✔
284
            default => $allowedDefault,
×
285
        };
1✔
286

287
        $filtered = [];
1✔
288
        foreach (array_keys($properties) as $property) {
1✔
289
            if (in_array($property, $allowed, true) && array_key_exists($property, $properties)) {
1✔
290
                $filtered[$property] = $properties[$property];
1✔
291
            }
292
        }
293

294
        return $filtered;
1✔
295
    }
296

297
    public function processImageFile(
298
        FileInterface $fileReference,
299
        ProcessingConfiguration $processingConfiguration
300
    ): ProcessedFile {
301
        try {
302
            $cropVariantCollection = $this->createCropVariant((string)$fileReference->getProperty('crop'));
3✔
303
            $cropArea = $cropVariantCollection->getCropArea($processingConfiguration->cropVariant);
3✔
304

305
            $instructions = [
3✔
306
                'width' => $processingConfiguration->width !== '' ? $processingConfiguration->width : null,
3✔
307
                'height' => $processingConfiguration->height !== '' ? $processingConfiguration->height : null ,
3✔
308
                'minWidth' => $processingConfiguration->minWidth > 0 ? $processingConfiguration->minWidth : null,
3✔
309
                'minHeight' => $processingConfiguration->minHeight > 0 ? $processingConfiguration->minHeight : null,
3✔
310
                'maxWidth' => $processingConfiguration->maxWidth > 0 ? $processingConfiguration->maxWidth : null,
3✔
311
                'maxHeight' => $processingConfiguration->maxHeight > 0 ? $processingConfiguration->maxHeight : null,
3✔
312
                'crop' => $cropArea->isEmpty() ? null : $cropArea->makeAbsoluteBasedOnFile($fileReference),
3✔
313
            ];
3✔
314

315
            if ($processingConfiguration->fileExtension) {
3✔
316
                $instructions['fileExtension'] = $processingConfiguration->fileExtension;
×
317
            }
318

319
            return $this->imageService->applyProcessingInstructions($fileReference, $instructions);
3✔
320
        } catch (UnexpectedValueException|RuntimeException|InvalidArgumentException $e) {
1✔
321
            $type = lcfirst(get_class($fileReference));
1✔
322
            $status = get_class($e);
1✔
323
            $this->errors['processImageFile'][$type . '-' . $fileReference->getUid()] = $status;
1✔
324
        }
325
    }
326

327
    public function getAbsoluteUrl(string $fileUrl): string
328
    {
329
        $siteUrl = $this->getNormalizedParams()->getSiteUrl();
2✔
330
        $sitePath = str_replace($this->getNormalizedParams()->getRequestHost(), '', $siteUrl);
2✔
331
        $absoluteUrl = trim($fileUrl);
2✔
332
        if (stripos($absoluteUrl, 'http') !== 0) {
2✔
333
            $fileUrl = preg_replace('#^' . preg_quote($sitePath, '#') . '#', '', $fileUrl);
1✔
334
            $fileUrl = $siteUrl . $fileUrl;
1✔
335
        }
336

337
        return $fileUrl;
2✔
338
    }
339

340
    public function getErrors(): array
341
    {
342
        return $this->errors;
1✔
343
    }
344

345
    /**
346
     * When retrieving the height or width for a media file
347
     * a possible cropping needs to be taken into account.
348
     */
349
    protected function getCroppedDimensionalProperty(
350
        FileInterface $fileObject,
351
        string $dimensionalProperty,
352
        string $cropVariant = 'default'
353
    ): int {
354
        if (!$fileObject->hasProperty('crop') || empty($fileObject->getProperty('crop'))) {
22✔
355
            return (int)$fileObject->getProperty($dimensionalProperty);
22✔
356
        }
357

358
        $croppingConfiguration = $fileObject->getProperty('crop');
1✔
359
        $cropVariantCollection = $this->createCropVariant($croppingConfiguration);
1✔
360
        return (int)$cropVariantCollection->getCropArea($cropVariant)->makeAbsoluteBasedOnFile($fileObject)->asArray()[$dimensionalProperty];
1✔
361
    }
362

363
    protected function calculateKilobytesToFileSize(int $value): string
364
    {
365
        $units = $this->translate('viewhelper.format.bytes.units', 'fluid');
22✔
366
        $units = GeneralUtility::trimExplode(',', $units, true);
22✔
367
        $bytes = max($value, 0);
22✔
368
        $pow = floor(($bytes ? log($bytes) : 0) / log(1024));
22✔
369
        $pow = min($pow, count($units) - 1);
22✔
370
        $bytes /= 2 ** (10 * $pow);
22✔
371

372
        return number_format(round($bytes, 4 * 2)) . ' ' . $units[$pow];
22✔
373
    }
374

375
    protected function getNormalizedParams(): NormalizedParams
376
    {
377
        return $this->contentObjectRenderer->getRequest()->getAttribute('normalizedParams');
2✔
378
    }
379

380
    protected function createCropVariant(string $cropString): CropVariantCollection
381
    {
382
        return CropVariantCollection::create($cropString);
3✔
383
    }
384

385
    /**
386
     * @codeCoverageIgnore
387
     */
388
    protected function translate(string $key, string $extensionName): ?string
389
    {
390
        return LocalizationUtility::translate($key, $extensionName);
391
    }
392

393
    private function processAutogenerate(
394
        FileInterface $originalReference,
395
        FileInterface $fileReference,
396
        array $processedFile,
397
        ProcessingConfiguration $processingConfiguration
398
    ): array {
399
        $originalWidth = $originalReference->getProperty('width');
1✔
400
        $originalHeight = $originalReference->getProperty('height');
1✔
401
        $targetWidth = (int)($processingConfiguration->width !== '' ? $processingConfiguration->width : $fileReference->getProperty('width'));
1✔
402
        $targetHeight = (int)($processingConfiguration->height !== '' ? $processingConfiguration->height : $fileReference->getProperty('height'));
1✔
403

404
        if ($targetWidth || $targetHeight) {
1✔
405
            foreach ($processingConfiguration->autogenerate as $autogenerateKey => $conf) {
1✔
406
                $autogenerateKey = rtrim($autogenerateKey, '.');
1✔
407
                $factor = (float)($conf['factor'] ?? 1.0);
1✔
408

409
                $processedFile[$autogenerateKey] = $this->process(
1✔
410
                    $originalReference,
1✔
411
                    $processingConfiguration->withOptions(
1✔
412
                        [
1✔
413
                            'fileExtension' => $conf['fileExtension'] ?? null,
1✔
414
                            // multiply width/height by factor,
415
                            // but don't stretch image beyond its original dimensions!
416
                            'width' => min($targetWidth * $factor, $originalWidth),
1✔
417
                            'height' => min($targetHeight * $factor, $originalHeight),
1✔
418
                            'autogenerate.' => null,
1✔
419
                            'legacyReturn' => 0,
1✔
420
                        ]
1✔
421
                    )
1✔
422
                )['url'];
1✔
423
            }
424
        }
425

426
        return $processedFile;
1✔
427
    }
428

429
    public function processCropVariants(
430
        FileInterface $originalFileReference,
431
        ProcessingConfiguration $processingConfiguration,
432
        array $processedFile
433
    ): array {
434
        /**
435
         * @var string|null $crop
436
         */
437
        $crop = $originalFileReference->getProperty('crop');
21✔
438

439
        if ($crop !== null) {
21✔
440
            if (!$processingConfiguration->legacyReturn) {
21✔
441
                unset($processedFile['crop'], $processedFile['properties']['crop']);
1✔
442
            }
443

444
            $cropVariants = json_decode($originalFileReference->getProperty('crop'), true);
21✔
445

446
            $collection = CropVariantCollection::create($originalFileReference->getProperty('crop'));
21✔
447

448
            if (is_array($cropVariants) && count($cropVariants) > 1 && str_starts_with(
21✔
449
                $originalFileReference->getMimeType(),
21✔
450
                'image/'
21✔
451
            )) {
21✔
452
                foreach (array_keys($cropVariants) as $cropVariantName) {
1✔
453
                    if ($processingConfiguration->conditionalCropVariant && $collection->getCropArea($cropVariantName)->isEmpty()) {
1✔
454
                        continue;
1✔
455
                    }
456

457
                    $processingConfiguration = $processingConfiguration->withOptions(['cropVariant' => $cropVariantName]);
1✔
458
                    $file = $this->process($originalFileReference, $processingConfiguration);
1✔
459
                    $processedFile['cropVariants'][$cropVariantName] = $this->cropVariant(
1✔
460
                        $processingConfiguration,
1✔
461
                        $file,
1✔
462
                        $cropVariants[$cropVariantName]
1✔
463
                    );
1✔
464
                }
465
            }
466
        }
467

468
        return $this->eventDispatcher->dispatch(
21✔
469
            new FileDataAfterCropVariantProcessingEvent(
21✔
470
                $originalFileReference,
21✔
471
                $processingConfiguration,
21✔
472
                $processedFile
21✔
473
            )
21✔
474
        )->getProcessedFile();
21✔
475
    }
476

477
    private function cropVariant(
478
        ProcessingConfiguration $processingConfiguration,
479
        array $file,
480
        array $cropVariant = []
481
    ): array {
482
        $url = $processingConfiguration->legacyReturn ? $file['publicUrl'] : $file['url'];
1✔
483
        $urlKey = $processingConfiguration->legacyReturn ? 'publicUrl' : 'url';
1✔
484

485
        $path = '';
1✔
486

487
        if ($processingConfiguration->legacyReturn) {
1✔
488
            $path .= 'properties/';
×
489
        }
490

491
        if (!$processingConfiguration->flattenProperties) {
1✔
492
            $path .= 'dimensions/';
×
493
        }
494

495
        $additional = [];
1✔
496

497
        if ($processingConfiguration->outputCropArea) {
1✔
498
            $additional = ['crop' => $cropVariant];
×
499
        }
500

501
        try {
502
            $width = ArrayUtility::getValueByPath($file, $path . 'width');
1✔
503
            $height = ArrayUtility::getValueByPath($file, $path . 'height');
1✔
504
        } catch (Throwable) {
×
505
            $width = 0;
×
506
            $height = 0;
×
507
        }
508

509
        $dimensions = [
1✔
510
            'width' => $width,
1✔
511
            'height' => $height,
1✔
512
            ...$additional,
1✔
513
        ];
1✔
514

515
        if (!$processingConfiguration->legacyReturn && $processingConfiguration->flattenProperties) {
1✔
516
            return array_merge([$urlKey => $url], $dimensions);
1✔
517
        }
518

519
        $wrappedDimensions = $dimensions;
×
520

521
        if (!$processingConfiguration->flattenProperties) {
×
522
            $wrappedDimensions = ['dimensions' => $wrappedDimensions];
×
523
        }
524

525
        if ($processingConfiguration->legacyReturn) {
×
526
            $wrappedDimensions = ['properties' => $wrappedDimensions];
×
527
        }
528

529
        return [$urlKey => $url, ...$wrappedDimensions];
×
530
    }
531
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc