Files
MrBlakeandClaude Opus 5.5 5b2d68356c feat: optional device images in rack elevations, HTML preview in new tab
- New export option "Gerätebilder anzeigen": the SVG is rendered with
  include_images and the /media/ image links are replaced by embedded
  data URIs, since Word, WeasyPrint and SVG-as-image cannot load
  login-protected files. The Pillow PNG renderer draws the front/rear
  images too, with the device name on a dark label.
- Custom rack_svg_renderer adapters receive images=..., older adapters
  without that parameter keep working.
- The HTML preview now opens in a new browser tab.

Bump version to 0.3.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 13:56:39 +02:00

142 lines
5.1 KiB
Python

"""Rack-Ansicht als PNG mit Pillow (in NetBox ohnehin installiert).
Wird für Word genutzt, wenn cairosvg nicht verfügbar ist oder das SVG nicht erzeugt werden konnte.
"""
from io import BytesIO
SCALE = 2
UNIT_H = 22 * SCALE
BODY_W = 220 * SCALE
LEGEND_W = 30 * SCALE
MARGIN = 15 * SCALE
FRAME = 3 * SCALE
BG = (255, 255, 255)
RACK_BG = (245, 245, 245)
FRAME_COLOR = (80, 80, 80)
GRID = (215, 215, 215)
LEGEND_TEXT = (110, 110, 110)
FALLBACK_ROLE = (158, 158, 158)
def _font(size):
from PIL import ImageFont
for name in ("DejaVuSans.ttf", "Arial.ttf", "arial.ttf", "LiberationSans-Regular.ttf"):
try:
return ImageFont.truetype(name, size)
except OSError:
continue
try:
return ImageFont.load_default(size=size)
except TypeError: # Pillow < 10.1
return ImageFont.load_default()
def _hex(color):
color = (color or "").lstrip("#")
if len(color) != 6:
return FALLBACK_ROLE
return tuple(int(color[i : i + 2], 16) for i in (0, 2, 4))
def _text_color(rgb):
r, g, b = rgb
return (0, 0, 0) if (r * 299 + g * 587 + b * 114) / 1000 > 140 else (255, 255, 255)
def _fit(draw, text, font, width):
if draw.textlength(text, font=font) <= width:
return text
while text and draw.textlength(text + "…", font=font) > width:
text = text[:-1]
return text + "…"
def _paste_image(img, image_field, box):
"""Gerätebild in das Rechteck box=(x1, y1, x2, y2) einpassen. False, wenn nicht lesbar."""
from PIL import Image
x1, y1, x2, y2 = (int(round(v)) for v in box)
try:
with image_field.open("rb") as f:
pic = Image.open(f).convert("RGBA")
pic = pic.resize((max(x2 - x1, 1), max(y2 - y1, 1)), Image.LANCZOS)
except (OSError, ValueError):
return False
img.paste(pic, (x1, y1), pic)
return True
def render_rack_png(rack, face, devices, images=False):
"""devices: iterierbare Geräte des Racks (bereits nach Berechtigung gefiltert).
images=True: Front-/Rückbilder der Gerätetypen einzeichnen (wie die NetBox-Rackansicht).
"""
from .rack_svg import device_image
from PIL import Image, ImageDraw
units = int(rack.u_height)
start = int(getattr(rack, "starting_unit", 1) or 1)
desc = bool(getattr(rack, "desc_units", False))
width = MARGIN * 2 + LEGEND_W + BODY_W
height = MARGIN * 2 + units * UNIT_H
img = Image.new("RGB", (width, height), BG)
draw = ImageDraw.Draw(img)
small, normal = _font(10 * SCALE), _font(11 * SCALE)
left, top = MARGIN + LEGEND_W, MARGIN
draw.rectangle([left, top, left + BODY_W, top + units * UNIT_H], fill=RACK_BG)
def y_of(unit):
"""Obere Kante einer Höheneinheit (unit = Nummer wie in NetBox)."""
idx = unit - start # 0 = unterste HE
row = idx if desc else units - 1 - idx
return top + row * UNIT_H
for i in range(units):
unit = start + i
y = y_of(unit)
draw.line([left, y, left + BODY_W, y], fill=GRID)
label = str(unit)
draw.text((left - 6 * SCALE - draw.textlength(label, font=small), y + UNIT_H / 2), label, fill=LEGEND_TEXT, font=small, anchor="lm")
for d in devices:
if d.position is None:
continue
on_face = d.face == face or getattr(d.device_type, "is_full_depth", False)
if not on_face:
continue
pos, u = float(d.position), float(d.device_type.u_height or 0)
if u <= 0:
continue
# Gerät belegt [pos, pos+u); stetig gerechnet, damit auch halbe HE passen
offset = (pos - start) if desc else (units - (pos - start) - u)
y1 = top + offset * UNIT_H
y2 = y1 + u * UNIT_H
fill = _hex(d.role.color if d.role else None)
rear_only = d.face != face # Full-Depth-Gerät von der anderen Seite: aufgehellt
if rear_only:
fill = tuple(int(c + (255 - c) * 0.55) for c in fill)
box = (left + 1, y1 + 1, left + BODY_W - 1, y2 - 1)
image = device_image(d.device_type, face) if images and not rear_only else None
name = _fit(draw, d.name or str(d.device_type), normal, BODY_W - 20 * SCALE)
if image and _paste_image(img, image, box):
draw.rectangle(box, outline=FRAME_COLOR)
# Name auf dunklem Balken, damit er auf jedem Bild lesbar bleibt
tw = draw.textlength(name, font=normal)
cx, cy = left + BODY_W / 2, (y1 + y2) / 2
pad_x, half_h = 5 * SCALE, 7 * SCALE
draw.rounded_rectangle([cx - tw / 2 - pad_x, cy - half_h, cx + tw / 2 + pad_x, cy + half_h], radius=3 * SCALE, fill=(30, 30, 30))
draw.text((cx, cy), name, fill=(255, 255, 255), font=normal, anchor="mm")
else:
draw.rectangle(box, fill=fill, outline=FRAME_COLOR)
draw.text((left + BODY_W / 2, (y1 + y2) / 2), name, fill=_text_color(fill), font=normal, anchor="mm")
draw.rectangle([left, top, left + BODY_W, top + units * UNIT_H], outline=FRAME_COLOR, width=FRAME)
out = BytesIO()
img.save(out, format="PNG", optimize=True)
return out.getvalue()