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