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:
@@ -1,6 +1,6 @@
|
||||
from netbox.plugins import PluginConfig, get_plugin_config
|
||||
|
||||
__version__ = "0.11.2"
|
||||
__version__ = "0.11.3"
|
||||
|
||||
|
||||
class NetBoxUtilitiesConfig(PluginConfig):
|
||||
|
||||
@@ -7,20 +7,27 @@ ET.register_namespace("", SVG_NS)
|
||||
ET.register_namespace("xlink", XLINK_NS)
|
||||
|
||||
_ANCHOR_TAGS = (f"{{{SVG_NS}}}a", "a")
|
||||
_IMAGE_TAGS = (f"{{{SVG_NS}}}image", "image")
|
||||
_HREF_ATTRS = ("href", f"{{{XLINK_NS}}}href")
|
||||
|
||||
|
||||
def strip_svg_hyperlinks(svg_source):
|
||||
"""Return svg_source with every <a> wrapper unwrapped and href/xlink:href removed.
|
||||
def strip_svg_hyperlinks(svg_source, image_loader=None):
|
||||
"""Return svg_source with every <a> wrapper unwrapped and dead links removed.
|
||||
|
||||
NetBox wraps rack elevation devices in hyperlinks pointing at internal,
|
||||
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
|
||||
a self-contained file with the same visuals but no link markup.
|
||||
the SVG is saved and opened outside of a NetBox session, so this unwraps
|
||||
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)
|
||||
_unwrap_anchors(root)
|
||||
_strip_href_attributes(root)
|
||||
_resolve_hrefs(root, image_loader)
|
||||
return ET.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
@@ -41,8 +48,20 @@ def _unwrap_anchors(parent):
|
||||
parent.append(child)
|
||||
|
||||
|
||||
def _strip_href_attributes(element):
|
||||
for attr in _HREF_ATTRS:
|
||||
element.attrib.pop(attr, None)
|
||||
def _resolve_hrefs(element, image_loader):
|
||||
if element.tag in _IMAGE_TAGS:
|
||||
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:
|
||||
_strip_href_attributes(child)
|
||||
_resolve_hrefs(child, image_loader)
|
||||
|
||||
@@ -19,16 +19,54 @@ class StripSvgHyperlinksTest(SimpleTestCase):
|
||||
self.assertNotIn("href", 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 xmlns="http://www.w3.org/2000/svg" '
|
||||
'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>"
|
||||
)
|
||||
|
||||
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.assertIn("<image", result)
|
||||
|
||||
|
||||
@@ -368,6 +368,37 @@ class SelectTenantView(LoginRequiredMixin, View):
|
||||
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):
|
||||
"""Serve a rack elevation SVG stripped of hyperlinks, for offline use."""
|
||||
|
||||
@@ -394,7 +425,7 @@ class RackElevationOfflineSVGView(LoginRequiredMixin, View):
|
||||
if hasattr(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(" ", "_")
|
||||
http_response = HttpResponse(offline_svg, content_type="image/svg+xml")
|
||||
http_response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
|
||||
Reference in New Issue
Block a user