fix: Excel-Zellfarben beim Dokumentimport erhalten

This commit is contained in:
2026-07-23 15:44:58 +02:00
parent 8a2ccfd516
commit e4f4cd7c14
5 changed files with 169 additions and 16 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ class DocumentationConfig(PluginConfig):
name = "netbox_documentation" name = "netbox_documentation"
verbose_name = "NetBox Dokumentation" verbose_name = "NetBox Dokumentation"
description = "Wiki und Office-Dokumentation direkt in NetBox" description = "Wiki und Office-Dokumentation direkt in NetBox"
version = "0.7.4" version = "0.7.5"
author = "LKE" author = "LKE"
base_url = "documentation" base_url = "documentation"
min_version = "4.0.0" min_version = "4.0.0"
+119 -6
View File
@@ -1,6 +1,7 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from html import escape
import re import re
@@ -12,6 +13,7 @@ class ImportFailure(ValueError):
class ImportResult: class ImportResult:
markdown: str markdown: str
warnings: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list)
body_format: str = "markdown"
@dataclass @dataclass
@@ -27,6 +29,7 @@ class ExcelSheetResult:
title: str title: str
markdown: str markdown: str
images: list[ImportedImage] = field(default_factory=list) images: list[ImportedImage] = field(default_factory=list)
body_format: str = "markdown"
def import_document(upload, flatten_excel=False) -> ImportResult: def import_document(upload, flatten_excel=False) -> ImportResult:
@@ -80,7 +83,7 @@ def _escape_markdown_text(value):
def _xlsx(content, flatten=False): def _xlsx(content, flatten=False):
from openpyxl import load_workbook 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 = [] sections = []
for sheet in workbook.worksheets: 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)] 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() rows.pop()
if not rows: if not rows:
continue continue
rendered = _render_flattened_worksheet(sheet) if flatten else _render_excel_rows(rows) rendered = _render_flattened_worksheet(sheet) if flatten else _render_excel_worksheet_html(sheet, workbook)
sections.append(f"## {sheet.title}\n\n{rendered}") sections.append(f"## {sheet.title}\n\n{rendered}" if flatten else f"<h2>{escape(sheet.title)}</h2>\n{rendered}")
if not sections: if not sections:
raise ImportFailure("Die Arbeitsmappe enthält keine Daten.") 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]: 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: if not rows and not images:
continue continue
if rows: 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: else:
markdown = "" 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: if not results:
raise ImportFailure("Die Arbeitsmappe enthält keine Daten oder unterstützten Bilder.") raise ImportFailure("Die Arbeitsmappe enthält keine Daten oder unterstützten Bilder.")
return results return results
@@ -146,6 +152,113 @@ def slugify_filename(value):
return value[:80] or "arbeitsblatt" 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 "<p><em>Keine Inhalte</em></p>"
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'<col style="width: {max(3, min(float(width or 13), 80)):.2f}ch">')
output = ['<table class="doc-table-bordered"><colgroup>', *columns, "</colgroup><tbody>"]
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"<tr{row_style}>")
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", "<br>")
output.append(f"<td{' ' if attributes else ''}{' '.join(attributes)}>{value}</td>")
output.append("</tr>")
output.append("</tbody></table>")
return "".join(output)
def _render_flattened_worksheet(sheet): def _render_flattened_worksheet(sheet):
"""Flatten ordinary cells while preserving explicitly defined Excel tables.""" """Flatten ordinary cells while preserving explicitly defined Excel tables."""
from openpyxl.utils.cell import range_boundaries from openpyxl.utils.cell import range_boundaries
+25 -7
View File
@@ -1,5 +1,6 @@
from pathlib import Path from pathlib import Path
from datetime import timedelta from datetime import timedelta
from html import escape
import mimetypes import mimetypes
import tinymce import tinymce
from django.conf import settings from django.conf import settings
@@ -168,7 +169,9 @@ class DocumentImportView(PermissionRequiredMixin, View):
while Document.objects.filter(slug=slug).exists(): while Document.objects.filter(slug=slug).exists():
slug = f"{base_slug}-{counter}" slug = f"{base_slug}-{counter}"
counter += 1 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) keep = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True)
if keep: if keep:
upload.seek(0) upload.seek(0)
@@ -219,11 +222,18 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
for form, sheet in zip(formset.forms, sheets): for form, sheet in zip(formset.forms, sheets):
sample = sheet.markdown sample = sheet.markdown
if sheet.images: 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"\n<h2>Bilder</h2><p><em>{image_notice}</em></p>"
else:
sample += f"\n\n## Bilder\n\n_{image_notice}_"
truncated = len(sample) > 100000 truncated = len(sample) > 100000
if truncated: if truncated:
sample = sample[:100000] + "\n\n_… Vorschau gekürzt …_" notice = "… Vorschau gekürzt …"
preview_document = Document(body=sample, body_format="markdown") sample = sample[:100000] + (
f"<p><em>{notice}</em></p>" if sheet.body_format == "html" else f"\n\n_{notice}_"
)
preview_document = Document(body=sample, body_format=sheet.body_format)
items.append({ items.append({
"form": form, "sheet": sheet, "form": form, "sheet": sheet,
"preview_html": preview_document.rendered_body(), "truncated": truncated, "preview_html": preview_document.rendered_body(), "truncated": truncated,
@@ -300,7 +310,7 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
counter += 1 counter += 1
document = Document.objects.create( document = Document.objects.create(
title=title[:200], slug=document_slug, body=sheet.markdown, 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}", summary=f"Importiert aus {preview.original_name}",
) )
if sheet.images: if sheet.images:
@@ -313,8 +323,16 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
attachment.file.save(image.name, ContentFile(image.content), save=False) attachment.file.save(image.name, ContentFile(image.content), save=False)
attachment.save() attachment.save()
location = f" ({image.cell})" if image.cell else "" location = f" ({image.cell})" if image.cell else ""
image_lines.append(f"![{image.name}{location}]({attachment.file.url})") if document.body_format == "html":
document.body += "\n\n## Bilder\n\n" + "\n\n".join(image_lines) image_lines.append(
f'<p><img src="{escape(attachment.file.url)}" alt="{escape(image.name + location)}"></p>'
)
else:
image_lines.append(f"![{image.name}{location}]({attachment.file.url})")
if document.body_format == "html":
document.body += "\n<h2>Bilder</h2>\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")) document.save(update_fields=("body", "last_updated"))
image_count += len(sheet.images) image_count += len(sheet.images)
created_documents.append(document) created_documents.append(document)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "netbox-documentation" name = "netbox-documentation"
version = "0.7.4" version = "0.7.5"
description = "Integrated Markdown wiki and office document importer for NetBox" description = "Integrated Markdown wiki and office document importer for NetBox"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
+23 -1
View File
@@ -5,6 +5,7 @@ import sys
import pytest import pytest
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import PatternFill, Font
from openpyxl.worksheet.table import Table from openpyxl.worksheet.table import Table
@@ -39,10 +40,31 @@ def test_xlsx_imports_sheets_as_tables():
stream = BytesIO() stream = BytesIO()
workbook.save(stream) workbook.save(stream)
result = import_document(Upload(stream.getvalue(), "server.xlsx")) result = import_document(Upload(stream.getvalue(), "server.xlsx"))
assert "## Server" in result.markdown assert "<h2>Server</h2>" in result.markdown
assert result.body_format == "html"
assert "web01" in result.markdown 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(): def test_rejects_legacy_excel():
with pytest.raises(ImportFailure, match="xlsx"): with pytest.raises(ImportFailure, match="xlsx"):
import_document(Upload(b"", "legacy.xls")) import_document(Upload(b"", "legacy.xls"))