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>
This commit is contained in:
2026-09-23 15:03:11 +02:00
co-authored by Claude Opus 5.5
parent 062413a2cd
commit e3c31b6312
7 changed files with 70 additions and 11 deletions
+2
View File
@@ -219,6 +219,8 @@ Der Abschnitt **Netzwerk-Topologie** zeichnet je Standort ein Diagramm mit Symbo
Die Reihenfolge innerhalb einer Ebene wird so optimiert, dass sich möglichst wenige Leitungen kreuzen.
* Leitungen werden **rechtwinklig** über eigene Sammelschienen geführt, farbig je übergeordnetem Gerät. Portnamen stehen
direkt am Zielgerät, Mehrfach-Uplinks werden dicker gezeichnet und als „2ד markiert.
* Jede Topologie steht auf einer **eigenen Seite im Querformat** (PDF und Word) und wird so groß wie möglich skaliert.
Danach geht das Dokument im Hochformat weiter.
* Provider-Leitungen erscheinen als Wolke, Geräte anderer Standorte gestrichelt (abschaltbar).
* Beschriftung wählbar: Management-IP, Modell, Ports an den Verbindungen.
+1 -1
View File
@@ -5,7 +5,7 @@ class CustomerExportConfig(PluginConfig):
name = "netbox_customer_export"
verbose_name = "Kunden-Export"
description = "Kundendokumentation als PDF oder Word exportieren"
version = "0.5.0"
version = "0.6.0"
author = "Claude"
base_url = "customer-export"
min_version = "4.6.8"
+11 -1
View File
@@ -28,7 +28,11 @@ class Document:
@property
def toc(self):
return [b for b in self.blocks if b.kind == "heading" and b.level <= 2]
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}
@@ -66,6 +70,12 @@ def IMAGES(images, wide=False):
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")
+36 -1
View File
@@ -61,6 +61,7 @@ def render_docx(doc):
# Ohne Vorlage: Standarddokument mit Farben/Schrift aus dem Branding
d = Docx()
sec = d.sections[0]
sec.page_width, sec.page_height = Cm(21), Cm(29.7) # A4 statt US-Letter aus der python-docx-Vorlage
sec.left_margin = sec.right_margin = Cm(2)
sec.top_margin = sec.bottom_margin = Cm(2)
d.styles["Normal"].font.name = b["font_family"]
@@ -116,8 +117,42 @@ def render_docx(doc):
d.settings.element.append(upd)
d.add_page_break()
from docx.enum.section import WD_ORIENT, WD_SECTION
portrait = d.sections[-1]
portrait_size = (portrait.page_width, portrait.page_height)
landscape_open = False
def new_section(landscape):
s = d.add_section(WD_SECTION.NEW_PAGE)
w, h = portrait_size
s.orientation = WD_ORIENT.LANDSCAPE if landscape else WD_ORIENT.PORTRAIT
s.page_width, s.page_height = (h, w) if landscape else (w, h)
return s
for blk in doc.blocks:
if blk.kind == "heading":
if landscape_open and blk.kind != "landscape":
# erst beim nächsten Inhalt zurück ins Hochformat – vermeidet leere Seiten
new_section(landscape=False)
landscape_open = False
if blk.kind == "landscape":
s = new_section(landscape=True)
landscape_open = True
for h in blk.rows:
d.add_heading(h.text, level=h.level)
png = blk.images[0]["png"]
from PIL import Image
px_w, px_h = Image.open(BytesIO(png)).size
avail_w = s.page_width - s.left_margin - s.right_margin
avail_h = s.page_height - s.top_margin - s.bottom_margin - Cm(1.4 * len(blk.rows) + 0.8)
para = d.add_paragraph()
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
if px_w / px_h >= avail_w / avail_h:
para.add_run().add_picture(BytesIO(png), width=avail_w)
else:
para.add_run().add_picture(BytesIO(png), height=avail_h)
elif blk.kind == "heading":
d.add_heading(blk.text, level=min(blk.level, 4))
elif blk.kind == "paragraph":
d.add_paragraph(blk.text)
+8 -7
View File
@@ -7,7 +7,7 @@ from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from netbox.plugins import get_plugin_config
from .blocks import EMPTY, H, IMAGES, KV, P, PAGEBREAK, T, get
from .blocks import EMPTY, H, IMAGES, KV, LANDSCAPE, P, PAGEBREAK, T, get
from .rack_png import render_rack_png
from .rack_svg import render_rack_svg
@@ -412,12 +412,13 @@ def licenses(scope, opts):
def topology(scope, opts):
from .topology import build_graphs, render_png
blocks = [H(1, "Netzwerk-Topologie")]
title = H(1, "Netzwerk-Topologie")
graphs = build_graphs(scope, opts)
if not graphs:
return _empty(opts, blocks, "Keine verkabelten Geräte gefunden.")
return _empty(opts, [title], "Keine verkabelten Geräte gefunden.")
ports = "ports" in (opts.get("topo_details") or [])
for g in graphs:
blocks.append(H(2, g.title))
blocks.append(IMAGES([(None, "", render_png(g, show_ports=ports))], wide=True))
return blocks
# Je Standort eine Querformat-Seite; die Abschnittsüberschrift steht mit auf der ersten
return [
LANDSCAPE(([title] if i == 0 else []) + [H(2, g.title)], render_png(g, show_ports=ports))
for i, g in enumerate(graphs)
]
@@ -8,6 +8,7 @@
@page { size: A4; margin: 20mm 15mm 20mm 15mm;
@bottom-left { content: element(footer); }
@bottom-right { content: "Seite " counter(page) " / " counter(pages); font-size: 7pt; color: #777; } }
@page landscape { size: A4 landscape; margin: 12mm 15mm 16mm 15mm; }
@page cover { @bottom-left { content: none } @bottom-right { content: none } }
body { font-family: "{{ b.font_family }}", "Segoe UI", "DejaVu Sans", Arial, sans-serif; font-size: {{ b.font_size }}pt; color: #222; }
.cover { page: cover; padding-top: 60mm; page-break-after: always; }
@@ -40,6 +41,11 @@
figcaption { font-style: italic; color: #555; }
.footer { position: running(footer); font-size: 7pt; color: #777; }
@media screen { .footer { display: none; } }
.landscape { page: landscape; page-break-before: always; page-break-after: always; }
.landscape h1 { page-break-before: avoid; margin-top: 0; }
.landscape h2 { margin-top: 4pt; }
.landscape img { display: block; margin: 0 auto; max-width: 100%; max-height: 152mm; }
@media screen { .landscape img { max-height: none; width: 100%; } }
.pagebreak { page-break-after: always; }
@media screen { body { max-width: 1100px; margin: auto; padding: 16px; } }
{{ b.extra_css|safe }}
@@ -73,6 +79,11 @@
{% elif b.kind == 'images' %}
<div class="images{% if b.level == 1 %} wide{% endif %}">{% for img in b.images %}<figure><img src="{{ img.data_uri }}" alt="{{ img.caption }}">{% if img.caption %}<figcaption>{{ img.caption }}</figcaption>{% endif %}</figure>{% endfor %}</div>
{% elif b.kind == 'pagebreak' %}<div class="pagebreak"></div>
{% elif b.kind == 'landscape' %}
<section class="landscape">
{% for h in b.rows %}{% if h.level == 1 %}<h1 id="{{ h.anchor }}">{{ h.text }}</h1>{% else %}<h2 id="{{ h.anchor }}">{{ h.text }}</h2>{% endif %}{% endfor %}
<img src="{{ b.images.0.data_uri }}" alt="">
</section>
{% endif %}
{% endfor %}
</body>
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "netbox-customer-export"
version = "0.5.0"
version = "0.6.0"
description = "NetBox-Plugin: Kundendokumentation (PDF/Word) für Standorte, Lokationen, Mandanten und Mandantengruppen"
readme = "README.md"
requires-python = ">=3.10"