From 13b183015eeeefafa3795c48c296cf8a54265a54 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Thu, 5 Mar 2026 13:05:41 +0100 Subject: [PATCH] Extract _scale_url() method for overridable scale URL generation Refactor hardcoded @@images URL building into an overridable _scale_url(uid, extension, base_url=None) method on both ImageScale and ImageScaling classes. This allows custom image backends (e.g. Thumbor) to generate direct URLs without copying entire methods. Refs #199 --- news/199.feature | 2 + src/plone/namedfile/scaling.py | 52 ++++++++++++---- src/plone/namedfile/tests/test_scaling.py | 73 +++++++++++++++++++++++ 3 files changed, 114 insertions(+), 13 deletions(-) create mode 100644 news/199.feature diff --git a/news/199.feature b/news/199.feature new file mode 100644 index 0000000..a0dcbe9 --- /dev/null +++ b/news/199.feature @@ -0,0 +1,2 @@ +Extract ``_scale_url()`` method on ``ImageScale`` and ``ImageScaling`` for overridable scale URL generation. This allows custom image backends (e.g. Thumbor) to generate direct URLs by overriding a single method. +@jensens diff --git a/src/plone/namedfile/scaling.py b/src/plone/namedfile/scaling.py index 1c9a82c..875afb5 100644 --- a/src/plone/namedfile/scaling.py +++ b/src/plone/namedfile/scaling.py @@ -94,7 +94,6 @@ def __init__(self, context, request, **info): if self.data is None: self.data = getattr(self.context, self.fieldname) - url = self.context.absolute_url() extension = self.data.contentType.split("/")[-1].lower() if self.data.contentType == "image/svg+xml": extension = "svg" @@ -103,9 +102,25 @@ def __init__(self, context, request, **info): else: name = info["fieldname"] self.__name__ = f"{name}.{extension}" - self.url = f"{url}/@@images/{self.__name__}" + self.url = self._scale_url(name, extension) self.srcset = info.get("srcset", []) + def _scale_url(self, uid, extension, base_url=None): + """Build the URL for an image scale. + + Override this method to generate custom scale URLs, e.g. for + external image services like Thumbor. + + :param uid: The unique scale identifier. + :param extension: The file extension (e.g. "jpeg", "png"). + :param base_url: The base URL of the content object. + Defaults to ``self.context.absolute_url()``. + :returns: The full URL to the image scale. + """ + if base_url is None: + base_url = self.context.absolute_url() + return f"{base_url}/@@images/{uid}.{extension}" + def absolute_url(self): return self.url @@ -113,11 +128,8 @@ def srcset_attribute(self): _srcset_attr = [] extension = self.data.contentType.split("/")[-1].lower() for scale in self.srcset: - _srcset_attr.append( - "{}/@@images/{}.{} {}x".format( - self.context.absolute_url(), scale["uid"], extension, scale["scale"] - ) - ) + url = self._scale_url(scale["uid"], extension) + _srcset_attr.append(f"{url} {scale['scale']}x") srcset_attr = ", ".join(_srcset_attr) return srcset_attr @@ -520,6 +532,22 @@ def available_sizes(self): def available_sizes(self, value): self._sizes = value + def _scale_url(self, uid, extension, base_url=None): + """Build the URL for an image scale. + + Override this method to generate custom scale URLs, e.g. for + external image services like Thumbor. + + :param uid: The unique scale identifier. + :param extension: The file extension (e.g. "jpeg", "png"). + :param base_url: The base URL of the content object. + Defaults to ``self.context.absolute_url()``. + :returns: The full URL to the image scale. + """ + if base_url is None: + base_url = self.context.absolute_url() + return f"{base_url}/@@images/{uid}.{extension}" + def getImageSize(self, fieldname=None): if fieldname is not None: try: @@ -776,9 +804,8 @@ def srcset( ) if scale: extension = scale["mimetype"].split("/")[-1].lower() - srcset_urls.append( - f'{self.context.absolute_url()}/@@images/{scale["uid"]}.{extension} {scale["width"]}w' - ) + url = self._scale_url(scale["uid"], extension) + srcset_urls.append(f"{url} {scale['width']}w") # then get the urls of the scales that are smaller than the original for width, height in self.available_sizes.values(): @@ -787,9 +814,8 @@ def srcset( fieldname=fieldname, width=width, height=height, mode="scale" ) extension = scale["mimetype"].split("/")[-1].lower() - srcset_urls.append( - f'{self.context.absolute_url()}/@@images/{scale["uid"]}.{extension} {scale["width"]}w' - ) + url = self._scale_url(scale["uid"], extension) + srcset_urls.append(f"{url} {scale['width']}w") attributes = {} if title is _marker: diff --git a/src/plone/namedfile/tests/test_scaling.py b/src/plone/namedfile/tests/test_scaling.py index ad90b14..3b6abaf 100644 --- a/src/plone/namedfile/tests/test_scaling.py +++ b/src/plone/namedfile/tests/test_scaling.py @@ -814,6 +814,79 @@ def testOversizedHighPixelDensityScale(self): self.assertEqual(foo.srcset[0]["scale"], 2) +class TestScaleUrl(unittest.TestCase): + """Test the _scale_url override mechanism on ImageScale and ImageScaling.""" + + layer = PLONE_NAMEDFILE_INTEGRATION_TESTING + + def setUp(self): + data = getFile("image.png") + item = DummyContent() + item.image = MockNamedImage(data, "image/png", "image.png") + self.layer["app"]._setOb("item", item) + self.item = self.layer["app"].item + + def test_image_scaling_scale_url_default(self): + scaling = ImageScaling(self.item, None) + url = scaling._scale_url("abc-123", "jpeg") + self.assertEqual(url, f"{self.item.absolute_url()}/@@images/abc-123.jpeg") + + def test_image_scaling_scale_url_custom_base(self): + scaling = ImageScaling(self.item, None) + url = scaling._scale_url("abc-123", "jpeg", base_url="http://example.com") + self.assertEqual(url, "http://example.com/@@images/abc-123.jpeg") + + def test_image_scaling_scale_url_override(self): + """Subclasses can override _scale_url to produce custom URLs.""" + + class CustomScaling(ImageScaling): + def _scale_url(self, uid, extension, base_url=None): + return f"https://thumbor.example.com/{uid}.{extension}" + + scaling = CustomScaling(self.item, None) + url = scaling._scale_url("abc-123", "jpeg") + self.assertEqual(url, "https://thumbor.example.com/abc-123.jpeg") + + def test_image_scale_uses_scale_url(self): + """ImageScale.url should use _scale_url.""" + scaling = ImageScaling(self.item, None) + scale = scaling.scale("image", width=100, height=100, pre=True) + # The url should follow the _scale_url pattern + self.assertIn("/@@images/", scale.url) + self.assertTrue(scale.url.endswith(".png")) + + def test_image_scale_override(self): + """A custom ImageScale subclass can override _scale_url.""" + + class CustomImageScale(ImageScale): + def _scale_url(self, uid, extension, base_url=None): + return f"https://cdn.example.com/{uid}.{extension}" + + class CustomScaling(ImageScaling): + _scale_view_class = CustomImageScale + + scaling = CustomScaling(self.item, None) + scale = scaling.scale("image", width=100, height=100, pre=True) + self.assertTrue(scale.url.startswith("https://cdn.example.com/")) + + def test_srcset_uses_scale_url(self): + """ImageScaling.srcset should use _scale_url for srcset URLs.""" + + class CustomScaling(ImageScaling): + def _scale_url(self, uid, extension, base_url=None): + return f"https://thumbor.example.com/{uid}.{extension}" + + scaling = CustomScaling(self.item, None) + scaling.available_sizes = { + "mini": (200, 65536), + "thumb": (128, 128), + "tile": (64, 64), + } + tag = scaling.srcset("image", sizes="100vw", scale_in_src="mini") + # The srcset attribute URLs should use the custom _scale_url + self.assertIn("https://thumbor.example.com/", tag) + + class TestImgSrcSet(unittest.TestCase): layer = PLONE_NAMEDFILE_INTEGRATION_TESTING