diff --git a/netbox_documentation/__init__.py b/netbox_documentation/__init__.py
index 0da1898..2fc9414 100644
--- a/netbox_documentation/__init__.py
+++ b/netbox_documentation/__init__.py
@@ -5,7 +5,7 @@ class DocumentationConfig(PluginConfig):
name = "netbox_documentation"
verbose_name = "NetBox Dokumentation"
description = "Wiki und Office-Dokumentation direkt in NetBox"
- version = "0.7.4"
+ version = "0.7.5"
author = "LKE"
base_url = "documentation"
min_version = "4.0.0"
diff --git a/netbox_documentation/importers.py b/netbox_documentation/importers.py
index fed0d6b..b8ed296 100644
--- a/netbox_documentation/importers.py
+++ b/netbox_documentation/importers.py
@@ -1,6 +1,7 @@
from dataclasses import dataclass, field
from io import BytesIO
from pathlib import Path
+from html import escape
import re
@@ -12,6 +13,7 @@ class ImportFailure(ValueError):
class ImportResult:
markdown: str
warnings: list[str] = field(default_factory=list)
+ body_format: str = "markdown"
@dataclass
@@ -27,6 +29,7 @@ class ExcelSheetResult:
title: str
markdown: str
images: list[ImportedImage] = field(default_factory=list)
+ body_format: str = "markdown"
def import_document(upload, flatten_excel=False) -> ImportResult:
@@ -80,7 +83,7 @@ def _escape_markdown_text(value):
def _xlsx(content, flatten=False):
from openpyxl import load_workbook
- workbook = load_workbook(BytesIO(content), read_only=not flatten, data_only=True)
+ workbook = load_workbook(BytesIO(content), read_only=False, data_only=True)
sections = []
for sheet in workbook.worksheets:
rows = [["" if cell is None else str(cell) for cell in row] for row in sheet.iter_rows(values_only=True)]
@@ -88,11 +91,11 @@ def _xlsx(content, flatten=False):
rows.pop()
if not rows:
continue
- rendered = _render_flattened_worksheet(sheet) if flatten else _render_excel_rows(rows)
- sections.append(f"## {sheet.title}\n\n{rendered}")
+ rendered = _render_flattened_worksheet(sheet) if flatten else _render_excel_worksheet_html(sheet, workbook)
+ sections.append(f"## {sheet.title}\n\n{rendered}" if flatten else f"
{escape(sheet.title)}
\n{rendered}")
if not sections:
raise ImportFailure("Die Arbeitsmappe enthält keine Daten.")
- return ImportResult("\n\n".join(sections))
+ return ImportResult("\n\n".join(sections), body_format="markdown" if flatten else "html")
def import_excel_sheets(upload, flatten=False) -> list[ExcelSheetResult]:
@@ -132,10 +135,13 @@ def import_excel_sheets(upload, flatten=False) -> list[ExcelSheetResult]:
if not rows and not images:
continue
if rows:
- markdown = _render_flattened_worksheet(sheet) if flatten else _render_excel_rows(rows)
+ markdown = _render_flattened_worksheet(sheet) if flatten else _render_excel_worksheet_html(sheet, workbook)
else:
markdown = ""
- results.append(ExcelSheetResult(title=sheet.title, markdown=markdown, images=images))
+ results.append(ExcelSheetResult(
+ title=sheet.title, markdown=markdown, images=images,
+ body_format="markdown" if flatten else "html",
+ ))
if not results:
raise ImportFailure("Die Arbeitsmappe enthält keine Daten oder unterstützten Bilder.")
return results
@@ -146,6 +152,113 @@ def slugify_filename(value):
return value[:80] or "arbeitsblatt"
+def _excel_color(color, workbook):
+ """Resolve RGB, indexed and theme-based openpyxl colors to a CSS hex value."""
+ if not color or not getattr(color, "type", None):
+ return None
+ value = None
+ if color.type == "rgb" and color.rgb:
+ value = str(color.rgb)[-6:]
+ elif color.type == "indexed" and color.indexed is not None:
+ from openpyxl.styles.colors import COLOR_INDEX
+ index = int(color.indexed)
+ if 0 <= index < len(COLOR_INDEX):
+ value = COLOR_INDEX[index][-6:]
+ elif color.type == "theme" and color.theme is not None and workbook.loaded_theme:
+ from xml.etree import ElementTree
+ try:
+ root = ElementTree.fromstring(workbook.loaded_theme)
+ scheme = root.find(".//{http://schemas.openxmlformats.org/drawingml/2006/main}clrScheme")
+ entries = list(scheme) if scheme is not None else []
+ entry = entries[int(color.theme)]
+ color_node = next(iter(entry))
+ value = color_node.attrib.get("val") or color_node.attrib.get("lastClr")
+ except (ElementTree.ParseError, IndexError, StopIteration, TypeError, ValueError):
+ value = None
+ if not value or not re.fullmatch(r"[0-9A-Fa-f]{6}", value):
+ return None
+ rgb = [int(value[index:index + 2], 16) for index in (0, 2, 4)]
+ tint = float(getattr(color, "tint", 0) or 0)
+ if tint:
+ rgb = [round(component * (1 + tint) if tint < 0 else component + (255 - component) * tint) for component in rgb]
+ return "#" + "".join(f"{max(0, min(255, component)):02x}" for component in rgb)
+
+
+def _excel_cell_style(cell, workbook):
+ declarations = []
+ if cell.fill and cell.fill.fill_type == "solid":
+ fill = _excel_color(cell.fill.fgColor, workbook)
+ if fill:
+ declarations.append(f"background-color: {fill}")
+ font_color = _excel_color(cell.font.color, workbook)
+ if font_color:
+ declarations.append(f"color: {font_color}")
+ if cell.font.bold:
+ declarations.append("font-weight: bold")
+ if cell.font.italic:
+ declarations.append("font-style: italic")
+ if cell.font.sz:
+ declarations.append(f"font-size: {float(cell.font.sz):g}pt")
+ if cell.alignment.horizontal in {"left", "center", "right", "justify"}:
+ declarations.append(f"text-align: {cell.alignment.horizontal}")
+ if cell.alignment.vertical in {"top", "center", "bottom"}:
+ declarations.append(f"vertical-align: {'middle' if cell.alignment.vertical == 'center' else cell.alignment.vertical}")
+ if cell.alignment.wrap_text:
+ declarations.append("white-space: normal")
+ return "; ".join(declarations)
+
+
+def _render_excel_worksheet_html(sheet, workbook):
+ """Render worksheet values and the most relevant visual Excel cell formatting."""
+ from openpyxl.utils import get_column_letter
+
+ populated = [cell for row in sheet.iter_rows() for cell in row if cell.value not in (None, "")]
+ if not populated:
+ return "Keine Inhalte
"
+ max_row = max(cell.row for cell in populated)
+ max_column = max(cell.column for cell in populated)
+ merged_starts, merged_children = {}, set()
+ for merged in sheet.merged_cells.ranges:
+ if merged.min_row > max_row or merged.min_col > max_column:
+ continue
+ merged_starts[(merged.min_row, merged.min_col)] = (
+ min(merged.max_row, max_row) - merged.min_row + 1,
+ min(merged.max_col, max_column) - merged.min_col + 1,
+ )
+ for row in range(merged.min_row, min(merged.max_row, max_row) + 1):
+ for column in range(merged.min_col, min(merged.max_col, max_column) + 1):
+ if (row, column) != (merged.min_row, merged.min_col):
+ merged_children.add((row, column))
+ columns = []
+ for column in range(1, max_column + 1):
+ width = sheet.column_dimensions[get_column_letter(column)].width
+ columns.append(f'')
+ output = ['', *columns, ""]
+ for row in range(1, max_row + 1):
+ row_style = ""
+ if sheet.row_dimensions[row].height:
+ row_style = f' style="height: {float(sheet.row_dimensions[row].height):g}pt"'
+ output.append(f"")
+ for column in range(1, max_column + 1):
+ if (row, column) in merged_children:
+ continue
+ cell = sheet.cell(row=row, column=column)
+ attributes = []
+ rowspan, colspan = merged_starts.get((row, column), (1, 1))
+ if rowspan > 1:
+ attributes.append(f'rowspan="{rowspan}"')
+ if colspan > 1:
+ attributes.append(f'colspan="{colspan}"')
+ style = _excel_cell_style(cell, workbook)
+ if style:
+ attributes.append(f'style="{style}"')
+ value = "" if cell.value is None else escape(str(cell.value)).replace("\n", "
")
+ output.append(f"{value} | ")
+ output.append("
")
+ output.append("
")
+ return "".join(output)
+
+
def _render_flattened_worksheet(sheet):
"""Flatten ordinary cells while preserving explicitly defined Excel tables."""
from openpyxl.utils.cell import range_boundaries
diff --git a/netbox_documentation/views.py b/netbox_documentation/views.py
index f721ba9..ff58fea 100644
--- a/netbox_documentation/views.py
+++ b/netbox_documentation/views.py
@@ -1,5 +1,6 @@
from pathlib import Path
from datetime import timedelta
+from html import escape
import mimetypes
import tinymce
from django.conf import settings
@@ -168,7 +169,9 @@ class DocumentImportView(PermissionRequiredMixin, View):
while Document.objects.filter(slug=slug).exists():
slug = f"{base_slug}-{counter}"
counter += 1
- document = Document.objects.create(title=title, slug=slug, body=result.markdown, body_format="markdown")
+ document = Document.objects.create(
+ title=title, slug=slug, body=result.markdown, body_format=result.body_format,
+ )
keep = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True)
if keep:
upload.seek(0)
@@ -219,11 +222,18 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
for form, sheet in zip(formset.forms, sheets):
sample = sheet.markdown
if sheet.images:
- sample += f"\n\n## Bilder\n\n_{len(sheet.images)} eingebettete Bilder werden beim Import angefügt._"
+ image_notice = f"{len(sheet.images)} eingebettete Bilder werden beim Import angefügt."
+ if sheet.body_format == "html":
+ sample += f"\nBilder
{image_notice}
"
+ else:
+ sample += f"\n\n## Bilder\n\n_{image_notice}_"
truncated = len(sample) > 100000
if truncated:
- sample = sample[:100000] + "\n\n_… Vorschau gekürzt …_"
- preview_document = Document(body=sample, body_format="markdown")
+ notice = "… Vorschau gekürzt …"
+ sample = sample[:100000] + (
+ f"{notice}
" if sheet.body_format == "html" else f"\n\n_{notice}_"
+ )
+ preview_document = Document(body=sample, body_format=sheet.body_format)
items.append({
"form": form, "sheet": sheet,
"preview_html": preview_document.rendered_body(), "truncated": truncated,
@@ -300,7 +310,7 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
counter += 1
document = Document.objects.create(
title=title[:200], slug=document_slug, body=sheet.markdown,
- body_format="markdown", category=preview.category,
+ body_format=sheet.body_format, category=preview.category,
summary=f"Importiert aus {preview.original_name}",
)
if sheet.images:
@@ -313,8 +323,16 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
attachment.file.save(image.name, ContentFile(image.content), save=False)
attachment.save()
location = f" ({image.cell})" if image.cell else ""
- image_lines.append(f"")
- document.body += "\n\n## Bilder\n\n" + "\n\n".join(image_lines)
+ if document.body_format == "html":
+ image_lines.append(
+ f'})
'
+ )
+ else:
+ image_lines.append(f"")
+ if document.body_format == "html":
+ document.body += "\nBilder
\n" + "\n".join(image_lines)
+ else:
+ document.body += "\n\n## Bilder\n\n" + "\n\n".join(image_lines)
document.save(update_fields=("body", "last_updated"))
image_count += len(sheet.images)
created_documents.append(document)
diff --git a/pyproject.toml b/pyproject.toml
index c008a6b..114465c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "netbox-documentation"
-version = "0.7.4"
+version = "0.7.5"
description = "Integrated Markdown wiki and office document importer for NetBox"
readme = "README.md"
requires-python = ">=3.10"
diff --git a/tests/test_importers.py b/tests/test_importers.py
index fa97469..b00a44e 100644
--- a/tests/test_importers.py
+++ b/tests/test_importers.py
@@ -5,6 +5,7 @@ import sys
import pytest
from openpyxl import Workbook
+from openpyxl.styles import PatternFill, Font
from openpyxl.worksheet.table import Table
@@ -39,10 +40,31 @@ def test_xlsx_imports_sheets_as_tables():
stream = BytesIO()
workbook.save(stream)
result = import_document(Upload(stream.getvalue(), "server.xlsx"))
- assert "## Server" in result.markdown
+ assert "Server
" in result.markdown
+ assert result.body_format == "html"
assert "web01" in result.markdown
+def test_xlsx_preserves_cell_fill_and_font_colors_as_html():
+ workbook = Workbook()
+ sheet = workbook.active
+ sheet["A1"] = "Standort"
+ sheet["A1"].fill = PatternFill(fill_type="solid", fgColor="A5BF60")
+ sheet["A1"].font = Font(color="FFFFFF", bold=True)
+ sheet["A2"] = "Verwaltung"
+ sheet["A2"].fill = PatternFill(fill_type="solid", fgColor="EAF0DC")
+ stream = BytesIO()
+ workbook.save(stream)
+
+ result = import_document(Upload(stream.getvalue(), "farben.xlsx"))
+
+ assert result.body_format == "html"
+ assert "background-color: #a5bf60" in result.markdown
+ assert "color: #ffffff" in result.markdown
+ assert "font-weight: bold" in result.markdown
+ assert "background-color: #eaf0dc" in result.markdown
+
+
def test_rejects_legacy_excel():
with pytest.raises(ImportFailure, match="xlsx"):
import_document(Upload(b"", "legacy.xls"))