Files
Netbox-Utilities/netbox_utilities/svg_export.py
T
MrBlakeandClaude Sonnet 5 3cff66ad33 fix: target the correct classes for images-only/labels-only export
Decompiled NetBox core's own bundled JS (netbox.js) to find the real
toggle logic: its "images-only"/"labels-only" control only ever
hides image.device-image and text.device-image-label - it never
touches the plain text.label, relying on an opaque device image to
visually cover it. A device without a photo therefore still shows its
name live, even in "images-only".

apply_rack_view_mode() previously removed .label for images-only and
.device-image/.device-image-label for labels-only, which not only
targeted the wrong element for images-only but also left device-
image-label (the name overlaid on the image) around in labels-only.

For a static, non-interactive export there's no image left to cover
stray text, so per explicit request this deliberately does not mirror
NetBox's live fallback: images-only now always removes every label
(.label and .device-image-label) for a pure image view, and
labels-only removes the image layer (.device-image and
.device-image-label) to reproduce NetBox's own box-with-text view.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 15:54:53 +02:00

104 lines
3.8 KiB
Python

import xml.etree.ElementTree as ET
SVG_NS = "http://www.w3.org/2000/svg"
XLINK_NS = "http://www.w3.org/1999/xlink"
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")
RACK_VIEW_MODES = {"images-and-labels", "images-only", "labels-only"}
_PLAIN_LABEL_CLASS = "label"
_IMAGE_CLASS = "device-image"
_IMAGE_LABEL_CLASS = "device-image-label"
def apply_rack_view_mode(svg_source, mode):
"""Filter the rack elevation SVG down to images-only or labels-only content.
NetBox's own "Bilder"/"Beschriftungen" toggle never actually removes the
plain .label text: it just leaves an opaque .device-image sitting on top
of it, so a device without a photo still shows its name in "images-only"
live. A downloaded, static export has no such fallback text to hide
behind an image, so here "images-only" removes every label outright
(.label and .device-image-label) to always produce a pure image view,
and "labels-only" removes the image layer to reproduce the same
box-with-text view NetBox itself already shows for that mode.
"""
if mode not in RACK_VIEW_MODES or mode == "images-and-labels":
return svg_source
root = ET.fromstring(svg_source)
if mode == "images-only":
_remove_elements_with_class(root, {_PLAIN_LABEL_CLASS, _IMAGE_LABEL_CLASS})
elif mode == "labels-only":
_remove_elements_with_class(root, {_IMAGE_CLASS, _IMAGE_LABEL_CLASS})
return ET.tostring(root, encoding="unicode")
def _remove_elements_with_class(parent, class_names):
for child in list(parent):
classes = set(child.attrib.get("class", "").split())
if classes & class_names:
parent.remove(child)
else:
_remove_elements_with_class(child, class_names)
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 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)
_resolve_hrefs(root, image_loader)
return ET.tostring(root, encoding="unicode")
def _unwrap_anchors(parent):
replaced = False
new_children = []
for child in list(parent):
_unwrap_anchors(child)
if child.tag in _ANCHOR_TAGS:
replaced = True
new_children.extend(list(child))
else:
new_children.append(child)
if replaced:
for child in list(parent):
parent.remove(child)
for child in new_children:
parent.append(child)
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:
_resolve_hrefs(child, image_loader)