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

TYPO3-Headless / headless / 29398229494

15 Jul 2026 07:42AM UTC coverage: 69.663% (-5.8%) from 75.459%
29398229494

Pull #893

github

web-flow
Merge ab993c965 into 79b7c5472
Pull Request #893: [TASK] Reintroduce missing features, extension cleanup

360 of 545 new or added lines in 33 files covered. (66.06%)

167 existing lines in 7 files now uncovered.

1364 of 1958 relevant lines covered (69.66%)

7.27 hits per line

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

97.16
/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 implements FileUtilityInterface
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
    ) {}
46✔
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;
41✔
71
        $originalFileUrl = $fileReference->getPublicUrl();
41✔
72
        $fileReferenceUid = $fileReference->getUid();
41✔
73
        $uidLocal = $fileReference->getProperty('uid_local');
41✔
74
        $fileRenderer = $this->rendererRegistry->getRenderer($fileReference);
41✔
75
        $crop = $fileReference->getProperty('crop');
41✔
76
        $link = $fileReference->getProperty('link');
41✔
77
        $linkData = null;
41✔
78

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

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

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

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

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

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

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

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

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

162
        $processedProperties = array_merge(
41✔
163
            $originalProperties,
41✔
164
            $processedProperties
41✔
165
        );
41✔
166

167
        if ($processingConfiguration->propertiesByType) {
41✔
168
            $processedProperties = $this->filterProperties($processingConfiguration, $processedProperties);
1✔
169
        }
170

171
        $event = $this->eventDispatcher->dispatch(
41✔
172
            new EnrichFileDataEvent(
41✔
173
                $originalFileReference,
41✔
174
                $fileReference,
41✔
175
                $processingConfiguration,
41✔
176
                $processedProperties
41✔
177
            )
41✔
178
        );
41✔
179

180
        $processedProperties = $event->getProperties();
41✔
181

182
        if ($processingConfiguration->includeProperties !== []) {
41✔
183
            $processedProperties = $this->onDemandProperties($processingConfiguration, $processedProperties);
3✔
184
        }
185

186
        $cacheBuster = '';
41✔
187

188
        if (($this->features->isFeatureEnabled('headless.assetsCacheBusting') || $processingConfiguration->cacheBusting) &&
41✔
189
            !in_array($fileReference->getMimeType(), ['video/youtube', 'video/vimeo'], true)) {
41✔
190
            $modified = $event->getProcessed()->getProperty('modification_date');
4✔
191

192
            if (!$modified) {
4✔
193
                $modified = $event->getProcessed()->getProperty('tstamp');
2✔
194
            }
195

196
            $cacheBuster = '?' . $modified;
4✔
197
        }
198

199
        $processedFile = [($processingConfiguration->legacyReturn ? 'publicUrl' : 'url') => $publicUrl . $cacheBuster];
41✔
200

201
        if ($processingConfiguration->legacyReturn && !isset($processedProperties['properties'])) {
41✔
202
            $processedProperties = ['properties' => $processedProperties];
21✔
203
        }
204

205
        $processedFile = array_merge($processedFile, $processedProperties);
41✔
206

207
        if ($processingConfiguration->autogenerate !== []) {
41✔
208
            $processedFile = $this->processAutogenerate(
10✔
209
                $originalFileReference,
10✔
210
                $fileReference,
10✔
211
                $processedFile,
10✔
212
                $processingConfiguration
10✔
213
            );
10✔
214
        }
215

216
        return $processedFile;
41✔
217
    }
218

219
    /**
220
     * @param array<string, mixed> $properties
221
     * @return array<string, mixed>
222
     */
223
    private function onDemandProperties(ProcessingConfiguration $processingConfiguration, array $properties): array
224
    {
225
        $props = [];
3✔
226

227
        foreach ($processingConfiguration->includeProperties as $prop) {
3✔
228
            if ($prop === 'publicUrl') {
3✔
229
                continue;
1✔
230
            }
231

232
            $propName = $prop;
3✔
233

234
            if (str_contains($prop, ' as ')) {
3✔
235
                $parts = GeneralUtility::trimExplode(' as ', $prop, true);
2✔
236
                $prop = $parts[0];
2✔
237
                $propName = $parts[1] ?? $prop;
2✔
238
            }
239

240
            if (in_array($prop, ['width', 'height'], true)) {
3✔
241
                $value = $properties['dimensions'][$prop] ?? 0;
3✔
242

243
                if ($processingConfiguration->flattenProperties) {
3✔
244
                    $props[$propName] = $value;
2✔
245
                } else {
246
                    $props['dimensions'][$propName] = $value;
2✔
247
                }
248
            } else {
249
                $props[$propName] = $properties[$prop] ?? null;
2✔
250
            }
251
        }
252

253
        return $props;
3✔
254
    }
