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        try {
142            $imageMargin = ConditionalLength::createFromString($imageMargin)->toPixelNumber();
143        } catch (ExceptionBadArgument $e) {
144            LogUtility::warning("The variable (" . self::CONF_RESPONSIVE_IMAGE_MARGIN . ") has a value ($imageMargin) that is not a valid length.", self::CANONICAL, $e);
145            $imageMargin = 20;
146        }
147
148        /**
149         * Srcset and sizes for responsive image
150         * Width is mandatory for responsive image
151         * Ref https://developers.google.com/search/docs/advanced/guidelines/google-images#responsive-images
152         */
153
154
155        /**
156         * The value of the target image
157         */
158        $targetWidth = $fetchRaster->getTargetWidth();
159        $fetchRaster->checkLogicalRatioAgainstTargetRatio($targetWidth, $targetHeight);
160
161        /**
162         * HTML Width attribute is important to avoid layout shift
163         */
164        $attributes->addOutputAttributeValue("width", $targetWidth . $htmlLengthUnit);
165        /**
166         * We don't allow the image to scale up by default
167         */
168        $attributes->addStyleDeclarationIfNotSet("max-width", $targetWidth . $cssLengthUnit);
169        /**
170         * We allow the image to scale down up to 100% of its parent
171         */
172        $attributes->addStyleDeclarationIfNotSet("width", "100%");
173
174
175        /**
176         * Continue
177         */
178        $srcSet = "";
179        $sizes = "";
180
181        /**
182         * Width
183         *
184         * We create a series of URL
185         * for different width and let the browser
186         * download the best one for:
187         *   * the actual container width
188         *   * the actual of screen resolution
189         *   * and the connection speed.
190         *
191         * The max-width value is set
192         */
193        $srcValue = $fetchRaster->getFetchUrl();
194        /**
195         * Add samller breakpoints sizes
196         */
197        $intrinsicWidth = $fetchRaster->getIntrinsicWidth();
198        foreach (Breakpoint::getBreakpoints() as $breakpoint) {
199
200            try {
201                $breakpointPixels = $breakpoint->getWidth();
202            } catch (ExceptionInfinite $e) {
203                continue;
204            }
205
206            if ($breakpointPixels > $targetWidth) {
207                continue;
208            }
209
210            if ($breakpointPixels > $intrinsicWidth) {
211                continue;
212            }
213
214            if (!empty($srcSet)) {
215                $srcSet .= ", ";
216                $sizes .= ", ";
217            }
218            $breakpointWidthMinusMargin = $breakpointPixels - $imageMargin;
219
220
221            $breakpointRaster = clone $fetchRaster;
222            if (
223                !$fetchRaster->hasHeightRequested() // breakpoint url needs only the h attribute in this case
224                || $fetchRaster->hasAspectRatioRequested() // width and height are mandatory
225            ) {
226                $breakpointRaster->setRequestedWidth($breakpointWidthMinusMargin);
227            }
228            if ($fetchRaster->hasHeightRequested() // if this is a height request
229                || $fetchRaster->hasAspectRatioRequested() // width and height are mandatory
230            ) {
231                $breakPointHeight = FetcherRaster::round($breakpointWidthMinusMargin / $fetchRaster->getTargetAspectRatio());
232                $breakpointRaster->setRequestedHeight($breakPointHeight);
233            }
234
235            $breakpointUrl = $breakpointRaster->getFetchUrl()->toString();
236
237
238            $srcSet .= "$breakpointUrl {$breakpointWidthMinusMargin}w";
239            $sizes .= $this->getSizes($breakpointPixels, $breakpointWidthMinusMargin);
240
241
242        }
243
244        /**
245         * Add the last size
246         * If the target image is really small, srcset and sizes are empty
247         */
248        if (!empty($srcSet)) {
249            $srcSet .= ", ";
250            $sizes .= ", ";
251            $srcUrl = $fetchRaster->getFetchUrl()->toString();
252            $srcSet .= "$srcUrl {$targetWidth}w";
253            $sizes .= "{$targetWidth}px";
254        }
255
256        /**
257         * Lazy load
258         */
259        $lazyLoad = $this->getLazyLoad();
260        if ($lazyLoad) {
261
262            /**
263             * Html Lazy loading
264             */
265            $lazyLoadMethod = $this->mediaMarkup->getLazyLoadMethodOrDefault();
266            switch ($lazyLoadMethod) {
267                case LazyLoad::LAZY_LOAD_METHOD_HTML_VALUE:
268                default:
269                    $attributes->addOutputAttributeValue("src", $srcValue);
270                    if (!empty($srcSet)) {
271                        // it the image is small, no srcset for instance
272                        $attributes->addOutputAttributeValue("srcset", $srcSet);
273                    }
274                    $attributes->addOutputAttributeValue("loading", "lazy");
275                    break;
276                case LazyLoad::LAZY_LOAD_METHOD_LOZAD_VALUE:
277                    /**
278                     * Snippet Lazy loading
279                     */
280                    LazyLoad::addLozadSnippet();
281                    PluginUtility::getSnippetManager()->attachJavascriptFromComponentId("lozad-raster");
282                    $attributes->addClassName(self::getLazyClass());
283                    $attributes->addClassName(LazyLoad::getLazyClass());
284
285                    /**
286                     * A small image has no srcset
287                     *
288                     */
289                    if (!empty($srcSet)) {
290
291                        /**
292                         * !!!!! DON'T FOLLOW THIS ADVICE !!!!!!!!!
293                         * https://github.com/aFarkas/lazysizes/#modern-transparent-srcset-pattern
294                         * The transparent image has a fix dimension aspect ratio of 1x1 making
295                         * a bad reserved space for the image
296                         * We use a svg instead
297                         */
298                        $attributes->addOutputAttributeValue("src", $srcValue);
299                        $attributes->addOutputAttributeValue("srcset", LazyLoad::getPlaceholder($targetWidth, $targetHeight));
300                        /**
301                         * We use `data-sizes` and not `sizes`
302                         * because `sizes` without `srcset`
303                         * shows the broken image symbol
304                         * Javascript changes them at the same time
305                         */
306                        $attributes->addOutputAttributeValue("data-sizes", $sizes);
307                        $attributes->addOutputAttributeValue("data-srcset", $srcSet);
308
309                    } else {
310
311                        /**
312                         * Small image but there is no little improvement
313                         */
314                        $attributes->addOutputAttributeValue("data-src", $srcValue);
315                        $attributes->addOutputAttributeValue("src", LazyLoad::getPlaceholder($targetWidth, $targetHeight));
316
317                    }
318                    LazyLoad::addPlaceholderBackground($attributes);
319                    break;
320            }
321
322
323        } else {
324
325            if (!empty($srcSet)) {
326                $attributes->addOutputAttributeValue("srcset", $srcSet);
327                $attributes->addOutputAttributeValue("sizes", $sizes);
328            } else {
329                $attributes->addOutputAttributeValue("src", $srcValue);
330            }
331
332        }
333
334
335        /**
336         * Title (ie alt)
337         */
338        $attributes->addOutputAttributeValueIfNotEmpty("alt", $this->getAltNotEmpty());
339
340        /**
341         * Create the img element
342         */
343        $htmlAttributes = $attributes->toHTMLAttributeString();
344        $imgHTML = '<img ' . $htmlAttributes . '/>';
345
346
347        return $this->wrapMediaMarkupWithLink($imgHTML);
348    }
349
350
351    public function getLazyLoad(): bool
352    {
353
354        if ($this->mediaMarkup->isLazy() === false) {
355            return false;
356        }
357        return SiteConfig::getConfValue(LazyLoad::CONF_RASTER_ENABLE, LazyLoad::CONF_RASTER_ENABLE_DEFAULT);
358
359    }
360
361    /**
362     * @param $screenWidth
363     * @param $imageWidth
364     * @return string sizes with a dpi correction if
365     */
366    private
367    function getSizes($screenWidth, $imageWidth): string
368    {
369
370        if ($this->getWithDpiCorrection()) {
371            $dpiBase = 96;
372            $sizes = "(max-width: {$screenWidth}px) and (min-resolution:" . (3 * $dpiBase) . "dpi) " . intval($imageWidth / 3) . "px";
373            $sizes .= ", (max-width: {$screenWidth}px) and (min-resolution:" . (2 * $dpiBase) . "dpi) " . intval($imageWidth / 2) . "px";
374            $sizes .= ", (max-width: {$screenWidth}px) and (min-resolution:" . (1 * $dpiBase) . "dpi) {$imageWidth}px";
375        } else {
376            $sizes = "(max-width: {$screenWidth}px) {$imageWidth}px";
377        }
378        return $sizes;
379    }
380
381    /**
382     * Return if the DPI correction is enabled or not for responsive image
383     *
384     * Mobile have a higher DPI and can then fit a bigger image on a smaller size.
385     *
386     * This can be disturbing when debugging responsive sizing image
387     * If you want also to use less bandwidth, this is also useful.
388     *
389     * @return bool
390     */
391    private
392    function getWithDpiCorrection(): bool
393    {
394        /**
395         * Support for retina means no DPI correction
396         */
397        $retinaEnabled = SiteConfig::getConfValue(self::CONF_RETINA_SUPPORT_ENABLED, 0);
398        return !$retinaEnabled;
399    }
400
401    /**
402     * Used to select the raster image lazy loaded
403     * @return string
404     */
405    public static function getLazyClass()
406    {
407        return StyleAttribute::addComboStrapSuffix("lazy-raster");
408    }
409
410
411}
412