From ead171d546b93c2fe72ff15cb070d238f95ca4fe Mon Sep 17 00:00:00 2001 From: Louis Date: Thu, 17 Sep 2026 15:20:43 +0200 Subject: [PATCH] fix: inline device images as data URIs in offline SVG export strip_svg_hyperlinks() removed every href, including on elements that reference device-type photos, leaving devices with images blank in the exported file. Non-anchor hrefs (currently only ) 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 --- netbox_utilities/__init__.py | 2 +- netbox_utilities/svg_export.py | 37 +++++++++++++++----- netbox_utilities/tests/test_svg_export.py | 42 +++++++++++++++++++++-- netbox_utilities/views.py | 33 +++++++++++++++++- pyproject.toml | 2 +- 5 files changed, 102 insertions(+), 14 deletions(-) diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py index 96dc203..6a625e6 100644 --- a/netbox_utilities/__init__.py +++ b/netbox_utilities/__init__.py @@ -1,6 +1,6 @@ from netbox.plugins import PluginConfig, get_plugin_config -__version__ = "0.11.2" +__version__ = "0.11.3" class NetBoxUtilitiesConfig(PluginConfig): diff --git a/netbox_utilities/svg_export.py b/netbox_utilities/svg_export.py index c9bacf0..aad2aaf 100644 --- a/netbox_utilities/svg_export.py +++ b/netbox_utilities/svg_export.py @@ -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 wrapper unwrapped and href/xlink:href removed. +def strip_svg_hyperlinks(svg_source, image_loader=None): + """Return svg_source with every 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. + + 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) diff --git a/netbox_utilities/tests/test_svg_export.py b/netbox_utilities/tests/test_svg_export.py index 24e9e5a..24e8592 100644 --- a/netbox_utilities/tests/test_svg_export.py +++ b/netbox_utilities/tests/test_svg_export.py @@ -19,16 +19,54 @@ class StripSvgHyperlinksTest(SimpleTestCase): self.assertNotIn("href", result) self.assertIn("' - '' + '' "" ) result = strip_svg_hyperlinks(svg) + self.assertNotIn("href", result) + self.assertIn("' + '' + "" + ) + + 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 = ( + '' + '' + "" + ) + + 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 = ( + '' + '' + "" + ) + + result = strip_svg_hyperlinks(svg, image_loader=lambda href: None) + self.assertNotIn("href", result) self.assertIn("