RackElevationSVG falls back to RACK_ELEVATION_DEFAULT_* config parameters, which raise AttributeError on the target NetBox version. Pass the dimensions explicitly instead. Add a Pillow-based PNG rack renderer (role colors, unit legend, full-depth devices shown dimmed from the other side). It is used for Word when cairosvg is unavailable and replaces a failed SVG in all formats. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
181 lines
7.0 KiB
Python
181 lines
7.0 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
|
||
try:
|
||
return cairosvg.svg2png(bytestring=svg.encode("utf-8"), scale=2)
|
||
except Exception: # noqa: BLE001 – dann greift das Pillow-PNG
|
||
return None
|
||
|
||
|
||
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"]) if img["svg"] else None) or img["png"]
|
||
cell = t.cell(0, i).paragraphs[0]
|
||
cell.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||
if png:
|
||
cell.add_run().add_picture(BytesIO(png), width=Cm(7)) # Höhe skaliert mit der Rackgröße
|
||
else:
|
||
cell.add_run("(Rack-Grafik nicht verfügbar)")
|
||
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()
|