From 0e7647a8ac7fe0e62e042f1b380535b9e1671c0f Mon Sep 17 00:00:00 2001 From: Martin Heise Date: Wed, 24 Jul 2024 19:11:54 +0200 Subject: [PATCH] Improved tests and documentation --- README.md | 67 ++++++++++++- src/Calculations/CssCalculator.php | 9 +- src/Data/RenderConfig.php | 27 ++++- src/ImageResizer.php | 4 +- tests/Calculations/CssCalculatorTest.php | 119 ++++++++++++----------- tests/Data/DummyImage.php | 4 +- tests/ImageResizerTest.php | 101 ++++++++++++++++--- 7 files changed, 248 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index d0e9e5e..7b12094 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ This module contains logic to calculate multiple scaled image sizes from one sou It only contains business logic to define the expected sizes. The actual handling of the image data, like resizing, caching of the result etc. is not handled by the module, but done by the calling code, the interface `ImageData` serves as main connection point. +## Background + +“Humans shouldn’t be doing this” – some inspiring thoughts on deciding which image resolutions to use in responsive output can be found in this article by Jason Grigsby: [Image Breakpoints](https://cloudfour.com/thinks/responsive-images-101-part-9-image-breakpoints/), especially [Setting image breakpoints based on a performance budget](https://cloudfour.com/thinks/responsive-images-101-part-9-image-breakpoints/#setting-image-breakpoints-based-on-a-performance-budget) + ## Installation Install with composer: @@ -13,19 +17,72 @@ Install with composer: ## Usage overview - Implement interface `Mhe\Imagetools\Data\ImageData` as a wrapper for your images and processing methods -- create a `Mhe\Imagetools\Data\RenderConfig` object holding options and context information like the specific image layout context +- create a `Mhe\Imagetools\Data\RenderConfig` object holding options and context information about the specific image layout context: ``` - $config = new RenderConfig("max-width: 1000px) 100vw, 1000px", 5, 20000, 2); + $config = new RenderConfig("(max-width: 1000px) 100vw, 1000px"); ``` - create a new `Mhe\Imagetools\ImageResizer` and let it process the source image with given configuration: ``` $resizer = new ImageResizer(); $result = $resizer->getVariants($srcimg, $config); ``` -- The result is an array of images you will usually use to output a `srcset` attribute +- The result is an array of images you can use to output a `srcset` attribute + +For a small demonstration of the very basic usage see [mhe/imagetools-cli](https://github.com/martinheise/imagetools-cli). + +A more advanced usage is [mhe/silverstripe-responsiveimages](https://github.com/martinheise/silverstripe-responsiveimages), a module for CMS Silverstripe. + +## Configuration and options + +### ImageResizer options + +When creating a new `ImageResizer` you can provide these parameters to tweak the general behaviour: + +- `min_viewport_width`: minimum viewport width to consider (default: 320) +- `max_viewport_width`: maximum viewport width to consider, e.g. for fullwidth images (default: 2400) +- `rem_size`: used to translate values in rem unit to px (default: 16) + +### RenderConfig options + +A `RenderConfig` contains several options used for the calculations for a specific image – usually they are the same for multiple images used in the same layout context and/or CSS class, so you would have one configuration for Hero images, one for slider images, one for teaser images etc. + +#### sizes + +The main parameter of `RenderConfig`. It matches the `sizes` attribute of a desired `ìmg` element, telling the ImageResizer in which actual layout sizes (widths) the image will output on different screensizes. + +This information can be deducted from the page layout usually defined in CSS, depending on the complexity of your layout it can get a bit more complicated to count in all conditions, often you just have to add up a couple of container margins etc. In doubt: if the information don’t exactly match the image sizes it probably doesn’t effect the result very much – a rough approximation is better than no information. + +*Some examples:* + +Full width image with margins of 16px on each side: + +```calc(100vw - 32px)``` + +Image has full width with margins in mobile view, displayed in a 2-column grid on desktop: + +```(max-width: 720px) calc(100vw - 32px), calc(50vw - 40px)``` + +Image has full width with margins in mobile view, displayed in a 2-column grid on desktop, and is limited to a fixed width on large screens: + +```(max-width: 720px) calc(100vw - 32px), (max-width: 1680px) calc(50vw - 40px), 800px``` + +#### sizediff + +This is the desired filesize difference in bytes between two different renditions. It will not be reached exactly, but rather a rough target. + +Lower values mean more generated files, better matching the particular user conditions, but of course more load on generating images. + +#### maxsteps + +Set a limit on the number of renditions generated, has precedence of `sizediff`. With low `sizediff` values and large images this will assure that you don’t end up with vast amount of images generated. + +#### retinalevel + +Set to either `2` or `3`adds up extra levels for high resolution screens. If e.g. the maximum calculated image width is 1200px, also renditions for 2400px are created. -For a small demonstration of the very basic usage see `mhe/imagetools-cli`. +#### rendersizes -A more advanced usage is `mhe/silverstripe-responsiveimages`, a module for CMS Silverstripe. +Request specific image widths to generate for specific purposes. If given the other parameters are ignored. +Can be helpful in specific cases – if you _only_ use this kind of configuration you probably don’t need this module at all ... diff --git a/src/Calculations/CssCalculator.php b/src/Calculations/CssCalculator.php index 0d00058..844d6d3 100644 --- a/src/Calculations/CssCalculator.php +++ b/src/Calculations/CssCalculator.php @@ -80,10 +80,11 @@ public function setRemSize($rem_size): void /** * calculates given image sizes for different width breakpoints, according media-query + * returns entries for each breakpoint (below and above point) and the minimum and maximum viewport widths * @internal may change in the future, public only for testing * * @param $string string, e.g. "(width > 1600px) 800px, (width > 800px) 40vw, 80vw" - * @return array + * @return array associative array in the form [ viewportwidth => imagewidth ... ] */ public function calculateBreakpointValues($string) { @@ -96,7 +97,7 @@ public function calculateBreakpointValues($string) foreach ($sizetokens as $token) { $range = $this->calculateBreakpointRange($token, $minbp, $maxbp); - if (is_array($range)) { + if (is_array($range) and count($range) > 1) { $bps = array_keys($range); // limit breakpoints for next step if ($bps[0] > $minbp) { @@ -112,6 +113,10 @@ public function calculateBreakpointValues($string) } } } + if (count($ranges) == 0) { + $ranges[$minbp] = $minbp; + $ranges[$maxbp] = $maxbp; + } return $ranges; } diff --git a/src/Data/RenderConfig.php b/src/Data/RenderConfig.php index 7e29bf2..51b9319 100644 --- a/src/Data/RenderConfig.php +++ b/src/Data/RenderConfig.php @@ -7,7 +7,7 @@ */ class RenderConfig { - protected string $sizesstring; + protected string $sizes; protected int $maxsteps; protected int $sizediff; protected int $retinalevel; @@ -22,7 +22,7 @@ class RenderConfig */ public function __construct(string $sizes, int $maxsteps = 10, int $sizediff = 50000, int $retinalevel = 1, array $rendersizes = []) { - $this->sizesstring = $sizes; + $this->sizes = $sizes; $this->maxsteps = $maxsteps; $this->sizediff = $sizediff; $this->retinalevel = $retinalevel; @@ -55,17 +55,36 @@ private function validateValues(): void /** * @return string */ + public function getSizes(): string + { + return $this->sizes; + } + + /** + * @param string $sizes + */ + public function setSizes(string $sizes): void + { + $this->sizes = $sizes; + $this->validateValues(); + } + + /** + * @return string + * @deprecated Use getSizes() instead + */ public function getSizesstring(): string { - return $this->sizesstring; + return $this->sizes; } /** * @param string $sizesstring + * @deprecated Use getSizes() instead */ public function setSizesstring(string $sizesstring): void { - $this->sizesstring = $sizesstring; + $this->sizes = $sizesstring; $this->validateValues(); } diff --git a/src/ImageResizer.php b/src/ImageResizer.php index 6f425a0..3262fa6 100644 --- a/src/ImageResizer.php +++ b/src/ImageResizer.php @@ -88,13 +88,13 @@ public function getVariants(ImageData $srcimage, RenderConfig $config): array // ToDo: support configured image ratio ? $ratio = null; - $sizevalues = $this->cssCalculator->calculateBreakpointValues($config->getSizesstring()); + $sizevalues = $this->cssCalculator->calculateBreakpointValues($config->getSizes()); $minwidth = min(array_values($sizevalues)); $maxwidth = max(array_values($sizevalues)); // prevent upscaling - // ToDo: check logic, especially for retina + // ToDo: check logic, especially for retina – retina will still be upscaled this way! $maxwidth = min($maxwidth, $srcimage->getWidth()); if ($maxwidth < $minwidth) { $minwidth = $maxwidth; diff --git a/tests/Calculations/CssCalculatorTest.php b/tests/Calculations/CssCalculatorTest.php index 2c7a5ac..cfee22d 100644 --- a/tests/Calculations/CssCalculatorTest.php +++ b/tests/Calculations/CssCalculatorTest.php @@ -29,6 +29,69 @@ public function testCalculateCssExpressionValue() $this->assertNull($calculator->calculateCssExpressionValue('(width > 5rem) calc(80vw - 30px)')); } + public function testCalculateBreakpointValues() + { + $calculator = new CssCalculator(120, 2400); + // ascending sizes + $this->assertEquals( + [ + 120 => 96, + 800 => 640, + 801 => 320, + 1600 => 640, + 1601 => 800, + 2400 => 800 + ], + $calculator->calculateBreakpointValues('(width <= 800px) 80vw, (width <= 1600px) 40vw, 800px') + ); + // descending sizes + $this->assertEquals( + [ + 2400 => 800, + 1601 => 800, + 1600 => 640, + 801 => 320, + 800 => 640, + 120 => 96, + ], + $calculator->calculateBreakpointValues('(width > 1600px) 800px, (width > 800px) 40vw, 80vw') + ); + // descending sizes with calc + $this->assertEquals( + [ + 2400 => 810, + 1617 => 810, + 1616 => 636, + 801 => 310, + 800 => 660, + 120 => 116, + ], + $calculator->calculateBreakpointValues('(width > calc(1600px + 1rem)) calc(50rem + 10px), (width > 800px) calc(40vw - 10px), calc(80vw + 20px)') + ); + // descending sizes with periods + $this->assertEquals( + [ + 2400 => 493, + 1680 => 493, + 1679 => 493, + 1020 => 273, + 1019 => 410, + 760 => 280, + 759 => 727, + 120 => 88 + ], + $calculator->calculateBreakpointValues('(min-width: 1680px) 493px, (min-width: 1020px) calc(33.33vw - 66.67px), (min-width: 760px) calc(50vw - 100px), calc(100vw - 32px)') + ); + // fallback to min/max viewport for invalid input + $this->assertEquals( + [ + 2400 => 2400, + 120 => 120 + ], + $calculator->calculateBreakpointValues('(min-width: 1680px) 493px 322px') + ); + } + public function testCalculateBreakpointRange() { $calculator = new CssCalculator(120, 2400); @@ -115,60 +178,4 @@ public function testCalculateBreakpointRange() $calculator->calculateBreakpointRange('what is this') ); } - - - public function testCalculateBreakpointValues() - { - $calculator = new CssCalculator(120, 2400); - // ascending sizes - $this->assertEquals( - [ - 120 => 96, - 800 => 640, - 801 => 320, - 1600 => 640, - 1601 => 800, - 2400 => 800 - ], - $calculator->calculateBreakpointValues('(width <= 800px) 80vw, (width <= 1600px) 40vw, 800px') - ); - // descending sizes - $this->assertEquals( - [ - 2400 => 800, - 1601 => 800, - 1600 => 640, - 801 => 320, - 800 => 640, - 120 => 96, - ], - $calculator->calculateBreakpointValues('(width > 1600px) 800px, (width > 800px) 40vw, 80vw') - ); - // descending sizes with calc - $this->assertEquals( - [ - 2400 => 810, - 1617 => 810, - 1616 => 636, - 801 => 310, - 800 => 660, - 120 => 116, - ], - $calculator->calculateBreakpointValues('(width > calc(1600px + 1rem)) calc(50rem + 10px), (width > 800px) calc(40vw - 10px), calc(80vw + 20px)') - ); - // descending sizes with periods - $this->assertEquals( - [ - 2400 => 493, - 1680 => 493, - 1679 => 493, - 1020 => 273, - 1019 => 410, - 760 => 280, - 759 => 727, - 120 => 88 - ], - $calculator->calculateBreakpointValues('(min-width: 1680px) 493px, (min-width: 1020px) calc(33.33vw - 66.67px), (min-width: 760px) calc(50vw - 100px), calc(100vw - 32px)') - ); - } } diff --git a/tests/Data/DummyImage.php b/tests/Data/DummyImage.php index 9e1f7db..84ab72f 100644 --- a/tests/Data/DummyImage.php +++ b/tests/Data/DummyImage.php @@ -40,7 +40,7 @@ public function getFilesize(): int */ public function getPublicPath(): string { - return "dummy_" . $this->getWidth() . "_" . $this->getFilesize(). ".jpg"; + return "dummy_" . $this->getWidth() . "_" . $this->getFilesize() . ".jpg"; } /** @@ -48,7 +48,7 @@ public function getPublicPath(): string */ public function resize($width): ImageData { - $filesize = $this->getFilesize() / ($this->width / $width)^2; + $filesize = round($this->getFilesize() / ($this->width / $width) ** 2); return new DummyImage($width, $filesize); } } diff --git a/tests/ImageResizerTest.php b/tests/ImageResizerTest.php index b4d3afa..4d9446b 100644 --- a/tests/ImageResizerTest.php +++ b/tests/ImageResizerTest.php @@ -18,10 +18,15 @@ protected function setUp(): void { parent::setUp(); $this->resizer = new ImageResizer(320, 2400, 16); - $this->images['200'] = new DummyImage(200, 5000); - $this->images['4000'] = new DummyImage(4000, 320000); + // test small image + $this->images['200'] = new DummyImage(200, 2000); + // test big image: 2x max viewport for easier calculation of desired values + $this->images['4800'] = new DummyImage(4800, 1024000); } + /* + * helper method to assert result widths of generated image variants + */ protected function assertResultWidths($expect, $variants) { $widths = array_map(fn($img): int => $img->getWidth(), $variants); @@ -30,45 +35,117 @@ protected function assertResultWidths($expect, $variants) public function testSimple() { - $config = new RenderConfig("100vw", 5, 500000, 1, []); + $config = new RenderConfig("100vw", 5, 1000000, 1, []); $variants = $this->resizer->getVariants($this->images['200'], $config); $this->assertResultWidths([200], $variants); // kept original size - $variants = $this->resizer->getVariants($this->images['4000'], $config); + $variants = $this->resizer->getVariants($this->images['4800'], $config); $this->assertResultWidths([2400], $variants); // max viewport } public function testSimpleRetina() { - $config = new RenderConfig("100vw", 5, 500000, 2, []); + $config = new RenderConfig("100vw", 5, 1000000, 2, []); $variants = $this->resizer->getVariants($this->images['200'], $config); $this->assertResultWidths([400, 200], $variants); // kept original size - $variants = $this->resizer->getVariants($this->images['4000'], $config); + $variants = $this->resizer->getVariants($this->images['4800'], $config); $this->assertResultWidths([4800, 2400], $variants); // max viewport } - public function testRemParam() + public function testRemValue() { $config = new RenderConfig("20rem", 1); $this->resizer->setRemSize(20); - $variants = $this->resizer->getVariants($this->images['4000'], $config); + $variants = $this->resizer->getVariants($this->images['4800'], $config); $this->assertResultWidths([400], $variants); $this->resizer->setRemSize(10); - $variants = $this->resizer->getVariants($this->images['4000'], $config); + $variants = $this->resizer->getVariants($this->images['4800'], $config); $this->assertResultWidths([200], $variants); } - public function testViewportParam() + public function testViewportValue() { $config = new RenderConfig("80vw", 1); $this->resizer->setMaxViewportWidth(1600); - $variants = $this->resizer->getVariants($this->images['4000'], $config); + $variants = $this->resizer->getVariants($this->images['4800'], $config); $this->assertResultWidths([1280], $variants); // maxvw * 0,8 $this->resizer->setMaxViewportWidth(3000); - $variants = $this->resizer->getVariants($this->images['4000'], $config); + $variants = $this->resizer->getVariants($this->images['4800'], $config); $this->assertResultWidths([2400], $variants); // maxvw * 0,8 } + + public function testSizeDiff() + { + $config = new RenderConfig("100vw", 10, 64000, 1, []); + $variants = $this->resizer->getVariants($this->images['200'], $config); + $this->assertResultWidths([200], $variants); // kept original size + // first step should be: 2400px -> filesize: 256000 => 4 steps + $variants = $this->resizer->getVariants($this->images['4800'], $config); + $this->assertEquals(4, count($variants)); + $this->assertResultWidths([2400, 1939, 1449, 900], $variants); // max viewport + } + + public function testSizeDiffRetina() + { + $config = new RenderConfig("100vw", 10, 64000, 2, []); + $variants = $this->resizer->getVariants($this->images['200'], $config); + $this->assertResultWidths([400, 200], $variants); // kept original size + $variants = $this->resizer->getVariants($this->images['4800'], $config); + $this->assertEquals(10, count($variants)); + // first step for 2x should be: 4800px -> filesize: 1024000 => 10 (max)steps, limited by next level + // first step should be: 2400px -> filesize: 256000 => 4 steps + $this->assertResultWidths([4800, 4437, 4067, 3688, 3299, 2897, 2400, 1939, 1449, 900], $variants); // max viewport + } + + public function testMaxSteps() + { + $config = new RenderConfig("100vw", 4, 500, 1, []); + $variants = $this->resizer->getVariants($this->images['200'], $config); + $this->assertResultWidths([200], $variants); // kept original size + $variants = $this->resizer->getVariants($this->images['4800'], $config); + $this->assertEquals(4, count($variants)); + $this->assertResultWidths([2400, 1939, 1449, 900], $variants); // max viewport + } + + public function testMaxStepsRetina() + { + $config = new RenderConfig("100vw", 4, 1000, 2, []); + $variants = $this->resizer->getVariants($this->images['200'], $config); + $this->assertResultWidths([400, 200], $variants); // kept original size + $variants = $this->resizer->getVariants($this->images['4800'], $config); + $this->assertEquals(7, count($variants)); + // retina resolutions: 2x the regular ones, but stopping before reaching 1x width, so fewer values + $this->assertResultWidths([4800, 3878, 2897, 2400, 1939, 1449, 900], $variants); // max viewport + } + + public function testBreakpoints() + { + // calculated max width: 600px + $config = new RenderConfig("(width <= 600px) 100vw, 520px", 4, 64000, 1, []); + $variants = $this->resizer->getVariants($this->images['200'], $config); + $this->assertResultWidths([200], $variants); // no upsacling + $variants = $this->resizer->getVariants($this->images['4800'], $config); + $this->assertResultWidths([600], $variants); + // calculated max width: 720px + $config = new RenderConfig("(max-width: 600px) 100vw, 720px", 4, 64000, 1, []); + $variants = $this->resizer->getVariants($this->images['200'], $config); + $this->assertResultWidths([200], $variants); // no upsacling + $variants = $this->resizer->getVariants($this->images['4800'], $config); + $this->assertResultWidths([720], $variants); + // calculated max width: 2000px, and min width: 1200px + $config = new RenderConfig("(max-width: 800px) 1200px, 2000px", 4, 500, 1, []); + $variants = $this->resizer->getVariants($this->images['4800'], $config); + $this->assertResultWidths([2000, 1616, 1207], $variants); // max viewport + } + + public function testInvalidConfig() + { + // calculate based on max/min viewport for invalid input + $config = new RenderConfig("(width <= 600px) 100vw 520px", 4, 64000, 1, []); + $variants = $this->resizer->getVariants($this->images['4800'], $config); + $this->assertResultWidths([2400, 1939, 1449, 900], $variants); + } }