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 15require_once(__DIR__ . '/MediaLink.php'); 16require_once(__DIR__ . '/LazyLoad.php'); 17require_once(__DIR__ . '/PluginUtility.php'); 18 19/** 20 * Image 21 * This is the class that handles the 22 * raster image type of the dokuwiki {@link MediaLink} 23 * 24 * The real documentation can be found on the image page 25 * @link https://www.dokuwiki.org/images 26 * 27 * Doc: 28 * https://web.dev/optimize-cls/#images-without-dimensions 29 * https://web.dev/cls/ 30 */ 31class RasterImageLink extends MediaLink 32{ 33 34 const CANONICAL = "raster"; 35 const CONF_LAZY_LOADING_ENABLE = "rasterImageLazyLoadingEnable"; 36 37 const RESPONSIVE_CLASS = "img-fluid"; 38 39 const CONF_RESPONSIVE_IMAGE_MARGIN = "responsiveImageMargin"; 40 const CONF_RETINA_SUPPORT_ENABLED = "retinaRasterImageEnable"; 41 const LAZY_CLASS = "lazy-raster-combo"; 42 43 const BREAKPOINTS = 44 array( 45 "xs" => 375, 46 "sm" => 576, 47 "md" => 768, 48 "lg" => 992 49 ); 50 51 52 private $imageWidth = null; 53 /** 54 * @var int 55 */ 56 private $imageWeight = null; 57 /** 58 * See {@link image_type_to_mime_type} 59 * @var int 60 */ 61 private $imageType; 62 private $wasAnalyzed = false; 63 64 /** 65 * @var bool 66 */ 67 private $analyzable = false; 68 69 /** 70 * @var mixed - the mime from the {@link RasterImageLink::analyzeImageIfNeeded()} 71 */ 72 private $mime; 73 74 /** 75 * RasterImageLink constructor. 76 * @param $ref 77 * @param TagAttributes $tagAttributes 78 */ 79 public function __construct($ref, $tagAttributes = null) 80 { 81 parent::__construct($ref, $tagAttributes); 82 $this->getTagAttributes()->setLogicalTag(self::CANONICAL); 83 84 } 85 86 87 /** 88 * @param string $ampersand 89 * @param null $localWidth - the asked width - use for responsive image 90 * @return string|null 91 */ 92 public function getUrl($ampersand = DokuwikiUrl::URL_ENCODED_AND, $localWidth = null) 93 { 94 95 if ($this->exists()) { 96 97 /** 98 * Link attribute 99 */ 100 $att = array(); 101 102 // Width is driving the computation 103 if ($localWidth != null && $localWidth != $this->getMediaWidth()) { 104 105 $att['w'] = $localWidth; 106 107 // Height 108 $height = $this->getImgTagHeightValue($localWidth); 109 if (!empty($height)) { 110 $att['h'] = $height; 111 $this->checkWidthAndHeightRatioAndReturnTheGoodValue($localWidth, $height); 112 } 113 114 115 } 116 117 if ($this->getCache()) { 118 $att[CacheMedia::CACHE_KEY] = $this->getCache(); 119 } 120 $direct = true; 121 122 return ml($this->getId(), $att, $direct, $ampersand, true); 123 124 } else { 125 126 return false; 127 128 } 129 } 130 131 public function getAbsoluteUrl() 132 { 133 134 return $this->getUrl(); 135 136 } 137 138 139 /** 140 * Render a link 141 * Snippet derived from {@link \Doku_Renderer_xhtml::internalmedia()} 142 * A media can be a video also (Use 143 * @return string 144 */ 145 public function renderMediaTag() 146 { 147 148 149 if ($this->exists()) { 150 151 152 /** 153 * No dokuwiki type attribute 154 */ 155 $this->tagAttributes->removeComponentAttributeIfPresent(MediaLink::MEDIA_DOKUWIKI_TYPE); 156 $this->tagAttributes->removeComponentAttributeIfPresent(MediaLink::DOKUWIKI_SRC); 157 158 /** 159 * Responsive image 160 * https://getbootstrap.com/docs/5.0/content/images/ 161 * to apply max-width: 100%; and height: auto; 162 * 163 * Even if the resizing is requested by height, 164 * the height: auto on styling is needed to conserve the ratio 165 * while scaling down the screen 166 */ 167 $this->tagAttributes->addClassName(self::RESPONSIVE_CLASS); 168 169 170 /** 171 * width and height to give the dimension ratio 172 * They have an effect on the space reservation 173 * but not on responsive image at all 174 * To allow responsive height, the height style property is set at auto 175 * (ie img-fluid in bootstrap) 176 */ 177 // The unit is not mandatory in HTML, this is expected to be CSS pixel 178 // https://html.spec.whatwg.org/multipage/embedded-content-other.html#attr-dim-height 179 // The HTML validator does not expect an unit otherwise it send an error 180 // https://validator.w3.org/ 181 $htmlLengthUnit = ""; 182 183 /** 184 * Height 185 * The logical height that the image should take on the page 186 * 187 * Note: The style is also set in {@link Dimension::processWidthAndHeight()} 188 * 189 * The doc is {@link https://www.dokuwiki.org/images#resizing} 190 * See the ''0x20'' 191 */ 192 $imgTagHeight = $this->getImgTagHeightValue(); 193 if (!empty($imgTagHeight)) { 194 $this->tagAttributes->addHtmlAttributeValue("height", $imgTagHeight . $htmlLengthUnit); 195 } 196 197 198 /** 199 * Width 200 * 201 * We create a series of URL 202 * for different width and let the browser 203 * download the best one for: 204 * * the actual container width 205 * * the actual of screen resolution 206 * * and the connection speed. 207 * 208 * The max-width value is set 209 */ 210 $mediaWidthValue = $this->getMediaWidth(); 211 $srcValue = $this->getUrl(); 212 213 /** 214 * Responsive image src set building 215 * We have chosen 216 * * 375: Iphone6 217 * * 768: Ipad 218 * * 1024: Ipad Pro 219 * 220 */ 221 // The image margin applied 222 $imageMargin = PluginUtility::getConfValue(self::CONF_RESPONSIVE_IMAGE_MARGIN, "20px"); 223 224 225 /** 226 * Srcset and sizes for responsive image 227 * Width is mandatory for responsive image 228 * Ref https://developers.google.com/search/docs/advanced/guidelines/google-images#responsive-images 229 */ 230 if (!empty($mediaWidthValue)) { 231 232 /** 233 * The internal intrinsic value of the image 234 */ 235 $imgTagWidth = $this->getImgTagWidthValue(); 236 if (!empty($imgTagWidth)) { 237 238 if (!empty($imgTagHeight)) { 239 $imgTagWidth = $this->checkWidthAndHeightRatioAndReturnTheGoodValue($imgTagWidth, $imgTagHeight); 240 } 241 $this->tagAttributes->addHtmlAttributeValue("width", $imgTagWidth . $htmlLengthUnit); 242 } 243 244 /** 245 * Continue 246 */ 247 $srcSet = ""; 248 $sizes = ""; 249 250 /** 251 * Add smaller sizes 252 */ 253 foreach (self::BREAKPOINTS as $breakpointWidth) { 254 255 if ($imgTagWidth > $breakpointWidth) { 256 257 if (!empty($srcSet)) { 258 $srcSet .= ", "; 259 $sizes .= ", "; 260 } 261 $breakpointWidthMinusMargin = $breakpointWidth - $imageMargin; 262 $xsmUrl = $this->getUrl(DokuwikiUrl::URL_ENCODED_AND, $breakpointWidthMinusMargin); 263 $srcSet .= "$xsmUrl {$breakpointWidthMinusMargin}w"; 264 $sizes .= $this->getSizes($breakpointWidth, $breakpointWidthMinusMargin); 265 266 } 267 268 } 269 270 /** 271 * Add the last size 272 * If the image is really small, srcset and sizes are empty 273 */ 274 if (!empty($srcSet)) { 275 $srcSet .= ", "; 276 $sizes .= ", "; 277 $srcUrl = $this->getUrl(DokuwikiUrl::URL_ENCODED_AND, $imgTagWidth); 278 $srcSet .= "$srcUrl {$imgTagWidth}w"; 279 $sizes .= "{$imgTagWidth}px"; 280 } 281 282 /** 283 * Lazy load 284 */ 285 $lazyLoad = $this->getLazyLoad(); 286 if ($lazyLoad) { 287 288 /** 289 * Snippet Lazy loading 290 */ 291 LazyLoad::addLozadSnippet(); 292 PluginUtility::getSnippetManager()->attachJavascriptSnippetForBar("lozad-raster"); 293 $this->tagAttributes->addClassName(self::LAZY_CLASS); 294 $this->tagAttributes->addClassName(LazyLoad::LAZY_CLASS); 295 296 /** 297 * A small image has no srcset 298 * 299 */ 300 if (!empty($srcSet)) { 301 302 /** 303 * !!!!! DON'T FOLLOW THIS ADVICE !!!!!!!!! 304 * https://github.com/aFarkas/lazysizes/#modern-transparent-srcset-pattern 305 * The transparent image has a fix dimension aspect ratio of 1x1 making 306 * a bad reserved space for the image 307 * We use a svg instead 308 */ 309 $this->tagAttributes->addHtmlAttributeValue("src", $srcValue); 310 $this->tagAttributes->addHtmlAttributeValue("srcset", LazyLoad::getPlaceholder($imgTagWidth,$imgTagHeight)); 311 /** 312 * We use `data-sizes` and not `sizes` 313 * because `sizes` without `srcset` 314 * shows the broken image symbol 315 * Javascript changes them at the same time 316 */ 317 $this->tagAttributes->addHtmlAttributeValue("data-sizes", $sizes); 318 $this->tagAttributes->addHtmlAttributeValue("data-srcset", $srcSet); 319 320 } else { 321 322 /** 323 * Small image but there is no little improvement 324 */ 325 $this->tagAttributes->addHtmlAttributeValue("data-src", $srcValue); 326 327 } 328 329 LazyLoad::addPlaceholderBackground($this->tagAttributes); 330 331 332 } else { 333 334 if (!empty($srcSet)) { 335 $this->tagAttributes->addHtmlAttributeValue("srcset", $srcSet); 336 $this->tagAttributes->addHtmlAttributeValue("sizes", $sizes); 337 } else { 338 $this->tagAttributes->addHtmlAttributeValue("src", $srcValue); 339 } 340 341 } 342 343 } else { 344 345 // No width, no responsive possibility 346 $lazyLoad = $this->getLazyLoad(); 347 if ($lazyLoad) { 348 349 LazyLoad::addPlaceholderBackground($this->tagAttributes); 350 $this->tagAttributes->addHtmlAttributeValue("src", LazyLoad::getPlaceholder()); 351 $this->tagAttributes->addHtmlAttributeValue("data-src", $srcValue); 352 353 } 354 355 } 356 357 358 /** 359 * Title (ie alt) 360 */ 361 if ($this->tagAttributes->hasComponentAttribute(TagAttributes::TITLE_KEY)) { 362 $title = $this->tagAttributes->getValueAndRemove(TagAttributes::TITLE_KEY); 363 $this->tagAttributes->addHtmlAttributeValueIfNotEmpty("alt", $title); 364 } 365 366 /** 367 * Create the img element 368 */ 369 $htmlAttributes = $this->tagAttributes->toHTMLAttributeString(); 370 $imgHTML = '<img ' . $htmlAttributes . '/>'; 371 372 } else { 373 374 $imgHTML = "<span class=\"text-danger\">The image ($this) does not exist</span>"; 375 376 } 377 378 return $imgHTML; 379 } 380 381 /** 382 * @return int - the width of the image from the file 383 */ 384 public 385 function getMediaWidth() 386 { 387 $this->analyzeImageIfNeeded(); 388 return $this->imageWidth; 389 } 390 391 /** 392 * @return int - the height of the image from the file 393 */ 394 public 395 function getMediaHeight() 396 { 397 $this->analyzeImageIfNeeded(); 398 return $this->imageWeight; 399 } 400 401 private 402 function analyzeImageIfNeeded() 403 { 404 405 if (!$this->wasAnalyzed) { 406 407 if ($this->exists()) { 408 409 /** 410 * Based on {@link media_image_preview_size()} 411 * $dimensions = media_image_preview_size($this->id, '', false); 412 */ 413 $imageInfo = array(); 414 $imageSize = getimagesize($this->getFileSystemPath(), $imageInfo); 415 if ($imageSize === false) { 416 $this->analyzable = false; 417 LogUtility::msg("We couldn't retrieve the type and dimensions of the image ($this). The image format seems to be not supported.", LogUtility::LVL_MSG_ERROR, self::CANONICAL); 418 } else { 419 $this->analyzable = true; 420 $this->imageWidth = (int)$imageSize[0]; 421 if (empty($this->imageWidth)) { 422 $this->analyzable = false; 423 } 424 $this->imageWeight = (int)$imageSize[1]; 425 if (empty($this->imageWeight)) { 426 $this->analyzable = false; 427 } 428 $this->imageType = (int)$imageSize[2]; 429 $this->mime = $imageSize[3]; 430 } 431 } 432 } 433 $this->wasAnalyzed = true; 434 } 435 436 437 /** 438 * 439 * @return bool true if we could extract the dimensions 440 */ 441 public 442 function isAnalyzable() 443 { 444 $this->analyzeImageIfNeeded(); 445 return $this->analyzable; 446 447 } 448 449 450 public function getRequestedHeight() 451 { 452 $requestedHeight = parent::getRequestedHeight(); 453 if (!empty($requestedHeight)) { 454 // it should not be bigger than the media Height 455 $mediaHeight = $this->getMediaHeight(); 456 if (!empty($mediaHeight)) { 457 if ($requestedHeight > $mediaHeight) { 458 LogUtility::msg("For the image ($this), the requested height of ($requestedHeight) can not be bigger than the intrinsic height of ($mediaHeight). The height was then set to its natural height ($mediaHeight)", LogUtility::LVL_MSG_ERROR, self::CANONICAL); 459 $requestedHeight = $mediaHeight; 460 } 461 } 462 } 463 return $requestedHeight; 464 } 465 466 public function getRequestedWidth() 467 { 468 $requestedWidth = parent::getRequestedWidth(); 469 if (!empty($requestedWidth)) { 470 // it should not be bigger than the media Height 471 $mediaWidth = $this->getMediaWidth(); 472 if (!empty($mediaWidth)) { 473 if ($requestedWidth > $mediaWidth) { 474 global $ID; 475 if ($ID != "wiki:syntax") { 476 // There is a bug in the wiki syntax page 477 // {{wiki:dokuwiki-128.png?200x50}} 478 // https://forum.dokuwiki.org/d/19313-bugtypo-how-to-make-a-request-to-change-the-syntax-page-on-dokuwikii 479 LogUtility::msg("For the image ($this), the requested width of ($requestedWidth) can not be bigger than the intrinsic width of ($mediaWidth). The width was then set to its natural width ($mediaWidth)", LogUtility::LVL_MSG_ERROR, self::CANONICAL); 480 } 481 $requestedWidth = $mediaWidth; 482 } 483 } 484 } 485 return $requestedWidth; 486 } 487 488 489 public 490 function getLazyLoad() 491 { 492 $lazyLoad = parent::getLazyLoad(); 493 if ($lazyLoad !== null) { 494 return $lazyLoad; 495 } else { 496 return PluginUtility::getConfValue(RasterImageLink::CONF_LAZY_LOADING_ENABLE); 497 } 498 } 499 500 /** 501 * @param $screenWidth 502 * @param $imageWidth 503 * @return string sizes with a dpi correction if 504 */ 505 private 506 function getSizes($screenWidth, $imageWidth) 507 { 508 509 if ($this->getWithDpiCorrection()) { 510 $dpiBase = 96; 511 $sizes = "(max-width: {$screenWidth}px) and (min-resolution:" . (3 * $dpiBase) . "dpi) " . intval($imageWidth / 3) . "px"; 512 $sizes .= ", (max-width: {$screenWidth}px) and (min-resolution:" . (2 * $dpiBase) . "dpi) " . intval($imageWidth / 2) . "px"; 513 $sizes .= ", (max-width: {$screenWidth}px) and (min-resolution:" . (1 * $dpiBase) . "dpi) {$imageWidth}px"; 514 } else { 515 $sizes = "(max-width: {$screenWidth}px) {$imageWidth}px"; 516 } 517 return $sizes; 518 } 519 520 /** 521 * Return if the DPI correction is enabled or not for responsive image 522 * 523 * Mobile have a higher DPI and can then fit a bigger image on a smaller size. 524 * 525 * This can be disturbing when debugging responsive sizing image 526 * If you want also to use less bandwidth, this is also useful. 527 * 528 * @return bool 529 */ 530 private 531 function getWithDpiCorrection() 532 { 533 /** 534 * Support for retina means no DPI correction 535 */ 536 $retinaEnabled = PluginUtility::getConfValue(self::CONF_RETINA_SUPPORT_ENABLED, 0); 537 return !$retinaEnabled; 538 } 539 540 541} 542