Adding extra functionality for svg (rotate, color, ... )

pull/3103/head
jwaes 2025-02-24 19:36:41 +00:00
parent be5d3f3c26
commit 12e11b00c6
5 changed files with 289 additions and 32 deletions

View File

@ -42,8 +42,34 @@ Usage
===== =====
This module works in conjunction with web_iconify. Once installed, icons This module works in conjunction with web_iconify. Once installed, icons
will be served through the proxy and cached locally. No specific usage will be served through the proxy and cached locally.
instructions are required.
SVG Icon Parameters
-------------------
You can customize SVG icons by adding query parameters to the URL. The
format is:
``/web_iconify_proxy/<prefix>/<icon>.svg?param1=value1&param2=value2...``
Available parameters:
- **color**: Icon color (e.g., ``color=red``, ``color=%23ff0000``).
- **width**: Icon width (e.g., ``width=50``, ``width=50px``).
- **height**: Icon height (e.g., ``height=50``, ``height=50px``). If
only one dimension is specified, the other will be calculated
automatically to maintain aspect ratio.
- **flip**: Flip the icon. Possible values: ``horizontal``,
``vertical``, or both (e.g., ``flip=horizontal``, ``flip=vertical``,
``flip=horizontal,vertical``).
- **rotate**: Rotate the icon by 90, 180, or 270 degrees (e.g.,
``rotate=90``, ``rotate=180``).
- **box**: Set to ``true`` to add an empty rectangle to the SVG that
matches the viewBox (e.g., ``box=true``).
Example:
``/web_iconify_proxy/mdi/home.svg?color=blue&width=64&flip=horizontal``
Changelog Changelog
========= =========

View File

