Skip to content

Commit 2a3e04a

Browse files
daniel cohenAmir Tocker
authored andcommitted
user-variables
1 parent 9a63d2d commit 2a3e04a

File tree

3 files changed

+110
-35
lines changed

3 files changed

+110
-35
lines changed

cloudinary/utils.py

Lines changed: 84 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -133,24 +133,30 @@ def recurse(bs):
133133
if_value = process_conditional(options.pop("if", None))
134134

135135
params = {
136-
"a": angle,
137-
"ar": aspect_ratio,
136+
"a": normalize_expression(angle),
137+
"ar": normalize_expression(aspect_ratio),
138138
"b": background,
139139
"bo": border,
140140
"c": crop,
141141
"co": color,
142-
"dpr": dpr,
143-
"du": duration,
144-
"e": effect,
145-
"eo": end_offset,
142+
"dpr": normalize_expression(dpr),
143+
"du": normalize_expression(duration),
144+
"e": normalize_expression(effect),
145+
"eo": normalize_expression(end_offset),
146146
"fl": flags,
147-
"h": height,
147+
"h": normalize_expression(height),
148148
"l": overlay,
149-
"so": start_offset,
149+
"o": normalize_expression(options.pop('opacity',None)),
150+
"q": normalize_expression(options.pop('quality',None)),
151+
"r": normalize_expression(options.pop('radius',None)),
152+
"so": normalize_expression(start_offset),
150153
"t": named_transformation,
151154
"u": underlay,
155+
"w": normalize_expression(width),
156+
"x": normalize_expression(options.pop('x',None)),
157+
"y": normalize_expression(options.pop('y',None)),
152158
"vc": video_codec,
153-
"w": width
159+
"z": normalize_expression(options.pop('zoom',None))
154160
}
155161
simple_params = {
156162
"ac": "audio_codec",
@@ -163,22 +169,34 @@ def recurse(bs):
163169
"f": "fetch_format",
164170
"g": "gravity",
165171
"ki": "keyframe_interval",
166-
"o": "opacity",
167172
"p": "prefix",
168173
"pg": "page",
169-
"q": "quality",
170-
"r": "radius",
171174
"sp": "streaming_profile",
172175
"vs": "video_sampling",
173-
"x": "x",
174-
"y": "y",
175-
"z": "zoom"
176176
}
177177

178178
for param, option in simple_params.items():
179179
params[param] = options.pop(option, None)
180180

181+
variables = options.pop('variables',{})
182+
var_params = []
183+
for key,value in options.items():
184+
if re.match(r'^\$', key):
185+
var_params.append(u"{0}_{1}".format(key, normalize_expression(str(value))))
186+
187+
var_params.sort()
188+
189+
if variables:
190+
for var in variables:
191+
var_params.append(u"{0}_{1}".format(var[0], normalize_expression(str(var[1]))))
192+
193+
194+
variables = ','.join(var_params)
195+
181196
sorted_params = sorted([param + "_" + str(value) for param, value in params.items() if (value or value == 0)])
197+
if variables:
198+
sorted_params.insert(0, str(variables))
199+
182200
if if_value is not None:
183201
sorted_params.insert(0, "if_" + str(if_value))
184202
transformation = ",".join(sorted_params)
@@ -441,10 +459,10 @@ def cloudinary_api_url(action='upload', **options):
441459

442460

443461
# Based on ruby's CGI::unescape. In addition does not escape / :
444-
def smart_escape(source):
462+
def smart_escape(source,unsafe = r"([^a-zA-Z0-9_.\-\/:]+)"):
445463
def pack(m):
446464
return to_bytes('%' + "%".join(["%02X" % x for x in struct.unpack('B' * len(m.group(1)), m.group(1))]).upper())
447-
return to_string(re.sub(to_bytes(r"([^a-zA-Z0-9_.\-\/:]+)"), pack, to_bytes(source)))
465+
return to_string(re.sub(to_bytes(unsafe), pack, to_bytes(source)))
448466

449467

450468
def random_public_id():
@@ -666,7 +684,7 @@ def process_layer(layer, layer_parameter):
666684
if resource_type == "text" or resource_type == "subtitles":
667685
if public_id is None and text is None:
668686
raise ValueError("Must supply either text or public_id in " + layer_parameter)
669-
687+
670688
text_options = __process_text_options(layer, layer_parameter)
671689

672690
if text_options is not None:
@@ -677,9 +695,20 @@ def process_layer(layer, layer_parameter):
677695
components.append(public_id)
678696

