xref: /plugin/combo/ComboStrap/RasterImageLink.php (revision 82a60d039cd81033dc8147c27f0a50716b7a5301)
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 ImageLink
32{
33
34    const CANONICAL = ImageRaster::CANONICAL;
35    const CONF_LAZY_LOADING_ENABLE = "rasterImageLazyLoadingEnable";
36    const CONF_LAZY_LOADING_ENABLE_DEFAULT = 1;
37
38    const RESPONSIVE_CLASS = "img-fluid";
39
40    const CONF_RESPONSIVE_IMAGE_MARGIN = "responsiveImageMargin";
41    const CONF_RETINA_SUPPORT_ENABLED = "retinaRasterImageEnable";
42    const LAZY_CLASS = "lazy-raster-combo";
43
44    const BREAKPOINTS =
45        array(
46            "xs" => 375,
47            "sm" => 576,
48            "md" => 768,
49            "lg" => 992
50        );
51
52
53    /**
54     * RasterImageLink constructor.
55     * @param ImageRaster $imageRaster
56     */
57    public function __construct($imageRaster)
58    {
59        parent::__construct($imageRaster);
60
61
62    }
63
64
65    /**
66     * Render a link
67     * Snippet derived from {@link \Doku_Renderer_xhtml::internalmedia()}
68     * A media can be a video also (Use
69     * @return string
70     */
71    public function renderMediaTag(): string
72    {
73        /**
74         * @var ImageRaster $image
75         */
76        $image = $this->getDefaultImage();
77        if ($image->exists()) {
78
79            $attributes = $image->getAttributes();
80
81            /**
82             * No dokuwiki type attribute
83             */
84            $attributes->removeComponentAttributeIfPresent(MediaLink::MEDIA_DOKUWIKI_TYPE);
85            $attributes->removeComponentAttributeIfPresent(MediaLink::DOKUWIKI_SRC);
86
87            /**
88             * Responsive image
89             * https://getbootstrap.com/docs/5.0/content/images/
90             * to apply max-width: 100%; and height: auto;
91             *
92             * Even if the resizing is requested by height,
93             * the height: auto on styling is needed to conserve the ratio
94             * while scaling down the screen
95             */
96            $attributes->addClassName(self::RESPONSIVE_CLASS);
97
98
99            /**
100             * width and height to give the dimension ratio
101             * They have an effect on the space reservation
102             * but not on responsive image at all
103             * To allow responsive height, the height style property is set at auto
104             * (ie img-fluid in bootstrap)
105             */
106            // The unit is not mandatory in HTML, this is expected to be CSS pixel
107            // https://html.spec.whatwg.org/multipage/embedded-content-other.html#attr-dim-height
108            // The HTML validator does not expect an unit otherwise it send an error
109            // https://validator.w3.org/
110            $htmlLengthUnit = "";
111            $cssLengthUnit = "px";
112
113            /**
114             * Height
115             * The logical height that the image should take on the page
116             *
117             * Note: The style is also set in {@link Dimension::processWidthAndHeight()}
118             *
119             */
120            try {
121                $targetHeight = $image->getTargetHeight();
122            } catch (ExceptionCombo $e) {
123                LogUtility::msg("No rendering for the image ($image). The target height reports a problem: {$e->getMessage()}");
124                return "";
125            }
126            if (!empty($targetHeight)) {
127                /**
128                 * HTML height attribute is important for the ratio calculation
129                 * No layout shift
130                 */
131                $attributes->addHtmlAttributeValue("height", $targetHeight . $htmlLengthUnit);
132                /**
133                 * We don't allow the image to scale up by default
134                 */
135                $attributes->addStyleDeclarationIfNotSet("max-height", $targetHeight . $cssLengthUnit);
136            }
137
138
139            /**
140             * Width
141             *
142             * We create a series of URL
143             * for different width and let the browser
144             * download the best one for:
145             *   * the actual container width
146             *   * the actual of screen resolution
147             *   * and the connection speed.
148             *
149             * The max-width value is set
150             */
151            try {
152                $mediaWidthValue = $image->getIntrinsicWidth();
153            } catch (ExceptionCombo $e) {
154                LogUtility::msg("No rendering for the image ($image). The intrinsic width reports a problem: {$e->getMessage()}");
155                return "";
156            }
157            $srcValue = $image->getUrl();
158
159            /**
160             * Responsive image src set building
161             * We have chosen
162             *   * 375: Iphone6
163             *   * 768: Ipad
164             *   * 1024: Ipad Pro
165             *
166             */
167            // The image margin applied
168            $imageMargin = PluginUtility::getConfValue(self::CONF_RESPONSIVE_IMAGE_MARGIN, "20px");
169
170
171            /**
172             * Srcset and sizes for responsive image
173             * Width is mandatory for responsive image
174             * Ref https://developers.google.com/search/docs/advanced/guidelines/google-images#responsive-images
175             */
176            if (!empty($mediaWidthValue)) {
177
178                /**
179                 * The value of the target image
180                 */
181                try {
182                    $targetWidth = $image->getTargetWidth();
183                } catch (ExceptionCombo $e) {
184                    LogUtility::msg("No rendering for the image ($image). The target width reports a problem: {$e->getMessage()}");
185                    return "";
186                }
187                if (!empty($targetWidth)) {
188
189                    if (!empty($targetHeight)) {
190                        $image->checkLogicalRatioAgainstTargetRatio($targetWidth, $targetHeight);
191                    }
192                    /**
193                     * HTML Width attribute is important to avoid layout shift
194                     */
195                    $attributes->addHtmlAttributeValue("width", $targetWidth . $htmlLengthUnit);
196                    /**
197                     * We don't allow the image to scale up by default
198                     */
199                    $attributes->addStyleDeclarationIfNotSet("max-width", $targetWidth . $cssLengthUnit);
200                    /**
201                     * We allow the image to scale down up to 100% of its parent
202                     */
203                    $attributes->addStyleDeclarationIfNotSet("width", "100%");
204
205                }
206
207                /**
208                 * Continue
209                 */
210                $srcSet = "";
211                $sizes = "";
212
213                /**
214                 * Add smaller sizes
215                 */
216                foreach (self::BREAKPOINTS as $breakpointWidth) {
217
218                    if ($targetWidth > $breakpointWidth) {
219
220                        if (!empty($srcSet)) {
221                            $srcSet .= ", ";
222                            $sizes .= ", ";
223                        }
224                        $breakpointWidthMinusMargin = $breakpointWidth - $imageMargin;
225                        $xsmUrl = $image->getUrlForSrcSetAtBreakpoint($breakpointWidthMinusMargin);
226                        $srcSet .= "$xsmUrl {$breakpointWidthMinusMargin}w";
227                        $sizes .= $this->getSizes($breakpointWidth, $breakpointWidthMinusMargin);
228
229                    }
230
231                }
232
233                /**
234                 * Add the last size
235                 * If the target image is really small, srcset and sizes are empty
236                 */
237                if (!empty($srcSet)) {
238                    $srcSet .= ", ";
239                    $sizes .= ", ";
240                    $srcUrl = $image->getUrlForSrcSetAtBreakpoint($targetWidth);
241                    $srcSet .= "$srcUrl {$targetWidth}w";
242                    $sizes .= "{$targetWidth}px";
243                }
244
245                /**
246                 * Lazy load
247                 */
248                $lazyLoad = $this->getLazyLoad();
249                if ($lazyLoad) {
250
251                    /**
252                     * Snippet Lazy loading
253                     */
254                    LazyLoad::addLozadSnippet();
255                    PluginUtility::getSnippetManager()->attachJavascriptSnippetForBar("lozad-raster");
256                    $attributes->addClassName(self::LAZY_CLASS);
257                    $attributes->addClassName(LazyLoad::LAZY_CLASS);
258
259                    /**
260                     * A small image has no srcset
261                     *
262                     */
263                    if (!empty($srcSet)) {
264
265                        /**
266                         * !!!!! DON'T FOLLOW THIS ADVICE !!!!!!!!!
267                         * https://github.com/aFarkas/lazysizes/#modern-transparent-srcset-pattern
268                         * The transparent image has a fix dimension aspect ratio of 1x1 making
269                         * a bad reserved space for the image
270                         * We use a svg instead
271                         */
272                        $attributes->addHtmlAttributeValue("src", $srcValue);
273                        $attributes->addHtmlAttributeValue("srcset", LazyLoad::getPlaceholder($targetWidth, $targetHeight));
274                        /**
275                         * We use `data-sizes` and not `sizes`
276                         * because `sizes` without `srcset`
277                         * shows the broken image symbol
278                         * Javascript changes them at the same time
279                         */
280                        $attributes->addHtmlAttributeValue("data-sizes", $sizes);
281                        $attributes->addHtmlAttributeValue("data-srcset", $srcSet);
282
283                    } else {
284
285                        /**
286                         * Small image but there is no little improvement
287                         */
288                        $attributes->addHtmlAttributeValue("data-src", $srcValue);
289                        $attributes->addHtmlAttributeValue("src", LazyLoad::getPlaceholder($targetWidth, $targetHeight));
290
291                    }
292
293                    LazyLoad::addPlaceholderBackground($attributes);
294
295
296                } else {
297
298                    if (!empty($srcSet)) {
299                        $attributes->addHtmlAttributeValue("srcset", $srcSet);
300                        $attributes->addHtmlAttributeValue("sizes", $sizes);
301                    } else {
302                        $attributes->addHtmlAttributeValue("src", $srcValue);
303                    }
304
305                }
306
307            } else {
308
309                // No width, no responsive possibility
310                $lazyLoad = $this->getLazyLoad();
311                if ($lazyLoad) {
312
313                    LazyLoad::addPlaceholderBackground($attributes);
314                    $attributes->addHtmlAttributeValue("src", LazyLoad::getPlaceholder());
315                    $attributes->addHtmlAttributeValue("data-src", $srcValue);
316
317                }
318
319            }
320
321
322            /**
323             * Title (ie alt)
324             */
325            $attributes->addHtmlAttributeValueIfNotEmpty("alt", $image->getAltNotEmpty());
326
327            /**
328             * TODO: Side effect of the fact that we use the same attributes
329             * Title attribute of a media is the alt of an image
330             * And title should not be in an image tag
331             */
332            $attributes->removeAttributeIfPresent(TagAttributes::TITLE_KEY);
333
334            /**
335             * Old model where the src is parsed and the path
336             * is in the attributes
337             */
338            $attributes->removeAttributeIfPresent(PagePath::PROPERTY_NAME);
339
340            /**
341             * Create the img element
342             */
343            $htmlAttributes = $attributes->toHTMLAttributeString();
344            $imgHTML = '<img ' . $htmlAttributes . '/>';
345
346        } else {
347
348            $imgHTML = "<span class=\"text-danger\">The image ($this) does not exist</span>";
349
350        }
351
352        return $imgHTML;
353    }
354
355
356    public
357    function getLazyLoad()
358    {
359        $lazyLoad = parent::getLazyLoad();
360        if ($lazyLoad !== null) {
361            return $lazyLoad;
362        } else {
363            return PluginUtility::getConfValue(RasterImageLink::CONF_LAZY_LOADING_ENABLE, RasterImageLink::CONF_LAZY_LOADING_ENABLE_DEFAULT);
364        }
365    }
366
367    /**
368     * @param $screenWidth
369     * @param $imageWidth
370     * @return string sizes with a dpi correction if
371     */
372    private
373    function getSizes($screenWidth, $imageWidth): string
374    {
375
376        if ($this->getWithDpiCorrection()) {
377            $dpiBase = 96;
378            $sizes = "(max-width: {$screenWidth}px) and (min-resolution:" . (3 * $dpiBase) . "dpi) " . intval($imageWidth / 3) . "px";
379            $sizes .= ", (max-width: {$screenWidth}px) and (min-resolution:" . (2 * $dpiBase) . "dpi) " . intval($imageWidth / 2) . "px";
380            $sizes .= ", (max-width: {$screenWidth}px) and (min-resolution:" . (1 * $dpiBase) . "dpi) {$imageWidth}px";
381        } else {
382            $sizes = "(max-width: {$screenWidth}px) {$imageWidth}px";
383        }
384        return $sizes;
385    }
386
387    /**
388     * Return if the DPI correction is enabled or not for responsive image
389     *
390     * Mobile have a higher DPI and can then fit a bigger image on a smaller size.
391     *
392     * This can be disturbing when debugging responsive sizing image
393     * If you want also to use less bandwidth, this is also useful.
394     *
395     * @return bool
396     */
397    private
398    function getWithDpiCorrection(): bool
399    {
400        /**
401         * Support for retina means no DPI correction
402         */
403        $retinaEnabled = PluginUtility::getConfValue(self::CONF_RETINA_SUPPORT_ENABLED, 0);
404        return !$retinaEnabled;
405    }
406
407
408}
409