xref: /plugin/combo/ComboStrap/RasterImageLink.php (revision 04fd306c7c155fa133ebb3669986875d65988276)
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
15use ComboStrap\TagAttribute\StyleAttribute;
16
17require_once(__DIR__ . '/MediaLink.php');
18require_once(__DIR__ . '/LazyLoad.php');
19require_once(__DIR__ . '/PluginUtility.php');
20
21/**
22 * Image
23 * This is the class that handles the
24 * raster image type of the dokuwiki {@link MediaLink}
25 *
26 * The real documentation can be found on the image page
27 * @link https://www.dokuwiki.org/images
28 *
29 * Doc:
30 * https://web.dev/optimize-cls/#images-without-dimensions
31 * https://web.dev/cls/
32 */
33class RasterImageLink extends ImageLink
34{
35
36    const CANONICAL = FetcherRaster::CANONICAL;
37
38    const RESPONSIVE_CLASS = "img-fluid";
39
40    const CONF_RESPONSIVE_IMAGE_MARGIN = "responsiveImageMargin";
41    const CONF_RETINA_SUPPORT_ENABLED = "retinaRasterImageEnable";
42
43    private FetcherImage $fetchRaster;
44
45
46    /**
47     * @throws ExceptionBadArgument - if the fetcher is not a raster mime and image fetcher
48     */
49    public function __construct(MediaMarkup $mediaMarkup)
50    {
51        $fetcher = $mediaMarkup->getFetcher();
52        $mime = $fetcher->getMime();
53        if (!$mime->isSupportedRasterImage()) {
54            throw new ExceptionBadArgument("The mime value ($mime) is not a supported raster image.", self::CANONICAL);
55        }
56        if (!($fetcher instanceof FetcherImage)) {
57            throw new ExceptionBadArgument("The fetcher is not a fetcher image but is a " . get_class($fetcher));
58        }
59        $this->fetchRaster = $fetcher;
60        parent::__construct($mediaMarkup);
61    }
62
63
64    /**
65     * Render a link
66     * Snippet derived from {@link \Doku_Renderer_xhtml::internalmedia()}
67     * A media can be a video also (Use
68     * @return string
69     * @throws ExceptionNotFound
70     */
71    public function renderMediaTag(): string
72    {
73
74
75        $fetchRaster = $this->fetchRaster;
76
77        $attributes = $this->mediaMarkup->getExtraMediaTagAttributes()
78            ->setLogicalTag(self::CANONICAL);
79
80        /**
81         * Responsive image
82         * https://getbootstrap.com/docs/5.0/content/images/
83         * to apply max-width: 100%; and height: auto;
84         *
85         * Even if the resizing is requested by height,
86         * the height: auto on styling is needed to conserve the ratio
87         * while scaling down the screen
88         */
89        $attributes->addClassName(self::RESPONSIVE_CLASS);
90
91
92        /**
93         * width and height to give the dimension ratio
94         * They have an effect on the space reservation
95         * but not on responsive image at all
96         * To allow responsive height, the height style property is set at auto
97         * (ie img-fluid in bootstrap)
98         */
99        // The unit is not mandatory in HTML, this is expected to be CSS pixel
100        // https://html.spec.whatwg.org/multipage/embedded-content-other.html#attr-dim-height
101        // The HTML validator does not expect an unit otherwise it send an error
102        // https://validator.w3.org/
103        $htmlLengthUnit = "";
104        $cssLengthUnit = "px";
105
106        /**
107         * Height
108         * The logical height that the image should take on the page
109         *
110         * Note: The style is also set in {@link Dimension::processWidthAndHeight()}
111         *
112         * Cannot be empty
113         */
114        $targetHeight = $fetchRaster->getTargetHeight();
115
116        /**
117         * HTML height attribute is important for the ratio calculation
118         * No layout shift
119         */
120        $attributes->addOutputAttributeValue("height", $targetHeight . $htmlLengthUnit);
121        /**
122         * We don't allow the image to scale up by default
123         */
124        $attributes->addStyleDeclarationIfNotSet("max-height", $targetHeight . $cssLengthUnit);
125        /**
126         * if the image has a class that has a `height: 100%`, the image will stretch
127         */
128        $attributes->addStyleDeclarationIfNotSet("height", "auto");
129
130
131        /**
132         * Responsive image src set building
133         * We have chosen
134         *   * 375: Iphone6
135         *   * 768: Ipad
136         *   * 1024: Ipad Pro
137         *
138         */
139        // The image margin applied
140        $imageMargin = SiteConfig::getConfValue(self::CONF_RESPONSIVE_IMAGE_MARGIN, "20px");
141
142
143        /**
144         * Srcset and sizes for responsive image
145         * Width is mandatory for responsive image
146         * Ref https://developers.google.com/search/docs/advanced/guidelines/google-images#responsive-images
147         */
148
149
150        /**
151         * The value of the target image
152         */
153        $targetWidth = $fetchRaster->getTargetWidth();
154        $fetchRaster->checkLogicalRatioAgainstTargetRatio($targetWidth, $targetHeight);
155
156        /**
157         * HTML Width attribute is important to avoid layout shift
158         */
159        $attributes->addOutputAttributeValue("width", $targetWidth . $htmlLengthUnit);
160        /**
161         * We don't allow the image to scale up by default
162         */
163        $attributes->addStyleDeclarationIfNotSet("max-width", $targetWidth . $cssLengthUnit);
164        /**
165         * We allow the image to scale down up to 100% of its parent
166         */
167        $attributes->addStyleDeclarationIfNotSet("width", "100%");
168
169
170        /**
171         * Continue
172         */
173        $srcSet = "";
174        $sizes = "";
175
176        /**
177         * Width
178         *
179         * We create a series of URL
180         * for different width and let the browser
181         * download the best one for:
182         *   * the actual container width
183         *   * the actual of screen resolution
184         *   * and the connection speed.
185         *
186         * The max-width value is set
187         */
188        $srcValue = $fetchRaster->getFetchUrl();
189        /**
190         * Add samller breakpoints sizes
191         */
192        $intrinsicWidth = $fetchRaster->getIntrinsicWidth();
193        foreach (Breakpoint::getBreakpoints() as $breakpoint) {
194
195            try {
196                $breakpointPixels = $breakpoint->getWidth();
197            } catch (ExceptionInfinite $e) {
198                continue;
199            }
200
201            if ($breakpointPixels > $targetWidth) {
202                continue;
203            }
204
205            if ($breakpointPixels > $intrinsicWidth) {
206                continue;
207            }
208
209            if (!empty($srcSet)) {
210                $srcSet .= ", ";
211                $sizes .= ", ";
212            }
213            $breakpointWidthMinusMargin = $breakpointPixels - $imageMargin;
214
215
216            $breakpointRaster = clone $fetchRaster;
217            if (
218                !$fetchRaster->hasHeightRequested() // breakpoint url needs only the h attribute in this case
219                || $fetchRaster->hasAspectRatioRequested() // width and height are mandatory
220            ) {
221                $breakpointRaster->setRequestedWidth($breakpointWidthMinusMargin);
222            }
223            if ($fetchRaster->hasHeightRequested() // if this is a height request
224                || $fetchRaster->hasAspectRatioRequested() // width and height are mandatory
225            ) {
226                $breakPointHeight = FetcherRaster::round($breakpointWidthMinusMargin / $fetchRaster->getTargetAspectRatio());
227                $breakpointRaster->setRequestedHeight($breakPointHeight);
228            }
229
230            $breakpointUrl = $breakpointRaster->getFetchUrl()->toString();
231
232
233            $srcSet .= "$breakpointUrl {$breakpointWidthMinusMargin}w";
234            $sizes .= $this->getSizes($breakpointPixels, $breakpointWidthMinusMargin);
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 = $fetchRaster->getFetchUrl()->toString();
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->mediaMarkup->getLazyLoadMethodOrDefault();
261            switch ($lazyLoadMethod) {
262                case LazyLoad::LAZY_LOAD_METHOD_HTML_VALUE:
263                default:
264                    $attributes->addOutputAttributeValue("src", $srcValue);
265                    if (!empty($srcSet)) {
266                        // it the image is small, no srcset for instance
267                        $attributes->addOutputAttributeValue("srcset", $srcSet);
268                    }
269                    $attributes->addOutputAttributeValue("loading", "lazy");
270                    break;
271                case LazyLoad::LAZY_LOAD_METHOD_LOZAD_VALUE:
272                    /**
273                     * Snippet Lazy loading
274                     */
275                    LazyLoad::addLozadSnippet();
276                    PluginUtility::getSnippetManager()->attachJavascriptFromComponentId("lozad-raster");
277                    $attributes->addClassName(self::getLazyClass());
278                    $attributes->addClassName(LazyLoad::getLazyClass());
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
330        /**
331         * Title (ie alt)
332         */
333        $attributes->addOutputAttributeValueIfNotEmpty("alt", $this->getAltNotEmpty());
334
335        /**
336         * Create the img element
337         */
338        $htmlAttributes = $attributes->toHTMLAttributeString();
339        $imgHTML = '<img ' . $htmlAttributes . '/>';
340
341
342        return $this->wrapMediaMarkupWithLink($imgHTML);
343    }
344
345
346    public function getLazyLoad(): bool
347    {
348
349        if ($this->mediaMarkup->isLazy() === false) {
350            return false;
351        }
352        return SiteConfig::getConfValue(LazyLoad::CONF_RASTER_ENABLE, LazyLoad::CONF_RASTER_ENABLE_DEFAULT);
353
354    }
355
356    /**
357     * @param $screenWidth
358     * @param $imageWidth
359     * @return string sizes with a dpi correction if
360     */
361    private
362    function getSizes($screenWidth, $imageWidth): string
363    {
364
365        if ($this->getWithDpiCorrection()) {
366            $dpiBase = 96;
367            $sizes = "(max-width: {$screenWidth}px) and (min-resolution:" . (3 * $dpiBase) . "dpi) " . intval($imageWidth / 3) . "px";
368            $sizes .= ", (max-width: {$screenWidth}px) and (min-resolution:" . (2 * $dpiBase) . "dpi) " . intval($imageWidth / 2) . "px";
369            $sizes .= ", (max-width: {$screenWidth}px) and (min-resolution:" . (1 * $dpiBase) . "dpi) {$imageWidth}px";
370        } else {
371            $sizes = "(max-width: {$screenWidth}px) {$imageWidth}px";
372        }
373        return $sizes;
374    }
375
376    /**
377     * Return if the DPI correction is enabled or not for responsive image
378     *
379     * Mobile have a higher DPI and can then fit a bigger image on a smaller size.
380     *
381     * This can be disturbing when debugging responsive sizing image
382     * If you want also to use less bandwidth, this is also useful.
383     *
384     * @return bool
385     */
386    private
387    function getWithDpiCorrection(): bool
388    {
389        /**
390         * Support for retina means no DPI correction
391         */
392        $retinaEnabled = SiteConfig::getConfValue(self::CONF_RETINA_SUPPORT_ENABLED, 0);
393        return !$retinaEnabled;
394    }
395
396    /**
397     * Used to select the raster image lazy loaded
398     * @return string
399     */
400    public static function getLazyClass()
401    {
402        return StyleAttribute::addComboStrapSuffix("lazy-raster");
403    }
404
405
406}
407