Files
NetBox-Customer-Export/netbox_customer_export/blocks.py
T
MrBlakeandClaude Opus 5.5 e3c31b6312 feat: topology on its own landscape page, as large as possible
The topology image was shrunk inside the flex container used for rack
images. Each site's topology now gets a dedicated A4 landscape page
(named @page in PDF, landscape section in Word) with the section and
site headings, scaled to the full usable width or height depending on
its aspect ratio. The document switches back to portrait only when more
content follows, so no blank pages are produced. The TOC includes
headings on landscape pages.

Also fix Word exports without a template using US Letter instead of A4.

Bump version to 0.6.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 15:03:11 +02:00

102 lines
3.0 KiB
Python

"""Neutrales Dokumentmodell, das sowohl der HTML/PDF- als auch der Word-Renderer versteht."""
import base64
from dataclasses import dataclass, field
from django.utils.text import slugify
EMPTY = "—"
@dataclass
class Block:
kind: str # heading | paragraph | table | kv | images | pagebreak
level: int = 0
text: str = ""
headers: list = field(default_factory=list)
rows: list = field(default_factory=list)
images: list = field(default_factory=list)
anchor: str = ""
@dataclass
class Document:
title: str
subtitle: str
blocks: list
meta: dict
branding: dict = field(default_factory=dict)
@property
def toc(self):
out = []
for b in self.blocks:
headings = b.rows if b.kind == "landscape" else [b]
out += [h for h in headings if h.kind == "heading" and h.level <= 2]
return out
_anchor_counter = {"n": 0}
def H(level, text):
_anchor_counter["n"] += 1
return Block("heading", level=level, text=str(text), anchor=f"s{_anchor_counter['n']}-{slugify(str(text))[:40]}")
def P(text):
return Block("paragraph", text=str(text))
def T(headers, rows):
return Block("table", headers=list(headers), rows=[[fmt(c) for c in r] for r in rows])
def KV(pairs, skip_empty=True):
rows = [[k, fmt(v)] for k, v in pairs if not (skip_empty and v in (None, "", [], EMPTY))]
return Block("kv", rows=rows)
def IMAGES(images, wide=False):
"""images: Liste von (svg_string oder None, caption, png_bytes_oder_None)."""
out = []
for svg, caption, png in images:
if svg:
data_uri = "data:image/svg+xml;base64," + base64.b64encode(svg.encode("utf-8")).decode("ascii")
elif png:
data_uri = "data:image/png;base64," + base64.b64encode(png).decode("ascii")
else:
continue
out.append({"svg": svg, "png": png, "caption": caption, "data_uri": data_uri})
return Block("images", images=out, level=1 if wide else 0) # level=1: volle Seitenbreite
def LANDSCAPE(headings, png):
"""Eigene Seite im Querformat: Überschriften + Bild so groß wie möglich (z.B. Topologie)."""
data_uri = "data:image/png;base64," + base64.b64encode(png).decode("ascii")
return Block("landscape", rows=list(headings), images=[{"svg": None, "png": png, "caption": "", "data_uri": data_uri}])
def PAGEBREAK():
return Block("pagebreak")
def fmt(value):
if value is None or value == "":
return EMPTY
if isinstance(value, bool):
return "Ja" if value else "Nein"
if isinstance(value, (list, tuple, set)) or hasattr(value, "all"):
items = value.all() if hasattr(value, "all") else value
return ", ".join(str(i) for i in items) or EMPTY
return str(value)
def get(obj, path):
"""Dotted-getattr, bevorzugt get_<feld>_display() für Choice-Felder."""
for part in path.split("."):
if obj is None:
return None
display = getattr(obj, f"get_{part}_display", None)
obj = display() if callable(display) else getattr(obj, part, None)
return obj