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 (substr($mime, 6) == "svg+xml") {
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     *
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     */
194    public function getIntrinsicAspectRatio()
195    {
196
197        if ($this->getIntrinsicHeight() == null || $this->getIntrinsicWidth() == null) {
198            return false;
199        } else {
200            return $this->getIntrinsicWidth() / $this->getIntrinsicHeight();
201        }
202    }
203
204    /**
205     * The Aspect ratio of the target image (may be the original or the an image scaled down)
206     *
207     * https://html.spec.whatwg.org/multipage/embedded-content-other.html#attr-dim-height
208     * @return float|int|false
209     * false if the image is not supported
210     *
211     * It's needed for an img tag to set the img `width` and `height` that pass the
212     * {@link MediaLink::checkWidthAndHeightRatioAndReturnTheGoodValue() check}
213     * to avoid layout shift
214     * @throws ExceptionCombo
215     */
216    public function getTargetAspectRatio()
217    {
218
219        $targetHeight = $this->getTargetHeight();
220        if ($targetHeight === 0) {
221            throw new ExceptionCombo("The target height is equal to zero, we can calculate the target aspect ratio");
222        }
223        $targetWidth = $this->getTargetWidth();
224        return $targetWidth / $targetHeight;
225
226    }
227
228    /**
229     * The Aspect ratio as explained here
230     * https://html.spec.whatwg.org/multipage/embedded-content-other.html#attr-dim-height
231     * @return float|int
232     * false if the image is not supported
233     *
234     * It's needed for an img tag to set the img `width` and `height` that pass the
235     * {@link MediaLink::checkWidthAndHeightRatioAndReturnTheGoodValue() check}
236     * to avoid layout shift
237     * @throws ExceptionCombo
238     */
239    public function getRequestedAspectRatio()
240    {
241
242        $requestedRatio = $this->attributes->getValue(Dimension::RATIO_ATTRIBUTE);
243        if ($requestedRatio !== null) {
244            try {
245                return Dimension::convertTextualRatioToNumber($requestedRatio);
246            } catch (ExceptionCombo $e) {
247                LogUtility::msg("The requested ratio ($requestedRatio) is not a valid value ({$e->getMessage()})", LogUtility::LVL_MSG_ERROR, self::CANONICAL);
248            }
249        }
250
251        if (
252            $this->getRequestedWidth() !== null
253            && $this->getRequestedWidth() !== 0 // default value for not set in dokuwiki
254            && $this->getRequestedHeight() !== null) {
255            if ($this->getRequestedHeight() === 0) {
256                LogUtility::msg("The requested height is 0, we can't calculate the requested ratio", LogUtility::LVL_MSG_ERROR, self::CANONICAL);
257            }
258            return $this->getRequestedWidth() / $this->getRequestedHeight();
259        }
260
261        return null;
262
263
264    }
265
266    /**
267     * @return bool if this is raster image, false if this is a vector image
268     */
269    public function isRaster(): bool
270    {
271        if ($this->getPath()->getMime()->toString() === Mime::SVG) {
272            return false;
273        } else {
274            return true;
275        }
276    }
277
278    /**
279     * Giving width and height, check that the aspect ratio is the same
280     * than the target one
281     * @param $height
282     * @param $width
283     */
284    public
285    function checkLogicalRatioAgainstTargetRatio($width, $height)
286    {
287        /**
288         * Check of height and width dimension
289         * as specified here
290         *
291         * This is about the intrinsic dimension but we have the notion of target dimension
292         *
293         * https://html.spec.whatwg.org/multipage/embedded-content-other.html#attr-dim-height
294         */
295        try {
296            $targetRatio = $this->getTargetAspectRatio();
297        } catch (ExceptionCombo $e) {
298            LogUtility::msg("Unable to check the target ratio because it returns this error: {$e->getMessage()}");
299            return;
300        }
301        if (!(
302            $height * $targetRatio >= $width - 1
303            &&
304            $height * $targetRatio <= $width + 1
305        )) {
306            // check the second statement
307            if (!(
308                $width / $targetRatio >= $height - 1
309                &&
310                $width / $targetRatio <= $height + 1
311            )) {
312
313                /**
314                 * Programmatic error from the developer
315                 */
316                $imgTagRatio = $width / $height;
317                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)");
318
319            }
320        }
321    }
322
323    /**
324     * The Url
325     * @return mixed
326     */
327    public abstract function getAbsoluteUrl();
328
329    /**
330     * This is mandatory for HTML
331     * The alternate text (the title in Dokuwiki media term)
332     * @return null
333     *
334     * TODO: try to extract it from the metadata file ?
335     *
336     * An img element must have an alt attribute, except under certain conditions.
337     * For details, consult guidance on providing text alternatives for images.
338     * https://www.w3.org/WAI/tutorials/images/
339     */
340    public function getAltNotEmpty()
341    {
342        $title = $this->getTitle();
343        if (!empty($title)) {
344            return $title;
345        }
346        $generatedAlt = str_replace("-", " ", $this->getPath()->getLastNameWithoutExtension());
347        return str_replace($generatedAlt, "_", " ");
348    }
349
350
351    /**
352     * The logical height is the calculated height of the target image
353     * specified in the query parameters
354     *
355     * For instance,
356     *   * 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}
357     *   * 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}
358     *
359     * The doc is {@link https://www.dokuwiki.org/images#resizing}
360     *
361     *
362     * @return int
363     * @throws ExceptionCombo
364     */
365    public function getTargetHeight(): int
366    {
367        $requestedHeight = $this->getRequestedHeight();
368        if (!empty($requestedHeight)) {
369            return $requestedHeight;
370        }
371
372        /**
373         * Scaled down by width
374         */
375        $width = $this->getRequestedWidth();
376        if (!empty($width)) {
377
378            try {
379                $ratio = $this->getRequestedAspectRatio();
380                if ($ratio === null) {
381                    $ratio = $this->getIntrinsicAspectRatio();
382                }
383                return self::round($width / $ratio);
384            } catch (ExceptionCombo $e) {
385                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);
386                return $this->getIntrinsicHeight();
387            }
388
389        }
390
391        /**
392         * Scaled down by ratio
393         */
394        $ratio = $this->getRequestedAspectRatio();
395        if (!empty($ratio)) {
396            [$croppedWidth, $croppedHeight] = Image::getCroppingDimensionsWithRatio(
397                $ratio,
398                $this->getIntrinsicWidth(),
399                $this->getIntrinsicHeight()
400            );
401            return $croppedHeight;
402        }
403
404        return $this->getIntrinsicHeight();
405
406    }
407
408    /**
409     * The logical width is the width of the target image calculated from the requested dimension
410     *
411     * For instance,
412     *   * 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}
413     *   * 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}
414     *
415     * The doc is {@link https://www.dokuwiki.org/images#resizing}
416     * @throws ExceptionCombo
417     */
418    public function getTargetWidth(): int
419    {
420
421        $requestedWidth = $this->getRequestedWidth();
422
423        /**
424         * May be 0 (ie empty)
425         */
426        if (!empty($requestedWidth)) {
427            return $requestedWidth;
428        }
429
430        /**
431         * Scaled down by Height
432         */
433        $height = $this->getRequestedHeight();
434        if (!empty($height)) {
435
436            try {
437                $ratio = $this->getRequestedAspectRatio();
438                if ($ratio === null) {
439                    $ratio = $this->getIntrinsicAspectRatio();
440                }
441                return self::round($ratio * $height);
442            } catch (ExceptionCombo $e) {
443                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);
444                return $this->getIntrinsicWidth();
445            }
446
447        }
448
449        /**
450         * Scaled down by Ratio
451         */
452        $ratio = $this->getRequestedAspectRatio();
453        if (!empty($ratio)) {
454            [$logicalWidthWithRatio, $logicalHeightWithRatio] = Image::getCroppingDimensionsWithRatio(
455                $ratio,
456                $this->getIntrinsicWidth(),
457                $this->getIntrinsicHeight()
458            );
459            return $logicalWidthWithRatio;
460        }
461
462        return $this->getIntrinsicWidth();
463
464    }
465
466    /**
467     * @return int|null
468     * @throws ExceptionCombo
469     */
470    public function getRequestedWidth(): ?int
471    {
472        $value = $this->attributes->getValue(Dimension::WIDTH_KEY);
473        if ($value === null) {
474            return null;
475        }
476        try {
477            return DataType::toInteger($value);
478        } catch (ExceptionCombo $e) {
479            throw new ExceptionCombo("The width value ($value) is not a valid integer", self::CANONICAL, $e);
480        }
481    }
482
483    /**
484     * @return int|null
485     * @throws ExceptionCombo
486     */
487    public function getRequestedHeight(): ?int
488    {
489        $value = $this->attributes->getValue(Dimension::HEIGHT_KEY);
490        if ($value === null) {
491            return null;
492        }
493        try {
494            return DataType::toInteger($value);
495        } catch (ExceptionCombo $e) {
496            throw new ExceptionCombo("The height value ($value) is not a valid integer", self::CANONICAL, $e);
497        }
498    }
499
500    /**
501     * Rounding to integer
502     * The fetch.php file takes int as value for width and height
503     * making a rounding if we pass a double (such as 37.5)
504     * This is important because the security token is based on width and height
505     * and therefore the fetch will failed
506     *
507     * And not directly {@link intval} because it will make from 3.6, 3 and not 4
508     */
509    public static function round(float $param): int
510    {
511        return intval(round($param));
512    }
513
514
515    /**
516     * Return the width and height of the image
517     * after applying a ratio (16x9, 4x3, ..)
518     *
519     * The new dimension will apply to:
520     *   * the viewBox for svg
521     *   * the physical dimension for raster image
522     *
523     * TODO: This function is static because the {@link SvgDocument} is not an image but an xml
524     */
525    public static function getCroppingDimensionsWithRatio(float $targetRatio, int $intrinsicWidth, int $intrinsicHeight): array
526    {
527
528        /**
529         * Trying to crop on the width
530         */
531        $logicalWidth = $intrinsicWidth;
532        $logicalHeight = Image::round($logicalWidth / $targetRatio);
533        if ($logicalHeight > $intrinsicHeight) {
534            /**
535             * Cropping by height
536             */
537            $logicalHeight = $intrinsicHeight;
538            $logicalWidth = Image::round($targetRatio * $logicalHeight);
539        }
540        return [$logicalWidth, $logicalHeight];
541
542    }
543
544
545}
546