255

256
    /**
257
     * @param array<string, mixed> $properties
258
     * @return array<string, mixed>
259
     */
260
    private function filterProperties(ProcessingConfiguration $processingConfiguration, array $properties): array
261
    {
262
        $allowedDefault = $processingConfiguration->defaultFieldsByType !== [] ? $processingConfiguration->defaultFieldsByType : [
1✔
263
            'type',
1✔
264
            'size',
1✔
265
            'title',
1✔
266
            'alternative',
1✔
267
            'description',
1✔
268
            'uidLocal',
1✔
269
            'fileReferenceUid',
1✔
270
            'mimeType',
1✔
271
        ];
1✔
272
        $allowedForImages = array_merge(
1✔
273
            $allowedDefault,
1✔
274
            $processingConfiguration->defaultImageFields !== [] ? $processingConfiguration->defaultImageFields : [
1✔
275
                'dimensions',
1✔
276
                'link',
1✔
277
                'linkData',
1✔
278
            ]
1✔
279
        );
1✔
280
        $allowedForVideo = array_merge(
1✔
281
            $allowedDefault,
1✔
282
            $processingConfiguration->defaultVideoFields !== [] ? $processingConfiguration->defaultVideoFields : [
1✔
283
                'dimensions',
1✔
284
                'autoplay',
1✔
285
                'originalUrl',
1✔
286
            ]
1✔
287
        );
1✔
288

289
        $allowed = match ($properties['type']) {
1✔
290
            'video' => $allowedForVideo,
×
291
            'image' => $allowedForImages,
1✔
292
            default => $allowedDefault,
×
293
        };
1✔
294

295
        $filtered = [];
1✔
296
        foreach (array_keys($properties) as $property) {
1✔
297
            if (in_array($property, $allowed, true) && array_key_exists($property, $properties)) {
1✔
298
                $filtered[$property] = $properties[$property];
1✔
299
            }
300
        }
301

302
        return $filtered;
1✔
303
    }
304

305
    public function processImageFile(
306
        FileInterface $fileReference,
307
        ProcessingConfiguration $processingConfiguration
308
    ): ?ProcessedFile {
309
        try {
310
            $cropVariantCollection = $this->createCropVariant((string)$fileReference->getProperty('crop'));
18✔
311
            $cropArea = $cropVariantCollection->getCropArea($processingConfiguration->cropVariant);
18✔
312

313
            $instructions = [
18✔
314
                'width' => $processingConfiguration->width !== '' ? $processingConfiguration->width : null,
18✔
315
                'height' => $processingConfiguration->height !== '' ? $processingConfiguration->height : null ,
18✔
316
                'minWidth' => $processingConfiguration->minWidth > 0 ? $processingConfiguration->minWidth : null,
18✔
317
                'minHeight' => $processingConfiguration->minHeight > 0 ? $processingConfiguration->minHeight : null,
18✔
318
                'maxWidth' => $processingConfiguration->maxWidth > 0 ? $processingConfiguration->maxWidth : null,
18✔
319
                'maxHeight' => $processingConfiguration->maxHeight > 0 ? $processingConfiguration->maxHeight : null,
18✔
320
                'crop' => $cropArea->isEmpty() ? null : $cropArea->makeAbsoluteBasedOnFile($fileReference),
18✔
321
            ];
18✔
322

323
            if ($processingConfiguration->fileExtension) {
18✔
324
                $instructions['fileExtension'] = $processingConfiguration->fileExtension;
2✔
325
            }
326

327
            return $this->imageService->applyProcessingInstructions($fileReference, $instructions);
18✔
328
        } catch (UnexpectedValueException|RuntimeException|InvalidArgumentException $e) {
1✔
329
            $type = lcfirst($fileReference::class);
1✔
330
            $this->errors['processImageFile'][$type . '-' . $fileReference->getUid()] = $e::class;
1✔
331
            return null;
1✔
332
        }
333
    }
334

335
    public function getAbsoluteUrl(string $fileUrl): string
