Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: create custom srcset #63

Merged
merged 4 commits into from Apr 17, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 7 additions & 0 deletions imgix/constants.py
Expand Up @@ -11,6 +11,13 @@
# For example, setting this value to 0.1 means that an image will not
# render more than 10% larger or smaller than its native size.
SRCSET_WIDTH_TOLERANCE = 8

# The minimum srcset width tolerance.
SRCSET_MIN_WIDTH_TOLERANCE = 1

# The default srcset target ratios.
SRCSET_DPR_TARGET_RATIOS = range(1, 6)

SRCSET_MAX_SIZE = 8192

# Representation of an image with a width of zero. This value is used
Expand Down
137 changes: 114 additions & 23 deletions imgix/urlbuilder.py
@@ -1,13 +1,15 @@
# -*- coding: utf-8 -*-
import re

from .constants import DOMAIN_PATTERN, SRCSET_TARGET_WIDTHS
from .constants import DOMAIN_PATTERN, SRCSET_TARGET_WIDTHS as TARGET_WIDTHS
from .constants import SRCSET_DPR_TARGET_RATIOS as TARGET_RATIOS
from .constants import IMAGE_MAX_WIDTH as MAX_WIDTH
from .constants import IMAGE_MIN_WIDTH as MIN_WIDTH
from .constants import SRCSET_WIDTH_TOLERANCE as TOLERANCE
from .validators import validate_min_max_tol
from .urlhelper import UrlHelper


SRCSET_DPR_TARGET_RATIOS = range(1, 6)


class UrlBuilder(object):
"""
Create imgix URLs
Expand Down Expand Up @@ -113,30 +115,58 @@ def create_url(self, path, params=None):

return str(url_obj)

def create_srcset(self, path, params=None):
def create_srcset(
self, path, params={},
start=MIN_WIDTH, stop=MAX_WIDTH, tol=TOLERANCE):
sherwinski marked this conversation as resolved.
Show resolved Hide resolved
"""
Create srcset attribute value with the supplied path and
`params` parameters dict.
Will generate a fixed-width DPR srcset if a width OR height and aspect
ratio are passed in as parameters. Otherwise will generate a srcset
with width-descriptor pairs.
Create a srcset attribute.

A srcset attribute consists of one or more non-empty URL. Each URL
represents an image candidate string and each candidate string is
separated by a comma (U+002C) character (,). Read more about the
srcset attribute here:

https://html.spec.whatwg.org/multipage/images.html#srcset-attributes

This function produces two types of image candidate strings,

* pixel density descriptors (x) and
* width descriptors (w)

Pixel density-described strings are produced when

* a height (h) and an aspect (ar) ratio are present in `params`, or
* only a width (w) is present in the `params`

Example, a width (or a height _and_ aspect ratio):
'https://example.test.com/image/path.png?dpr=1&w=320 1x'

Width-described strings are produced if neither a width nor a
height-aspect-ratio pair are present in `params`.

Example, no width, no height, no aspect ratio:
'https://example.test.com/image/path.png?w=100 100w'

Parameters
----------
path : str
params : dict
Dictionary specifying URL parameters. Non-imgix parameters are
added to the URL unprocessed. For a complete list of imgix
supported parameters, visit https://docs.imgix.com/apis/url .
(default None)
Path to the image file, e.g. 'image/path.png'.
params : dict, optional
Parameters that will be transformed into query parameters,
including 'w' or 'ar' and 'h'; {} by default.
sherwinski marked this conversation as resolved.
Show resolved Hide resolved
start : int, optional
Starting minimum width value, MIN_WIDTH by default.
stop : int, optional
Stopping maximum width value, MAX_WIDTH by default.
tol : int, optional
Tolerable amount of width value variation, TOLERANCE by default.

