From e4f4cd7c1494cbd83ff0a7898ef96708d90b2d1f Mon Sep 17 00:00:00 2001 From: Louis Date: Thu, 23 Jul 2026 15:44:58 +0200 Subject: [PATCH] fix: Excel-Zellfarben beim Dokumentimport erhalten --- netbox_documentation/__init__.py | 2 +- netbox_documentation/importers.py | 125 ++++++++++++++++++++++++++++-- netbox_documentation/views.py | 32 ++++++-- pyproject.toml | 2 +- tests/test_importers.py | 24 +++++- 5 files changed, 169 insertions(+), 16 deletions(-) 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"\n

Bilder

{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"![{image.name}{location}]({attachment.file.url})") - document.body += "\n\n## Bilder\n\n" + "\n\n".join(image_lines) + if document.body_format == "html": + image_lines.append( + f'

{escape(image.name + location)}

' + ) + else: + image_lines.append(f"![{image.name}{location}]({attachment.file.url})") + if document.body_format == "html": + document.body += "\n

Bilder

\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"))