Plugin for NetBox 4.6.8–4.6.99 that exports customer documentation (PDF, Word, HTML preview) for a site, location, tenant or tenant group. Technicians pick sections, device roles and columns before exporting. Branding profiles (colors, font, logo, footer, Word template incl. .dotx, table style, extra CSS) are configurable via PLUGINS_CONFIG and selectable per export. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
178 lines
6.9 KiB
Python
178 lines
6.9 KiB
Python
import zipfile
|
|
from io import BytesIO
|
|
|
|
from . import MissingDependency
|
|
|
|
DOTX_TYPE = b"application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml"
|
|
DOCX_TYPE = b"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"
|
|
|
|
|
|
def _svg_to_png(svg):
|
|
try:
|
|
import cairosvg
|
|
except (ImportError, OSError):
|
|
return None
|
|
return cairosvg.svg2png(bytestring=svg.encode("utf-8"), scale=2)
|
|
|
|
|
|
def _load_template(path):
|
|
"""Öffnet eine .docx/.dotx-Vorlage und leert deren Textkörper (Formatvorlagen, Kopf-/Fußzeile bleiben)."""
|
|
from docx import Document as Docx
|
|
|
|
buf = BytesIO()
|
|
with zipfile.ZipFile(path) as src, zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as dst:
|
|
for item in src.infolist():
|
|
data = src.read(item.filename)
|
|
if item.filename == "[Content_Types].xml":
|
|
data = data.replace(DOTX_TYPE, DOCX_TYPE) # .dotx -> .docx, sonst lehnt python-docx ab
|
|
dst.writestr(item, data)
|
|
buf.seek(0)
|
|
d = Docx(buf)
|
|
body = d.element.body
|
|
for child in list(body):
|
|
if not child.tag.endswith("}sectPr"):
|
|
body.remove(child)
|
|
return d
|
|
|
|
|
|
def render_docx(doc):
|
|
try:
|
|
from docx import Document as Docx
|
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
from docx.oxml import OxmlElement
|
|
from docx.oxml.ns import qn
|
|
from docx.shared import Cm, Pt, RGBColor
|
|
except ImportError as e:
|
|
raise MissingDependency(f"Word-Export benötigt python-docx (pip install python-docx cairosvg): {e}")
|
|
|
|
b = doc.branding
|
|
primary = RGBColor.from_string(b["primary_color"])
|
|
template = b.get("docx_template")
|
|
|
|
if template:
|
|
try:
|
|
d = _load_template(template)
|
|
except (OSError, KeyError, zipfile.BadZipFile) as e:
|
|
raise MissingDependency(f"Word-Vorlage {template!r} konnte nicht geladen werden: {e}")
|
|
else:
|
|
# Ohne Vorlage: Standarddokument mit Farben/Schrift aus dem Branding
|
|
d = Docx()
|
|
sec = d.sections[0]
|
|
sec.left_margin = sec.right_margin = Cm(2)
|
|
sec.top_margin = sec.bottom_margin = Cm(2)
|
|
d.styles["Normal"].font.name = b["font_family"]
|
|
d.styles["Normal"].font.size = Pt(b["font_size"])
|
|
for level, color in ((1, b["primary_color"]), (2, b["primary_color"]), (3, b["accent_color"]), (4, b["accent_color"])):
|
|
style = d.styles[f"Heading {level}"]
|
|
style.font.name = b["font_family"]
|
|
style.font.color.rgb = RGBColor.from_string(color)
|
|
sec = d.sections[0]
|
|
|
|
style_names = {s.name for s in d.styles}
|
|
table_style = b.get("docx_table_style") if b.get("docx_table_style") in style_names else None
|
|
|
|
def shade(cell, color):
|
|
tc_pr = cell._tc.get_or_add_tcPr()
|
|
shd = OxmlElement("w:shd")
|
|
shd.set(qn("w:val"), "clear")
|
|
shd.set(qn("w:fill"), color)
|
|
tc_pr.append(shd)
|
|
|
|
def set_style(table):
|
|
if table_style:
|
|
table.style = table_style
|
|
elif "Table Grid" in style_names:
|
|
table.style = "Table Grid"
|
|
|
|
# Deckblatt
|
|
meta = doc.meta
|
|
if b.get("logo_path"):
|
|
d.add_picture(b["logo_path"], width=Cm(5))
|
|
for _ in range(6):
|
|
d.add_paragraph()
|
|
title = d.add_paragraph()
|
|
run = title.add_run(doc.title)
|
|
run.bold, run.font.size = True, Pt(28)
|
|
run.font.color.rgb = primary
|
|
if doc.subtitle:
|
|
d.add_paragraph().add_run(doc.subtitle).font.size = Pt(16)
|
|
d.add_paragraph()
|
|
info = d.add_paragraph()
|
|
for label, value in (("Stand", meta.get("date")), ("Erstellt von", meta.get("author")), ("", b.get("company_name"))):
|
|
if value:
|
|
info.add_run(f"{label + ': ' if label else ''}{value}\n")
|
|
d.add_page_break()
|
|
|
|
# Inhaltsverzeichnis (wird beim Öffnen in Word aktualisiert)
|
|
d.add_heading("Inhaltsverzeichnis", level=1)
|
|
fld = OxmlElement("w:fldSimple")
|
|
fld.set(qn("w:instr"), 'TOC \\o "1-2" \\h \\z \\u')
|
|
d.add_paragraph()._p.append(fld)
|
|
upd = OxmlElement("w:updateFields")
|
|
upd.set(qn("w:val"), "true")
|
|
d.settings.element.append(upd)
|
|
d.add_page_break()
|
|
|
|
for blk in doc.blocks:
|
|
if blk.kind == "heading":
|
|
d.add_heading(blk.text, level=min(blk.level, 4))
|
|
elif blk.kind == "paragraph":
|
|
d.add_paragraph(blk.text)
|
|
elif blk.kind == "pagebreak":
|
|
d.add_page_break()
|
|
elif blk.kind in ("table", "kv"):
|
|
cols = len(blk.headers) if blk.kind == "table" else 2
|
|
t = d.add_table(rows=0, cols=cols)
|
|
set_style(t)
|
|
if blk.kind == "table":
|
|
cells = t.add_row().cells
|
|
for c, h in zip(cells, blk.headers):
|
|
c.text = h
|
|
r = c.paragraphs[0].runs[0]
|
|
r.bold = True
|
|
if not table_style: # eine Tabellenformatvorlage bringt eigene Kopfzeilenfarben mit
|
|
r.font.color.rgb = RGBColor.from_string(b["header_text_color"])
|
|
shade(c, b["primary_color"])
|
|
# Kopfzeile auf jeder Seite wiederholen
|
|
tr_pr = t.rows[0]._tr.get_or_add_trPr()
|
|
hdr = OxmlElement("w:tblHeader")
|
|
hdr.set(qn("w:val"), "true")
|
|
tr_pr.append(hdr)
|
|
for row in blk.rows:
|
|
cells = t.add_row().cells
|
|
for i, (c, v) in enumerate(zip(cells, row)):
|
|
c.text = v
|
|
for p in c.paragraphs:
|
|
for r in p.runs:
|
|
r.font.size = Pt(max(b["font_size"] - 1, 7))
|
|
if blk.kind == "kv" and i == 0:
|
|
c.paragraphs[0].runs[0].bold = True
|
|
if not table_style:
|
|
shade(c, b["label_bg_color"])
|
|
d.add_paragraph()
|
|
elif blk.kind == "images":
|
|
t = d.add_table(rows=2, cols=len(blk.images))
|
|
for i, img in enumerate(blk.images):
|
|
png = _svg_to_png(img["svg"])
|
|
cell = t.cell(0, i).paragraphs[0]
|
|
cell.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
if png:
|
|
cell.add_run().add_picture(BytesIO(png), height=Cm(18))
|
|
else:
|
|
cell.add_run("(Rack-Grafik benötigt cairosvg)")
|
|
cap = t.cell(1, i).paragraphs[0]
|
|
cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
cap.add_run(img["caption"]).italic = True
|
|
d.add_paragraph()
|
|
|
|
# Fußzeile nur setzen, wenn die Vorlage keine eigene mitbringt
|
|
footer = sec.footer
|
|
if not any(p.text.strip() for p in footer.paragraphs):
|
|
text = " · ".join(x for x in (b.get("company_name"), b.get("footer_text"), doc.title) if x)
|
|
p = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
|
|
p.add_run(text).font.size = Pt(8)
|
|
|
|
out = BytesIO()
|
|
d.save(out)
|
|
return out.getvalue()
|