Returns
-------
str
srcset attribute value
Srcset attribute string.
"""
if not params:
params = {}
validate_min_max_tol(start, stop, tol)

has_width = 'w' in params
has_height = 'h' in params
Expand All @@ -145,28 +175,89 @@ def create_srcset(self, path, params=None):
if (has_width or (has_height and has_aspect_ratio)):
return self._build_srcset_DPR(path, params)
else:
return self._build_srcset_pairs(path, params)
targets = target_widths(start, stop, tol)
return self._build_srcset_pairs(path, params, targets)

def _build_srcset_pairs(self, path, params):
def _build_srcset_pairs(
self, path, params, targets=TARGET_WIDTHS):
# prevents mutating the params dict
srcset_params = dict(params)
srcset_entries = []

for w in SRCSET_TARGET_WIDTHS:
for w in targets:
srcset_params['w'] = w
srcset_entries.append(self.create_url(path, srcset_params) +
" " + str(w) + "w")

return ",\n".join(srcset_entries)

def _build_srcset_DPR(self, path, params):
def _build_srcset_DPR(
self, path, params, targets=TARGET_RATIOS):
# prevents mutating the params dict
srcset_params = dict(params)
srcset_entries = []

for dpr in SRCSET_DPR_TARGET_RATIOS:
for dpr in targets:
srcset_params['dpr'] = dpr
srcset_entries.append(self.create_url(path, srcset_params) +
" " + str(dpr) + "x")

return ",\n".join(srcset_entries)


def target_widths(start=MIN_WIDTH, stop=MAX_WIDTH, tol=TOLERANCE):
sherwinski marked this conversation as resolved.
Show resolved Hide resolved
"""
Generate a list of target widths.

This function generates a list of target widths used to width-describe
image candidate strings (URLs) within a srcset attribute.

For example, if the target widths are [100, 200, 300], they would become:

'https://example.test.com/image/path.png?w=100 100w
https://example.test.com/image/path.png?w=200 200w
https://example.test.com/image/path.png?w=300 300w'

in the srcset attribute string. Read more about image candidate strings
and width descriptors here:

https://html.spec.whatwg.org/multipage/images.html#image-candidate-string

Parameters
----------
start : int, optional
Starting minimum width value, MIN_WIDTH by default.
stop : int, optional
Stopping maximum width value, MAX_WIDTH by default.
tol : int, optional
Tolerable amount of image width-variation, TOLERANCE by default.

Returns
-------
list
A list of even integer values.
"""
validate_min_max_tol(start, stop, tol)
# If any value differs from the default, we're constructing a custom
# target widths list.
CUSTOM = any([tol != TOLERANCE, start != MIN_WIDTH, stop != MAX_WIDTH])

if not CUSTOM:
return TARGET_WIDTHS

resolutions = []

def make_even_integer(n):
return int(2 * round(n/2.0))

while start < stop and start < MAX_WIDTH:
resolutions.append(make_even_integer(start))
start *= 1 + (tol / 100.0) * 2

# The most recently appended value may, or may not, be
# the `stop` value. In order to be inclusive of the
# stop value, check for this case and add it, if necessary.
if resolutions[-1] < stop:
resolutions.append(stop)

return resolutions
43 changes: 43 additions & 0 deletions imgix/validators.py
@@ -1,5 +1,6 @@
from .constants import IMAGE_MAX_WIDTH as MAX_WIDTH
from .constants import IMAGE_ZERO_WIDTH as ZERO_WIDTH
from .constants import SRCSET_MIN_WIDTH_TOLERANCE as ONE_PERCENT


def validate_min_width(value):
Expand Down Expand Up @@ -98,3 +99,45 @@ def validate_range(min_width, max_width):

invalid_range_error = 'error: `min_width` must be less than `max_width`'
assert min_width < max_width, invalid_range_error


def validate_width_tol(value):
"""
Validate the width tolerance.

This function ensures that the width tolerance `value` is greater than or
equal to 1, where 1 represents a width tolerance of 1%.

Note: `value` can be either a float or an int, but this is only to yield
flexibility to the caller.

