1<?php 2/** 3 * Copyright (c) 2020. ComboStrap, Inc. and its affiliates. All Rights Reserved. 4 * 5 * This source code is licensed under the GPL license found in the 6 * COPYING file in the root directory of this source tree. 7 * 8 * @license GPL 3 (https://www.gnu.org/licenses/gpl-3.0.en.html) 9 * @author ComboStrap <support@combostrap.com> 10 * 11 */ 12 13namespace ComboStrap; 14 15use ComboStrap\TagAttribute\StyleAttribute; 16 17require_once(__DIR__ . '/MediaLink.php'); 18require_once(__DIR__ . '/LazyLoad.php'); 19require_once(__DIR__ . '/PluginUtility.php'); 20 21/** 22 * Image 23 * This is the class that handles the 24 * raster image type of the dokuwiki {@link MediaLink} 25 * 26 * The real documentation can be found on the image page 27 * @link https://www.dokuwiki.org/images 28 * 29 * Doc: 30 * https://web.dev/optimize-cls/#images-without-dimensions 31 * https://web.dev/cls/ 32 */ 33class RasterImageLink extends ImageLink 34{ 35 36 const CANONICAL = FetcherRaster::CANONICAL; 37 38 const RESPONSIVE_CLASS = "img-fluid"; 39 40 const CONF_RESPONSIVE_IMAGE_MARGIN = "responsiveImageMargin"; 41 const CONF_RETINA_SUPPORT_ENABLED = "retinaRasterImageEnable"; 42 43 private FetcherImage $fetchRaster; 44 45 46 /** 47 * @throws ExceptionBadArgument - if the fetcher is not a raster mime and image fetcher 48 */ 49 public function __construct(MediaMarkup $mediaMarkup) 50 { 51 $fetcher = $mediaMarkup->getFetcher(); 52 $mime = $fetcher->getMime(); 53 if (!$mime->isSupportedRasterImage()) { 54 throw new ExceptionBadArgument("The mime value ($mime) is not a supported raster image.", self::CANONICAL); 55 } 56 if (!($fetcher instanceof FetcherImage)) { 57 throw new ExceptionBadArgument("The fetcher is not a fetcher image but is a " . get_class($fetcher)); 58 } 59 $this->fetchRaster = $fetcher; 60 parent::__construct($mediaMarkup); 61 } 62 63 64 /** 65 * Render a link 66 * Snippet derived from {@link \Doku_Renderer_xhtml::internalmedia()} 67 * A media can be a video also (Use 68 * @return string 69 * @throws ExceptionNotFound 70 */ 71 public function renderMediaTag(): string 72 { 73 74 75 $fetchRaster = $this->fetchRaster; 76 77 $attributes = $this->mediaMarkup->getExtraMediaTagAttributes() 78 ->setLogicalTag(self::CANONICAL); 79 80 /** 81 * Responsive image 82 * https://getbootstrap.com/docs/5.0/content/images/ 83 * to apply max-width: 100%; and height: auto; 84 * 85 * Even if the resizing is requested by height, 86 * the height: auto on styling is needed to conserve the ratio 87 * while scaling down the screen 88 */ 89 $attributes->addClassName(self::RESPONSIVE_CLASS); 90 91 92 /** 93 * width and height to give the dimension ratio 94 * They have an effect on the space reservation 95 * but not on responsive image at all 96 * To allow responsive height, the height style property is set at auto 97 * (ie img-fluid in bootstrap) 98 */ 99 // The unit is not mandatory in HTML, this is expected to be CSS pixel 100 // https://html.spec.whatwg.org/multipage/embedded-content-other.html#attr-dim-height 101 // The HTML validator does not expect an unit otherwise it send an error 102 // https://validator.w3.org/ 103 $htmlLengthUnit = ""; 104 $cssLengthUnit = "px"; 105 106 /** 107 * Height 108 * The logical height that the image should take on the page 109 * 110 * Note: The style is also set in {@link Dimension::processWidthAndHeight()} 111 * 112 * Cannot be empty 113 */ 114 $targetHeight = $fetchRaster->getTargetHeight(); 115 116 /** 117 * HTML height attribute is important for the ratio calculation 118 * No layout shift 119 */ 120 $attributes->addOutputAttributeValue("height", $targetHeight . $htmlLengthUnit); 121 /** 122 * We don't allow the image to scale up by default 123 */ 124 $attributes->addStyleDeclarationIfNotSet("max-height", $targetHeight . $cssLengthUnit); 125 /** 126 * if the image has a class that has a `height: 100%`, the image will stretch 127 */ 128 $attributes->addStyleDeclarationIfNotSet("height", "auto"); 129 130 131 /** 132 * Responsive image src set building 133 * We have chosen 134 * * 375: Iphone6 135 * * 768: Ipad 136 * * 1024: Ipad Pro 137 * 138 */ 139 // The image margin applied 140 $imageMargin = SiteConfig::getConfValue(self::CONF_RESPONSIVE_IMAGE_MARGIN, "20px"); 141 try { 142 $imageMargin = ConditionalLength::createFromString($imageMargin)->toPixelNumber(); 143 } catch (ExceptionBadArgument $e) { 144 LogUtility::warning("The variable (" . self::CONF_RESPONSIVE_IMAGE_MARGIN . ") has a value ($imageMargin) that is not a valid length.", self::CANONICAL, $e); 145 $imageMargin = 20; 146 } 147 148 /** 149 * Srcset and sizes for responsive image 150 * Width is mandatory for responsive image 151 * Ref https://developers.google.com/search/docs/advanced/guidelines/google-images#responsive-images 152 */ 153 154 155 /** 156 * The value of the target image 157 */ 158 $targetWidth = $fetchRaster->getTargetWidth(); 159 $fetchRaster->checkLogicalRatioAgainstTargetRatio($targetWidth, $targetHeight); 160 161 /** 162 * HTML Width attribute is important to avoid layout shift 163 */ 164 $attributes->addOutputAttributeValue("width", $targetWidth . $htmlLengthUnit); 165 /** 166 * We don't allow the image to scale up by default 167 */ 168 $attributes->addStyleDeclarationIfNotSet("max-width", $targetWidth . $cssLengthUnit); 169 /** 170 * We allow the image to scale down up to 100% of its parent 171 */ 172 $attributes->addStyleDeclarationIfNotSet("width", "100%"); 173 174 175 /** 176 * Continue 177 */ 178 $srcSet = ""; 179 $sizes = ""; 180 181 /** 182 * Width 183 * 184 * We create a series of URL 185 * for different width and let the browser 186 * download the best one for: 187 * * the actual container width 188 * * the actual of screen resolution 189 * * and the connection speed. 190 * 191 * The max-width value is set 192 */ 193 $srcValue = $fetchRaster->getFetchUrl(); 194 /** 195 * Add samller breakpoints sizes 196 */ 197 $intrinsicWidth = $fetchRaster->getIntrinsicWidth(); 198 foreach (Breakpoint::getBreakpoints() as $breakpoint) { 199 200 try { 201 $breakpointPixels = $breakpoint->getWidth(); 202 } catch (ExceptionInfinite $e) { 203 continue; 204 } 205 206 if ($breakpointPixels > $targetWidth) { 207 continue; 208 } 209 210 if ($breakpointPixels > $intrinsicWidth) { 211 continue; 212 } 213 214 if (!empty($srcSet)) { 215 $srcSet .= ", "; 216 $sizes .= ", "; 217 } 218 $breakpointWidthMinusMargin = $breakpointPixels - $imageMargin; 219 220 221 $breakpointRaster = clone $fetchRaster; 222 if ( 223 !$fetchRaster->hasHeightRequested() // breakpoint url needs only the h attribute in this case 224 || $fetchRaster->hasAspectRatioRequested() // width and height are mandatory 225 ) { 226 $breakpointRaster->setRequestedWidth($breakpointWidthMinusMargin); 227 } 228 if ($fetchRaster->hasHeightRequested() // if this is a height request 229 || $fetchRaster->hasAspectRatioRequested() // width and height are mandatory 230 ) { 231 $breakPointHeight = FetcherRaster::round($breakpointWidthMinusMargin / $fetchRaster->getTargetAspectRatio()); 232 $breakpointRaster->setRequestedHeight($breakPointHeight); 233 } 234 235 $breakpointUrl = $breakpointRaster->getFetchUrl()->toString(); 236 237 238 $srcSet .= "$breakpointUrl {$breakpointWidthMinusMargin}w"; 239 $sizes .= $this->getSizes($breakpointPixels, $breakpointWidthMinusMargin); 240 241 242 } 243 244 /** 245 * Add the last size 246 * If the target image is really small, srcset and sizes are empty 247 */ 248 if (!empty($srcSet)) { 249 $srcSet .= ", "; 250 $sizes .= ", "; 251 $srcUrl = $fetchRaster->getFetchUrl()->toString(); 252 $srcSet .= "$srcUrl {$targetWidth}w"; 253 $sizes .= "{$targetWidth}px"; 254 } 255 256 /** 257 * Lazy load 258 */ 259 $lazyLoad = $this->getLazyLoad(); 260 if ($lazyLoad) { 261 262 /** 263 * Html Lazy loading 264 */ 265 $lazyLoadMethod = $this->mediaMarkup->getLazyLoadMethodOrDefault(); 266 switch ($lazyLoadMethod) { 267 case LazyLoad::LAZY_LOAD_METHOD_HTML_VALUE: 268 default: 269 $attributes->addOutputAttributeValue("src", $srcValue); 270 if (!empty($srcSet)) { 271 // it the image is small, no srcset for instance 272 $attributes->addOutputAttributeValue("srcset", $srcSet); 273 } 274 $attributes->addOutputAttributeValue("loading", "lazy"); 275 break; 276 case LazyLoad::LAZY_LOAD_METHOD_LOZAD_VALUE: 277 /** 278 * Snippet Lazy loading 279 */ 280 LazyLoad::addLozadSnippet(); 281 PluginUtility::getSnippetManager()->attachJavascriptFromComponentId("lozad-raster"); 282 $attributes->addClassName(self::getLazyClass()); 283 $attributes->addClassName(LazyLoad::getLazyClass()); 284 285 /** 286 * A small image has no srcset 287 * 288 */ 289 if (!empty($srcSet)) { 290 291 /** 292 * !!!!! DON'T FOLLOW THIS ADVICE !!!!!!!!! 293 * https://github.com/aFarkas/lazysizes/#modern-transparent-srcset-pattern 294 * The transparent image has a fix dimension aspect ratio of 1x1 making 295 * a bad reserved space for the image 296 * We use a svg instead 297 */ 298 $attributes->addOutputAttributeValue("src", $srcValue); 299 $attributes->addOutputAttributeValue("srcset", LazyLoad::getPlaceholder($targetWidth, $targetHeight)); 300 /** 301 * We use `data-sizes` and not `sizes` 302 * because `sizes` without `srcset` 303 * shows the broken image symbol 304 * Javascript changes them at the same time 305 */ 306 $attributes->addOutputAttributeValue("data-sizes", $sizes); 307 $attributes->addOutputAttributeValue("data-srcset", $srcSet); 308 309 } else { 310 311 /** 312 * Small image but there is no little improvement 313 */ 314 $attributes->addOutputAttributeValue("data-src", $srcValue); 315 $attributes->addOutputAttributeValue("src", LazyLoad::getPlaceholder($targetWidth, $targetHeight)); 316 317 } 318 LazyLoad::addPlaceholderBackground($attributes); 319 break; 320 } 321 322 323 } else { 324 325 if (!empty($srcSet)) { 326 $attributes->addOutputAttributeValue("srcset", $srcSet); 327 $attributes->addOutputAttributeValue("sizes", $sizes); 328 } else { 329 $attributes->addOutputAttributeValue("src", $srcValue); 330 } 331 332 } 333 334 335 /** 336 * Title (ie alt) 337 */ 338 $attributes->addOutputAttributeValueIfNotEmpty("alt", $this->getAltNotEmpty()); 339 340 /** 341 * Create the img element 342 */ 343 $htmlAttributes = $attributes->toHTMLAttributeString(); 344 $imgHTML = '<img ' . $htmlAttributes . '/>'; 345 346 347 return $this->wrapMediaMarkupWithLink($imgHTML); 348 } 349 350 351 public function getLazyLoad(): bool 352 { 353 354 if ($this->mediaMarkup->isLazy() === false) { 355 return false; 356 } 357 return SiteConfig::getConfValue(LazyLoad::CONF_RASTER_ENABLE, LazyLoad::CONF_RASTER_ENABLE_DEFAULT); 358 359 } 360 361 /** 362 * @param $screenWidth 363 * @param $imageWidth 364 * @return string sizes with a dpi correction if 365 */ 366 private 367 function getSizes($screenWidth, $imageWidth): string 368 { 369 370 if ($this->getWithDpiCorrection()) { 371 $dpiBase = 96; 372 $sizes = "(max-width: {$screenWidth}px) and (min-resolution:" . (3 * $dpiBase) . "dpi) " . intval($imageWidth / 3) . "px"; 373 $sizes .= ", (max-width: {$screenWidth}px) and (min-resolution:" . (2 * $dpiBase) . "dpi) " . intval($imageWidth / 2) . "px"; 374 $sizes .= ", (max-width: {$screenWidth}px) and (min-resolution:" . (1 * $dpiBase) . "dpi) {$imageWidth}px"; 375 } else { 376 $sizes = "(max-width: {$screenWidth}px) {$imageWidth}px"; 377 } 378 return $sizes; 379 } 380 381 /** 382 * Return if the DPI correction is enabled or not for responsive image 383 * 384 * Mobile have a higher DPI and can then fit a bigger image on a smaller size. 385 * 386 * This can be disturbing when debugging responsive sizing image 387 * If you want also to use less bandwidth, this is also useful. 388 * 389 * @return bool 390 */ 391 private 392 function getWithDpiCorrection(): bool 393 { 394 /** 395 * Support for retina means no DPI correction 396 */ 397 $retinaEnabled = SiteConfig::getConfValue(self::CONF_RETINA_SUPPORT_ENABLED, 0); 398 return !$retinaEnabled; 399 } 400 401 /** 402 * Used to select the raster image lazy loaded 403 * @return string 404 */ 405 public static function getLazyClass() 406 { 407 return StyleAttribute::addComboStrapSuffix("lazy-raster"); 408 } 409 410 411} 412