1<?php 2 3 4namespace ComboStrap; 5 6 7use syntax_plugin_combo_card; 8 9require_once(__DIR__ . "/PluginUtility.php"); 10 11/** 12 * Class Image 13 * @package ComboStrap 14 * An image and its attribute 15 * (ie a file and its transformation attribute if any such as 16 * width, height, ...) 17 */ 18abstract class Image extends Media 19{ 20 21 22 const CANONICAL = "image"; 23 24 25 /** 26 * Image constructor. 27 * @param Path $path 28 * @param TagAttributes|null $attributes - the attributes 29 */ 30 public function __construct(Path $path, $attributes = null) 31 { 32 if ($attributes === null) { 33 $this->attributes = TagAttributes::createEmpty(self::CANONICAL); 34 } 35 36 parent::__construct($path, $attributes); 37 } 38 39 40 /** 41 * @param Path $path 42 * @param null $attributes 43 * @return ImageRaster|ImageSvg 44 * @throws ExceptionCombo if not valid 45 */ 46 public static function createImageFromPath(Path $path, $attributes = null) 47 { 48 49 $mime = $path->getMime(); 50 51 if (!$mime->isImage()) { 52 53 throw new ExceptionCombo("The file ($path) has not been detected as being an image, media returned", LogUtility::LVL_MSG_ERROR, self::CANONICAL); 54 55 } 56 if ($mime->toString() === Mime::SVG) { 57 58 $image = new ImageSvg($path, $attributes); 59 60 } else { 61 62 $image = new ImageRaster($path, $attributes); 63 64 } 65 return $image; 66 67 68 } 69 70 /** 71 * @throws ExceptionCombo if not valid 72 */ 73 public static function createImageFromId(string $imageId, $rev = '', $attributes = null) 74 { 75 $dokuPath = DokuPath::createMediaPathFromId($imageId, $rev); 76 return self::createImageFromPath($dokuPath, $attributes); 77 } 78 79 /** 80 * Return a height value that is conform to the {@link Image::getIntrinsicAspectRatio()} of the image. 81 * 82 * @param int|null $breakpointWidth - the width to derive the height from (in case the image is created for responsive lazy loading) 83 * if not specified, the requested width and if not specified the intrinsic width 84 * @param int|null $requestedHeight 85 * @return int the height value attribute in a img 86 * 87 * Algorithm: 88 * * If the requested height given is not null, return the given height rounded 89 * * If the requested height is null, if the requested width is: 90 * * null: return the intrinsic / natural height 91 * * not null: return the height as being the width scaled down by the {@link Image::getIntrinsicAspectRatio()} 92 */ 93 public function getBreakpointHeight(?int $breakpointWidth): int 94 { 95 96 try { 97 $targetAspectRatio = $this->getTargetAspectRatio(); 98 } catch (ExceptionCombo $e) { 99 LogUtility::msg("The target ratio for the image was set to 1 because we got this error: {$e->getMessage()}"); 100 $targetAspectRatio = 1; 101 } 102 if ($targetAspectRatio === 0) { 103 LogUtility::msg("The target ratio for the image was set to 1 because its value was 0"); 104 $targetAspectRatio = 1; 105 } 106 return $this->round($breakpointWidth / $targetAspectRatio); 107 108 } 109 110 /** 111 * Return a width value that is conform to the {@link Image::getIntrinsicAspectRatio()} of the image. 112 * 113 * @param int|null $requestedWidth - the requested width (may be null) 114 * @param int|null $requestedHeight - the request height (may be null) 115 * @return int - the width value attribute in a img (in CSS pixel that the image should takes) 116 * 117 * Algorithm: 118 * * If the requested width given is not null, return the given width 119 * * If the requested width is null, if the requested height is: 120 * * null: return the intrinsic / natural width 121 * * not null: return the width as being the height scaled down by the {@link Image::getIntrinsicAspectRatio()} 122 */ 123 public function getWidthValueScaledDown(?int $requestedWidth, ?int $requestedHeight): int 124 { 125 126 if (!empty($requestedWidth) && !empty($requestedHeight)) { 127 LogUtility::msg("The requested width ($requestedWidth) and the requested height ($requestedHeight) are not null. You can't scale an image in width and height. The width or the height should be null.", LogUtility::LVL_MSG_ERROR, self::CANONICAL); 128 } 129 130 $computedWidth = $requestedWidth; 131 if (empty($requestedWidth)) { 132 133 if (empty($requestedHeight)) { 134 135 $computedWidth = $this->getIntrinsicWidth(); 136 137 } else { 138 139 if ($this->getIntrinsicAspectRatio() !== false) { 140 $computedWidth = $this->getIntrinsicAspectRatio() * $requestedHeight; 141 } else { 142 LogUtility::msg("The aspect ratio of the image ($this) could not be calculated", LogUtility::LVL_MSG_ERROR, self::CANONICAL); 143 } 144 145 } 146 } 147 /** 148 * Rounding to integer 149 * The fetch.php file takes int as value for width and height 150 * making a rounding if we pass a double (such as 37.5) 151 * This is important because the security token is based on width and height 152 * and therefore the fetch will failed 153 * 154 * And this is also ask by the specification 155 * a non-null positive integer 156 * https://html.spec.whatwg.org/multipage/embedded-content-other.html#attr-dim-height 157 * 158 * And not {@link intval} because it will make from 3.6, 3 and not 4 159 */ 160 return intval(round($computedWidth)); 161 } 162 163 164 /** 165 * For a raster image, the internal width 166 * for a svg, the defined viewBox 167 * 168 * @throws ExceptionCombo 169 * @return int in pixel 170 */ 171 public abstract function getIntrinsicWidth(): int; 172 173 /** 174 * For a raster image, the internal height 175 * for a svg, the defined `viewBox` value 176 * 177 * This is needed to calculate the {@link MediaLink::getTargetRatio() target ratio} 178 * and pass them to the img tag to avoid layout shift 179 * 180 * @return int in pixel 181 */ 182 public abstract function getIntrinsicHeight(): int; 183 184 /** 185 * The Aspect ratio as explained here 186 * https://html.spec.whatwg.org/multipage/embedded-content-other.html#attr-dim-height 187 * @return float|int|false 188 * false if the image is not supported 189 * 190 * It's needed for an img tag to set the img `width` and `height` that pass the 191 * {@link MediaLink::checkWidthAndHeightRatioAndReturnTheGoodValue() check} 192 * to avoid layout shift 193 * @throws ExceptionCombo 194 */ 195 public function getIntrinsicAspectRatio() 196 { 197 198 if ($this->getIntrinsicHeight() == null || $this->getIntrinsicWidth() == null) { 199 return false; 200 } else { 201 return $this->getIntrinsicWidth() / $this->getIntrinsicHeight(); 202 } 203 } 204 205 /** 206 * The Aspect ratio of the target image (may be the original or the an image scaled down) 207 * 208 * https://html.spec.whatwg.org/multipage/embedded-content-other.html#attr-dim-height 209 * @return float|int|false 210 * false if the image is not supported 211 * 212 * It's needed for an img tag to set the img `width` and `height` that pass the 213 * {@link MediaLink::checkWidthAndHeightRatioAndReturnTheGoodValue() check} 214 * to avoid layout shift 215 * @throws ExceptionCombo 216 */ 217 public function getTargetAspectRatio() 218 { 219 220 $targetHeight = $this->getTargetHeight(); 221 if ($targetHeight === 0) { 222 throw new ExceptionCombo("The target height is equal to zero, we can calculate the target aspect ratio"); 223 } 224 $targetWidth = $this->getTargetWidth(); 225 return $targetWidth / $targetHeight; 226 227 } 228 229 /** 230 * The Aspect ratio as explained here 231 * https://html.spec.whatwg.org/multipage/embedded-content-other.html#attr-dim-height 232 * @return float|int 233 * false if the image is not supported 234 * 235 * It's needed for an img tag to set the img `width` and `height` that pass the 236 * {@link MediaLink::checkWidthAndHeightRatioAndReturnTheGoodValue() check} 237 * to avoid layout shift 238 * @throws ExceptionCombo 239 */ 240 public function getRequestedAspectRatio() 241 { 242 243 $requestedRatio = $this->attributes->getValue(Dimension::RATIO_ATTRIBUTE); 244 if ($requestedRatio !== null) { 245 try { 246 return Dimension::convertTextualRatioToNumber($requestedRatio); 247 } catch (ExceptionCombo $e) { 248 LogUtility::msg("The requested ratio ($requestedRatio) is not a valid value ({$e->getMessage()})", LogUtility::LVL_MSG_ERROR, self::CANONICAL); 249 } 250 } 251 252 if ( 253 $this->getRequestedWidth() !== null 254 && $this->getRequestedWidth() !== 0 // default value for not set in dokuwiki 255 && $this->getRequestedHeight() !== null) { 256 if ($this->getRequestedHeight() === 0) { 257 LogUtility::msg("The requested height is 0, we can't calculate the requested ratio", LogUtility::LVL_MSG_ERROR, self::CANONICAL); 258 } 259 return $this->getRequestedWidth() / $this->getRequestedHeight(); 260 } 261 262 return null; 263 264 265 } 266 267 /** 268 * @return bool if this is raster image, false if this is a vector image 269 */ 270 public function isRaster(): bool 271 { 272 if ($this->getPath()->getMime()->toString() === Mime::SVG) { 273 return false; 274 } else { 275 return true; 276 } 277 } 278 279 /** 280 * Giving width and height, check that the aspect ratio is the same 281 * than the target one 282 * @param $height 283 * @param $width 284 */ 285 public 286 function checkLogicalRatioAgainstTargetRatio($width, $height) 287 { 288 /** 289 * Check of height and width dimension 290 * as specified here 291 * 292 * This is about the intrinsic dimension but we have the notion of target dimension 293 * 294 * https://html.spec.whatwg.org/multipage/embedded-content-other.html#attr-dim-height 295 */ 296 try { 297 $targetRatio = $this->getTargetAspectRatio(); 298 } catch (ExceptionCombo $e) { 299 LogUtility::msg("Unable to check the target ratio because it returns this error: {$e->getMessage()}"); 300 return; 301 } 302 if (!( 303 $height * $targetRatio >= $width - 1 304 && 305 $height * $targetRatio <= $width + 1 306 )) { 307 // check the second statement 308 if (!( 309 $width / $targetRatio >= $height - 1 310 && 311 $width / $targetRatio <= $height + 1 312 )) { 313 314 /** 315 * Programmatic error from the developer 316 */ 317 $imgTagRatio = $width / $height; 318 LogUtility::msg("Internal Error: The width ($width) and height ($height) calculated for the image ($this) does not pass the ratio test. They have a ratio of ($imgTagRatio) while the target dimension ratio is ($targetRatio)"); 319 320 } 321 } 322 } 323 324 /** 325 * The Url 326 * @return mixed 327 */ 328 public abstract function getAbsoluteUrl(); 329 330 /** 331 * This is mandatory for HTML 332 * The alternate text (the title in Dokuwiki media term) 333 * @return null 334 * 335 * TODO: try to extract it from the metadata file ? 336 * 337 * An img element must have an alt attribute, except under certain conditions. 338 * For details, consult guidance on providing text alternatives for images. 339 * https://www.w3.org/WAI/tutorials/images/ 340 */ 341 public function getAltNotEmpty() 342 { 343 $title = $this->getTitle(); 344 if (!empty($title)) { 345 return $title; 346 } 347 $generatedAlt = str_replace("-", " ", $this->getPath()->getLastNameWithoutExtension()); 348 return str_replace($generatedAlt, "_", " "); 349 } 350 351 352 /** 353 * The logical height is the calculated height of the target image 354 * specified in the query parameters 355 * 356 * For instance, 357 * * with `200`, the target image has a {@link Image::getTargetWidth() logical width} of 200 and a {@link Image::getTargetHeight() logical height} that is scaled down by the {@link Image::getIntrinsicAspectRatio() instrinsic ratio} 358 * * with ''0x20'', the target image has a {@link Image::getTargetHeight() logical height} of 20 and a {@link Image::getTargetWidth() logical width} that is scaled down by the {@link Image::getIntrinsicAspectRatio() instrinsic ratio} 359 * 360 * The doc is {@link https://www.dokuwiki.org/images#resizing} 361 * 362 * 363 * @return int 364 * @throws ExceptionCombo 365 */ 366 public function getTargetHeight(): int 367 { 368 $requestedHeight = $this->getRequestedHeight(); 369 if (!empty($requestedHeight)) { 370 return $requestedHeight; 371 } 372 373 /** 374 * Scaled down by width 375 */ 376 $width = $this->getRequestedWidth(); 377 if (!empty($width)) { 378 379 try { 380 $ratio = $this->getRequestedAspectRatio(); 381 if ($ratio === null) { 382 $ratio = $this->getIntrinsicAspectRatio(); 383 } 384 return self::round($width / $ratio); 385 } catch (ExceptionCombo $e) { 386 LogUtility::msg("The intrinsic height of the image ($this) was used because retrieving the ratio returns this error: {$e->getMessage()} ", LogUtility::LVL_MSG_ERROR, self::CANONICAL); 387 return $this->getIntrinsicHeight(); 388 } 389 390 } 391 392 /** 393 * Scaled down by ratio 394 */ 395 $ratio = $this->getRequestedAspectRatio(); 396 if (!empty($ratio)) { 397 [$croppedWidth, $croppedHeight] = Image::getCroppingDimensionsWithRatio( 398 $ratio, 399 $this->getIntrinsicWidth(), 400 $this->getIntrinsicHeight() 401 ); 402 return $croppedHeight; 403 } 404 405 return $this->getIntrinsicHeight(); 406 407 } 408 409 /** 410 * The logical width is the width of the target image calculated from the requested dimension 411 * 412 * For instance, 413 * * with `200`, the target image has a {@link Image::getTargetWidth() logical width} of 200 and a {@link Image::getTargetHeight() logical height} that is scaled down by the {@link Image::getIntrinsicAspectRatio() instrinsic ratio} 414 * * with ''0x20'', the target image has a {@link Image::getTargetHeight() logical height} of 20 and a {@link Image::getTargetWidth() logical width} that is scaled down by the {@link Image::getIntrinsicAspectRatio() instrinsic ratio} 415 * 416 * The doc is {@link https://www.dokuwiki.org/images#resizing} 417 * @throws ExceptionCombo 418 */ 419 public function getTargetWidth(): int 420 { 421 422 $requestedWidth = $this->getRequestedWidth(); 423 424 /** 425 * May be 0 (ie empty) 426 */ 427 if (!empty($requestedWidth)) { 428 return $requestedWidth; 429 } 430 431 /** 432 * Scaled down by Height 433 */ 434 $height = $this->getRequestedHeight(); 435 if (!empty($height)) { 436 437 try { 438 $ratio = $this->getRequestedAspectRatio(); 439 if ($ratio === null) { 440 $ratio = $this->getIntrinsicAspectRatio(); 441 } 442 return self::round($ratio * $height); 443 } catch (ExceptionCombo $e) { 444 LogUtility::msg("The intrinsic width of the image ($this) was used because retrieving the ratio returns this error: {$e->getMessage()} ", LogUtility::LVL_MSG_ERROR, self::CANONICAL); 445 return $this->getIntrinsicWidth(); 446 } 447 448 } 449 450 /** 451 * Scaled down by Ratio 452 */ 453 $ratio = $this->getRequestedAspectRatio(); 454 if (!empty($ratio)) { 455 [$logicalWidthWithRatio, $logicalHeightWithRatio] = Image::getCroppingDimensionsWithRatio( 456 $ratio, 457 $this->getIntrinsicWidth(), 458 $this->getIntrinsicHeight() 459 ); 460 return $logicalWidthWithRatio; 461 } 462 463 return $this->getIntrinsicWidth(); 464 465 } 466 467 /** 468 * @return int|null 469 * @throws ExceptionCombo 470 */ 471 public function getRequestedWidth(): ?int 472 { 473 $value = $this->attributes->getValue(Dimension::WIDTH_KEY); 474 if ($value === null) { 475 return null; 476 } 477 try { 478 return DataType::toInteger($value); 479 } catch (ExceptionCombo $e) { 480 throw new ExceptionCombo("The width value ($value) is not a valid integer", self::CANONICAL, $e); 481 } 482 } 483 484 /** 485 * @return int|null 486 * @throws ExceptionCombo 487 */ 488 public function getRequestedHeight(): ?int 489 { 490 $value = $this->attributes->getValue(Dimension::HEIGHT_KEY); 491 if ($value === null) { 492 return null; 493 } 494 try { 495 return DataType::toInteger($value); 496 } catch (ExceptionCombo $e) { 497 throw new ExceptionCombo("The height value ($value) is not a valid integer", self::CANONICAL, $e); 498 } 499 } 500 501 /** 502 * Rounding to integer 503 * The fetch.php file takes int as value for width and height 504 * making a rounding if we pass a double (such as 37.5) 505 * This is important because the security token is based on width and height 506 * and therefore the fetch will failed 507 * 508 * And not directly {@link intval} because it will make from 3.6, 3 and not 4 509 */ 510 public static function round(float $param): int 511 { 512 return intval(round($param)); 513 } 514 515 516 /** 517 * Return the width and height of the image 518 * after applying a ratio (16x9, 4x3, ..) 519 * 520 * The new dimension will apply to: 521 * * the viewBox for svg 522 * * the physical dimension for raster image 523 * 524 * TODO: This function is static because the {@link SvgDocument} is not an image but an xml 525 */ 526 public static function getCroppingDimensionsWithRatio(float $targetRatio, int $intrinsicWidth, int $intrinsicHeight): array 527 { 528 529 /** 530 * Trying to crop on the width 531 */ 532 $logicalWidth = $intrinsicWidth; 533 $logicalHeight = Image::round($logicalWidth / $targetRatio); 534 if ($logicalHeight > $intrinsicHeight) { 535 /** 536 * Cropping by height 537 */ 538 $logicalHeight = $intrinsicHeight; 539 $logicalWidth = Image::round($targetRatio * $logicalHeight); 540 } 541 return [$logicalWidth, $logicalHeight]; 542 543 } 544 545 546} 547