xref: /plugin/combo/ComboStrap/RasterImageLink.php (revision 37748cd8654635afbeca80942126742f0f4cc346)
1<?php
2/**
3 * Copyright (c) 2020. ComboStrap, Inc. and its affiliates. All Rights Reserved.
4 *
5 * This source code is licensed under the GPL license found in the
6 * COPYING  file in the root directory of this source tree.
7 *
8 * @license  GPL 3 (https://www.gnu.org/licenses/gpl-3.0.en.html)
9 * @author   ComboStrap <support@combostrap.com>
10 *
11 */
12
13namespace ComboStrap;
14
15require_once(__DIR__ . '/MediaLink.php');
16require_once(__DIR__ . '/LazyLoad.php');
17require_once(__DIR__ . '/PluginUtility.php');
18
19/**
20 * Image
21 * This is the class that handles the
22 * raster image type of the dokuwiki {@link MediaLink}
23 *
24 * The real documentation can be found on the image page
25 * @link https://www.dokuwiki.org/images
26 *
27 * Doc:
28 * https://web.dev/optimize-cls/#images-without-dimensions
29 * https://web.dev/cls/
30 */
31class RasterImageLink extends MediaLink
32{
33
34    const CANONICAL = "raster";
35    const CONF_LAZY_LOADING_ENABLE = "rasterImageLazyLoadingEnable";
36
37    const RESPONSIVE_CLASS = "img-fluid";
38
39    const CONF_RESPONSIVE_IMAGE_MARGIN = "responsiveImageMargin";
40    const CONF_RETINA_SUPPORT_ENABLED = "retinaRasterImageEnable";
41    const LAZY_CLASS = "lazy-raster-combo";
42
43    const BREAKPOINTS =
44        array(
45            "xs" => 375,
46            "sm" => 576,
47            "md" => 768,
48            "lg" => 992
49        );
50
51
52    private $imageWidth = null;
53    /**
54     * @var int
55     */
56    private $imageWeight = null;
57    /**
58     * See {@link image_type_to_mime_type}
59     * @var int
60     */
61    private $imageType;
62    private $wasAnalyzed = false;
63
64    /**
65     * @var bool
66     */
67    private $analyzable = false;
68
69    /**
70     * @var mixed - the mime from the {@link RasterImageLink::analyzeImageIfNeeded()}
71     */
72    private $mime;
73
74    /**
75     * RasterImageLink constructor.
76     * @param $ref
77     * @param TagAttributes $tagAttributes
78     */
79    public function __construct($ref, $tagAttributes = null)
80    {
81        parent::__construct($ref, $tagAttributes);
82        $this->getTagAttributes()->setLogicalTag(self::CANONICAL);
83
84    }
85
86
87    /**
88     * @param string $ampersand
89     * @param null $localWidth - the asked width - use for responsive image
90     * @return string|null
91     */
92    public function getUrl($ampersand = DokuwikiUrl::URL_ENCODED_AND, $localWidth = null)
93    {
94
95        if ($this->exists()) {
96
97            /**
98             * Link attribute
99             */
100            $att = array();
101
102            // Width is driving the computation
103            if ($localWidth != null && $localWidth != $this->getMediaWidth()) {
104
105                $att['w'] = $localWidth;
106
107                // Height
108                $height = $this->getImgTagHeightValue($localWidth);
109                if (!empty($height)) {
110                    $att['h'] = $height;
111                    $this->checkWidthAndHeightRatioAndReturnTheGoodValue($localWidth, $height);
112                }
113
114
115            }
116
117            if ($this->getCache()) {
118                $att[CacheMedia::CACHE_KEY] = $this->getCache();
119            }
120            $direct = true;
121
122            return ml($this->getId(), $att, $direct, $ampersand, true);
123
124        } else {
125
126            return false;
127
128        }
129    }
130
131    public function getAbsoluteUrl()
132    {
133
134        return $this->getUrl();
135
136    }
137
138
139    /**
140     * Render a link
141     * Snippet derived from {@link \Doku_Renderer_xhtml::internalmedia()}
142     * A media can be a video also (Use
143     * @return string
144     */
145    public function renderMediaTag()
146    {
147
148
149        if ($this->exists()) {
150
151
152            /**
153             * No dokuwiki type attribute
154             */
155            $this->tagAttributes->removeComponentAttributeIfPresent(MediaLink::MEDIA_DOKUWIKI_TYPE);
156            $this->tagAttributes->removeComponentAttributeIfPresent(MediaLink::DOKUWIKI_SRC);
157
158            /**
159             * Responsive image
160             * https://getbootstrap.com/docs/5.0/content/images/
161             * to apply max-width: 100%; and height: auto;
162             *
163             * Even if the resizing is requested by height,
164             * the height: auto on styling is needed to conserve the ratio
165             * while scaling down the screen
166             */
167            $this->tagAttributes->addClassName(self::RESPONSIVE_CLASS);
168
169
170            /**
171             * width and height to give the dimension ratio
172             * They have an effect on the space reservation
173             * but not on responsive image at all
174             * To allow responsive height, the height style property is set at auto
175             * (ie img-fluid in bootstrap)
176             */
177            // The unit is not mandatory in HTML, this is expected to be CSS pixel
178            // https://html.spec.whatwg.org/multipage/embedded-content-other.html#attr-dim-height
179            // The HTML validator does not expect an unit otherwise it send an error
180            // https://validator.w3.org/
181            $htmlLengthUnit = "";
182
183            /**
184             * Height
185             * The logical height that the image should take on the page
186             *
187             * Note: The style is also set in {@link Dimension::processWidthAndHeight()}
188             *
189             * The doc is {@link https://www.dokuwiki.org/images#resizing}
190             * See the ''0x20''
191             */
192            $imgTagHeight = $this->getImgTagHeightValue();
193            if (!empty($imgTagHeight)) {
194                $this->tagAttributes->addHtmlAttributeValue("height", $imgTagHeight . $htmlLengthUnit);
195            }
196
197
198            /**
199             * Width
200             *
201             * We create a series of URL
202             * for different width and let the browser
203             * download the best one for:
204             *   * the actual container width
205             *   * the actual of screen resolution
206             *   * and the connection speed.
207             *
208             * The max-width value is set
209             */
210            $mediaWidthValue = $this->getMediaWidth();
211            $srcValue = $this->getUrl();
212
213            /**
214             * Responsive image src set building
215             * We have chosen
216             *   * 375: Iphone6
217             *   * 768: Ipad
218             *   * 1024: Ipad Pro
219             *
220             */
221            // The image margin applied
222            $imageMargin = PluginUtility::getConfValue(self::CONF_RESPONSIVE_IMAGE_MARGIN, "20px");
223
224
225            /**
226             * Srcset and sizes for responsive image
227             * Width is mandatory for responsive image
228             * Ref https://developers.google.com/search/docs/advanced/guidelines/google-images#responsive-images
229             */
230            if (!empty($mediaWidthValue)) {
231
232                /**
233                 * The internal intrinsic value of the image
234                 */
235                $imgTagWidth = $this->getImgTagWidthValue();
236                if (!empty($imgTagWidth)) {
237
238                    if (!empty($imgTagHeight)) {
239                        $imgTagWidth = $this->checkWidthAndHeightRatioAndReturnTheGoodValue($imgTagWidth, $imgTagHeight);
240                    }
241                    $this->tagAttributes->addHtmlAttributeValue("width", $imgTagWidth . $htmlLengthUnit);
242                }
243
244                /**
245                 * Continue
246                 */
247                $srcSet = "";
248                $sizes = "";
249
250                /**
251                 * Add smaller sizes
252                 */
253                foreach (self::BREAKPOINTS as $breakpointWidth) {
254
255                    if ($imgTagWidth > $breakpointWidth) {
256
257                        if (!empty($srcSet)) {
258                            $srcSet .= ", ";
259                            $sizes .= ", ";
260                        }
261                        $breakpointWidthMinusMargin = $breakpointWidth - $imageMargin;
262                        $xsmUrl = $this->getUrl(DokuwikiUrl::URL_ENCODED_AND, $breakpointWidthMinusMargin);
263                        $srcSet .= "$xsmUrl {$breakpointWidthMinusMargin}w";
264                        $sizes .= $this->getSizes($breakpointWidth, $breakpointWidthMinusMargin);
265
266                    }
267
268                }
269
270                /**
271                 * Add the last size
272                 * If the image is really small, srcset and sizes are empty
273                 */
274                if (!empty($srcSet)) {
275                    $srcSet .= ", ";
276                    $sizes .= ", ";
277                    $srcUrl = $this->getUrl(DokuwikiUrl::URL_ENCODED_AND, $imgTagWidth);
278                    $srcSet .= "$srcUrl {$imgTagWidth}w";
279                    $sizes .= "{$imgTagWidth}px";
280                }
281
282                /**
283                 * Lazy load
284                 */
285                $lazyLoad = $this->getLazyLoad();
286                if ($lazyLoad) {
287
288                    /**
289                     * Snippet Lazy loading
290                     */
291                    LazyLoad::addLozadSnippet();
292                    PluginUtility::getSnippetManager()->attachJavascriptSnippetForBar("lozad-raster");
293                    $this->tagAttributes->addClassName(self::LAZY_CLASS);
294                    $this->tagAttributes->addClassName(LazyLoad::LAZY_CLASS);
295
296                    /**
297                     * A small image has no srcset
298                     *
299                     */
300                    if (!empty($srcSet)) {
301
302                        /**
303                         * !!!!! DON'T FOLLOW THIS ADVICE !!!!!!!!!
304                         * https://github.com/aFarkas/lazysizes/#modern-transparent-srcset-pattern
305                         * The transparent image has a fix dimension aspect ratio of 1x1 making
306                         * a bad reserved space for the image
307                         * We use a svg instead
308                         */
309                        $this->tagAttributes->addHtmlAttributeValue("src", $srcValue);
310                        $this->tagAttributes->addHtmlAttributeValue("srcset", LazyLoad::getPlaceholder($imgTagWidth,$imgTagHeight));
311                        /**
312                         * We use `data-sizes` and not `sizes`
313                         * because `sizes` without `srcset`
314                         * shows the broken image symbol
315                         * Javascript changes them at the same time
316                         */
317                        $this->tagAttributes->addHtmlAttributeValue("data-sizes", $sizes);
318                        $this->tagAttributes->addHtmlAttributeValue("data-srcset", $srcSet);
319
320                    } else {
321
322                        /**
323                         * Small image but there is no little improvement
324                         */
325                        $this->tagAttributes->addHtmlAttributeValue("data-src", $srcValue);
326
327                    }
328
329                    LazyLoad::addPlaceholderBackground($this->tagAttributes);
330
331
332                } else {
333
334                    if (!empty($srcSet)) {
335                        $this->tagAttributes->addHtmlAttributeValue("srcset", $srcSet);
336                        $this->tagAttributes->addHtmlAttributeValue("sizes", $sizes);
337                    } else {
338                        $this->tagAttributes->addHtmlAttributeValue("src", $srcValue);
339                    }
340
341                }
342
343            } else {
344
345                // No width, no responsive possibility
346                $lazyLoad = $this->getLazyLoad();
347                if ($lazyLoad) {
348
349                    LazyLoad::addPlaceholderBackground($this->tagAttributes);
350                    $this->tagAttributes->addHtmlAttributeValue("src", LazyLoad::getPlaceholder());
351                    $this->tagAttributes->addHtmlAttributeValue("data-src", $srcValue);
352
353                }
354
355            }
356
357
358            /**
359             * Title (ie alt)
360             */
361            if ($this->tagAttributes->hasComponentAttribute(TagAttributes::TITLE_KEY)) {
362                $title = $this->tagAttributes->getValueAndRemove(TagAttributes::TITLE_KEY);
363                $this->tagAttributes->addHtmlAttributeValueIfNotEmpty("alt", $title);
364            }
365
366            /**
367             * Create the img element
368             */
369            $htmlAttributes = $this->tagAttributes->toHTMLAttributeString();
370            $imgHTML = '<img ' . $htmlAttributes . '/>';
371
372        } else {
373
374            $imgHTML = "<span class=\"text-danger\">The image ($this) does not exist</span>";
375
376        }
377
378        return $imgHTML;
379    }
380
381    /**
382     * @return int - the width of the image from the file
383     */
384    public
385    function getMediaWidth()
386    {
387        $this->analyzeImageIfNeeded();
388        return $this->imageWidth;
389    }
390
391    /**
392     * @return int - the height of the image from the file
393     */
394    public
395    function getMediaHeight()
396    {
397        $this->analyzeImageIfNeeded();
398        return $this->imageWeight;
399    }
400
401    private
402    function analyzeImageIfNeeded()
403    {
404
405        if (!$this->wasAnalyzed) {
406
407            if ($this->exists()) {
408
409                /**
410                 * Based on {@link media_image_preview_size()}
411                 * $dimensions = media_image_preview_size($this->id, '', false);
412                 */
413                $imageInfo = array();
414                $imageSize = getimagesize($this->getFileSystemPath(), $imageInfo);
415                if ($imageSize === false) {
416                    $this->analyzable = false;
417                    LogUtility::msg("We couldn't retrieve the type and dimensions of the image ($this). The image format seems to be not supported.", LogUtility::LVL_MSG_ERROR, self::CANONICAL);
418                } else {
419                    $this->analyzable = true;
420                    $this->imageWidth = (int)$imageSize[0];
421                    if (empty($this->imageWidth)) {
422                        $this->analyzable = false;
423                    }
424                    $this->imageWeight = (int)$imageSize[1];
425                    if (empty($this->imageWeight)) {
426                        $this->analyzable = false;
427                    }
428                    $this->imageType = (int)$imageSize[2];
429                    $this->mime = $imageSize[3];
430                }
431            }
432        }
433        $this->wasAnalyzed = true;
434    }
435
436
437    /**
438     *
439     * @return bool true if we could extract the dimensions
440     */
441    public
442    function isAnalyzable()
443    {
444        $this->analyzeImageIfNeeded();
445        return $this->analyzable;
446
447    }
448
449
450    public function getRequestedHeight()
451    {
452        $requestedHeight = parent::getRequestedHeight();
453        if (!empty($requestedHeight)) {
454            // it should not be bigger than the media Height
455            $mediaHeight = $this->getMediaHeight();
456            if (!empty($mediaHeight)) {
457                if ($requestedHeight > $mediaHeight) {
458                    LogUtility::msg("For the image ($this), the requested height of ($requestedHeight) can not be bigger than the intrinsic height of ($mediaHeight). The height was then set to its natural height ($mediaHeight)", LogUtility::LVL_MSG_ERROR, self::CANONICAL);
459                    $requestedHeight = $mediaHeight;
460                }
461            }
462        }
463        return $requestedHeight;
464    }
465
466    public function getRequestedWidth()
467    {
468        $requestedWidth = parent::getRequestedWidth();
469        if (!empty($requestedWidth)) {
470            // it should not be bigger than the media Height
471            $mediaWidth = $this->getMediaWidth();
472            if (!empty($mediaWidth)) {
473                if ($requestedWidth > $mediaWidth) {
474                    global $ID;
475                    if ($ID != "wiki:syntax") {
476                        // There is a bug in the wiki syntax page
477                        // {{wiki:dokuwiki-128.png?200x50}}
478                        // https://forum.dokuwiki.org/d/19313-bugtypo-how-to-make-a-request-to-change-the-syntax-page-on-dokuwikii
479                        LogUtility::msg("For the image ($this), the requested width of ($requestedWidth) can not be bigger than the intrinsic width of ($mediaWidth). The width was then set to its natural width ($mediaWidth)", LogUtility::LVL_MSG_ERROR, self::CANONICAL);
480                    }
481                    $requestedWidth = $mediaWidth;
482                }
483            }
484        }
485        return $requestedWidth;
486    }
487
488
489    public
490    function getLazyLoad()
491    {
492        $lazyLoad = parent::getLazyLoad();
493        if ($lazyLoad !== null) {
494            return $lazyLoad;
495        } else {
496            return PluginUtility::getConfValue(RasterImageLink::CONF_LAZY_LOADING_ENABLE);
497        }
498    }
499
500    /**
501     * @param $screenWidth
502     * @param $imageWidth
503     * @return string sizes with a dpi correction if
504     */
505    private
506    function getSizes($screenWidth, $imageWidth)
507    {
508
509        if ($this->getWithDpiCorrection()) {
510            $dpiBase = 96;
511            $sizes = "(max-width: {$screenWidth}px) and (min-resolution:" . (3 * $dpiBase) . "dpi) " . intval($imageWidth / 3) . "px";
512            $sizes .= ", (max-width: {$screenWidth}px) and (min-resolution:" . (2 * $dpiBase) . "dpi) " . intval($imageWidth / 2) . "px";
513            $sizes .= ", (max-width: {$screenWidth}px) and (min-resolution:" . (1 * $dpiBase) . "dpi) {$imageWidth}px";
514        } else {
515            $sizes = "(max-width: {$screenWidth}px) {$imageWidth}px";
516        }
517        return $sizes;
518    }
519
520    /**
521     * Return if the DPI correction is enabled or not for responsive image
522     *
523     * Mobile have a higher DPI and can then fit a bigger image on a smaller size.
524     *
525     * This can be disturbing when debugging responsive sizing image
526     * If you want also to use less bandwidth, this is also useful.
527     *
528     * @return bool
529     */
530    private
531    function getWithDpiCorrection()
532    {
533        /**
534         * Support for retina means no DPI correction
535         */
536        $retinaEnabled = PluginUtility::getConfValue(self::CONF_RETINA_SUPPORT_ENABLED, 0);
537        return !$retinaEnabled;
538    }
539
540
541}
542