Parameters
----------
value : float, int
Numerical value, typically within the range of [1, 100]. It can be
greater than 100, but no less than 1.
"""
invalid_tol_error = 'tolerance `value` must be a positive numerical value'
assert isinstance(value, (float, int)), invalid_tol_error
assert ONE_PERCENT <= value, invalid_tol_error


def validate_min_max_tol(min_width, max_width, tol):
"""
Validate the minimum, maximum, and tolerance values.

This function is composed of two other validators and exists to provide
convenience at the call site, i.e. instead of calling three functions to
validate this triplet, one function can be called.

Parameters
----------
min_width : float, int
Minimum renderable image width requested, by default.
max_width : float, int
Maximum renderable image width requested, by default.
tol : float, int
Tolerable amount of image width variation requested, by default.
"""
validate_range(min_width, max_width)
validate_width_tol(tol)
36 changes: 36 additions & 0 deletions tests/test_url_builder.py
Expand Up @@ -2,6 +2,9 @@
import imgix

from future.moves.urllib.parse import urlparse
from imgix import constants
from imgix import urlbuilder
from imgix.constants import IMAGE_MAX_WIDTH, IMAGE_MIN_WIDTH


def _get_domain(url):
Expand Down Expand Up @@ -214,3 +217,36 @@ def test_include_library_param_false():
ub = imgix.UrlBuilder("assets.imgix.net", include_library_param=False)

assert url == ub.create_url("image.jpg")


def test_target_widths_default():
expected = constants.SRCSET_TARGET_WIDTHS
actual = urlbuilder.target_widths()
assert len(actual) == len(expected)
assert actual == expected


def test_target_widths_100_7400():
idx_of_7400 = -1
expected = constants.SRCSET_TARGET_WIDTHS[:idx_of_7400]
actual = urlbuilder.target_widths(start=100, stop=7400)
assert actual == expected


def test_target_widths_380_4088():
idx_of_328, idx_of_4088 = 8, -5
expected = constants.SRCSET_TARGET_WIDTHS[idx_of_328: idx_of_4088]
actual = urlbuilder.target_widths(start=328, stop=4088)
assert len(actual) == len(expected)
assert actual[0] == expected[0]
assert actual[-1] == expected[-1]


def test_target_widths_100_max():
idx_of_100 = constants.SRCSET_TARGET_WIDTHS[0]
idx_of_8192 = constants.SRCSET_TARGET_WIDTHS[-1]
expected = [idx_of_100, idx_of_8192]
actual = urlbuilder.target_widths(tol=10000000000)
assert actual == expected
assert actual[0] == IMAGE_MIN_WIDTH
assert actual[-1] == IMAGE_MAX_WIDTH
22 changes: 20 additions & 2 deletions tests/test_validators.py
@@ -1,10 +1,10 @@
import unittest

from imgix.constants import IMAGE_MIN_WIDTH, IMAGE_MAX_WIDTH, \
IMAGE_ZERO_WIDTH
IMAGE_ZERO_WIDTH, SRCSET_MIN_WIDTH_TOLERANCE

from imgix.validators import validate_min_width, validate_max_width, \
validate_range
validate_range, validate_min_max_tol


class TestValidators(unittest.TestCase):
Expand Down Expand Up @@ -56,3 +56,21 @@ def test_validate_range_raises(self):

with self.assertRaises(AssertionError):
validate_range(IMAGE_MAX_WIDTH, IMAGE_MAX_WIDTH)

def test_validate_min_max_tol_raises(self):

with self.assertRaises(AssertionError):
# `IMAGE_ZERO_WIDTH` is being used to
# simulate a `tol` < ONE_PERCENT.
validate_min_max_tol(
IMAGE_MIN_WIDTH,
IMAGE_MAX_WIDTH,
IMAGE_ZERO_WIDTH)

def test_validate_min_max_tol(self):
# Due to the assertive nature of this validator
# if this test does not raise, it passes.
validate_min_max_tol(
IMAGE_MIN_WIDTH,
IMAGE_MAX_WIDTH,
SRCSET_MIN_WIDTH_TOLERANCE)