679697
if text is not None:
680-
text = smart_escape(text)
681-
text = text.replace("%2C", "%252C")
682-
text = text.replace("/", "%252F")
698+
var_pattern = r'(\$\([a-zA-Z]\w+\))'
699+
match = re.findall(var_pattern,text)
700+
701+
parts= filter(lambda p: p is not None, re.split(var_pattern,text))
702+
encoded_text = []
703+
for part in parts:
704+
if re.match(var_pattern,part):
705+
encoded_text.append(part)
706+
else:
707+
encoded_text.append(smart_escape(smart_escape(part, r"([,/])")))
708+
709+
text = ''.join(encoded_text)
710+
# text = text.replace("%2C", "%252C")
711+
# text = text.replace("/", "%252F")
683712
components.append(text)
684713
else:
685714
public_id = public_id.replace("/", ':')
@@ -695,32 +724,55 @@ def process_layer(layer, layer_parameter):
695724
"<=": 'lte',
696725
">=": 'gte',
697726
"&&": 'and',
698-
"||": 'or'
727+
"||": 'or',
728+
"*": 'mul',
729+
"/": 'div',
730+
"+": 'add',
731+
"-": 'min'
699732
}
700-
IF_PARAMETERS = {
701-
"width": 'w',
702-
"height": 'h',
703-
"page_count": "pc",
733+
734+
PREDEFINED_VARS = {
735+
"aspect_ratio": "ar",
736+
"current_page": "cp",
704737
"face_count": "fc",
705-
"aspect_ratio": "ar"
738+
"height": "h",
739+
"initial_aspect_ratio": "iar",
740+
"initial_height": "ih",
741+
"initial_width": "iw",
742+
"page_count": "pc",
743+
"page_x": "px",
744+
"page_y": "py",
745+
"tags": "tags",
746+
"width": "w"
706747
}
707748

708-
replaceRE = "(" + "|".join(IF_PARAMETERS.keys()) + "|[=<>&|!]+)"
749+
replaceRE = "(" + "|".join(PREDEFINED_VARS.keys())+ "|[=<>&|!]+)"
750+
replaceIF = "(" + '|'.join(map( lambda key: re.escape(key),IF_OPERATORS.keys()))+ ")"
709751

710752

711753
def translate_if(match):
712754
name = match.group(0)
713755
return IF_OPERATORS.get(name,
714-
IF_PARAMETERS.get(name,
756+
PREDEFINED_VARS.get(name,
715757
name))
716758

717-
718759
def process_conditional(conditional):
719760
if conditional is None:
720761
return conditional
721-
result = re.sub('[ _]+', '_', conditional)
722-
return re.sub(replaceRE, translate_if, result)
762+
result = normalize_expression(conditional)
763+
return result
723764

765+
def normalize_expression(expression):
766+
if re.match(r'^!.+!$',str(expression)): # quoted string
767+
return expression
768+
elif expression:
769+
result = str(expression)
770+
result = re.sub(replaceRE, translate_if, result)
771+
result = re.sub(replaceIF, translate_if, result)
772+
result = re.sub('[ _]+', '_', result)
773+
return result
774+
else:
775+
return expression
724776

725777
def __join_pair(key, value):
726778
if value is None or value == "":

tests/image_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,9 @@ def test_client_hints_as_false(self):
9393
def test_width_auto_breakpoints(self):
9494
"""supports auto width"""
9595
tag = CloudinaryImage('sample.jpg')\
96-
.image(crop="scale", dpr="auto", cloud_name="test", width="auto=breakpoints", client_hints=True)
96+
.image(crop="scale", dpr="auto", cloud_name="test", width="auto:breakpoints", client_hints=True)
9797
six.assertRegex(self, tag,
98-
'src=["\']http://res.cloudinary.com/test/image/upload/c_scale,dpr_auto,w_auto=breakpoints/sample.jpg["\']')
98+
'src=["\']http://res.cloudinary.com/test/image/upload/c_scale,dpr_auto,w_auto:breakpoints/sample.jpg["\']')
9999

100100
if __name__ == "__main__":
101101
unittest.main()

tests/utils_test.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
class TestUtils(unittest.TestCase):
1414
def setUp(self):
15+
1516
cloudinary.config(cloud_name="test123",
1617
api_key="a", api_secret="b",
1718
secure_distribution=None,
@@ -641,6 +642,28 @@ def test_merge(self):
641642
self.assertDictEqual(a, cloudinary.utils.merge(None, a))
642643
self.assertDictEqual({"foo": "bar", "bar": "foo"}, cloudinary.utils.merge(a, b))
643644
self.assertDictEqual(a, cloudinary.utils.merge(b, a))
644-
645+
646+
def test_array_should_define_a_set_of_variables(self):
647+
options = { "if": "face_count > 2", "variables" : [ ["$z", 5], ["$foo", "$z * 2"] ], "crop" : "scale", "width" : "$foo * 200" }
648+
transformation, options = cloudinary.utils.generate_transformation_string(**options)
649+
self.assertEqual('if_fc_gt_2,$z_5,$foo_$z_mul_2,c_scale,w_$foo_mul_200', transformation)
650+
651+
def test_dollar_key_should_define_a_varialbe(self):
652+
options = { "transformation":[ {"$foo":10 }, {"if":"face_count > 2"}, {"crop":"scale", "width":"$foo * 200 / face_count"}, {"if":"end"} ] }
653+
transformation, options = cloudinary.utils.generate_transformation_string(**options)
654+
self.assertEqual('$foo_10/if_fc_gt_2/c_scale,w_$foo_mul_200_div_fc/if_end', transformation)
655+
656+
def test_should_support_text_values(self):
657+
public_id = "sample"
658+
options = {"effect":"$efname:100", "$efname":"!blur!"}
659+
url, options = cloudinary.utils.cloudinary_url(public_id, **options)
660+
self.assertEqual(DEFAULT_UPLOAD_PATH+"$efname_!blur!,e_$efname:100/sample",url)
661+
662+
def test_should_support_string_interpolation(self):
663+
public_id = "sample"
664+
options = { "crop":"scale", "overlay":{"text":"$(start)Hello $(name)$(ext), $(no ) $( no)$(end)", "font_family":"Arial", "font_size":"18"}}
665+
url, options = cloudinary.utils.cloudinary_url(public_id, **options)
666+
self.assertEqual(DEFAULT_UPLOAD_PATH+"c_scale,l_text:Arial_18:$(start)Hello%20$(name)$(ext)%252C%20%24%28no%20%29%20%24%28%20no%29$(end)/sample",url)
667+
645668
if __name__ == '__main__':
646669
unittest.main()

0 commit comments

Comments
 (0)