vendor/contao/core-bundle/src/Image/Studio/Figure.php line 306

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. /*
  4. * This file is part of Contao.
  5. *
  6. * (c) Leo Feyer
  7. *
  8. * @license LGPL-3.0-or-later
  9. */
  10. namespace Contao\CoreBundle\Image\Studio;
  11. use Contao\Controller;
  12. use Contao\CoreBundle\File\Metadata;
  13. use Contao\File;
  14. use Contao\StringUtil;
  15. use Contao\Template;
  16. /**
  17. * A Figure object holds image and metadata ready to be applied to a
  18. * template's context. If you are using the legacy PHP templates, you can still
  19. * use the provided legacy helper methods to manually apply the data to them.
  20. *
  21. * Wherever possible, the actual data is only requested/built on demand.
  22. *
  23. * @final This class will be made final in Contao 5.
  24. */
  25. class Figure
  26. {
  27. private ImageResult $image;
  28. /**
  29. * @var Metadata|(\Closure(self):Metadata|null)|null
  30. */
  31. private $metadata;
  32. /**
  33. * @var array<string, string|null>|(\Closure(self):array<string, string|null>)|null
  34. */
  35. private $linkAttributes;
  36. /**
  37. * @var LightboxResult|(\Closure(self):LightboxResult|null)|null
  38. */
  39. private $lightbox;
  40. /**
  41. * @var array<string, mixed>|(\Closure(self):array<string, mixed>)|null
  42. */
  43. private $options;
  44. /**
  45. * Creates a figure container.
  46. *
  47. * All arguments but the main image result can also be set via a Closure
  48. * that only returns the value on demand.
  49. *
  50. * @param Metadata|(\Closure(self):Metadata|null)|null $metadata Metadata container
  51. * @param array<string, string|null>|(\Closure(self):array<string, string|null>)|null $linkAttributes Link attributes
  52. * @param LightboxResult|(\Closure(self):LightboxResult|null)|null $lightbox Lightbox
  53. * @param array<string, mixed>|(\Closure(self):array<string, mixed>)|null $options Template options
  54. */
  55. public function __construct(ImageResult $image, $metadata = null, $linkAttributes = null, $lightbox = null, $options = null)
  56. {
  57. $this->image = $image;
  58. $this->metadata = $metadata;
  59. $this->linkAttributes = $linkAttributes;
  60. $this->lightbox = $lightbox;
  61. $this->options = $options;
  62. }
  63. /**
  64. * Returns the image result of the main resource.
  65. */
  66. public function getImage(): ImageResult
  67. {
  68. return $this->image;
  69. }
  70. /**
  71. * Returns true if a lightbox result can be obtained.
  72. */
  73. public function hasLightbox(): bool
  74. {
  75. $this->resolveIfClosure($this->lightbox);
  76. return $this->lightbox instanceof LightboxResult;
  77. }
  78. /**
  79. * Returns the lightbox result (if available).
  80. */
  81. public function getLightbox(): LightboxResult
  82. {
  83. if (!$this->hasLightbox()) {
  84. throw new \LogicException('This result container does not include a lightbox.');
  85. }
  86. /** @var LightboxResult */
  87. return $this->lightbox;
  88. }
  89. public function hasMetadata(): bool
  90. {
  91. $this->resolveIfClosure($this->metadata);
  92. return $this->metadata instanceof Metadata;
  93. }
  94. /**
  95. * Returns the main resource's metadata.
  96. */
  97. public function getMetadata(): Metadata
  98. {
  99. if (!$this->hasMetadata()) {
  100. throw new \LogicException('This result container does not include metadata.');
  101. }
  102. /** @var Metadata */
  103. return $this->metadata;
  104. }
  105. public function getSchemaOrgData(): array
  106. {
  107. $imageIdentifier = $this->getImage()->getImageSrc();
  108. if ($this->hasMetadata() && $this->getMetadata()->has(Metadata::VALUE_UUID)) {
  109. $imageIdentifier = '#/schema/image/'.$this->getMetadata()->getUuid();
  110. }
  111. $imageSrc = $this->getImage()->getImageSrc();
  112. // Workaround for Contao 4.13 only (see #6388)
  113. if ('' !== $imageSrc && '/' !== $imageSrc[0] && !preg_match('/^https?:/i', $imageSrc)) {
  114. $imageSrc = '/'.$imageSrc;
  115. }
  116. $jsonLd = [
  117. '@type' => 'ImageObject',
  118. 'identifier' => $imageIdentifier,
  119. 'contentUrl' => $imageSrc,
  120. ];
  121. if (!$this->hasMetadata()) {
  122. ksort($jsonLd);
  123. return $jsonLd;
  124. }
  125. $jsonLd = array_merge($this->getMetadata()->getSchemaOrgData('ImageObject'), $jsonLd);
  126. ksort($jsonLd);
  127. return $jsonLd;
  128. }
  129. /**
  130. * Returns a key-value list of all link attributes. This excludes "href" by
  131. * default.
  132. */
  133. public function getLinkAttributes(bool $includeHref = false): array
  134. {
  135. $this->resolveIfClosure($this->linkAttributes);
  136. if (null === $this->linkAttributes) {
  137. $this->linkAttributes = [];
  138. }
  139. // Generate the href attribute
  140. if (!\array_key_exists('href', $this->linkAttributes)) {
  141. $this->linkAttributes['href'] = (
  142. function () {
  143. if ($this->hasLightbox()) {
  144. return $this->getLightbox()->getLinkHref();
  145. }
  146. if ($this->hasMetadata()) {
  147. return $this->getMetadata()->getUrl();
  148. }
  149. return '';
  150. }
  151. )();
  152. }
  153. // Add rel attribute "noreferrer noopener" to external links
  154. if (
  155. !empty($this->linkAttributes['href'])
  156. && !\array_key_exists('rel', $this->linkAttributes)
  157. && preg_match('#^https?://#', $this->linkAttributes['href'])
  158. ) {
  159. $this->linkAttributes['rel'] = 'noreferrer noopener';
  160. }
  161. // Add lightbox attributes
  162. if (!\array_key_exists('data-lightbox', $this->linkAttributes) && $this->hasLightbox()) {
  163. $lightbox = $this->getLightbox();
  164. $this->linkAttributes['data-lightbox'] = $lightbox->getGroupIdentifier();
  165. }
  166. // Allow removing attributes by setting them to null
  167. $linkAttributes = array_filter($this->linkAttributes, static fn ($attribute): bool => null !== $attribute);
  168. // Optionally strip the href attribute
  169. return $includeHref ? $linkAttributes : array_diff_key($linkAttributes, ['href' => null]);
  170. }
  171. /**
  172. * Returns the "href" link attribute.
  173. */
  174. public function getLinkHref(): string
  175. {
  176. return $this->getLinkAttributes(true)['href'] ?? '';
  177. }
  178. /**
  179. * Returns a key-value list of template options.
  180. */
  181. public function getOptions(): array
  182. {
  183. $this->resolveIfClosure($this->options);
  184. return $this->options ?? [];
  185. }
  186. /**
  187. * Compiles an opinionated data set to be applied to a Contao template.
  188. *
  189. * Note: Do not use this method when building new templates from scratch or
  190. * when using Twig templates! Instead, add this object to your
  191. * template's context and directly access the specific data you need.
  192. *
  193. * @param string|array|null $margin Set margins that will compose the inline CSS for the "margin" key
  194. * @param string|null $floating Set/determine values for the "float_class" and "addBefore" keys
  195. * @param bool $includeFullMetadata Make all metadata available in the first dimension of the returned data set (key-value pairs)
  196. */
  197. public function getLegacyTemplateData($margin = null, ?string $floating = null, bool $includeFullMetadata = true): array
  198. {
  199. // Create a key-value list of the metadata and apply some renaming and
  200. // formatting transformations to fit the legacy templates.
  201. $createLegacyMetadataMapping = static function (Metadata $metadata): array {
  202. if ($metadata->empty()) {
  203. return [];
  204. }
  205. $mapping = $metadata->all();
  206. // Handle special chars
  207. foreach ([Metadata::VALUE_ALT, Metadata::VALUE_TITLE] as $key) {
  208. if (isset($mapping[$key])) {
  209. $mapping[$key] = StringUtil::specialchars($mapping[$key]);
  210. }
  211. }
  212. // Rename certain keys (as used in the Contao templates)
  213. if (isset($mapping[Metadata::VALUE_TITLE])) {
  214. $mapping['imageTitle'] = $mapping[Metadata::VALUE_TITLE];
  215. }
  216. if (isset($mapping[Metadata::VALUE_URL])) {
  217. $mapping['imageUrl'] = $mapping[Metadata::VALUE_URL];
  218. }
  219. unset($mapping[Metadata::VALUE_TITLE], $mapping[Metadata::VALUE_URL]);
  220. return $mapping;
  221. };
  222. // Create a CSS margin property from an array or serialized string
  223. $createMargin = static function ($margin): string {
  224. if (!$margin) {
  225. return '';
  226. }
  227. $values = array_merge(
  228. ['top' => '', 'right' => '', 'bottom' => '', 'left' => '', 'unit' => ''],
  229. StringUtil::deserialize($margin, true)
  230. );
  231. return Controller::generateMargin($values);
  232. };
  233. $image = $this->getImage();
  234. $originalSize = $image->getOriginalDimensions()->getSize();
  235. $fileInfoImageSize = (new File($image->getImageSrc(true)))->imageSize;
  236. $linkAttributes = $this->getLinkAttributes();
  237. $metadata = $this->hasMetadata() ? $this->getMetadata() : new Metadata([]);
  238. // Primary image and metadata
  239. $templateData = array_merge(
  240. [
  241. 'picture' => [
  242. 'img' => $image->getImg(),
  243. 'sources' => $image->getSources(),
  244. 'alt' => StringUtil::specialchars($metadata->getAlt()),
  245. ],
  246. 'width' => $originalSize->getWidth(),
  247. 'height' => $originalSize->getHeight(),
  248. 'arrSize' => $fileInfoImageSize,
  249. 'imgSize' => !empty($fileInfoImageSize) ? sprintf(' width="%d" height="%d"', $fileInfoImageSize[0], $fileInfoImageSize[1]) : '',
  250. 'singleSRC' => $image->getFilePath(),
  251. 'src' => $image->getImageSrc(),
  252. 'fullsize' => ('_blank' === ($linkAttributes['target'] ?? null)) || $this->hasLightbox(),
  253. 'margin' => $createMargin($margin),
  254. 'addBefore' => 'below' !== $floating,
  255. 'addImage' => true,
  256. ],
  257. $includeFullMetadata ? $createLegacyMetadataMapping($metadata) : []
  258. );
  259. // Link attributes and title
  260. if ('' !== ($href = $this->getLinkHref())) {
  261. $templateData['href'] = $href;
  262. $templateData['attributes'] = ''; // always define attributes key if href is set
  263. // Use link "title" attribute for "linkTitle" as it is already output explicitly in image.html5 (see #3385)
  264. if (\array_key_exists('title', $linkAttributes)) {
  265. $templateData['linkTitle'] = $linkAttributes['title'];
  266. unset($linkAttributes['title']);
  267. } else {
  268. // Map "imageTitle" to "linkTitle"
  269. $templateData['linkTitle'] = ($templateData['imageTitle'] ?? null) ?? StringUtil::specialchars($metadata->getTitle());
  270. unset($templateData['imageTitle']);
  271. }
  272. } elseif ($metadata->has(Metadata::VALUE_TITLE)) {
  273. $templateData['picture']['title'] = StringUtil::specialchars($metadata->getTitle());
  274. }
  275. if (!empty($linkAttributes)) {
  276. $htmlAttributes = array_map(
  277. static fn (string $attribute, string $value) => sprintf('%s="%s"', $attribute, $value),
  278. array_keys($linkAttributes),
  279. $linkAttributes
  280. );
  281. $templateData['attributes'] = ' '.implode(' ', $htmlAttributes);
  282. }
  283. // Lightbox
  284. if ($this->hasLightbox()) {
  285. $lightbox = $this->getLightbox();
  286. if ($lightbox->hasImage()) {
  287. $lightboxImage = $lightbox->getImage();
  288. $templateData['lightboxPicture'] = [
  289. 'img' => $lightboxImage->getImg(),
  290. 'sources' => $lightboxImage->getSources(),
  291. ];
  292. }
  293. }
  294. // Other
  295. if ($floating) {
  296. $templateData['floatClass'] = " float_$floating";
  297. }
  298. if (isset($this->getOptions()['attr']['class'])) {
  299. $templateData['floatClass'] = ($templateData['floatClass'] ?? '').' '.$this->getOptions()['attr']['class'];
  300. }
  301. // Add arbitrary template options
  302. return array_merge($templateData, $this->getOptions());
  303. }
  304. /**
  305. * Applies the legacy template data to an existing template. This will
  306. * prevent overriding the "href" property if already present and use
  307. * "imageHref" instead.
  308. *
  309. * Note: Do not use this method when building new templates from scratch or
  310. * when using Twig templates! Instead, add this object to your
  311. * template's context and directly access the specific data you need.
  312. *
  313. * @param Template|object $template The template to apply the data to
  314. * @param string|array|null $margin Set margins that will compose the inline CSS for the template's "margin" property
  315. * @param string|null $floating Set/determine values for the template's "float_class" and "addBefore" properties
  316. * @param bool $includeFullMetadata Make all metadata entries directly available in the template
  317. */
  318. public function applyLegacyTemplateData(object $template, $margin = null, ?string $floating = null, bool $includeFullMetadata = true): void
  319. {
  320. $new = $this->getLegacyTemplateData($margin, $floating, $includeFullMetadata);
  321. $existing = $template instanceof Template ? $template->getData() : get_object_vars($template);
  322. // Do not override the "href" key (see #6468)
  323. if (isset($new['href'], $existing['href'])) {
  324. $new['imageHref'] = $new['href'];
  325. unset($new['href']);
  326. }
  327. // Allow accessing Figure methods in a legacy template context
  328. $new['figure'] = $this;
  329. // Apply data
  330. if ($template instanceof Template) {
  331. $template->setData(array_replace($existing, $new));
  332. return;
  333. }
  334. foreach ($new as $key => $value) {
  335. $template->$key = $value;
  336. }
  337. }
  338. /**
  339. * Evaluates closures to retrieve the value.
  340. *
  341. * @param mixed $property
  342. */
  343. private function resolveIfClosure(&$property): void
  344. {
  345. if ($property instanceof \Closure) {
  346. $property = $property($this);
  347. }
  348. }
  349. }