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