From 67d999e23482c6e7672c2530bc66f0650a7f8c3c Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 16 Sep 2026 12:53:42 +0200 Subject: [PATCH] v0.2.1: rework PNG export to draw directly on canvas, like netbox-topology-views Investigated how MrBlake/mrb-netbox-topology-views does its PNG export: it renders its topology natively onto a canvas (vis-network draws there directly) and just calls canvas.toDataURL() - no SVG round-trip at all. Applied the same principle here. Previous approach loaded the standalone SVG export via and rasterized that onto a canvas - fragile, because the whole export failed if that SVG document wasn't strictly valid XML (as our leaked {# #} comment bug demonstrated) or if a single device type image reference inside it couldn't be resolved. New approach: a dedicated export/data/ endpoint returns the elevation's plain pixel geometry as JSON (positions, sizes, colors, labels, image URLs - the same numbers the SVG is built from), and export.js draws that directly onto a with native 2D primitives (fillRect, strokeRect, fillText/strokeText, drawImage). Device type images are fetched individually as same-origin blobs before drawing; one image failing to load only drops that picture; the rest of the export still succeeds. Added a combined front+rear PNG option. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 10 + README.md | 11 +- netbox_rack_concept/__init__.py | 2 +- .../static/netbox_rack_concept/export.js | 242 ++++++++++++++---- .../netbox_rack_concept/rackconcept.html | 13 +- netbox_rack_concept/views.py | 55 ++++ pyproject.toml | 2 +- 7 files changed, 274 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f84e0e9..7e6eefc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.2.1 + +- Rework PNG export: instead of rasterizing the SVG export via ``/`` + (fragile - any malformed SVG or cross-origin image reference could fail the whole + export with an opaque error), draw the elevation directly onto a `` from + plain pixel geometry (`export/data/`), the same approach netbox-topology-views uses. + Device type images are fetched individually as same-origin blobs; a single image + failing to load only drops that picture instead of aborting the export. Added a + combined front+rear PNG option to the Export menu. + ## 0.2.0 - Fix: several multi-line `{# ... #}` template comments rendered as literal text on diff --git a/README.md b/README.md index 99e626c..44f321e 100644 --- a/README.md +++ b/README.md @@ -45,10 +45,13 @@ Dazu: Components"-Dropdown (genau wie am echten Gerät) legt neue Komponenten pro Typ an — nötig für Platzhalter ohne Gerätetyp, die nichts zum Syncen haben. * **Export** – Button **„Export"** über der Elevation: Front bzw. Rear jeweils als - eigenständige **SVG**- oder **PNG**-Datei, sowie das ganze Konzept (beide Seiten) als - **Draw.io**-Diagramm (`.drawio`, per Datei > Öffnen oder Extras > Diagramm bearbeiten - in diagrams.net einlesbar). PNG wird komplett im Browser aus dem SVG gerastert — - dafür ist keine zusätzliche Bildbibliothek auf dem Server nötig. + eigenständige **SVG**-Datei, als **PNG** (einzeln oder beide Seiten zusammen), sowie + das ganze Konzept als **Draw.io**-Diagramm (`.drawio`, per Datei > Öffnen oder Extras + > Diagramm bearbeiten in diagrams.net einlesbar). PNG wird direkt im Browser auf ein + `` gezeichnet (derselbe Ansatz wie bei netbox-topology-views) statt ein SVG zu + rastern — Gerätetyp-Bilder werden dafür einzeln per `fetch()` als Blob geladen; scheitert + eines davon (z.B. Netzwerkfehler), fehlt nur dieses eine Bild im Export, der Rest bleibt + unversehrt. Für PNG ist keine zusätzliche Bildbibliothek auf dem Server nötig. * Vollständige REST-API, Filter, CSV-Import/-Export, Bulk-Edit, Changelog, Journal, Tags, Custom Fields und globale Suche — wie bei jedem Core-Objekt. diff --git a/netbox_rack_concept/__init__.py b/netbox_rack_concept/__init__.py index a47956b..8b76b6c 100644 --- a/netbox_rack_concept/__init__.py +++ b/netbox_rack_concept/__init__.py @@ -1,6 +1,6 @@ from netbox.plugins import PluginConfig -__version__ = '0.2.0' +__version__ = '0.2.1' class RackConceptConfig(PluginConfig): diff --git a/netbox_rack_concept/static/netbox_rack_concept/export.js b/netbox_rack_concept/static/netbox_rack_concept/export.js index 77ef343..6cfb9a6 100644 --- a/netbox_rack_concept/static/netbox_rack_concept/export.js +++ b/netbox_rack_concept/static/netbox_rack_concept/export.js @@ -1,83 +1,219 @@ /* - * "Export as PNG" - fetches the same standalone SVG the SVG download link points to - * and rasterizes it client-side via . Deliberately no server-side image - * library involved: a NetBox plugin shouldn't need one just for this, and every - * browser can already turn an SVG into a PNG on its own. + * "Export as PNG" - draws the elevation straight onto a using the plain + * pixel geometry from the export/data/ endpoint, the same approach + * netbox-topology-views uses for its own PNG export (native canvas drawing rather + * than rasterizing an SVG image). A device type image only ever reaches the canvas + * via a same-origin fetch() -> blob() -> Image, never via , + * so there's no SVG document whose validity (and thus whether it even decodes at + * all) the export depends on, and one broken/cross-origin image only drops that one + * picture instead of failing the whole export. */ (function () { 'use strict'; const SCALE = 2; // render at 2x so the PNG stays crisp + const TITLE_HEIGHT = 26; + const FACE_GAP = 40; + const MARGIN = 10; document.querySelectorAll('.concept-export-png').forEach(function (link) { link.addEventListener('click', function (event) { event.preventDefault(); - exportPng(link.dataset.svgUrl, link.dataset.filename || 'export.png'); + exportPng(link.dataset.dataUrl, link.dataset.filename || 'export.png', link.dataset.faces || 'both'); }); }); - function exportPng(svgUrl, filename) { - fetch(svgUrl, { credentials: 'same-origin' }) + function exportPng(dataUrl, filename, faces) { + fetch(dataUrl, { credentials: 'same-origin' }) .then(function (response) { if (!response.ok) { - throw new Error('Could not load the SVG (' + response.status + ').'); + throw new Error('Could not load the elevation data (' + response.status + ').'); } - return response.text(); + return response.json(); }) - .then(function (svgText) { - rasterize(svgText, filename); + .then(function (data) { + const elevations = faces === 'both' ? [data.front, data.rear] : [ + faces === 'front' ? data.front : data.rear + ]; + return renderCanvas(elevations).then(function (canvas) { + downloadCanvas(canvas, filename); + }); }) - .catch(function () { - window.alert('The PNG could not be generated.'); + .catch(function (error) { + window.alert('The PNG could not be generated: ' + error.message); }); } - function rasterize(svgText, filename) { - const sizeMatch = svgText.match(/]*\swidth="([\d.]+)"[^>]*\sheight="([\d.]+)"/); - const width = sizeMatch ? parseFloat(sizeMatch[1]) : 800; - const height = sizeMatch ? parseFloat(sizeMatch[2]) : 600; + function collectImageUrls(elevations) { + const urls = new Set(); + elevations.forEach(function (elevation) { + elevation.blocks.forEach(function (block) { + if (block.image_url) urls.add(block.image_url); + }); + }); + return Array.from(urls); + } - const svgBlob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' }); - const svgUrl = URL.createObjectURL(svgBlob); - const img = new Image(); + function loadImage(url) { + return fetch(url, { credentials: 'same-origin' }) + .then(function (response) { + if (!response.ok) throw new Error('fetch failed'); + return response.blob(); + }) + .then(function (blob) { + return new Promise(function (resolve, reject) { + const objectUrl = URL.createObjectURL(blob); + const img = new Image(); + img.onload = function () { + URL.revokeObjectURL(objectUrl); + resolve(img); + }; + img.onerror = function () { + URL.revokeObjectURL(objectUrl); + reject(new Error('decode failed')); + }; + img.src = objectUrl; + }); + }); + } - img.onload = function () { - URL.revokeObjectURL(svgUrl); + function renderCanvas(elevations) { + const totalWidth = elevations.reduce(function (sum, e) { return sum + e.width; }, 0) + + FACE_GAP * (elevations.length - 1) + MARGIN * 2; + const totalHeight = TITLE_HEIGHT + Math.max.apply(null, elevations.map(function (e) { return e.height; })) + + MARGIN * 2; - const canvas = document.createElement('canvas'); - canvas.width = width * SCALE; - canvas.height = height * SCALE; - const ctx = canvas.getContext('2d'); - // The SVG itself has a white background, but that's inside a