1<?php 2 3namespace ComboStrap; 4 5use ComboStrap\Web\Url; 6 7/** 8 * Image request / response 9 * 10 * with requested attribute (ie a file and its transformation attribute if any such as 11 * width, height, ...) 12 * 13 * Image may be generated that's why they don't extends {@link FetcherRawLocalPath}. 14 * Image that depends on a source file use the {@link FetcherTraitWikiPath} and extends {@link IFetcherLocalImage} 15 * 16 * See also third provider such as: 17 * * https://docs.imgix.com/setup/quick-start - still need to host them (https://docs.imgix.com/apis/rendering) 18 * 19 * 20 * 21 */ 22abstract class FetcherImage extends IFetcherAbs implements IFetcherPath 23{ 24 25 const TOK = "tok"; 26 const CANONICAL = "image"; 27 28 29 protected ?int $requestedWidth = null; 30 protected ?int $requestedHeight = null; 31 32 private ?string $requestedRatio = null; 33 private ?float $requestedRatioAsFloat = null; 34 35 36 /** 37 * Image Fetch constructor. 38 * 39 */ 40 public function __construct() 41 { 42 /** 43 * Image can be generated, ie {@link FetcherVignette}, {@link FetcherScreenshot} 44 */ 45 } 46 47 48 /** 49 * @param Url|null $url 50 * 51 */ 52 public function getFetchUrl(Url $url = null): Url 53 { 54 $url = parent::getFetchUrl($url); 55 56 try { 57 $ratio = $this->getRequestedAspectRatio(); 58 $url->addQueryParameterIfNotPresent(Dimension::RATIO_ATTRIBUTE, $ratio); 59 } catch (ExceptionNotFound $e) { 60 // no width ok 61 } 62 63 try { 64 $requestedWidth = $this->getRequestedWidth(); 65 $url->addQueryParameterIfNotPresent(Dimension::WIDTH_KEY_SHORT, $requestedWidth); 66 } catch (ExceptionNotFound $e) { 67 // no width ok 68 } 69 70 try { 71 $requestedHeight = $this->getRequestedHeight(); 72 $url->addQueryParameterIfNotPresent(Dimension::HEIGHT_KEY_SHORT, $requestedHeight); 73 } catch (ExceptionNotFound $e) { 74 // no height ok 75 } 76 77 78 /** 79 * Dokuwiki Conformance 80 */ 81 try { 82 $url->addQueryParameter(FetcherImage::TOK, $this->getTok()); 83 } catch (ExceptionNotNeeded $e) { 84 // ok not needed 85 } 86 87 88 return $url; 89 } 90 91 /** 92 * The tok is supposed to counter a DDOS attack when 93 * with or height are requested 94 * 95 * 96 * @throws ExceptionNotNeeded 97 */ 98 public function getTok(): string 99 { 100 /** 101 * Dokuwiki Compliance 102 */ 103 if (!($this instanceof IFetcherLocalImage)) { 104 throw new ExceptionNotNeeded("No tok for non local image"); 105 } 106 try { 107 $requestedWidth = $this->getRequestedWidth(); 108 } catch (ExceptionNotFound $e) { 109 $requestedWidth = null; 110 } 111 try { 112 $requestedHeight = $this->getRequestedHeight(); 113 } catch (ExceptionNotFound $e) { 114 $requestedHeight = null; 115 } 116 if ($requestedWidth !== null || $requestedHeight !== null) { 117 118 try { 119 $id = $this->getSourcePath()->toWikiPath()->getWikiId(); 120 } catch (ExceptionCast $e) { 121 LogUtility::error("Unable to calculate the image tok. The source path is not a web/wiki path", self::CANONICAL, $e); 122 throw new ExceptionNotNeeded("No tok added, error " . $e->getMessage()); 123 } 124 return media_get_token($id, $requestedWidth, $requestedHeight); 125 126 } 127 throw new ExceptionNotNeeded("No tok needed"); 128 } 129 130 /** 131 * @throws ExceptionBadArgument 132 */ 133 public function buildFromTagAttributes(TagAttributes $tagAttributes): FetcherImage 134 { 135 136 $requestedWidth = $tagAttributes->getValueAndRemove(Dimension::WIDTH_KEY); 137 if ($requestedWidth === null) { 138 $requestedWidth = $tagAttributes->getValueAndRemove(Dimension::WIDTH_KEY_SHORT); 139 } 140 if ($requestedWidth !== null) { 141 try { 142 $requestedWidthInt = DataType::toInteger(ConditionalLength::createFromString($requestedWidth)->toPixelNumber()); 143 } catch (ExceptionBadArgument $e) { 144 throw new ExceptionBadArgument("The width value ($requestedWidth) is not a valid integer", FetcherImage::CANONICAL, 0, $e); 145 } 146 $this->setRequestedWidth($requestedWidthInt); 147 } 148 149 $requestedHeight = $tagAttributes->getValueAndRemove(Dimension::HEIGHT_KEY); 150 if ($requestedHeight === null) { 151 $requestedHeight = $tagAttributes->getValueAndRemove(Dimension::HEIGHT_KEY_SHORT); 152 } 153 if ($requestedHeight !== null) { 154 try { 155 $requestedHeightInt = DataType::toInteger($requestedHeight); 156 } catch (ExceptionBadArgument $e) { 157 throw new ExceptionBadArgument("The height value ($requestedHeight) is not a valid integer", FetcherImage::CANONICAL, 0, $e); 158 } 159 $this->setRequestedHeight($requestedHeightInt); 160 } 161 162 $requestedRatio = $tagAttributes->getValueAndRemove(Dimension::RATIO_ATTRIBUTE); 163 if ($requestedRatio !== null) { 164 try { 165 $this->setRequestedAspectRatio($requestedRatio); 166 } catch (ExceptionBadSyntax $e) { 167 throw new ExceptionBadArgument("The requested ratio ($requestedRatio) is not a valid value ({$e->getMessage()})", FetcherImage::CANONICAL, 0, $e); 168 } 169 } 170 parent::buildFromTagAttributes($tagAttributes); 171 return $this; 172 } 173 174 175 /** 176 * For a raster image, the internal width 177 * for a svg, the defined viewBox 178 * 179 * @return int in pixel 180 */ 181 public 182 183 abstract function getIntrinsicWidth(): int; 184 185 /** 186 * For a raster image, the internal height 187 * for a svg, the defined `viewBox` value 188 * 189 * This is needed to calculate the {@link MediaLink::getTargetRatio() target ratio} 190 * and pass them to the img tag to avoid layout shift 191 * 192 * @return int in pixel 193 */ 194 public abstract function getIntrinsicHeight(): int; 195 196 /** 197 * The Aspect ratio as explained here 198 * https://html.spec.whatwg.org/multipage/embedded-content-other.html#attr-dim-height 199 * @return float 200 * false if the image is not supported 201 * 202 * It's needed for an img tag to set the img `width` and `height` that pass the 203 * {@link MediaLink::checkWidthAndHeightRatioAndReturnTheGoodValue() check} 204 * to avoid layout shift 205 * 206 */ 207 public function getIntrinsicAspectRatio(): float 208 { 209 210 return $this->getIntrinsicWidth() / $this->getIntrinsicHeight(); 211 212 } 213 214 /** 215 * The Aspect ratio of the target image (may be the original or the an image scaled down) 216 * 217 * https://html.spec.whatwg.org/multipage/embedded-content-other.html#attr-dim-height 218 * @return float 219 * false if the image is not supported 220 * 221 * It's needed for an img tag to set the img `width` and `height` that pass the 222 * {@link MediaLink::checkWidthAndHeightRatioAndReturnTheGoodValue() check} 223 * to avoid layout shift 224 * 225 */ 226 public function getTargetAspectRatio() 227 { 228 229 return $this->getTargetWidth() / $this->getTargetHeight(); 230 231 } 232 233 /** 234 * Return the requested aspect ratio requested 235 * with the property 236 * or if the width and height were specified. 237 * 238 * The Aspect ratio as explained here 239 * https://html.spec.whatwg.org/multipage/embedded-content-other.html#attr-dim-height 240 * @return float 241 * 242 * 243 * It's needed for an img tag to set the img `width` and `height` that pass the 244 * {@link MediaLink::checkWidthAndHeightRatioAndReturnTheGoodValue() check} 245 * to avoid layout shift 246 * @throws ExceptionNotFound 247 */ 248 public function getCalculatedRequestedAspectRatioAsFloat(): float 249 { 250 251 if ($this->requestedRatioAsFloat !== null) { 252 return $this->requestedRatioAsFloat; 253 } 254 255 /** 256 * Note: requested weight and width throw a `not found` if width / height == 0 257 * No division by zero then 258 */ 259 return $this->getRequestedWidth() / $this->getRequestedHeight(); 260 261 262 } 263 264 265 /** 266 * Giving width and height, check that the aspect ratio is the same 267 * than the target one 268 * @param $height 269 * @param $width 270 */ 271 public 272 function checkLogicalRatioAgainstTargetRatio($width, $height) 273 { 274 /** 275 * Check of height and width dimension 276 * as specified here 277 * 278 * This is about the intrinsic dimension but we have the notion of target dimension 279 * 280 * https://html.spec.whatwg.org/multipage/embedded-content-other.html#attr-dim-height 281 */ 282 try { 283 $targetRatio = $this->getTargetAspectRatio(); 284 } catch (ExceptionCompile $e) { 285 LogUtility::msg("Unable to check the target ratio because it returns this error: {$e->getMessage()}"); 286 return; 287 } 288 if (!( 289 $height * $targetRatio >= $width - 1 290 && 291 $height * $targetRatio <= $width + 1 292 )) { 293 // check the second statement 294 if (!( 295 $width / $targetRatio >= $height - 1 296 && 297 $width / $targetRatio <= $height + 1 298 )) { 299 300 /** 301 * Programmatic error from the developer 302 */ 303 $imgTagRatio = $width / $height; 304 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)"); 305 306 } 307 } 308 } 309 310 311 /** 312 * The logical height is the calculated height of the target image 313 * specified in the query parameters 314 * 315 * For instance, 316 * * with `200`, the target image has a {@link FetcherTraitImage::getTargetWidth() logical width} of 200 and a {@link FetcherTraitImage::getTargetHeight() logical height} that is scaled down by the {@link FetcherTraitImage::getIntrinsicAspectRatio() instrinsic ratio} 317 * * with ''0x20'', the target image has a {@link FetcherTraitImage::getTargetHeight() logical height} of 20 and a {@link FetcherTraitImage::getTargetWidth() logical width} that is scaled down by the {@link FetcherTraitImage::getIntrinsicAspectRatio() instrinsic ratio} 318 * 319 * The doc is {@link https://www.dokuwiki.org/images#resizing} 320 * 321 * 322 * @return int 323 */ 324 public function getTargetHeight(): int 325 { 326 327 try { 328 return $this->getRequestedHeight(); 329 } catch (ExceptionNotFound $e) { 330 // no height 331 } 332 333 /** 334 * Scaled down by width 335 */ 336 try { 337 $width = $this->getRequestedWidth(); 338 try { 339 $ratio = $this->getCalculatedRequestedAspectRatioAsFloat(); 340 } catch (ExceptionNotFound $e) { 341 $ratio = $this->getIntrinsicAspectRatio(); 342 } 343 return self::round($width / $ratio); 344 } catch (ExceptionNotFound $e) { 345 // no width 346 } 347 348 349 /** 350 * Scaled down by ratio 351 */ 352 try { 353 $ratio = $this->getCalculatedRequestedAspectRatioAsFloat(); 354 [$croppedWidth, $croppedHeight] = $this->getCroppingDimensionsWithRatio($ratio); 355 return $croppedHeight; 356 } catch (ExceptionNotFound $e) { 357 // no requested aspect ratio 358 } 359 360 return $this->getIntrinsicHeight(); 361 362 } 363 364 /** 365 * The logical width is the width of the target image calculated from the requested dimension 366 * 367 * For instance, 368 * * with `200`, the target image has a {@link FetcherTraitImage::getTargetWidth() logical width} of 200 and a {@link FetcherTraitImage::getTargetHeight() logical height} that is scaled down by the {@link FetcherTraitImage::getIntrinsicAspectRatio() instrinsic ratio} 369 * * with ''0x20'', the target image has a {@link FetcherTraitImage::getTargetHeight() logical height} of 20 and a {@link FetcherTraitImage::getTargetWidth() logical width} that is scaled down by the {@link FetcherTraitImage::getIntrinsicAspectRatio() instrinsic ratio} 370 * 371 * The doc is {@link https://www.dokuwiki.org/images#resizing} 372 * @return int 373 */ 374 public function getTargetWidth(): int 375 { 376 377 try { 378 return $this->getRequestedWidth(); 379 } catch (ExceptionNotFound $e) { 380 // no requested width 381 } 382 383 /** 384 * Scaled down by Height 385 */ 386 try { 387 $height = $this->getRequestedHeight(); 388 try { 389 $ratio = $this->getCalculatedRequestedAspectRatioAsFloat(); 390 } catch (ExceptionNotFound $e) { 391 $ratio = $this->getIntrinsicAspectRatio(); 392 } 393 return self::round($ratio * $height); 394 } catch (ExceptionNotFound $e) { 395 // no requested height 396 } 397 398 399 /** 400 * Scaled down by Ratio 401 */ 402 try { 403 $ratio = $this->getCalculatedRequestedAspectRatioAsFloat(); 404 [$logicalWidthWithRatio, $logicalHeightWithRatio] = $this->getCroppingDimensionsWithRatio($ratio); 405 return $logicalWidthWithRatio; 406 } catch (ExceptionNotFound $e) { 407 // no ratio requested 408 } 409 410 return $this->getIntrinsicWidth(); 411 412 } 413 414 /** 415 * @return int|null 416 * @throws ExceptionNotFound - if no requested width was asked 417 */ 418 public function getRequestedWidth(): int 419 { 420 if ($this->requestedWidth === null) { 421 throw new ExceptionNotFound("No width was requested"); 422 } 423 if ($this->requestedWidth === 0) { 424 throw new ExceptionNotFound("Width 0 was requested"); 425 } 426 return $this->requestedWidth; 427 } 428 429 /** 430 * @return int 431 * @throws ExceptionNotFound - if no requested height was asked 432 */ 433 public function getRequestedHeight(): int 434 { 435 if ($this->requestedHeight === null) { 436 throw new ExceptionNotFound("Height not requested"); 437 } 438 if ($this->requestedHeight === 0) { 439 throw new ExceptionNotFound("Height 0 requested"); 440 } 441 return $this->requestedHeight; 442 } 443 444 /** 445 * Rounding to integer 446 * The fetch.php file takes int as value for width and height 447 * making a rounding if we pass a double (such as 37.5) 448 * This is important because the security token is based on width and height 449 * and therefore the fetch will failed 450 * 451 * And not directly {@link intval} because it will make from 3.6, 3 and not 4 452 * 453 * And this is also ask by the specification 454 * a non-null positive integer 455 * https://html.spec.whatwg.org/multipage/embedded-content-other.html#attr-dim-height 456 * 457 */ 458 public static function round(float $param): int 459 { 460 return intval(round($param)); 461 } 462 463 464 /** 465 * 466 * Return the width and height of the image 467 * after applying a ratio (16x9, 4x3, ..) 468 * 469 * The new dimension will apply to: 470 * * the viewBox for svg 471 * * the physical dimension for raster image 472 * 473 */ 474 public function getCroppingDimensionsWithRatio(float $targetRatio): array 475 { 476 477 /** 478 * Trying to crop on the width 479 */ 480 $logicalWidth = $this->getIntrinsicWidth(); 481 $logicalHeight = $this->round($logicalWidth / $targetRatio); 482 if ($logicalHeight > $this->getIntrinsicHeight()) { 483 /** 484 * Cropping by height 485 */ 486 $logicalHeight = $this->getIntrinsicHeight(); 487 $logicalWidth = $this->round($targetRatio * $logicalHeight); 488 } 489 return [$logicalWidth, $logicalHeight]; 490 491 } 492 493 494 public function setRequestedWidth(int $requestedWidth): FetcherImage 495 { 496 $this->requestedWidth = $requestedWidth; 497 return $this; 498 } 499 500 public function setRequestedHeight(int $requestedHeight): FetcherImage 501 { 502 $this->requestedHeight = $requestedHeight; 503 return $this; 504 } 505 506 /** 507 * @throws ExceptionBadSyntax 508 */ 509 public function setRequestedAspectRatio(string $requestedRatio): FetcherImage 510 { 511 $this->requestedRatio = $requestedRatio; 512 $this->requestedRatioAsFloat = Dimension::convertTextualRatioToNumber($requestedRatio); 513 return $this; 514 } 515 516 517 public function __toString() 518 { 519 return get_class($this); 520 } 521 522 523 public function hasHeightRequested(): bool 524 { 525 try { 526 $this->getRequestedHeight(); 527 return true; 528 } catch (ExceptionNotFound $e) { 529 return false; 530 } 531 } 532 533 public function hasAspectRatioRequested(): bool 534 { 535 try { 536 $this->getCalculatedRequestedAspectRatioAsFloat(); 537 return true; 538 } catch (ExceptionNotFound $e) { 539 return false; 540 } 541 542 } 543 544 545 /** 546 * @throws ExceptionNotFound 547 */ 548 public function getRequestedAspectRatio(): string 549 { 550 if ($this->requestedRatio === null) { 551 throw new ExceptionNotFound("No ratio was specified"); 552 } 553 return $this->requestedRatio; 554 } 555 556 public function isCropRequested(): bool 557 { 558 if ($this->requestedHeight !== null && $this->requestedWidth !== null) { 559 return true; 560 } 561 if ($this->requestedRatio != null) { 562 return true; 563 } 564 return false; 565 } 566 567 568} 569