336
    {
337
        $siteUrl = $this->getNormalizedParams()->getSiteUrl();
2✔
338
        $sitePath = str_replace($this->getNormalizedParams()->getRequestHost(), '', $siteUrl);
2✔
339
        $absoluteUrl = trim($fileUrl);
2✔
340
        if (stripos($absoluteUrl, 'http') !== 0 && !str_starts_with($absoluteUrl, '//')) {
2✔
341
            $fileUrl = preg_replace('#^' . preg_quote($sitePath, '#') . '#', '', $fileUrl);
1✔
342
            $fileUrl = $siteUrl . $fileUrl;
1✔
343
        }
344

345
        return $fileUrl;
2✔
346
    }
347

348
    public function getErrors(): array
349
    {
UNCOV
350
        return $this->errors;
×
351
    }
352

353
    /**
354
     * When retrieving the height or width for a media file
355
     * a possible cropping needs to be taken into account.
356
     */
357
    protected function getCroppedDimensionalProperty(
358
        FileInterface $fileObject,
359
        string $dimensionalProperty,
360
        string $cropVariant = 'default'
361
    ): int {
362
        if (!$fileObject->hasProperty('crop') || empty($fileObject->getProperty('crop'))) {
41✔
363
            return (int)$fileObject->getProperty($dimensionalProperty);
38✔
364
        }
365

366
        $croppingConfiguration = $fileObject->getProperty('crop');
5✔
367
        $cropVariantCollection = $this->createCropVariant($croppingConfiguration);
5✔
368
        return (int)$cropVariantCollection->getCropArea($cropVariant)->makeAbsoluteBasedOnFile($fileObject)->asArray()[$dimensionalProperty];
5✔
369
    }
370

371
    protected function calculateKilobytesToFileSize(int $value): string
372
    {
373
        $units = $this->translate('viewhelper.format.bytes.units', 'fluid');
41✔
374
        $units = GeneralUtility::trimExplode(',', $units, true);
41✔
375
        $bytes = max($value, 0);
41✔
376
        $pow = floor(($bytes ? log($bytes) : 0) / log(1024));
41✔
377
        $pow = min($pow, count($units) - 1);
41✔
378
        $bytes /= 2 ** (10 * $pow);
41✔
379

380
        return number_format(round($bytes, 4 * 2)) . ' ' . $units[$pow];
41✔
381
    }
382

383
    protected function getNormalizedParams(): NormalizedParams
384
    {
385
        return $this->contentObjectRenderer->getRequest()->getAttribute('normalizedParams');
2✔
386
    }
387

388
    /** @var array<string, CropVariantCollection> */
389
    private array $cropVariantCache = [];
390

391
    protected function createCropVariant(string $cropString): CropVariantCollection
392
    {
393
        return $this->cropVariantCache[$cropString] ??= CropVariantCollection::create($cropString);
18✔
394
    }
395

396
    /**
397
     * @codeCoverageIgnore
398
     */
399
    protected function translate(string $key, string $extensionName): ?string
400
    {
401
        return LocalizationUtility::translate($key, $extensionName);
402
    }
403

404
    /**
405
     * @param array<string, mixed> $processedFile
406
     * @return array<string, mixed>
407
     */
408
    private function processAutogenerate(
409
        FileInterface $originalReference,
410
        FileInterface $fileReference,
411
        array $processedFile,
412
        ProcessingConfiguration $processingConfiguration
413
    ): array {
414
        $originalWidth = $this->getCroppedDimensionalProperty($originalReference, 'width', $processingConfiguration->cropVariant);
10✔
415
        $originalHeight = $this->getCroppedDimensionalProperty($originalReference, 'height', $processingConfiguration->cropVariant);
10✔
416
        $targetWidth = (int)($processingConfiguration->width !== '' ? $processingConfiguration->width : $fileReference->getProperty('width'));
10✔
417
        $targetHeight = (int)($processingConfiguration->height !== '' ? $processingConfiguration->height : $fileReference->getProperty('height'));
10✔
418

419
        if ($targetWidth || $targetHeight) {
10✔
420
            foreach ($processingConfiguration->autogenerate as $autogenerateKey => $conf) {
9✔
421
                $autogenerateKey = rtrim($autogenerateKey, '.');
9✔
422
                $factor = (float)($conf['factor'] ?? 1.0);
9✔
423

424
                $processedFile[$autogenerateKey] = $this->process(
9✔
425
                    $originalReference,
9✔
426
                    $processingConfiguration->withOptions(
9✔
427
                        [
9✔
428
                            'fileExtension' => $conf['fileExtension'] ?? null,
9✔
429
                            // multiply width/height by factor,
430
                            // but don't stretch image beyond its original dimensions!
431
                            'width' => min($targetWidth * $factor, $originalWidth),
9✔
432
                            'height' => min($targetHeight * $factor, $originalHeight),
9✔
433
                            'autogenerate.' => null,
9✔
434
                            'legacyReturn' => 0,
9✔
435
                        ]
9✔
436
                    )
9✔
437
                )['url'];
9✔
438
            }
439
        }
440

441
        return $processedFile;
10✔
442
    }
