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 142 143 /** 144 * Srcset and sizes for responsive image 145 * Width is mandatory for responsive image 146 * Ref https://developers.google.com/search/docs/advanced/guidelines/google-images#responsive-images 147 */ 148 149 150 /** 151 * The value of the target image 152 */ 153 $targetWidth = $fetchRaster->getTargetWidth(); 154 $fetchRaster->checkLogicalRatioAgainstTargetRatio($targetWidth, $targetHeight); 155 156 /** 157 * HTML Width attribute is important to avoid layout shift 158 */ 159 $attributes->addOutputAttributeValue("width", $targetWidth . $htmlLengthUnit); 160 /** 161 * We don't allow the image to scale up by default 162 */ 163 $attributes->addStyleDeclarationIfNotSet("max-width", $targetWidth . $cssLengthUnit); 164 /** 165 * We allow the image to scale down up to 100% of its parent 166 */ 167 $attributes->addStyleDeclarationIfNotSet("width", "100%"); 168 169 170 /** 171 * Continue 172 */ 173 $srcSet = ""; 174 $sizes = ""; 175 176 /** 177 * Width 178 * 179 * We create a series of URL 180 * for different width and let the browser 181 * download the best one for: 182 * * the actual container width 183 * * the actual of screen resolution 184 * * and the connection speed. 185 * 186 * The max-width value is set 187 */ 188 $srcValue = $fetchRaster->getFetchUrl(); 189 /** 190 * Add samller breakpoints sizes 191 */ 192 $intrinsicWidth = $fetchRaster->getIntrinsicWidth(); 193 foreach (Breakpoint::getBreakpoints() as $breakpoint) { 194 195 try { 196 $breakpointPixels = $breakpoint->getWidth(); 197 } catch (ExceptionInfinite $e) { 198 continue; 199 } 200 201 if ($breakpointPixels > $targetWidth) { 202 continue; 203 } 204 205 if ($breakpointPixels > $intrinsicWidth) { 206 continue; 207 } 208 209 if (!empty($srcSet)) { 210 $srcSet .= ", "; 211 $sizes .= ", "; 212 } 213 $breakpointWidthMinusMargin = $breakpointPixels - $imageMargin; 214 215 216 $breakpointRaster = clone $fetchRaster; 217 if ( 218 !$fetchRaster->hasHeightRequested() // breakpoint url needs only the h attribute in this case 219 || $fetchRaster->hasAspectRatioRequested() // width and height are mandatory 220 ) { 221 $breakpointRaster->setRequestedWidth($breakpointWidthMinusMargin); 222 } 223 if ($fetchRaster->hasHeightRequested() // if this is a height request 224 || $fetchRaster->hasAspectRatioRequested() // width and height are mandatory 225 ) { 226 $breakPointHeight = FetcherRaster::round($breakpointWidthMinusMargin / $fetchRaster->getTargetAspectRatio()); 227 $breakpointRaster->setRequestedHeight($breakPointHeight); 228 } 229 230 $breakpointUrl = $breakpointRaster->getFetchUrl()->toString(); 231 232 233 $srcSet .= "$breakpointUrl {$breakpointWidthMinusMargin}w"; 234 $sizes .= $this->getSizes($breakpointPixels, $breakpointWidthMinusMargin); 235 236 237 } 238 239 /** 240 * Add the last size 241 * If the target image is really small, srcset and sizes are empty 242 */ 243 if (!empty($srcSet)) { 244 $srcSet .= ", "; 245 $sizes .= ", "; 246 $srcUrl = $fetchRaster->getFetchUrl()->toString(); 247 $srcSet .= "$srcUrl {$targetWidth}w"; 248 $sizes .= "{$targetWidth}px"; 249 } 250 251 /** 252 * Lazy load 253 */ 254 $lazyLoad = $this->getLazyLoad(); 255 if ($lazyLoad) { 256 257 /** 258 * Html Lazy loading 259 */ 260 $lazyLoadMethod = $this->mediaMarkup->getLazyLoadMethodOrDefault(); 261 switch ($lazyLoadMethod) { 262 case LazyLoad::LAZY_LOAD_METHOD_HTML_VALUE: 263 default: 264 $attributes->addOutputAttributeValue("src", $srcValue); 265 if (!empty($srcSet)) { 266 // it the image is small, no srcset for instance 267 $attributes->addOutputAttributeValue("srcset", $srcSet); 268 } 269 $attributes->addOutputAttributeValue("loading", "lazy"); 270 break; 271 case LazyLoad::LAZY_LOAD_METHOD_LOZAD_VALUE: 272 /** 273 * Snippet Lazy loading 274 */ 275 LazyLoad::addLozadSnippet(); 276 PluginUtility::getSnippetManager()->attachJavascriptFromComponentId("lozad-raster"); 277 $attributes->addClassName(self::getLazyClass()); 278 $attributes->addClassName(LazyLoad::getLazyClass()); 279 280 /** 281 * A small image has no srcset 282 * 283 */ 284 if (!empty($srcSet)) { 285 286 /** 287 * !!!!! DON'T FOLLOW THIS ADVICE !!!!!!!!! 288 * https://github.com/aFarkas/lazysizes/#modern-transparent-srcset-pattern 289 * The transparent image has a fix dimension aspect ratio of 1x1 making 290 * a bad reserved space for the image 291 * We use a svg instead 292 */ 293 $attributes->addOutputAttributeValue("src", $srcValue); 294 $attributes->addOutputAttributeValue("srcset", LazyLoad::getPlaceholder($targetWidth, $targetHeight)); 295 /** 296 * We use `data-sizes` and not `sizes` 297 * because `sizes` without `srcset` 298 * shows the broken image symbol 299 * Javascript changes them at the same time 300 */ 301 $attributes->addOutputAttributeValue("data-sizes", $sizes); 302 $attributes->addOutputAttributeValue("data-srcset", $srcSet); 303 304 } else { 305 306 /** 307 * Small image but there is no little improvement 308 */ 309 $attributes->addOutputAttributeValue("data-src", $srcValue); 310 $attributes->addOutputAttributeValue("src", LazyLoad::getPlaceholder($targetWidth, $targetHeight)); 311 312 } 313 LazyLoad::addPlaceholderBackground($attributes); 314 break; 315 } 316 317 318 } else { 319 320 if (!empty($srcSet)) { 321 $attributes->addOutputAttributeValue("srcset", $srcSet); 322 $attributes->addOutputAttributeValue("sizes", $sizes); 323 } else { 324 $attributes->addOutputAttributeValue("src", $srcValue); 325 } 326 327 } 328 329 330 /** 331 * Title (ie alt) 332 */ 333 $attributes->addOutputAttributeValueIfNotEmpty("alt", $this->getAltNotEmpty()); 334 335 /** 336 * Create the img element 337 */ 338 $htmlAttributes = $attributes->toHTMLAttributeString(); 339 $imgHTML = '<img ' . $htmlAttributes . '/>'; 340 341 342 return $this->wrapMediaMarkupWithLink($imgHTML); 343 } 344 345 346 public function getLazyLoad(): bool 347 { 348 349 if ($this->mediaMarkup->isLazy() === false) { 350 return false; 351 } 352 return SiteConfig::getConfValue(LazyLoad::CONF_RASTER_ENABLE, LazyLoad::CONF_RASTER_ENABLE_DEFAULT); 353 354 } 355 356 /** 357 * @param $screenWidth 358 * @param $imageWidth 359 * @return string sizes with a dpi correction if 360 */ 361 private 362 function getSizes($screenWidth, $imageWidth): string 363 { 364 365 if ($this->getWithDpiCorrection()) { 366 $dpiBase = 96; 367 $sizes = "(max-width: {$screenWidth}px) and (min-resolution:" . (3 * $dpiBase) . "dpi) " . intval($imageWidth / 3) . "px"; 368 $sizes .= ", (max-width: {$screenWidth}px) and (min-resolution:" . (2 * $dpiBase) . "dpi) " . intval($imageWidth / 2) . "px"; 369 $sizes .= ", (max-width: {$screenWidth}px) and (min-resolution:" . (1 * $dpiBase) . "dpi) {$imageWidth}px"; 370 } else { 371 $sizes = "(max-width: {$screenWidth}px) {$imageWidth}px"; 372 } 373 return $sizes; 374 } 375 376 /** 377 * Return if the DPI correction is enabled or not for responsive image 378 * 379 * Mobile have a higher DPI and can then fit a bigger image on a smaller size. 380 * 381 * This can be disturbing when debugging responsive sizing image 382 * If you want also to use less bandwidth, this is also useful. 383 * 384 * @return bool 385 */ 386 private 387 function getWithDpiCorrection(): bool 388 { 389 /** 390 * Support for retina means no DPI correction 391 */ 392 $retinaEnabled = SiteConfig::getConfValue(self::CONF_RETINA_SUPPORT_ENABLED, 0); 393 return !$retinaEnabled; 394 } 395 396 /** 397 * Used to select the raster image lazy loaded 398 * @return string 399 */ 400 public static function getLazyClass() 401 { 402 return StyleAttribute::addComboStrapSuffix("lazy-raster"); 403 } 404 405 406} 407