@ -1,3 +1,4 @@
import ast
import base64 import base64
import datetime import datetime
import logging import logging
@ -15,7 +16,13 @@ class IconifyProxyController(http.Controller):
"""Controller for proxying Iconify requests.""" """Controller for proxying Iconify requests."""
def _fetch_iconify_data( def _fetch_iconify_data(
self, upstream_url, content_type, prefix, icons=None, icon=None self,
upstream_url,
content_type,
prefix,
icons=None,
icon=None,
normalized_params_string="",
): ):
"""Fetches data from the Iconify API or the local cache. """Fetches data from the Iconify API or the local cache.
@ -25,22 +32,28 @@ class IconifyProxyController(http.Controller):
prefix (str): The icon prefix. prefix (str): The icon prefix.
icons (str, optional): Comma-separated list of icons (for CSS and JSON). icons (str, optional): Comma-separated list of icons (for CSS and JSON).
icon (str, optional): The icon name (for SVG). icon (str, optional): The icon name (for SVG).
normalized_params_string (str, optional): Normalized parameters string.
Returns: Returns:
Response: The HTTP response. Response: The HTTP response.
""" """
# Validate prefix
prefix = prefix.lower()
# Validate prefix # Validate prefix
if not re.match(r"^[a-z0-9-]+$", prefix): if not re.match(r"^[a-z0-9-]+$", prefix):
raise request.not_found() raise request.not_found()
# Validate icon (if provided) # Validate icon (if provided)
if icon and not re.match(r"^[a-z0-9:-]+$", icon): if icon:
raise request.not_found() icon = icon.lower()
if not re.match(r"^[a-z0-9:-]+$", icon):
raise request.not_found()
# Validate icons (if provided) # Validate icons (if provided)
if icons: if icons:
icon_list = icons.split(",") icon_list = [i.lower() for i in icons.split(",")]
for single_icon in icon_list: for single_icon in icon_list:
if not re.match(r"^[a-z0-9:-]+$", single_icon): if not re.match(r"^[a-z0-9:-]+$", single_icon):
raise request.not_found() raise request.not_found()
@ -48,13 +61,13 @@ class IconifyProxyController(http.Controller):
Attachment = request.env["ir.attachment"].sudo() Attachment = request.env["ir.attachment"].sudo()
if content_type == "image/svg+xml": if content_type == "image/svg+xml":
name = f"{prefix}-{icon}" name = f"{prefix}-{icon}-{normalized_params_string.lower()}"
res_model = "iconify.svg" res_model = "iconify.svg"
elif content_type == "text/css": elif content_type == "text/css":
name = f"{prefix}-{icons}" name = f"{prefix}-{icons}-{normalized_params_string.lower()}"
res_model = "iconify.css" res_model = "iconify.css"
elif content_type == "application/json": elif content_type == "application/json":
name = f"{prefix}-{icons}" name = f"{prefix}-{icons}-{normalized_params_string.lower()}"
res_model = "iconify.json" res_model = "iconify.json"
else: else:
raise request.not_found() raise request.not_found()
@ -98,6 +111,36 @@ class IconifyProxyController(http.Controller):
] ]
return request.make_response(data, headers) return request.make_response(data, headers)
def _normalize_params_common(self, params):
"""Normalizes common parameters for Iconify requests."""
normalized = []
for key in sorted(params.keys()): # Sort keys alphabetically
normalized.append(f"{key.lower()}={params[key]}")
return ";".join(normalized)
def _normalize_params_svg(self, params):
"""Normalizes parameters specifically for SVG requests."""
allowed_params = ["color", "width", "height", "flip", "rotate", "box"]
normalized = []
for key in sorted(params.keys()):
if key in allowed_params:
value = params[key]
key = key.lower()
# Basic type validation
if key in ("width", "height", "rotate") and not (
isinstance(value, str) or isinstance(value, int)
):
continue # Skip invalid values
if key == "box":
try:
value = ast.literal_eval(str(value).lower())
if not isinstance(value, bool):
continue
except (ValueError, SyntaxError):
continue
normalized.append(f"{key}={value}")
return ";".join(normalized)
@http.route( @http.route(
"/web_iconify_proxy/<string:prefix>/<string:icon>.svg", "/web_iconify_proxy/<string:prefix>/<string:icon>.svg",
type="http", type="http",
@ -115,9 +158,21 @@ class IconifyProxyController(http.Controller):
Returns: Returns:
Response: The HTTP response containing the SVG data. Response: The HTTP response containing the SVG data.
""" """
upstream_url = f"https://api.iconify.design/{prefix}/{icon}.svg" normalized_params = self._normalize_params_svg(params)
if normalized_params:
query_string = normalized_params.replace(";", "&")
upstream_url = (
f"https://api.iconify.design/{prefix}/{icon}.svg?{query_string}"
)
else:
upstream_url = f"https://api.iconify.design/{prefix}/{icon}.svg"
return self._fetch_iconify_data( return self._fetch_iconify_data(
upstream_url, "image/svg+xml", prefix, icon=icon upstream_url,
"image/svg+xml",
prefix,
icon=icon,
normalized_params_string=normalized_params,
) )
@http.route( @http.route(

View File

@ -1,3 +1,21 @@
This module works in conjunction with web_iconify. Once installed, icons This module works in conjunction with web_iconify. Once installed, icons
will be served through the proxy and cached locally. No specific usage will be served through the proxy and cached locally.
instructions are required.
## SVG Icon Parameters
You can customize SVG icons by adding query parameters to the URL. The format is:
`/web_iconify_proxy/<prefix>/<icon>.svg?param1=value1&param2=value2...`
Available parameters:
* **color**: Icon color (e.g., `color=red`, `color=%23ff0000`).
* **width**: Icon width (e.g., `width=50`, `width=50px`).
* **height**: Icon height (e.g., `height=50`, `height=50px`). If only one dimension is specified, the other will be calculated automatically to maintain aspect ratio.
* **flip**: Flip the icon. Possible values: `horizontal`, `vertical`, or both (e.g., `flip=horizontal`, `flip=vertical`, `flip=horizontal,vertical`).
* **rotate**: Rotate the icon by 90, 180, or 270 degrees (e.g., `rotate=90`, `rotate=180`).
* **box**: Set to `true` to add an empty rectangle to the SVG that matches the viewBox (e.g., `box=true`).
Example:
`/web_iconify_proxy/mdi/home.svg?color=blue&width=64&flip=horizontal`

View File

@ -377,16 +377,19 @@ ir.attachment model.</p>
<p><strong>Table of contents</strong></p> <p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents"> <div class="contents local topic" id="contents">
<ul class="simple"> <ul class="simple">
<li><a class="reference internal" href="#usage" id="toc-entry-1">Usage</a></li> <li><a class="reference internal" href="#usage" id="toc-entry-1">Usage</a><ul>
<li><a class="reference internal" href="#changelog" id="toc-entry-2">Changelog</a><ul> <li><a class="reference internal" href="#svg-icon-parameters" id="toc-entry-2">SVG Icon Parameters</a></li>
<li><a class="reference internal" href="#section-1" id="toc-entry-3">18.0.1.0.0 (2025-02-23)</a></li>
</ul> </ul>
</li> </li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-4">Bug Tracker</a></li> <li><a class="reference internal" href="#changelog" id="toc-entry-3">Changelog</a><ul>
<li><a class="reference internal" href="#credits" id="toc-entry-5">Credits</a><ul> <li><a class="reference internal" href="#section-1" id="toc-entry-4">18.0.1.0.0 (2025-02-23)</a></li>
<li><a class="reference internal" href="#authors" id="toc-entry-6">Authors</a></li> </ul>
<li><a class="reference internal" href="#contributors" id="toc-entry-7">Contributors</a></li> </li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-8">Maintainers</a></li> <li><a class="reference internal" href="#bug-tracker" id="toc-entry-5">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-6">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-7">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="toc-entry-8">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-9">Maintainers</a></li>
</ul> </ul>
</li> </li>
</ul> </ul>
@ -394,20 +397,42 @@ ir.attachment model.</p>
<div class="section" id="usage"> <div class="section" id="usage">
<h1><a class="toc-backref" href="#toc-entry-1">Usage</a></h1> <h1><a class="toc-backref" href="#toc-entry-1">Usage</a></h1>
<p>This module works in conjunction with web_iconify. Once installed, icons <p>This module works in conjunction with web_iconify. Once installed, icons
will be served through the proxy and cached locally. No specific usage will be served through the proxy and cached locally.</p>
instructions are required.</p> <div class="section" id="svg-icon-parameters">
<h2><a class="toc-backref" href="#toc-entry-2">SVG Icon Parameters</a></h2>
<p>You can customize SVG icons by adding query parameters to the URL. The
format is:</p>
<p><tt class="docutils literal"><span class="pre">/web_iconify_proxy/&lt;prefix&gt;/&lt;icon&gt;.svg?param1=value1&amp;param2=value2...</span></tt></p>
<p>Available parameters:</p>
<ul class="simple">
<li><strong>color</strong>: Icon color (e.g., <tt class="docutils literal">color=red</tt>, <tt class="docutils literal"><span class="pre">color=%23ff0000</span></tt>).</li>
<li><strong>width</strong>: Icon width (e.g., <tt class="docutils literal">width=50</tt>, <tt class="docutils literal">width=50px</tt>).</li>
<li><strong>height</strong>: Icon height (e.g., <tt class="docutils literal">height=50</tt>, <tt class="docutils literal">height=50px</tt>). If
only one dimension is specified, the other will be calculated
automatically to maintain aspect ratio.</li>
<li><strong>flip</strong>: Flip the icon. Possible values: <tt class="docutils literal">horizontal</tt>,
<tt class="docutils literal">vertical</tt>, or both (e.g., <tt class="docutils literal">flip=horizontal</tt>, <tt class="docutils literal">flip=vertical</tt>,
<tt class="docutils literal">flip=horizontal,vertical</tt>).</li>
<li><strong>rotate</strong>: Rotate the icon by 90, 180, or 270 degrees (e.g.,
<tt class="docutils literal">rotate=90</tt>, <tt class="docutils literal">rotate=180</tt>).</li>
<li><strong>box</strong>: Set to <tt class="docutils literal">true</tt> to add an empty rectangle to the SVG that
matches the viewBox (e.g., <tt class="docutils literal">box=true</tt>).</li>
</ul>
<p>Example:</p>
<p><tt class="docutils literal"><span class="pre">/web_iconify_proxy/mdi/home.svg?color=blue&amp;width=64&amp;flip=horizontal</span></tt></p>
</div>
</div> </div>
<div class="section" id="changelog"> <div class="section" id="changelog">
<h1><a class="toc-backref" href="#toc-entry-2">Changelog</a></h1> <h1><a class="toc-backref" href="#toc-entry-3">Changelog</a></h1>
<div class="section" id="section-1"> <div class="section" id="section-1">
<h2><a class="toc-backref" href="#toc-entry-3">18.0.1.0.0 (2025-02-23)</a></h2> <h2><a class="toc-backref" href="#toc-entry-4">18.0.1.0.0 (2025-02-23)</a></h2>
<ul class="simple"> <ul class="simple">
<li>Initial version.</li> <li>Initial version.</li>
</ul> </ul>
</div> </div>
</div> </div>
<div class="section" id="bug-tracker"> <div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#toc-entry-4">Bug Tracker</a></h1> <h1><a class="toc-backref" href="#toc-entry-5">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/web/issues">GitHub Issues</a>. <p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/web/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported. In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed If you spotted it first, help us to smash it by providing a detailed and welcomed
@ -415,21 +440,21 @@ If you spotted it first, help us to smash it by providing a detailed and welcome
<p>Do not contact contributors directly about support or help with technical issues.</p> <p>Do not contact contributors directly about support or help with technical issues.</p>
</div> </div>
<div class="section" id="credits"> <div class="section" id="credits">
<h1><a class="toc-backref" href="#toc-entry-5">Credits</a></h1> <h1><a class="toc-backref" href="#toc-entry-6">Credits</a></h1>
<div class="section" id="authors"> <div class="section" id="authors">
<h2><a class="toc-backref" href="#toc-entry-6">Authors</a></h2> <h2><a class="toc-backref" href="#toc-entry-7">Authors</a></h2>
<ul class="simple"> <ul class="simple">
<li>jaco.tech</li> <li>jaco.tech</li>
</ul> </ul>
</div> </div>
<div class="section" id="contributors"> <div class="section" id="contributors">
<h2><a class="toc-backref" href="#toc-entry-7">Contributors</a></h2> <h2><a class="toc-backref" href="#toc-entry-8">Contributors</a></h2>
<ul class="simple"> <ul class="simple">
<li>Jaco Waes &lt;<a class="reference external" href="mailto:jaco&#64;jaco.tech">jaco&#64;jaco.tech</a>&gt;</li> <li>Jaco Waes &lt;<a class="reference external" href="mailto:jaco&#64;jaco.tech">jaco&#64;jaco.tech</a>&gt;</li>
</ul> </ul>
</div> </div>
<div class="section" id="maintainers"> <div class="section" id="maintainers">
<h2><a class="toc-backref" href="#toc-entry-8">Maintainers</a></h2> <h2><a class="toc-backref" href="#toc-entry-9">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p> <p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org"> <a class="reference external image-reference" href="https://odoo-community.org">
<img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /> <img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" />

View File

@ -3,8 +3,7 @@ from unittest.mock import patch
import requests import requests
from odoo.tests import tagged from odoo.tests.common import HttpCase, tagged
from odoo.tests.common import HttpCase
@tagged("post_install", "-at_install") @tagged("post_install", "-at_install")
@ -129,6 +128,140 @@ class TestIconifyProxyController(HttpCase):
# Check that content is the same # Check that content is the same
self.assertEqual(response1.content, response2.content) self.assertEqual(response1.content, response2.content)
@patch("odoo.addons.web_iconify_proxy.controllers.main.requests.get")
def test_caching_with_params(self, mock_get):
"""Test caching with different parameters."""
mock_response = requests.Response()
mock_response.status_code = 200
mock_response._content = b"<svg>dummy content</svg>"
mock_response.headers["Content-Type"] = "image/svg+xml"
mock_get.return_value = mock_response
# First request with specific parameters
response1 = self.url_open(
"/web_iconify_proxy/mdi/home.svg?color=red&width=50&flip=horizontal"
)
self.assertEqual(response1.status_code, 200)
self.assertFalse("X-Cached-At" in response1.headers)
# Second request with the same parameters, should be cached
response2 = self.url_open(
"/web_iconify_proxy/mdi/home.svg?color=red&width=50&flip=horizontal"
)
self.assertEqual(response2.status_code, 200)
self.assertTrue("X-Cached-At" in response2.headers)
# Third request with different parameters, should not be cached
response3 = self.url_open(
"/web_iconify_proxy/mdi/home.svg?color=blue&width=100"
)
self.assertEqual(response3.status_code, 200)
self.assertFalse("X-Cached-At" in response3.headers)
@patch("odoo.addons.web_iconify_proxy.controllers.main.requests.get")
def test_caching_parameter_order(self, mock_get):
"""Test that parameter order doesn't affect caching."""
mock_response = requests.Response()
mock_response.status_code = 200
mock_response._content = b"<svg>dummy content</svg>"
mock_response.headers["Content-Type"] = "image/svg+xml"
mock_get.return_value = mock_response
# First request with specific parameter order
response1 = self.url_open(
"/web_iconify_proxy/mdi/home.svg?color=red&width=50&flip=horizontal"
)
self.assertEqual(response1.status_code, 200)
self.assertFalse("X-Cached-At" in response1.headers)
# Second request with different parameter order, should be cached
response2 = self.url_open(
"/web_iconify_proxy/mdi/home.svg?flip=horizontal&width=50&color=red"
)
self.assertEqual(response2.status_code, 200)
self.assertTrue("X-Cached-At" in response2.headers)
# Check that content is the same
self.assertEqual(response1.content, response2.content)
@patch("odoo.addons.web_iconify_proxy.controllers.main.requests.get")
def test_caching_boolean_values(self, mock_get):
"""Test that boolean values are case-insensitive for caching."""
mock_response = requests.Response()
mock_response.status_code = 200
mock_response._content = b"<svg>dummy content</svg>"
mock_response.headers["Content-Type"] = "image/svg+xml"
mock_get.return_value = mock_response
# First request with "true"
response1 = self.url_open("/web_iconify_proxy/mdi/home.svg?box=true")
self.assertEqual(response1.status_code, 200)
self.assertFalse("X-Cached-At" in response1.headers)
# Second request with "True", should be cached
response2 = self.url_open("/web_iconify_proxy/mdi/home.svg?box=True")
self.assertEqual(response2.status_code, 200)
self.assertTrue("X-Cached-At" in response2.headers)
# Third request with "TRUE", should be cached
response3 = self.url_open("/web_iconify_proxy/mdi/home.svg?box=TRUE")
self.assertEqual(response3.status_code, 200)
self.assertTrue("X-Cached-At" in response3.headers)
# Check that content is the same for all
self.assertEqual(response1.content, response2.content)
self.assertEqual(response1.content, response3.content)
@patch("odoo.addons.web_iconify_proxy.controllers.main.requests.get")
def test_get_svg_with_parameters(self, mock_get):
"""Test the get_svg route with various valid parameters."""
mock_response = requests.Response()
mock_response.status_code = 200
mock_response._content = b"<svg>dummy content</svg>"
mock_response.headers["Content-Type"] = "image/svg+xml"
mock_get.return_value = mock_response
test_cases = [
"/web_iconify_proxy/mdi/home.svg?color=red",
"/web_iconify_proxy/mdi/home.svg?width=50",
"/web_iconify_proxy/mdi/home.svg?height=50",
"/web_iconify_proxy/mdi/home.svg?flip=horizontal",
"/web_iconify_proxy/mdi/home.svg?flip=vertical",
"/web_iconify_proxy/mdi/home.svg?flip=horizontal,vertical",
"/web_iconify_proxy/mdi/home.svg?rotate=90",
"/web_iconify_proxy/mdi/home.svg?rotate=180",
"/web_iconify_proxy/mdi/home.svg?rotate=270",
"/web_iconify_proxy/mdi/home.svg?box=true",
"/web_iconify_proxy/mdi/home.svg?color=blue&width=64&flip=horizontal&rotate=180",
]
for url in test_cases:
with self.subTest(url=url):
response = self.url_open(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], "image/svg+xml")
@patch("odoo.addons.web_iconify_proxy.controllers.main.requests.get")
def test_get_svg_with_invalid_parameters(self, mock_get):
"""Test the get_svg route with invalid parameters."""
mock_response = requests.Response()
mock_response.status_code = 200
mock_response._content = b"<svg>dummy content</svg>"
mock_response.headers["Content-Type"] = "image/svg+xml"
mock_get.return_value = mock_response
test_cases = [
"/web_iconify_proxy/mdi/home.svg?width=invalid", # Invalid width
"/web_iconify_proxy/mdi/home.svg?height=invalid", # Invalid height
"/web_iconify_proxy/mdi/home.svg?rotate=invalid", # Invalid rotate
"/web_iconify_proxy/mdi/home.svg?box=invalid", # Invalid box
"/web_iconify_proxy/mdi/home.svg?unknown=param", # Unknown parameter
]
for url in test_cases:
with self.subTest(url=url):
response = self.url_open(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], "image/svg+xml")
@patch("odoo.addons.web_iconify_proxy.controllers.main.requests.get") @patch("odoo.addons.web_iconify_proxy.controllers.main.requests.get")
def test_api_error(self, mock_get): def test_api_error(self, mock_get):
# Mock requests.get to simulate an API error # Mock requests.get to simulate an API error