443

444
    public function processCropVariants(
445
        FileInterface $originalFileReference,
446
        ProcessingConfiguration $processingConfiguration,
447
        array $processedFile
448
    ): array {
449
        /**
450
         * @var string|null $crop
451
         */
452
        $crop = $originalFileReference->getProperty('crop');
23✔
453

454
        if ($crop !== null && $crop !== '') {
23✔
455
            if (!$processingConfiguration->legacyReturn) {
3✔
456
                unset($processedFile['crop'], $processedFile['properties']['crop']);
3✔
457
            }
458

459
            $cropVariants = json_decode($crop, true);
3✔
460

461
            $collection = $this->createCropVariant($crop);
3✔
462

463
            if (is_array($cropVariants) && count($cropVariants) > 1 && str_starts_with(
3✔
464
                $originalFileReference->getMimeType(),
3✔
465
                'image/'
3✔
466
            )) {
3✔
467
                foreach (array_keys($cropVariants) as $cropVariantName) {
3✔
468
                    if ($processingConfiguration->conditionalCropVariant && $collection->getCropArea($cropVariantName)->isEmpty()) {
3✔
469
                        continue;
2✔
470
                    }
471

472
                    $variantConfiguration = $processingConfiguration->withOptions(['cropVariant' => $cropVariantName]);
3✔
473
                    $file = $this->process($originalFileReference, $variantConfiguration);
3✔
474
                    $processedFile['cropVariants'][$cropVariantName] = $this->cropVariant(
3✔
475
                        $variantConfiguration,
3✔
476
                        $file,
3✔
477
                        $cropVariants[$cropVariantName]
3✔
478
                    );
3✔
479
                }
480
            }
481
        }
482

483
        return $this->eventDispatcher->dispatch(
23✔
484
            new FileDataAfterCropVariantProcessingEvent(
23✔
485
                $originalFileReference,
23✔
486
                $processingConfiguration,
23✔
487
                $processedFile
23✔
488
            )
23✔
489
        )->getProcessedFile();
23✔
490
    }
491

492
    /**
493
     * @param array<string, mixed> $file
494
     * @param array<string, mixed> $cropVariant
495
     * @return array<string, mixed>
496
     */
497
    private function cropVariant(
498
        ProcessingConfiguration $processingConfiguration,
499
        array $file,
500
        array $cropVariant = []
501
    ): array {
502
        $url = $processingConfiguration->legacyReturn ? $file['publicUrl'] : $file['url'];
3✔
503
        $urlKey = $processingConfiguration->legacyReturn ? 'publicUrl' : 'url';
3✔
504

505
        $path = '';
3✔
506

507
        if ($processingConfiguration->legacyReturn) {
3✔
508
            $path .= 'properties/';
×
509
        }
510

511
        if (!$processingConfiguration->flattenProperties) {
3✔
512
            $path .= 'dimensions/';
2✔
513
        }
514

515
        $additional = [];
3✔
516

517
        if ($processingConfiguration->outputCropArea) {
3✔
518
            $additional = ['crop' => $cropVariant];
1✔
519
        }
520

521
        try {
522
            $width = ArrayUtility::getValueByPath($file, $path . 'width');
3✔
523
            $height = ArrayUtility::getValueByPath($file, $path . 'height');
3✔
524
        } catch (Throwable) {
×
525
            $width = 0;
×
526
            $height = 0;
×
527
        }
528

529
        $dimensions = [
3✔
530
            'width' => $width,
3✔
531
            'height' => $height,
3✔
532
            ...$additional,
3✔
533
        ];
3✔
534

535
        if (!$processingConfiguration->legacyReturn && $processingConfiguration->flattenProperties) {
3✔
536
            return array_merge([$urlKey => $url], $dimensions);
1✔
537
        }
538

539
        $wrappedDimensions = $dimensions;
2✔
540

541
        if (!$processingConfiguration->flattenProperties) {
2✔
542
            $wrappedDimensions = ['dimensions' => $wrappedDimensions];
2✔
543
        }
544

545
        if ($processingConfiguration->legacyReturn) {
2✔
546
            $wrappedDimensions = ['properties' => $wrappedDimensions];
×
547
        }
548

549
        return [$urlKey => $url, ...$wrappedDimensions];
2✔
550
    }
551
}
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