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