Files
VITEC-website/packages/vitec/Classes/UserFunc/ContainerBackgroundRenderer.php
2026-08-14 10:59:22 +02:00

155 lines
5.9 KiB
PHP

<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Evomedien\Vitec\Service\UsecaseSerializer;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/**
* UserFunc emitting the background block of a VITEC container.
*
* The container elements are rendered entirely in TypoScript
* (Configuration/TypoScript/Headless/vitec_containers.typoscript), where plain
* fields are TEXT objects. A FAL image is not a plain field, so it comes through
* here — and going through `UsecaseSerializer::image()` rather than the headless
* FilesProcessor is deliberate: Clause 9.2 puts image serialisation in one place,
* so a background image has the same `{url, srcset, properties}` shape as every
* other image in this JSON. One shape for the front end instead of two.
*
* Emits nothing at all when no image is set — the whole `background` key then
* stays out of the payload, which is a clearer signal than an object with a null
* image and two orphaned CSS settings.
*
* `size` and `position` are stored as CSS-ready values (`cover`, `left top`, …).
* The single exception is `stretch`, which has no CSS keyword and the front end
* maps to `100% 100%`; see the TCA comment in tt_content_vitec_shared.php.
*
* Fail-soft per Clause 9.5: any error yields an empty string, never an exception
* that would take the surrounding content element down with it.
*/
class ContainerBackgroundRenderer
{
private ?ContentObjectRenderer $cObj = null;
/**
* TYPO3 v14 no longer assigns `$classObj->cObj` dynamically. The renderer is
* handed over through this setter, and only when the method exists at all -
* ContentObjectRenderer::callUserFunction() duck-types it with
* `is_callable([$classObj, 'setContentObjectRenderer'])`. Without the method
* `$this->cObj` stays null and the userFunc never sees its own record.
*/
public function setContentObjectRenderer(ContentObjectRenderer $cObj): void
{
$this->cObj = $cObj;
}
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
try {
$row = is_array($this->cObj?->data ?? null) ? $this->cObj->data : null;
if ($row === null) {
return '';
}
$uid = (int)($row['uid'] ?? 0);
if ($uid <= 0) {
return '';
}
// No shortcut on the tx_vitec_bg_image counter: sys_file_reference is
// the single source of truth, and a counter that is stale or missing
// (schema not updated yet) would silently swallow a set image. One
// indexed query per container is cheaper than that class of bug.
$image = GeneralUtility::makeInstance(UsecaseSerializer::class)
->image($uid, 'tx_vitec_bg_image', 'tt_content');
if ($image === null) {
return '';
}
return (string)json_encode([
'image' => $image,
'size' => $this->resolveSize($row),
'position' => $this->resolvePosition($row),
]);
} catch (\Throwable $e) {
return '';
}
}
/**
* The stored size, resolved into something the front end can hand straight
* to CSS. `custom` is the only value that needs a second field; turning it
* into "80%" here keeps the promise that `size` is always usable as-is,
* instead of making every consumer look up a companion field.
*
* A custom size without a usable percentage falls back to `auto` - the CSS
* default - rather than emitting "0%" or the meaningless word "custom".
*
* @param array<string,mixed> $row
*/
private function resolveSize(array $row): string
{
$size = (string)($row['tx_vitec_bg_size'] ?? 'cover');
if ($size !== 'custom') {
return $size;
}
$percent = (int)($row['tx_vitec_bg_size_percent'] ?? 0);
return $percent > 0 ? $percent . '%' : 'auto';
}
/**
* The stored position, again resolved into one CSS-ready value.
*
* The four custom fields are edge offsets, but CSS cannot take a top AND a
* bottom offset at once, so they are folded into the percentage form. That
* form says exactly the same thing: in `background-position` a percentage
* aligns the same point of image and container, so "10% from the right edge"
* IS `90%`. Nothing is lost in the translation.
*
* Per axis the near edge wins when both are filled - `left` over `right`,
* `top` over `bottom` - because two opposing offsets contradict each other
* and silently picking one beats emitting something invalid.
*
* @param array<string,mixed> $row
*/
private function resolvePosition(array $row): string
{
$position = (string)($row['tx_vitec_bg_position'] ?? 'center center');
if ($position !== 'custom') {
return $position;
}
$x = $this->axisPercent($row, 'tx_vitec_bg_pos_left', 'tx_vitec_bg_pos_right');
$y = $this->axisPercent($row, 'tx_vitec_bg_pos_top', 'tx_vitec_bg_pos_bottom');
return $x . '% ' . $y . '%';
}
/**
* One axis as a percentage. An empty field means "centred on this axis" -
* which is why the columns are nullable: 0 is a legitimate offset (flush
* against that edge) and has to stay distinguishable from "not set".
*
* @param array<string,mixed> $row
*/
private function axisPercent(array $row, string $nearField, string $farField): int
{
$near = $row[$nearField] ?? null;
if ($near !== null && $near !== '') {
return (int)$near;
}
$far = $row[$farField] ?? null;
if ($far !== null && $far !== '') {
return 100 - (int)$far;
}
return 50;
}
}