fix: inline device images as data URIs in offline SVG export

strip_svg_hyperlinks() removed every href, including on <image>
elements that reference device-type photos, leaving devices with
images blank in the exported file. Non-anchor hrefs (currently only
<image>) are now resolved through an optional image_loader callback
and embedded as base64 data URIs instead of being stripped; anchor
unwrapping is unchanged. RackElevationOfflineSVGView wires this up to
NetBox's MEDIA storage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 15:20:43 +02:00
co-authored by Claude Sonnet 5
parent 23acebcf17
commit ead171d546
5 changed files with 102 additions and 14 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig, get_plugin_config from netbox.plugins import PluginConfig, get_plugin_config
__version__ = "0.11.2" __version__ = "0.11.3"
class NetBoxUtilitiesConfig(PluginConfig): class NetBoxUtilitiesConfig(PluginConfig):
+28 -9
View File
@@ -7,20 +7,27 @@ ET.register_namespace("", SVG_NS)
ET.register_namespace("xlink", XLINK_NS) ET.register_namespace("xlink", XLINK_NS)
_ANCHOR_TAGS = (f"{{{SVG_NS}}}a", "a") _ANCHOR_TAGS = (f"{{{SVG_NS}}}a", "a")
_IMAGE_TAGS = (f"{{{SVG_NS}}}image", "image")
_HREF_ATTRS = ("href", f"{{{XLINK_NS}}}href") _HREF_ATTRS = ("href", f"{{{XLINK_NS}}}href")
def strip_svg_hyperlinks(svg_source): def strip_svg_hyperlinks(svg_source, image_loader=None):
"""Return svg_source with every <a> wrapper unwrapped and href/xlink:href removed. """Return svg_source with every <a> wrapper unwrapped and dead links removed.
NetBox wraps rack elevation devices in hyperlinks pointing at internal, NetBox wraps rack elevation devices in hyperlinks pointing at internal,
relative object URLs. Those links are dead weight (and broken paths) once relative object URLs. Those links are dead weight (and broken paths) once
the SVG is saved and opened outside of a NetBox session, so this produces the SVG is saved and opened outside of a NetBox session, so this unwraps
a self-contained file with the same visuals but no link markup. them while keeping their contents.
<image> elements reference device photos by the same kind of internal,
relative URL. Simply stripping their href would leave the device blank,
so image_loader (href -> "data:...;base64,..." or None) is used to embed
the image data directly instead. Without an image_loader, image hrefs are
left untouched.
""" """
root = ET.fromstring(svg_source) root = ET.fromstring(svg_source)
_unwrap_anchors(root) _unwrap_anchors(root)
_strip_href_attributes(root) _resolve_hrefs(root, image_loader)
return ET.tostring(root, encoding="unicode") return ET.tostring(root, encoding="unicode")
@@ -41,8 +48,20 @@ def _unwrap_anchors(parent):
parent.append(child) parent.append(child)
def _strip_href_attributes(element): def _resolve_hrefs(element, image_loader):
for attr in _HREF_ATTRS: if element.tag in _IMAGE_TAGS:
element.attrib.pop(attr, None) if image_loader is not None:
for attr in _HREF_ATTRS:
href = element.attrib.get(attr)
if not href:
continue
data_uri = image_loader(href)
if data_uri:
element.attrib[attr] = data_uri
else:
element.attrib.pop(attr, None)
else:
for attr in _HREF_ATTRS:
element.attrib.pop(attr, None)
for child in element: for child in element:
_strip_href_attributes(child) _resolve_hrefs(child, image_loader)
+40 -2
View File
@@ -19,16 +19,54 @@ class StripSvgHyperlinksTest(SimpleTestCase):
self.assertNotIn("href", result) self.assertNotIn("href", result)
self.assertIn("<rect", result) self.assertIn("<rect", result)
def test_removes_href_on_non_anchor_elements(self): def test_removes_href_on_non_image_non_anchor_elements(self):
svg = ( svg = (
'<svg xmlns="http://www.w3.org/2000/svg" ' '<svg xmlns="http://www.w3.org/2000/svg" '
'xmlns:xlink="http://www.w3.org/1999/xlink">' 'xmlns:xlink="http://www.w3.org/1999/xlink">'
'<image xlink:href="/static/logo.png" href="/static/logo.png"/>' '<use xlink:href="#some-symbol" href="#some-symbol"/>'
"</svg>" "</svg>"
) )
result = strip_svg_hyperlinks(svg) result = strip_svg_hyperlinks(svg)
self.assertNotIn("href", result)
self.assertIn("<use", result)
def test_leaves_image_href_untouched_without_a_loader(self):
svg = (
'<svg xmlns="http://www.w3.org/2000/svg" '
'xmlns:xlink="http://www.w3.org/1999/xlink">'
'<image xlink:href="/media/devicetype-images/example.png"/>'
"</svg>"
)
result = strip_svg_hyperlinks(svg)
self.assertIn("/media/devicetype-images/example.png", result)
def test_replaces_image_href_with_data_uri_from_loader(self):
svg = (
'<svg xmlns="http://www.w3.org/2000/svg" '
'xmlns:xlink="http://www.w3.org/1999/xlink">'
'<image xlink:href="/media/devicetype-images/example.png"/>'
"</svg>"
)
result = strip_svg_hyperlinks(svg, image_loader=lambda href: "data:image/png;base64,AAAA")
self.assertIn("data:image/png;base64,AAAA", result)
self.assertNotIn("/media/devicetype-images/example.png", result)
def test_drops_image_href_when_loader_cannot_resolve_it(self):
svg = (
'<svg xmlns="http://www.w3.org/2000/svg" '
'xmlns:xlink="http://www.w3.org/1999/xlink">'
'<image xlink:href="/media/devicetype-images/missing.png"/>'
"</svg>"
)
result = strip_svg_hyperlinks(svg, image_loader=lambda href: None)
self.assertNotIn("href", result) self.assertNotIn("href", result)
self.assertIn("<image", result) self.assertIn("<image", result)
+32 -1
View File
@@ -368,6 +368,37 @@ class SelectTenantView(LoginRequiredMixin, View):
return redirect(_safe_return_url(request)) return redirect(_safe_return_url(request))
def _media_data_uri_loader():
import base64
import mimetypes
from urllib.parse import urlparse
from django.conf import settings
from django.core.files.storage import default_storage
media_url_path = urlparse(settings.MEDIA_URL).path
def load(href):
storage_path = urlparse(href).path
if media_url_path and storage_path.startswith(media_url_path):
storage_path = storage_path[len(media_url_path) :]
storage_path = storage_path.lstrip("/")
try:
if not storage_path or not default_storage.exists(storage_path):
return None
with default_storage.open(storage_path, "rb") as image_file:
data = image_file.read()
except OSError:
return None
mime_type, _ = mimetypes.guess_type(storage_path)
mime_type = mime_type or "application/octet-stream"
encoded = base64.b64encode(data).decode("ascii")
return f"data:{mime_type};base64,{encoded}"
return load
class RackElevationOfflineSVGView(LoginRequiredMixin, View): class RackElevationOfflineSVGView(LoginRequiredMixin, View):
"""Serve a rack elevation SVG stripped of hyperlinks, for offline use.""" """Serve a rack elevation SVG stripped of hyperlinks, for offline use."""
@@ -394,7 +425,7 @@ class RackElevationOfflineSVGView(LoginRequiredMixin, View):
if hasattr(response, "render"): if hasattr(response, "render"):
response.render() response.render()
offline_svg = strip_svg_hyperlinks(response.content) offline_svg = strip_svg_hyperlinks(response.content, image_loader=_media_data_uri_loader())
filename = f"{rack.name}-{face}-offline.svg".replace(" ", "_") filename = f"{rack.name}-{face}-offline.svg".replace(" ", "_")
http_response = HttpResponse(offline_svg, content_type="image/svg+xml") http_response = HttpResponse(offline_svg, content_type="image/svg+xml")
http_response["Content-Disposition"] = f'attachment; filename="{filename}"' http_response["Content-Disposition"] = f'attachment; filename="{filename}"'
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "netbox-utilities" name = "netbox-utilities"
version = "0.11.2" version = "0.11.3"
description = "Navigation, tenant utilities, connection VLANs, partial-width racks, and bulk operations for NetBox 4.6/4.7" description = "Navigation, tenant utilities, connection VLANs, partial-width racks, and bulk operations for NetBox 4.6/4.7"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"