diff --git a/README.md b/README.md index 2a597b5..9a952bb 100644 --- a/README.md +++ b/README.md @@ -81,3 +81,42 @@ echo $builder->createURL("bridge.png", $params); // Prints out: // http://demos.imgix.net/bridge.png?h=100&w=100&s=bb8f3a2ab832e35997456823272103a4 ``` + +## Srcset Generation + +The imgix library allows for generation of custom `srcset` attributes, which can be invoked through `createSrcSet()`. By default, the `srcset` generated will allow for responsive size switching by building a list of image-width mappings. + +```php +$builder = new UrlBuilder("demos.imgix.net", true, "my-key", false); +echo $builder->createSrcSet("bridge.png"); +``` + +Will produce the following attribute value, which can then be served to the client: + +```html +https://demos.imgix.net/bridge.png?w=100&s=ac331d314510e3039a33aa1a7ebc23ee 100w, +https://demos.imgix.net/bridge.png?w=116&s=aac0667a00791c8c8801a2fef134e78a 116w, +https://demos.imgix.net/bridge.png?w=134&s=2fcd42d984155efe26cc1e36e16b2897 134w, + ... +https://demos.imgix.net/bridge.png?w=7400&s=6a6cbe01416dc4e0c65d1a2f87b868ac 7400w, +https://demos.imgix.net/bridge.png?w=8192&s=9e6b0a94e81e929ad71829fcccf4d2d8 8192w +``` + +In cases where enough information is provided about an image's dimensions, `createSrcSet()` will instead build a `srcset` that will allow for an image to be served at different resolutions. The parameters taken into consideration when determining if an image is fixed-width are `w`, `h`, and `ar`. By invoking `createSrcSet()` with either a width **or** the height and aspect ratio (along with `fit=crop`, typically) provided, a different `srcset` will be generated for a fixed-size image instead. + +```php +$builder = new UrlBuilder("demos.imgix.net", true, "my-key", false); +echo $builder->createSrcSet("bridge.png", array("h"=>800, "ar"=>"3:2", "fit"=>"crop")); +``` + +Will produce the following attribute value: + +```html +https://demos.imgix.net/bridge.png?ar=3%3A2&dpr=1&fit=crop&h=800&s=39eb37ad41acf7170343aa463424ae49 1x, +https://demos.imgix.net/bridge.png?ar=3%3A2&dpr=2&fit=crop&h=800&s=a8ab13a2c7a17b91db42cb86e45f7c9d 2x, +https://demos.imgix.net/bridge.png?ar=3%3A2&dpr=3&fit=crop&h=800&s=8fefe5daf312f04fb6912a101afbf704 3x, +https://demos.imgix.net/bridge.png?ar=3%3A2&dpr=4&fit=crop&h=800&s=74a6167d6ef8ba410109feda814b9ac0 4x, +https://demos.imgix.net/bridge.png?ar=3%3A2&dpr=5&fit=crop&h=800&s=4449b7f44ba7d6d0527a16d9a10b6e39 5x +``` + +For more information to better understand `srcset`, we highly recommend [Eric Portis' "Srcset and sizes" article](https://ericportis.com/posts/2014/srcset-sizes/) which goes into depth about the subject. diff --git a/src/Imgix/UrlBuilder.php b/src/Imgix/UrlBuilder.php index 8489dc3..99b9396 100644 --- a/src/Imgix/UrlBuilder.php +++ b/src/Imgix/UrlBuilder.php @@ -9,6 +9,12 @@ class UrlBuilder { private $useHttps; private $signKey; + // define class constants + // should be private; but visibility modifiers are not supported php version <7.1 + const TARGETRATIOS = array(1, 2, 3, 4, 5); + // constants cannot be dynamically assigned; keeping as a class variable instead + private $targetWidths; + public function __construct($domain, $useHttps = false, $signKey = "", $includeLibraryParam = true) { if (!is_string($domain)) { @@ -21,6 +27,7 @@ public function __construct($domain, $useHttps = false, $signKey = "", $includeL $this->useHttps = $useHttps; $this->signKey = $signKey; $this->includeLibraryParam = $includeLibraryParam; + $this->targetWidths = $this->targetWidths(); } private function validateDomain($domain) { @@ -57,4 +64,66 @@ public function createURL($path, $params=array()) { return $uh->getURL(); } + + public function createSrcSet($path, $params=array()) { + $width = array_key_exists('w', $params) ? $params['w'] : NULL; + $height = array_key_exists('h', $params) ? $params['h'] : NULL; + $aspectRatio = array_key_exists('ar', $params) ? $params['ar'] : NULL; + + if (($width) || ($height && $aspectRatio)) { + return $this->createDPRSrcSet($path, $params); + } + else { + return $this->createSrcSetPairs($path, $params); + } + } + + private function createDPRSrcSet($path, $params) { + $srcset = ""; + + $size = count(self::TARGETRATIOS); + for ($i = 0; $i < $size; $i++) { + $currentRatio = self::TARGETRATIOS[$i]; + $currentParams = $params; + $currentParams['dpr'] = $i+1; + $srcset .= $this->createURL($path, $currentParams) . " " . $currentRatio . "x,\n"; + } + + return substr($srcset, 0, strlen($srcset) - 2); + } + + private function createSrcSetPairs($path, $params) { + $srcset = ""; + $currentWidth = NULL; + $currentParams = NULL; + + $size = count($this->targetWidths); + for ($i = 0; $i < $size; $i++) { + $currentWidth = $this->targetWidths[$i]; + $currentParams = $params; + $currentParams['w'] = $currentWidth; + $srcset .= $this->createURL($path, $currentParams) . " " . $currentWidth . "w,\n"; + } + + return substr($srcset, 0, strlen($srcset) - 2); + } + + private function targetWidths() { + $resolutions = array(); + $prev = 100; + $INCREMENT_PERCENTAGE = 8; + $MAX_SIZE = 8192; + + $ensureEven = function($n) { + return 2 * round($n / 2); + }; + + while ($prev <= $MAX_SIZE) { + array_push($resolutions, $ensureEven($prev)); + $prev *= 1 + ($INCREMENT_PERCENTAGE / 100) * 2; + } + + array_push($resolutions, $MAX_SIZE); + return $resolutions; + } } diff --git a/tests/Imgix/Tests/UrlBuilderTest.php b/tests/Imgix/Tests/UrlBuilderTest.php index 9901279..b288bc3 100644 --- a/tests/Imgix/Tests/UrlBuilderTest.php +++ b/tests/Imgix/Tests/UrlBuilderTest.php @@ -139,5 +139,293 @@ public function test_invalid_domain_append_dash() { $builder = new UrlBuilder("demos.imgix.net-", true, "", false); } + + private function srcsetBuilder($params=array()) { + $builder = new UrlBuilder("demos.imgix.net", true, "my-key", false); + return $builder->createSrcSet("bridge.png", $params); + } + + // parse the width as an int, eg "100w" => 100 + private function parseWidth($width) { + return (int)substr($width, 0, strlen($width)-1); + } + + public function testNoParametersGeneratesSrcsetPairs() { + $srcset = $this->srcsetBuilder(); + $expectedNumberOfPairs = 31; + $this->assertEquals($expectedNumberOfPairs, count(explode(",", $srcset))); + } + + public function testSrcsetPairValues() { + $srcset = $this->srcsetBuilder(); + $index = 0; + // array of expected resolutions generated by srcset + $resolutions = array(100, 116, 134, 156, 182, 210, 244, 282, + 328, 380, 442, 512, 594, 688, 798, 926, + 1074, 1246, 1446, 1678, 1946, 2258, 2618, + 3038, 3524, 4088, 4742, 5500, 6380, 7400, 8192); + $srclist = explode(",", $srcset); + $matches = array(); + + foreach ($srclist as $src) { + $width = explode(" ", $src)[1]; + + // extract width int values + preg_match("/\d+/", $width, $matches); + $this->assertEquals($resolutions[$index], $matches[0]); + $index ++; + } + } + + public function testGivenWidthSrcsetIsDPR() { + $srcset = $this->srcsetBuilder(array("w"=>300)); + $devicePixelRatio = 1; + $srclist = explode(",", $srcset); + + foreach ($srclist as $src) { + list($generatedURL, $generatedRatio) = explode(" ", $src); + + $dprStr = $devicePixelRatio . "x"; + $this->assertEquals($dprStr, $generatedRatio); + + $this->assertRegExp("/dpr=".$devicePixelRatio."/", $generatedURL); + + $devicePixelRatio += 1; + } + } + + public function testGivenWidthSignsURLs() { + $srcset = $this->srcsetBuilder(array("w"=>300)); + $srclist = explode(",", $srcset); + + foreach ($srclist as $src) { + $url = explode(" ", $src)[0]; + $this->assertRegExp("/s=/", $url); + + // parse out query params + $params = substr($url, strrpos($url, "?")); + $params = substr($params, 0, strrpos($params, "s=")-1); + + // parse out sign parameter + $generatedSignature = substr($url, strrpos($url, "s=")+2); + + $signatureBase = "my-key" . "/bridge.png" . $params; + $expectSignature = md5($signatureBase); + + $this->assertEquals($expectSignature, $generatedSignature); + } + } + + public function testGivenHeightSrcsetGeneratesPairs() { + $srcset = $this->srcsetBuilder(array("h"=>300)); + $expectedNumberOfPairs = 31; + $this->assertEquals($expectedNumberOfPairs, count(explode(",", $srcset))); + } + + public function testGivenHeightRespectsParameter() { + $srcset = $this->srcsetBuilder(array("h"=>300)); + $srclist = explode(",", $srcset); + + foreach ($srclist as $src) { + $this->assertRegExp("/h=300/", $src); + } + } + + public function testGivenHeightSrcsetPairsWithinBounds() { + $srcset = $this->srcsetBuilder(array("h"=>300)); + $srclist = explode(",", $srcset); + + $minParsed = explode(" ", $srclist[0])[1]; + $maxParsed = explode(" ", $srclist[count($srclist)-1])[1]; + $min = $this->parseWidth($minParsed); + $max = $this->parseWidth($maxParsed); + + $this->assertGreaterThanOrEqual(100, $min); + $this->assertLessThanOrEqual(8192, $max); + } + + public function testGivenHeightSrcsetIteratesEighteenPercent() { + $incrementAllowed = .18; + $srcset = $this->srcsetBuilder(array("h"=>300)); + $srclist = explode(",", $srcset); + + $widths = array_map(function ($src) { + return $this->parseWidth(explode(" ", $src)[1]); + }, $srclist); + + $prev = $widths[0]; + $size = count($widths); + for ($i = 1; $i < $size; $i++) { + $width = $widths[$i]; + $this->assertLessThan((1 + $incrementAllowed), ($width / $prev)); + $prev = $width; + } + } + + public function testGivenHeightSrcsetSignsUrls() { + $srcset = $this->srcsetBuilder(array("h"=>300)); + $srclist = explode(",", $srcset); + + $srcs = array_map(function ($src) { + return explode(" ", $src)[0]; + }, $srclist); + + foreach ($srcs as $src) { + $this->assertRegExp("/s=/", $src); + + // parse out query params + $params = substr($src, strrpos($src, "?")); + $params = substr($params, 0, strrpos($params, "s=")-1); + + // parse out sign parameter + $generatedSignature = substr($src, strrpos($src, "s=")+2); + + $signatureBase = "my-key" . "/bridge.png" . $params; + $expectSignature = md5($signatureBase); + + $this->assertEquals($expectSignature, $generatedSignature); + } + } + + public function testGivenWidthAndHeightSrcsetIsDPR() { + $srcset = $this->srcsetBuilder(array("w"=>300, "h"=>"400")); + $devicePixelRatio = 1; + $srclist = explode(",", $srcset); + + foreach ($srclist as $src) { + list($generatedURL, $generatedRatio) = explode(" ", $src); + + $dprStr = $devicePixelRatio . "x"; + $this->assertEquals($dprStr, $generatedRatio); + + $this->assertRegExp("/dpr=".$devicePixelRatio."/", $generatedURL); + + $devicePixelRatio += 1; + } + } + + public function testGivenWidthAndHeightSignsURLs() { + $srcset = $this->srcsetBuilder(array("w"=>300, "h"=>"400")); + $srclist = explode(",", $srcset); + + foreach ($srclist as $src) { + $url = explode(" ", $src)[0]; + $this->assertRegExp("/s=/", $url); + + // parse out query params + $params = substr($url, strrpos($url, "?")); + $params = substr($params, 0, strrpos($params, "s=")-1); + + // parse out sign parameter + $generatedSignature = substr($url, strrpos($url, "s=")+2); + + $signatureBase = "my-key" . "/bridge.png" . $params; + $expectSignature = md5($signatureBase); + + $this->assertEquals($expectSignature, $generatedSignature); + } + } + + public function testGivenAspectRatioSrcsetGeneratesPairs() { + $srcset = $this->srcsetBuilder(array("ar"=>"3:2")); + $expectedNumberOfPairs = 31; + $this->assertEquals($expectedNumberOfPairs, count(explode(",", $srcset))); + } + + public function testGivenAspectRatioSrcsetPairsWithinBounds() { + $srcset = $this->srcsetBuilder(array("ar"=>"3:2")); + $srclist = explode(",", $srcset); + + $minParsed = explode(" ", $srclist[0])[1]; + $maxParsed = explode(" ", $srclist[count($srclist)-1])[1]; + $min = $this->parseWidth($minParsed); + $max = $this->parseWidth($maxParsed); + + $this->assertGreaterThanOrEqual(100, $min); + $this->assertLessThanOrEqual(8192, $max); + } + + public function testGivenAspectRatioSrcsetIteratesEighteenPercent() { + $incrementAllowed = .18; + $srcset = $this->srcsetBuilder(array("ar"=>"3:2")); + $srclist = explode(",", $srcset); + + $widths = array_map(function ($src) { + return $this->parseWidth(explode(" ", $src)[1]); + }, $srclist); + + $prev = $widths[0]; + $size = count($widths); + for ($i = 1; $i < $size; $i++) { + $width = $widths[$i]; + $this->assertLessThan((1 + $incrementAllowed), ($width / $prev)); + $prev = $width; + } + } + + public function testGivenAspectRatioSrcsetSignsUrls() { + $srcset = $this->srcsetBuilder(array("ar"=>"3:2")); + $srclist = explode(",", $srcset); + + $srcs = array_map(function ($src) { + return explode(" ", $src)[0]; + }, $srclist); + + foreach ($srcs as $src) { + $this->assertRegExp("/s=/", $src); + + // parse out query params + $params = substr($src, strrpos($src, "?")); + $params = substr($params, 0, strrpos($params, "s=")-1); + + // parse out sign parameter + $generatedSignature = substr($src, strrpos($src, "s=")+2); + + $signatureBase = "my-key" . "/bridge.png" . $params; + $expectSignature = md5($signatureBase); + + $this->assertEquals($expectSignature, $generatedSignature); + } + } + + public function testGivenAspectRatioAndHeightSrcsetIsDPR() { + $srcset = $this->srcsetBuilder(array("h"=>400,"ar"=>"3:2")); + $devicePixelRatio = 1; + $srclist = explode(",", $srcset); + + foreach ($srclist as $src) { + list($generatedURL, $generatedRatio) = explode(" ", $src); + + $dprStr = $devicePixelRatio . "x"; + $this->assertEquals($dprStr, $generatedRatio); + + $this->assertRegExp("/dpr=".$devicePixelRatio."/", $generatedURL); + + $devicePixelRatio += 1; + } + } + + public function testGivenAspectRatioAndHeightSignsURLs() { + $srcset = $this->srcsetBuilder(array("h"=>400,"ar"=>"3:2")); + $srclist = explode(",", $srcset); + + foreach ($srclist as $src) { + $url = explode(" ", $src)[0]; + $this->assertRegExp("/s=/", $url); + + // parse out query params + $params = substr($url, strrpos($url, "?")); + $params = substr($params, 0, strrpos($params, "s=")-1); + + // parse out sign parameter + $generatedSignature = substr($url, strrpos($url, "s=")+2); + + $signatureBase = "my-key" . "/bridge.png" . $params; + $expectSignature = md5($signatureBase); + + $this->assertEquals($expectSignature, $generatedSignature); + } + } + } ?>